mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00
WebUI (#100)
* Object-Oriented base changes for web-ui prep * remove debug raise * optimize broadcast to serialize once * Implement WebUI socket, static assets, and classes - Still need to wrap logging functions and send output to UI - UI commands are successfully being sent to the server * GUI operational. Wrap logging functions, implement server address selection on GUI, automatically launch web browser when client websocket is served * Update MultiServer status when a user disconnects / reconnects * Implement colored item and hint checks, improve GUI readability * Fix improper formatting on received items * Update SNES connection status on disconnect / reconnect. Implement itemFound, prevent accidentally printing JS objects * Minor text change for itemFound * Fixed a very wrong comment * Fixed client commands not working, fixed un-helpful error messages appearing in GUI * Fix a bug causing a failure to connect to a multiworld server if a previously existing cached address was present and the client was loaded without an address passed in * Convert WebUI to React /w Redux. WebSocket communications not yet operational. * WebUI fully converted to React / Redux. - Websocket communication operational - Added a button to connect to the multiserver which appears only when a SNES is connected and a server connection is not active * Restore some features lost in WebUI - Restore (found) notification on hints if the item has already been obtained - Restore (x/y) indicator on received items, which indicates the number of items the client is waiting to receive from the client in a queue * Fix a grammatical UI big causing player names to show only an apostrophe when possessive * Add support for multiple SNES Devices, and switching between them * freeze support for client * make sure flask works when frozen * UI Improvements - Hint messages now actually show a found status via ✔ and ❌ emoji - Active player name is always a different color than other players (orange for now) - Add a toggle to show only entries relevant to the active player - Added a WidgetArea - Added a notes widget * Received items now marked as relevant * Include production build for deployment * Notes now survive a browser close. Minimum width applied to monitor to prevent CSS issues. * include webUi folder in setup.py * Bugfixes for Monitor - Fix a bug causing the monitor window to grow beyond it's intended content limit - Reduced monitor content limit to 200 items - Ensured each monitor entry has a unique key * Prevent eslint from yelling at me about stupid things * Add button to collapse sidebar, press enter on empty server input to disconnect on purpose * WebUI is now aware of client disconnect, message log limit increased to 350, fix !missing output * Update WebUI to v2.2.1 - Added color to WebUI for entrance-span - Make !missing show total count at bottom of list to match /missing behavior * Fix a bug causing clients version <= 2.2.0 to crash when anyone asks for a hint - Also fix a bug in the WebUI causing the entrance location to always show as "somewhere" * Update WebUI color palette (this cost me $50) * allow text console input alongside web-ui * remove Flask a bit overkill for what we're doing * remove jinja2 * Update WebUI to work with new hosting mechanism * with flask gone, we no longer need subprocess shenanigans * If multiple web ui clients try to run, at least present a working console * Update MultiClient and WebUI to handle multiple clients simultaneously. - The port on which the websocket for the WebUI is hosted is not chosen randomly from 5000 - 5999. This port is passed to the browser so it knows which MultiClient to connect to - Removed failure condition if a web server is already running, as there is no need to run more than one web server on a single system. If an exception is thrown while attempting to launch a web server, a check is made for the port being unavailable. If the port is unavailable, it probably means the user is launching a second MultiClient. A web browser is then opened with a connection to the correct webui_socket_port. - Add a /web command to the MultiClient to repoen the appropriate browser window and get params in case a user accidentally closes the tab * Use proper name for WebUI * move webui into /data with other data files * make web ui optional This is mostly for laptop users wanting to preserve some battery, should not be needed outside of that. * fix direct server start * re-add connection timer * fix indentation Co-authored-by: Chris <chris@legendserver.info>
This commit is contained in:
4
data/web/.babelrc
Normal file
4
data/web/.babelrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-react", "@babel/preset-env"],
|
||||
"plugins": ["@babel/plugin-proposal-class-properties"]
|
||||
}
|
39
data/web/.eslintrc.js
Normal file
39
data/web/.eslintrc.js
Normal file
@@ -0,0 +1,39 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es6: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:react/recommended',
|
||||
'airbnb',
|
||||
],
|
||||
parser: 'babel-eslint',
|
||||
globals: {
|
||||
Atomics: 'readonly',
|
||||
SharedArrayBuffer: 'readonly',
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: [
|
||||
'react',
|
||||
],
|
||||
rules: {
|
||||
"react/jsx-filename-extension": 0,
|
||||
"react/jsx-one-expression-per-line": 0,
|
||||
"react/destructuring-assignment": 0,
|
||||
"react/jsx-curly-spacing": [2, { "when": "always" }],
|
||||
"react/prop-types": 0,
|
||||
"react/button-has-type": 0,
|
||||
"max-len": [2, { code: 120 }],
|
||||
"operator-linebreak": [2, "after"],
|
||||
"no-console": [2, { allow: ["error", "warn"] }],
|
||||
"linebreak-style": 0,
|
||||
"jsx-a11y/no-static-element-interactions": 0,
|
||||
"jsx-a11y/click-events-have-key-events": 0,
|
||||
},
|
||||
};
|
2
data/web/.gitignore
vendored
Normal file
2
data/web/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
*.map
|
8295
data/web/package-lock.json
generated
Normal file
8295
data/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
data/web/package.json
Normal file
47
data/web/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "web-ui",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.jsx",
|
||||
"scripts": {
|
||||
"build": "webpack --mode production --config webpack.config.js",
|
||||
"dev": "webpack --mode development --config webpack.dev.js --watch"
|
||||
},
|
||||
"author": "LegendaryLinux",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.28",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.13.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.9",
|
||||
"crypto-js": "^4.0.0",
|
||||
"css-loader": "^3.5.3",
|
||||
"lodash-es": "^4.17.15",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-redux": "^7.2.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"redux": "^4.0.5",
|
||||
"redux-devtools-extension": "^2.13.8",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^1.2.1",
|
||||
"webpack-cli": "^3.3.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.9.6",
|
||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||
"@babel/preset-env": "^7.9.6",
|
||||
"@babel/preset-react": "^7.9.4",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-loader": "^8.1.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-airbnb": "^18.1.0",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-react": "^7.19.0",
|
||||
"eslint-plugin-react-hooks": "^2.5.1",
|
||||
"file-loader": "^6.0.0",
|
||||
"node-sass": "^4.14.0",
|
||||
"webpack": "^4.43.0"
|
||||
}
|
||||
}
|
BIN
data/web/public/assets/fonts/HyliaSerif.otf
Normal file
BIN
data/web/public/assets/fonts/HyliaSerif.otf
Normal file
Binary file not shown.
44
data/web/public/assets/index.bundle.js
Normal file
44
data/web/public/assets/index.bundle.js
Normal file
File diff suppressed because one or more lines are too long
13
data/web/public/index.html
Normal file
13
data/web/public/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Berserker Multiworld Web GUI</title>
|
||||
<script type="application/ecmascript" src="assets/index.bundle.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Populated by React/JSX -->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
BIN
data/web/src/assets/HyliaSerif.otf
Normal file
BIN
data/web/src/assets/HyliaSerif.otf
Normal file
Binary file not shown.
BIN
data/web/src/assets/lttp-light-map.jpg
Normal file
BIN
data/web/src/assets/lttp-light-map.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 242 KiB |
10
data/web/src/js/HeaderBar/components/HeaderBar.js
Normal file
10
data/web/src/js/HeaderBar/components/HeaderBar.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import '../../../styles/HeaderBar/components/HeaderBar.scss';
|
||||
|
||||
const HeaderBar = () => (
|
||||
<div id="header-bar">
|
||||
Multiworld WebUI
|
||||
</div>
|
||||
);
|
||||
|
||||
export default HeaderBar;
|
8
data/web/src/js/Monitor/Redux/actions/appendMessage.js
Normal file
8
data/web/src/js/Monitor/Redux/actions/appendMessage.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const APPEND_MESSAGE = 'APPEND_MESSAGE';
|
||||
|
||||
const appendMessage = (content) => ({
|
||||
type: APPEND_MESSAGE,
|
||||
content,
|
||||
});
|
||||
|
||||
export default appendMessage;
|
@@ -0,0 +1,8 @@
|
||||
const SET_MONITOR_FONT_SIZE = 'SET_MONITOR_FONT_SIZE';
|
||||
|
||||
const setMonitorFontSize = (fontSize) => ({
|
||||
type: SET_MONITOR_FONT_SIZE,
|
||||
fontSize,
|
||||
});
|
||||
|
||||
export default setMonitorFontSize;
|
8
data/web/src/js/Monitor/Redux/actions/setShowRelevant.js
Normal file
8
data/web/src/js/Monitor/Redux/actions/setShowRelevant.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const SET_SHOW_RELEVANT = 'SET_SHOW_RELEVANT';
|
||||
|
||||
const setShowRelevant = (showRelevant) => ({
|
||||
type: SET_SHOW_RELEVANT,
|
||||
showRelevant,
|
||||
});
|
||||
|
||||
export default setShowRelevant;
|
36
data/web/src/js/Monitor/Redux/reducers/monitorReducer.js
Normal file
36
data/web/src/js/Monitor/Redux/reducers/monitorReducer.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import _assign from 'lodash-es/assign';
|
||||
|
||||
const initialState = {
|
||||
fontSize: 18,
|
||||
showRelevantOnly: false,
|
||||
messageLog: [],
|
||||
};
|
||||
|
||||
const appendToLog = (log, item) => {
|
||||
const trimmedLog = log.slice(-349);
|
||||
trimmedLog.push(item);
|
||||
return trimmedLog;
|
||||
};
|
||||
|
||||
const monitorReducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_MONITOR_FONT_SIZE':
|
||||
return _assign({}, state, {
|
||||
fontSize: action.fontSize,
|
||||
});
|
||||
|
||||
case 'SET_SHOW_RELEVANT':
|
||||
return _assign({}, state, {
|
||||
showRelevantOnly: action.showRelevant,
|
||||
});
|
||||
|
||||
case 'APPEND_MESSAGE':
|
||||
return _assign({}, state, {
|
||||
messageLog: appendToLog(state.messageLog, action.content),
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default monitorReducer;
|
13
data/web/src/js/Monitor/components/Monitor.js
Normal file
13
data/web/src/js/Monitor/components/Monitor.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import '../../../styles/Monitor/components/Monitor.scss';
|
||||
import MonitorControls from '../containers/MonitorControls';
|
||||
import MonitorWindow from '../containers/MonitorWindow';
|
||||
|
||||
const Monitor = () => (
|
||||
<div id="monitor">
|
||||
<MonitorControls />
|
||||
<MonitorWindow />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Monitor;
|
187
data/web/src/js/Monitor/containers/MonitorControls.js
Normal file
187
data/web/src/js/Monitor/containers/MonitorControls.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import _forEach from 'lodash-es/forEach';
|
||||
import WebSocketUtils from '../../global/WebSocketUtils';
|
||||
import '../../../styles/Monitor/containers/MonitorControls.scss';
|
||||
|
||||
// Redux actions
|
||||
import setMonitorFontSize from '../Redux/actions/setMonitorFontSize';
|
||||
import setShowRelevant from '../Redux/actions/setShowRelevant';
|
||||
|
||||
const mapReduxStateToProps = (reduxState) => ({
|
||||
fontSize: reduxState.monitor.fontSize,
|
||||
webSocket: reduxState.webUI.webSocket,
|
||||
availableDevices: reduxState.webUI.availableDevices,
|
||||
snesDevice: reduxState.gameState.connections.snesDevice,
|
||||
snesConnected: reduxState.gameState.connections.snesConnected,
|
||||
serverAddress: reduxState.gameState.connections.serverAddress,
|
||||
serverConnected: reduxState.gameState.connections.serverConnected,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
updateFontSize: (fontSize) => {
|
||||
dispatch(setMonitorFontSize(fontSize));
|
||||
},
|
||||
doToggleRelevance: (showRelevantOnly) => {
|
||||
dispatch(setShowRelevant(showRelevantOnly));
|
||||
},
|
||||
});
|
||||
|
||||
class MonitorControls extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
deviceId: null,
|
||||
serverAddress: this.props.serverAddress,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
if (this.props.webSocket) {
|
||||
// Poll for available devices
|
||||
this.pollSnesDevices();
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.props.availableDevices.length === 1) {
|
||||
this.setState({ deviceId: this.props.availableDevices[0] }, () => {
|
||||
if (!this.props.snesConnected) {
|
||||
this.connectToSnes();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
increaseTextSize = () => {
|
||||
if (this.props.fontSize >= 25) return;
|
||||
this.props.updateFontSize(this.props.fontSize + 1);
|
||||
};
|
||||
|
||||
decreaseTextSize = () => {
|
||||
if (this.props.fontSize <= 10) return;
|
||||
this.props.updateFontSize(this.props.fontSize - 1);
|
||||
};
|
||||
|
||||
generateSnesOptions = () => {
|
||||
const options = [];
|
||||
// No available devices, show waiting for devices
|
||||
if (this.props.availableDevices.length === 0) {
|
||||
options.push(<option key="0" value="-1">Waiting for devices...</option>);
|
||||
return options;
|
||||
}
|
||||
|
||||
// More than one available device, list all options
|
||||
options.push(<option key="-1" value="-1">Select a device</option>);
|
||||
_forEach(this.props.availableDevices, (device) => {
|
||||
options.push(<option key={ device } value={ device }>{device}</option>);
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
updateDeviceId = (event) => this.setState({ deviceId: event.target.value }, this.connectToSnes);
|
||||
|
||||
pollSnesDevices = () => {
|
||||
if (!this.props.webSocket) { return; }
|
||||
this.props.webSocket.send(WebSocketUtils.formatSocketData('webStatus', 'devices'));
|
||||
}
|
||||
|
||||
connectToSnes = () => {
|
||||
if (!this.props.webSocket) { return; }
|
||||
this.props.webSocket.send(WebSocketUtils.formatSocketData('webConfig', { deviceId: this.state.deviceId }));
|
||||
}
|
||||
|
||||
updateServerAddress = (event) => this.setState({ serverAddress: event.target.value ? event.target.value : null });
|
||||
|
||||
connectToServer = (event) => {
|
||||
if (event.key !== 'Enter') { return; }
|
||||
|
||||
// If the user presses enter on an empty textbox, disconnect from the server
|
||||
if (!event.target.value) {
|
||||
this.props.webSocket.send(WebSocketUtils.formatSocketData('webControl', 'disconnect'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.webSocket.send(
|
||||
WebSocketUtils.formatSocketData('webConfig', { serverAddress: this.state.serverAddress }),
|
||||
);
|
||||
}
|
||||
|
||||
toggleRelevance = (event) => {
|
||||
this.props.doToggleRelevance(event.target.checked);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="monitor-controls">
|
||||
<div id="connection-status">
|
||||
<div id="snes-connection">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>SNES Device:</td>
|
||||
<td>
|
||||
<select
|
||||
onChange={ this.updateDeviceId }
|
||||
disabled={ this.props.availableDevices.length === 0 }
|
||||
value={ this.state.deviceId }
|
||||
>
|
||||
{this.generateSnesOptions()}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status:</td>
|
||||
<td>
|
||||
<span className={ this.props.snesConnected ? 'connected' : 'not-connected' }>
|
||||
{this.props.snesConnected ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="server-connection">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Server:</td>
|
||||
<td>
|
||||
<input
|
||||
defaultValue={ this.props.serverAddress }
|
||||
onKeyUp={ this.updateServerAddress }
|
||||
onKeyDown={ this.connectToServer }
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status:</td>
|
||||
<td>
|
||||
<span className={ this.props.serverConnected ? 'connected' : 'not-connected' }>
|
||||
{this.props.serverConnected ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accessibility">
|
||||
<div>
|
||||
Text Size:
|
||||
<button disabled={ this.props.fontSize <= 10 } onClick={ this.decreaseTextSize }>-</button>
|
||||
{ this.props.fontSize }
|
||||
<button disabled={ this.props.fontSize >= 25 } onClick={ this.increaseTextSize }>+</button>
|
||||
</div>
|
||||
<div>
|
||||
Only show my items <input type="checkbox" onChange={ this.toggleRelevance } />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapReduxStateToProps, mapDispatchToProps)(MonitorControls);
|
96
data/web/src/js/Monitor/containers/MonitorWindow.js
Normal file
96
data/web/src/js/Monitor/containers/MonitorWindow.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import md5 from 'crypto-js/md5';
|
||||
import WebSocketUtils from '../../global/WebSocketUtils';
|
||||
import '../../../styles/Monitor/containers/MonitorWindow.scss';
|
||||
|
||||
// Redux actions
|
||||
import appendMessage from '../Redux/actions/appendMessage';
|
||||
|
||||
const mapReduxStateToProps = (reduxState) => ({
|
||||
fontSize: reduxState.monitor.fontSize,
|
||||
webSocket: reduxState.webUI.webSocket,
|
||||
messageLog: reduxState.monitor.messageLog,
|
||||
showRelevantOnly: reduxState.monitor.showRelevantOnly,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
doAppendMessage: (message) => dispatch(appendMessage(
|
||||
<div
|
||||
key={ `${md5(message)}${Math.floor((Math.random() * 1000000))}` }
|
||||
className="user-command relevant"
|
||||
>
|
||||
{message}
|
||||
</div>,
|
||||
)),
|
||||
});
|
||||
|
||||
class MonitorWindow extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.monitorRef = React.createRef();
|
||||
this.commandRef = React.createRef();
|
||||
this.commandInputRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Adjust the monitor height to match user's viewport
|
||||
this.adjustMonitorHeight();
|
||||
|
||||
// Resize the monitor as the user adjusts the window size
|
||||
window.addEventListener('resize', this.adjustMonitorHeight);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.monitorRef.current.style.fontSize = `${this.props.fontSize}px`;
|
||||
this.adjustMonitorHeight();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// If one day we have different components occupying the main viewport, let us not attempt to
|
||||
// perform actions on an unmounted component
|
||||
window.removeEventListener('resize', this.adjustMonitorHeight);
|
||||
}
|
||||
|
||||
adjustMonitorHeight = () => {
|
||||
const monitorDimensions = this.monitorRef.current.getBoundingClientRect();
|
||||
const commandDimensions = this.commandRef.current.getBoundingClientRect();
|
||||
|
||||
// Set monitor height
|
||||
const newMonitorHeight = window.innerHeight - monitorDimensions.top - commandDimensions.height - 30;
|
||||
this.monitorRef.current.style.height = `${newMonitorHeight}px`;
|
||||
this.scrollToBottom();
|
||||
};
|
||||
|
||||
scrollToBottom = () => {
|
||||
this.monitorRef.current.scrollTo(0, this.monitorRef.current.scrollHeight);
|
||||
};
|
||||
|
||||
sendCommand = (event) => {
|
||||
// If the user didn't press enter, or the command is empty, do nothing
|
||||
if (event.key !== 'Enter' || !event.target.value) return;
|
||||
this.props.doAppendMessage(event.target.value);
|
||||
this.scrollToBottom();
|
||||
this.props.webSocket.send(WebSocketUtils.formatSocketData('webCommand', event.target.value));
|
||||
this.commandInputRef.current.value = '';
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="monitor-window-wrapper">
|
||||
<div
|
||||
id="monitor-window"
|
||||
ref={ this.monitorRef }
|
||||
className={ `${this.props.showRelevantOnly ? 'relevant-only' : null}` }
|
||||
>
|
||||
{ this.props.messageLog }
|
||||
</div>
|
||||
<div id="command-wrapper" ref={ this.commandRef }>
|
||||
Command: <input onKeyDown={ this.sendCommand } ref={ this.commandInputRef } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapReduxStateToProps, mapDispatchToProps)(MonitorWindow);
|
@@ -0,0 +1,8 @@
|
||||
const SET_AVAILABLE_DEVICES = 'SET_AVAILABLE_DEVICES';
|
||||
|
||||
const setAvailableDevices = (devices) => ({
|
||||
type: SET_AVAILABLE_DEVICES,
|
||||
devices,
|
||||
});
|
||||
|
||||
export default setAvailableDevices;
|
8
data/web/src/js/WebUI/Redux/actions/setWebSocket.js
Normal file
8
data/web/src/js/WebUI/Redux/actions/setWebSocket.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const SET_WEBSOCKET = 'SET_WEBSOCKET';
|
||||
|
||||
const setWebSocket = (webSocket) => ({
|
||||
type: SET_WEBSOCKET,
|
||||
webSocket,
|
||||
});
|
||||
|
||||
export default setWebSocket;
|
25
data/web/src/js/WebUI/Redux/reducers/webUIReducer.js
Normal file
25
data/web/src/js/WebUI/Redux/reducers/webUIReducer.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import _assign from 'lodash-es/assign';
|
||||
|
||||
const initialState = {
|
||||
webSocket: null,
|
||||
availableDevices: [],
|
||||
};
|
||||
|
||||
const webUIReducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_WEBSOCKET':
|
||||
return _assign({}, state, {
|
||||
webSocket: action.webSocket,
|
||||
});
|
||||
|
||||
case 'SET_AVAILABLE_DEVICES':
|
||||
return _assign({}, state, {
|
||||
availableDevices: action.devices,
|
||||
});
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default webUIReducer;
|
97
data/web/src/js/WebUI/containers/WebUI.js
Normal file
97
data/web/src/js/WebUI/containers/WebUI.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import HeaderBar from '../../HeaderBar/components/HeaderBar';
|
||||
import Monitor from '../../Monitor/components/Monitor';
|
||||
import WidgetArea from '../../WidgetArea/containers/WidgetArea';
|
||||
import '../../../styles/WebUI/containers/WebUI.scss';
|
||||
|
||||
// Redux actions
|
||||
import setWebSocket from '../Redux/actions/setWebSocket';
|
||||
import WebSocketUtils from '../../global/WebSocketUtils';
|
||||
import updateGameState from '../../global/Redux/actions/updateGameState';
|
||||
|
||||
const mapReduxStateToProps = (reduxState) => ({
|
||||
connections: reduxState.gameState.connections,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
doSetWebSocket: (webSocket) => dispatch(setWebSocket(webSocket)),
|
||||
handleIncomingMessage: (message) => dispatch(WebSocketUtils.handleIncomingMessage(message)),
|
||||
doUpdateGameState: (gameState) => dispatch(updateGameState(gameState)),
|
||||
});
|
||||
|
||||
class WebUI extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.webSocket = null;
|
||||
this.webUiRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.webSocketConnect();
|
||||
}
|
||||
|
||||
webSocketConnect = () => {
|
||||
const getParams = new URLSearchParams(document.location.search.substring(1));
|
||||
const port = getParams.get('port');
|
||||
if (!port) { throw new Error('Unable to determine socket port from GET parameters'); }
|
||||
|
||||
const webSocketAddress = `ws://localhost:${port}`;
|
||||
try {
|
||||
this.props.webSocket.close();
|
||||
this.props.doSetWebSocket(null);
|
||||
} catch (error) {
|
||||
// Ignore errors caused by attempting to close an invalid WebSocket object
|
||||
}
|
||||
|
||||
const webSocket = new WebSocket(webSocketAddress);
|
||||
webSocket.onerror = () => {
|
||||
this.props.doUpdateGameState({
|
||||
connections: {
|
||||
snesDevice: this.props.connections.snesDevice,
|
||||
snesConnected: false,
|
||||
serverAddress: this.props.connections.serverAddress,
|
||||
serverConnected: false,
|
||||
},
|
||||
});
|
||||
setTimeout(this.webSocketConnect, 5000);
|
||||
};
|
||||
webSocket.onclose = () => {
|
||||
// If the WebSocket connection is closed for some reason, attempt to reconnect
|
||||
this.props.doUpdateGameState({
|
||||
connections: {
|
||||
snesDevice: this.props.connections.snesDevice,
|
||||
snesConnected: false,
|
||||
serverAddress: this.props.connections.serverAddress,
|
||||
serverConnected: false,
|
||||
},
|
||||
});
|
||||
setTimeout(this.webSocketConnect, 5000);
|
||||
};
|
||||
|
||||
// Dispatch a custom event when websocket messages are received
|
||||
webSocket.onmessage = (message) => {
|
||||
this.props.handleIncomingMessage(message);
|
||||
};
|
||||
|
||||
// Store the webSocket object in the Redux store so other components can access it
|
||||
webSocket.onopen = () => {
|
||||
this.props.doSetWebSocket(webSocket);
|
||||
webSocket.send(WebSocketUtils.formatSocketData('webStatus', 'connections'));
|
||||
};
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="web-ui" ref={ this.webUiRef }>
|
||||
<HeaderBar />
|
||||
<div id="content-middle">
|
||||
<Monitor />
|
||||
<WidgetArea />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapReduxStateToProps, mapDispatchToProps)(WebUI);
|
49
data/web/src/js/WidgetArea/containers/WidgetArea.js
Normal file
49
data/web/src/js/WidgetArea/containers/WidgetArea.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import '../../../styles/WidgetArea/containers/WidgetArea.scss';
|
||||
|
||||
class WidgetArea extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
saveNotes = (event) => {
|
||||
localStorage.setItem('notes', event.target.value);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/no-access-state-in-setstate
|
||||
toggleCollapse = () => this.setState({ collapsed: !this.state.collapsed });
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="widget-area" className={ `${this.state.collapsed ? 'collapsed' : null}` }>
|
||||
{
|
||||
this.state.collapsed ? (
|
||||
<div id="widget-button-row">
|
||||
<button className="collapse-button" onClick={ this.toggleCollapse }>↩</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
this.state.collapsed ? null : (
|
||||
<div id="widget-area-contents">
|
||||
<div id="notes">
|
||||
<div id="notes-title">
|
||||
<div>Notes:</div>
|
||||
<button className="collapse-button" onClick={ this.toggleCollapse }>↪</button>
|
||||
</div>
|
||||
<textarea defaultValue={ localStorage.getItem('notes') } onKeyUp={ this.saveNotes } />
|
||||
</div>
|
||||
More tools Coming Soon™
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect()(WidgetArea);
|
69
data/web/src/js/global/MonitorTools.js
Normal file
69
data/web/src/js/global/MonitorTools.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import md5 from 'crypto-js/md5';
|
||||
|
||||
const finderSpan = (finder, possessive = false, ownItem = false) => (
|
||||
<span className={ `finder-span ${ownItem ? 'mine' : null}` }>{finder}{possessive ? "'s" : null}</span>
|
||||
);
|
||||
const recipientSpan = (recipient, possessive = false, ownItem = false) => (
|
||||
<span className={ `recipient-span ${ownItem ? 'mine' : null}` }>{recipient}{possessive ? "'s" : null}</span>
|
||||
);
|
||||
const itemSpan = (item) => <span className="item-span">{item}</span>;
|
||||
const locationSpan = (location) => <span className="location-span">{location}</span>;
|
||||
const entranceSpan = (entrance) => <span className="entrance-span">{entrance}</span>;
|
||||
|
||||
class MonitorTools {
|
||||
/** Convert plaintext into a React-friendly div */
|
||||
static createTextDiv = (text) => (
|
||||
<div key={ `${md5(text)}${Math.floor((Math.random() * 1000000))}` }>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Sent an item to another player */
|
||||
static sentItem = (finder, recipient, item, location, iAmFinder = false, iAmRecipient = false) => (
|
||||
<div
|
||||
key={ `${md5(finder + recipient + item + location)}${Math.floor((Math.random() * 1000000))}` }
|
||||
className={ (iAmFinder || iAmRecipient) ? 'relevant' : null }
|
||||
>
|
||||
{finderSpan(finder, false, iAmFinder)} found {recipientSpan(recipient, true, iAmRecipient)}
|
||||
{itemSpan(item)} at {locationSpan(location)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/** Received item from another player */
|
||||
static receivedItem = (finder, item, location, itemIndex, queueLength) => (
|
||||
<div
|
||||
key={ `${md5(finder + item + location)}${Math.floor((Math.random() * 1000000))}` }
|
||||
className="relevant"
|
||||
>
|
||||
({itemIndex}/{queueLength}) {finderSpan(finder, false)} found your
|
||||
{itemSpan(item)} at {locationSpan(location)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/** Player found their own item (local or remote player) */
|
||||
static foundItem = (finder, item, location, iAmFinder = false) => (
|
||||
<div
|
||||
key={ `${md5(finder + item + location)}${Math.floor((Math.random() * 1000000))}` }
|
||||
className={ iAmFinder ? 'relevant' : null }
|
||||
>
|
||||
{finderSpan(finder, false, iAmFinder)} found their own {itemSpan(item)} at {locationSpan(location)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/** Hint message */
|
||||
static hintMessage = (finder, recipient, item, location, found, iAmFinder = false, iAmRecipient = false,
|
||||
entranceLocation = null) => (
|
||||
<div
|
||||
key={ `${md5(finder + recipient + item + location)}${Math.floor((Math.random() * 1000000))}` }
|
||||
className={ (iAmFinder || iAmRecipient) ? 'relevant' : null }
|
||||
>
|
||||
{recipientSpan(recipient, true, iAmRecipient)} {itemSpan(item)} can be found in
|
||||
{finderSpan(finder, true, iAmFinder)} world at {locationSpan(location)}
|
||||
{ entranceLocation ? [', which is at ', entranceSpan(entranceLocation)] : null }
|
||||
({found ? '✔' : '❌'})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MonitorTools;
|
8
data/web/src/js/global/Redux/actions/updateGameState.js
Normal file
8
data/web/src/js/global/Redux/actions/updateGameState.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const UPDATE_GAME_STATE = 'UPDATE_GAME_STATE';
|
||||
|
||||
const updateGameState = (gameState) => ({
|
||||
type: UPDATE_GAME_STATE,
|
||||
gameState,
|
||||
});
|
||||
|
||||
export default updateGameState;
|
27
data/web/src/js/global/Redux/reducers/gameStateReducer.js
Normal file
27
data/web/src/js/global/Redux/reducers/gameStateReducer.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import _assign from 'lodash-es/assign';
|
||||
|
||||
const initialState = {
|
||||
connections: {
|
||||
snesDevice: '',
|
||||
snesConnected: false,
|
||||
serverAddress: null,
|
||||
serverConnected: false,
|
||||
},
|
||||
hints: {
|
||||
hintCost: null,
|
||||
checkPoints: null,
|
||||
playerPoints: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const gameStateReducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case 'UPDATE_GAME_STATE':
|
||||
return _assign({}, state, action.gameState);
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default gameStateReducer;
|
89
data/web/src/js/global/WebSocketUtils.js
Normal file
89
data/web/src/js/global/WebSocketUtils.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import MonitorTools from './MonitorTools';
|
||||
|
||||
// Redux actions
|
||||
import appendMessage from '../Monitor/Redux/actions/appendMessage';
|
||||
import updateGameState from './Redux/actions/updateGameState';
|
||||
import setAvailableDevices from '../WebUI/Redux/actions/setAvailableDevices';
|
||||
|
||||
class WebSocketUtils {
|
||||
static formatSocketData = (eventType, content) => JSON.stringify({
|
||||
type: eventType,
|
||||
content,
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle incoming websocket data and return appropriate data for dispatch
|
||||
* @param message
|
||||
* @returns Object
|
||||
*/
|
||||
static handleIncomingMessage = (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.data);
|
||||
|
||||
switch (data.type) {
|
||||
// Client sent snes and server connection statuses
|
||||
case 'connections':
|
||||
return updateGameState({
|
||||
connections: {
|
||||
snesDevice: data.content.snesDevice ? data.content.snesDevice : '',
|
||||
snesConnected: parseInt(data.content.snes, 10) === 3,
|
||||
serverAddress: data.content.serverAddress ? data.content.serverAddress.replace(/^.*\/\//, '') : null,
|
||||
serverConnected: parseInt(data.content.server, 10) === 1,
|
||||
},
|
||||
});
|
||||
|
||||
case 'availableDevices':
|
||||
return setAvailableDevices(data.content.devices);
|
||||
|
||||
// Client unable to automatically connect to multiworld server
|
||||
case 'serverAddress':
|
||||
return appendMessage(MonitorTools.createTextDiv(
|
||||
'Unable to automatically connect to multiworld server. Please enter an address manually.',
|
||||
));
|
||||
|
||||
case 'itemSent':
|
||||
return appendMessage(MonitorTools.sentItem(data.content.finder, data.content.recipient,
|
||||
data.content.item, data.content.location, parseInt(data.content.iAmFinder, 10) === 1,
|
||||
parseInt(data.content.iAmRecipient, 10) === 1));
|
||||
|
||||
case 'itemReceived':
|
||||
return appendMessage(MonitorTools.receivedItem(data.content.finder, data.content.item,
|
||||
data.content.location, data.content.itemIndex, data.content.queueLength));
|
||||
|
||||
case 'itemFound':
|
||||
return appendMessage(MonitorTools.foundItem(data.content.finder, data.content.item, data.content.location,
|
||||
parseInt(data.content.iAmFinder, 10) === 1));
|
||||
|
||||
case 'hint':
|
||||
return appendMessage(MonitorTools.hintMessage(data.content.finder, data.content.recipient,
|
||||
data.content.item, data.content.location, parseInt(data.content.found, 10) === 1,
|
||||
parseInt(data.content.iAmFinder, 10) === 1, parseInt(data.content.iAmRecipient, 10) === 1,
|
||||
data.content.entranceLocation));
|
||||
|
||||
// The client prints several types of messages to the console
|
||||
case 'critical':
|
||||
case 'error':
|
||||
case 'warning':
|
||||
case 'info':
|
||||
case 'chat':
|
||||
return appendMessage(MonitorTools.createTextDiv(
|
||||
(typeof (data.content) === 'string') ? data.content : JSON.stringify(data.content),
|
||||
));
|
||||
default:
|
||||
console.warn(`Unknown message type received: ${data.type}`);
|
||||
console.warn(data.content);
|
||||
return { type: 'NO_OP' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(message);
|
||||
console.error(error);
|
||||
// The returned value from this function will be dispatched to Redux. If an error occurs,
|
||||
// Redux and the SPA in general should live on. Dispatching something with the correct format
|
||||
// but that matches no known Redux action will cause the state to update to itself, which is
|
||||
// treated as a no-op.
|
||||
return { type: 'NO_OP' };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default WebSocketUtils;
|
28
data/web/src/js/index.js
Normal file
28
data/web/src/js/index.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import ReactDom from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createStore, combineReducers } from 'redux';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
|
||||
import WebUI from './WebUI/containers/WebUI';
|
||||
import '../styles/index.scss';
|
||||
|
||||
// Redux reducers
|
||||
import webUI from './WebUI/Redux/reducers/webUIReducer';
|
||||
import gameState from './global/Redux/reducers/gameStateReducer';
|
||||
import monitor from './Monitor/Redux/reducers/monitorReducer';
|
||||
|
||||
const store = createStore(combineReducers({
|
||||
webUI,
|
||||
gameState,
|
||||
monitor,
|
||||
}), composeWithDevTools());
|
||||
|
||||
const App = () => (
|
||||
<Provider store={ store }>
|
||||
<WebUI />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
window.onload = () => {
|
||||
ReactDom.render(<App />, document.getElementById('app'));
|
||||
};
|
4
data/web/src/styles/HeaderBar/components/HeaderBar.scss
Normal file
4
data/web/src/styles/HeaderBar/components/HeaderBar.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
#header-bar{
|
||||
font-size: 3.4em;
|
||||
min-width: 1036px;
|
||||
}
|
4
data/web/src/styles/Monitor/components/Monitor.scss
Normal file
4
data/web/src/styles/Monitor/components/Monitor.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
#monitor{
|
||||
flex-grow: 1;
|
||||
min-width: 800px;
|
||||
}
|
45
data/web/src/styles/Monitor/containers/MonitorControls.scss
Normal file
45
data/web/src/styles/Monitor/containers/MonitorControls.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
#monitor-controls{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
margin-top: 0.5em;
|
||||
padding-bottom: 0.5em;
|
||||
|
||||
#connection-status{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
#snes-connection, #server-connection{
|
||||
margin-right: 1em;
|
||||
table{
|
||||
td{
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.connected{
|
||||
color: #008000;
|
||||
}
|
||||
|
||||
.not-connected{
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
#accessibility{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 48px;
|
||||
|
||||
button{
|
||||
border-radius: 4px;
|
||||
margin: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
48
data/web/src/styles/Monitor/containers/MonitorWindow.scss
Normal file
48
data/web/src/styles/Monitor/containers/MonitorWindow.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
#monitor-window-wrapper{
|
||||
#monitor-window{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
background-color: #414042;
|
||||
color: #dce7df;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
|
||||
div{
|
||||
width: calc(100% - 14px);
|
||||
padding: 7px;
|
||||
border-bottom: 1px solid #000000;
|
||||
|
||||
&.user-command{
|
||||
color: #ffffff;
|
||||
background-color: #575757;
|
||||
}
|
||||
}
|
||||
|
||||
&.relevant-only{
|
||||
div:not(.relevant){
|
||||
visibility: collapse;
|
||||
}
|
||||
}
|
||||
|
||||
.item-span{ color: #67e9ff; }
|
||||
.location-span{ color: #f5e63c; }
|
||||
.entrance-span{ color: #73ae38 }
|
||||
.finder-span{ color: #f96cb8; }
|
||||
.recipient-span{ color: #9b8aff; }
|
||||
.mine{ color: #ffa500; }
|
||||
}
|
||||
|
||||
#command-wrapper{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
|
||||
input{
|
||||
margin-left: 0.5em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
9
data/web/src/styles/WebUI/containers/WebUI.scss
Normal file
9
data/web/src/styles/WebUI/containers/WebUI.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
#web-ui{
|
||||
width: calc(100% - 1.5em);
|
||||
padding: 0.75em;
|
||||
|
||||
#content-middle{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
39
data/web/src/styles/WidgetArea/containers/WidgetArea.scss
Normal file
39
data/web/src/styles/WidgetArea/containers/WidgetArea.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
#widget-area{
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.5em;
|
||||
padding: 0.25em;
|
||||
border: 2px solid #6a6a6a;
|
||||
|
||||
&:not(.collapsed){
|
||||
width: calc(20% - 1.5em - 4px);
|
||||
}
|
||||
|
||||
#widget-button-row{
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
|
||||
.collapse-button{
|
||||
width: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
#widget-area-contents{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
#notes{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
#notes-title{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
textarea{
|
||||
height: 10em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
12
data/web/src/styles/index.scss
Normal file
12
data/web/src/styles/index.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
@font-face{
|
||||
font-family: HyliaSerif;
|
||||
src: local('HyliaSerif'), url('../assets/HyliaSerif.otf')
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #131313;
|
||||
color: #eae703;
|
||||
font-family: HyliaSerif, serif;
|
||||
letter-spacing: 2px;
|
||||
margin: 0;
|
||||
}
|
45
data/web/webpack.config.js
Normal file
45
data/web/webpack.config.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
index: './src/js/index.js',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|jsx|es6)$/,
|
||||
loader: 'babel-loader',
|
||||
query: {
|
||||
compact: true,
|
||||
minified: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.((css)|(s[a|c]ss))$/,
|
||||
use: [
|
||||
{ loader: 'style-loader' },
|
||||
{ loader: 'css-loader' },
|
||||
{ loader: 'sass-loader' },
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(otf)$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
name: '[name].[ext]',
|
||||
outputPath: 'fonts/',
|
||||
publicPath: 'assets/fonts/',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
path: `${path.resolve(__dirname)}/public/assets`,
|
||||
publicPath: '/',
|
||||
filename: '[name].bundle.js',
|
||||
},
|
||||
};
|
46
data/web/webpack.dev.js
Normal file
46
data/web/webpack.dev.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
index: './src/js/index.js',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|jsx|es6)$/,
|
||||
loader: 'babel-loader',
|
||||
query: {
|
||||
compact: false,
|
||||
minified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.((css)|(s[a|c]ss))$/,
|
||||
use: [
|
||||
{ loader: 'style-loader' },
|
||||
{ loader: 'css-loader' },
|
||||
{ loader: 'sass-loader' },
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(otf)$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
name: '[name].[ext]',
|
||||
outputPath: 'fonts/',
|
||||
publicPath: 'assets/fonts/',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
path: `${path.resolve(__dirname)}/public/assets`,
|
||||
publicPath: '/',
|
||||
filename: '[name].bundle.js',
|
||||
},
|
||||
devtool: 'source-map',
|
||||
};
|
Reference in New Issue
Block a user