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,71 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ace from 'ace-builds'
ace['define'](
'ace/mode/mclog_highlight_rules',
['require', 'exports', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
function (require: any, exports: any) {
const oop = require('ace/lib/oop')
const TextHighlightRules = require('ace/mode/text_highlight_rules').TextHighlightRules
const MclogHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.timestamp',
regex: /^\[\d\d:\d\d:\d\d\]/.source,
},
{
token: 'invalid.error',
regex: /\[.+?\/ERROR\]:?/.source,
},
{
token: 'keyword.warn',
regex: /\[.+?\/WARN\]:?/.source,
},
{
token: 'string.info',
regex: /\[.+?\/INFO\]:/.source,
},
{
token: 'support.command',
regex: /: \/.+/.source,
},
{
token: 'comment.stacktrace',
regex: /\tat\s.+/.source,
},
{
token: 'entity.name.function',
regex: /\w+?\[\/\d+?\.\d+?\.\d+?\.\d+?:\d+?\]/.source,
},
{
token: 'storage.chat',
regex: /\[CHAT\]/.source,
},
],
}
this.normalizeRules()
}
oop.inherits(MclogHighlightRules, TextHighlightRules)
exports.MclogHighlightRules = MclogHighlightRules
},
)
ace['define'](
'ace/mode/mclog',
['require', 'exports', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/mclog_highlight_rules'],
function (require: any, exports: any) {
const oop = require('ace/lib/oop')
const TextMode = require('ace/mode/text').Mode
const MclogHighlightRules = require('ace/mode/mclog_highlight_rules').MclogHighlightRules
const Mode = function (this: any) {
this.HighlightRules = MclogHighlightRules
}
oop.inherits(Mode, TextMode)
exports.Mode = Mode
},
)

View File

@ -0,0 +1,17 @@
import 'ace-builds/esm-resolver'
import cssText from '@modrinth/assets/styles/ace.css?raw'
import ace from 'ace-builds'
ace['define'](
'ace/theme/modrinth',
['require', 'exports', 'module', 'ace/lib/dom'],
function (require, exports, _module) {
exports.isDark = false
exports.cssClass = 'ace-modrinth'
exports.cssText = cssText
const dom = require('ace/lib/dom')
dom.importCssString(exports.cssText, exports.cssClass, false)
},
)

View File

@ -0,0 +1,177 @@
import {
ArchiveIcon,
BoxIcon,
BracesIcon,
CalendarIcon,
CardIcon,
CheckCircleIcon,
CircleAlertIcon,
CurrencyIcon,
DiscordIcon,
FileArchiveIcon,
FileCodeIcon,
FileIcon,
FileImageIcon,
FileTextIcon,
FolderOpenIcon,
GithubIcon,
GlassesIcon,
GlobeIcon,
InfoIcon,
IssuesIcon,
LinkIcon,
LockIcon,
PackageOpenIcon,
PaintbrushIcon,
PayPalIcon,
PlugIcon,
PolygonIcon,
ScaleIcon,
ServerIcon,
UnknownIcon,
UpdatedIcon,
USDCColorIcon,
XCircleIcon,
XIcon,
} from '@modrinth/assets'
import type { ProjectStatus, ProjectType } from '@modrinth/utils'
import type { Component } from 'vue'
import {
FILE_ARCHIVE_EXTENSIONS,
FILE_CODE_EXTENSIONS,
FILE_IMAGE_EXTENSIONS,
FILE_TEXT_EXTENSIONS,
} from './file-extensions'
export const PROJECT_TYPE_ICONS: Record<ProjectType, Component> = {
mod: BoxIcon,
modpack: PackageOpenIcon,
resourcepack: PaintbrushIcon,
shader: GlassesIcon,
plugin: PlugIcon,
datapack: BracesIcon,
project: BoxIcon,
minecraft_java_server: ServerIcon,
}
export const PAYMENT_METHOD_ICONS: Record<string, Component> = {
card: CardIcon,
cashapp: CurrencyIcon,
paypal: PayPalIcon,
}
export const SOCIAL_PLATFORM_ICONS: Record<string, Component> = {
discord: DiscordIcon,
github: GithubIcon,
}
export const SEVERITY_ICONS: Record<string, Component> = {
info: InfoIcon,
warning: IssuesIcon,
error: XCircleIcon,
critical: XCircleIcon,
success: CheckCircleIcon,
moderation: ScaleIcon,
'circle-warning': CircleAlertIcon,
}
export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {
approved: GlobeIcon,
unlisted: LinkIcon,
withheld: LinkIcon,
private: LockIcon,
scheduled: CalendarIcon,
draft: FileTextIcon,
archived: ArchiveIcon,
rejected: XIcon,
processing: UpdatedIcon,
unknown: UnknownIcon,
}
export const DIRECTORY_ICONS: Record<string, Component> = {
config: FolderOpenIcon,
world: FolderOpenIcon,
resourcepacks: PaintbrushIcon,
_default: FolderOpenIcon,
}
const CURRENCY_CONFIG: Record<string, { icon: Component; color: string }> = {
usdc: { icon: USDCColorIcon, color: 'text-blue' },
}
const BLOCKCHAIN_CONFIG: Record<string, { icon: Component; color: string }> = {
polygon: { icon: PolygonIcon, color: 'text-purple' },
}
export function getProjectTypeIcon(projectType: ProjectType): Component {
return PROJECT_TYPE_ICONS[projectType] ?? BoxIcon
}
export function getPaymentMethodIcon(method: string): Component {
return PAYMENT_METHOD_ICONS[method] ?? UnknownIcon
}
export function getSocialPlatformIcon(platform: string): Component {
return SOCIAL_PLATFORM_ICONS[platform.toLowerCase()] ?? UnknownIcon
}
export function getSeverityIcon(severity: string): Component {
return SEVERITY_ICONS[severity] ?? InfoIcon
}
export function getProjectStatusIcon(status: ProjectStatus): Component {
return PROJECT_STATUS_ICONS[status] ?? UnknownIcon
}
export function getDirectoryIcon(name: string): Component {
return DIRECTORY_ICONS[name.toLowerCase()] ?? DIRECTORY_ICONS._default
}
export function getFileExtensionIcon(extension: string): Component {
const ext: string = extension.toLowerCase()
if ((FILE_CODE_EXTENSIONS as readonly string[]).includes(ext)) {
return FileCodeIcon
}
if ((FILE_TEXT_EXTENSIONS as readonly string[]).includes(ext)) {
return FileTextIcon
}
if ((FILE_IMAGE_EXTENSIONS as readonly string[]).includes(ext)) {
return FileImageIcon
}
if ((FILE_ARCHIVE_EXTENSIONS as readonly string[]).includes(ext)) {
return FileArchiveIcon
}
return FileIcon
}
export function getFileIcon(fileName: string): Component {
const extension = fileName.split('.').pop()?.toLowerCase() || ''
return getFileExtensionIcon(extension)
}
export function getCurrencyIcon(currency: string): Component | null {
const lower = currency.toLowerCase()
const key = Object.keys(CURRENCY_CONFIG).find((k) => lower.includes(k))
return key ? CURRENCY_CONFIG[key].icon : null
}
export function getCurrencyColor(currency: string): string {
const lower = currency.toLowerCase()
const key = Object.keys(CURRENCY_CONFIG).find((k) => lower.includes(k))
return key ? CURRENCY_CONFIG[key].color : 'text-contrast'
}
export function getBlockchainIcon(blockchain: string): Component | null {
const lower = blockchain.toLowerCase()
const key = Object.keys(BLOCKCHAIN_CONFIG).find((k) => lower.includes(k))
return key ? BLOCKCHAIN_CONFIG[key].icon : null
}
export function getBlockchainColor(blockchain: string): string {
const lower = blockchain.toLowerCase()
const key = Object.keys(BLOCKCHAIN_CONFIG).find((k) => lower.includes(k))
return key ? BLOCKCHAIN_CONFIG[key].color : 'text-contrast'
}

View File

@ -0,0 +1,118 @@
import type Stripe from 'stripe'
import type { ServerLoader } from './loaders'
export type ServerBillingInterval = 'monthly' | 'yearly' | 'quarterly'
export const monthsInInterval: Record<ServerBillingInterval, number> = {
monthly: 1,
quarterly: 3,
yearly: 12,
}
export interface ServerPlan {
id: string
name: string
description: string
metadata: {
type: string
ram?: number
cpu?: number
storage?: number
swap?: number
}
prices: {
id: string
currency_code: string
prices: {
intervals: {
monthly: number
yearly: number
}
}
}[]
}
export interface ServerStockRequest {
cpu?: number
memory_mb?: number
swap_mb?: number
storage_mb?: number
}
export interface ServerRegion {
shortcode: string
country_code: string
display_name: string
lat: number
lon: number
}
/*
Request types
*/
export type PaymentMethodRequest = {
type: 'payment_method'
id: string
}
export type ConfirmationTokenRequest = {
type: 'confirmation_token'
token: string
}
export type PaymentRequestType = PaymentMethodRequest | ConfirmationTokenRequest
export type ChargeRequestType =
| {
type: 'existing'
id: string
}
| {
type: 'new'
product_id: string
interval?: ServerBillingInterval
}
export type CreatePaymentIntentRequest = PaymentRequestType & {
charge: ChargeRequestType
metadata?: {
type: 'pyro'
server_name?: string
server_region?: string
affiliate_code?: string
source:
| {
loader: ServerLoader
game_version?: string
loader_version?: string
}
| {
project_id: string
version_id?: string
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
| {}
}
}
export type UpdatePaymentIntentRequest = CreatePaymentIntentRequest & {
existing_payment_intent: string
}
/*
Response types
*/
export type BasePaymentIntentResponse = {
price_id: string
tax: number
total: number
payment_method: Stripe.PaymentMethod
}
export type UpdatePaymentIntentResponse = BasePaymentIntentResponse
export type CreatePaymentIntentResponse = BasePaymentIntentResponse & {
payment_intent_id: string
client_secret: string
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
/**
* Checks if any modifier key (Ctrl, Alt, Meta, or Shift) is held down during an event.
*/
export function isModifierKeyDown(
e: Pick<KeyboardEvent, 'ctrlKey' | 'altKey' | 'metaKey' | 'shiftKey'>,
): boolean {
return e.ctrlKey || e.altKey || e.metaKey || e.shiftKey
}

View File

@ -0,0 +1,162 @@
// File extension constants
export const FILE_CODE_EXTENSIONS = [
'json',
'json5',
'jsonc',
'java',
'kt',
'kts',
'sh',
'bat',
'ps1',
'yml',
'yaml',
'toml',
'js',
'ts',
'py',
'rb',
'php',
'html',
'css',
'cpp',
'c',
'h',
'rs',
'go',
] as const
export const FILE_TEXT_EXTENSIONS = [
'txt',
'md',
'log',
'cfg',
'conf',
'properties',
'ini',
'sk',
] as const
export const FILE_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'] as const
export const FILE_ARCHIVE_EXTENSIONS = ['zip', 'jar', 'tar', 'gz', 'rar', '7z'] as const
// Type for extension strings
export type CodeExtension = (typeof FILE_CODE_EXTENSIONS)[number]
export type TextExtension = (typeof FILE_TEXT_EXTENSIONS)[number]
export type ImageExtension = (typeof FILE_IMAGE_EXTENSIONS)[number]
export type ArchiveExtension = (typeof FILE_ARCHIVE_EXTENSIONS)[number]
/**
* Extract file extension from filename (lowercase)
*/
export function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() ?? ''
}
/**
* Check if extension is a code file
*/
export function isCodeFile(ext: string): boolean {
return (FILE_CODE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
}
/**
* Check if extension is a text file
*/
export function isTextFile(ext: string): boolean {
return (FILE_TEXT_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
}
/**
* Check if extension is an image file
*/
export function isImageFile(ext: string): boolean {
return (FILE_IMAGE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
}
/**
* Check if extension is an archive file
*/
export function isArchiveFile(ext: string): boolean {
return (FILE_ARCHIVE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
}
/**
* Check if file is editable (code or text)
*/
export function isEditableFile(ext: string): boolean {
return isCodeFile(ext) || isTextFile(ext)
}
/**
* Check if a file can be opened in the file editor
*/
export function canOpenInFileEditor(filename: string): boolean {
const ext = getFileExtension(filename)
return !filename.includes('.') || isEditableFile(ext) || isImageFile(ext)
}
/**
* Get Ace editor language mode for a file extension
*/
export function getEditorLanguage(ext: string): string {
const lowered = ext.toLowerCase()
switch (lowered) {
// Code files
case 'json':
case 'json5':
case 'jsonc':
return 'json'
case 'toml':
return 'toml'
case 'sh':
return 'sh'
case 'bat':
return 'batchfile'
case 'ps1':
return 'powershell'
case 'yml':
case 'yaml':
return 'yaml'
case 'js':
return 'javascript'
case 'ts':
return 'typescript'
case 'py':
return 'python'
case 'rb':
return 'ruby'
case 'php':
return 'php'
case 'html':
return 'html'
case 'css':
return 'css'
case 'java':
case 'kt':
case 'kts':
return 'java'
case 'cpp':
case 'c':
case 'h':
return 'c_cpp'
case 'rs':
return 'rust'
case 'go':
return 'golang'
// Text files
case 'md':
return 'markdown'
case 'properties':
return 'properties'
case 'ini':
case 'cfg':
case 'conf':
return 'ini'
case 'log':
return 'mclog'
default:
return 'text'
}
}

View File

@ -0,0 +1,166 @@
export type FileTreeEntry = {
relativePath: string
fileName: string
id?: string
}
export type FileTreeFolderRow = {
kind: 'folder'
path: string
name: string
depth: number
fileCount: number
expanded: boolean
childIds: string[]
}
export type FileTreeFileRow<TFile extends FileTreeEntry> = {
kind: 'file'
file: TFile
depth: number
parentPath: string
}
export type FileTreeRow<TFile extends FileTreeEntry> = FileTreeFolderRow | FileTreeFileRow<TFile>
type FileTreeNode<TFile extends FileTreeEntry> = {
path: string
name: string
directFiles: TFile[]
children: Map<string, FileTreeNode<TFile>>
parent?: FileTreeNode<TFile>
fileCount: number
fileIds: string[]
}
function createFileTreeNode<TFile extends FileTreeEntry>(
path: string,
name: string,
): FileTreeNode<TFile> {
return {
path,
name,
directFiles: [],
children: new Map(),
fileCount: 0,
fileIds: [],
}
}
function filePathSegments(relativePath: string): string[] {
return relativePath.split(/[\\/]/).filter(Boolean)
}
function fileParentPath(relativePath: string): string {
return filePathSegments(relativePath).slice(0, -1).join('/')
}
function buildFileTree<TFile extends FileTreeEntry>(files: readonly TFile[]): FileTreeNode<TFile> {
const root = createFileTreeNode<TFile>('', '')
for (const file of files) {
const segments = filePathSegments(file.relativePath)
let node = root
let path = ''
for (let index = 0; index < segments.length - 1; index += 1) {
path = path ? `${path}/${segments[index]}` : segments[index]
let child = node.children.get(path)
if (!child) {
child = createFileTreeNode<TFile>(path, segments[index])
child.parent = node
node.children.set(path, child)
}
node = child
}
node.directFiles.push(file)
for (
let countNode: FileTreeNode<TFile> | undefined = node;
countNode;
countNode = countNode.parent
) {
countNode.fileCount += 1
if (file.id) countNode.fileIds.push(file.id)
}
}
return root
}
function appendFolderRows<TFile extends FileTreeEntry>(
node: FileTreeNode<TFile>,
depth: number,
expandedFolders: ReadonlySet<string>,
rows: FileTreeRow<TFile>[],
locale: string,
) {
const children = [...node.children.values()].sort((left, right) =>
left.name.localeCompare(right.name, locale, { sensitivity: 'base' }),
)
const files = [...node.directFiles].sort((left, right) =>
left.fileName.localeCompare(right.fileName, locale, { sensitivity: 'base' }),
)
let folderIndex = 0
let fileIndex = 0
while (folderIndex < children.length || fileIndex < files.length) {
const folder = children[folderIndex]
const file = files[fileIndex]
if (
!folder ||
(file && folder.name.localeCompare(file.fileName, locale, { sensitivity: 'base' }) > 0)
) {
rows.push({
kind: 'file',
file,
depth,
parentPath: node.path,
})
fileIndex += 1
continue
}
rows.push({
kind: 'folder',
path: folder.path,
name: folder.name,
depth,
fileCount: folder.fileCount,
expanded: expandedFolders.has(folder.path),
childIds: folder.fileIds,
})
if (expandedFolders.has(folder.path)) {
appendFolderRows(folder, depth + 1, expandedFolders, rows, locale)
}
folderIndex += 1
}
}
export function collectFileTreeFolders(files: readonly FileTreeEntry[]): string[] {
const folders = new Set<string>()
for (const file of files) {
const segments = filePathSegments(file.relativePath)
for (let index = 1; index < segments.length; index += 1) {
folders.add(segments.slice(0, index).join('/'))
}
}
return [...folders].sort()
}
export function buildFileTreeRows<TFile extends FileTreeEntry>(
files: readonly TFile[],
expandedFolders: ReadonlySet<string>,
searchQuery: string,
locale = 'en',
): FileTreeRow<TFile>[] {
const query = searchQuery.trim().toLocaleLowerCase(locale)
if (query) {
return files
.filter((file) => file.relativePath.toLocaleLowerCase(locale).includes(query))
.map((file) => ({
kind: 'file',
file,
depth: 0,
parentPath: fileParentPath(file.relativePath),
}))
}
const rows: FileTreeRow<TFile>[] = []
appendFolderRows(buildFileTree(files), 0, expandedFolders, rows, locale)
return rows
}

View File

@ -0,0 +1,41 @@
import { BlocksIcon, CompassIcon, EyeIcon, PickaxeIcon, UnknownIcon } from '@modrinth/assets'
import { defineMessage } from '../composables/i18n'
export const GAME_MODES = {
survival: {
icon: PickaxeIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.survival',
defaultMessage: 'Survival mode',
}),
},
creative: {
icon: BlocksIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.creative',
defaultMessage: 'Creative mode',
}),
},
adventure: {
icon: CompassIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.adventure',
defaultMessage: 'Adventure mode',
}),
},
spectator: {
icon: EyeIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.spectator',
defaultMessage: 'Spectator mode',
}),
},
unknown: {
icon: UnknownIcon,
message: defineMessage({
id: 'instance.worlds.game_mode.unknown',
defaultMessage: 'Unknown game mode',
}),
},
}

View File

@ -0,0 +1,17 @@
export * from './auto-icons'
export * from './common-messages'
export * from './events'
export * from './file-extensions'
export * from './file-tree'
export * from './game-modes'
export * from './loaders'
export * from './log-share'
export * from './notices'
export * from './savable'
export * from './search'
export * from './server-content-installing'
export * from './server-search'
export * from './tag-messages'
export * from './truncate'
export * from './version-compatibility'
export * from './vue-children'

View File

@ -0,0 +1,95 @@
import type { Archon } from '@modrinth/api-client'
export type ServerLoader = Archon.Servers.v0.Loader | 'Bukkit'
export const clientInstallableLoaders = [
'fabric',
'neoforge',
'forge',
'quilt',
'optifine',
'cleanroom',
'lite_loader',
'legacy_fabric',
'babric',
] as const
export const instanceInstallablePlatforms = ['vanilla', ...clientInstallableLoaders] as const
export const loaderDisplayNames: Record<string, string> = {
fabric: 'Fabric',
neoforge: 'NeoForge',
neo_forge: 'NeoForge',
forge: 'Forge',
quilt: 'Quilt',
paper: 'Paper',
spigot: 'Spigot',
purpur: 'Purpur',
bukkit: 'Bukkit',
vanilla: 'Vanilla',
lite_loader: 'LiteLoader',
cleanroom: 'Cleanroom',
legacy_fabric: 'Legacy Fabric',
babric: 'Babric',
optifine: 'OptiFine',
}
export const loaderMessages: Record<string, { id: string; defaultMessage: string }> = {
vanilla: {
id: 'loader.vanilla',
defaultMessage: 'None',
},
}
export const formatLoaderLabel = (
item: string,
formatMessage?: (msg: { id: string; defaultMessage: string }) => string,
) => {
if (formatMessage && loaderMessages[item]) {
return formatMessage(loaderMessages[item])
}
return loaderDisplayNames[item] ?? item.charAt(0).toUpperCase() + item.slice(1)
}
function concreteLoaderVersion(loaderVersion: string | null): string | null {
return loaderVersion && loaderVersion !== 'latest' && loaderVersion !== 'stable'
? loaderVersion
: null
}
export function defaultInstanceName(
loader: string | null,
gameVersion: string,
loaderVersion: string | null = null,
): string {
const loaderLabel = loader ? formatLoaderLabel(loader) : 'Vanilla'
const exactLoaderVersion = concreteLoaderVersion(loaderVersion)
return `${gameVersion}-${loaderLabel}${exactLoaderVersion ? ` ${exactLoaderVersion}` : ''}`
}
export function buildUpgradeDisplayNames(input: {
sourceName: string
sourceLoader: string
sourceGameVersion: string
sourceLoaderVersion: string | null
targetLoader: string
targetGameVersion: string
targetLoaderVersion: string | null
backupName: string
customCopyName: string
}) {
const sourceUsesDefaultName =
input.sourceName ===
defaultInstanceName(input.sourceLoader, input.sourceGameVersion, input.sourceLoaderVersion)
const targetDefault = defaultInstanceName(
input.targetLoader,
input.targetGameVersion,
input.targetLoaderVersion,
)
return {
backup: input.backupName,
copy: sourceUsesDefaultName ? targetDefault : input.customCopyName,
upgradedTarget: sourceUsesDefaultName ? targetDefault : null,
shouldAutoRename: sourceUsesDefaultName,
}
}

View File

@ -0,0 +1,54 @@
import type { AbstractModrinthClient } from '@modrinth/api-client'
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
/**
* Maximum size in bytes of the log content sent to mclo.gs. Logs
* larger than this are trimmed to their last `LOG_SHARE_MAX_BYTES` before
* uploading, and the caller is notified through `LogShareResult.truncated`.
*/
export const LOG_SHARE_MAX_BYTES = 9 * 1024 * 1024
/**
* Return the tail of `content` that fits within `maxBytes` of UTF-8 without
* splitting a multi-byte code point. Returns the original string when it
* already fits.
*/
export function truncateLogToMaxBytes(content: string, maxBytes: number): string {
if (!content) return content
const bytes = textEncoder.encode(content)
if (bytes.byteLength <= maxBytes) return content
// Keep the last `maxBytes` bytes, starting at the first byte that begins a
// UTF-8 code point so decoding never produces replacement characters.
let start = bytes.byteLength - maxBytes
while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80) start++
return textDecoder.decode(bytes.subarray(start))
}
/**
* Result of sharing log content. `truncated` is set when the content exceeded
* `LOG_SHARE_MAX_BYTES` and only its tail was uploaded.
*/
export type LogShareResult = {
url: string
truncated: boolean
}
/**
* Share log content through mclo.gs. Content larger than `LOG_SHARE_MAX_BYTES`
* is trimmed to its last
* `LOG_SHARE_MAX_BYTES` before uploading.
*/
export async function shareLogs(
client: AbstractModrinthClient,
content: string,
): Promise<LogShareResult> {
const uploadContent = truncateLogToMaxBytes(content, LOG_SHARE_MAX_BYTES)
const truncated = uploadContent !== content
const data = await client.mclogs.logs_v1.create(uploadContent)
if (data.success && data.url) return { url: data.url, truncated }
throw new Error('mclo.gs upload failed')
}

View File

@ -0,0 +1,73 @@
import { defineMessage, type MessageDescriptor } from '../composables/i18n'
export const NOTICE_LEVELS: Record<
string,
{ name: MessageDescriptor; colors: { text: string; bg: string } }
> = {
info: {
name: defineMessage({
id: 'servers.notice.level.info.name',
defaultMessage: 'Info',
}),
colors: {
text: 'var(--color-blue)',
bg: 'var(--color-blue-bg)',
},
},
warn: {
name: defineMessage({
id: 'servers.notice.level.warn.name',
defaultMessage: 'Warning',
}),
colors: {
text: 'var(--color-orange)',
bg: 'var(--color-orange-bg)',
},
},
critical: {
name: defineMessage({
id: 'servers.notice.level.critical.name',
defaultMessage: 'Critical',
}),
colors: {
text: 'var(--color-red)',
bg: 'var(--color-red-bg)',
},
},
survey: {
name: defineMessage({
id: 'servers.notice.level.survey.name',
defaultMessage: 'Survey',
}),
colors: {
text: 'var(--color-purple)',
bg: 'var(--color-purple-bg)',
},
},
}
export const DISMISSABLE = {
name: defineMessage({
id: 'servers.notice.dismissable',
defaultMessage: 'Dismissable',
}),
colors: {
text: 'var(--color-green)',
bg: 'var(--color-green-bg)',
},
}
export const UNDISMISSABLE = {
name: defineMessage({
id: 'servers.notice.undismissable',
defaultMessage: 'Undismissable',
}),
colors: {
text: 'var(--color-red)',
bg: 'var(--color-red-bg)',
},
}
export function getDismissableMetadata(dismissable: boolean) {
return dismissable ? DISMISSABLE : UNDISMISSABLE
}

View File

@ -0,0 +1,54 @@
import type { Labrinth } from '@modrinth/api-client'
export function getProductDisplayName(product: Labrinth.Billing.Internal.Product): string {
const { metadata } = product
if (metadata.type === 'pyro') {
const ramGB = metadata.ram / 1024
return `${ramGB}GB Server`
}
if (metadata.type === 'medal') {
const ramGB = metadata.ram / 1024
return `${ramGB}GB Medal Server (${metadata.region})`
}
return 'Unknown Product'
}
export function getProductDescription(product: Labrinth.Billing.Internal.Product): string {
const { metadata } = product
if (metadata.type === 'pyro') {
return `${metadata.cpu} vCPU, ${metadata.ram}MB RAM, ${metadata.storage}MB Storage`
}
if (metadata.type === 'medal') {
return `${metadata.cpu} vCPU, ${metadata.ram}MB RAM, ${metadata.storage}MB Storage`
}
return ''
}
export function getPriceForInterval(
product: Labrinth.Billing.Internal.Product,
currency: string,
interval: Labrinth.Billing.Internal.PriceDuration,
): number | undefined {
const productPrice = product.prices.find((x) => x.currency_code === currency)
if (!productPrice) return undefined
const { prices } = productPrice
if (prices.type === 'recurring') {
return prices.intervals[interval]
}
return undefined
}
export const monthsInInterval: Record<'monthly' | 'quarterly' | 'yearly', number> = {
monthly: 1,
quarterly: 3,
yearly: 12,
}

View File

@ -0,0 +1,53 @@
import { defineMessage, type MessageDescriptor } from '../composables/i18n'
export const regionOverrides = {
'us-sjc': {
name: defineMessage({
id: 'servers.region.north-america-west',
defaultMessage: 'Western North America',
}),
flag: 'https://flagcdn.com/us.svg',
},
'us-dal': {
name: defineMessage({
id: 'servers.region.north-america-central',
defaultMessage: 'Central North America',
}),
flag: 'https://flagcdn.com/us.svg',
},
'us-vin': {
name: defineMessage({
id: 'servers.region.north-america-east',
defaultMessage: 'Eastern North America',
}),
flag: 'https://flagcdn.com/us.svg',
},
'eu-cov': {
name: defineMessage({
id: 'servers.region.western-europe',
defaultMessage: 'Western Europe',
}),
flag: 'https://flagcdn.com/gb.svg',
},
'eu-lim': {
name: defineMessage({
id: 'servers.region.central-europe',
defaultMessage: 'Central Europe',
}),
flag: 'https://flagcdn.com/de.svg',
},
'as-sin': {
name: defineMessage({
id: 'servers.region.southeast-asia',
defaultMessage: 'Southeast Asia',
}),
flag: 'https://flagcdn.com/sg.svg',
},
'au-syd': {
name: defineMessage({
id: 'servers.region.australia',
defaultMessage: 'Australia',
}),
flag: 'https://flagcdn.com/au.svg',
},
} satisfies Record<string, { name?: MessageDescriptor; flag?: string }>

View File

@ -0,0 +1,58 @@
import { isEqual } from 'es-toolkit'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
export function useSavable<T extends Record<string, unknown>>(
data: () => T,
save: (changes: Partial<T>) => void | Promise<void>,
): {
saved: ComputedRef<T>
current: Ref<T>
changes: ComputedRef<Partial<T>>
hasChanges: ComputedRef<boolean>
saving: Ref<boolean>
reset: () => void
save: () => Promise<void>
} {
const savedValues = computed(data)
const currentValues = ref({ ...data() }) as Ref<T>
const saving = ref(false)
const changes = computed<Partial<T>>(() => {
const values: Partial<T> = {}
const keys = Object.keys(currentValues.value) as (keyof T)[]
for (const key of keys) {
if (!isEqual(savedValues.value[key], currentValues.value[key])) {
values[key] = currentValues.value[key]
}
}
return values
})
const hasChanges = computed(() => Object.keys(changes.value).length > 0)
const reset = () => {
currentValues.value = data()
}
const saveInternal = async () => {
if (!hasChanges.value) return
saving.value = true
try {
await save(changes.value)
currentValues.value = data()
} finally {
saving.value = false
}
}
return {
saved: savedValues,
current: currentValues,
changes,
hasChanges,
saving,
reset,
save: saveInternal,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,203 @@
import type {
ContentCardProject,
ContentCardVersion,
ContentOwner,
} from '../layouts/shared/content-tab/types'
export type PendingServerContentInstallType = 'mod' | 'plugin' | 'datapack'
type PendingServerContentOwner = Omit<ContentOwner, 'link'> & { link?: string }
export interface PendingServerContentInstall {
projectId: string
versionId: string
contentType: PendingServerContentInstallType
title: ContentCardProject['title']
versionName?: ContentCardVersion['version_number'] | null
versionNumber?: ContentCardVersion['version_number'] | null
fileName?: ContentCardVersion['file_name'] | null
owner?: PendingServerContentOwner | null
slug?: ContentCardProject['slug'] | null
iconUrl?: ContentCardProject['icon_url'] | null
createdAt: number
}
interface PendingServerContentInstallBaseline {
contentKeys: string[]
projectIds?: string[]
createdAt: number
}
export const pendingServerContentInstallsEvent = 'modrinth:pending-server-content-installs'
const stalePendingInstallAge = 30 * 60 * 1000
function getPendingServerContentInstallsKey(serverId: string | null, worldId: string | null) {
if (!serverId || !worldId) return null
return `server-content-installing:${serverId}:${worldId}`
}
function getPendingServerContentInstallBaselineKey(
serverId: string | null,
worldId: string | null,
) {
if (!serverId || !worldId) return null
return `server-content-installing-baseline:${serverId}:${worldId}`
}
function isPendingServerContentInstall(value: unknown): value is PendingServerContentInstall {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return (
typeof record.projectId === 'string' &&
typeof record.versionId === 'string' &&
(record.contentType === 'mod' ||
record.contentType === 'plugin' ||
record.contentType === 'datapack') &&
typeof record.title === 'string' &&
typeof record.createdAt === 'number'
)
}
function isPendingServerContentInstallBaseline(
value: unknown,
): value is PendingServerContentInstallBaseline {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
const contentKeys = record.contentKeys ?? record.projectIds
return (
Array.isArray(contentKeys) &&
contentKeys.every((contentKey) => typeof contentKey === 'string') &&
typeof record.createdAt === 'number'
)
}
function filterFreshPendingServerContentInstalls(items: PendingServerContentInstall[]) {
const cutoff = Date.now() - stalePendingInstallAge
return items.filter((item) => item.createdAt >= cutoff)
}
function isFreshPendingServerContentInstallBaseline(item: PendingServerContentInstallBaseline) {
return item.createdAt >= Date.now() - stalePendingInstallAge
}
function emitPendingServerContentInstallsChanged(serverId: string | null, worldId: string | null) {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent(pendingServerContentInstallsEvent, {
detail: { serverId, worldId },
}),
)
}
export function readPendingServerContentInstalls(serverId: string | null, worldId: string | null) {
const key = getPendingServerContentInstallsKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return []
try {
const raw = localStorage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
const freshItems = filterFreshPendingServerContentInstalls(
parsed.filter(isPendingServerContentInstall),
)
if (freshItems.length !== parsed.length) {
writePendingServerContentInstalls(serverId, worldId, freshItems)
}
return freshItems
} catch {
return []
}
}
export function writePendingServerContentInstalls(
serverId: string | null,
worldId: string | null,
items: PendingServerContentInstall[],
) {
const key = getPendingServerContentInstallsKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return
const freshItems = filterFreshPendingServerContentInstalls(items)
if (freshItems.length === 0) {
localStorage.removeItem(key)
const baselineKey = getPendingServerContentInstallBaselineKey(serverId, worldId)
if (baselineKey) {
localStorage.removeItem(baselineKey)
}
} else {
localStorage.setItem(key, JSON.stringify(freshItems))
}
emitPendingServerContentInstallsChanged(serverId, worldId)
}
export function readPendingServerContentInstallBaseline(
serverId: string | null,
worldId: string | null,
) {
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return null
try {
const raw = localStorage.getItem(key)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!isPendingServerContentInstallBaseline(parsed)) return null
if (!isFreshPendingServerContentInstallBaseline(parsed)) {
localStorage.removeItem(key)
return null
}
return new Set(parsed.contentKeys ?? parsed.projectIds)
} catch {
return null
}
}
export function writePendingServerContentInstallBaseline(
serverId: string | null,
worldId: string | null,
contentKeys: Iterable<string>,
) {
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
if (!key || typeof localStorage === 'undefined') return
localStorage.setItem(
key,
JSON.stringify({
contentKeys: Array.from(new Set(contentKeys)),
createdAt: Date.now(),
} satisfies PendingServerContentInstallBaseline),
)
emitPendingServerContentInstallsChanged(serverId, worldId)
}
export function addPendingServerContentInstalls(
serverId: string | null,
worldId: string | null,
items: Omit<PendingServerContentInstall, 'createdAt'>[],
) {
if (items.length === 0) return
const now = Date.now()
const next = new Map(
readPendingServerContentInstalls(serverId, worldId).map((item) => [item.projectId, item]),
)
for (const item of items) {
next.set(item.projectId, { ...item, createdAt: now })
}
writePendingServerContentInstalls(serverId, worldId, Array.from(next.values()))
}
export function removePendingServerContentInstall(
serverId: string | null,
worldId: string | null,
projectId: string,
) {
writePendingServerContentInstalls(
serverId,
worldId,
readPendingServerContentInstalls(serverId, worldId).filter(
(item) => item.projectId !== projectId,
),
)
}

View File

@ -0,0 +1,503 @@
import type { Labrinth } from '@modrinth/api-client'
import { getCategoryIcon, GlobeIcon, SERVER_CATEGORY_ICON_MAP, UserIcon } from '@modrinth/assets'
import { sortedCategories } from '@modrinth/utils'
import { computed, type ComputedRef, type Ref, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router'
import { defineMessage, LOCALES, useVIntl } from '../composables/i18n'
import type { FilterType, FilterValue, SortType, Tags } from './search'
import { formatSearchFilterValue } from './search'
import { formatCategory, formatCategoryHeader } from './tag-messages'
export const SERVER_REGIONS = {
us_east: defineMessage({ id: 'project.server.region.us_east', defaultMessage: 'US East' }),
us_west: defineMessage({ id: 'project.server.region.us_west', defaultMessage: 'US West' }),
europe: defineMessage({ id: 'project.server.region.europe', defaultMessage: 'Europe' }),
asia: defineMessage({ id: 'project.server.region.asia', defaultMessage: 'Asia' }),
australia: defineMessage({ id: 'project.server.region.australia', defaultMessage: 'Australia' }),
south_america: defineMessage({
id: 'project.server.region.south_america',
defaultMessage: 'South America',
}),
middle_east: defineMessage({
id: 'project.server.region.middle_east',
defaultMessage: 'Middle East',
}),
russia: defineMessage({ id: 'project.server.region.russia', defaultMessage: 'Russia' }),
}
export const SERVER_LANGUAGES = {
en: defineMessage({ id: 'project.server.language.en', defaultMessage: 'English' }),
es: defineMessage({ id: 'project.server.language.es', defaultMessage: 'Spanish' }),
pt: defineMessage({ id: 'project.server.language.pt', defaultMessage: 'Portuguese' }),
fr: defineMessage({ id: 'project.server.language.fr', defaultMessage: 'French' }),
de: defineMessage({ id: 'project.server.language.de', defaultMessage: 'German' }),
it: defineMessage({ id: 'project.server.language.it', defaultMessage: 'Italian' }),
nl: defineMessage({ id: 'project.server.language.nl', defaultMessage: 'Dutch' }),
ru: defineMessage({ id: 'project.server.language.ru', defaultMessage: 'Russian' }),
uk: defineMessage({ id: 'project.server.language.uk', defaultMessage: 'Ukrainian' }),
pl: defineMessage({ id: 'project.server.language.pl', defaultMessage: 'Polish' }),
cs: defineMessage({ id: 'project.server.language.cs', defaultMessage: 'Czech' }),
sk: defineMessage({ id: 'project.server.language.sk', defaultMessage: 'Slovak' }),
hu: defineMessage({ id: 'project.server.language.hu', defaultMessage: 'Hungarian' }),
ro: defineMessage({ id: 'project.server.language.ro', defaultMessage: 'Romanian' }),
bg: defineMessage({ id: 'project.server.language.bg', defaultMessage: 'Bulgarian' }),
hr: defineMessage({ id: 'project.server.language.hr', defaultMessage: 'Croatian' }),
sr: defineMessage({ id: 'project.server.language.sr', defaultMessage: 'Serbian' }),
el: defineMessage({ id: 'project.server.language.el', defaultMessage: 'Greek' }),
tr: defineMessage({ id: 'project.server.language.tr', defaultMessage: 'Turkish' }),
ar: defineMessage({ id: 'project.server.language.ar', defaultMessage: 'Arabic' }),
he: defineMessage({ id: 'project.server.language.he', defaultMessage: 'Hebrew' }),
hi: defineMessage({ id: 'project.server.language.hi', defaultMessage: 'Hindi' }),
bn: defineMessage({ id: 'project.server.language.bn', defaultMessage: 'Bengali' }),
ur: defineMessage({ id: 'project.server.language.ur', defaultMessage: 'Urdu' }),
zh: defineMessage({ id: 'project.server.language.zh', defaultMessage: 'Chinese' }),
ja: defineMessage({ id: 'project.server.language.ja', defaultMessage: 'Japanese' }),
ko: defineMessage({ id: 'project.server.language.ko', defaultMessage: 'Korean' }),
th: defineMessage({ id: 'project.server.language.th', defaultMessage: 'Thai' }),
vi: defineMessage({ id: 'project.server.language.vi', defaultMessage: 'Vietnamese' }),
id: defineMessage({ id: 'project.server.language.id', defaultMessage: 'Indonesian' }),
ms: defineMessage({ id: 'project.server.language.ms', defaultMessage: 'Malay' }),
tl: defineMessage({ id: 'project.server.language.tl', defaultMessage: 'Filipino' }),
sv: defineMessage({ id: 'project.server.language.sv', defaultMessage: 'Swedish' }),
no: defineMessage({ id: 'project.server.language.no', defaultMessage: 'Norwegian' }),
da: defineMessage({ id: 'project.server.language.da', defaultMessage: 'Danish' }),
fi: defineMessage({ id: 'project.server.language.fi', defaultMessage: 'Finnish' }),
lt: defineMessage({ id: 'project.server.language.lt', defaultMessage: 'Lithuanian' }),
lv: defineMessage({ id: 'project.server.language.lv', defaultMessage: 'Latvian' }),
et: defineMessage({ id: 'project.server.language.et', defaultMessage: 'Estonian' }),
af: defineMessage({ id: 'project.server.language.af', defaultMessage: 'Afrikaans' }),
am: defineMessage({ id: 'project.server.language.am', defaultMessage: 'Amharic' }),
az: defineMessage({ id: 'project.server.language.az', defaultMessage: 'Azerbaijani' }),
be: defineMessage({ id: 'project.server.language.be', defaultMessage: 'Belarusian' }),
bs: defineMessage({ id: 'project.server.language.bs', defaultMessage: 'Bosnian' }),
ca: defineMessage({ id: 'project.server.language.ca', defaultMessage: 'Catalan' }),
eo: defineMessage({ id: 'project.server.language.eo', defaultMessage: 'Esperanto' }),
eu: defineMessage({ id: 'project.server.language.eu', defaultMessage: 'Basque' }),
fa: defineMessage({ id: 'project.server.language.fa', defaultMessage: 'Persian' }),
ga: defineMessage({ id: 'project.server.language.ga', defaultMessage: 'Irish' }),
gl: defineMessage({ id: 'project.server.language.gl', defaultMessage: 'Galician' }),
hy: defineMessage({ id: 'project.server.language.hy', defaultMessage: 'Armenian' }),
is: defineMessage({ id: 'project.server.language.is', defaultMessage: 'Icelandic' }),
ka: defineMessage({ id: 'project.server.language.ka', defaultMessage: 'Georgian' }),
kk: defineMessage({ id: 'project.server.language.kk', defaultMessage: 'Kazakh' }),
km: defineMessage({ id: 'project.server.language.km', defaultMessage: 'Khmer' }),
kn: defineMessage({ id: 'project.server.language.kn', defaultMessage: 'Kannada' }),
lo: defineMessage({ id: 'project.server.language.lo', defaultMessage: 'Lao' }),
mk: defineMessage({ id: 'project.server.language.mk', defaultMessage: 'Macedonian' }),
ml: defineMessage({ id: 'project.server.language.ml', defaultMessage: 'Malayalam' }),
mn: defineMessage({ id: 'project.server.language.mn', defaultMessage: 'Mongolian' }),
mr: defineMessage({ id: 'project.server.language.mr', defaultMessage: 'Marathi' }),
my: defineMessage({ id: 'project.server.language.my', defaultMessage: 'Burmese' }),
ne: defineMessage({ id: 'project.server.language.ne', defaultMessage: 'Nepali' }),
pa: defineMessage({ id: 'project.server.language.pa', defaultMessage: 'Punjabi' }),
si: defineMessage({ id: 'project.server.language.si', defaultMessage: 'Sinhala' }),
sl: defineMessage({ id: 'project.server.language.sl', defaultMessage: 'Slovenian' }),
sq: defineMessage({ id: 'project.server.language.sq', defaultMessage: 'Albanian' }),
sw: defineMessage({ id: 'project.server.language.sw', defaultMessage: 'Swahili' }),
ta: defineMessage({ id: 'project.server.language.ta', defaultMessage: 'Tamil' }),
te: defineMessage({ id: 'project.server.language.te', defaultMessage: 'Telugu' }),
uz: defineMessage({ id: 'project.server.language.uz', defaultMessage: 'Uzbek' }),
yo: defineMessage({ id: 'project.server.language.yo', defaultMessage: 'Yoruba' }),
zu: defineMessage({ id: 'project.server.language.zu', defaultMessage: 'Zulu' }),
}
export const SERVER_SORT_TYPES: SortType[] = [
{ display: 'Relevance', name: 'relevance' },
{ display: 'Verified Plays', name: 'minecraft_java_server.verified_plays_2w' },
{ display: 'Players', name: 'minecraft_java_server.ping.data.players_online' },
{ display: 'Followers', name: 'follows' },
{ display: 'Date Published', name: 'date_created' },
{ display: 'Date Updated', name: 'date_modified' },
]
const FILTER_FIELD_MAP: Record<string, string> = {
server_content_type: 'minecraft_java_server.content.kind',
server_game_version: 'game_versions',
server_status: 'minecraft_java_server.ping.data',
server_region: 'minecraft_server.region',
server_language: 'minecraft_server.languages',
}
function getFilterField(filterId: string): string | undefined {
if (filterId.startsWith('server_category_')) return 'categories'
return FILTER_FIELD_MAP[filterId]
}
export function useServerSearch(opts: {
tags: Ref<Tags>
query: Ref<string>
maxResults: Ref<number>
currentPage: Ref<number>
providedFilters?: ComputedRef<FilterValue[]>
}) {
const { tags, query, maxResults, currentPage } = opts
const { formatMessage, locale } = useVIntl()
const formatCategoryName = (categoryName: string) => {
return formatCategory(formatMessage, categoryName)
}
const route = useRoute()
const serverCurrentSortType = shallowRef<SortType>(SERVER_SORT_TYPES[0])
const serverCurrentFilters = ref<FilterValue[]>([{ type: 'server_status', option: 'online' }])
const serverToggledGroups = ref<string[]>([])
const serverFilterTypes = computed<FilterType[]>(() => {
const categoryFilters: Record<string, FilterType> = {}
for (const c of sortedCategories(tags.value, formatCategoryName, locale.value).filter(
(c: Labrinth.Tags.v2.Category) => c.project_type === 'minecraft_java_server',
)) {
const filterTypeId = `server_category_${c.header}`
if (!categoryFilters[filterTypeId]) {
categoryFilters[filterTypeId] = {
id: filterTypeId,
formatted_name: formatCategoryHeader(formatMessage, c.header),
supported_project_types: ['server'],
display: 'all',
query_param: 'sc',
supports_negative_filter: true,
searchable: false,
options: [],
}
}
categoryFilters[filterTypeId].options.push({
id: c.name,
formatted_name: formatCategory(formatMessage, c.name),
icon: getCategoryIcon(SERVER_CATEGORY_ICON_MAP[c.name] ?? c.name),
method: 'or' as const,
value: c.name,
})
}
const sortedRegions = Object.entries(SERVER_REGIONS).sort(([_, a], [__, b]) => {
const aFormatted = formatMessage(a)
const bFormatted = formatMessage(b)
return aFormatted.localeCompare(bFormatted, locale.value)
})
const localeDefinition = LOCALES.find((l) => l.code === locale.value)
const userLanguageCode =
localeDefinition?.serverLanguageCode ?? locale.value.substring(0, locale.value.indexOf('-'))
const sortedLanguages = Object.entries(SERVER_LANGUAGES).sort(([aCode, a], [bCode, b]) => {
if (aCode === 'en') return -1
if (bCode === 'en') return 1
if (aCode === userLanguageCode) return -1
if (bCode === userLanguageCode) return 1
const aFormatted = formatMessage(a)
const bFormatted = formatMessage(b)
return aFormatted.localeCompare(bFormatted, locale.value)
})
return [
{
id: 'server_content_type',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.server_content_type',
defaultMessage: 'Type',
}),
),
supported_project_types: ['server'],
display: 'all',
query_param: 'sct',
supports_negative_filter: false,
searchable: false,
options: [
{
id: 'vanilla',
formatted_name: formatMessage(
defineMessage({
id: 'search.server_content_type.vanilla',
defaultMessage: 'Vanilla',
}),
),
method: 'or',
value: 'vanilla',
},
{
id: 'modpack',
formatted_name: formatMessage(
defineMessage({
id: 'search.server_content_type.modpack',
defaultMessage: 'Modded',
}),
),
method: 'or',
value: 'modpack',
},
],
},
...[
'minecraft_server_features',
'minecraft_server_gameplay',
'minecraft_server_meta',
'minecraft_server_community',
]
.map((h) => categoryFilters[`server_category_${h}`])
.filter(Boolean),
{
id: 'server_game_version',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.game_version',
defaultMessage: 'Game version',
}),
),
supported_project_types: ['server'],
display: 'scrollable',
query_param: 'sgv',
supports_negative_filter: false,
searchable: true,
options: (tags.value?.gameVersions ?? []).map((gv) => ({
id: gv.version,
toggle_group: gv.version_type !== 'release' ? 'all_versions' : undefined,
method: 'or' as const,
value: gv.version,
query_value: gv.version,
})),
},
{
id: 'server_region',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.server_region',
defaultMessage: 'Region',
}),
),
supported_project_types: ['server'],
display: 'all',
query_param: 'sr',
supports_negative_filter: true,
searchable: false,
options: sortedRegions.map(([code, name]) => ({
id: code,
formatted_name: formatMessage(name),
method: 'or' as const,
value: code,
})),
},
{
id: 'server_language',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.server_language',
defaultMessage: 'Language',
}),
),
supported_project_types: ['server'],
display: 'scrollable',
query_param: 'sl',
supports_negative_filter: false,
searchable: true,
options: sortedLanguages.map(([code, name]) => ({
id: code,
formatted_name: formatMessage(name),
icon: code === 'en' ? GlobeIcon : code === userLanguageCode ? UserIcon : undefined,
method: 'or' as const,
value: code,
})),
},
{
id: 'server_status',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.server_status',
defaultMessage: 'Status',
}),
),
supported_project_types: ['server'],
display: 'all',
query_param: 'sst',
supports_negative_filter: false,
searchable: false,
options: [
{
id: 'online',
formatted_name: formatMessage(
defineMessage({
id: 'project.server.status.online',
defaultMessage: 'Online',
}),
),
method: 'or',
value: 'online',
},
{
id: 'offline',
formatted_name: formatMessage(
defineMessage({
id: 'project.server.status.offline',
defaultMessage: 'Offline',
}),
),
method: 'or',
value: 'offline',
},
],
},
]
})
const newFilters = computed(() => {
const parts = ['project_types = minecraft_java_server']
for (const filterType of serverFilterTypes.value) {
const field = getFilterField(filterType.id)
if (!field) continue
const matched = serverCurrentFilters.value.filter((f) => f.type === filterType.id)
if (matched.length === 0) continue
if (filterType.id === 'server_status') {
const selected = matched[0]?.option
if (selected === 'online') {
parts.push(`${field} EXISTS`)
} else if (selected === 'offline') {
parts.push(`${field} NOT EXISTS`)
}
continue
}
const included = matched.filter((f) => !f.negative)
const excluded = matched.filter((f) => f.negative)
if (included.length > 0) {
const values = included.map((f) => formatSearchFilterValue(f.option)).join(', ')
parts.push(`${field} IN [${values}]`)
}
if (excluded.length > 0) {
const values = excluded.map((f) => formatSearchFilterValue(f.option)).join(', ')
parts.push(`${field} NOT IN [${values}]`)
}
}
const providedProjectIds = (opts.providedFilters?.value ?? [])
.filter((filter) => filter.type === 'project_id')
.map((filter) => ({
projectId: filter.option.startsWith('project_id:')
? filter.option.slice('project_id:'.length)
: filter.option,
negative: !!filter.negative,
}))
.filter((filter) => filter.projectId.length > 0)
const excludedProjectIds = providedProjectIds
.filter((filter) => filter.negative)
.map((filter) => filter.projectId)
const includedProjectIds = providedProjectIds
.filter((filter) => !filter.negative)
.map((filter) => filter.projectId)
if (includedProjectIds.length > 0) {
const values = includedProjectIds.map(formatSearchFilterValue).join(', ')
parts.push(`project_id IN [${values}]`)
}
if (excludedProjectIds.length > 0) {
const values = excludedProjectIds.map(formatSearchFilterValue).join(', ')
parts.push(`project_id NOT IN [${values}]`)
}
return parts.join(' AND ')
})
const serverRequestParams = computed(() => {
const params = [`limit=${maxResults.value}`, `index=${serverCurrentSortType.value.name}`]
if (query.value) params.push(`query=${encodeURIComponent(query.value)}`)
const offset = (currentPage.value - 1) * maxResults.value
if (offset > 0) params.push(`offset=${offset}`)
params.push(`new_filters=${encodeURIComponent(newFilters.value)}`)
return `?${params.join('&')}`
})
function readServerQueryParams() {
const q = route.query
if (q.q) {
query.value = String(q.q)
}
if (q.ss) {
serverCurrentSortType.value =
SERVER_SORT_TYPES.find((s) => s.name === String(q.ss)) ?? SERVER_SORT_TYPES[0]
}
if (q.m) {
maxResults.value = Number(q.m)
}
if (q.page) {
currentPage.value = Number(q.page)
}
for (const filterType of serverFilterTypes.value) {
const paramValue = q[filterType.query_param]
if (!paramValue) continue
const values =
typeof paramValue === 'string'
? [paramValue]
: paramValue.filter((v): v is string => v !== null)
for (const value of values) {
const isNegative = value.startsWith('!')
const cleanValue = isNegative ? value.slice(1) : value
const option = filterType.options.find((o) => o.id === cleanValue)
if (option) {
serverCurrentFilters.value.push({
type: filterType.id,
option: option.id,
negative: isNegative,
})
}
}
}
}
function createServerPageParams(): Record<string, string | string[]> {
const items: Record<string, string[]> = {}
if (query.value) {
items.q = [query.value]
}
for (const filterValue of serverCurrentFilters.value) {
const type = serverFilterTypes.value.find((t) => t.id === filterValue.type)
if (type) {
const value = filterValue.negative ? `!${filterValue.option}` : filterValue.option
if (items[type.query_param]) {
items[type.query_param].push(value)
} else {
items[type.query_param] = [value]
}
}
}
if (serverCurrentSortType.value.name !== 'relevance') {
items.ss = [serverCurrentSortType.value.name]
}
if (maxResults.value !== 20) {
items.m = [String(maxResults.value)]
}
if (currentPage.value > 1) {
items.page = [String(currentPage.value)]
}
return items
}
readServerQueryParams()
return {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes: SERVER_SORT_TYPES,
serverFilterTypes,
newFilters,
serverRequestParams,
createServerPageParams,
}
}

View File

@ -0,0 +1,683 @@
import { capitalizeString } from '@modrinth/utils'
import { defineMessages, type MessageDescriptor, type VIntlFormatters } from '../composables/i18n'
export const loaderMessages = defineMessages({
babric: {
id: 'tag.loader.babric',
defaultMessage: 'Babric',
},
'bta-babric': {
id: 'tag.loader.bta-babric',
defaultMessage: 'BTA (Babric)',
},
bukkit: {
id: 'tag.loader.bukkit',
defaultMessage: 'Bukkit',
},
bungeecord: {
id: 'tag.loader.bungeecord',
defaultMessage: 'BungeeCord',
},
canvas: {
id: 'tag.loader.canvas',
defaultMessage: 'Canvas',
},
datapack: {
id: 'tag.loader.datapack',
defaultMessage: 'Data Pack',
},
fabric: {
id: 'tag.loader.fabric',
defaultMessage: 'Fabric',
},
folia: {
id: 'tag.loader.folia',
defaultMessage: 'Folia',
},
forge: {
id: 'tag.loader.forge',
defaultMessage: 'Forge',
},
geyser: {
id: 'tag.loader.geyser',
defaultMessage: 'Geyser Extension',
},
iris: {
id: 'tag.loader.iris',
defaultMessage: 'Iris',
},
'java-agent': {
id: 'tag.loader.java-agent',
defaultMessage: 'Java Agent',
},
'legacy-fabric': {
id: 'tag.loader.legacy-fabric',
defaultMessage: 'Legacy Fabric',
},
liteloader: {
id: 'tag.loader.liteloader',
defaultMessage: 'LiteLoader',
},
minecraft: {
id: 'tag.loader.minecraft',
defaultMessage: 'Resource Pack',
},
modloader: {
id: 'tag.loader.modloader',
defaultMessage: "Risugami's ModLoader",
},
mrpack: {
id: 'tag.loader.mrpack',
defaultMessage: 'Modpack',
},
neoforge: {
id: 'tag.loader.neoforge',
defaultMessage: 'NeoForge',
},
nilloader: {
id: 'tag.loader.nilloader',
defaultMessage: 'NilLoader',
},
optifine: {
id: 'tag.loader.optifine',
defaultMessage: 'OptiFine',
},
ornithe: {
id: 'tag.loader.ornithe',
defaultMessage: 'Ornithe',
},
paper: {
id: 'tag.loader.paper',
defaultMessage: 'Paper',
},
purpur: {
id: 'tag.loader.purpur',
defaultMessage: 'Purpur',
},
quilt: {
id: 'tag.loader.quilt',
defaultMessage: 'Quilt',
},
rift: {
id: 'tag.loader.rift',
defaultMessage: 'Rift',
},
spigot: {
id: 'tag.loader.spigot',
defaultMessage: 'Spigot',
},
sponge: {
id: 'tag.loader.sponge',
defaultMessage: 'Sponge',
},
vanilla: {
id: 'tag.loader.vanilla',
defaultMessage: 'Vanilla Shader',
},
velocity: {
id: 'tag.loader.velocity',
defaultMessage: 'Velocity',
},
waterfall: {
id: 'tag.loader.waterfall',
defaultMessage: 'Waterfall',
},
})
export const categoryMessages = defineMessages({
'128x': {
id: 'tag.category.128x',
defaultMessage: '128x',
},
'16x': {
id: 'tag.category.16x',
defaultMessage: '16x',
},
'256x': {
id: 'tag.category.256x',
defaultMessage: '256x',
},
'32x': {
id: 'tag.category.32x',
defaultMessage: '32x',
},
'48x': {
id: 'tag.category.48x',
defaultMessage: '48x',
},
'512x+': {
id: 'tag.category.512x+',
defaultMessage: '512x or higher',
},
'64x': {
id: 'tag.category.64x',
defaultMessage: '64x',
},
'8x-': {
id: 'tag.category.8x-',
defaultMessage: '8x or lower',
},
adventure: {
id: 'tag.category.adventure',
defaultMessage: 'Adventure',
},
'adventure-mode': {
id: 'tag.category.adventure-mode',
defaultMessage: 'Adventure Mode',
},
anarchy: {
id: 'tag.category.anarchy',
defaultMessage: 'Anarchy',
},
atmosphere: {
id: 'tag.category.atmosphere',
defaultMessage: 'Atmosphere',
},
audio: {
id: 'tag.category.audio',
defaultMessage: 'Audio',
},
'battle-royale': {
id: 'tag.category.battle-royale',
defaultMessage: 'Battle Royale',
},
bedwars: {
id: 'tag.category.bedwars',
defaultMessage: 'Bed Wars',
},
blocks: {
id: 'tag.category.blocks',
defaultMessage: 'Blocks',
},
bloom: {
id: 'tag.category.bloom',
defaultMessage: 'Bloom',
},
bosses: {
id: 'tag.category.bosses',
defaultMessage: 'Bosses',
},
cartoon: {
id: 'tag.category.cartoon',
defaultMessage: 'Cartoon',
},
challenging: {
id: 'tag.category.challenging',
defaultMessage: 'Challenging',
},
classes: {
id: 'tag.category.classes',
defaultMessage: 'Classes',
},
'colored-lighting': {
id: 'tag.category.colored-lighting',
defaultMessage: 'Colored Lighting',
},
combat: {
id: 'tag.category.combat',
defaultMessage: 'Combat',
},
competitive: {
id: 'tag.category.competitive',
defaultMessage: 'Competitive',
},
'core-shaders': {
id: 'tag.category.core-shaders',
defaultMessage: 'Core Shaders',
},
'creative-mode': {
id: 'tag.category.creative-mode',
defaultMessage: 'Creative Mode',
},
'creator-community': {
id: 'tag.category.creator-community',
defaultMessage: 'Creator Community',
},
crossplay: {
id: 'tag.category.crossplay',
defaultMessage: 'Crossplay',
},
cursed: {
id: 'tag.category.cursed',
defaultMessage: 'Cursed',
},
'custom-content': {
id: 'tag.category.custom-content',
defaultMessage: 'Custom Content',
},
decoration: {
id: 'tag.category.decoration',
defaultMessage: 'Decoration',
},
dungeons: {
id: 'tag.category.dungeons',
defaultMessage: 'Dungeons',
},
economy: {
id: 'tag.category.economy',
defaultMessage: 'Economy',
},
entities: {
id: 'tag.category.entities',
defaultMessage: 'Entities',
},
environment: {
id: 'tag.category.environment',
defaultMessage: 'Environment',
},
equipment: {
id: 'tag.category.equipment',
defaultMessage: 'Equipment',
},
factions: {
id: 'tag.category.factions',
defaultMessage: 'Factions',
},
fantasy: {
id: 'tag.category.fantasy',
defaultMessage: 'Fantasy',
},
foliage: {
id: 'tag.category.foliage',
defaultMessage: 'Foliage',
},
fonts: {
id: 'tag.category.fonts',
defaultMessage: 'Fonts',
},
food: {
id: 'tag.category.food',
defaultMessage: 'Food',
},
'game-mechanics': {
id: 'tag.category.game-mechanics',
defaultMessage: 'Game Mechanics',
},
gens: {
id: 'tag.category.gens',
defaultMessage: 'Gens',
},
gui: {
id: 'tag.category.gui',
defaultMessage: 'GUI',
},
'hardcore-mode': {
id: 'tag.category.hardcore-mode',
defaultMessage: 'Hardcore Mode',
},
high: {
id: 'tag.category.high',
defaultMessage: 'High',
},
items: {
id: 'tag.category.items',
defaultMessage: 'Items',
},
'keep-inventory': {
id: 'tag.category.keep-inventory',
defaultMessage: 'Keep Inventory',
},
'kitchen-sink': {
id: 'tag.category.kitchen-sink',
defaultMessage: 'Kitchen Sink',
},
kitpvp: {
id: 'tag.category.kitpvp',
defaultMessage: 'Kit PvP',
},
library: {
id: 'tag.category.library',
defaultMessage: 'Library',
},
lifesteal: {
id: 'tag.category.lifesteal',
defaultMessage: 'Lifesteal',
},
lightweight: {
id: 'tag.category.lightweight',
defaultMessage: 'Lightweight',
},
locale: {
id: 'tag.category.locale',
defaultMessage: 'Locale',
},
low: {
id: 'tag.category.low',
defaultMessage: 'Low',
},
magic: {
id: 'tag.category.magic',
defaultMessage: 'Magic',
},
management: {
id: 'tag.category.management',
defaultMessage: 'Management',
},
media: {
id: 'tag.category.media',
defaultMessage: 'Media',
},
medium: {
id: 'tag.category.medium',
defaultMessage: 'Medium',
},
microgames: {
id: 'tag.category.microgames',
defaultMessage: 'Microgames',
},
minigame: {
id: 'tag.category.minigame',
defaultMessage: 'Minigame',
},
minigames: {
id: 'tag.category.minigames',
defaultMessage: 'Minigames',
},
mmo: {
id: 'tag.category.mmo',
defaultMessage: 'MMO',
},
mobs: {
id: 'tag.category.mobs',
defaultMessage: 'Mobs',
},
modded: {
id: 'tag.category.modded',
defaultMessage: 'Modded',
},
models: {
id: 'tag.category.models',
defaultMessage: 'Models',
},
multiplayer: {
id: 'tag.category.multiplayer',
defaultMessage: 'Multiplayer',
},
network: {
id: 'tag.category.network',
defaultMessage: 'Network',
},
'offline-mode': {
id: 'tag.category.offline-mode',
defaultMessage: 'Offline Mode',
},
oneblock: {
id: 'tag.category.oneblock',
defaultMessage: 'One Block',
},
op: {
id: 'tag.category.op',
defaultMessage: 'OP',
},
optimization: {
id: 'tag.category.optimization',
defaultMessage: 'Optimization',
},
parkour: {
id: 'tag.category.parkour',
defaultMessage: 'Parkour',
},
'path-tracing': {
id: 'tag.category.path-tracing',
defaultMessage: 'Path Tracing',
},
pbr: {
id: 'tag.category.pbr',
defaultMessage: 'PBR',
},
'personal-worlds': {
id: 'tag.category.personal-worlds',
defaultMessage: 'Personal Worlds',
},
plots: {
id: 'tag.category.plots',
defaultMessage: 'Plots',
},
pokemon: {
id: 'tag.category.pokemon',
defaultMessage: 'Pokémon',
},
potato: {
id: 'tag.category.potato',
defaultMessage: 'Potato',
},
prison: {
id: 'tag.category.prison',
defaultMessage: 'Prison',
},
pve: {
id: 'tag.category.pve',
defaultMessage: 'PvE',
},
pvp: {
id: 'tag.category.pvp',
defaultMessage: 'PvP',
},
questing: {
id: 'tag.category.questing',
defaultMessage: 'Questing',
},
quests: {
id: 'tag.category.quests',
defaultMessage: 'Quests',
},
racing: {
id: 'tag.category.racing',
defaultMessage: 'Racing',
},
realistic: {
id: 'tag.category.realistic',
defaultMessage: 'Realistic',
},
'recording-smp': {
id: 'tag.category.recording-smp',
defaultMessage: 'Recording SMP',
},
reflections: {
id: 'tag.category.reflections',
defaultMessage: 'Reflections',
},
roleplay: {
id: 'tag.category.roleplay',
defaultMessage: 'Roleplay',
},
rpg: {
id: 'tag.category.rpg',
defaultMessage: 'RPG',
},
screenshot: {
id: 'tag.category.screenshot',
defaultMessage: 'Screenshot',
},
'semi-realistic': {
id: 'tag.category.semi-realistic',
defaultMessage: 'Semi Realistic',
},
shadows: {
id: 'tag.category.shadows',
defaultMessage: 'Shadows',
},
simplistic: {
id: 'tag.category.simplistic',
defaultMessage: 'Simplistic',
},
skyblock: {
id: 'tag.category.skyblock',
defaultMessage: 'Skyblock',
},
smp: {
id: 'tag.category.smp',
defaultMessage: 'SMP',
},
social: {
id: 'tag.category.social',
defaultMessage: 'Social',
},
storage: {
id: 'tag.category.storage',
defaultMessage: 'Storage',
},
'survival-mode': {
id: 'tag.category.survival-mode',
defaultMessage: 'Survival Mode',
},
teams: {
id: 'tag.category.teams',
defaultMessage: 'Teams',
},
technical: {
id: 'tag.category.technical',
defaultMessage: 'Technical',
},
technology: {
id: 'tag.category.technology',
defaultMessage: 'Technology',
},
themed: {
id: 'tag.category.themed',
defaultMessage: 'Themed',
},
towns: {
id: 'tag.category.towns',
defaultMessage: 'Towns',
},
transportation: {
id: 'tag.category.transportation',
defaultMessage: 'Transportation',
},
tweaks: {
id: 'tag.category.tweaks',
defaultMessage: 'Tweaks',
},
utility: {
id: 'tag.category.utility',
defaultMessage: 'Utility',
},
'vanilla-like': {
id: 'tag.category.vanilla-like',
defaultMessage: 'Vanilla Like',
},
whitelisted: {
id: 'tag.category.whitelisted',
defaultMessage: 'Whitelisted',
},
'world-resets': {
id: 'tag.category.world-resets',
defaultMessage: 'World Resets',
},
worldgen: {
id: 'tag.category.worldgen',
defaultMessage: 'World Generation',
},
})
export const DEFAULT_MOD_LOADERS: string[] = ['fabric', 'forge', 'neoforge']
export const DEFAULT_PLUGIN_LOADERS: string[] = ['paper', 'spigot']
export const DEFAULT_SHADER_LOADERS: string[] = ['iris', 'optifine', 'vanilla']
const DEFAULT_LOADER_NAMES = new Set([...DEFAULT_MOD_LOADERS, ...DEFAULT_SHADER_LOADERS])
// sort by:
// 1. categories, alphabetically
// 2. default loaders, alphabetically
// 3. other loaders, alphabetically
export function sortTagsForDisplay(tags: string[]): string[] {
const isLoader = (tag: string) => getTagMessage(tag, 'loader') !== undefined
const loaders = tags.filter(isLoader)
const categories = tags.filter((tag) => !isLoader(tag))
categories.sort((a, b) => a.localeCompare(b))
loaders.sort((a, b) => {
const aDefault = DEFAULT_LOADER_NAMES.has(a)
const bDefault = DEFAULT_LOADER_NAMES.has(b)
if (aDefault !== bDefault) return aDefault ? -1 : 1
return a.localeCompare(b)
})
return [...categories, ...loaders]
}
export const categoryHeaderMessages = defineMessages({
resolutions: {
id: 'header.category.resolutions',
defaultMessage: 'Resolution',
},
categories: {
id: 'header.category.category',
defaultMessage: 'Category',
},
features: {
id: 'header.category.feature',
defaultMessage: 'Feature',
},
'performance impact': {
id: 'header.category.performance-impact',
defaultMessage: 'Performance impact',
},
minecraft_server_community: {
id: 'header.category.minecraft-server-community',
defaultMessage: 'Community',
},
minecraft_server_features: {
id: 'header.category.minecraft-server-features',
defaultMessage: 'Features',
},
minecraft_server_gameplay: {
id: 'header.category.minecraft-server-gameplay',
defaultMessage: 'Gameplay',
},
minecraft_server_meta: {
id: 'header.category.minecraft-server-meta',
defaultMessage: 'Meta',
},
'cf-extra': {
id: 'header.category.cf-extra',
defaultMessage: 'More CurseForge categories',
},
})
export function getTagMessage(
tag: string,
enforceType?: 'loader' | 'category',
): MessageDescriptor | undefined {
if (enforceType === 'loader') {
return loaderMessages[tag]
} else if (enforceType === 'category') {
return categoryMessages[tag]
} else {
return loaderMessages[tag] ?? categoryMessages[tag]
}
}
export function getLoaderMessage(loader: string) {
return getTagMessage(loader, 'loader')
}
export function getCategoryMessage(category: string) {
return getTagMessage(category, 'category')
}
export function getCategoryHeaderMessage(header: string): MessageDescriptor | undefined {
return categoryHeaderMessages[header]
}
export function formatTag(
formatter: VIntlFormatters['formatMessage'],
tag: string,
enforceType?: 'loader' | 'category',
) {
const message = getTagMessage(tag, enforceType)
return message ? formatter(message) : capitalizeString(tag)
}
export function formatCategory(formatter: VIntlFormatters['formatMessage'], category: string) {
return formatTag(formatter, category, 'category')
}
export function formatLoader(formatter: VIntlFormatters['formatMessage'], category: string) {
return formatTag(formatter, category, 'loader')
}
export function formatCategoryHeader(formatter: VIntlFormatters['formatMessage'], header: string) {
const message = getCategoryHeaderMessage(header)
return message ? formatter(message) : capitalizeString(header)
}

