Merge branch 'mod_management'

This commit is contained in:
SpikeHD
2022-07-26 20:39:54 -07:00
43 changed files with 2100 additions and 275 deletions

View File

@@ -35,12 +35,29 @@ select:focus {
.TopButton {
height: 60%;
margin: 0px 10px;
color: #c5c5c5;
transition: color 0.1s ease-in-out;
}
.TopButton span {
display: inline-block;
line-height: normal;
border-bottom: 1px solid #c5c5c5;
}
.TopButton img {
filter: invert(95%) sepia(0%) saturate(18%) hue-rotate(153deg) brightness(88%) contrast(81%);
transition: filter 0.1s ease-in-out;
}
.TopButton:hover {
color: #fff;
cursor: pointer;
}
.TopButton:hover img {
filter: invert(100%) sepia(0%) saturate(18%) hue-rotate(153deg) brightness(100%) contrast(100%);
cursor: pointer;
}
@@ -97,6 +114,9 @@ select:focus {
padding: 0;
}
.ExtrasMenu {
}
@media (max-height: 580px) {
.BottomSection {
height: 150px;

View File

@@ -1,101 +1,33 @@
import React from 'react'
import { listen } from '@tauri-apps/api/event'
import './App.css'
import DownloadHandler from '../utils/download'
// Major Components
import TopBar from './components/TopBar'
import ServerLaunchSection from './components/ServerLaunchSection'
import MainProgressBar from './components/common/MainProgressBar'
import Options from './components/menu/Options'
import MiniDialog from './components/MiniDialog'
import DownloadList from './components/common/DownloadList'
import Downloads from './components/menu/Downloads'
import NewsSection from './components/news/NewsSection'
import Game from './components/menu/Game'
import RightBar from './components/RightBar'
import { getConfigOption, setConfigOption } from '../utils/configuration'
import { invoke } from '@tauri-apps/api'
import { dataDir } from '@tauri-apps/api/path'
import { appWindow } from '@tauri-apps/api/window'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { getConfigOption } from '../utils/configuration'
import { getTheme, loadTheme } from '../utils/themes'
import { unpatchGame } from '../utils/metadata'
interface IProps {
[key: string]: never
}
import { convertFileSrc, invoke } from '@tauri-apps/api/tauri'
import { dataDir } from '@tauri-apps/api/path'
import { Main } from './Main'
import { Mods } from './Mods'
interface IState {
isDownloading: boolean
optionsOpen: boolean
miniDownloadsOpen: boolean
downloadsOpen: boolean
gameDownloadsOpen: boolean
page: string
bgFile: string
}
const downloadHandler = new DownloadHandler()
const DEFAULT_BG = 'https://api.grasscutter.io/cultivation/bgfile'
const downloadHandler = new DownloadHandler()
class App extends React.Component<IProps, IState> {
constructor(props: IProps) {
class App extends React.Component<Readonly<unknown>, IState> {
constructor(props: Readonly<unknown>) {
super(props)
this.state = {
isDownloading: false,
optionsOpen: false,
miniDownloadsOpen: false,
downloadsOpen: false,
gameDownloadsOpen: false,
page: 'main',
bgFile: DEFAULT_BG,
}
listen('lang_error', (payload) => {
console.log(payload)
})
listen('jar_extracted', ({ payload }: { payload: string }) => {
setConfigOption('grasscutter_path', payload)
})
// Emitted for metadata replacing-purposes
listen('game_closed', async () => {
const wasPatched = await getConfigOption('patch_metadata')
if (wasPatched) {
const unpatched = await unpatchGame()
console.log(`unpatched game? ${unpatched}`)
if (!unpatched) {
alert(
`Could not unpatch game! (You should be able to find your metadata backup in ${await dataDir()}\\cultivation\\)`
)
}
}
})
let min = false
// periodically check if we need to min/max based on whether the game is open
setInterval(async () => {
const gameOpen = await invoke('is_game_running')
if (gameOpen && !min) {
appWindow.minimize()
min = true
} else if (!gameOpen && min) {
appWindow.unminimize()
min = false
}
}, 1000)
}
async componentDidMount() {
const cert_generated = await getConfigOption('cert_generated')
const game_exe = await getConfigOption('game_install_path')
const game_path = game_exe?.substring(0, game_exe.replace(/\\/g, '/').lastIndexOf('/')) || ''
const root_path = game_path?.substring(0, game_path.replace(/\\/g, '/').lastIndexOf('/')) || ''
@@ -155,21 +87,12 @@ class App extends React.Component<IProps, IState> {
}
}
if (!cert_generated) {
// Generate the certificate
await invoke('generate_ca_files', {
path: (await dataDir()) + 'cultivation',
})
await setConfigOption('cert_generated', true)
}
// Period check to only show progress bar when downloading files
setInterval(() => {
window.addEventListener('changePage', (e) => {
this.setState({
isDownloading: downloadHandler.getDownloads().filter((d) => d.status !== 'finished')?.length > 0,
// @ts-expect-error - TS doesn't like our custom event
page: e.detail,
})
}, 1000)
})
}
render() {
@@ -184,69 +107,14 @@ class App extends React.Component<IProps, IState> {
: {}
}
>
<TopBar
optFunc={() => {
this.setState({ optionsOpen: !this.state.optionsOpen })
}}
downFunc={() => this.setState({ downloadsOpen: !this.state.downloadsOpen })}
gameFunc={() => this.setState({ gameDownloadsOpen: !this.state.gameDownloadsOpen })}
/>
<RightBar />
<NewsSection />
{
// Mini downloads section
this.state.miniDownloadsOpen ? (
<div className="MiniDownloads" id="miniDownloadContainer">
<MiniDialog
title="Downloads"
closeFn={() => {
this.setState({ miniDownloadsOpen: false })
}}
>
<DownloadList downloadManager={downloadHandler} />
</MiniDialog>
<div className="arrow-down"></div>
</div>
) : null
}
{
// Download menu
this.state.downloadsOpen ? (
<Downloads downloadManager={downloadHandler} closeFn={() => this.setState({ downloadsOpen: false })} />
) : null
}
{
// Options menu
this.state.optionsOpen ? (
<Options
downloadManager={downloadHandler}
closeFn={() => this.setState({ optionsOpen: !this.state.optionsOpen })}
/>
) : null
}
{
// Game downloads menu
this.state.gameDownloadsOpen ? (
<Game downloadManager={downloadHandler} closeFn={() => this.setState({ gameDownloadsOpen: false })} />
) : null
}
<div className="BottomSection" id="bottomSectionContainer">
<ServerLaunchSection />
<div
id="DownloadProgress"
onClick={() => this.setState({ miniDownloadsOpen: !this.state.miniDownloadsOpen })}
>
{this.state.isDownloading ? <MainProgressBar downloadManager={downloadHandler} /> : null}
</div>
</div>
{(() => {
switch (this.state.page) {
case 'modding':
return <Mods downloadHandler={downloadHandler} />
default:
return <Main downloadHandler={downloadHandler} />
}
})()}
</div>
)
}

View File

@@ -44,7 +44,7 @@ class Debug extends React.Component {
render() {
return (
<div className="App">
<TopBar optFunc={none} downFunc={none} gameFunc={none} />
<TopBar />
<TextInput readOnly={false} initalValue={'change to set proxy address'} onChange={setProxyAddress} />
<button onClick={startProxy}>start proxy</button>
<button onClick={stopProxy}>stop proxy</button>

242
src/ui/Main.tsx Normal file
View File

@@ -0,0 +1,242 @@
import React from 'react'
// Major Components
import TopBar from './components/TopBar'
import ServerLaunchSection from './components/ServerLaunchSection'
import MainProgressBar from './components/common/MainProgressBar'
import Options from './components/menu/Options'
import MiniDialog from './components/MiniDialog'
import DownloadList from './components/common/DownloadList'
import Downloads from './components/menu/Downloads'
import NewsSection from './components/news/NewsSection'
import Game from './components/menu/Game'
import RightBar from './components/RightBar'
import { getConfigOption, setConfigOption } from '../utils/configuration'
import { invoke } from '@tauri-apps/api'
import { listen } from '@tauri-apps/api/event'
import { dataDir } from '@tauri-apps/api/path'
import { appWindow } from '@tauri-apps/api/window'
import { unpatchGame } from '../utils/metadata'
import DownloadHandler from '../utils/download'
// Graphics
import cogBtn from '../resources/icons/cog.svg'
import downBtn from '../resources/icons/download.svg'
import wrenchBtn from '../resources/icons/wrench.svg'
import Menu from './components/menu/Menu'
import { ExtrasMenu } from './components/menu/ExtrasMenu'
interface IProps {
downloadHandler: DownloadHandler
}
interface IState {
isDownloading: boolean
optionsOpen: boolean
miniDownloadsOpen: boolean
downloadsOpen: boolean
gameDownloadsOpen: boolean
extrasOpen: boolean
migotoSet: boolean
playGame: (exe?: string, proc_name?: string) => void
}
export class Main extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props)
this.state = {
isDownloading: false,
optionsOpen: false,
miniDownloadsOpen: false,
downloadsOpen: false,
gameDownloadsOpen: false,
extrasOpen: false,
migotoSet: false,
playGame: () => {
alert('Error launching game')
},
}
listen('lang_error', (payload) => {
console.log(payload)
})
listen('jar_extracted', ({ payload }: { payload: string }) => {
setConfigOption('grasscutter_path', payload)
})
// Emitted for metadata replacing-purposes
listen('game_closed', async () => {
const wasPatched = await getConfigOption('patch_metadata')
if (wasPatched) {
const unpatched = await unpatchGame()
if (!unpatched) {
alert(
`Could not unpatch game! (You should be able to find your metadata backup in ${await dataDir()}\\cultivation\\)`
)
}
}
})
let min = false
// periodically check if we need to min/max based on whether the game is open
setInterval(async () => {
const gameOpen = await invoke('is_game_running')
if (gameOpen && !min) {
appWindow.minimize()
min = true
} else if (!gameOpen && min) {
appWindow.unminimize()
min = false
}
}, 1000)
this.openExtrasMenu = this.openExtrasMenu.bind(this)
}
async componentDidMount() {
const cert_generated = await getConfigOption('cert_generated')
this.setState({
migotoSet: !!(await getConfigOption('migoto_path')),
})
if (!cert_generated) {
// Generate the certificate
await invoke('generate_ca_files', {
path: (await dataDir()) + 'cultivation',
})
await setConfigOption('cert_generated', true)
}
// Period check to only show progress bar when downloading files
setInterval(() => {
this.setState({
isDownloading: this.props.downloadHandler.getDownloads().filter((d) => d.status !== 'finished')?.length > 0,
})
}, 1000)
}
async openExtrasMenu(playGame: () => void) {
this.setState({
extrasOpen: true,
playGame,
})
}
render() {
return (
<>
<TopBar>
<div
id="settingsBtn"
onClick={() => this.setState({ optionsOpen: !this.state.optionsOpen })}
className="TopButton"
>
<img src={cogBtn} alt="settings" />
</div>
<div
id="downloadsBtn"
className="TopButton"
onClick={() => this.setState({ downloadsOpen: !this.state.downloadsOpen })}
>
<img src={downBtn} alt="downloads" />
</div>
{this.state.migotoSet && (
<div
id="modsBtn"
onClick={() => {
// Create and dispatch a custom "openMods" event
const event = new CustomEvent('changePage', { detail: 'modding' })
window.dispatchEvent(event)
}}
className="TopButton"
>
<img src={wrenchBtn} alt="mods" />
</div>
)}
{/* <div id="gameBtn" className="TopButton" onClick={() => this.setState({ gameDownloadsOpen: !this.state.gameDownloadsOpen })}>
<img src={gameBtn} alt="game" />
</div> */}
</TopBar>
<RightBar />
<NewsSection />
{
// Extras section
this.state.extrasOpen && (
<ExtrasMenu closeFn={() => this.setState({ extrasOpen: false })} playGame={this.state.playGame}>
Yo
</ExtrasMenu>
)
}
{
// Mini downloads section
this.state.miniDownloadsOpen ? (
<div className="MiniDownloads" id="miniDownloadContainer">
<MiniDialog
title="Downloads"
closeFn={() => {
this.setState({ miniDownloadsOpen: false })
}}
>
<DownloadList downloadManager={this.props.downloadHandler} />
</MiniDialog>
<div className="arrow-down"></div>
</div>
) : null
}
{
// Download menu
this.state.downloadsOpen ? (
<Downloads
downloadManager={this.props.downloadHandler}
closeFn={() => this.setState({ downloadsOpen: false })}
/>
) : null
}
{
// Options menu
this.state.optionsOpen ? (
<Options
downloadManager={this.props.downloadHandler}
closeFn={() => this.setState({ optionsOpen: !this.state.optionsOpen })}
/>
) : null
}
{
// Game downloads menu
this.state.gameDownloadsOpen ? (
<Game
downloadManager={this.props.downloadHandler}
closeFn={() => this.setState({ gameDownloadsOpen: false })}
/>
) : null
}
<div className="BottomSection" id="bottomSectionContainer">
<ServerLaunchSection openExtras={this.openExtrasMenu} />
<div
id="DownloadProgress"
onClick={() => this.setState({ miniDownloadsOpen: !this.state.miniDownloadsOpen })}
>
{this.state.isDownloading ? <MainProgressBar downloadManager={this.props.downloadHandler} /> : null}
</div>
</div>
</>
)
}
}

41
src/ui/Mods.css Normal file
View File

@@ -0,0 +1,41 @@
.Mods {
backdrop-filter: blur(10px);
height: 90%;
width: 100%;
}
/* Stuff for the top bar progress bar */
.TopDownloads {
position: absolute;
top: 10px;
left: 35.5%;
width: 30%;
}
.TopDownloads .ProgressBar {
width: 100%;
height: 10px;
}
.ModMenu {
width: 40%;
}
.ModMenu .BigButton {
font-size: 16px;
padding: 6px 30px;
}
.ModDownloadList {
width: 80%;
}
.ModDownloadItem {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin: 10px;
}

169
src/ui/Mods.tsx Normal file
View File

@@ -0,0 +1,169 @@
import { invoke } from '@tauri-apps/api'
import React from 'react'
import DownloadHandler from '../utils/download'
import { getModDownload, ModData } from '../utils/gamebanana'
import { getModsFolder } from '../utils/mods'
import { unzip } from '../utils/zipUtils'
import ProgressBar from './components/common/MainProgressBar'
import { ModHeader } from './components/mods/ModHeader'
import { ModList } from './components/mods/ModList'
import TopBar from './components/TopBar'
import './Mods.css'
import Back from '../resources/icons/back.svg'
import Menu from './components/menu/Menu'
import BigButton from './components/common/BigButton'
import Tr from '../utils/language'
interface IProps {
downloadHandler: DownloadHandler
}
interface IState {
isDownloading: boolean
category: string
downloadList: { name: string; url: string; mod: ModData }[] | null
}
const headers = [
{
name: 'ripe',
title: 'Hot',
},
{
name: 'new',
title: 'New',
},
{
name: 'installed',
title: 'Installed',
},
]
/**
* Mods currently install into folder labelled with their GB ID
*
* @TODO Categorizaiton/sorting (by likes, views, etc)
*/
export class Mods extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props)
this.state = {
isDownloading: false,
category: '',
downloadList: null,
}
this.setCategory = this.setCategory.bind(this)
this.addDownload = this.addDownload.bind(this)
}
async addDownload(mod: ModData) {
const dlLinks = await getModDownload(String(mod.id))
// Not gonna bother allowing sorting for now
const firstLink = dlLinks[0].downloadUrl
const fileExt = firstLink.split('.').pop()
const modName = `${mod.id}.${fileExt}`
if (dlLinks.length === 0) return
// If there is one download we don't care to choose
if (dlLinks.length === 1) {
this.downloadMod(firstLink, modName, mod)
return
}
this.setState({
downloadList: dlLinks.map((link) => ({
name: link.filename,
url: link.downloadUrl,
mod: mod,
})),
})
}
async downloadMod(link: string, modName: string, mod: ModData) {
const modFolder = await getModsFolder()
const path = `${modFolder}/${modName}`
if (!modFolder) return
this.props.downloadHandler.addDownload(link, path, async () => {
const unzipRes = await unzip(path, modFolder, false, true)
// Write a modinfo.json file
invoke('write_file', {
path: `${unzipRes.new_folder}/modinfo.json`,
contents: JSON.stringify(mod),
})
})
}
async setCategory(value: string) {
this.setState(
{
category: value,
},
this.forceUpdate
)
}
render() {
return (
<div className="Mods">
<TopBar>
<div
id="backbtn"
className="TopButton"
onClick={() => {
// Create and dispatch a custom "changePage" event
const event = new CustomEvent('changePage', { detail: 'main' })
window.dispatchEvent(event)
}}
>
<img src={Back} alt="back" />
</div>
</TopBar>
{this.state.downloadList && (
<Menu className="ModMenu" heading="Links" closeFn={() => this.setState({ downloadList: null })}>
<div className="ModDownloadList">
{this.state.downloadList.map((o) => {
return (
<div className="ModDownloadItem" key={o.name}>
<div className="ModDownloadName">{o.name}</div>
<BigButton
id={o.url}
onClick={() => {
const fileExt = o.url.split('.').pop()
const modName = `${o.mod.id}.${fileExt}`
this.downloadMod(o.url, modName, o.mod)
this.setState({
downloadList: null,
})
}}
>
<Tr text="components.download" />
</BigButton>
</div>
)
})}
</div>
</Menu>
)}
<div className="TopDownloads">
<ProgressBar downloadManager={this.props.downloadHandler} withStats={false} />
</div>
<ModHeader onChange={this.setCategory} headers={headers} defaultHeader={'ripe'} />
<ModList key={this.state.category} mode={this.state.category} addDownload={this.addDownload} />
</div>
)
}
}

View File

@@ -82,7 +82,12 @@
width: 5%;
}
.AkebiIcon,
#ExtrasMenuButton {
width: 5%;
padding: 0 20px;
}
.ExtrasIcon,
.ServerIcon {
height: 20px;
filter: invert(28%) sepia(28%) saturate(1141%) hue-rotate(352deg) brightness(96%) contrast(88%);

View File

@@ -8,13 +8,17 @@ import { translate } from '../../utils/language'
import { invoke } from '@tauri-apps/api/tauri'
import Server from '../../resources/icons/server.svg'
import Akebi from '../../resources/icons/akebi.svg'
import Plus from '../../resources/icons/plus.svg'
import './ServerLaunchSection.css'
import { dataDir } from '@tauri-apps/api/path'
import { getGameExecutable } from '../../utils/game'
import { patchGame, unpatchGame } from '../../utils/metadata'
interface IProps {
openExtras: (playGame: () => void) => void
}
interface IState {
grasscutterEnabled: boolean
buttonLabel: string
@@ -31,10 +35,12 @@ interface IState {
httpsEnabled: boolean
swag: boolean
akebiSet: boolean
migotoSet: boolean
}
export default class ServerLaunchSection extends React.Component<{}, IState> {
constructor(props: {}) {
export default class ServerLaunchSection extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props)
this.state = {
@@ -49,11 +55,12 @@ export default class ServerLaunchSection extends React.Component<{}, IState> {
httpsLabel: '',
httpsEnabled: false,
swag: false,
akebiSet: false,
migotoSet: false,
}
this.toggleGrasscutter = this.toggleGrasscutter.bind(this)
this.playGame = this.playGame.bind(this)
this.launchAkebi = this.launchAkebi.bind(this)
this.setIp = this.setIp.bind(this)
this.setPort = this.setPort.bind(this)
this.toggleHttps = this.toggleHttps.bind(this)
@@ -74,6 +81,8 @@ export default class ServerLaunchSection extends React.Component<{}, IState> {
httpsLabel: await translate('main.https_enable'),
httpsEnabled: config.https_enabled || false,
swag: config.swag_mode || false,
akebiSet: config.akebi_path !== '',
migotoSet: config.migoto_path !== '',
})
}
@@ -182,16 +191,6 @@ export default class ServerLaunchSection extends React.Component<{}, IState> {
})
}
async launchAkebi() {
const config = await getConfig()
// Get game exe from game path, so we can watch it
const pathArr = config.game_install_path.replace(/\\/g, '/').split('/')
const gameExec = pathArr[pathArr.length - 1]
await this.playGame(config.akebi_path, gameExec)
}
setIp(text: string) {
this.setState({
ip: text,
@@ -265,11 +264,9 @@ export default class ServerLaunchSection extends React.Component<{}, IState> {
{this.state.buttonLabel}
</BigButton>
{this.state.swag && (
<>
<BigButton onClick={this.launchAkebi} id="akebiLaunch">
<img className="AkebiIcon" id="akebiIcon" src={Akebi} />
</BigButton>
</>
<BigButton onClick={() => this.props.openExtras(this.playGame)} id="ExtrasMenuButton">
<img className="ExtrasIcon" id="extrasIcon" src={Plus} />
</BigButton>
)}
<BigButton onClick={this.launchServer} id="serverLaunch">
<img className="ServerIcon" id="serverLaunchIcon" src={Server} />

View File

@@ -1,20 +1,15 @@
import React from 'react'
import { app } from '@tauri-apps/api'
import { appWindow } from '@tauri-apps/api/window'
import closeIcon from '../../resources/icons/close.svg'
import minIcon from '../../resources/icons/min.svg'
import cogBtn from '../../resources/icons/cog.svg'
import downBtn from '../../resources/icons/download.svg'
import { getConfig, setConfigOption } from '../../utils/configuration'
import Tr from '../../utils/language'
import './TopBar.css'
import { getConfig, setConfigOption } from '../../utils/configuration'
import closeIcon from '../../resources/icons/close.svg'
import minIcon from '../../resources/icons/min.svg'
interface IProps {
optFunc: () => void
downFunc: () => void
gameFunc: () => void
children?: React.ReactNode | React.ReactNode[]
}
interface IState {
@@ -110,15 +105,7 @@ export default class TopBar extends React.Component<IProps, IState> {
<div id="minBtn" onClick={this.handleMinimize} className="TopButton">
<img src={minIcon} alt="minimize" />
</div>
<div id="settingsBtn" onClick={this.props.optFunc} className="TopButton">
<img src={cogBtn} alt="settings" />
</div>
<div id="downloadsBtn" className="TopButton" onClick={this.props.downFunc}>
<img src={downBtn} alt="downloads" />
</div>
{/* <div id="gameBtn" className="TopButton" onClick={this.props.gameFunc}>
<img src={gameBtn} alt="game" />
</div> */}
{this.props.children}
</div>
</div>
)

View File

@@ -7,7 +7,7 @@ interface IProps {
label?: string
checked: boolean
onChange: () => void
id: string
id?: string
}
interface IState {

View File

@@ -68,7 +68,6 @@ export default class DirInput extends React.Component<IProps, IState> {
directory: true,
})
} else {
console.log(this.props.openFolder)
path = await open({
filters: [{ name: 'Files', extensions: this.props.extensions || ['*'] }],
defaultPath: this.props.openFolder,

View File

@@ -5,6 +5,7 @@ import './ProgressBar.css'
interface IProps {
downloadManager: DownloadHandler
withStats?: boolean
}
interface IState {
@@ -70,11 +71,13 @@ export default class ProgressBar extends React.Component<IProps, IState> {
></div>
</div>
<div className="MainProgressText">
<Tr text="main.files_downloading" /> {this.state.files} ({this.state.speed})
<br />
<Tr text="main.files_extracting" /> {this.state.extracting}
</div>
{(this.props.withStats === undefined || this.props.withStats) && (
<div className="MainProgressText">
<Tr text="main.files_downloading" /> {this.state.files} ({this.state.speed})
<br />
<Tr text="main.files_extracting" /> {this.state.extracting}
</div>
)}
</div>
)
}

View File

@@ -111,8 +111,9 @@ export default class Downloads extends React.Component<IProps, IState> {
async downloadGrasscutterStableRepo() {
const folder = await this.getGrasscutterFolder()
this.props.downloadManager.addDownload(STABLE_REPO_DOWNLOAD, folder + '\\grasscutter_repo.zip', () => {
unzip(folder + '\\grasscutter_repo.zip', folder + '\\', this.toggleButtons)
this.props.downloadManager.addDownload(STABLE_REPO_DOWNLOAD, folder + '\\grasscutter_repo.zip', async () => {
await unzip(folder + '\\grasscutter_repo.zip', folder + '\\', true)
this.toggleButtons()
})
this.toggleButtons()
@@ -120,8 +121,9 @@ export default class Downloads extends React.Component<IProps, IState> {
async downloadGrasscutterDevRepo() {
const folder = await this.getGrasscutterFolder()
this.props.downloadManager.addDownload(DEV_REPO_DOWNLOAD, folder + '\\grasscutter_repo.zip', () => {
unzip(folder + '\\grasscutter_repo.zip', folder + '\\', this.toggleButtons)
this.props.downloadManager.addDownload(DEV_REPO_DOWNLOAD, folder + '\\grasscutter_repo.zip', async () => {
await unzip(folder + '\\grasscutter_repo.zip', folder + '\\', true)
this.toggleButtons()
})
this.toggleButtons()
@@ -129,8 +131,9 @@ export default class Downloads extends React.Component<IProps, IState> {
async downloadGrasscutterStable() {
const folder = await this.getGrasscutterFolder()
this.props.downloadManager.addDownload(STABLE_DOWNLOAD, folder + '\\grasscutter.zip', () => {
unzip(folder + '\\grasscutter.zip', folder + '\\', this.toggleButtons)
this.props.downloadManager.addDownload(STABLE_DOWNLOAD, folder + '\\grasscutter.zip', async () => {
await unzip(folder + '\\grasscutter.zip', folder + '\\', true)
this.toggleButtons
})
// Also add repo download
@@ -141,8 +144,9 @@ export default class Downloads extends React.Component<IProps, IState> {
async downloadGrasscutterLatest() {
const folder = await this.getGrasscutterFolder()
this.props.downloadManager.addDownload(DEV_DOWNLOAD, folder + '\\grasscutter.zip', () => {
unzip(folder + '\\grasscutter.zip', folder + '\\', this.toggleButtons)
this.props.downloadManager.addDownload(DEV_DOWNLOAD, folder + '\\grasscutter.zip', async () => {
await unzip(folder + '\\grasscutter.zip', folder + '\\', true)
this.toggleButtons()
})
// Also add repo download
@@ -165,15 +169,14 @@ export default class Downloads extends React.Component<IProps, IState> {
})
}
await unzip(folder + '\\resources.zip', folder + '\\', () => {
// Rename folder to resources
invoke('rename', {
path: folder + '\\Resources',
newName: 'resources',
})
this.toggleButtons()
await unzip(folder + '\\resources.zip', folder + '\\', true)
// Rename folder to resources
invoke('rename', {
path: folder + '\\Resources',
newName: 'resources',
})
this.toggleButtons()
})
this.toggleButtons()

View File

@@ -0,0 +1,32 @@
.ExtrasMenu {
width: 20%;
height: 40%;
}
.ExtrasMenu .MenuInner {
justify-content: space-between;
height: 70%;
}
.ExtrasMenuContent {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.ExtraItem {
width: 80%;
padding: 6px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.ExtraLaunch .BigButton {
padding: 20px 50px;
}

View File

@@ -0,0 +1,178 @@
import React from 'react'
import { getConfig, saveConfig } from '../../../utils/configuration'
import Checkbox from '../common/Checkbox'
import Menu from './Menu'
import './ExtrasMenu.css'
import BigButton from '../common/BigButton'
import { invoke } from '@tauri-apps/api'
import Tr from '../../../utils/language'
import { getGameExecutable } from '../../../utils/game'
interface IProps {
children: React.ReactNode | React.ReactNode[]
closeFn: () => void
playGame: (exe?: string, proc_name?: string) => void
}
interface IState {
migoto?: string
akebi?: string
reshade?: string
launch_migoto: boolean
launch_akebi: boolean
launch_reshade: boolean
}
export class ExtrasMenu extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props)
this.state = {
launch_migoto: false,
launch_akebi: false,
launch_reshade: false,
}
this.launchPreprograms = this.launchPreprograms.bind(this)
this.toggleMigoto = this.toggleMigoto.bind(this)
this.toggleAkebi = this.toggleAkebi.bind(this)
this.toggleReshade = this.toggleReshade.bind(this)
}
async componentDidMount() {
const config = await getConfig()
this.setState({
migoto: config.migoto_path,
akebi: config.akebi_path,
reshade: config.reshade_path,
launch_akebi: config?.last_extras?.akebi ?? false,
launch_migoto: config?.last_extras?.migoto ?? false,
launch_reshade: config?.last_extras?.reshade ?? false,
})
}
async launchPreprograms() {
const config = await getConfig()
config.last_extras = {
migoto: this.state.launch_migoto,
akebi: this.state.launch_akebi,
reshade: this.state.launch_reshade,
}
await saveConfig(config)
// Close menu
this.props.closeFn()
// This injects independent of the game
if (this.state.launch_migoto) {
await this.launchMigoto()
}
// This injects independent of the game
if (this.state.launch_reshade) {
await this.launchReshade()
}
// This will launch the game
if (this.state.launch_akebi) {
await this.launchAkebi()
// This already launches the game
return
}
// Launch the game
await this.props.playGame()
}
async launchAkebi() {
const config = await getConfig()
// Get game exe from game path, so we can watch it
const pathArr = config.game_install_path.replace(/\\/g, '/').split('/')
const gameExec = pathArr[pathArr.length - 1]
await this.props.playGame(config.akebi_path, gameExec)
}
async launchMigoto() {
const config = await getConfig()
if (!config.migoto_path) return alert('Migoto not installed or set!')
await invoke('run_program_relative', { path: config.migoto_path })
}
async launchReshade() {
const config = await getConfig()
if (!config.reshade_path) return alert('Reshade not installed or set!')
await invoke('run_command', {
program: config.reshade_path,
args: [await getGameExecutable()],
})
}
toggleMigoto() {
this.setState({
launch_migoto: !this.state.launch_migoto,
})
}
toggleAkebi() {
this.setState({
launch_akebi: !this.state.launch_akebi,
})
}
toggleReshade() {
this.setState({
launch_reshade: !this.state.launch_reshade,
})
}
render() {
return (
<Menu closeFn={this.props.closeFn} heading="Extras" className="ExtrasMenu">
<div className="ExtrasMenuContent">
{this.state.migoto && (
<div className="ExtraItem">
<div className="ExtraItemLabel">
<Tr text="swag.migoto_name" />
</div>
<Checkbox id="MigotoCheckbox" checked={this.state.launch_migoto} onChange={this.toggleMigoto} />
</div>
)}
{this.state.akebi && (
<div className="ExtraItem">
<div className="ExtraItemLabel">
<Tr text="swag.akebi_name" />
</div>
<Checkbox id="AkebiCheckbox" checked={this.state.launch_akebi} onChange={this.toggleAkebi} />
</div>
)}
{this.state.reshade && (
<div className="ExtraItem">
<div className="ExtraItemLabel">
<Tr text="swag.reshade_name" />
</div>
<Checkbox id="ReshadeCheckbox" checked={this.state.launch_reshade} onChange={this.toggleReshade} />
</div>
)}
</div>
<div className="ExtraLaunch">
<BigButton id="ExtraLaunch" onClick={this.launchPreprograms}>
<Tr text="main.launch_button" />
</BigButton>
</div>
</Menu>
)
}
}

View File

@@ -45,11 +45,10 @@ export default class Downloads extends React.Component<IProps, IState> {
async downloadGame() {
const folder = this.state.gameDownloadFolder
this.props.downloadManager.addDownload(GAME_DOWNLOAD, folder + '\\game.zip', () => {
unzip(folder + '\\game.zip', folder + '\\', () => {
this.setState({
gameDownloading: false,
})
this.props.downloadManager.addDownload(GAME_DOWNLOAD, folder + '\\game.zip', async () => {
await unzip(folder + '\\game.zip', folder + '\\', true)
this.setState({
gameDownloading: false,
})
})

View File

@@ -38,6 +38,8 @@ interface IState {
// Swag stuff
akebi_path: string
migoto_path: string
reshade_path: string
}
export default class Options extends React.Component<IProps, IState> {
@@ -61,12 +63,15 @@ export default class Options extends React.Component<IProps, IState> {
// Swag stuff
akebi_path: '',
migoto_path: '',
reshade_path: '',
}
this.setGameExecutable = this.setGameExecutable.bind(this)
this.setGrasscutterJar = this.setGrasscutterJar.bind(this)
this.setJavaPath = this.setJavaPath.bind(this)
this.setAkebi = this.setAkebi.bind(this)
this.setMigoto = this.setMigoto.bind(this)
this.toggleGrasscutterWithGame = this.toggleGrasscutterWithGame.bind(this)
this.setCustomBackground = this.setCustomBackground.bind(this)
this.toggleEncryption = this.toggleEncryption.bind(this)
@@ -101,6 +106,8 @@ export default class Options extends React.Component<IProps, IState> {
// Swag stuff
akebi_path: config.akebi_path || '',
migoto_path: config.migoto_path || '',
reshade_path: config.reshade_path || '',
})
this.forceUpdate()
@@ -138,6 +145,22 @@ export default class Options extends React.Component<IProps, IState> {
})
}
setMigoto(value: string) {
setConfigOption('migoto_path', value)
this.setState({
migoto_path: value,
})
}
setReshade(value: string) {
setConfigOption('reshade_path', value)
this.setState({
reshade_path: value,
})
}
async setLanguage(value: string) {
await setConfigOption('language', value)
window.location.reload()
@@ -204,7 +227,6 @@ export default class Options extends React.Component<IProps, IState> {
}
async restoreMetadata() {
console.log(this.props)
await meta.restoreMetadata(this.props.downloadManager)
}
@@ -313,10 +335,26 @@ export default class Options extends React.Component<IProps, IState> {
<div className="OptionLabel" id="menuOptionsLabelAkebi">
<Tr text="swag.akebi" />
</div>
<div className="OptionValue" id="menuOptionsDirMigoto">
<div className="OptionValue" id="menuOptionsDirAkebi">
<DirInput onChange={this.setAkebi} value={this.state?.akebi_path} extensions={['exe']} />
</div>
</div>
<div className="OptionSection" id="menuOptionsContainerMigoto">
<div className="OptionLabel" id="menuOptionsLabelMigoto">
<Tr text="swag.migoto" />
</div>
<div className="OptionValue" id="menuOptionsDirMigoto">
<DirInput onChange={this.setMigoto} value={this.state?.migoto_path} extensions={['exe']} />
</div>
</div>
<div className="OptionSection" id="menuOptionsContainerReshade">
<div className="OptionLabel" id="menuOptionsLabelReshade">
<Tr text="swag.reshade" />
</div>
<div className="OptionValue" id="menuOptionsDirReshade">
<DirInput onChange={this.setReshade} value={this.state?.reshade_path} extensions={['exe']} />
</div>
</div>
</>
)}

View File

@@ -0,0 +1,39 @@
/**
* Blatantly yoinked from https://loading.io/css/
*/
.LoadingCircle {
display: inline-block;
transform: translateZ(1px);
position: absolute;
top: 45%;
left: 48.5%;
}
.LoadingCircle > div {
display: inline-block;
width: 64px;
height: 64px;
margin: 8px;
border-radius: 50%;
background: #fff;
animation: loading 2.4s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
@keyframes loading {
0%,
100% {
animation-timing-function: cubic-bezier(0.5, 0, 1, 0.5);
}
0% {
transform: rotateY(0deg);
}
50% {
transform: rotateY(1800deg);
animation-timing-function: cubic-bezier(0, 0.5, 0.5, 1);
}
100% {
transform: rotateY(3600deg);
}
}

View File

@@ -0,0 +1,13 @@
import React from 'react'
import './LoadingCircle.css'
export class LoadingCircle extends React.Component {
render() {
return (
<div className="LoadingCircle">
<div></div>
</div>
)
}
}

View File

@@ -0,0 +1,30 @@
.ModHeader {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
width: 100%;
padding: 10px;
font-size: 20px;
font-weight: bold;
color: #fff;
background: rgba(77, 77, 77, 0.6);
}
.ModHeaderTitle {
display: flex;
justify-content: center;
width: 100%;
max-width: 20%;
}
.ModHeaderTitle:hover {
cursor: pointer;
}
.ModHeaderTitle.selected {
border-bottom: 2px solid #fff;
}

View File

@@ -0,0 +1,52 @@
import React from 'react'
import './ModHeader.css'
interface IProps {
headers: {
title: string
name: string
}[]
onChange: (value: string) => void
defaultHeader: string
}
interface IState {
selected: string
}
export class ModHeader extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props)
this.state = {
selected: this.props.defaultHeader,
}
}
setSelected(value: string) {
this.setState({
selected: value,
})
this.props.onChange(value)
}
render() {
return (
<div className="ModHeader">
{this.props.headers.map((header, index) => {
return (
<div
key={index}
className={`ModHeaderTitle ${this.state.selected === header.name ? 'selected' : ''}`}
onClick={() => this.setSelected(header.name)}
>
{header.title}
</div>
)
})}
</div>
)
}
}

View File

@@ -0,0 +1,19 @@
.ModList {
width: 100%;
height: 100%;
background-color: rgba(106, 105, 106, 0.6);
}
.ModListInner {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
flex-grow: 1;
align-items: center;
justify-content: center;
flex-wrap: wrap;
overflow-y: auto;
}

View File

@@ -0,0 +1,93 @@
import React from 'react'
import { getInstalledMods, getMods, ModData, PartialModData } from '../../../utils/gamebanana'
import { LoadingCircle } from './LoadingCircle'
import './ModList.css'
import { ModTile } from './ModTile'
interface IProps {
mode: string
addDownload: (mod: ModData) => void
}
interface IState {
modList: ModData[] | null
installedList:
| {
path: string
info: ModData | PartialModData
}[]
| null
}
export class ModList extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props)
this.state = {
modList: null,
installedList: null,
}
this.downloadMod = this.downloadMod.bind(this)
}
async componentDidMount() {
if (this.props.mode === 'installed') {
const installedMods = (await getInstalledMods()).map((mod) => {
// Check if it's a partial mod, and if so, fill in some pseudo-data
if (!('id' in mod.info)) {
const newInfo = mod.info as PartialModData
newInfo.images = []
newInfo.submitter = { name: 'Unknown' }
newInfo.likes = 0
newInfo.views = 0
mod.info = newInfo
return mod
}
return mod
})
this.setState({
installedList: installedMods,
})
return
}
const mods = await getMods(this.props.mode)
this.setState({
modList: mods,
})
}
async downloadMod(mod: ModData) {
this.props.addDownload(mod)
}
render() {
return (
<div className="ModList">
{(this.state.modList && this.props.mode !== 'installed') ||
(this.state.installedList && this.props.mode === 'installed') ? (
<div className="ModListInner">
{this.props.mode === 'installed'
? this.state.installedList?.map((mod) => (
<ModTile path={mod.path} mod={mod.info} key={mod.info.name} onClick={this.downloadMod} />
))
: this.state.modList?.map((mod: ModData) => (
<ModTile mod={mod} key={mod.id} onClick={this.downloadMod} />
))}
</div>
) : (
<LoadingCircle />
)}
</div>
)
}
}

View File

@@ -0,0 +1,137 @@
.ModListItem {
display: flex;
flex-direction: column;
align-items: center;
width: 23%;
margin: 10px;
background: rgb(99, 98, 98, 0.2);
border-radius: 10px;
backdrop-filter: blur(10px);
transition: background-color 0.1s ease-in-out;
}
.ModListItem:hover {
cursor: pointer;
background: rgb(99, 98, 98, 0.8);
}
.ModAuthor,
.ModName {
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
padding: 10px 0 0 10px;
margin-left: 10px;
color: #fff;
font-weight: bold;
}
.ModAuthor {
font-weight: normal;
padding: 0 0 0 10px;
}
.ModTileOpen {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.ModListItem .Checkbox {
filter: invert(100%) sepia(2%) saturate(201%) hue-rotate(47deg) brightness(117%) contrast(100%);
}
.ModImage .ModTileFolder {
width: 40px !important;
height: 40px !important;
margin: 20px;
}
.ModTileFolder:hover {
cursor: pointer;
filter: invert(1) brightness(0.2);
}
.ModTileOpen,
.ModTileDownload {
position: absolute;
object-fit: contain;
left: 40%;
top: 45%;
z-index: 999;
width: 40px !important;
height: 40px !important;
filter: invert(1);
}
.ModTileOpen {
left: 37.5%;
}
.ModImage {
width: 100%;
height: 100%;
object-fit: cover;
display: flex;
align-items: center;
justify-content: center;
}
.ModImage .ModImageInner {
object-fit: cover;
width: 100%;
height: 150px;
margin: 14px;
}
img.blur {
filter: blur(6px);
}
img.nsfw {
filter: blur(16px);
}
.ModInner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
color: #fff;
padding-bottom: 10px;
}
.ModInner div {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 0px 8px;
}
.ModInner div span {
margin: 0px 5px;
}
.ModInner img {
object-fit: contain;
width: 20px;
height: 20px;
margin: 0px;
filter: invert(1);
}

View File

@@ -0,0 +1,126 @@
import React from 'react'
import { ModData, PartialModData } from '../../../utils/gamebanana'
import './ModTile.css'
import Like from '../../../resources/icons/like.svg'
import Eye from '../../../resources/icons/eye.svg'
import Download from '../../../resources/icons/download.svg'
import Folder from '../../../resources/icons/folder.svg'
import { shell } from '@tauri-apps/api'
import Checkbox from '../common/Checkbox'
import { disableMod, enableMod, modIsEnabled } from '../../../utils/mods'
interface IProps {
mod: ModData | PartialModData
path?: string
onClick: (mod: ModData) => void
}
interface IState {
hover: boolean
modEnabled: boolean
}
export class ModTile extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props)
this.state = {
hover: false,
modEnabled: false,
}
this.openInExplorer = this.openInExplorer.bind(this)
this.toggleMod = this.toggleMod.bind(this)
}
getModFolderName() {
if (!('id' in this.props.mod)) {
return this.props.mod.name.includes('DISABLED_') ? this.props.mod.name.split('DISABLED_')[1] : this.props.mod.name
}
return String(this.props.mod.id)
}
async componentDidMount() {
if (!('id' in this.props.mod)) {
// Partial mod
this.setState({
modEnabled: await modIsEnabled(this.props.mod.name),
})
return
}
this.setState({
modEnabled: await modIsEnabled(String(this.props.mod.id)),
})
}
async openInExplorer() {
if (this.props.path) shell.open(this.props.path)
}
toggleMod() {
this.setState(
{
modEnabled: !this.state.modEnabled,
},
() => {
if (this.state.modEnabled) {
enableMod(String(this.getModFolderName()))
return
}
disableMod(String(this.getModFolderName()))
}
)
}
render() {
const { mod } = this.props
return (
<div
className="ModListItem"
onMouseEnter={() => this.setState({ hover: true })}
onMouseLeave={() => this.setState({ hover: false })}
{...(!this.props.path && {
onClick: () => {
if (!('id' in mod)) return
this.props.onClick(mod)
},
})}
>
<span className="ModName">{mod.name.includes('DISABLED_') ? mod.name.split('DISABLED_')[1] : mod.name}</span>
<span className="ModAuthor">{mod.submitter.name}</span>
<div className="ModImage">
{this.state.hover &&
(!this.props.path ? (
<img src={Download} className="ModTileDownload" alt="Download" />
) : (
<div className="ModTileOpen">
<img src={Folder} className="ModTileFolder" alt="Open" onClick={this.openInExplorer} />
<Checkbox checked={this.state.modEnabled} id={this.props.mod.name} onChange={this.toggleMod} />
</div>
))}
<img
src={mod.images[0]}
className={`ModImageInner ${'id' in mod && mod.nsfw ? 'nsfw' : ''} ${this.state.hover ? 'blur' : ''}`}
/>
</div>
<div className="ModInner">
<div className="likes">
<img src={Like} />
<span>{mod.likes.toLocaleString()}</span>
</div>
<div className="views">
<img src={Eye} />
<span>{mod.views.toLocaleString()}</span>
</div>
</div>
</div>
)
}
}