Compare commits
20 commits
next
...
client-mod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67b99dd669 | ||
|
|
730c09b6f3 | ||
|
|
f71f10651d | ||
|
|
14f6aa1074 | ||
|
|
0b6b4eb7ad | ||
|
|
6af7b7ae97 | ||
|
|
932ff60d43 | ||
|
|
23ae0e0f0c | ||
|
|
396e539420 | ||
|
|
12b22b7bbf | ||
|
|
ce7b065cbf | ||
|
|
7fb49c1d20 | ||
|
|
31a657948b | ||
|
|
f4632c5388 | ||
|
|
66b9305db0 | ||
|
|
61bf7b0cb7 | ||
|
|
69bfc1a5a7 | ||
|
|
75a7baf02d | ||
|
|
5af0f7ce27 | ||
|
|
e26e947d20 |
23 changed files with 1494 additions and 29 deletions
39
assets/config.html
Normal file
39
assets/config.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Configure client</title>
|
||||||
|
<script>
|
||||||
|
function removeSettings() {
|
||||||
|
if (confirm('Are you sure you want to RESET ALL SETTINGS?')) {
|
||||||
|
localStorage.setItem('options', '{}');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAllData() {
|
||||||
|
localStorage.removeItem('serversList')
|
||||||
|
localStorage.removeItem('serversHistory')
|
||||||
|
localStorage.removeItem('authenticatedAccounts')
|
||||||
|
localStorage.removeItem('modsAutoUpdateLastCheck')
|
||||||
|
localStorage.removeItem('firstModsPageVisit')
|
||||||
|
localStorage.removeItem('proxiesData')
|
||||||
|
localStorage.removeItem('keybindings')
|
||||||
|
localStorage.removeItem('username')
|
||||||
|
localStorage.removeItem('customCommands')
|
||||||
|
localStorage.removeItem('options')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="display: flex;gap: 10px;">
|
||||||
|
<button onclick="removeSettings()">Reset all settings</button>
|
||||||
|
<button onclick="removeAllData()">Remove all user data (but not mods or worlds)</button>
|
||||||
|
<!-- <button>Remove all user data (worlds, resourcepacks)</button> -->
|
||||||
|
<!-- <button>Remove all mods</button> -->
|
||||||
|
<!-- <button>Remove all mod repositories</button> -->
|
||||||
|
</div>
|
||||||
|
<input />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -551,3 +551,4 @@ export class EntityMesh {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
window.EntityMesh = EntityMesh
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { Vec3 } from 'vec3'
|
import { Vec3 } from 'vec3'
|
||||||
import { proxy } from 'valtio'
|
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer'
|
||||||
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
|
|
||||||
import { ProgressReporter } from '../../../src/core/progressReporter'
|
import { ProgressReporter } from '../../../src/core/progressReporter'
|
||||||
|
import { showNotification } from '../../../src/react/NotificationProvider'
|
||||||
import { WorldRendererThree } from './worldrendererThree'
|
import { WorldRendererThree } from './worldrendererThree'
|
||||||
import { DocumentRenderer } from './documentRenderer'
|
import { DocumentRenderer } from './documentRenderer'
|
||||||
import { PanoramaRenderer } from './panorama'
|
import { PanoramaRenderer } from './panorama'
|
||||||
|
|
@ -53,12 +53,14 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
||||||
let panoramaRenderer: PanoramaRenderer | null = null
|
let panoramaRenderer: PanoramaRenderer | null = null
|
||||||
let worldRenderer: WorldRendererThree | null = null
|
let worldRenderer: WorldRendererThree | null = null
|
||||||
|
|
||||||
const startPanorama = () => {
|
const startPanorama = async () => {
|
||||||
if (worldRenderer) return
|
if (worldRenderer) return
|
||||||
if (!panoramaRenderer) {
|
if (!panoramaRenderer) {
|
||||||
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
|
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
|
||||||
void panoramaRenderer.start()
|
|
||||||
window.panoramaRenderer = panoramaRenderer
|
window.panoramaRenderer = panoramaRenderer
|
||||||
|
callModsMethod('panoramaCreated', panoramaRenderer)
|
||||||
|
await panoramaRenderer.start()
|
||||||
|
callModsMethod('panoramaReady', panoramaRenderer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +81,7 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
||||||
worldRenderer?.render(sizeChanged)
|
worldRenderer?.render(sizeChanged)
|
||||||
}
|
}
|
||||||
window.world = worldRenderer
|
window.world = worldRenderer
|
||||||
|
callModsMethod('worldReady', worldRenderer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
|
|
@ -120,8 +123,24 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
globalThis.threeJsBackend = backend
|
||||||
|
globalThis.resourcesManager = initOptions.resourcesManager
|
||||||
|
callModsMethod('default', backend)
|
||||||
|
|
||||||
return backend
|
return backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const callModsMethod = (method: string, ...args: any[]) => {
|
||||||
|
for (const mod of Object.values((window.loadedMods ?? {}) as Record<string, any>)) {
|
||||||
|
try {
|
||||||
|
mod.threeJsBackendModule?.[method]?.(...args)
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = `[mod three.js] Error calling ${method} on ${mod.name}: ${err}`
|
||||||
|
showNotification(errorMessage, 'error')
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createGraphicsBackend.id = 'threejs'
|
createGraphicsBackend.id = 'threejs'
|
||||||
export default createGraphicsBackend
|
export default createGraphicsBackend
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,7 @@ const appConfig = defineConfig({
|
||||||
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
|
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
|
||||||
fs.copyFileSync('./assets/playground.html', './dist/playground.html')
|
fs.copyFileSync('./assets/playground.html', './dist/playground.html')
|
||||||
fs.copyFileSync('./assets/manifest.json', './dist/manifest.json')
|
fs.copyFileSync('./assets/manifest.json', './dist/manifest.json')
|
||||||
|
fs.copyFileSync('./assets/config.html', './dist/config.html')
|
||||||
fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg')
|
fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg')
|
||||||
if (fs.existsSync('./assets/release.json')) {
|
if (fs.existsSync('./assets/release.json')) {
|
||||||
fs.copyFileSync('./assets/release.json', './dist/release.json')
|
fs.copyFileSync('./assets/release.json', './dist/release.json')
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export type AppConfig = {
|
||||||
defaultLanguage?: string
|
defaultLanguage?: string
|
||||||
displayLanguageSelector?: boolean
|
displayLanguageSelector?: boolean
|
||||||
supportedLanguages?: string[]
|
supportedLanguages?: string[]
|
||||||
|
showModsButton?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadAppConfig = (appConfig: AppConfig) => {
|
export const loadAppConfig = (appConfig: AppConfig) => {
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export interface GraphicsBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppViewer {
|
export class AppViewer {
|
||||||
|
waitBackendLoadPromises = [] as Array<Promise<void>>
|
||||||
|
|
||||||
resourcesManager = new ResourcesManager()
|
resourcesManager = new ResourcesManager()
|
||||||
worldView: WorldDataEmitter | undefined
|
worldView: WorldDataEmitter | undefined
|
||||||
readonly config: GraphicsBackendConfig = {
|
readonly config: GraphicsBackendConfig = {
|
||||||
|
|
@ -114,11 +116,14 @@ export class AppViewer {
|
||||||
this.disconnectBackend()
|
this.disconnectBackend()
|
||||||
}
|
}
|
||||||
|
|
||||||
loadBackend (loader: GraphicsBackendLoader) {
|
async loadBackend (loader: GraphicsBackendLoader) {
|
||||||
if (this.backend) {
|
if (this.backend) {
|
||||||
this.disconnectBackend()
|
this.disconnectBackend()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all(this.waitBackendLoadPromises)
|
||||||
|
this.waitBackendLoadPromises = []
|
||||||
|
|
||||||
this.backendLoader = loader
|
this.backendLoader = loader
|
||||||
const rendererSpecificSettings = {} as Record<string, any>
|
const rendererSpecificSettings = {} as Record<string, any>
|
||||||
const rendererSettingsKey = `renderer.${this.backendLoader?.id}`
|
const rendererSettingsKey = `renderer.${this.backendLoader?.id}`
|
||||||
|
|
|
||||||
|
|
@ -9,25 +9,27 @@ import { showNotification } from './react/NotificationProvider'
|
||||||
const backends = [
|
const backends = [
|
||||||
createGraphicsBackend,
|
createGraphicsBackend,
|
||||||
]
|
]
|
||||||
const loadBackend = () => {
|
const loadBackend = async () => {
|
||||||
let backend = backends.find(backend => backend.id === options.activeRenderer)
|
let backend = backends.find(backend => backend.id === options.activeRenderer)
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true)
|
showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true)
|
||||||
backend = backends[0]
|
backend = backends[0]
|
||||||
}
|
}
|
||||||
appViewer.loadBackend(backend)
|
await appViewer.loadBackend(backend)
|
||||||
}
|
}
|
||||||
window.loadBackend = loadBackend
|
window.loadBackend = loadBackend
|
||||||
if (process.env.SINGLE_FILE_BUILD_MODE) {
|
if (process.env.SINGLE_FILE_BUILD_MODE) {
|
||||||
const unsub = subscribeKey(miscUiState, 'fsReady', () => {
|
const unsub = subscribeKey(miscUiState, 'fsReady', () => {
|
||||||
if (miscUiState.fsReady) {
|
if (miscUiState.fsReady) {
|
||||||
// don't do it earlier to load fs and display menu faster
|
// don't do it earlier to load fs and display menu faster
|
||||||
loadBackend()
|
void loadBackend()
|
||||||
unsub()
|
unsub()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
loadBackend()
|
setTimeout(() => {
|
||||||
|
void loadBackend()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const animLoop = () => {
|
const animLoop = () => {
|
||||||
|
|
@ -40,10 +42,10 @@ watchOptionsAfterViewerInit()
|
||||||
|
|
||||||
// reset backend when renderer changes
|
// reset backend when renderer changes
|
||||||
|
|
||||||
subscribeKey(options, 'activeRenderer', () => {
|
subscribeKey(options, 'activeRenderer', async () => {
|
||||||
if (appViewer.currentDisplay === 'world' && bot) {
|
if (appViewer.currentDisplay === 'world' && bot) {
|
||||||
appViewer.resetBackend(true)
|
appViewer.resetBackend(true)
|
||||||
loadBackend()
|
await loadBackend()
|
||||||
void appViewer.startWithBot()
|
void appViewer.startWithBot()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
582
src/clientMods.ts
Normal file
582
src/clientMods.ts
Normal file
|
|
@ -0,0 +1,582 @@
|
||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
import { openDB } from 'idb'
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as valtio from 'valtio'
|
||||||
|
import * as valtioUtils from 'valtio/utils'
|
||||||
|
import { gt } from 'semver'
|
||||||
|
import { proxy } from 'valtio'
|
||||||
|
import { options } from './optionsStorage'
|
||||||
|
import { appStorage } from './react/appStorageProvider'
|
||||||
|
import { showInputsModal, showOptionsModal } from './react/SelectOption'
|
||||||
|
import { ProgressReporter } from './core/progressReporter'
|
||||||
|
|
||||||
|
let sillyProtection = false
|
||||||
|
const protectRuntime = () => {
|
||||||
|
if (sillyProtection) return
|
||||||
|
sillyProtection = true
|
||||||
|
const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username'])
|
||||||
|
const proxy = new Proxy(window.localStorage, {
|
||||||
|
get (target, prop) {
|
||||||
|
if (typeof prop === 'string') {
|
||||||
|
if (sensetiveKeys.has(prop)) {
|
||||||
|
console.warn(`Access to sensitive key "${prop}" was blocked`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (prop === 'getItem') {
|
||||||
|
return (key: string) => {
|
||||||
|
if (sensetiveKeys.has(key)) {
|
||||||
|
console.warn(`Access to sensitive key "${key}" via getItem was blocked`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return target.getItem(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop === 'setItem') {
|
||||||
|
return (key: string, value: string) => {
|
||||||
|
if (sensetiveKeys.has(key)) {
|
||||||
|
console.warn(`Attempt to set sensitive key "${key}" via setItem was blocked`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.setItem(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop === 'removeItem') {
|
||||||
|
return (key: string) => {
|
||||||
|
if (sensetiveKeys.has(key)) {
|
||||||
|
console.warn(`Attempt to delete sensitive key "${key}" via removeItem was blocked`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.removeItem(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop === 'clear') {
|
||||||
|
console.warn('Attempt to clear localStorage was blocked')
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Reflect.get(target, prop)
|
||||||
|
},
|
||||||
|
set (target, prop, value) {
|
||||||
|
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
|
||||||
|
console.warn(`Attempt to set sensitive key "${prop}" was blocked`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return Reflect.set(target, prop, value)
|
||||||
|
},
|
||||||
|
deleteProperty (target, prop) {
|
||||||
|
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
|
||||||
|
console.warn(`Attempt to delete sensitive key "${prop}" was blocked`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return Reflect.deleteProperty(target, prop)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: proxy,
|
||||||
|
writable: false,
|
||||||
|
configurable: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// #region Database
|
||||||
|
const dbPromise = openDB('mods-db', 1, {
|
||||||
|
upgrade (db) {
|
||||||
|
db.createObjectStore('mods', {
|
||||||
|
keyPath: 'name',
|
||||||
|
})
|
||||||
|
db.createObjectStore('repositories', {
|
||||||
|
keyPath: 'url',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// mcraft-repo.json
|
||||||
|
export interface McraftRepoFile {
|
||||||
|
packages: ClientModDefinition[]
|
||||||
|
/** @default true */
|
||||||
|
prefix?: string | boolean
|
||||||
|
name?: string // display name
|
||||||
|
description?: string
|
||||||
|
mirrorUrls?: string[]
|
||||||
|
autoUpdateOverride?: boolean
|
||||||
|
lastUpdated?: number
|
||||||
|
}
|
||||||
|
export interface Repository extends McraftRepoFile {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientMod {
|
||||||
|
name: string; // unique identifier like owner.name
|
||||||
|
version: string
|
||||||
|
enabled?: boolean
|
||||||
|
|
||||||
|
scriptMainUnstable?: string;
|
||||||
|
serverPlugin?: string
|
||||||
|
// serverPlugins?: string[]
|
||||||
|
// mesherThread?: string
|
||||||
|
stylesGlobal?: string
|
||||||
|
threeJsBackend?: string // three.js
|
||||||
|
// stylesLocal?: string
|
||||||
|
|
||||||
|
requiresNetwork?: boolean
|
||||||
|
fullyOffline?: boolean
|
||||||
|
description?: string
|
||||||
|
author?: string
|
||||||
|
section?: string
|
||||||
|
autoUpdateOverride?: boolean
|
||||||
|
lastUpdated?: number
|
||||||
|
wasModifiedLocally?: boolean
|
||||||
|
// todo depends, hashsum
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupFetchedModData = (mod: ClientModDefinition | Record<string, any>) => {
|
||||||
|
delete mod['enabled']
|
||||||
|
delete mod['repo']
|
||||||
|
delete mod['autoUpdateOverride']
|
||||||
|
delete mod['lastUpdated']
|
||||||
|
delete mod['wasModifiedLocally']
|
||||||
|
return mod
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientModDefinition = Omit<ClientMod, 'enabled' | 'wasModifiedLocally'> & {
|
||||||
|
scriptMainUnstable?: boolean
|
||||||
|
stylesGlobal?: boolean
|
||||||
|
serverPlugin?: boolean
|
||||||
|
threeJsBackend?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveClientModData (data: ClientMod) {
|
||||||
|
const db = await dbPromise
|
||||||
|
data.lastUpdated = Date.now()
|
||||||
|
await db.put('mods', data)
|
||||||
|
modsReactiveUpdater.counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPlugin (name: string) {
|
||||||
|
const db = await dbPromise
|
||||||
|
return db.get('mods', name) as Promise<ClientMod | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllMods () {
|
||||||
|
const db = await dbPromise
|
||||||
|
return db.getAll('mods') as Promise<ClientMod[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlugin (name) {
|
||||||
|
const db = await dbPromise
|
||||||
|
await db.delete('mods', name)
|
||||||
|
modsReactiveUpdater.counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAllMods () {
|
||||||
|
const db = await dbPromise
|
||||||
|
await db.clear('mods')
|
||||||
|
modsReactiveUpdater.counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
async function saveRepository (data: Repository) {
|
||||||
|
const db = await dbPromise
|
||||||
|
data.lastUpdated = Date.now()
|
||||||
|
await db.put('repositories', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRepository (url: string) {
|
||||||
|
const db = await dbPromise
|
||||||
|
return db.get('repositories', url) as Promise<Repository | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllRepositories () {
|
||||||
|
const db = await dbPromise
|
||||||
|
return db.getAll('repositories') as Promise<Repository[]>
|
||||||
|
}
|
||||||
|
window.getAllRepositories = getAllRepositories
|
||||||
|
|
||||||
|
async function deleteRepository (url) {
|
||||||
|
const db = await dbPromise
|
||||||
|
await db.delete('repositories', url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
window.mcraft = {
|
||||||
|
version: process.env.RELEASE_TAG,
|
||||||
|
build: process.env.BUILD_VERSION,
|
||||||
|
ui: {},
|
||||||
|
React,
|
||||||
|
valtio: {
|
||||||
|
...valtio,
|
||||||
|
...valtioUtils,
|
||||||
|
},
|
||||||
|
// openDB
|
||||||
|
}
|
||||||
|
|
||||||
|
const activateMod = async (mod: ClientMod, reason: string) => {
|
||||||
|
if (mod.enabled === false) return false
|
||||||
|
protectRuntime()
|
||||||
|
console.debug(`Activating mod ${mod.name} (${reason})...`)
|
||||||
|
window.loadedMods ??= {}
|
||||||
|
if (window.loadedMods[mod.name]) {
|
||||||
|
console.warn(`Mod is ${mod.name} already loaded, skipping activation...`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (mod.stylesGlobal) {
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.textContent = mod.stylesGlobal
|
||||||
|
style.id = `mod-${mod.name}`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
if (mod.scriptMainUnstable) {
|
||||||
|
const blob = new Blob([mod.scriptMainUnstable], { type: 'text/javascript' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
// eslint-disable-next-line no-useless-catch
|
||||||
|
try {
|
||||||
|
const module = await import(/* webpackIgnore: true */ url)
|
||||||
|
module.default?.(structuredClone(mod))
|
||||||
|
window.loadedMods[mod.name] ??= {}
|
||||||
|
window.loadedMods[mod.name].mainUnstableModule = module
|
||||||
|
} catch (e) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
if (mod.threeJsBackend) {
|
||||||
|
const blob = new Blob([mod.threeJsBackend], { type: 'text/javascript' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
// eslint-disable-next-line no-useless-catch
|
||||||
|
try {
|
||||||
|
const module = await import(/* webpackIgnore: true */ url)
|
||||||
|
// todo
|
||||||
|
window.loadedMods[mod.name] ??= {}
|
||||||
|
// for accessing global world var
|
||||||
|
window.loadedMods[mod.name].threeJsBackendModule = module
|
||||||
|
} catch (e) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
mod.enabled = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appStartup = async () => {
|
||||||
|
void checkModsUpdates()
|
||||||
|
|
||||||
|
const mods = await getAllMods()
|
||||||
|
for (const mod of mods) {
|
||||||
|
await activateMod(mod, 'autostart').catch(e => {
|
||||||
|
modsErrors[mod.name] ??= []
|
||||||
|
modsErrors[mod.name].push(`startup: ${String(e)}`)
|
||||||
|
console.error(`Error activating mod on startup ${mod.name}:`, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modsUpdateStatus = proxy({} as Record<string, [string, string]>)
|
||||||
|
export const modsWaitingReloadStatus = proxy({} as Record<string, boolean>)
|
||||||
|
export const modsErrors = proxy({} as Record<string, string[]>)
|
||||||
|
|
||||||
|
const normalizeRepoUrl = (url: string) => {
|
||||||
|
if (url.startsWith('https://')) return url
|
||||||
|
if (url.startsWith('http://')) return url
|
||||||
|
if (url.startsWith('//')) return `https:${url}`
|
||||||
|
return `https://raw.githubusercontent.com/${url}/master`
|
||||||
|
}
|
||||||
|
|
||||||
|
const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true, progress?: ProgressReporter) => {
|
||||||
|
// eslint-disable-next-line no-useless-catch
|
||||||
|
try {
|
||||||
|
const fetchData = async (urls: string[]) => {
|
||||||
|
const errored = [] as string[]
|
||||||
|
// eslint-disable-next-line no-unreachable-loop
|
||||||
|
for (const urlTemplate of urls) {
|
||||||
|
const modNameOnly = mod.name.split('.').pop()
|
||||||
|
const modFolder = repo.prefix === false ? modNameOnly : typeof repo.prefix === 'string' ? `${repo.prefix}/${modNameOnly}` : mod.name
|
||||||
|
const url = new URL(`${modFolder}/${urlTemplate}`, normalizeRepoUrl(repo.url).replace(/\/$/, '') + '/').href
|
||||||
|
// eslint-disable-next-line no-useless-catch
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
|
||||||
|
return await response.text()
|
||||||
|
} catch (e) {
|
||||||
|
// errored.push(String(e))
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.warn(`[${mod.name}] Error installing component of ${urls[0]}: ${errored.join(', ')}`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (mod.stylesGlobal) {
|
||||||
|
await progress?.executeWithMessage(
|
||||||
|
`Downloading ${mod.name} styles`,
|
||||||
|
async () => {
|
||||||
|
mod.stylesGlobal = await fetchData(['global.css']) as any
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mod.scriptMainUnstable) {
|
||||||
|
await progress?.executeWithMessage(
|
||||||
|
`Downloading ${mod.name} script`,
|
||||||
|
async () => {
|
||||||
|
mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mod.threeJsBackend) {
|
||||||
|
await progress?.executeWithMessage(
|
||||||
|
`Downloading ${mod.name} three.js backend`,
|
||||||
|
async () => {
|
||||||
|
mod.threeJsBackend = await fetchData(['three.js']) as any
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mod.serverPlugin) {
|
||||||
|
if (mod.name.endsWith('.disabled')) throw new Error(`Mod name ${mod.name} can't end with .disabled`)
|
||||||
|
await progress?.executeWithMessage(
|
||||||
|
`Downloading ${mod.name} server plugin`,
|
||||||
|
async () => {
|
||||||
|
mod.serverPlugin = await fetchData(['serverPlugin.js']) as any
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (activate) {
|
||||||
|
// todo try to de-activate mod if it's already loaded
|
||||||
|
if (window.loadedMods?.[mod.name]) {
|
||||||
|
modsWaitingReloadStatus[mod.name] = true
|
||||||
|
} else {
|
||||||
|
await activateMod(mod as ClientMod, 'install')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveClientModData(mod as ClientMod)
|
||||||
|
delete modsUpdateStatus[mod.name]
|
||||||
|
} catch (e) {
|
||||||
|
// console.error(`Error installing mod ${mod.name}:`, e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkRepositoryUpdates = async (repo: Repository) => {
|
||||||
|
for (const mod of repo.packages) {
|
||||||
|
|
||||||
|
const modExisting = await getPlugin(mod.name)
|
||||||
|
if (modExisting?.version && gt(mod.version, modExisting.version)) {
|
||||||
|
modsUpdateStatus[mod.name] = [modExisting.version, mod.version]
|
||||||
|
if (options.modsAutoUpdate === 'always' && (!repo.autoUpdateOverride && !modExisting.autoUpdateOverride)) {
|
||||||
|
void installOrUpdateMod(repo, mod).catch(e => {
|
||||||
|
console.error(`Error updating mod ${mod.name}:`, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => {
|
||||||
|
const fetchUrl = normalizeRepoUrl(url).replace(/\/$/, '') + '/mcraft-repo.json'
|
||||||
|
try {
|
||||||
|
const response = await fetch(fetchUrl).then(async res => res.json())
|
||||||
|
if (!response.packages) throw new Error(`No packages field in the response json of the repository: ${fetchUrl}`)
|
||||||
|
response.autoUpdateOverride = (await getRepository(urlOriginal))?.autoUpdateOverride
|
||||||
|
response.url = urlOriginal
|
||||||
|
void saveRepository(response)
|
||||||
|
modsReactiveUpdater.counter++
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error fetching repository (trying other mirrors) ${url}:`, e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchAllRepositories = async () => {
|
||||||
|
const repositories = await getAllRepositories()
|
||||||
|
await Promise.all(repositories.map(async (repo) => {
|
||||||
|
const allUrls = [repo.url, ...(repo.mirrorUrls || [])]
|
||||||
|
for (const [i, url] of allUrls.entries()) {
|
||||||
|
const isLast = i === allUrls.length - 1
|
||||||
|
|
||||||
|
if (await fetchRepository(repo.url, url, !isLast)) break
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
appStorage.modsAutoUpdateLastCheck = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkModsUpdates = async () => {
|
||||||
|
await autoRefreshModRepositories()
|
||||||
|
for (const repo of await getAllRepositories()) {
|
||||||
|
|
||||||
|
await checkRepositoryUpdates(repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoRefreshModRepositories = async () => {
|
||||||
|
if (options.modsAutoUpdate === 'never') return
|
||||||
|
const lastCheck = appStorage.modsAutoUpdateLastCheck
|
||||||
|
if (lastCheck && Date.now() - lastCheck < 1000 * 60 * 60 * options.modsUpdatePeriodCheck) return
|
||||||
|
await fetchAllRepositories()
|
||||||
|
// todo think of not updating check timestamp on offline access
|
||||||
|
}
|
||||||
|
|
||||||
|
export const installModByName = async (repoUrl: string, name: string, progress?: ProgressReporter) => {
|
||||||
|
progress?.beginStage('main', `Installing ${name}`)
|
||||||
|
const repo = await getRepository(repoUrl)
|
||||||
|
if (!repo) throw new Error(`Repository ${repoUrl} not found`)
|
||||||
|
const mod = repo.packages.find(m => m.name === name)
|
||||||
|
if (!mod) throw new Error(`Mod ${name} not found in repository ${repoUrl}`)
|
||||||
|
await installOrUpdateMod(repo, mod, undefined, progress)
|
||||||
|
progress?.endStage('main')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uninstallModAction = async (name: string) => {
|
||||||
|
const choice = await showOptionsModal(`Uninstall mod ${name}?`, ['Yes'])
|
||||||
|
if (!choice) return
|
||||||
|
await deletePlugin(name)
|
||||||
|
window.loadedMods ??= {}
|
||||||
|
if (window.loadedMods[name]) {
|
||||||
|
// window.loadedMods[name].default?.(null)
|
||||||
|
delete window.loadedMods[name]
|
||||||
|
modsWaitingReloadStatus[name] = true
|
||||||
|
}
|
||||||
|
// Clear any errors associated with the mod
|
||||||
|
delete modsErrors[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setEnabledModAction = async (name: string, newEnabled: boolean) => {
|
||||||
|
const mod = await getPlugin(name)
|
||||||
|
if (!mod) throw new Error(`Mod ${name} not found`)
|
||||||
|
if (newEnabled) {
|
||||||
|
mod.enabled = true
|
||||||
|
if (!window.loadedMods?.[mod.name]) {
|
||||||
|
await activateMod(mod, 'manual')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// todo deactivate mod
|
||||||
|
mod.enabled = false
|
||||||
|
if (window.loadedMods?.[mod.name]) {
|
||||||
|
if (window.loadedMods[mod.name]?.threeJsBackendModule) {
|
||||||
|
window.loadedMods[mod.name].threeJsBackendModule.deactivate()
|
||||||
|
delete window.loadedMods[mod.name].threeJsBackendModule
|
||||||
|
}
|
||||||
|
if (window.loadedMods[mod.name]?.mainUnstableModule) {
|
||||||
|
window.loadedMods[mod.name].mainUnstableModule.deactivate()
|
||||||
|
delete window.loadedMods[mod.name].mainUnstableModule
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(window.loadedMods[mod.name]).length === 0) {
|
||||||
|
delete window.loadedMods[mod.name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveClientModData(mod)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modsReactiveUpdater = proxy({
|
||||||
|
counter: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getAllModsDisplayList = async () => {
|
||||||
|
const repos = await getAllRepositories()
|
||||||
|
const installedMods = await getAllMods()
|
||||||
|
const modsWithoutRepos = installedMods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name)))
|
||||||
|
const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({
|
||||||
|
...mod,
|
||||||
|
installed: installedMods.find(m => m.name === mod.name),
|
||||||
|
activated: !!window.loadedMods?.[mod.name],
|
||||||
|
installedVersion: installedMods.find(m => m.name === mod.name)?.version,
|
||||||
|
canBeActivated: mod.scriptMainUnstable || mod.stylesGlobal,
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
repos: repos.map(repo => ({
|
||||||
|
...repo,
|
||||||
|
packages: mapMods(repo.packages as ClientMod[]),
|
||||||
|
})),
|
||||||
|
modsWithoutRepos: mapMods(modsWithoutRepos),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeRepositoryAction = async (url: string) => {
|
||||||
|
// todo remove mods
|
||||||
|
const choice = await showOptionsModal('Remove repository? Installed mods wont be automatically removed.', ['Yes'])
|
||||||
|
if (!choice) return
|
||||||
|
await deleteRepository(url)
|
||||||
|
modsReactiveUpdater.counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectAndRemoveRepository = async () => {
|
||||||
|
const repos = await getAllRepositories()
|
||||||
|
const choice = await showOptionsModal('Select repository to remove', repos.map(repo => repo.url))
|
||||||
|
if (!choice) return
|
||||||
|
await removeRepositoryAction(choice)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addRepositoryAction = async () => {
|
||||||
|
const { url } = await showInputsModal('Add repository', {
|
||||||
|
url: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Repository URL or slug',
|
||||||
|
placeholder: 'github-owner/repo-name',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!url) return
|
||||||
|
await fetchRepository(url, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerPlugin = async (plugin: string) => {
|
||||||
|
const mod = await getPlugin(plugin)
|
||||||
|
if (!mod) return null
|
||||||
|
if (mod.serverPlugin) {
|
||||||
|
return {
|
||||||
|
content: mod.serverPlugin,
|
||||||
|
version: mod.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAvailableServerPlugins = async () => {
|
||||||
|
const mods = await getAllMods()
|
||||||
|
return mods.filter(mod => mod.serverPlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.inspectInstalledMods = getAllMods
|
||||||
|
|
||||||
|
type ModifiableField = {
|
||||||
|
field: string
|
||||||
|
label: string
|
||||||
|
language: string
|
||||||
|
getContent?: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
export const getAllModsModifiableFields = () => {
|
||||||
|
const fields: ModifiableField[] = [
|
||||||
|
{
|
||||||
|
field: 'scriptMainUnstable',
|
||||||
|
label: 'Main Thread Script (unstable)',
|
||||||
|
language: 'js'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'stylesGlobal',
|
||||||
|
label: 'Global CSS Styles',
|
||||||
|
language: 'css'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'threeJsBackend',
|
||||||
|
label: 'Three.js Renderer Backend Thread',
|
||||||
|
language: 'js'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'serverPlugin',
|
||||||
|
label: 'Built-in server plugin',
|
||||||
|
language: 'js'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getModModifiableFields = (mod: ClientMod): ModifiableField[] => {
|
||||||
|
return getAllModsModifiableFields().filter(field => mod[field.field])
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { setLoadingScreenStatus } from '../appStatus'
|
import { setLoadingScreenStatus } from '../appStatus'
|
||||||
import { appStatusState } from '../react/AppStatusProvider'
|
import { appStatusState } from '../react/AppStatusProvider'
|
||||||
import { hideNotification, showNotification } from '../react/NotificationProvider'
|
import { hideNotification, showNotification } from '../react/NotificationProvider'
|
||||||
|
import { pixelartIcons } from '../react/PixelartIcon'
|
||||||
|
|
||||||
export interface ProgressReporter {
|
export interface ProgressReporter {
|
||||||
currentMessage: string | undefined
|
currentMessage: string | undefined
|
||||||
|
|
@ -170,7 +171,7 @@ export const createNotificationProgressReporter = (endMessage?: string): Progres
|
||||||
},
|
},
|
||||||
end () {
|
end () {
|
||||||
if (endMessage) {
|
if (endMessage) {
|
||||||
showNotification(endMessage, '', false, '', undefined, true)
|
showNotification(endMessage, '', false, pixelartIcons.check, undefined, true)
|
||||||
} else {
|
} else {
|
||||||
hideNotification(id)
|
hideNotification(id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ module.exports = {
|
||||||
'gameMode': 0,
|
'gameMode': 0,
|
||||||
'difficulty': 0,
|
'difficulty': 0,
|
||||||
'worldFolder': 'world',
|
'worldFolder': 'world',
|
||||||
|
'pluginsFolder': true,
|
||||||
// todo set sid, disable entities auto-spawn
|
// todo set sid, disable entities auto-spawn
|
||||||
'generation': {
|
'generation': {
|
||||||
// grass_field
|
// grass_field
|
||||||
|
|
|
||||||
15
src/index.ts
15
src/index.ts
|
|
@ -81,6 +81,7 @@ import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerCon
|
||||||
import { mainMenuState } from './react/MainMenuRenderApp'
|
import { mainMenuState } from './react/MainMenuRenderApp'
|
||||||
import './mobileShim'
|
import './mobileShim'
|
||||||
import { parseFormattedMessagePacket } from './botUtils'
|
import { parseFormattedMessagePacket } from './botUtils'
|
||||||
|
import { appStartup } from './clientMods'
|
||||||
import { getViewerVersionData, getWsProtocolStream, onBotCreatedViewerHandler } from './viewerConnector'
|
import { getViewerVersionData, getWsProtocolStream, onBotCreatedViewerHandler } from './viewerConnector'
|
||||||
import { getWebsocketStream } from './mineflayer/websocket-core'
|
import { getWebsocketStream } from './mineflayer/websocket-core'
|
||||||
import { appQueryParams, appQueryParamsArray } from './appParams'
|
import { appQueryParams, appQueryParamsArray } from './appParams'
|
||||||
|
|
@ -95,6 +96,7 @@ import './appViewerLoad'
|
||||||
import { registerOpenBenchmarkListener } from './benchmark'
|
import { registerOpenBenchmarkListener } from './benchmark'
|
||||||
import { tryHandleBuiltinCommand } from './builtinCommands'
|
import { tryHandleBuiltinCommand } from './builtinCommands'
|
||||||
import { loadingTimerState } from './react/LoadingTimer'
|
import { loadingTimerState } from './react/LoadingTimer'
|
||||||
|
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
|
||||||
|
|
||||||
window.debug = debug
|
window.debug = debug
|
||||||
window.beforeRenderFrame = []
|
window.beforeRenderFrame = []
|
||||||
|
|
@ -369,6 +371,16 @@ export async function connect (connectOptions: ConnectOptions) {
|
||||||
// Client (class) of flying-squid (in server/login.js of mc-protocol): onLogin handler: skip most logic & go to loginClient() which assigns uuid and sends 'success' back to client (onLogin handler) and emits 'login' on the server (login.js in flying-squid handler)
|
// Client (class) of flying-squid (in server/login.js of mc-protocol): onLogin handler: skip most logic & go to loginClient() which assigns uuid and sends 'success' back to client (onLogin handler) and emits 'login' on the server (login.js in flying-squid handler)
|
||||||
// flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer
|
// flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer
|
||||||
|
|
||||||
|
const serverPlugins = new URLSearchParams(location.search).getAll('serverPlugin')
|
||||||
|
if (serverPlugins.length > 0 && !serverOptions.worldFolder) {
|
||||||
|
console.log('Placing server plugins', serverPlugins)
|
||||||
|
|
||||||
|
serverOptions.worldFolder ??= '/temp'
|
||||||
|
await loadPluginsIntoWorld('/temp', serverPlugins)
|
||||||
|
|
||||||
|
console.log('Server plugins placed')
|
||||||
|
}
|
||||||
|
|
||||||
localServer = window.localServer = window.server = startLocalServer(serverOptions)
|
localServer = window.localServer = window.server = startLocalServer(serverOptions)
|
||||||
connectOptions?.connectEvents?.serverCreated?.()
|
connectOptions?.connectEvents?.serverCreated?.()
|
||||||
// todo need just to call quit if started
|
// todo need just to call quit if started
|
||||||
|
|
@ -669,7 +681,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
||||||
|
|
||||||
bot.once('login', () => {
|
bot.once('login', () => {
|
||||||
loadingTimerState.networkOnlyStart = 0
|
loadingTimerState.networkOnlyStart = 0
|
||||||
setLoadingScreenStatus('Loading world')
|
progress.setMessage('Loading world')
|
||||||
})
|
})
|
||||||
|
|
||||||
let worldWasReady = false
|
let worldWasReady = false
|
||||||
|
|
@ -984,4 +996,5 @@ if (initialLoader) {
|
||||||
window.pageLoaded = true
|
window.pageLoaded = true
|
||||||
|
|
||||||
void possiblyHandleStateVariable()
|
void possiblyHandleStateVariable()
|
||||||
|
appViewer.waitBackendLoadPromises.push(appStartup())
|
||||||
registerOpenBenchmarkListener()
|
registerOpenBenchmarkListener()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { openFilePicker, resetLocalStorage } from './browserfs'
|
||||||
import { completeResourcepackPackInstall, getResourcePackNames, resourcepackReload, resourcePackState, uninstallResourcePack } from './resourcePack'
|
import { completeResourcepackPackInstall, getResourcePackNames, resourcepackReload, resourcePackState, uninstallResourcePack } from './resourcePack'
|
||||||
import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy'
|
import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy'
|
||||||
import { showInputsModal, showOptionsModal } from './react/SelectOption'
|
import { showInputsModal, showOptionsModal } from './react/SelectOption'
|
||||||
|
import { modsUpdateStatus } from './clientMods'
|
||||||
import supportedVersions from './supportedVersions.mjs'
|
import supportedVersions from './supportedVersions.mjs'
|
||||||
import { getVersionAutoSelect } from './connect'
|
import { getVersionAutoSelect } from './connect'
|
||||||
import { createNotificationProgressReporter } from './core/progressReporter'
|
import { createNotificationProgressReporter } from './core/progressReporter'
|
||||||
|
|
@ -227,6 +228,15 @@ export const guiOptionsScheme: {
|
||||||
return <Button label='Advanced...' onClick={() => openOptionsMenu('advanced')} inScreen />
|
return <Button label='Advanced...' onClick={() => openOptionsMenu('advanced')} inScreen />
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
custom () {
|
||||||
|
const { appConfig } = useSnapshot(miscUiState)
|
||||||
|
const modsUpdateSnapshot = useSnapshot(modsUpdateStatus)
|
||||||
|
|
||||||
|
if (appConfig?.showModsButton === false) return null
|
||||||
|
return <Button label={`Client Mods: ${Object.keys(window.loadedMods ?? {}).length} (${Object.keys(modsUpdateSnapshot).length})`} onClick={() => showModal({ reactType: 'mods' })} inScreen />
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
custom () {
|
custom () {
|
||||||
return <Button label='VR...' onClick={() => openOptionsMenu('VR')} inScreen />
|
return <Button label='VR...' onClick={() => openOptionsMenu('VR')} inScreen />
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ const defaultOptions = {
|
||||||
// todo ui setting, maybe enable by default?
|
// todo ui setting, maybe enable by default?
|
||||||
waitForChunksRender: false as 'sp-only' | boolean,
|
waitForChunksRender: false as 'sp-only' | boolean,
|
||||||
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
|
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
|
||||||
|
modsSupport: false,
|
||||||
|
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
|
||||||
|
modsUpdatePeriodCheck: 24, // hours
|
||||||
preventBackgroundTimeoutKick: false,
|
preventBackgroundTimeoutKick: false,
|
||||||
preventSleep: false,
|
preventSleep: false,
|
||||||
debugContro: false,
|
debugContro: false,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { proxy, useSnapshot } from 'valtio'
|
import { proxy, useSnapshot } from 'valtio'
|
||||||
import { filesize } from 'filesize'
|
import { filesize } from 'filesize'
|
||||||
|
import { getAvailableServerPlugins } from '../clientMods'
|
||||||
|
import { showModal } from '../globalState'
|
||||||
import Input from './Input'
|
import Input from './Input'
|
||||||
import Screen from './Screen'
|
import Screen from './Screen'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import SelectGameVersion from './SelectGameVersion'
|
import SelectGameVersion from './SelectGameVersion'
|
||||||
import styles from './createWorld.module.css'
|
import styles from './createWorld.module.css'
|
||||||
|
import { InputOption, showInputsModal, showOptionsModal } from './SelectOption'
|
||||||
|
|
||||||
// const worldTypes = ['default', 'flat', 'largeBiomes', 'amplified', 'customized', 'buffet', 'debug_all_block_states']
|
// const worldTypes = ['default', 'flat', 'largeBiomes', 'amplified', 'customized', 'buffet', 'debug_all_block_states']
|
||||||
const worldTypes = ['default', 'flat'/* , 'void' */]
|
const worldTypes = ['default', 'flat'/* , 'void' */]
|
||||||
|
|
@ -15,13 +18,14 @@ export const creatingWorldState = proxy({
|
||||||
title: '',
|
title: '',
|
||||||
type: worldTypes[0],
|
type: worldTypes[0],
|
||||||
gameMode: gameModes[0],
|
gameMode: gameModes[0],
|
||||||
version: ''
|
version: '',
|
||||||
|
plugins: [] as string[]
|
||||||
})
|
})
|
||||||
|
|
||||||
export default ({ cancelClick, createClick, customizeClick, versions, defaultVersion }) => {
|
export default ({ cancelClick, createClick, customizeClick, versions, defaultVersion }) => {
|
||||||
const [quota, setQuota] = useState('')
|
const [quota, setQuota] = useState('')
|
||||||
|
|
||||||
const { title, type, version, gameMode } = useSnapshot(creatingWorldState)
|
const { title, type, version, gameMode, plugins } = useSnapshot(creatingWorldState)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
creatingWorldState.version = defaultVersion
|
creatingWorldState.version = defaultVersion
|
||||||
void navigator.storage?.estimate?.().then(({ quota, usage }) => {
|
void navigator.storage?.estimate?.().then(({ quota, usage }) => {
|
||||||
|
|
@ -69,7 +73,38 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer
|
||||||
creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1]
|
creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1]
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Gamemode: {gameMode}
|
Game Mode: {gameMode}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<Button onClick={async () => {
|
||||||
|
const availableServerPlugins = await getAvailableServerPlugins()
|
||||||
|
const availableModNames = availableServerPlugins.map(mod => mod.name)
|
||||||
|
const choices: Record<string, InputOption> = Object.fromEntries(availableServerPlugins.map(mod => [mod.name, {
|
||||||
|
type: 'checkbox' as const,
|
||||||
|
defaultValue: creatingWorldState.plugins.includes(mod.name),
|
||||||
|
label: mod.name
|
||||||
|
}]))
|
||||||
|
choices.installMore = {
|
||||||
|
type: 'button' as const,
|
||||||
|
onButtonClick () {
|
||||||
|
showModal({ reactType: 'mods' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const choice = await showInputsModal('Select server plugins from mods to install:', choices)
|
||||||
|
if (!choice) return
|
||||||
|
creatingWorldState.plugins = availableModNames.filter(modName => choice[modName])
|
||||||
|
}}
|
||||||
|
>Use Mods ({plugins.length})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const index = gameModes.indexOf(gameMode)
|
||||||
|
creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1]
|
||||||
|
}}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Save Type: Java
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className='muted' style={{ fontSize: 8 }}>Default and other world types are WIP</div>
|
<div className='muted' style={{ fontSize: 8 }}>Default and other world types are WIP</div>
|
||||||
|
|
@ -80,7 +115,11 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer
|
||||||
}}
|
}}
|
||||||
>Cancel
|
>Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!title} onClick={createClick}>Create</Button>
|
<Button disabled={!title} onClick={createClick}>
|
||||||
|
<b>
|
||||||
|
Create
|
||||||
|
</b>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className='muted' style={{ fontSize: 9 }}>Note: save important worlds in folders on your hard drive!</div>
|
<div className='muted' style={{ fontSize: 9 }}>Note: save important worlds in folders on your hard drive!</div>
|
||||||
<div className='muted' style={{ fontSize: 9 }}>{quota}</div>
|
<div className='muted' style={{ fontSize: 9 }}>{quota}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
import { hideCurrentModal, showModal } from '../globalState'
|
import { hideCurrentModal, showModal } from '../globalState'
|
||||||
import defaultLocalServerOptions from '../defaultLocalServerOptions'
|
import defaultLocalServerOptions from '../defaultLocalServerOptions'
|
||||||
import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
|
import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
|
||||||
import supportedVersions from '../supportedVersions.mjs'
|
import supportedVersions from '../supportedVersions.mjs'
|
||||||
|
import { getServerPlugin } from '../clientMods'
|
||||||
import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld'
|
import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld'
|
||||||
import { getWorldsPath } from './SingleplayerProvider'
|
import { getWorldsPath } from './SingleplayerProvider'
|
||||||
import { useIsModalActive } from './utilsApp'
|
import { useIsModalActive } from './utilsApp'
|
||||||
|
|
@ -14,7 +17,7 @@ export default () => {
|
||||||
const versions = Object.values(versionsPerMinor).map(x => {
|
const versions = Object.values(versionsPerMinor).map(x => {
|
||||||
return {
|
return {
|
||||||
version: x,
|
version: x,
|
||||||
label: x === defaultLocalServerOptions.version ? `${x} (available offline)` : x
|
label: x === defaultLocalServerOptions.version ? `${x} (default)` : x
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return <CreateWorld
|
return <CreateWorld
|
||||||
|
|
@ -24,10 +27,11 @@ export default () => {
|
||||||
}}
|
}}
|
||||||
createClick={async () => {
|
createClick={async () => {
|
||||||
// create new world
|
// create new world
|
||||||
const { title, type, version, gameMode } = creatingWorldState
|
const { title, type, version, gameMode, plugins } = creatingWorldState
|
||||||
// todo display path in ui + disable if exist
|
// todo display path in ui + disable if exist
|
||||||
const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath())
|
const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath())
|
||||||
await mkdirRecursive(savePath)
|
await mkdirRecursive(savePath)
|
||||||
|
await loadPluginsIntoWorld(savePath, plugins)
|
||||||
let generation
|
let generation
|
||||||
if (type === 'flat') {
|
if (type === 'flat') {
|
||||||
generation = {
|
generation = {
|
||||||
|
|
@ -68,3 +72,16 @@ export default () => {
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const loadPluginsIntoWorld = async (worldPath: string, plugins: string[]) => {
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const { content, version } = await getServerPlugin(plugin) ?? {}
|
||||||
|
if (content) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await mkdirRecursive(path.join(worldPath, 'plugins'))
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await fs.promises.writeFile(path.join(worldPath, 'plugins', `${plugin}-${version}.js`), content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w
|
||||||
return <div id='input-container' className={styles.container} style={rootStyles}>
|
return <div id='input-container' className={styles.container} style={rootStyles}>
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={styles.input}
|
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
autoCapitalize='off'
|
autoCapitalize='off'
|
||||||
autoCorrect='off'
|
autoCorrect='off'
|
||||||
|
|
@ -43,6 +42,7 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w
|
||||||
spellCheck='false'
|
spellCheck='false'
|
||||||
style={{ ...validationStyle }}
|
style={{ ...validationStyle }}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
|
className={styles.input + ' ' + (inputProps.className ?? '')}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setValue(e.target.value)
|
setValue(e.target.value)
|
||||||
|
|
|
||||||
483
src/react/ModsPage.tsx
Normal file
483
src/react/ModsPage.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
||||||
|
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { openURL } from 'renderer/viewer/lib/simpleUtils'
|
||||||
|
import { addRepositoryAction, setEnabledModAction, getAllModsDisplayList, installModByName, selectAndRemoveRepository, uninstallModAction, fetchAllRepositories, modsReactiveUpdater, modsErrors, fetchRepository, getModModifiableFields, saveClientModData, getAllModsModifiableFields } from '../clientMods'
|
||||||
|
import { createNotificationProgressReporter, ProgressReporter } from '../core/progressReporter'
|
||||||
|
import { hideModal } from '../globalState'
|
||||||
|
import { useIsModalActive } from './utilsApp'
|
||||||
|
import Input from './Input'
|
||||||
|
import Button from './Button'
|
||||||
|
import styles from './mods.module.css'
|
||||||
|
import { showOptionsModal, showInputsModal } from './SelectOption'
|
||||||
|
import Screen from './Screen'
|
||||||
|
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
|
||||||
|
import { showNotification } from './NotificationProvider'
|
||||||
|
import { usePassesScaledDimensions } from './UIProvider'
|
||||||
|
import { appStorage } from './appStorageProvider'
|
||||||
|
|
||||||
|
type ModsData = Awaited<ReturnType<typeof getAllModsDisplayList>>
|
||||||
|
|
||||||
|
const ModListItem = ({
|
||||||
|
mod,
|
||||||
|
onClick,
|
||||||
|
hasError
|
||||||
|
}: {
|
||||||
|
mod: ModsData['repos'][0]['packages'][0],
|
||||||
|
onClick: () => void,
|
||||||
|
hasError: boolean
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={styles.modRow}
|
||||||
|
onClick={onClick}
|
||||||
|
data-enabled={mod.installed ? '' : mod.activated}
|
||||||
|
data-has-error={hasError}
|
||||||
|
>
|
||||||
|
<div className={styles.modRowTitle}>
|
||||||
|
{mod.name}
|
||||||
|
{mod.installedVersion && mod.installedVersion !== mod.version && (
|
||||||
|
<PixelartIcon
|
||||||
|
iconName={pixelartIcons['arrow-up-box']}
|
||||||
|
styles={{ fontSize: 14, marginLeft: 3 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modRowInfo}>
|
||||||
|
{mod.description}
|
||||||
|
{mod.author && ` • By ${mod.author}`}
|
||||||
|
{mod.version && ` • v${mod.version}`}
|
||||||
|
{mod.serverPlugin && ` • World plugin`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { repo?: string }) | null }) => {
|
||||||
|
const errors = useSnapshot(modsErrors)
|
||||||
|
const [editingField, setEditingField] = useState<{ name: string, content: string, language: string } | null>(null)
|
||||||
|
|
||||||
|
const handleAction = async (action: () => Promise<void>, errorMessage: string, progress?: ProgressReporter) => {
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
progress?.end()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
progress?.end()
|
||||||
|
showNotification(errorMessage, error.message, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mod) {
|
||||||
|
return <div className={styles.modInfoText}>Select a mod to view details</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiableFields = mod.installed ? getModModifiableFields(mod.installed) : []
|
||||||
|
|
||||||
|
const handleSaveField = async (newContents: string) => {
|
||||||
|
if (!editingField) return
|
||||||
|
try {
|
||||||
|
mod[editingField.name] = newContents
|
||||||
|
mod.wasModifiedLocally = true
|
||||||
|
await saveClientModData(mod)
|
||||||
|
setEditingField(null)
|
||||||
|
showNotification('Success', 'Contents saved successfully')
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Error', 'Failed to save contents: ' + error.message, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingField) {
|
||||||
|
return (
|
||||||
|
<EditingCodeWindow
|
||||||
|
contents={editingField.content}
|
||||||
|
language={editingField.language}
|
||||||
|
onClose={newContents => {
|
||||||
|
if (newContents === undefined) {
|
||||||
|
setEditingField(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void handleSaveField(newContents)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.modInfo}>
|
||||||
|
<div className={styles.modInfoTitle}>
|
||||||
|
{mod.name} {mod.installed?.wasModifiedLocally ? '(modified)' : ''}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modInfoText}>
|
||||||
|
{mod.description}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modInfoText}>
|
||||||
|
{mod.author && `Author: ${mod.author}\n`}
|
||||||
|
{mod.version && `Version: ${mod.version}\n`}
|
||||||
|
{mod.installedVersion && mod.installedVersion !== mod.version && `Installed version: ${mod.installedVersion}\n`}
|
||||||
|
{mod.section && `Section: ${mod.section}\n`}
|
||||||
|
</div>
|
||||||
|
{errors[mod.name]?.length > 0 && (
|
||||||
|
<div className={styles.modErrorList}>
|
||||||
|
<ul>
|
||||||
|
{errors[mod.name].map((error, i) => (
|
||||||
|
<li key={i}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modActions}>
|
||||||
|
{mod.installed ? (
|
||||||
|
<>
|
||||||
|
{mod.activated ? (
|
||||||
|
<Button
|
||||||
|
onClick={async () => handleAction(
|
||||||
|
async () => setEnabledModAction(mod.name, false),
|
||||||
|
'Failed to disable mod:'
|
||||||
|
)}
|
||||||
|
icon={pixelartIcons['remove-box']}
|
||||||
|
title="Disable"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={async () => handleAction(
|
||||||
|
async () => setEnabledModAction(mod.name, true),
|
||||||
|
'Failed to enable mod:'
|
||||||
|
)}
|
||||||
|
icon={pixelartIcons['add-box']}
|
||||||
|
title="Enable"
|
||||||
|
disabled={!mod.canBeActivated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={async () => handleAction(
|
||||||
|
async () => uninstallModAction(mod.name),
|
||||||
|
'Failed to uninstall mod:'
|
||||||
|
)}
|
||||||
|
icon={pixelartIcons.trash}
|
||||||
|
title="Delete"
|
||||||
|
/>
|
||||||
|
{mod.installedVersion && mod.installedVersion !== mod.version && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!mod.repo) return
|
||||||
|
const progress = createNotificationProgressReporter(`${mod.name} updated and activated`)
|
||||||
|
await handleAction(
|
||||||
|
async () => {
|
||||||
|
await installModByName(mod.repo!, mod.name, progress)
|
||||||
|
},
|
||||||
|
'Failed to update mod:',
|
||||||
|
progress
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
icon={pixelartIcons['arrow-up-box']}
|
||||||
|
title="Update"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mod.serverPlugin && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set('sp', '1')
|
||||||
|
url.searchParams.set('serverPlugin', mod.name)
|
||||||
|
openURL(url.toString())
|
||||||
|
}}
|
||||||
|
icon={pixelartIcons.play}
|
||||||
|
title="Try in blank world"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!mod.repo) return
|
||||||
|
const progress = createNotificationProgressReporter(`${mod.name} installed and enabled`)
|
||||||
|
await handleAction(
|
||||||
|
async () => {
|
||||||
|
await installModByName(mod.repo!, mod.name, progress)
|
||||||
|
},
|
||||||
|
'Failed to install & activate mod:',
|
||||||
|
progress
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
icon={pixelartIcons.download}
|
||||||
|
title="Install"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modifiableFields.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={async (e) => {
|
||||||
|
const fields = e.shiftKey ? getAllModsModifiableFields() : modifiableFields
|
||||||
|
const result = await showInputsModal('Edit Mod Field', Object.fromEntries(fields.map(field => {
|
||||||
|
return [field.field, {
|
||||||
|
type: 'button' as const,
|
||||||
|
label: field.label,
|
||||||
|
onButtonClick () {
|
||||||
|
setEditingField({
|
||||||
|
name: field.field,
|
||||||
|
content: field.getContent?.() || mod.installed![field.field] || '',
|
||||||
|
language: field.language
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})), {
|
||||||
|
showConfirm: false
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
icon={pixelartIcons['edit']}
|
||||||
|
title="Edit Mod"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditingCodeWindow = ({
|
||||||
|
contents,
|
||||||
|
language,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
contents: string,
|
||||||
|
language: string,
|
||||||
|
onClose: (newContents?: string) => void
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <Screen title="Editing code">
|
||||||
|
<div className="">
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={styles.fieldEditorTextarea}
|
||||||
|
defaultValue={contents}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
style={{ position: 'absolute', bottom: 10, left: 10, backgroundColor: 'red' }}
|
||||||
|
onClick={() => onClose(undefined)}
|
||||||
|
icon={pixelartIcons.close}
|
||||||
|
title="Cancel"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
style={{ position: 'absolute', bottom: 10, right: 10, backgroundColor: '#4CAF50' }}
|
||||||
|
onClick={() => onClose(ref.current?.value)}
|
||||||
|
icon={pixelartIcons.check}
|
||||||
|
title="Save"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Screen>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const isModalActive = useIsModalActive('mods', true)
|
||||||
|
const [modsData, setModsData] = useState<ModsData | null>(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [showOnlyInstalled, setShowOnlyInstalled] = useState(false)
|
||||||
|
const [showOnlyEnabled, setShowOnlyEnabled] = useState(false)
|
||||||
|
const [selectedModIndex, setSelectedModIndex] = useState<number | null>(null)
|
||||||
|
const [expandedRepos, setExpandedRepos] = useState<Record<string, boolean>>({})
|
||||||
|
const useHorizontalLayout = usePassesScaledDimensions(400)
|
||||||
|
const { counter } = useSnapshot(modsReactiveUpdater)
|
||||||
|
const errors = useSnapshot(modsErrors)
|
||||||
|
|
||||||
|
const allModsArray = useMemo(() => {
|
||||||
|
if (!modsData) return []
|
||||||
|
return [
|
||||||
|
...modsData.repos.flatMap(repo => repo.packages.map(mod => ({ ...mod, repo: repo.url }))),
|
||||||
|
...modsData.modsWithoutRepos
|
||||||
|
]
|
||||||
|
}, [modsData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isModalActive) {
|
||||||
|
if (appStorage.firstModsPageVisit) {
|
||||||
|
appStorage.firstModsPageVisit = false
|
||||||
|
const defaultRepo = 'zardoy/mcraft-client-mods'
|
||||||
|
void fetchRepository(defaultRepo, defaultRepo)
|
||||||
|
}
|
||||||
|
void getAllModsDisplayList().then(mods => {
|
||||||
|
setModsData(mods)
|
||||||
|
// Update selected mod index if needed
|
||||||
|
if (selectedModIndex !== null && selectedModIndex < allModsArray.length) {
|
||||||
|
setSelectedModIndex(selectedModIndex)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [isModalActive, counter])
|
||||||
|
|
||||||
|
if (!isModalActive) return null
|
||||||
|
|
||||||
|
const toggleRepo = (repoUrl: string) => {
|
||||||
|
setExpandedRepos(prev => ({
|
||||||
|
...prev,
|
||||||
|
[repoUrl]: !prev[repoUrl]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const modFilter = (mod: ModsData['repos'][0]['packages'][0]) => {
|
||||||
|
const matchesSearch = mod.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
mod.description?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
const matchesInstalledFilter = !showOnlyInstalled || mod.installed
|
||||||
|
const matchesEnabledFilter = !showOnlyEnabled || mod.activated
|
||||||
|
return matchesSearch && matchesInstalledFilter && matchesEnabledFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMods = modsData ? {
|
||||||
|
repos: modsData.repos.map(repo => ({
|
||||||
|
...repo,
|
||||||
|
packages: repo.packages.filter(modFilter)
|
||||||
|
})),
|
||||||
|
modsWithoutRepos: modsData.modsWithoutRepos.filter(modFilter)
|
||||||
|
} : null
|
||||||
|
|
||||||
|
const filteredModsCount = filteredMods ?
|
||||||
|
filteredMods.repos.reduce((acc, repo) => acc + repo.packages.length, 0) + filteredMods.modsWithoutRepos.length : 0
|
||||||
|
|
||||||
|
const totalRepos = modsData?.repos.length ?? 0
|
||||||
|
|
||||||
|
const getStatsText = () => {
|
||||||
|
if (!filteredMods) return 'Loading...'
|
||||||
|
if (showOnlyEnabled) {
|
||||||
|
return `Showing ${filteredMods.repos.reduce((acc, repo) => acc + repo.packages.filter(mod => mod.activated).length, 0)} enabled mods in ${totalRepos} repos`
|
||||||
|
} else if (showOnlyInstalled) {
|
||||||
|
return `Showing ${filteredMods.repos.reduce((acc, repo) => acc + repo.packages.filter(mod => mod.installed).length, 0)} installed mods in ${totalRepos} repos`
|
||||||
|
}
|
||||||
|
return `Showing all ${totalRepos} repos with ${filteredModsCount} mods`
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedMod = selectedModIndex === null ? null : allModsArray[selectedModIndex]
|
||||||
|
|
||||||
|
return <Screen backdrop="dirt" title="Client Mods (Preview)" titleMarginTop={0} contentStyle={{ paddingTop: 15, height: '100%', width: '100%' }}>
|
||||||
|
<Button
|
||||||
|
icon={pixelartIcons['close']}
|
||||||
|
onClick={() => {
|
||||||
|
hideModal()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
color: '#ff5d5d',
|
||||||
|
position: 'fixed',
|
||||||
|
top: 10,
|
||||||
|
left: 20
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.root}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Button
|
||||||
|
style={{}}
|
||||||
|
icon={pixelartIcons['sliders']}
|
||||||
|
onClick={() => {
|
||||||
|
if (showOnlyEnabled) {
|
||||||
|
setShowOnlyEnabled(false)
|
||||||
|
} else if (showOnlyInstalled) {
|
||||||
|
setShowOnlyInstalled(false)
|
||||||
|
setShowOnlyEnabled(true)
|
||||||
|
} else {
|
||||||
|
setShowOnlyInstalled(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={showOnlyEnabled ? 'Show all mods' : showOnlyInstalled ? 'Show enabled mods' : 'Show installed mods'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
// const refreshButton = `Refresh repositories (last update)`
|
||||||
|
const refreshButton = `Refresh repositories`
|
||||||
|
const choice = await showOptionsModal(`Manage repositories (${modsData?.repos.length ?? '-'} repos)`, ['Add repository', 'Remove repository', refreshButton])
|
||||||
|
switch (choice) {
|
||||||
|
case 'Add repository': {
|
||||||
|
await addRepositoryAction()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'Remove repository': {
|
||||||
|
await selectAndRemoveRepository()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case refreshButton: {
|
||||||
|
await fetchAllRepositories()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case undefined:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={pixelartIcons['list-box']}
|
||||||
|
title="Manage repositories"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className={styles.searchBar}
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search mods in added repositories..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsRow}>
|
||||||
|
{getStatsText()}
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.content} ${useHorizontalLayout ? '' : styles.verticalContent}`}>
|
||||||
|
<div className={styles.modList}>
|
||||||
|
{filteredMods ? (
|
||||||
|
<>
|
||||||
|
{filteredMods.repos.map(repo => (
|
||||||
|
<div key={repo.url}>
|
||||||
|
<div
|
||||||
|
className={styles.repoHeader}
|
||||||
|
onClick={() => toggleRepo(repo.url)}
|
||||||
|
>
|
||||||
|
<span>{expandedRepos[repo.url] ? '▼' : '▶'}</span>
|
||||||
|
<span>{repo.name || repo.url}</span>
|
||||||
|
<span>({repo.packages.length})</span>
|
||||||
|
</div>
|
||||||
|
{expandedRepos[repo.url] && (
|
||||||
|
<div className={styles.repoContent}>
|
||||||
|
{repo.packages.map((mod) => (
|
||||||
|
<ModListItem
|
||||||
|
key={mod.name}
|
||||||
|
mod={mod}
|
||||||
|
onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))}
|
||||||
|
hasError={errors[mod.name]?.length > 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredMods.modsWithoutRepos.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className={styles.repoHeader}>
|
||||||
|
<span>▼</span>
|
||||||
|
<span>Other Mods</span>
|
||||||
|
<span>({filteredMods.modsWithoutRepos.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.repoContent}>
|
||||||
|
{filteredMods.modsWithoutRepos.map(mod => (
|
||||||
|
<ModListItem
|
||||||
|
key={mod.name}
|
||||||
|
mod={mod}
|
||||||
|
onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))}
|
||||||
|
hasError={errors[mod.name]?.length > 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.modRowInfo}>Loading mods...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<ModSidebar mod={selectedMod} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Screen>
|
||||||
|
}
|
||||||
|
|
@ -6,14 +6,15 @@ interface Props {
|
||||||
className?: string
|
className?: string
|
||||||
titleSelectable?: boolean
|
titleSelectable?: boolean
|
||||||
titleMarginTop?: number
|
titleMarginTop?: number
|
||||||
|
contentStyle?: React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop }: Props) => {
|
export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop, contentStyle }: Props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{backdrop === 'dirt' ? <div className='dirt-bg' /> : backdrop ? <div className="backdrop" /> : null}
|
{backdrop === 'dirt' ? <div className='dirt-bg' /> : backdrop ? <div className="backdrop" /> : null}
|
||||||
<div className={`fullscreen ${className}`} style={{ overflow: 'auto', ...style }}>
|
<div className={`fullscreen ${className}`} style={{ overflow: 'auto', ...style }}>
|
||||||
<div className="screen-content" style={titleMarginTop === undefined ? {} : { marginTop: titleMarginTop }}>
|
<div className="screen-content" style={{ ...contentStyle, ...(titleMarginTop === undefined ? {} : { marginTop: titleMarginTop }) }}>
|
||||||
<div className={`screen-title ${titleSelectable ? 'text-select' : ''}`}>{title}</div>
|
<div className={`screen-title ${titleSelectable ? 'text-select' : ''}`}>{title}</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -48,21 +48,33 @@ export const showOptionsModal = async <T extends string> (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type InputOption = {
|
export type InputOption = {
|
||||||
type: 'text' | 'checkbox'
|
type: 'text' | 'checkbox' | 'button'
|
||||||
defaultValue?: string | boolean
|
defaultValue?: string | boolean
|
||||||
label?: string
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
onButtonClick?: () => void
|
||||||
}
|
}
|
||||||
export const showInputsModal = async <T extends Record<string, InputOption>>(
|
export const showInputsModal = async <T extends Record<string, InputOption>>(
|
||||||
title: string,
|
title: string,
|
||||||
inputs: T,
|
inputs: T,
|
||||||
{ cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {}
|
{
|
||||||
|
cancel = true,
|
||||||
|
minecraftJsonMessage,
|
||||||
|
showConfirm = true
|
||||||
|
}: {
|
||||||
|
cancel?: boolean,
|
||||||
|
minecraftJsonMessage?
|
||||||
|
showConfirm?: boolean
|
||||||
|
} = {}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
[K in keyof T]: T[K] extends { type: 'text' }
|
[K in keyof T]: T[K] extends { type: 'text' }
|
||||||
? string
|
? string
|
||||||
: T[K] extends { type: 'checkbox' }
|
: T[K] extends { type: 'checkbox' }
|
||||||
? boolean
|
? boolean
|
||||||
: never
|
: T[K] extends { type: 'button' }
|
||||||
|
? string
|
||||||
|
: never
|
||||||
}> => {
|
}> => {
|
||||||
showModal({ reactType: 'general-select' })
|
showModal({ reactType: 'general-select' })
|
||||||
let minecraftJsonMessageParsed
|
let minecraftJsonMessageParsed
|
||||||
|
|
@ -81,7 +93,7 @@ export const showInputsModal = async <T extends Record<string, InputOption>>(
|
||||||
showCancel: cancel,
|
showCancel: cancel,
|
||||||
minecraftJsonMessage: minecraftJsonMessageParsed,
|
minecraftJsonMessage: minecraftJsonMessageParsed,
|
||||||
options: [],
|
options: [],
|
||||||
inputsConfirmButton: 'Confirm'
|
inputsConfirmButton: showConfirm ? 'Confirm' : ''
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -130,6 +142,7 @@ export default () => {
|
||||||
autoFocus
|
autoFocus
|
||||||
type='text'
|
type='text'
|
||||||
defaultValue={input.defaultValue as string}
|
defaultValue={input.defaultValue as string}
|
||||||
|
placeholder={input.placeholder}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
inputValues.current[key] = e.target.value
|
inputValues.current[key] = e.target.value
|
||||||
}}
|
}}
|
||||||
|
|
@ -148,6 +161,15 @@ export default () => {
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{input.type === 'button' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
resolveClose(inputValues.current)
|
||||||
|
input.onButtonClick?.()
|
||||||
|
}}
|
||||||
|
>{label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { BaseServerInfo } from './AddServerOrConnect'
|
||||||
|
|
||||||
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
|
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
|
||||||
const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : ''
|
const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : ''
|
||||||
|
const { localStorage } = window
|
||||||
|
|
||||||
export interface SavedProxiesData {
|
export interface SavedProxiesData {
|
||||||
proxies: string[]
|
proxies: string[]
|
||||||
|
|
@ -39,6 +40,8 @@ type StorageData = {
|
||||||
serversHistory: ServerHistoryEntry[]
|
serversHistory: ServerHistoryEntry[]
|
||||||
authenticatedAccounts: AuthenticatedAccount[]
|
authenticatedAccounts: AuthenticatedAccount[]
|
||||||
serversList: StoreServerItem[] | undefined
|
serversList: StoreServerItem[] | undefined
|
||||||
|
modsAutoUpdateLastCheck: number | undefined
|
||||||
|
firstModsPageVisit: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
|
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
|
||||||
|
|
@ -79,6 +82,8 @@ const defaultStorageData: StorageData = {
|
||||||
serversHistory: [],
|
serversHistory: [],
|
||||||
authenticatedAccounts: [],
|
authenticatedAccounts: [],
|
||||||
serversList: undefined,
|
serversList: undefined,
|
||||||
|
modsAutoUpdateLastCheck: undefined,
|
||||||
|
firstModsPageVisit: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setStorageDataOnAppConfigLoad = () => {
|
export const setStorageDataOnAppConfigLoad = () => {
|
||||||
|
|
@ -86,7 +91,6 @@ export const setStorageDataOnAppConfigLoad = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appStorage = proxy({ ...defaultStorageData })
|
export const appStorage = proxy({ ...defaultStorageData })
|
||||||
window.appStorage = appStorage
|
|
||||||
|
|
||||||
// Restore data from localStorage
|
// Restore data from localStorage
|
||||||
for (const key of Object.keys(defaultStorageData)) {
|
for (const key of Object.keys(defaultStorageData)) {
|
||||||
|
|
|
||||||
193
src/react/mods.module.css
Normal file
193
src/react/mods.module.css
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
padding-top: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsRow {
|
||||||
|
color: #999;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsRow {
|
||||||
|
color: #999;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchBar {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0; /* Important for Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.verticalContent {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verticalContent .modList {
|
||||||
|
height: 50%;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verticalContent .sidebar {
|
||||||
|
height: 50%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
min-height: 0; /* Important for Firefox */
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modInfoTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modInfoText {
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: #bcbcbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modRow:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modRowTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modRowInfo {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bcbcbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repoHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #bcbcbc;
|
||||||
|
font-size: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repoHeader:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repoContent {
|
||||||
|
margin-left: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mod state styles */
|
||||||
|
.modRow[data-enabled="false"] {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modRow[data-enabled="true"] {
|
||||||
|
color: lime;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modRow[data-enabled="true"] .modRowTitle {
|
||||||
|
color: lime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error state styles */
|
||||||
|
.modRow[data-has-error="true"] {
|
||||||
|
background: rgba(255, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modRow[data-has-error="true"] .modRowTitle {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modErrorList {
|
||||||
|
font-size: 8px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
margin-top: 5px;
|
||||||
|
padding-left: 10px;
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldEditorTextarea {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldEditorTextarea {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
25
src/react/mods.module.css.d.ts
vendored
Normal file
25
src/react/mods.module.css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// This file is automatically generated.
|
||||||
|
// Please do not change this file!
|
||||||
|
interface CssExports {
|
||||||
|
content: string;
|
||||||
|
fieldEditorTextarea: string;
|
||||||
|
header: string;
|
||||||
|
modActions: string;
|
||||||
|
modErrorList: string;
|
||||||
|
modInfo: string;
|
||||||
|
modInfoText: string;
|
||||||
|
modInfoTitle: string;
|
||||||
|
modList: string;
|
||||||
|
modRow: string;
|
||||||
|
modRowInfo: string;
|
||||||
|
modRowTitle: string;
|
||||||
|
repoContent: string;
|
||||||
|
repoHeader: string;
|
||||||
|
root: string;
|
||||||
|
searchBar: string;
|
||||||
|
sidebar: string;
|
||||||
|
statsRow: string;
|
||||||
|
verticalContent: string;
|
||||||
|
}
|
||||||
|
declare const cssExports: CssExports;
|
||||||
|
export default cssExports;
|
||||||
|
|
@ -45,6 +45,7 @@ import SignInMessageProvider from './react/SignInMessageProvider'
|
||||||
import BookProvider from './react/BookProvider'
|
import BookProvider from './react/BookProvider'
|
||||||
import { options } from './optionsStorage'
|
import { options } from './optionsStorage'
|
||||||
import BossBarOverlayProvider from './react/BossBarOverlayProvider'
|
import BossBarOverlayProvider from './react/BossBarOverlayProvider'
|
||||||
|
import ModsPage from './react/ModsPage'
|
||||||
import DebugEdges from './react/DebugEdges'
|
import DebugEdges from './react/DebugEdges'
|
||||||
import GameInteractionOverlay from './react/GameInteractionOverlay'
|
import GameInteractionOverlay from './react/GameInteractionOverlay'
|
||||||
import MineflayerPluginHud from './react/MineflayerPluginHud'
|
import MineflayerPluginHud from './react/MineflayerPluginHud'
|
||||||
|
|
@ -226,7 +227,6 @@ const App = () => {
|
||||||
<CreateWorldProvider />
|
<CreateWorldProvider />
|
||||||
<AppStatusProvider />
|
<AppStatusProvider />
|
||||||
<KeybindingsScreenProvider />
|
<KeybindingsScreenProvider />
|
||||||
<SelectOption />
|
|
||||||
<ServersListProvider />
|
<ServersListProvider />
|
||||||
<OptionsRenderApp />
|
<OptionsRenderApp />
|
||||||
<MainMenuRenderApp />
|
<MainMenuRenderApp />
|
||||||
|
|
@ -235,6 +235,9 @@ const App = () => {
|
||||||
<SignInMessageProvider />
|
<SignInMessageProvider />
|
||||||
<PacketsReplayProvider />
|
<PacketsReplayProvider />
|
||||||
<NotificationProvider />
|
<NotificationProvider />
|
||||||
|
<ModsPage />
|
||||||
|
|
||||||
|
<SelectOption />
|
||||||
|
|
||||||
<NoModalFoundProvider />
|
<NoModalFoundProvider />
|
||||||
</RobustPortal>
|
</RobustPortal>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue