feat:移除了弹窗,服务器添加sls
This commit is contained in:
71
packages/ui/src/utils/ace-mode-log.ts
Normal file
71
packages/ui/src/utils/ace-mode-log.ts
Normal 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
|
||||
},
|
||||
)
|
||||
17
packages/ui/src/utils/ace-theme.ts
Normal file
17
packages/ui/src/utils/ace-theme.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
177
packages/ui/src/utils/auto-icons.ts
Normal file
177
packages/ui/src/utils/auto-icons.ts
Normal 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'
|
||||
}
|
||||
118
packages/ui/src/utils/billing.ts
Normal file
118
packages/ui/src/utils/billing.ts
Normal 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
|
||||
}
|
||||
1378
packages/ui/src/utils/common-messages.ts
Normal file
1378
packages/ui/src/utils/common-messages.ts
Normal file
File diff suppressed because it is too large
Load Diff
8
packages/ui/src/utils/events.ts
Normal file
8
packages/ui/src/utils/events.ts
Normal 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
|
||||
}
|
||||
162
packages/ui/src/utils/file-extensions.ts
Normal file
162
packages/ui/src/utils/file-extensions.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
166
packages/ui/src/utils/file-tree.ts
Normal file
166
packages/ui/src/utils/file-tree.ts
Normal 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
|
||||
}
|
||||
41
packages/ui/src/utils/game-modes.ts
Normal file
41
packages/ui/src/utils/game-modes.ts
Normal 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',
|
||||
}),
|
||||
},
|
||||
}
|
||||
17
packages/ui/src/utils/index.ts
Normal file
17
packages/ui/src/utils/index.ts
Normal 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'
|
||||
95
packages/ui/src/utils/loaders.ts
Normal file
95
packages/ui/src/utils/loaders.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
54
packages/ui/src/utils/log-share.ts
Normal file
54
packages/ui/src/utils/log-share.ts
Normal 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')
|
||||
}
|
||||
73
packages/ui/src/utils/notices.ts
Normal file
73
packages/ui/src/utils/notices.ts
Normal 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
|
||||
}
|
||||
54
packages/ui/src/utils/product-utils.ts
Normal file
54
packages/ui/src/utils/product-utils.ts
Normal 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,
|
||||
}
|
||||
53
packages/ui/src/utils/regions.ts
Normal file
53
packages/ui/src/utils/regions.ts
Normal 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 }>
|
||||
58
packages/ui/src/utils/savable.ts
Normal file
58
packages/ui/src/utils/savable.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
1066
packages/ui/src/utils/search.ts
Normal file
1066
packages/ui/src/utils/search.ts
Normal file
File diff suppressed because it is too large
Load Diff
203
packages/ui/src/utils/server-content-installing.ts
Normal file
203
packages/ui/src/utils/server-content-installing.ts
Normal 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
503
packages/ui/src/utils/server-search.ts
Normal file
503
packages/ui/src/utils/server-search.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
683
packages/ui/src/utils/tag-messages.ts
Normal file
683
packages/ui/src/utils/tag-messages.ts
Normal 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)
|
||||
}
|
||||
30
packages/ui/src/utils/truncate.ts
Normal file
30
packages/ui/src/utils/truncate.ts
Normal 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
|
||||
}
|
||||
74
packages/ui/src/utils/version-compatibility.ts
Normal file
74
packages/ui/src/utils/version-compatibility.ts
Normal 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))
|
||||
}
|
||||
25
packages/ui/src/utils/vue-children.ts
Normal file
25
packages/ui/src/utils/vue-children.ts
Normal 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)]
|
||||
}
|
||||
434
packages/ui/src/utils/webgl/skin-rendering.ts
Normal file
434
packages/ui/src/utils/webgl/skin-rendering.ts
Normal 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()
|
||||
}
|
||||
275
packages/ui/src/utils/webgl/solid-skin-layer.ts
Normal file
275
packages/ui/src/utils/webgl/solid-skin-layer.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user