feat: Client side js mods. Modding! (#255)
This commit is contained in:
parent
109daa2783
commit
28faa9417a
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 { Vec3 } from 'vec3'
|
||||
import { proxy } from 'valtio'
|
||||
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
|
||||
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer'
|
||||
import { ProgressReporter } from '../../../src/core/progressReporter'
|
||||
import { showNotification } from '../../../src/react/NotificationProvider'
|
||||
import { WorldRendererThree } from './worldrendererThree'
|
||||
import { DocumentRenderer } from './documentRenderer'
|
||||
import { PanoramaRenderer } from './panorama'
|
||||
|
|
@ -53,12 +53,14 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
|||
let panoramaRenderer: PanoramaRenderer | null = null
|
||||
let worldRenderer: WorldRendererThree | null = null
|
||||
|
||||
const startPanorama = () => {
|
||||
const startPanorama = async () => {
|
||||
if (worldRenderer) return
|
||||
if (!panoramaRenderer) {
|
||||
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
|
||||
void panoramaRenderer.start()
|
||||
window.panoramaRenderer = panoramaRenderer
|
||||
callModsMethod('panoramaCreated', panoramaRenderer)
|
||||
await panoramaRenderer.start()
|
||||
callModsMethod('panoramaReady', panoramaRenderer)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +81,7 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
|||
worldRenderer?.render(sizeChanged)
|
||||
}
|
||||
window.world = worldRenderer
|
||||
callModsMethod('worldReady', worldRenderer)
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
|
|
@ -120,8 +123,24 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
|||
}
|
||||
}
|
||||
|
||||
globalThis.threeJsBackend = backend
|
||||
globalThis.resourcesManager = initOptions.resourcesManager
|
||||
callModsMethod('default', 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'
|
||||
export default createGraphicsBackend
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ const appConfig = defineConfig({
|
|||
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
|
||||
fs.copyFileSync('./assets/playground.html', './dist/playground.html')
|
||||
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')
|
||||
if (fs.existsSync('./assets/release.json')) {
|
||||
fs.copyFileSync('./assets/release.json', './dist/release.json')
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export type AppConfig = {
|
|||
defaultLanguage?: string
|
||||
displayLanguageSelector?: boolean
|
||||
supportedLanguages?: string[]
|
||||
showModsButton?: boolean
|
||||
}
|
||||
|
||||
export const loadAppConfig = (appConfig: AppConfig) => {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ export interface GraphicsBackend {
|
|||
}
|
||||
|
||||
export class AppViewer {
|
||||
waitBackendLoadPromises = [] as Array<Promise<void>>
|
||||
|
||||
resourcesManager = new ResourcesManager()
|
||||
worldView: WorldDataEmitter | undefined
|
||||
readonly config: GraphicsBackendConfig = {
|
||||
|
|
@ -114,11 +116,14 @@ export class AppViewer {
|
|||
this.disconnectBackend()
|
||||
}
|
||||
|
||||
loadBackend (loader: GraphicsBackendLoader) {
|
||||
async loadBackend (loader: GraphicsBackendLoader) {
|
||||
if (this.backend) {
|
||||
this.disconnectBackend()
|
||||
}
|
||||
|
||||
await Promise.all(this.waitBackendLoadPromises)
|
||||
this.waitBackendLoadPromises = []
|
||||
|
||||
this.backendLoader = loader
|
||||
const rendererSpecificSettings = {} as Record<string, any>
|
||||
const rendererSettingsKey = `renderer.${this.backendLoader?.id}`
|
||||
|
|
|
|||
|
|
@ -9,25 +9,27 @@ import { showNotification } from './react/NotificationProvider'
|
|||
const backends = [
|
||||
createGraphicsBackend,
|
||||
]
|
||||
const loadBackend = () => {
|
||||
const loadBackend = async () => {
|
||||
let backend = backends.find(backend => backend.id === options.activeRenderer)
|
||||
if (!backend) {
|
||||
showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true)
|
||||
backend = backends[0]
|
||||
}
|
||||
appViewer.loadBackend(backend)
|
||||
await appViewer.loadBackend(backend)
|
||||
}
|
||||
window.loadBackend = loadBackend
|
||||
if (process.env.SINGLE_FILE_BUILD_MODE) {
|
||||
const unsub = subscribeKey(miscUiState, 'fsReady', () => {
|
||||
if (miscUiState.fsReady) {
|
||||
// don't do it earlier to load fs and display menu faster
|
||||
loadBackend()
|
||||
void loadBackend()
|
||||
unsub()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
loadBackend()
|
||||
setTimeout(() => {
|
||||
void loadBackend()
|
||||
})
|
||||
}
|
||||
|
||||
const animLoop = () => {
|
||||
|
|
@ -40,10 +42,10 @@ watchOptionsAfterViewerInit()
|
|||
|
||||
// reset backend when renderer changes
|
||||
|
||||
subscribeKey(options, 'activeRenderer', () => {
|
||||
subscribeKey(options, 'activeRenderer', async () => {
|
||||
if (appViewer.currentDisplay === 'world' && bot) {
|
||||
appViewer.resetBackend(true)
|
||||
loadBackend()
|
||||
await loadBackend()
|
||||
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 { appStatusState } from '../react/AppStatusProvider'
|
||||
import { hideNotification, showNotification } from '../react/NotificationProvider'
|
||||
import { pixelartIcons } from '../react/PixelartIcon'
|
||||
|
||||
export interface ProgressReporter {
|
||||
currentMessage: string | undefined
|
||||
|
|
@ -170,7 +171,7 @@ export const createNotificationProgressReporter = (endMessage?: string): Progres
|
|||
},
|
||||
end () {
|
||||
if (endMessage) {
|
||||
showNotification(endMessage, '', false, '', undefined, true)
|
||||
showNotification(endMessage, '', false, pixelartIcons.check, undefined, true)
|
||||
} else {
|
||||
hideNotification(id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ module.exports = {
|
|||
'gameMode': 0,
|
||||
'difficulty': 0,
|
||||
'worldFolder': 'world',
|
||||
'pluginsFolder': true,
|
||||
// todo set sid, disable entities auto-spawn
|
||||
'generation': {
|
||||
// grass_field
|
||||
|
|
|
|||
15
src/index.ts
15
src/index.ts
|
|
@ -81,6 +81,7 @@ import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerCon
|
|||
import { mainMenuState } from './react/MainMenuRenderApp'
|
||||
import './mobileShim'
|
||||
import { parseFormattedMessagePacket } from './botUtils'
|
||||
import { appStartup } from './clientMods'
|
||||
import { getViewerVersionData, getWsProtocolStream, onBotCreatedViewerHandler } from './viewerConnector'
|
||||
import { getWebsocketStream } from './mineflayer/websocket-core'
|
||||
import { appQueryParams, appQueryParamsArray } from './appParams'
|
||||
|
|
@ -95,6 +96,7 @@ import './appViewerLoad'
|
|||
import { registerOpenBenchmarkListener } from './benchmark'
|
||||
import { tryHandleBuiltinCommand } from './builtinCommands'
|
||||
import { loadingTimerState } from './react/LoadingTimer'
|
||||
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
|
||||
|
||||
window.debug = debug
|
||||
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)
|
||||
// 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)
|
||||
connectOptions?.connectEvents?.serverCreated?.()
|
||||
// todo need just to call quit if started
|
||||
|
|
@ -669,7 +681,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
|
||||
bot.once('login', () => {
|
||||
loadingTimerState.networkOnlyStart = 0
|
||||
setLoadingScreenStatus('Loading world')
|
||||
progress.setMessage('Loading world')
|
||||
})
|
||||
|
||||
let worldWasReady = false
|
||||
|
|
@ -984,4 +996,5 @@ if (initialLoader) {
|
|||
window.pageLoaded = true
|
||||
|
||||
void possiblyHandleStateVariable()
|
||||
appViewer.waitBackendLoadPromises.push(appStartup())
|
||||
registerOpenBenchmarkListener()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { openFilePicker, resetLocalStorage } from './browserfs'
|
|||
import { completeResourcepackPackInstall, getResourcePackNames, resourcepackReload, resourcePackState, uninstallResourcePack } from './resourcePack'
|
||||
import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy'
|
||||
import { showInputsModal, showOptionsModal } from './react/SelectOption'
|
||||
import { modsUpdateStatus } from './clientMods'
|
||||
import supportedVersions from './supportedVersions.mjs'
|
||||
import { getVersionAutoSelect } from './connect'
|
||||
import { createNotificationProgressReporter } from './core/progressReporter'
|
||||
|
|
@ -227,6 +228,15 @@ export const guiOptionsScheme: {
|
|||
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 () {
|
||||
return <Button label='VR...' onClick={() => openOptionsMenu('VR')} inScreen />
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ const defaultOptions = {
|
|||
// todo ui setting, maybe enable by default?
|
||||
waitForChunksRender: false as 'sp-only' | boolean,
|
||||
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
|
||||
modsSupport: false,
|
||||
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
|
||||
modsUpdatePeriodCheck: 24, // hours
|
||||
preventBackgroundTimeoutKick: false,
|
||||
preventSleep: false,
|
||||
debugContro: false,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
import { filesize } from 'filesize'
|
||||
import { getAvailableServerPlugins } from '../clientMods'
|
||||
import { showModal } from '../globalState'
|
||||
import Input from './Input'
|
||||
import Screen from './Screen'
|
||||
import Button from './Button'
|
||||
import SelectGameVersion from './SelectGameVersion'
|
||||
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'/* , 'void' */]
|
||||
|
|
@ -15,13 +18,14 @@ export const creatingWorldState = proxy({
|
|||
title: '',
|
||||
type: worldTypes[0],
|
||||
gameMode: gameModes[0],
|
||||
version: ''
|
||||
version: '',
|
||||
plugins: [] as string[]
|
||||
})
|
||||
|
||||
export default ({ cancelClick, createClick, customizeClick, versions, defaultVersion }) => {
|
||||
const [quota, setQuota] = useState('')
|
||||
|
||||
const { title, type, version, gameMode } = useSnapshot(creatingWorldState)
|
||||
const { title, type, version, gameMode, plugins } = useSnapshot(creatingWorldState)
|
||||
useEffect(() => {
|
||||
creatingWorldState.version = defaultVersion
|
||||
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]
|
||||
}}
|
||||
>
|
||||
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>
|
||||
</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
|
||||
</Button>
|
||||
<Button disabled={!title} onClick={createClick}>Create</Button>
|
||||
<Button disabled={!title} onClick={createClick}>
|
||||
<b>
|
||||
Create
|
||||
</b>
|
||||
</Button>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { hideCurrentModal, showModal } from '../globalState'
|
||||
import defaultLocalServerOptions from '../defaultLocalServerOptions'
|
||||
import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
|
||||
import supportedVersions from '../supportedVersions.mjs'
|
||||
import { getServerPlugin } from '../clientMods'
|
||||
import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld'
|
||||
import { getWorldsPath } from './SingleplayerProvider'
|
||||
import { useIsModalActive } from './utilsApp'
|
||||
|
|
@ -14,7 +17,7 @@ export default () => {
|
|||
const versions = Object.values(versionsPerMinor).map(x => {
|
||||
return {
|
||||
version: x,
|
||||
label: x === defaultLocalServerOptions.version ? `${x} (available offline)` : x
|
||||
label: x === defaultLocalServerOptions.version ? `${x} (default)` : x
|
||||
}
|
||||
})
|
||||
return <CreateWorld
|
||||
|
|
@ -24,10 +27,11 @@ export default () => {
|
|||
}}
|
||||
createClick={async () => {
|
||||
// create new world
|
||||
const { title, type, version, gameMode } = creatingWorldState
|
||||
const { title, type, version, gameMode, plugins } = creatingWorldState
|
||||
// todo display path in ui + disable if exist
|
||||
const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath())
|
||||
await mkdirRecursive(savePath)
|
||||
await loadPluginsIntoWorld(savePath, plugins)
|
||||
let generation
|
||||
if (type === 'flat') {
|
||||
generation = {
|
||||
|
|
@ -68,3 +72,16 @@ export default () => {
|
|||
}
|
||||
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}>
|
||||
<input
|
||||
ref={ref}
|
||||
className={styles.input}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
autoCorrect='off'
|
||||
|
|
@ -43,6 +42,7 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w
|
|||
spellCheck='false'
|
||||
style={{ ...validationStyle }}
|
||||
{...inputProps}
|
||||
className={styles.input + ' ' + (inputProps.className ?? '')}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
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
|
||||
titleSelectable?: boolean
|
||||
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 (
|
||||
<>
|
||||
{backdrop === 'dirt' ? <div className='dirt-bg' /> : backdrop ? <div className="backdrop" /> : null}
|
||||
<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>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,21 +48,33 @@ export const showOptionsModal = async <T extends string> (
|
|||
})
|
||||
}
|
||||
|
||||
type InputOption = {
|
||||
type: 'text' | 'checkbox'
|
||||
export type InputOption = {
|
||||
type: 'text' | 'checkbox' | 'button'
|
||||
defaultValue?: string | boolean
|
||||
label?: string
|
||||
placeholder?: string
|
||||
onButtonClick?: () => void
|
||||
}
|
||||
export const showInputsModal = async <T extends Record<string, InputOption>>(
|
||||
title: string,
|
||||
inputs: T,
|
||||
{ cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {}
|
||||
{
|
||||
cancel = true,
|
||||
minecraftJsonMessage,
|
||||
showConfirm = true
|
||||
}: {
|
||||
cancel?: boolean,
|
||||
minecraftJsonMessage?
|
||||
showConfirm?: boolean
|
||||
} = {}
|
||||
): Promise<{
|
||||
[K in keyof T]: T[K] extends { type: 'text' }
|
||||
? string
|
||||
: T[K] extends { type: 'checkbox' }
|
||||
? boolean
|
||||
: never
|
||||
: T[K] extends { type: 'button' }
|
||||
? string
|
||||
: never
|
||||
}> => {
|
||||
showModal({ reactType: 'general-select' })
|
||||
let minecraftJsonMessageParsed
|
||||
|
|
@ -81,7 +93,7 @@ export const showInputsModal = async <T extends Record<string, InputOption>>(
|
|||
showCancel: cancel,
|
||||
minecraftJsonMessage: minecraftJsonMessageParsed,
|
||||
options: [],
|
||||
inputsConfirmButton: 'Confirm'
|
||||
inputsConfirmButton: showConfirm ? 'Confirm' : ''
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -130,6 +142,7 @@ export default () => {
|
|||
autoFocus
|
||||
type='text'
|
||||
defaultValue={input.defaultValue as string}
|
||||
placeholder={input.placeholder}
|
||||
onChange={(e) => {
|
||||
inputValues.current[key] = e.target.value
|
||||
}}
|
||||
|
|
@ -148,6 +161,15 @@ export default () => {
|
|||
{label}
|
||||
</label>
|
||||
)}
|
||||
{input.type === 'button' && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
resolveClose(inputValues.current)
|
||||
input.onButtonClick?.()
|
||||
}}
|
||||
>{label}
|
||||
</Button>
|
||||
)}
|
||||
</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
|
||||
const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : ''
|
||||
const { localStorage } = window
|
||||
|
||||
export interface SavedProxiesData {
|
||||
proxies: string[]
|
||||
|
|
@ -39,6 +40,8 @@ type StorageData = {
|
|||
serversHistory: ServerHistoryEntry[]
|
||||
authenticatedAccounts: AuthenticatedAccount[]
|
||||
serversList: StoreServerItem[] | undefined
|
||||
modsAutoUpdateLastCheck: number | undefined
|
||||
firstModsPageVisit: boolean
|
||||
}
|
||||
|
||||
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
|
||||
|
|
@ -79,6 +82,8 @@ const defaultStorageData: StorageData = {
|
|||
serversHistory: [],
|
||||
authenticatedAccounts: [],
|
||||
serversList: undefined,
|
||||
modsAutoUpdateLastCheck: undefined,
|
||||
firstModsPageVisit: true,
|
||||
}
|
||||
|
||||
export const setStorageDataOnAppConfigLoad = () => {
|
||||
|
|
@ -86,7 +91,6 @@ export const setStorageDataOnAppConfigLoad = () => {
|
|||
}
|
||||
|
||||
export const appStorage = proxy({ ...defaultStorageData })
|
||||
window.appStorage = appStorage
|
||||
|
||||
// Restore data from localStorage
|
||||
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 { options } from './optionsStorage'
|
||||
import BossBarOverlayProvider from './react/BossBarOverlayProvider'
|
||||
import ModsPage from './react/ModsPage'
|
||||
import DebugEdges from './react/DebugEdges'
|
||||
import GameInteractionOverlay from './react/GameInteractionOverlay'
|
||||
import MineflayerPluginHud from './react/MineflayerPluginHud'
|
||||
|
|
@ -226,7 +227,6 @@ const App = () => {
|
|||
<CreateWorldProvider />
|
||||
<AppStatusProvider />
|
||||
<KeybindingsScreenProvider />
|
||||
<SelectOption />
|
||||
<ServersListProvider />
|
||||
<OptionsRenderApp />
|
||||
<MainMenuRenderApp />
|
||||
|
|
@ -235,6 +235,9 @@ const App = () => {
|
|||
<SignInMessageProvider />
|
||||
<PacketsReplayProvider />
|
||||
<NotificationProvider />
|
||||
<ModsPage />
|
||||
|
||||
<SelectOption />
|
||||
|
||||
<NoModalFoundProvider />
|
||||
</RobustPortal>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue