Merge pull request #34 from gempir/frontend

Frontend
This commit is contained in:
Daniel Pasch 2020-01-26 13:14:37 +01:00 committed by GitHub
commit 5a6cf58391
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 9715 additions and 5 deletions

View file

@ -1,10 +1,22 @@
build: get
swag init
go build
full: build_web build_swagger build
get:
go get ./...
build_swagger:
swag init
build_web: get_web
cd web && npm run build
go run api/assets.go
get_web:
cd web && npm install
# Docker stuff
container:
docker build -t gempir/justlog .

22
api/assets.go Normal file
View file

@ -0,0 +1,22 @@
// +build ignore
package main
import (
"log"
"net/http"
"github.com/shurcooL/vfsgen"
)
var assets http.FileSystem = http.Dir("web/public")
func main() {
err := vfsgen.Generate(assets, vfsgen.Options{
Filename: "api/assets_vfsgen.go",
PackageName: "api",
})
if err != nil {
log.Fatalln(err)
}
}

192
api/assets_vfsgen.go Normal file

File diff suppressed because one or more lines are too long

View file

@ -45,7 +45,6 @@ func (s *Server) AddChannel(channel string) {
}
func (s *Server) Init() {
e := echo.New()
e.HideBanner = true
@ -66,10 +65,14 @@ func (s *Server) Init() {
}))
e.Use(middleware.CORSWithConfig(DefaultCORSConfig))
e.GET("/", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/index.html")
assetHandler := http.FileServer(assets)
e.GET("/", echo.WrapHandler(assetHandler))
e.GET("/bundle.js", echo.WrapHandler(assetHandler))
e.GET("/docs", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/swagger/index.html")
})
e.GET("/*", echoSwagger.WrapHandler)
e.GET("/docs/*", echoSwagger.WrapHandler)
e.GET("/channels", s.getAllChannels)
e.GET("/channel/:channel/user/:username/range", s.getUserLogsRangeByName)

2
go.mod
View file

@ -7,6 +7,8 @@ require (
github.com/gempir/go-twitch-irc/v2 v2.2.0
github.com/json-iterator/go v1.1.7
github.com/labstack/echo/v4 v4.0.0
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect
github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd
github.com/sirupsen/logrus v1.4.2
github.com/swaggo/echo-swagger v0.0.0-20190329130007-1219b460a043
github.com/swaggo/swag v1.6.2

4
go.sum
View file

@ -56,6 +56,10 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd h1:ug7PpSOB5RBPK1Kg6qskGBoP3Vnj/aNYFTznWvlkGo0=
github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

3
package-lock.json generated Normal file
View file

@ -0,0 +1,3 @@
{
"lockfileVersion": 1
}

10
web/.babelrc Normal file
View file

@ -0,0 +1,10 @@
{
"presets": [
"@babel/env",
"@babel/react"
],
"plugins": [
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-proposal-class-properties"
]
}

14
web/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# dependencies
node_modules/
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.idea/
npm-debug.log*
yarn-debug.log*
yarn-error.log*dist/

8807
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
web/package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "justlog-web",
"license": "MIT",
"dependencies": {
"material-design-icons": "^3.0.1",
"moment": "^2.22.2",
"react": "^16.6.1",
"react-dom": "^16.6.1",
"react-md": "^1.9.0",
"react-redux": "^5.1.0",
"react-string-replace": "^0.4.1",
"react-toastify": "^4.4.2",
"redux": "^4.0.1",
"redux-thunk": "^2.3.0",
"whatwg-fetch": "^3.0.0"
},
"scripts": {
"start": "webpack-dev-server --mode development --content-base public",
"build": "node_modules/.bin/webpack -p --mode production && cp public/* dist/"
},
"devDependencies": {
"@babel/core": "^7.1.5",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/plugin-proposal-object-rest-spread": "^7.0.0",
"@babel/preset-env": "^7.1.5",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.1",
"mini-css-extract-plugin": "^0.4.4",
"node-sass": "^4.10.0",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.11"
}
}

43
web/public/bundle.js Normal file