View File

@ -0,0 +1,30 @@
import type { Ref } from 'vue'
import { unref } from 'vue'
/**
* Checks if an element's content is truncated (showing ellipsis).
* Returns the tooltip text if truncated, undefined otherwise.
*
* @param element - HTMLElement, Ref<HTMLElement>, or null
* @param tooltipText - Text to show in tooltip when truncated
* @returns The tooltip text if element is truncated, undefined otherwise
*
* @example
* ```vue
* <span ref="titleRef" class="truncate" v-tooltip="truncatedTooltip(titleRef, project.title)">
* {{ project.title }}
* </span>
* ```
*/
export function truncatedTooltip(
element: HTMLElement | Ref<HTMLElement | null> | null | undefined,
tooltipText: string,
): string | undefined {
const el = unref(element)
if (!el) return undefined
if (!tooltipText) return undefined
return el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight
? tooltipText
: undefined
}

View File

@ -0,0 +1,74 @@
const NON_MOD_PROJECT_TYPES = new Set(['shader', 'shaderpack', 'resourcepack', 'datapack'])
const LOADER_ALIAS_GROUPS = [
['paper', 'purpur', 'spigot', 'bukkit'],
['neoforge', 'neo'],
]
type VersionCompatibilityData = {
game_versions: string[]
loaders: string[]
}
export function normalizeLoaderAlias(loader: string) {
return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '')
}
export function getCompatibleLoaderAliases(loader: string) {
const normalizedLoader = normalizeLoaderAlias(loader)
const aliases = new Set([normalizedLoader])
const aliasGroup = LOADER_ALIAS_GROUPS.find((group) => group.includes(normalizedLoader))
if (aliasGroup) {
for (const alias of aliasGroup) {
aliases.add(alias)
}
}
return aliases
}
export function versionChangesGameVersion(
version: VersionCompatibilityData,
currentGameVersion: string,
) {
return !!currentGameVersion && !version.game_versions.includes(currentGameVersion)
}
export function versionMatchesCompatibilityTarget(
version: VersionCompatibilityData,
target: {
gameVersion: string
loader: string
projectType?: string
},
) {
if (!target.gameVersion || !version.game_versions.includes(target.gameVersion)) {
return false
}
const normalizedVersionLoaders = version.loaders.map(normalizeLoaderAlias)
if (target.projectType === 'datapack') {
return normalizedVersionLoaders.includes('datapack')
}
if (target.projectType && NON_MOD_PROJECT_TYPES.has(target.projectType)) {
return true
}
if (
target.projectType === 'modpack' &&
(normalizedVersionLoaders.length === 0 ||
normalizedVersionLoaders.every((loader) => loader === 'mrpack'))
) {
return true
}
if (!target.loader) {
return false
}
const loaderAliases = getCompatibleLoaderAliases(target.loader)
return normalizedVersionLoaders.some((loader) => loaderAliases.has(loader))
}

View File

@ -0,0 +1,25 @@
import { createTextVNode, isVNode, toDisplayString, type VNode } from 'vue'
/**
* Checks whether a specific child is a VNode. If not, converts it to a display
* string and then creates text VNode for the result.
*
* @param child Child to normalize.
* @returns Either the original VNode or a text VNode containing child converted
* to a display string.
*/
function normalizeChild(child: unknown): VNode {
return isVNode(child) ? child : createTextVNode(toDisplayString(child))
}
/**
* Takes in an array of VNodes and other children. It then converts each child
* that is not already a VNode to a display string, and creates a text VNode for
* that string.
*
* @param children Children to normalize.
* @returns Children with all of non-VNodes converted to display strings.
*/
export function normalizeChildren(children: unknown | unknown[]): VNode[] {
return Array.isArray(children) ? children.map(normalizeChild) : [normalizeChild(children)]
}

View File

@ -0,0 +1,434 @@
import * as THREE from 'three'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { createSolidSkinLayerGeometry } from './solid-skin-layer'
export interface SkinRendererConfig {
textureColorSpace?: THREE.ColorSpace
textureFlipY?: boolean
textureMagFilter?: THREE.MagnificationTextureFilter
textureMinFilter?: THREE.MinificationTextureFilter
}
const ENABLE_VOXEL_LAYER_GEOMETRY = true
const MODEL_PIXEL_SIZE = 1 / 16
const NON_LEG_VERTICAL_OFFSET = -MODEL_PIXEL_SIZE / 2
/** Aligns the torso, arms, head, and cape with the stationary legs. */
function offsetNonLegModelParts(model: THREE.Object3D): void {
if (model.userData.nonLegPartsOffsetApplied) return
const nonLegRoots = new Set(['Head', 'Right_Arm', 'Left_Arm', 'Body_2', 'Body_Layer', 'Cape'])
model.traverse((node) => {
if (nonLegRoots.has(node.name)) node.position.y += NON_LEG_VERTICAL_OFFSET
})
model.userData.nonLegPartsOffsetApplied = true
}
/**
* Rebuilds the outer layer as solid, textured voxels. This follows 3D Skin
* Layers' SolidPixelWrapper: every non-transparent outer-layer pixel becomes
* a real cube with six faces instead of a zero-thickness quad.
*/
function getSkinLayerDefinition(name: string): {
width: number
height: number
depth: number
u: number
v: number
} | null {
if (name === 'Hat_Layer') return { width: 8, height: 8, depth: 8, u: 32, v: 0 }
if (name === 'Body_Layer') return { width: 8, height: 12, depth: 4, u: 16, v: 32 }
if (name === 'Right_Leg_Layer') return { width: 4, height: 12, depth: 4, u: 0, v: 32 }
if (name === 'Left_Leg_Layer') return { width: 4, height: 12, depth: 4, u: 0, v: 48 }
if (name === 'Right_Arm_Layer') return { width: 4, height: 12, depth: 4, u: 40, v: 32 }
if (name === 'Left_Arm_Layer') return { width: 4, height: 12, depth: 4, u: 48, v: 48 }
return null
}
function readSkinPixels(texture: THREE.Texture): Uint8ClampedArray | null {
const image = texture.image as CanvasImageSource | undefined
if (!image) return null
try {
const canvas = document.createElement('canvas')
canvas.width = canvas.height = 64
const context = canvas.getContext('2d')
if (!context) return null
context.drawImage(image, 0, 0, 64, 64)
return context.getImageData(0, 0, 64, 64).data
} catch {
// Cross-origin images may not be readable. The regular GLTF layer remains
// as a graceful fallback in that case.
return null
}
}
function scaleLayerGeometry(geometry: THREE.BufferGeometry, name: string): void {
geometry.computeBoundingBox()
const bounds = geometry.boundingBox
if (!bounds) return
const isHead = name === 'Hat_Layer'
const isBody = name === 'Body_Layer'
// The authored GLTF outer shells already include most of the mod's offset.
// Apply only the remaining per-pixel correction; multiplying by the full
// config values would make the head pixels noticeably oversized.
const scaleX = isHead ? 1.05 : isBody ? 1 : 1.02
const scaleY = isHead ? 1.05 : 1
const scaleZ = isHead ? 1.05 : 1.02
const center = bounds.getCenter(new THREE.Vector3())
const position = geometry.getAttribute('position')
const vertex = new THREE.Vector3()
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i)
vertex.sub(center)
vertex.set(vertex.x * scaleX, vertex.y * scaleY, vertex.z * scaleZ)
vertex.add(center)
position.setXYZ(i, vertex.x, vertex.y, vertex.z)
}
position.needsUpdate = true
geometry.computeBoundingBox()
geometry.computeBoundingSphere()
}
export function applyThreeDSkinLayers(model: THREE.Object3D, texture?: THREE.Texture): void {
offsetNonLegModelParts(model)
const pixels = texture ? readSkinPixels(texture) : null
if (!pixels || !texture) return
model.traverse((child) => {
const mesh = child as THREE.Mesh
if (
!mesh.isMesh ||
!mesh.name.endsWith('_Layer') ||
!mesh.geometry ||
mesh.userData.threeDSkinLayersApplied
)
return
// GLTF clones share BufferGeometry objects. Clone before changing vertex data
// so one preview (or cached model) cannot affect another.
mesh.geometry = mesh.geometry.clone()
const definition = getSkinLayerDefinition(mesh.name)
if (ENABLE_VOXEL_LAYER_GEOMETRY && pixels && definition) {
const meshBounds = new THREE.Box3().setFromBufferAttribute(
mesh.geometry.getAttribute('position') as THREE.BufferAttribute,
)
const isSlimArm =
mesh.name.includes('Arm') && meshBounds.getSize(new THREE.Vector3()).x < 0.25
const voxelDefinition = isSlimArm ? { ...definition, width: 3 } : definition
const voxelGeometry = createSolidSkinLayerGeometry(mesh, texture!, pixels, voxelDefinition)
if (voxelGeometry) {
scaleLayerGeometry(voxelGeometry, mesh.name)
const voxelMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
voxelMaterials.forEach((material) => {
if (!(material instanceof THREE.MeshStandardMaterial)) return
// The mod renders these as alpha-tested cutouts. Keeping depth writes
// deterministic avoids transparent-surface sorting cracks between voxels.
material.transparent = false
material.alphaTest = 0.1
material.depthWrite = true
material.alphaToCoverage = true
material.polygonOffset = false
material.polygonOffsetFactor = 0
material.polygonOffsetUnits = 0
material.needsUpdate = true
})
mesh.geometry.dispose()
mesh.geometry = voxelGeometry
mesh.userData.threeDSkinLayersApplied = true
return
}
}
const geometry = mesh.geometry
geometry.computeBoundingBox()
const bounds = geometry.boundingBox
if (!bounds) return
scaleLayerGeometry(geometry, mesh.name)
mesh.userData.threeDSkinLayersApplied = true
})
}
const modelCache: Map<string, GLTF> = new Map()
const modelPromiseCache: Map<string, Promise<GLTF>> = new Map()
const textureCache: Map<string, THREE.Texture> = new Map()
const texturePromiseCache: Map<string, Promise<THREE.Texture>> = new Map()
export async function loadModel(modelUrl: string): Promise<GLTF> {
if (modelCache.has(modelUrl)) {
return modelCache.get(modelUrl)!
}
if (modelPromiseCache.has(modelUrl)) {
return modelPromiseCache.get(modelUrl)!
}
const loader = new GLTFLoader()
const promise = new Promise<GLTF>((resolve, reject) => {
loader.load(
modelUrl,
(gltf) => {
modelCache.set(modelUrl, gltf)
resolve(gltf)
},
undefined,
reject,
)
}).finally(() => {
modelPromiseCache.delete(modelUrl)
})
modelPromiseCache.set(modelUrl, promise)
return promise
}
export async function loadTexture(
textureUrl: string,
config: SkinRendererConfig = {},
): Promise<THREE.Texture> {
const cacheKey = `${textureUrl}_${JSON.stringify(config)}`
if (textureCache.has(cacheKey)) {
return textureCache.get(cacheKey)!
}
if (texturePromiseCache.has(cacheKey)) {
return texturePromiseCache.get(cacheKey)!
}
const textureLoader = new THREE.TextureLoader()
const promise = new Promise<THREE.Texture>((resolve, reject) => {
textureLoader.load(
textureUrl,
(texture) => {
texture.colorSpace = config.textureColorSpace ?? THREE.SRGBColorSpace
texture.flipY = config.textureFlipY ?? false
texture.magFilter = config.textureMagFilter ?? THREE.NearestFilter
texture.minFilter = config.textureMinFilter ?? THREE.NearestFilter
textureCache.set(cacheKey, texture)
resolve(texture)
},
undefined,
reject,
)
}).finally(() => {
texturePromiseCache.delete(cacheKey)
})
texturePromiseCache.set(cacheKey, promise)
return promise
}
function applyMap(mat: THREE.MeshStandardMaterial, texture: THREE.Texture | null): boolean {
const hadMap = mat.map !== null
const hasMap = texture !== null
if (mat.map !== texture) {
mat.map = texture
}
return hadMap !== hasMap
}
function setShaderMaterialProperties(
mat: THREE.MeshStandardMaterial,
properties: {
alphaTest: number
flatShading: boolean
side: THREE.Side
toneMapped: boolean
transparent?: boolean
},
): boolean {
let needsUpdate = false
if (mat.alphaTest !== properties.alphaTest) {
mat.alphaTest = properties.alphaTest
needsUpdate = true
}
if (mat.flatShading !== properties.flatShading) {
mat.flatShading = properties.flatShading
needsUpdate = true
}
if (mat.side !== properties.side) {
mat.side = properties.side
needsUpdate = true
}
if (mat.toneMapped !== properties.toneMapped) {
mat.toneMapped = properties.toneMapped
needsUpdate = true
}
if (properties.transparent !== undefined && mat.transparent !== properties.transparent) {
mat.transparent = properties.transparent
needsUpdate = true
}
return needsUpdate
}
function setCommonMaterialProperties(mat: THREE.MeshStandardMaterial): void {
if (mat.metalness !== 0) {
mat.metalness = 0
}
if (mat.color.getHex() !== 0xffffff) {
mat.color.set(0xffffff)
}
if (mat.roughness !== 1) {
mat.roughness = 1
}
if (!mat.depthTest) {
mat.depthTest = true
}
if (!mat.depthWrite) {
mat.depthWrite = true
}
}
export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const isSkinLayer = mesh.name.endsWith('_Layer')
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
if (mat.name !== 'cape') {
const mapNeedsUpdate = applyMap(mat, texture)
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
alphaTest: 0.1,
flatShading: true,
side: THREE.FrontSide,
toneMapped: false,
transparent: isSkinLayer,
})
if (mat.alphaToCoverage !== isSkinLayer) {
mat.alphaToCoverage = isSkinLayer
mat.needsUpdate = true
}
setCommonMaterialProperties(mat)
if (mapNeedsUpdate || propertiesNeedUpdate) {
mat.needsUpdate = true
}
}
}
})
}
})
}
export function applyCapeTexture(
model: THREE.Object3D,
texture: THREE.Texture | null,
transparentTexture?: THREE.Texture,
): void {
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
if (mat.name === 'cape') {
const nextMap = texture || transparentTexture || null
const mapNeedsUpdate = applyMap(mat, nextMap)
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
alphaTest: 0.1,
flatShading: true,
side: THREE.DoubleSide,
toneMapped: false,
transparent: !texture || !!transparentTexture,
})
setCommonMaterialProperties(mat)
if (mapNeedsUpdate || propertiesNeedUpdate) {
mat.needsUpdate = true
}
mat.visible = !!texture
}
}
})
}
})
}
export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null {
let bodyNode: THREE.Object3D | null = null
model.traverse((node) => {
if (node.name === 'Body') {
bodyNode = node
}
})
return bodyNode
}
export function createTransparentTexture(): THREE.Texture {
const canvas = document.createElement('canvas')
canvas.width = canvas.height = 1
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
ctx.clearRect(0, 0, 1, 1)
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
texture.colorSpace = THREE.SRGBColorSpace
texture.flipY = false
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
return texture
}
export async function setupSkinModel(
modelUrl: string,
textureUrl: string,
capeTextureUrl?: string,
config: SkinRendererConfig = {},
): Promise<{
model: THREE.Object3D
bodyNode: THREE.Object3D | null
}> {
const [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)])
const model = gltf.scene.clone()
applyTexture(model, texture)
applyThreeDSkinLayers(model, texture)
if (capeTextureUrl) {
const capeTexture = await loadTexture(capeTextureUrl, config)
applyCapeTexture(model, capeTexture)
}
const bodyNode = findBodyNode(model)
return { model, bodyNode }
}
export function disposeCaches(): void {
Array.from(textureCache.values()).forEach((texture) => {
texture.dispose()
})
textureCache.clear()
texturePromiseCache.clear()
modelCache.clear()
modelPromiseCache.clear()
}

