diff --git a/js/.gitignore b/js/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/js/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/js/package.json b/js/package.json new file mode 100644 index 0000000..5e8d3ee --- /dev/null +++ b/js/package.json @@ -0,0 +1,43 @@ +{ + "name": "saver-webui-fe", + "version": "0.0.1", + "description": "Saver's Web UI frontend", + "main": "index.js", + "scripts": { + "start": "webpack-dev-server --open --config webpack.dev.js", + "build-prod": "webpack --config webpack.prod.js", + "build-dev": "webpack --config webpack.dev.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/drbig/saver.git" + }, + "author": "Piotr S. Staszewski", + "license": "BSD-2-Clause", + "bugs": { + "url": "https://github.com/drbig/saver/issues" + }, + "homepage": "https://github.com/drbig/saver#readme", + "dependencies": { + "babel-core": "^6.26.3", + "babel-preset-env": "^1.7.0", + "babel-preset-react": "^6.24.1", + "clean-webpack-plugin": "^0.1.19", + "coffee-loader": "^0.9.0", + "coffeescript": "^2.3.1", + "css-loader": "^1.0.0", + "git-revision-webpack-plugin": "^3.0.3", + "handlebars": "^4.0.11", + "handlebars-loader": "^1.7.0", + "html-webpack-plugin": "^3.2.0", + "less": "^3.8.0", + "less-loader": "^4.1.0", + "react": "^16.4.1", + "react-dom": "^16.4.1", + "style-loader": "^0.21.0", + "uglifyjs-webpack-plugin": "^1.2.7", + "webpack": "^3.10.0", + "webpack-dev-server": "^2.11.1", + "webpack-merge": "^4.1.1" + } +} diff --git a/js/src/index.coffee b/js/src/index.coffee new file mode 100644 index 0000000..0918eca --- /dev/null +++ b/js/src/index.coffee @@ -0,0 +1,108 @@ +require './index.less' +import React from 'react' +import {render} from 'react-dom' + + +class Save extends React.Component + constructor: (props) -> + super props + this.state = { + isDetailed: false, + } + + toggleDetailed: -> + this.setState((prevState) => {isDetailed: !prevState.isDetailed}) + + render: -> +
  • + [del] + {this.props.save.Stamp} + {this.props.save.Note} +
  • + + +class Game extends React.Component + constructor: (props) -> + super props + this.state = { + isExpanded: false, + isDetailed: false, + } + + toggleExpanded: -> + this.setState((prevState) => {isExpanded: !prevState.isExpanded}) + + toggleDetailed: -> + this.setState((prevState) => {isDetailed: !prevState.isDetailed}) + + render: -> + knob = if this.state.isExpanded + '[ - ]' + else + '[ + ]' + +
  • + {knob} + this.toggleDetailed()}>[i] + ({this.props.game.Saves.length}) + this.toggleExpanded()}>{this.props.game.Name} + {this.props.game.Stamp} + {if this.state.isDetailed +
    + Path: {this.props.game.Path} + Size: {this.props.game.Size} +
    + } + {if this.state.isExpanded +
      + {this.props.game.Saves.map((save) => )} +
    + } +
  • + + +class App extends React.Component + constructor: (props) -> + super props + this.state = { + isLoaded: false, + error: null, + cfg: {}, + currentGame: null, + } + + componentDidMount: -> + fetch('/api/list') + .then((response) => response.json()) + .then( + (ok) => + this.setState({ + isLoaded: true, + cfg: ok, + }) + , + (error) => + this.setState({ + isLoaded: true, + error: error, + }) + ) + + render: -> + if this.state.error +
    Error: {this.state.error.message}
    + else if !this.state.isLoaded +
    Loading...
    + else +
    + Root: {this.state.cfg.Root} + {if this.state.cfg.Games.length < 1 +
    No games defined. Please use CLI.
    + else + + } +
    + +render , document.getElementById('app') diff --git a/js/src/index.hbs b/js/src/index.hbs new file mode 100644 index 0000000..aa1cc5a --- /dev/null +++ b/js/src/index.hbs @@ -0,0 +1,15 @@ + + + + + {{ htmlWebpackPlugin.options.title }} + + +
    + + + diff --git a/js/src/index.less b/js/src/index.less new file mode 100644 index 0000000..04a9d45 --- /dev/null +++ b/js/src/index.less @@ -0,0 +1,75 @@ +body { + background: black; + color: white; +} + +div.footer { + font-size: 0.7em; + padding-top: 1em; +} + +div.error { + color: red; +} + +div.gameInfo { + display: block; + font-size: 0.8em; + padding: 0.5em; +} + +ol.saves { + display: block; + padding: 0.5em; +} + +span { + display: inline-block; +} + +a.gameName { + display: inline-block; + width: 32em; +} +a.gameName:hover { + color: yellow; +} + +a.saveMain { + display: inline-block; + width: 35em; +} +a.saveMain:hover { + color: yellow; +} + +span.gameStamp { + font-family: monospace; + font-size: 0.8em; +} + +.knob { + font-family: monospace; + padding-right: 0.5em; +} + +span.savesCounter { + font-family: monospace; + display: inline-block; + width: 2em; + text-align: center; + padding-right: 0.5em; +} + +a.info { + font-family: monospace; + padding-right: 0.5em; +} +a.info:hover { + color: yellow; +} + +li.saves { + display: block; + padding: 0.5em; +} diff --git a/js/webpack.common.js b/js/webpack.common.js new file mode 100644 index 0000000..546af5e --- /dev/null +++ b/js/webpack.common.js @@ -0,0 +1,53 @@ +const path = require('path'); + +const CleanWebpackPlugin = require('clean-webpack-plugin'); +const GitRevisionPlugin = require('git-revision-webpack-plugin'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const gitRevisionPlugin = new GitRevisionPlugin({ + versionCommand: 'describe --always' +}); + +module.exports = { + entry: { + app: './src/index.coffee' + }, + plugins: [ + new CleanWebpackPlugin(['dist']), + new HtmlWebpackPlugin({ + hash: true, + template: 'src/index.hbs', + title: 'Saver Web UI', + version: gitRevisionPlugin.version() + }) + ], + module: { + loaders: [ + { + test: /\.hbs$/, + use: ['handlebars-loader'] + }, + { + test: /\.coffee$/, + use: [ + { + loader: 'coffee-loader', + options: { + transpile: { + presets: ['env', 'react'] + } + } + } + ] + }, + { + test: /\.less$/, + use: ['style-loader', 'css-loader', 'less-loader'] + } + ] + }, + output: { + filename: '[name].bundle.js', + path: path.resolve(__dirname, 'dist') + } +}; diff --git a/js/webpack.dev.js b/js/webpack.dev.js new file mode 100644 index 0000000..c840145 --- /dev/null +++ b/js/webpack.dev.js @@ -0,0 +1,9 @@ +const common = require('./webpack.common.js'); +const merge = require('webpack-merge'); + +module.exports = merge(common, { + devtool: 'inline-source-map', + devServer: { + contentBase: './dist' + } +}); diff --git a/js/webpack.prod.js b/js/webpack.prod.js new file mode 100644 index 0000000..4f69bc7 --- /dev/null +++ b/js/webpack.prod.js @@ -0,0 +1,10 @@ +const common = require('./webpack.common.js'); +const merge = require('webpack-merge'); + +const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); + +module.exports = merge(common, { + plugins: [ + new UglifyJSPlugin() + ] +}); diff --git a/main.go b/main.go index fe2e88d..d820d9f 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,7 @@ import ( ) const ( - VERSION = `0.9.1` + VERSION = `0.9.1-webui` timeFmt = `2006-01-02 15:04:05` fileFmt = `2006-01-02_150405` ) @@ -23,6 +23,7 @@ var build = `UNKNOWN` // injected in Makefile var ( flagConfig string flagVerbose bool + flagPort int cfg *Config idRange = regexp.MustCompile(`(\d+)-(\d+)`) spinner = Spinner{} @@ -47,6 +48,7 @@ Commands: [del]ete - delete given save(s) [kill] - delete game and all saves [migrate] - migrate config, if needed + [webui] - start the Web UI Where: name - arbitrary name used to identify a game/character/world etc. @@ -58,6 +60,7 @@ Where: } flag.StringVar(&flagConfig, "c", "saver.json", "path to config file") flag.BoolVar(&flagVerbose, "v", false, "be very verbose") + flag.IntVar(&flagPort, "p", 8888, "Web UI port") } func main() { @@ -96,6 +99,11 @@ func main() { err := cfg.Migrate() dieOnErr("ERROR", err) save = true + case "webui": + // start the Web UI + go startWebUI() + sigwait() + save = true default: // per-game commands checkArgs(false, 2) diff --git a/sigwait_common.go b/sigwait_common.go new file mode 100644 index 0000000..7ec84a5 --- /dev/null +++ b/sigwait_common.go @@ -0,0 +1,20 @@ +// See LICENSE.txt for licensing information. + +package main + +import ( + "fmt" + "os" + "os/signal" +) + +func _sigwait(sigs ...os.Signal) { + sig := make(chan os.Signal) + signal.Notify(sig, sigs...) + s := <-sig + if s == sigs[0] { + fmt.Println() + } + webuiLog.Printf("Signal '%s' received, stopping", s) + return +} diff --git a/sigwait_unix.go b/sigwait_unix.go new file mode 100644 index 0000000..3ea6e69 --- /dev/null +++ b/sigwait_unix.go @@ -0,0 +1,11 @@ +// See LICENSE.txt for licensing information. +// +build !windows + +package main + +import "syscall" + +// sigwait processes signals such as a CTRL-C hit. +func sigwait() { + _sigwait(syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL) +} diff --git a/sigwait_windows.go b/sigwait_windows.go new file mode 100644 index 0000000..55d747d --- /dev/null +++ b/sigwait_windows.go @@ -0,0 +1,11 @@ +// See LICENSE.txt for licensing information. +// +build windows + +package main + +import "os" + +// sigwait processes signals such as a CTRL-C hit. +func sigwait() { + _sigwait(os.Interrupt, os.Kill) +} diff --git a/webui.go b/webui.go new file mode 100644 index 0000000..b99b58d --- /dev/null +++ b/webui.go @@ -0,0 +1,126 @@ +// See LICENSE.txt for licensing information. + +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strconv" + "time" +) + +import ( + "github.com/gorilla/mux" +) + +var webuiLog *log.Logger + +func handleGetList(w http.ResponseWriter, r *http.Request) { + outputJSON(w, cfg) +} + +func handleGetGameBackup(w http.ResponseWriter, r *http.Request) { + g, ok := getGame(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + webuiLog.Println("FAILED to parse form") + http.Error(w, "Couldn't parse form.", http.StatusBadRequest) + return + } + sv, err := g.Backup() + if err != nil { + webuiLog.Println("FAILED to save: ", err) + http.Error(w, fmt.Sprintln("Failed to save: ", err), http.StatusInternalServerError) + return + } + sv.Note = r.Form.Get("note") + + outputJSON(w, sv) +} + +func handleGetGameRestore(w http.ResponseWriter, r *http.Request) { + g, ok := getGame(w, r) + if !ok { + return + } + v := mux.Vars(r) + i, _ := strconv.Atoi(v["id"]) + sv, err := g.Restore(i) + if err != nil { + webuiLog.Println("FAILED to restore: ", err) + http.Error(w, fmt.Sprintln("Failed to restore: ", err), http.StatusInternalServerError) + return + } + g.Stamp = time.Now() + + outputJSON(w, sv) +} + +func handleGetGameDelete(w http.ResponseWriter, r *http.Request) { + g, ok := getGame(w, r) + if !ok { + return + } + v := mux.Vars(r) + f, _ := strconv.Atoi(v["from"]) + t, _ := strconv.Atoi(v["to"]) + _, err := g.Delete(f, t) + if err != nil { + webuiLog.Println("FAILED to delete: ", err) + http.Error(w, fmt.Sprintln("Failed to delete: ", err), http.StatusInternalServerError) + } + + outputJSON(w, cfg) +} + +func loggingMw(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webuiLog.Printf("Request to %s\n", r.RequestURI) + next.ServeHTTP(w, r) + }) +} + +func startWebUI() { + webuiLog = log.New(os.Stdout, "", log.Ltime|log.Lshortfile) + + r := mux.NewRouter() + api := r.PathPrefix("/api/").Subrouter() + api.HandleFunc("/list", handleGetList). + Methods(http.MethodGet) + api.HandleFunc("/{game}/backup", handleGetGameBackup). + Methods(http.MethodGet) + api.HandleFunc("/{game}/restore/{id:[0-9]+}", handleGetGameRestore). + Methods(http.MethodGet) + api.HandleFunc("/{game}/delete/{from:[0-9]+}-{to:[0-9]+}", handleGetGameDelete). + Methods(http.MethodGet) + r.PathPrefix("/").Handler(http.FileServer(http.Dir("./js/dist/"))) + r.Use(loggingMw) + http.Handle("/", r) + + addr := fmt.Sprintf("127.0.0.1:%d", flagPort) + webuiLog.Printf("Starting Web UI at http://%s\n", addr) + http.ListenAndServe(addr, nil) +} + +func getGame(w http.ResponseWriter, r *http.Request) (*Game, bool) { + v := mux.Vars(r) + game := v["game"] + g := cfg.GetGame(game) + if g == nil { + webuiLog.Printf("Game '%s' not found\n", game) + http.NotFound(w, r) + return nil, false + } + return g, true +} + +func outputJSON(w http.ResponseWriter, data interface{}) { + j := json.NewEncoder(w) + w.Header()["Content-Type"] = []string{"application/json"} + j.Encode(data) +}