File diff suppressed because one or more lines are too long

18
web/public/index.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link href='https://fonts.googleapis.com/css?family=Roboto' rel='stylesheet'>
<title>logsearch</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
<script src="bundle.js"></script>
</html>

6
web/src/index.jsx Normal file
View file

@ -0,0 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './js/App';
import './scss/app.scss';
ReactDOM.render(<App />, document.getElementById('root'));

25
web/src/js/App.jsx Normal file
View file

@ -0,0 +1,25 @@
import React, { Component } from 'react';
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { Provider } from "react-redux";
import reducer from "./store/reducer";
import createInitialState from "./store/createInitialState";
import LogSearch from './components/LogSearch';
export default class App extends Component {
constructor(props) {
super(props);
this.store = createStore(reducer, createInitialState(), applyMiddleware(thunk));
}
render() {
return (
<Provider store={this.store}>
<LogSearch />
</Provider>
);
}
}

View file

@ -0,0 +1,25 @@
import setChannels from "./setChannels";
export default function () {
return function (dispatch, getState) {
return new Promise((resolve, reject) => {
fetch("/channels").then((response) => {
if (response.status >= 200 && response.status < 300) {
return response
} else {
var error = new Error(response.statusText)
error.response = response
throw error
}
}).then((response) => {
return response.json();
}).then((json) => {
dispatch(setChannels(json.channels));
resolve();
}).catch(() => {
reject();
});
});
};
}

View file

@ -0,0 +1,41 @@
import setLogs from "./setLogs";
import setLoading from "./setLoading";
export default function (channel, username, year, month) {
return function (dispatch, getState) {
return new Promise((resolve, reject) => {
dispatch(setLoading(true));
let options = {
headers: {
"Content-Type": "application/json"
}
}
fetch(`/channel/${channel}/user/${username}/${year}/${month}`, options).then((response) => {
if (response.status >= 200 && response.status < 300) {
return response
} else {
var error = new Error(response.statusText)
error.response = response
throw error
}
}).then((response) => {
return response.json()
}).then((json) => {
for (let value of json.messages) {
value.timestamp = Date.parse(value.timestamp)
}
dispatch(setLogs(json));
dispatch(setLoading(false));
resolve();
}).catch((error) => {
dispatch(setLoading(false));
reject(error);
});
});
};
}

View file

@ -0,0 +1,28 @@
import setTwitchEmotes from "./setTwitchEmotes";
export default function () {
return function (dispatch, getState) {
return new Promise((resolve, reject) => {
fetch("https://twitchemotes.com/api_cache/v3/global.json").then((response) => {
if (response.status >= 200 && response.status < 300) {
return response
} else {
var error = new Error(response.statusText)
error.response = response
throw error
}
}).then((response) => {
return response.json();
}).then((json) => {
dispatch(setTwitchEmotes(json));
resolve();
}).catch(() => {
reject();
});
});
};
}

View file

@ -0,0 +1,6 @@
export default (channels) => (dispatch) => {
dispatch({
type: 'SET_CHANNELS',
channels: channels
});
}

View file

@ -0,0 +1,6 @@
export default (loading) => (dispatch) => {
dispatch({
type: 'SET_LOADING',
loading: loading
});
}

View file

@ -0,0 +1,6 @@
export default (logs) => (dispatch) => {
dispatch({
type: 'SET_LOGS',
logs: logs
});
}

View file

@ -0,0 +1,6 @@
export default (twitchEmotes) => (dispatch) => {
dispatch({
type: 'SET_TWITCH_EMOTES',
twitchEmotes: twitchEmotes
});
}

View file

@ -0,0 +1,77 @@
import React, { Component } from 'react';
import { Autocomplete, TextField, Button, SelectField } from 'react-md';
import moment from "moment";
export default class Filter extends Component {
constructor(props) {
super(props);
this.state = {
channel: "",
username: "",
year: moment().year(),
month: moment().format("M")
}
}
render() {
return (
<form className="filter" autoComplete="off" onSubmit={this.onSubmit}>
<Autocomplete
id="channel"
label="Channel"
placeholder="forsen"
onChange={this.onChannelChange}
onAutocomplete={this.onChannelChange}
data={this.props.channels}
/>
<TextField
id="username"
label="Username"
lineDirection="center"
onChange={this.onUsernameChange}
placeholder="gempir"
/>
<SelectField
id="year"
label="Year"
defaultValue={this.state.year}
menuItems={[moment().year(), moment().subtract(1, "year").year(), moment().subtract(2, "year").year()]}
onChange={this.onYearChange}
value={this.state.year}
/>
<SelectField
id="month"
label="Month"
defaultValue={this.state.month}
menuItems={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]}
onChange={this.onMonthChange}
value={this.state.month}
/>
<Button flat primary swapTheming type="submit" className="show-logs">Show logs</Button>
</form>
)
}
onChannelChange = (value) => {
this.setState({...this.state, channel: value});
}
onUsernameChange = (value) => {
this.setState({...this.state, username: value});
}
onYearChange = (value) => {
this.setState({...this.state, year: value});
}
onMonthChange = (value) => {
this.setState({...this.state, month: moment().month(value).format("M")});
}
onSubmit = (e) => {
e.preventDefault();
this.props.searchLogs(this.state.channel, this.state.username, this.state.year, this.state.month);
}
}

View file

@ -0,0 +1,43 @@
import React, { Component } from "react";
import Filter from "./Filter";
import LogView from "./LogView";
import { connect } from "react-redux";
import loadChannels from "../actions/loadChannels";
import loadLogs from "../actions/loadLogs";
import { ToastContainer, toast } from "react-toastify";
import 'react-toastify/dist/ReactToastify.min.css';
class LogSearch extends Component {
constructor(props) {
super(props);
props.dispatch(loadChannels());
}
render() {
return (
<div className="log-search">
<ToastContainer />
<Filter
channels={this.props.channels}
searchLogs={this.searchLogs}
/>
<LogView />
</div>
);
}
searchLogs = (channel, username, year, month) => {
this.props.dispatch(loadLogs(channel, username, year, month)).catch((error) => {
toast.error("Failed to load logs: " + error);
});
}
}
const mapStateToProps = (state) => {
return {
channels: state.channels,
};
};
export default connect(mapStateToProps)(LogSearch);

View file

@ -0,0 +1,70 @@
import React, { Component } from 'react';
import { connect } from "react-redux";
import { Button, CircularProgress } from 'react-md';
import moment from 'moment';
import twitchEmotes from "../emotes/twitch";
import reactStringReplace from "react-string-replace";
class LogView extends Component {
static LOAD_LIMIT = 100;
state = {
limitLoad: true,
};
render() {
return (
<div className={"log-view"}>
{this.getLogs().map((value, key) =>
<div key={key} className="line" onClick={() => this.setState({})}>
<span id={value.timestamp} className="timestamp">{this.formatDate(value.timestamp)}</span>{this.renderMessage(value.text)}
</div>
)}
{this.getLogs().length > 0 && this.state.limitLoad && <Button className={"load-all"} raised primary onClick={() => this.setState({ ...this.state, limitLoad: false })}>Load all</Button>}
{this.props.loading && <CircularProgress className={"progress"} scale={10} id={"progress"} />}
</div>
);
}
getLogs = () => {
if (this.state.limitLoad) {
return this.props.messages.slice(this.props.messages.length - LogView.LOAD_LIMIT, this.props.messages.length).reverse();
} else {
return this.props.messages.reverse();
}
};
renderMessage = (message) => {
for (let emoteCode in twitchEmotes) {
const regex = new RegExp(`(?:^|\ )(${emoteCode})(?:$|\ )`);
message = reactStringReplace(message, regex, (match, i) => (
<img key={i} src={this.buildTwitchEmote(twitchEmotes[emoteCode].id)} alt={match} />
));
}
return (
<p>
{message}
</p>
);
}
formatDate = (timestamp) => {
return moment(timestamp).format("YYYY-MM-DD HH:mm:ss UTC");
}
buildTwitchEmote = (id) => {
return `https://static-cdn.jtvnw.net/emoticons/v1/${id}/1.0`;
}
}
const mapStateToProps = (state) => {
return {
messages: state.logs.messages,
loading: state.loading
};
};
export default connect(mapStateToProps)(LogView);

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,10 @@
export default () => {
return {
channels: [],
logs: {
messages: [],
},
loading: false,
twitchEmotes: {},
}
}