View File

@ -0,0 +1,275 @@
import * as THREE from 'three'
export interface SolidSkinLayerDefinition {
width: number
height: number
depth: number
u: number
v: number
}
type Face = 'down' | 'up' | 'north' | 'south' | 'west' | 'east'
const FACES: Face[] = ['down', 'up', 'north', 'south', 'west', 'east']
function opaque(pixels: Uint8ClampedArray, u: number, v: number): boolean {
return u >= 0 && v >= 0 && u < 64 && v < 64 && pixels[(v * 64 + u) * 4 + 3] > 0
}
function addQuad(
positions: number[],
normals: number[],
uvs: number[],
corners: Array<[number, number, number]>,
normal: [number, number, number],
uvCorners: Array<[number, number]>,
): void {
for (const index of [0, 1, 2, 0, 2, 3]) {
positions.push(...corners[index])
normals.push(...normal)
uvs.push(...uvCorners[index])
}
}
function addCube(
positions: number[],
normals: number[],
uvs: number[],
min: THREE.Vector3,
max: THREE.Vector3,
anchors: Partial<Record<Face, [number, number]>>,
visible: Record<Face, boolean>,
): void {
const fallbackAnchor =
anchors.north ?? anchors.south ?? anchors.west ?? anchors.east ?? anchors.down ?? anchors.up
if (!fallbackAnchor) return
const faceUv = (face: Face, du: number, dv: number): [number, number] => {
const [u, v] = anchors[face] ?? fallbackAnchor
// GLTF's authored UVs inset every pixel edge by 1/4096. Without this
// inset, exact boundaries can round into an adjacent arm pixel with
// nearest filtering, which appears as a one-pixel UV shift.
const inset = 1 / 4096
return [
(u + du + (du === 0 ? inset : -inset)) / 64,
(v + dv + (dv === 0 ? inset : -inset)) / 64,
]
}
if (visible.north)
addQuad(
positions,
normals,
uvs,
[
[max.x, min.y, min.z],
[min.x, min.y, min.z],
[min.x, max.y, min.z],
[max.x, max.y, min.z],
],
[0, 0, -1],
[faceUv('north', 1, 1), faceUv('north', 0, 1), faceUv('north', 0, 0), faceUv('north', 1, 0)],
)
if (visible.south)
addQuad(
positions,
normals,
uvs,
[
[min.x, min.y, max.z],
[max.x, min.y, max.z],
[max.x, max.y, max.z],
[min.x, max.y, max.z],
],
[0, 0, 1],
[faceUv('south', 0, 1), faceUv('south', 1, 1), faceUv('south', 1, 0), faceUv('south', 0, 0)],
)
if (visible.down)
addQuad(
positions,
normals,
uvs,
[
[min.x, min.y, min.z],
[max.x, min.y, min.z],
[max.x, min.y, max.z],
[min.x, min.y, max.z],
],
[0, -1, 0],
[faceUv('down', 1, 1), faceUv('down', 0, 1), faceUv('down', 0, 0), faceUv('down', 1, 0)],
)
if (visible.up)
addQuad(
positions,
normals,
uvs,
[
[min.x, max.y, max.z],
[max.x, max.y, max.z],
[max.x, max.y, min.z],
[min.x, max.y, min.z],
],
[0, 1, 0],
[faceUv('up', 1, 0), faceUv('up', 0, 0), faceUv('up', 0, 1), faceUv('up', 1, 1)],
)
if (visible.west)
addQuad(
positions,
normals,
uvs,
[
[min.x, min.y, min.z],
[min.x, min.y, max.z],
[min.x, max.y, max.z],
[min.x, max.y, min.z],
],
[-1, 0, 0],
[faceUv('west', 0, 1), faceUv('west', 1, 1), faceUv('west', 1, 0), faceUv('west', 0, 0)],
)
if (visible.east)
addQuad(
positions,
normals,
uvs,
[
[max.x, min.y, max.z],
[max.x, min.y, min.z],
[max.x, max.y, min.z],
[max.x, max.y, max.z],
],
[1, 0, 0],
[faceUv('east', 0, 1), faceUv('east', 1, 1), faceUv('east', 1, 0), faceUv('east', 0, 0)],
)
}
function facePixel(face: Face, u: number, v: number, d: SolidSkinLayerDefinition) {
if (face === 'down')
return { x: u, y: d.height - 1, z: d.depth - 1 - v, tu: d.u + d.depth + u, tv: d.v + v }
if (face === 'up')
return {
x: u,
y: 0,
z: d.depth - 1 - v,
tu: d.u + d.depth + d.width + u,
tv: d.v + v,
}
if (face === 'north')
return {
x: d.width - 1 - u,
y: d.height - 1 - v,
z: 0,
tu: d.u + d.depth + u,
tv: d.v + d.depth + v,
}
if (face === 'south')
return {
x: u,
y: d.height - 1 - v,
z: d.depth - 1,
tu: d.u + d.depth + d.width + d.depth + u,
tv: d.v + d.depth + v,
}
if (face === 'west')
return {
x: d.width - 1,
y: d.height - 1 - v,
z: d.depth - 1 - u,
tu: d.u + u,
tv: d.v + d.depth + v,
}
return {
x: 0,
y: d.height - 1 - v,
z: u,
tu: d.u + d.depth + d.width + u,
tv: d.v + d.depth + v,
}
}
export function createSolidSkinLayerGeometry(
mesh: THREE.Mesh,
_texture: THREE.Texture,
pixels: Uint8ClampedArray,
d: SolidSkinLayerDefinition,
): THREE.BufferGeometry | null {
const position = mesh.geometry.getAttribute('position') as THREE.BufferAttribute | undefined
if (!position) return null
const bounds = new THREE.Box3().setFromBufferAttribute(position)
const size = bounds.getSize(new THREE.Vector3())
const voxel = new THREE.Vector3(size.x / d.width, size.y / d.height, size.z / d.depth)
const voxels = new Map<
string,
{ x: number; y: number; z: number; anchors: Partial<Record<Face, [number, number]>> }
>()
for (const face of FACES) {
const faceWidth = face === 'west' || face === 'east' ? d.depth : d.width
const faceHeight = face === 'down' || face === 'up' ? d.depth : d.height
for (let u = 0; u < faceWidth; u++) {
for (let v = 0; v < faceHeight; v++) {
const p = facePixel(face, u, v, d)
if (!opaque(pixels, p.tu, p.tv)) continue
const key = `${p.x},${p.y},${p.z}`
const entry = voxels.get(key) ?? { x: p.x, y: p.y, z: p.z, anchors: {} }
// Corners can be hit by more than one source face. Preserve each
// source anchor so the corresponding visible cube face samples the
// correct pixel instead of inheriting a one-pixel-shifted neighbour.
// Java's ModelPart uses Y-down and labels WEST as the player's
// right side; GLTF uses Y-up and the opposite X-side labels.
const geometryFace =
face === 'down'
? 'up'
: face === 'up'
? 'down'
: face === 'west'
? 'east'
: face === 'east'
? 'west'
: face
entry.anchors[geometryFace] = [p.tu, p.tv]
voxels.set(key, entry)
}
}
}
if (!voxels.size) return null
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const hasVoxel = (x: number, y: number, z: number) => voxels.has(`${x},${y},${z}`)
// Keep neighbouring cubes microscopically overlapped. GLTF arm bounds use
// fractional coordinates; a fixed 1e-4 gap is still visible after projection.
// A small fraction of one voxel closes the seam without changing the layer
// silhouette. The value is scaled per axis below, so slim arms get enough
// coverage while the head remains visually unchanged.
const epsilon = 0.01
for (const voxelPosition of voxels.values()) {
const min = new THREE.Vector3(
bounds.min.x + voxelPosition.x * voxel.x - voxel.x * epsilon,
bounds.min.y + voxelPosition.y * voxel.y - voxel.y * epsilon,
bounds.min.z + voxelPosition.z * voxel.z - voxel.z * epsilon,
)
const max = min
.clone()
.add(voxel)
.add(new THREE.Vector3(voxel.x * epsilon * 2, voxel.y * epsilon * 2, voxel.z * epsilon * 2))
const { x, y, z } = voxelPosition
addCube(positions, normals, uvs, min, max, voxelPosition.anchors, {
down: !hasVoxel(x, y - 1, z),
up: !hasVoxel(x, y + 1, z),
north: !hasVoxel(x, y, z - 1),
south: !hasVoxel(x, y, z + 1),
west: !hasVoxel(x - 1, y, z),
east: !hasVoxel(x + 1, y, z),
})
}
if (!positions.length) return null
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.computeBoundingBox()
geometry.computeBoundingSphere()
return geometry
}