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
+
+ {this.state.cfg.Games.map((game) => )}
+
+ }
+
+
+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)
+}