View file

@ -0,0 +1,14 @@
export default (state, action) => {
switch (action.type) {
case "SET_CHANNELS":
return { ...state, channels: action.channels };
case "SET_LOADING":
return { ...state, loading: action.loading };
case "SET_LOGS":
return { ...state, logs: action.logs };
case "SET_TWITCH_EMOTES":
return { ...state, twitchEmotes: action.twitchEmotes };
default:
return { ...state };
}
};

13
web/src/scss/app.scss Normal file
View file

@ -0,0 +1,13 @@
@import "vendor/react-md";
@import "vendor/reset";
@import "variables/colors";
@import "tools/mixins";
@import "base/body";
@import "base/forms";
@import "base/links";
@import "modules/log-search";
@import "modules/filter";
@import "modules/log-view";

View file

@ -0,0 +1,12 @@
body {
margin: 0;
padding: 0;
background: $grey-dark;
font-family: Helvetica, Arial, sans-serif;
height: 100%;
overflow: hidden;
position: absolute;
width: 100%;
}

View file

@ -0,0 +1,7 @@
input {
color: white;
}
label {
color: white;
}

View file

@ -0,0 +1,8 @@
a {
color: white;
text-decoration: none;
&:hover {
cursor: pointer;
}
}

View file

@ -0,0 +1,22 @@
.filter {
position: relative;
flex: 0.1;
background: $grey-medium;
margin-right: 15px;
height: 300px;
padding: 1rem;
border-radius: 3px;
@include box-shadow(1);
input {
&::-webkit-input-placeholder {
opacity: 0.25;
}
}
.show-logs {
position: absolute;
bottom: 1rem;
right: 1rem;
}
}

View file

@ -0,0 +1,5 @@
.log-search {
display: flex;
padding: 1rem;
min-height: 100vh;
}

View file

@ -0,0 +1,63 @@
.log-view {
flex: 0.9;
background: $grey-medium;
@include box-shadow(1);
height: 97vh;
overflow-y: scroll;
overflow-x: hidden;
border-radius: 3px;
position: relative;
padding: 2px;
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
background: $grey-light;
}
.line {
padding: 0.25rem;
color: white;
font-size: 13px;
min-height: 24px;
p {
display: inline-block;
}
img {
height: 16px;
width: auto;
padding: 0 3px;
}
.timestamp {
position: relative;
color: $grey-light;
margin-right: 0.25rem;
&:hover {
color: $grey-lighter;
}
}
&:last-child {
border: 0;
}
}
.load-all {
width: 100%;
}
.progress {
position: absolute;
top: 50px;
left: 0;
right: 0;
margin: auto;
}
}

View file

@ -0,0 +1,15 @@
@mixin box-shadow ($level) {
@if $level == 1 {
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
} @else if $level == 2 {
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
} @else if $level == 3 {
box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
} @else if $level == 4 {
box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22);
} @else if $level == 5 {
box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22);
}
}

View file

@ -0,0 +1,5 @@
$grey-lighter: #919191;
$grey-light: #616161;
$grey-medium: #424242;
$grey-dark: #212121;

28
web/webpack.config.js Normal file
View file

@ -0,0 +1,28 @@
const path = require("path");
module.exports = {
entry: './src/index.jsx',
output: {
path: path.resolve(__dirname, 'public'),
filename: 'bundle.js',
publicPath: "/",
},
module: {
rules: [
{
test: /\.jsx$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
},
{
test: /\.s?css$/,
use: ["style-loader", "css-loader", "sass-loader"]
},
]
},
resolve: {
extensions: ['.js', '.jsx'],
}
};