commit
ede371aae2
78 changed files with 8087 additions and 3234 deletions
|
@ -9,7 +9,7 @@ import (
|
|||
"github.com/shurcooL/vfsgen"
|
||||
)
|
||||
|
||||
var assets http.FileSystem = http.Dir("web/public")
|
||||
var assets http.FileSystem = http.Dir("web/build")
|
||||
|
||||
func main() {
|
||||
err := vfsgen.Generate(assets, vfsgen.Options{
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -54,6 +54,8 @@ func (s *Server) getChannelLogs(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.ClearChatMessage:
|
||||
message := *parsedMessage.(*twitch.ClearChatMessage)
|
||||
|
@ -73,6 +75,7 @@ func (s *Server) getChannelLogs(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.UserNoticeMessage:
|
||||
message := *parsedMessage.(*twitch.UserNoticeMessage)
|
||||
|
@ -85,6 +88,8 @@ func (s *Server) getChannelLogs(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -137,6 +142,8 @@ func (s *Server) getChannelLogsRange(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.ClearChatMessage:
|
||||
message := *parsedMessage.(*twitch.ClearChatMessage)
|
||||
|
@ -160,6 +167,7 @@ func (s *Server) getChannelLogsRange(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.UserNoticeMessage:
|
||||
message := *parsedMessage.(*twitch.UserNoticeMessage)
|
||||
|
@ -172,6 +180,8 @@ func (s *Server) getChannelLogsRange(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -30,7 +30,6 @@ type Server struct {
|
|||
fileLogger *filelog.Logger
|
||||
helixClient *helix.Client
|
||||
channels []string
|
||||
assets []string
|
||||
assetHandler http.Handler
|
||||
}
|
||||
|
||||
|
@ -44,7 +43,6 @@ func NewServer(cfg *config.Config, bot *bot.Bot, fileLogger *filelog.Logger, hel
|
|||
fileLogger: fileLogger,
|
||||
helixClient: helixClient,
|
||||
channels: channels,
|
||||
assets: []string{"/", "/favicon.ico", "/robots.txt"},
|
||||
assetHandler: http.FileServer(assets),
|
||||
}
|
||||
}
|
||||
|
@ -89,8 +87,10 @@ type chatMessage struct {
|
|||
DisplayName string `json:"displayName"`
|
||||
Channel string `json:"channel"`
|
||||
Timestamp timestamp `json:"timestamp"`
|
||||
ID string `json:"id"`
|
||||
Type twitch.MessageType `json:"type"`
|
||||
Raw string `json:"raw"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
// ErrorResponse a simple error response
|
||||
|
@ -113,11 +113,6 @@ func (s *Server) Init() {
|
|||
func (s *Server) route(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.EscapedPath()
|
||||
|
||||
if contains(s.assets, url) || strings.HasPrefix(url, "/bundle") {
|
||||
s.assetHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
query := s.fillUserids(w, r)
|
||||
|
||||
if url == "/list" {
|
||||
|
@ -146,7 +141,12 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
s.routeLogs(w, r)
|
||||
routedLogs := s.routeLogs(w, r)
|
||||
|
||||
if !routedLogs {
|
||||
s.assetHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) fillUserids(w http.ResponseWriter, r *http.Request) url.Values {
|
||||
|
@ -175,16 +175,14 @@ func (s *Server) fillUserids(w http.ResponseWriter, r *http.Request) url.Values
|
|||
return query
|
||||
}
|
||||
|
||||
func (s *Server) routeLogs(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) routeLogs(w http.ResponseWriter, r *http.Request) bool {
|
||||
|
||||
request, err := s.newLogRequestFromURL(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
return false
|
||||
}
|
||||
if request.redirectPath != "" {
|
||||
http.Redirect(w, r, request.redirectPath, http.StatusFound)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
var logs *chatLog
|
||||
|
@ -208,7 +206,7 @@ func (s *Server) routeLogs(w http.ResponseWriter, r *http.Request) {
|
|||
if err != nil {
|
||||
log.Error(err)
|
||||
http.Error(w, "could not load logs", http.StatusInternalServerError)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
// Disable content type sniffing for log output
|
||||
|
@ -216,20 +214,20 @@ func (s *Server) routeLogs(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if request.responseType == responseTypeJSON {
|
||||
writeJSON(logs, http.StatusOK, w, r)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if request.responseType == responseTypeRaw {
|
||||
writeRaw(logs, http.StatusOK, w, r)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if request.responseType == responseTypeText {
|
||||
writeText(logs, http.StatusOK, w, r)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
http.Error(w, "unkown response type", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
|
||||
func corsHandler(h http.Handler) http.Handler {
|
||||
|
|
15
api/user.go
15
api/user.go
|
@ -37,6 +37,8 @@ func (s *Server) getRandomQuote(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.ClearChatMessage:
|
||||
message := *parsedMessage.(*twitch.ClearChatMessage)
|
||||
|
@ -49,6 +51,7 @@ func (s *Server) getRandomQuote(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.UserNoticeMessage:
|
||||
message := *parsedMessage.(*twitch.UserNoticeMessage)
|
||||
|
@ -61,6 +64,8 @@ func (s *Server) getRandomQuote(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -106,6 +111,8 @@ func (s *Server) getUserLogs(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.ClearChatMessage:
|
||||
message := *parsedMessage.(*twitch.ClearChatMessage)
|
||||
|
@ -118,6 +125,7 @@ func (s *Server) getUserLogs(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.UserNoticeMessage:
|
||||
message := *parsedMessage.(*twitch.UserNoticeMessage)
|
||||
|
@ -130,6 +138,8 @@ func (s *Server) getUserLogs(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -183,6 +193,8 @@ func (s *Server) getUserLogsRange(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.ClearChatMessage:
|
||||
message := *parsedMessage.(*twitch.ClearChatMessage)
|
||||
|
@ -199,6 +211,7 @@ func (s *Server) getUserLogsRange(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
case *twitch.UserNoticeMessage:
|
||||
message := *parsedMessage.(*twitch.UserNoticeMessage)
|
||||
|
@ -211,6 +224,8 @@ func (s *Server) getUserLogsRange(request logRequest) (*chatLog, error) {
|
|||
Type: message.Type,
|
||||
Channel: message.Channel,
|
||||
Raw: message.Raw,
|
||||
ID: message.ID,
|
||||
Tags: message.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
10
web/.babelrc
10
web/.babelrc
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"presets": [
|
||||
"@babel/env",
|
||||
"@babel/react"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-object-rest-spread",
|
||||
"@babel/plugin-proposal-class-properties"
|
||||
]
|
||||
}
|
1
web/.env.development
Normal file
1
web/.env.development
Normal file
|
@ -0,0 +1 @@
|
|||
REACT_APP_API_BASE_URL=http://localhost:8025
|
19
web/.gitignore
vendored
19
web/.gitignore
vendored
|
@ -1,20 +1,23 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
.yarn/
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# 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/
|
||||
|
||||
public/*
|
||||
!public/favicon.ico
|
||||
!public/robots.txt
|
||||
yarn-error.log*
|
||||
|
|
|
@ -1,37 +1,53 @@
|
|||
{
|
||||
"name": "web",
|
||||
"license": "MIT",
|
||||
"description": "frontend for justlog",
|
||||
"repository": "github.com/gempir/justlog",
|
||||
"dependencies": {
|
||||
"irc-message": "^3.0.2",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-redux": "^7.1.3",
|
||||
"react-toastify": "^5.5.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server --mode development --content-base public",
|
||||
"build": "webpack -p --mode production"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.8.3",
|
||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.8.3",
|
||||
"@babel/preset-env": "^7.8.3",
|
||||
"@babel/preset-react": "^7.8.3",
|
||||
"babel-loader": "^8.0.6",
|
||||
"css-loader": "^3.4.2",
|
||||
"html-webpack-plugin": "^4.4.1",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"node-sass": "^4.13.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^1.1.3",
|
||||
"webpack": "^4.41.5",
|
||||
"webpack-cli": "^3.3.10",
|
||||
"webpack-dev-server": "^3.10.1"
|
||||
}
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^16.9.53",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-linkify": "^1.0.0",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
"dayjs": "^1.9.5",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
"react-query": "^2.25.2",
|
||||
"react-scripts": "4.0.0",
|
||||
"typescript": "^4.0.3",
|
||||
"web-vitals": "^0.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"styled-components": "^5.2.1"
|
||||
}
|
||||
}
|
||||
|
|
44
web/public/index.html
Normal file
44
web/public/index.html
Normal file
|
@ -0,0 +1,44 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#090A0B" />
|
||||
<title>justlog</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0e0e10;
|
||||
--bg-bright: #18181b;
|
||||
--bg-brighter: #3d4146;
|
||||
--bg-dark: #121416;
|
||||
--theme: #00CC66;
|
||||
--theme-bright: #00FF80;
|
||||
--text: #F5F5F5;
|
||||
--text-dark: #616161;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
56
web/src/components/Filters.tsx
Normal file
56
web/src/components/Filters.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { Button, TextField } from "@material-ui/core";
|
||||
import React, { FormEvent, useContext } from "react";
|
||||
import { useQueryCache } from "react-query";
|
||||
import styled from "styled-components";
|
||||
import { store } from "../store";
|
||||
import { Settings } from "./Settings";
|
||||
|
||||
const FiltersContainer = styled.form`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: var(--bg-bright);
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
margin: 0 auto;
|
||||
|
||||
> * {
|
||||
margin-right: 15px !important;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FiltersWrapper = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export function Filters() {
|
||||
const { setCurrents, state } = useContext(store);
|
||||
const queryCache = useQueryCache();
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.target instanceof HTMLFormElement) {
|
||||
const data = new FormData(e.target);
|
||||
|
||||
const channel = data.get("channel") as string | null;
|
||||
const username = data.get("username") as string | null;
|
||||
|
||||
queryCache.invalidateQueries(`${channel}:${username}`);
|
||||
setCurrents(channel, username);
|
||||
}
|
||||
};
|
||||
|
||||
return <FiltersWrapper>
|
||||
<FiltersContainer onSubmit={handleSubmit} action="none">
|
||||
<TextField name="channel" label="channel" variant="filled" autoComplete="off" defaultValue={state.currentChannel} autoFocus={state.currentChannel === null} />
|
||||
<TextField error={state.error} name="username" label="username" variant="filled" autoComplete="off" defaultValue={state.currentUsername} autoFocus={state.currentChannel !== null && state.currentUsername === null} />
|
||||
<Button variant="contained" color="primary" size="large" type="submit">load</Button>
|
||||
<Settings />
|
||||
</FiltersContainer>
|
||||
</FiltersWrapper>
|
||||
}
|
53
web/src/components/Log.tsx
Normal file
53
web/src/components/Log.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { Button } from "@material-ui/core";
|
||||
import React, { useContext, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { useLog } from "../hooks/useLog";
|
||||
import { store } from "../store";
|
||||
import { LogLine } from "./LogLine";
|
||||
|
||||
const LogContainer = styled.div`
|
||||
background: var(--bg-bright);
|
||||
border-radius: 3px;
|
||||
padding: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
`;
|
||||
|
||||
export function Log({ year, month, initialLoad = false }: { year: string, month: string, initialLoad?: boolean }) {
|
||||
const [load, setLoad] = useState(initialLoad);
|
||||
|
||||
if (!load) {
|
||||
return <LogContainer>
|
||||
<LoadableLog year={year} month={month} onLoad={() => setLoad(true)} />
|
||||
</LogContainer>
|
||||
}
|
||||
|
||||
return <LogContainer>
|
||||
<ContentLog year={year} month={month} />
|
||||
</LogContainer>
|
||||
}
|
||||
|
||||
const ContentLogContainer = styled.ul`
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
function ContentLog({ year, month }: { year: string, month: string }) {
|
||||
const { state } = useContext(store);
|
||||
|
||||
const logs = useLog(state.currentChannel ?? "", state.currentUsername ?? "", year, month)
|
||||
|
||||
return <ContentLogContainer>
|
||||
{logs.map((log, index) => <LogLine key={log.id ? log.id : index} message={log} />)}
|
||||
</ContentLogContainer>
|
||||
}
|
||||
|
||||
const LoadableLogContainer = styled.div`
|
||||
|
||||
`;
|
||||
|
||||
function LoadableLog({ year, month, onLoad }: { year: string, month: string, onLoad: () => void }) {
|
||||
return <LoadableLogContainer>
|
||||
<Button variant="contained" color="primary" size="large" onClick={onLoad}>load {year}/{month}</Button>
|
||||
</LoadableLogContainer>
|
||||
}
|
22
web/src/components/LogContainer.tsx
Normal file
22
web/src/components/LogContainer.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React, { useContext } from "react";
|
||||
import styled from "styled-components";
|
||||
import { useAvailableLogs } from "../hooks/useAvailableLogs";
|
||||
import { store } from "../store";
|
||||
import { Log } from "./Log";
|
||||
|
||||
const LogContainerDiv = styled.div`
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
padding-top: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export function LogContainer() {
|
||||
const { state } = useContext(store);
|
||||
|
||||
const availableLogs = useAvailableLogs(state.currentChannel, state.currentUsername);
|
||||
|
||||
return <LogContainerDiv>
|
||||
{availableLogs.map((log, index) => <Log key={`${log.year}:${log.month}`} year={log.year} month={log.month} initialLoad={index === 0} />)}
|
||||
</LogContainerDiv>
|
||||
}
|
60
web/src/components/LogLine.tsx
Normal file
60
web/src/components/LogLine.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import dayjs from "dayjs";
|
||||
import React, { useContext } from "react";
|
||||
import styled from "styled-components";
|
||||
import { useThirdPartyEmotes } from "../hooks/useThirdPartyEmotes";
|
||||
import { store } from "../store";
|
||||
import { LogMessage } from "../types/log";
|
||||
import { Message } from "./Message";
|
||||
import { User } from "./User";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.tz.guess()
|
||||
|
||||
const LogLineContainer = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.timestamp {
|
||||
color: var(--text-dark);
|
||||
user-select: none;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.user {
|
||||
margin-left: 5px;
|
||||
user-select: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-left: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function LogLine({ message }: { message: LogMessage }) {
|
||||
const { state } = useContext(store);
|
||||
|
||||
if (state.settings.showEmotes.value) {
|
||||
return <LogLineWithEmotes message={message} />;
|
||||
}
|
||||
|
||||
return <LogLineContainer>
|
||||
<span className="timestamp">{dayjs(message.timestamp).format("YYYY-MM-DD HH:mm:ss")}</span>
|
||||
{state.settings.showName.value && <User displayName={message.displayName} color={message.tags["color"]} />}
|
||||
<Message message={message} thirdPartyEmotes={[]} />
|
||||
</LogLineContainer>
|
||||
}
|
||||
|
||||
export function LogLineWithEmotes({ message }: { message: LogMessage }) {
|
||||
const thirdPartyEmotes = useThirdPartyEmotes(message.tags["room-id"])
|
||||
const { state } = useContext(store);
|
||||
|
||||
return <LogLineContainer>
|
||||
<span className="timestamp">{dayjs(message.timestamp).format("YYYY-MM-DD HH:mm:ss")}</span>
|
||||
{state.settings.showName.value && <User displayName={message.displayName} color={message.tags["color"]} />}
|
||||
<Message message={message} thirdPartyEmotes={thirdPartyEmotes} />
|
||||
</LogLineContainer>
|
||||
}
|
88
web/src/components/Message.tsx
Normal file
88
web/src/components/Message.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
import React, { useContext } from "react";
|
||||
import Linkify from "react-linkify";
|
||||
import styled from "styled-components";
|
||||
import { store } from "../store";
|
||||
import { LogMessage } from "../types/log";
|
||||
import { ThirdPartyEmote } from "../types/ThirdPartyEmote";
|
||||
|
||||
const MessageContainer = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
line-height: 1rem;
|
||||
|
||||
a {
|
||||
margin: 0 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Emote = styled.img`
|
||||
/* transform: scale(0.5); */
|
||||
max-height: 14px;
|
||||
width: auto;
|
||||
`;
|
||||
|
||||
export function Message({ message, thirdPartyEmotes }: { message: LogMessage, thirdPartyEmotes: Array<ThirdPartyEmote> }): JSX.Element {
|
||||
const { state } = useContext(store);
|
||||
const renderMessage = [];
|
||||
|
||||
let replaced;
|
||||
let buffer = "";
|
||||
|
||||
for (let x = 0; x <= message.text.length; x++) {
|
||||
const c = message.text[x];
|
||||
|
||||
replaced = false;
|
||||
|
||||
if (state.settings.showEmotes.value) {
|
||||
for (const emote of message.emotes) {
|
||||
if (emote.startIndex === x) {
|
||||
replaced = true;
|
||||
renderMessage.push(<Emote
|
||||
key={x}
|
||||
alt={emote.code}
|
||||
src={`https://static-cdn.jtvnw.net/emoticons/v1/${emote.id}/1.0`}
|
||||
/>);
|
||||
x += emote.endIndex - emote.startIndex - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!replaced) {
|
||||
if (c !== " " && x !== message.text.length) {
|
||||
buffer += c;
|
||||
continue;
|
||||
}
|
||||
let emoteFound = false;
|
||||
|
||||
for (const emote of thirdPartyEmotes) {
|
||||
if (buffer.trim() === emote.code) {
|
||||
renderMessage.push(<Emote
|
||||
key={x}
|
||||
alt={emote.code}
|
||||
src={emote.urls.small}
|
||||
/>);
|
||||
emoteFound = true;
|
||||
buffer = "";
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!emoteFound) {
|
||||
renderMessage.push(<Linkify key={x} componentDecorator={(decoratedHref, decoratedText, key) => (
|
||||
<a target="__blank" href={decoratedHref} key={key}>
|
||||
{decoratedText}
|
||||
</a>
|
||||
)}>{buffer}</Linkify>);
|
||||
buffer = "";
|
||||
}
|
||||
renderMessage.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
return <MessageContainer className="message">
|
||||
{renderMessage}
|
||||
</MessageContainer>;
|
||||
};
|
15
web/src/components/Page.tsx
Normal file
15
web/src/components/Page.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Filters } from "./Filters";
|
||||
import { LogContainer } from "./LogContainer";
|
||||
|
||||
const PageContainer = styled.div`
|
||||
|
||||
`;
|
||||
|
||||
export function Page() {
|
||||
return <PageContainer>
|
||||
<Filters />
|
||||
<LogContainer />
|
||||
</PageContainer>;
|
||||
}
|
55
web/src/components/Settings.tsx
Normal file
55
web/src/components/Settings.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { IconButton, Menu, MenuItem } from "@material-ui/core";
|
||||
import { Check, Clear, Settings as SettingsIcon } from "@material-ui/icons";
|
||||
import React, { MouseEvent, useContext, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Setting, store } from "../store";
|
||||
|
||||
const SettingsContainer = styled.div`
|
||||
|
||||
`;
|
||||
|
||||
export function Settings() {
|
||||
const { state, setSettings } = useContext(store);
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const toggleSetting = (name: string, setting: Setting) => {
|
||||
const newSetting = { ...setting, value: !setting.value };
|
||||
|
||||
setSettings({ ...state.settings, [name]: newSetting });
|
||||
};
|
||||
|
||||
const menuItems = [];
|
||||
|
||||
for (const [name, setting] of Object.entries(state.settings)) {
|
||||
menuItems.push(
|
||||
<MenuItem key={name} onClick={() => toggleSetting(name, setting)}>
|
||||
{setting.value ? <Check /> : <Clear />} {setting.displayName}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<IconButton aria-controls="settings" aria-haspopup="true" onClick={handleClick} size="small">
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
id="settings"
|
||||
anchorEl={anchorEl}
|
||||
keepMounted
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
21
web/src/components/User.tsx
Normal file
21
web/src/components/User.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
|
||||
|
||||
const UserContainer = styled.div.attrs(props => ({
|
||||
style: {
|
||||
color: props.color,
|
||||
}
|
||||
}))`
|
||||
display: inline;
|
||||
`;
|
||||
|
||||
export function User({ displayName, color }: { displayName: string, color: string }): JSX.Element {
|
||||
|
||||
const renderColor = color !== "" ? color : "grey";
|
||||
|
||||
return <UserContainer color={renderColor} className="user">
|
||||
{displayName}:
|
||||
</UserContainer>;
|
||||
}
|
39
web/src/hooks/useAvailableLogs.ts
Normal file
39
web/src/hooks/useAvailableLogs.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useContext } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { isUserId } from "../services/isUserId";
|
||||
import { store } from "../store";
|
||||
|
||||
export type AvailableLogs = Array<{ month: string, year: string }>;
|
||||
|
||||
export function useAvailableLogs(channel: string | null, username: string | null): AvailableLogs {
|
||||
const { state, setState } = useContext(store);
|
||||
|
||||
const { data } = useQuery<AvailableLogs>(`${channel}:${username}`, () => {
|
||||
if (channel && username) {
|
||||
const channelIsId = isUserId(channel);
|
||||
const usernameIsId = isUserId(username);
|
||||
|
||||
const queryUrl = new URL(`${state.apiBaseUrl}/list`);
|
||||
queryUrl.searchParams.append(`channel${channelIsId ? "id" : ""}`, channel);
|
||||
queryUrl.searchParams.append(`user${usernameIsId ? "id" : ""}`, username);
|
||||
|
||||
return fetch(queryUrl.toString()).then((response) => {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
throw Error(response.statusText);
|
||||
}).then(response => response.json())
|
||||
.then((data: { availableLogs: AvailableLogs }) => data.availableLogs)
|
||||
.catch(() => {
|
||||
setState({ ...state, error: true });
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}, { refetchOnWindowFocus: false, refetchOnReconnect: false });
|
||||
|
||||
return data ?? [];
|
||||
}
|
41
web/src/hooks/useBttvChannelEmotes.ts
Normal file
41
web/src/hooks/useBttvChannelEmotes.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { useQuery } from "react-query";
|
||||
import { QueryDefaults } from "../store";
|
||||
import { BttvChannelEmotesResponse } from "../types/Bttv";
|
||||
import { ThirdPartyEmote } from "../types/ThirdPartyEmote";
|
||||
|
||||
export function useBttvChannelEmotes(channelId: string): Array<ThirdPartyEmote> {
|
||||
const { isLoading, error, data } = useQuery(`bttv:channel:${channelId}`, () => {
|
||||
if (channelId === "") {
|
||||
return Promise.resolve({sharedEmotes: [], channelEmotes: []});
|
||||
}
|
||||
|
||||
return fetch(`https://api.betterttv.net/3/cached/users/twitch/${channelId}`).then(res =>
|
||||
res.json() as Promise<BttvChannelEmotesResponse>
|
||||
);
|
||||
}, QueryDefaults);
|
||||
|
||||
if (isLoading) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const emotes = [];
|
||||
|
||||
for (const channelEmote of [...data?.channelEmotes ?? [], ...data?.sharedEmotes ?? []]) {
|
||||
emotes.push({
|
||||
id: channelEmote.id,
|
||||
code: channelEmote.code,
|
||||
urls: {
|
||||
small: `https://cdn.betterttv.net/emote/${channelEmote.id}/1x`,
|
||||
medium: `https://cdn.betterttv.net/emote/${channelEmote.id}/2x`,
|
||||
big: `https://cdn.betterttv.net/emote/${channelEmote.id}/3x`,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return emotes;
|
||||
}
|
37
web/src/hooks/useBttvGlobalEmotes.ts
Normal file
37
web/src/hooks/useBttvGlobalEmotes.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from "react-query";
|
||||
import { QueryDefaults } from "../store";
|
||||
import { BttvGlobalEmotesResponse } from "../types/Bttv";
|
||||
import { ThirdPartyEmote } from "../types/ThirdPartyEmote";
|
||||
|
||||
export function useBttvGlobalEmotes(): Array<ThirdPartyEmote> {
|
||||
const { isLoading, error, data } = useQuery("bttv:global", () => {
|
||||
return fetch("https://api.betterttv.net/3/cached/emotes/global").then(res =>
|
||||
res.json() as Promise<BttvGlobalEmotesResponse>
|
||||
);
|
||||
}, QueryDefaults);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const emotes = [];
|
||||
|
||||
for (const channelEmote of data) {
|
||||
emotes.push({
|
||||
id: channelEmote.id,
|
||||
code: channelEmote.code,
|
||||
urls: {
|
||||
small: `https://cdn.betterttv.net/emote/${channelEmote.id}/1x`,
|
||||
medium: `https://cdn.betterttv.net/emote/${channelEmote.id}/2x`,
|
||||
big: `https://cdn.betterttv.net/emote/${channelEmote.id}/3x`,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return emotes;
|
||||
}
|
43
web/src/hooks/useFfzChannelEmotes.ts
Normal file
43
web/src/hooks/useFfzChannelEmotes.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { useQuery } from "react-query";
|
||||
import { QueryDefaults } from "../store";
|
||||
import { EmoteSet, FfzChannelEmotesResponse } from "../types/Ffz";
|
||||
import { ThirdPartyEmote } from "../types/ThirdPartyEmote";
|
||||
|
||||
export function useFfzChannelEmotes(channelId: string): Array<ThirdPartyEmote> {
|
||||
const { isLoading, error, data } = useQuery(`ffz:channel:${channelId}`, () => {
|
||||
if (channelId === "") {
|
||||
return Promise.resolve({sets: {}});
|
||||
}
|
||||
|
||||
return fetch(`https://api.frankerfacez.com/v1/room/id/${channelId}`).then(res =>
|
||||
res.json() as Promise<FfzChannelEmotesResponse>
|
||||
);
|
||||
}, QueryDefaults);
|
||||
|
||||
if (isLoading || !data?.sets) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const emotes = [];
|
||||
|
||||
for (const set of Object.values(data.sets) as Array<EmoteSet>) {
|
||||
for (const channelEmote of set.emoticons) {
|
||||
emotes.push({
|
||||
id: String(channelEmote.id),
|
||||
code: channelEmote.name,
|
||||
urls: {
|
||||
small: channelEmote.urls["1"],
|
||||
medium: channelEmote.urls["2"],
|
||||
big: channelEmote.urls["4"],
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return emotes;
|
||||
}
|
39
web/src/hooks/useFfzGlobalEmotes.ts
Normal file
39
web/src/hooks/useFfzGlobalEmotes.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useQuery } from "react-query";
|
||||
import { QueryDefaults } from "../store";
|
||||
import { EmoteSet, FfzGlobalEmotesResponse } from "../types/Ffz";
|
||||
import { ThirdPartyEmote } from "../types/ThirdPartyEmote";
|
||||
|
||||
export function useFfzGlobalEmotes(): Array<ThirdPartyEmote> {
|
||||
const { isLoading, error, data } = useQuery("ffz:global", () => {
|
||||
return fetch("https://api.frankerfacez.com/v1/set/global").then(res =>
|
||||
res.json() as Promise<FfzGlobalEmotesResponse>
|
||||
);
|
||||
}, QueryDefaults);
|
||||
|
||||
if (isLoading || !data?.sets) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const emotes = [];
|
||||
|
||||
for (const set of Object.values(data.sets) as Array<EmoteSet>) {
|
||||
for (const channelEmote of set.emoticons) {
|
||||
emotes.push({
|
||||
id: String(channelEmote.id),
|
||||
code: channelEmote.name,
|
||||
urls: {
|
||||
small: channelEmote.urls["1"],
|
||||
medium: channelEmote.urls["2"],
|
||||
big: channelEmote.urls["4"],
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return emotes;
|
||||
}
|
43
web/src/hooks/useLocalStorage.ts
Normal file
43
web/src/hooks/useLocalStorage.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { useState } from "react";
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
|
||||
// State to store our value
|
||||
// Pass initial state function to useState so logic is only executed once
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
try {
|
||||
// Get from local storage by key
|
||||
const item = window.localStorage.getItem(key);
|
||||
// Parse stored json or if none return initialValue
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
// If error also return initialValue
|
||||
console.log(error);
|
||||
setValue(initialValue);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Return a wrapped version of useState's setter function that ...
|
||||
// ... persists the new value to localStorage.
|
||||
const setValue = (value: T): void => {
|
||||
try {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
// Save state
|
||||
setStoredValue(valueToStore);
|
||||
// Save to local storage
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
// A more advanced implementation would handle the error case
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
let returnValue = storedValue;
|
||||
if (typeof initialValue === "object") {
|
||||
returnValue = { ...initialValue, ...storedValue };
|
||||
}
|
||||
|
||||
return [returnValue, setValue];
|
||||
}
|
70
web/src/hooks/useLog.ts
Normal file
70
web/src/hooks/useLog.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { useContext } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { isUserId } from "../services/isUserId";
|
||||
import { store } from "../store";
|
||||
import { Emote, LogMessage, UserLogResponse } from "../types/log";
|
||||
|
||||
|
||||
|
||||
export function useLog(channel: string, username: string, year: string, month: string): Array<LogMessage> {
|
||||
const { state } = useContext(store);
|
||||
|
||||
const { data } = useQuery<Array<LogMessage>>(`${channel}:${username}:${year}:${month}`, () => {
|
||||
if (channel && username) {
|
||||
const channelIsId = isUserId(channel);
|
||||
const usernameIsId = isUserId(username);
|
||||
|
||||
const queryUrl = new URL(`${state.apiBaseUrl}/channel${channelIsId ? "id" : ""}/${channel}/user${usernameIsId ? "id" : ""}/${username}/${year}/${month}?reverse&json`);
|
||||
|
||||
return fetch(queryUrl.toString()).then((response) => {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
throw Error(response.statusText);
|
||||
}).then(response => response.json()).then((data: UserLogResponse) => {
|
||||
const messages: Array<LogMessage> = [];
|
||||
|
||||
for (const msg of data.messages) {
|
||||
messages.push({ ...msg, timestamp: new Date(msg.timestamp), emotes: parseEmotes(msg.text, msg.tags["emotes"]) })
|
||||
}
|
||||
|
||||
return messages;
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}, { refetchOnWindowFocus: false, refetchOnReconnect: false });
|
||||
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
function parseEmotes(messageText: string, emotes: string | undefined): Array<Emote> {
|
||||
const parsed: Array<Emote> = [];
|
||||
if (!emotes) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const groups = emotes.split(";");
|
||||
|
||||
for (const group of groups) {
|
||||
const [id, positions] = group.split(":");
|
||||
const positionGroups = positions.split(",");
|
||||
|
||||
for (const positionGroup of positionGroups) {
|
||||
const [startPos, endPos] = positionGroup.split("-");
|
||||
|
||||
const startIndex = Number(startPos);
|
||||
const endIndex = Number(endPos) + 1;
|
||||
|
||||
parsed.push({
|
||||
id,
|
||||
startIndex: startIndex,
|
||||
endIndex: endIndex,
|
||||
code: messageText.substr(startIndex, startIndex + endIndex)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
16
web/src/hooks/useThirdPartyEmotes.ts
Normal file
16
web/src/hooks/useThirdPartyEmotes.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { ThirdPartyEmote } from "../types/ThirdPartyEmote";
|
||||
import { useBttvChannelEmotes } from "./useBttvChannelEmotes";
|
||||
import { useBttvGlobalEmotes } from "./useBttvGlobalEmotes";
|
||||
import { useFfzChannelEmotes } from "./useFfzChannelEmotes";
|
||||
import { useFfzGlobalEmotes } from "./useFfzGlobalEmotes";
|
||||
|
||||
export function useThirdPartyEmotes(channelId: string): Array<ThirdPartyEmote> {
|
||||
const thirdPartyEmotes: Array<ThirdPartyEmote> = [
|
||||
...useBttvChannelEmotes(channelId),
|
||||
...useFfzChannelEmotes(channelId),
|
||||
...useBttvGlobalEmotes(),
|
||||
...useFfzGlobalEmotes(),
|
||||
];
|
||||
|
||||
return thirdPartyEmotes;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
<!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="#282828">
|
||||
<title>logsearch</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,6 +0,0 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './js/App';
|
||||
import './scss/app.scss';
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
33
web/src/index.tsx
Normal file
33
web/src/index.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import { useContext } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { ReactQueryCacheProvider } from 'react-query';
|
||||
import { Page } from './components/Page';
|
||||
import { StateProvider, store } from './store';
|
||||
import { unstable_createMuiStrictModeTheme as createMuiTheme } from '@material-ui/core';
|
||||
import { ThemeProvider } from '@material-ui/core/styles';
|
||||
|
||||
const pageTheme = createMuiTheme({
|
||||
palette: {
|
||||
type: 'dark'
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
const { state } = useContext(store);
|
||||
|
||||
return <ReactQueryCacheProvider queryCache={state.queryCache}>
|
||||
<Page />
|
||||
</ReactQueryCacheProvider>
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<StateProvider>
|
||||
<ThemeProvider theme={pageTheme}>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StateProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
|
@ -1,24 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import setBttvChannelEmotes from "./setBttvChannelEmotes";
|
||||
|
||||
export default function (channelid) {
|
||||
return function (dispatch, getState) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch("https://api.betterttv.net/3/cached/users/twitch/" + channelid).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(setBttvChannelEmotes(json))
|
||||
|
||||
resolve();
|
||||
}).catch(err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import setBttvEmotes from "./setBttvEmotes";
|
||||
|
||||
export default function () {
|
||||
return function (dispatch, getState) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch("https://api.betterttv.net/3/cached/emotes/global").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(setBttvEmotes(json))
|
||||
|
||||
resolve();
|
||||
}).catch(err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import setChannels from "./setChannels";
|
||||
|
||||
export default function () {
|
||||
return function (dispatch, getState) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`${getState().apiBaseUrl}/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();
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import setFfzChannelEmotes from "./setFfzChannelEmotes";
|
||||
|
||||
export default function (channelid) {
|
||||
return function (dispatch, getState) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch("https://api.frankerfacez.com/v1/room/id/" + channelid).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(setFfzChannelEmotes(json))
|
||||
|
||||
resolve();
|
||||
}).catch(err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
import setLogs from "./setLogs";
|
||||
import setLoading from "./setLoading";
|
||||
import Log from "../model/Log";
|
||||
|
||||
export default function (channel, username, year, month) {
|
||||
return function (dispatch, getState) {
|
||||
return new Promise((resolve, reject) => {
|
||||
channel = channel || getState().channel;
|
||||
username = username || getState().username;
|
||||
channel = channel.toLowerCase();
|
||||
username = username.toLowerCase();
|
||||
|
||||
let channelPath = "channel";
|
||||
if (channel.startsWith("id:")) {
|
||||
channelPath = "id";
|
||||
}
|
||||
let usernamePath = "user";
|
||||
if (username.startsWith("id:")) {
|
||||
usernamePath = "userid";
|
||||
}
|
||||
|
||||
dispatch(setLoading(true));
|
||||
|
||||
const url = `${getState().apiBaseUrl}/${channelPath}/${channel.replace("id:", "")}/${usernamePath}/${username.replace("id:", "")}/${year}/${month}?reverse`;
|
||||
fetch(url, { headers: { "Content-Type": "application/json" } }).then((response) => {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response
|
||||
} else {
|
||||
var error = new Error(response.statusText)
|
||||
error.response = response
|
||||
dispatch(setLoading(false));
|
||||
reject(error);
|
||||
}
|
||||
}).then((response) => {
|
||||
return response.json()
|
||||
}).then((json) => {
|
||||
for (let value of json.messages) {
|
||||
value.timestamp = Date.parse(value.timestamp)
|
||||
}
|
||||
|
||||
const logs = {...getState().logs};
|
||||
|
||||
logs[`${year}-${month}`] = new Log(year, month, json.messages, true);
|
||||
|
||||
dispatch(setLogs(logs));
|
||||
dispatch(setLoading(false));
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
dispatch(setLoading(false));
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
import setLogs from "./setLogs";
|
||||
import setLoading from "./setLoading";
|
||||
import Log from "../model/Log";
|
||||
|
||||
export default function (channel, username, year, month) {
|
||||
return function (dispatch, getState) {
|
||||
return new Promise((resolve, reject) => {
|
||||
channel = channel || getState().channel;
|
||||
username = username || getState().username;
|
||||
channel = channel.toLowerCase();
|
||||
username = username.toLowerCase();
|
||||
|
||||
dispatch(setLoading(true));
|
||||
|
||||
let channelPath = "channel";
|
||||
if (channel.startsWith("id:")) {
|
||||
channelPath = "id";
|
||||
}
|
||||
let usernamePath = "user";
|
||||
if (username.startsWith("id:")) {
|
||||
usernamePath = "userid";
|
||||
}
|
||||
|
||||
const logs = {}
|
||||
|
||||
fetchAvailableLogs(getState().apiBaseUrl, channel, username).then(data => {
|
||||
data.availableLogs.map(log => logs[`${log.year}-${log.month}`] = new Log(log.year, log.month, [], false));
|
||||
|
||||
if (Object.keys(logs).length === 0) {
|
||||
dispatch(setLoading(false));
|
||||
reject(new Error("not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
year = year || data.availableLogs[0].year;
|
||||
month = month || data.availableLogs[0].month;
|
||||
|
||||
const url = `${getState().apiBaseUrl}/${channelPath}/${channel.replace("id:", "")}/${usernamePath}/${username.replace("id:", "")}/${year}/${month}?reverse`;
|
||||
fetch(url, { headers: { "Content-Type": "application/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) => {
|
||||
for (let value of json.messages) {
|
||||
value.timestamp = Date.parse(value.timestamp)
|
||||
}
|
||||
|
||||
logs[`${year}-${month}`] = new Log(year, month, json.messages, true);
|
||||
|
||||
dispatch(setLogs(logs));
|
||||
dispatch(setLoading(false));
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
dispatch(setLoading(false));
|
||||
reject(error);
|
||||
});
|
||||
}).catch((error) => {
|
||||
dispatch(setLoading(false));
|
||||
reject(new Error("not found"));
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function fetchAvailableLogs(baseUrl, channel, username) {
|
||||
let channelQuery = "channel=" + channel;
|
||||
if (channel.startsWith("id:")) {
|
||||
channelQuery = "channelid" + channel.replace("id:", "")
|
||||
}
|
||||
let userQuery = "user=" + username;
|
||||
if (username.startsWith("id:")) {
|
||||
userQuery = "userid=" + username.replace("id:", "")
|
||||
}
|
||||
|
||||
return fetch(`${baseUrl}/list?${channelQuery}&${userQuery}`, { headers: { "Content-Type": "application/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()
|
||||
});
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
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();
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
};
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default (bttvChannelEmotes) => (dispatch) => {
|
||||
dispatch({
|
||||
type: 'SET_BTTV_CHANNEL_EMOTES',
|
||||
bttvChannelEmotes: bttvChannelEmotes
|
||||
});
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default (bttvEmotes) => (dispatch) => {
|
||||
dispatch({
|
||||
type: 'SET_BTTV_EMOTES',
|
||||
bttvEmotes: bttvEmotes
|
||||
});
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default (channels) => (dispatch) => {
|
||||
dispatch({
|
||||
type: 'SET_CHANNELS',
|
||||
channels: channels
|
||||
});
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
export default (channel, username) => (dispatch) => {
|
||||
dispatch({
|
||||
type: 'SET_CURRENT',
|
||||
channel: channel.trim(),
|
||||
username: username.trim()
|
||||
});
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default (ffzChannelEmotes) => (dispatch) => {
|
||||
dispatch({
|
||||
type: 'SET_FFZ_CHANNEL_EMOTES',
|
||||
ffzChannelEmotes: ffzChannelEmotes
|
||||
});
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default (loading) => (dispatch) => {
|
||||
dispatch({
|
||||
type: 'SET_LOADING',
|
||||
loading: loading
|
||||
});
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default (logs) => (dispatch) => {
|
||||
dispatch({
|
||||
type: 'SET_LOGS',
|
||||
logs: logs
|
||||
});
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
export default (settings) => (dispatch) => {
|
||||
localStorage.setItem('settings', JSON.stringify(settings));
|
||||
dispatch({
|
||||
type: 'SET_SETTINGS',
|
||||
settings: settings
|
||||
});
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default (twitchEmotes) => (dispatch) => {
|
||||
dispatch({
|
||||
type: 'SET_TWITCH_EMOTES',
|
||||
twitchEmotes: twitchEmotes
|
||||
});
|
||||
}
|
|
@ -1,408 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2017 - today Stanko Tadić
|
||||
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const ANIMATION_STATE_CLASSES = {
|
||||
animating: 'rah-animating',
|
||||
animatingUp: 'rah-animating--up',
|
||||
animatingDown: 'rah-animating--down',
|
||||
animatingToHeightZero: 'rah-animating--to-height-zero',
|
||||
animatingToHeightAuto: 'rah-animating--to-height-auto',
|
||||
animatingToHeightSpecific: 'rah-animating--to-height-specific',
|
||||
static: 'rah-static',
|
||||
staticHeightZero: 'rah-static--height-zero',
|
||||
staticHeightAuto: 'rah-static--height-auto',
|
||||
staticHeightSpecific: 'rah-static--height-specific',
|
||||
};
|
||||
|
||||
const PROPS_TO_OMIT = [
|
||||
'animateOpacity',
|
||||
'animationStateClasses',
|
||||
'applyInlineTransitions',
|
||||
'children',
|
||||
'contentClassName',
|
||||
'delay',
|
||||
'duration',
|
||||
'easing',
|
||||
'height',
|
||||
'onAnimationEnd',
|
||||
'onAnimationStart',
|
||||
];
|
||||
|
||||
function omit(obj, ...keys) {
|
||||
if (!keys.length) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const res = {};
|
||||
const objectKeys = Object.keys(obj);
|
||||
|
||||
for (let i = 0; i < objectKeys.length; i++) {
|
||||
const key = objectKeys[i];
|
||||
|
||||
if (keys.indexOf(key) === -1) {
|
||||
res[key] = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// Start animation helper using nested requestAnimationFrames
|
||||
function startAnimationHelper(callback) {
|
||||
const requestAnimationFrameIDs = [];
|
||||
|
||||
requestAnimationFrameIDs[0] = requestAnimationFrame(() => {
|
||||
requestAnimationFrameIDs[1] = requestAnimationFrame(() => {
|
||||
callback();
|
||||
});
|
||||
});
|
||||
|
||||
return requestAnimationFrameIDs;
|
||||
}
|
||||
|
||||
function cancelAnimationFrames(requestAnimationFrameIDs) {
|
||||
requestAnimationFrameIDs.forEach(id => cancelAnimationFrame(id));
|
||||
}
|
||||
|
||||
function isNumber(n) {
|
||||
return !isNaN(parseFloat(n)) && isFinite(n);
|
||||
}
|
||||
|
||||
function isPercentage(height) {
|
||||
// Percentage height
|
||||
return typeof height === 'string' &&
|
||||
height.search('%') === height.length - 1 &&
|
||||
isNumber(height.substr(0, height.length - 1));
|
||||
}
|
||||
|
||||
function runCallback(callback, params) {
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback(params);
|
||||
}
|
||||
}
|
||||
|
||||
const AnimateHeight = class extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.animationFrameIDs = [];
|
||||
|
||||
let height = 'auto';
|
||||
let overflow = 'visible';
|
||||
|
||||
if (isNumber(props.height)) {
|
||||
// If value is string "0" make sure we convert it to number 0
|
||||
height = props.height < 0 || props.height === '0' ? 0 : props.height;
|
||||
overflow = 'hidden';
|
||||
} else if (isPercentage(props.height)) {
|
||||
// If value is string "0%" make sure we convert it to number 0
|
||||
height = props.height === '0%' ? 0 : props.height;
|
||||
overflow = 'hidden';
|
||||
}
|
||||
|
||||
this.animationStateClasses = { ...ANIMATION_STATE_CLASSES, ...props.animationStateClasses };
|
||||
|
||||
const animationStateClasses = this.getStaticStateClasses(height);
|
||||
|
||||
this.state = {
|
||||
animationStateClasses,
|
||||
height,
|
||||
overflow,
|
||||
shouldUseTransitions: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { height } = this.state;
|
||||
|
||||
// Hide content if height is 0 (to prevent tabbing into it)
|
||||
// Check for contentElement is added cause this would fail in tests (react-test-renderer)
|
||||
// Read more here: https://github.com/Stanko/react-animate-height/issues/17
|
||||
if (this.contentElement && this.contentElement.style) {
|
||||
this.hideContent(height);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
delay,
|
||||
duration,
|
||||
height,
|
||||
onAnimationEnd,
|
||||
onAnimationStart,
|
||||
} = this.props;
|
||||
|
||||
// Check if 'height' prop has changed
|
||||
if (this.contentElement && height !== prevProps.height) {
|
||||
// Remove display: none from the content div
|
||||
// if it was hidden to prevent tabbing into it
|
||||
this.showContent(prevState.height);
|
||||
|
||||
// Cache content height
|
||||
this.contentElement.style.overflow = 'hidden';
|
||||
const contentHeight = this.contentElement.offsetHeight;
|
||||
this.contentElement.style.overflow = '';
|
||||
|
||||
// set total animation time
|
||||
const totalDuration = duration + delay;
|
||||
|
||||
let newHeight = null;
|
||||
const timeoutState = {
|
||||
height: null, // it will be always set to either 'auto' or specific number
|
||||
overflow: 'hidden',
|
||||
};
|
||||
const isCurrentHeightAuto = prevState.height === 'auto';
|
||||
|
||||
|
||||
if (isNumber(height)) {
|
||||
// If value is string "0" make sure we convert it to number 0
|
||||
newHeight = height < 0 || height === '0' ? 0 : height;
|
||||
timeoutState.height = newHeight;
|
||||
} else if (isPercentage(height)) {
|
||||
// If value is string "0%" make sure we convert it to number 0
|
||||
newHeight = height === '0%' ? 0 : height;
|
||||
timeoutState.height = newHeight;
|
||||
} else {
|
||||
// If not, animate to content height
|
||||
// and then reset to auto
|
||||
newHeight = contentHeight; // TODO solve contentHeight = 0
|
||||
timeoutState.height = 'auto';
|
||||
timeoutState.overflow = null;
|
||||
}
|
||||
|
||||
if (isCurrentHeightAuto) {
|
||||
// This is the height to be animated to
|
||||
timeoutState.height = newHeight;
|
||||
|
||||
// If previous height was 'auto'
|
||||
// set starting height explicitly to be able to use transition
|
||||
newHeight = contentHeight;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Animation classes
|
||||
let animationStateClasses = [this.animationStateClasses.animating];
|
||||
|
||||
if (prevProps.height === 'auto' || height < prevProps.height) {
|
||||
animationStateClasses.push(this.animationStateClasses.animatingUp);
|
||||
}
|
||||
if (height === 'auto' || height > prevProps.height) {
|
||||
animationStateClasses.push(this.animationStateClasses.animatingDown);
|
||||
}
|
||||
if (timeoutState.height === 0) {
|
||||
animationStateClasses.push(this.animationStateClasses.animatingToHeightZero);
|
||||
}
|
||||
if (timeoutState.height === 'auto') {
|
||||
animationStateClasses.push(this.animationStateClasses.animatingToHeightAuto);
|
||||
}
|
||||
if (timeoutState.height > 0) {
|
||||
animationStateClasses.push(this.animationStateClasses.animatingToHeightSpecific);
|
||||
}
|
||||
|
||||
animationStateClasses = animationStateClasses.join(" ");
|
||||
|
||||
// Animation classes to be put after animation is complete
|
||||
const timeoutAnimationStateClasses = this.getStaticStateClasses(timeoutState.height);
|
||||
|
||||
// Set starting height and animating classes
|
||||
// We are safe to call set state as it will not trigger infinite loop
|
||||
// because of the "height !== prevProps.height" check
|
||||
this.setState({ // eslint-disable-line react/no-did-update-set-state
|
||||
animationStateClasses,
|
||||
height: newHeight,
|
||||
overflow: 'hidden',
|
||||
// When animating from 'auto' we first need to set fixed height
|
||||
// that change should be animated
|
||||
shouldUseTransitions: !isCurrentHeightAuto,
|
||||
});
|
||||
|
||||
// Clear timeouts
|
||||
clearTimeout(this.timeoutID);
|
||||
clearTimeout(this.animationClassesTimeoutID);
|
||||
|
||||
if (isCurrentHeightAuto) {
|
||||
// When animating from 'auto' we use a short timeout to start animation
|
||||
// after setting fixed height above
|
||||
timeoutState.shouldUseTransitions = true;
|
||||
|
||||
cancelAnimationFrames(this.animationFrameIDs);
|
||||
this.animationFrameIDs = startAnimationHelper(() => {
|
||||
this.setState(timeoutState);
|
||||
|
||||
// ANIMATION STARTS, run a callback if it exists
|
||||
runCallback(onAnimationStart, { newHeight: timeoutState.height });
|
||||
});
|
||||
|
||||
// Set static classes and remove transitions when animation ends
|
||||
this.animationClassesTimeoutID = setTimeout(() => {
|
||||
this.setState({
|
||||
animationStateClasses: timeoutAnimationStateClasses,
|
||||
shouldUseTransitions: false,
|
||||
});
|
||||
|
||||
// ANIMATION ENDS
|
||||
// Hide content if height is 0 (to prevent tabbing into it)
|
||||
this.hideContent(timeoutState.height);
|
||||
// Run a callback if it exists
|
||||
runCallback(onAnimationEnd, { newHeight: timeoutState.height });
|
||||
}, totalDuration);
|
||||
} else {
|
||||
// ANIMATION STARTS, run a callback if it exists
|
||||
runCallback(onAnimationStart, { newHeight });
|
||||
|
||||
// Set end height, classes and remove transitions when animation is complete
|
||||
this.timeoutID = setTimeout(() => {
|
||||
timeoutState.animationStateClasses = timeoutAnimationStateClasses;
|
||||
timeoutState.shouldUseTransitions = false;
|
||||
|
||||
this.setState(timeoutState);
|
||||
|
||||
// ANIMATION ENDS
|
||||
// If height is auto, don't hide the content
|
||||
// (case when element is empty, therefore height is 0)
|
||||
if (height !== 'auto') {
|
||||
// Hide content if height is 0 (to prevent tabbing into it)
|
||||
this.hideContent(newHeight); // TODO solve newHeight = 0
|
||||
}
|
||||
// Run a callback if it exists
|
||||
runCallback(onAnimationEnd, { newHeight });
|
||||
}, totalDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
cancelAnimationFrames(this.animationFrameIDs);
|
||||
|
||||
clearTimeout(this.timeoutID);
|
||||
clearTimeout(this.animationClassesTimeoutID);
|
||||
|
||||
this.timeoutID = null;
|
||||
this.animationClassesTimeoutID = null;
|
||||
this.animationStateClasses = null;
|
||||
}
|
||||
|
||||
showContent(height) {
|
||||
if (height === 0) {
|
||||
this.contentElement.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
hideContent(newHeight) {
|
||||
if (newHeight === 0) {
|
||||
this.contentElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
getStaticStateClasses(height) {
|
||||
const classes = [this.animationStateClasses.static];
|
||||
|
||||
if (height === 0) {
|
||||
classes.push(this.animationStateClasses.staticHeightZero);
|
||||
}
|
||||
if (height > 0) {
|
||||
classes.push(this.animationStateClasses.staticHeightSpecific);
|
||||
}
|
||||
if (height === 'auto') {
|
||||
classes.push(this.animationStateClasses.staticHeightAuto);
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
animateOpacity,
|
||||
applyInlineTransitions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
duration,
|
||||
easing,
|
||||
delay,
|
||||
style,
|
||||
} = this.props;
|
||||
const {
|
||||
height,
|
||||
overflow,
|
||||
animationStateClasses,
|
||||
shouldUseTransitions,
|
||||
} = this.state;
|
||||
|
||||
|
||||
const componentStyle = {
|
||||
...style,
|
||||
height,
|
||||
overflow: overflow || style.overflow,
|
||||
};
|
||||
|
||||
if (shouldUseTransitions && applyInlineTransitions) {
|
||||
componentStyle.transition = `height ${duration}ms ${easing} ${delay}ms`;
|
||||
|
||||
// Include transition passed through styles
|
||||
if (style.transition) {
|
||||
componentStyle.transition = `${style.transition}, ${componentStyle.transition}`;
|
||||
}
|
||||
|
||||
// Add webkit vendor prefix still used by opera, blackberry...
|
||||
componentStyle.WebkitTransition = componentStyle.transition;
|
||||
}
|
||||
|
||||
const contentStyle = {};
|
||||
|
||||
if (animateOpacity) {
|
||||
contentStyle.transition = `opacity ${duration}ms ${easing} ${delay}ms`;
|
||||
// Add webkit vendor prefix still used by opera, blackberry...
|
||||
contentStyle.WebkitTransition = contentStyle.transition;
|
||||
|
||||
if (height === 0) {
|
||||
contentStyle.opacity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let componentClasses = [animationStateClasses];
|
||||
if (className) {
|
||||
componentClasses.push(className);
|
||||
}
|
||||
componentClasses = componentClasses.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
{...omit(this.props, ...PROPS_TO_OMIT)}
|
||||
aria-hidden={height === 0}
|
||||
className={componentClasses}
|
||||
style={componentStyle}
|
||||
>
|
||||
<div
|
||||
className={contentClassName}
|
||||
style={contentStyle}
|
||||
ref={el => this.contentElement = el}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AnimateHeight.defaultProps = {
|
||||
animateOpacity: false,
|
||||
animationStateClasses: ANIMATION_STATE_CLASSES,
|
||||
applyInlineTransitions: true,
|
||||
duration: 250,
|
||||
delay: 0,
|
||||
easing: 'ease',
|
||||
style: {},
|
||||
};
|
||||
|
||||
export default AnimateHeight;
|
|
@ -1,113 +0,0 @@
|
|||
import React, { Component } from "react";
|
||||
|
||||
export default class AutocompleteInput extends Component {
|
||||
|
||||
state = {
|
||||
focused: false,
|
||||
previewValue: null,
|
||||
selectedIndex: -1,
|
||||
};
|
||||
|
||||
input;
|
||||
|
||||
render() {
|
||||
return <div className="AutocompleteInput">
|
||||
<input
|
||||
type="text"
|
||||
ref={el => this.input = el}
|
||||
placeholder={this.props.placeholder}
|
||||
onChange={this.handleChange}
|
||||
onFocus={() => this.setState({ focused: true })}
|
||||
onBlur={this.handleBlur}
|
||||
value={this.state.previewValue ?? this.props.value}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
/>
|
||||
{this.state.focused && <ul>
|
||||
{this.getAutocompletions()
|
||||
.map((completion, index) =>
|
||||
<li className={index === this.state.selectedIndex ? "selected" : ""} key={completion} onClick={() => this.handleClick(completion)} onMouseDown={e => e.preventDefault()}>
|
||||
{completion}
|
||||
</li>
|
||||
)}
|
||||
</ul>}
|
||||
</div>
|
||||
}
|
||||
|
||||
getAutocompletions = () => {
|
||||
return this.props.autocompletions
|
||||
.filter(completion => completion.includes(this.props.value) && this.props.value !== "")
|
||||
.sort();
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
if (this.state.selectedIndex !== -1) {
|
||||
this.props.onChange(this.getAutocompletions()[this.state.selectedIndex]);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
focused: false, previewValue: null, selectedIndex: -1
|
||||
});
|
||||
}
|
||||
|
||||
handleChange = (e) => {
|
||||
this.props.onChange(e.target.value);
|
||||
}
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (["Backspace"].includes(e.key)) {
|
||||
if (this.state.selectedIndex !== -1 && this.getAutocompletions().length > 0) {
|
||||
this.props.onChange(this.getAutocompletions()[this.state.selectedIndex]);
|
||||
this.setState({ previewValue: null, selectedIndex: -1 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) {
|
||||
this.setState({ previewValue: null });
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
if (this.state.selectedIndex === this.props.autocompletions.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = this.state.selectedIndex + 1;
|
||||
|
||||
this.setState({
|
||||
selectedIndex: newIndex,
|
||||
previewValue: this.getAutocompletions()[newIndex]
|
||||
});
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (this.state.selectedIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = this.state.selectedIndex - 1;
|
||||
|
||||
this.setState({
|
||||
selectedIndex: newIndex,
|
||||
previewValue: this.getAutocompletions()[newIndex]
|
||||
});
|
||||
} else if (e.key === "Enter") {
|
||||
if (this.state.selectedIndex === -1) {
|
||||
this.props.onSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onChange(this.state.previewValue);
|
||||
this.props.onSubmit();
|
||||
this.setState({
|
||||
selectedIndex: -1,
|
||||
previewValue: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleClick = (completion) => {
|
||||
this.props.onChange(completion);
|
||||
this.input.blur();
|
||||
this.props.onSubmit();
|
||||
};
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
import React, { Component } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import setCurrent from "./../actions/setCurrent";
|
||||
import AutocompleteInput from "./AutocompleteInput";
|
||||
import loadLogs from "../actions/loadLogs";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
|
||||
class Filter extends Component {
|
||||
username;
|
||||
|
||||
state = {
|
||||
buttonText: "Show logs"
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.channel && this.props.username) {
|
||||
this.props.dispatch(loadLogs()).catch(err => {
|
||||
this.setState({buttonText: err.message});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<form className="filter" autoComplete="off" onSubmit={this.onSubmit}>
|
||||
<AutocompleteInput placeholder="channel" onChange={this.onChannelChange} value={this.props.channel} autocompletions={this.props.channels.map(channel => channel.name)} onSubmit={() => this.username.focus()} />
|
||||
<input
|
||||
ref={el => this.username = el}
|
||||
type="text"
|
||||
placeholder="username"
|
||||
onChange={this.onUsernameChange}
|
||||
value={this.props.username}
|
||||
/>
|
||||
<button type="submit" className="show-logs">{this.props.loading ? <LoadingSpinner /> : <>{this.state.buttonText}</>}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
onChannelChange = (channel) => {
|
||||
this.props.dispatch(setCurrent(channel, this.props.username));
|
||||
}
|
||||
|
||||
onUsernameChange = (e) => {
|
||||
this.props.dispatch(setCurrent(this.props.channel, e.target.value));
|
||||
}
|
||||
|
||||
onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
params.set('channel', this.props.channel);
|
||||
params.set('username', this.props.username);
|
||||
window.location.search = params.toString();
|
||||
|
||||
this.props.dispatch(loadLogs()).catch(err => {
|
||||
this.setState({buttonText: err.message});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
channels: state.channels,
|
||||
channel: state.channel,
|
||||
username: state.username,
|
||||
loading: state.loading
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Filter);
|
|
@ -1,11 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export default class LoadingSpinner extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="LoadingSpinner">
|
||||
<div></div><div></div><div></div><div></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import React, { Component } from "react";
|
||||
import Filter from "./Filter";
|
||||
import LogView from "./LogView";
|
||||
import { connect } from "react-redux";
|
||||
import loadChannels from "../actions/loadChannels";
|
||||
import loadBttvEmotes from "../actions/loadBttvEmotes";
|
||||
import Settings from "./Settings";
|
||||
|
||||
class LogSearch extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
props.dispatch(loadChannels());
|
||||
props.dispatch(loadBttvEmotes());
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="log-search">
|
||||
<Settings/>
|
||||
<Filter
|
||||
channels={this.props.channels}
|
||||
/>
|
||||
{Object.values(this.props.logs).map(log =>
|
||||
<LogView key={log.getTitle()} log={log} channel={this.props.channel} username={this.props.username} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
channels: state.channels,
|
||||
channel: state.channel,
|
||||
username: state.username,
|
||||
logs: state.logs,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(LogSearch);
|
|
@ -1,195 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import { connect } from "react-redux";
|
||||
import loadLog from '../actions/loadLog';
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import AnimateHeight from "./AnimateHeight";
|
||||
import { parse } from "irc-message";
|
||||
import loadBttvChannelEmotes from "../actions/loadBttvChannelEmotes";
|
||||
import loadFfzChannelEmotes from "../actions/loadFfzChannelEmotes";
|
||||
import Txt from '../icons/Txt';
|
||||
|
||||
class LogView extends Component {
|
||||
|
||||
state = {
|
||||
loading: false,
|
||||
height: 0,
|
||||
buttonText: "load",
|
||||
};
|
||||
|
||||
loadedBttvEmotes = false;
|
||||
loadedFfzEmotes = false;
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.log.messages.length > 0) {
|
||||
setTimeout(() => this.setState({ height: 'auto' }), 10);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.log.messages.length !== this.props.log.messages.length) {
|
||||
this.setState({
|
||||
height: 'auto'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.log.loaded === false) {
|
||||
return <div className="log-view not-loaded" onClick={this.loadLog}>
|
||||
<span>{this.props.log.getTitle()}</span>
|
||||
<button>{this.state.loading ? <LoadingSpinner /> : this.state.buttonText}</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"log-view"}>
|
||||
<a className="txt" target="__blank" href={`/channel/${this.props.channel}/user/${this.props.username}/${this.props.log.year}/${this.props.log.month}`}><Txt /></a>
|
||||
<AnimateHeight duration={500} easing={"ease-in-out"} height={this.state.height} animateOpacity>
|
||||
{this.props.log.messages.map((value, key) =>
|
||||
<div key={key} className="line">
|
||||
<span id={value.timestamp} className="timestamp">{this.formatDate(value.timestamp)}</span>{this.renderMessage(value)}
|
||||
</div>
|
||||
)}
|
||||
</AnimateHeight>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderMessage = (value) => {
|
||||
const msgObj = parse(value.raw);
|
||||
let message = this.htmlencode(value.text);
|
||||
|
||||
if (this.props.settings.showEmotes) {
|
||||
message = [...message];
|
||||
|
||||
if (this.loadedBttvEmotes !== msgObj.tags["room-id"]) {
|
||||
this.props.dispatch(loadBttvChannelEmotes(msgObj.tags["room-id"]));
|
||||
this.loadedBttvEmotes = msgObj.tags["room-id"];
|
||||
}
|
||||
|
||||
if (this.loadedFfzEmotes !== msgObj.tags["room-id"]) {
|
||||
this.props.dispatch(loadFfzChannelEmotes(msgObj.tags["room-id"]));
|
||||
this.loadedFfzEmotes = msgObj.tags["room-id"];
|
||||
}
|
||||
|
||||
const replacements = {};
|
||||
|
||||
if (msgObj.tags.emotes && msgObj.tags.emotes !== true) {
|
||||
for (const emoteString of msgObj.tags.emotes.split("/")) {
|
||||
if (typeof emoteString !== "string") {
|
||||
continue;
|
||||
}
|
||||
const [emoteId, occurences] = emoteString.split(":");
|
||||
if (typeof occurences !== "string") {
|
||||
continue;
|
||||
}
|
||||
for (const occurence of occurences.split(",")) {
|
||||
const [start, end] = occurence.split("-");
|
||||
replacements[Number(start)] = { length: Number(end) - Number(start), emoteId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message.forEach((char, key) => {
|
||||
if (typeof replacements[key] !== "undefined") {
|
||||
const emote = `<img src="${this.buildTwitchEmote(replacements[key].emoteId)}" alt="${message.slice(key, replacements[key].end).join("")}" />`;
|
||||
message[key] = emote;
|
||||
for (let i = key + 1; i < (key + replacements[key].length + 1); i++) {
|
||||
message[i] = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
message = message.join("");
|
||||
|
||||
const replacedEmoteCodes = {};
|
||||
|
||||
if (this.props.bttvChannelEmotes) {
|
||||
for (const emote of [...this.props.bttvChannelEmotes.channelEmotes, ...this.props.bttvChannelEmotes.sharedEmotes]) {
|
||||
if (replacedEmoteCodes[emote.code]) {
|
||||
continue;
|
||||
}
|
||||
replacedEmoteCodes[emote.code] = true;
|
||||
|
||||
const regex = new RegExp(`\\b(${emote.code})\\b`, "g");
|
||||
message = message.replace(regex, `<img src="${this.buildBttvEmote(emote.id)}" alt="${emote.code}" />`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.ffzChannelEmotes) {
|
||||
for (const emote of Object.values(this.props.ffzChannelEmotes.sets).map(set => set.emoticons).flat()) {
|
||||
if (replacedEmoteCodes[emote.name]) {
|
||||
continue;
|
||||
}
|
||||
replacedEmoteCodes[emote.name] = true;
|
||||
|
||||
const regex = new RegExp(`\\b(${emote.name})\\b`, "g");
|
||||
message = message.replace(regex, `<img src="${emote.urls[1]}" alt="${emote.name}" />`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.bttvEmotes) {
|
||||
for (const emote of this.props.bttvEmotes) {
|
||||
if (replacedEmoteCodes[emote.code]) {
|
||||
continue;
|
||||
}
|
||||
replacedEmoteCodes[emote.code] = true;
|
||||
|
||||
const regex = new RegExp(`\\b(${emote.code})\\b`, "g");
|
||||
message = message.replace(regex, `<img src="${this.buildBttvEmote(emote.id)}" alt="${emote.code}" />`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.settings.showDisplayName) {
|
||||
message = `${value.displayName}: ${message}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<p dangerouslySetInnerHTML={{ __html: message }}>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
loadLog = () => {
|
||||
this.setState({ loading: true });
|
||||
this.props.dispatch(loadLog(null, null, this.props.log.year, this.props.log.month)).then(() => this.setState({ loading: false })).catch(err => {
|
||||
console.error(err);
|
||||
this.setState({ loading: false, buttonText: err.message });
|
||||
});
|
||||
}
|
||||
|
||||
formatDate = (timestamp) => {
|
||||
return new Date(timestamp).toUTCString();
|
||||
}
|
||||
|
||||
buildTwitchEmote = (id) => {
|
||||
return `https://static-cdn.jtvnw.net/emoticons/v1/${id}/1.0`;
|
||||
}
|
||||
|
||||
buildBttvEmote = (id) => {
|
||||
return `https://cdn.betterttv.net/emote/${id}/1x`;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML entities encode
|
||||
*
|
||||
* @param {string} str Input text
|
||||
* @return {string} Filtered text
|
||||
*/
|
||||
htmlencode = (str) => {
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
bttvChannelEmotes: state.bttvChannelEmotes,
|
||||
bttvEmotes: state.bttvEmotes,
|
||||
ffzChannelEmotes: state.ffzChannelEmotes,
|
||||
settings: state.settings,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(LogView);
|
|
@ -1,25 +0,0 @@
|
|||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import setSettings from "../actions/setSettings";
|
||||
|
||||
const Settings = (props) => {
|
||||
|
||||
const toggleSetting = (setting) => {
|
||||
props.dispatch(setSettings({ ...props.settings, [setting]: !props.settings[setting] }))
|
||||
}
|
||||
|
||||
return <div className="settings">
|
||||
<div className="setting">
|
||||
<input type="checkbox" id="showEmotes" checked={props.settings.showEmotes} onChange={() => toggleSetting("showEmotes")} />
|
||||
<label htmlFor="showEmotes">show emotes</label>
|
||||
</div>
|
||||
<div className="setting">
|
||||
<input type="checkbox" id="showDisplayName" checked={props.settings.showDisplayName} onChange={() => toggleSetting("showDisplayName")} />
|
||||
<label htmlFor="showDisplayName">show displayName</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
settings: state.settings
|
||||
}))(Settings);
|
|
@ -1,16 +0,0 @@
|
|||
import * as React from "react"
|
||||
|
||||
function Txt(props) {
|
||||
return (
|
||||
<svg height={32} viewBox="0 0 32 32" width={32} {...props}>
|
||||
<title />
|
||||
<path
|
||||
d="M21 26v2.003A1.995 1.995 0 0119.003 30H3.997A2 2 0 012 27.993V5.007C2 3.898 2.9 3 4.009 3H14v6.002c0 1.111.898 1.998 2.006 1.998H21v2h-8.993A3.003 3.003 0 009 15.999V23A2.996 2.996 0 0012.007 26H21zM15 3v5.997c0 .554.451 1.003.99 1.003H21l-6-7zm-3.005 11C10.893 14 10 14.9 10 15.992v7.016A2 2 0 0011.995 25h17.01C30.107 25 31 24.1 31 23.008v-7.016A2 2 0 0029.005 14h-17.01zM14 17v6h1v-6h2v-1h-5v1h2zm6 2.5L18 16h1l1.5 2.625L22 16h1l-2 3.5 2 3.5h-1l-1.5-2.625L19 23h-1l2-3.5zm6-2.5v6h1v-6h2v-1h-5v1h2z"
|
||||
fill="#929292"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Txt
|
|
@ -1,23 +0,0 @@
|
|||
export default class Log {
|
||||
|
||||
constructor(year, month, messages = [], loaded = false) {
|
||||
const monthList = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ];
|
||||
this.title = `${monthList[month - 1]} ${year} `;
|
||||
this.year = year;
|
||||
this.month = month;
|
||||
this.messages = messages;
|
||||
this.loaded = loaded;
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return this.title;
|
||||
}
|
||||
|
||||
getMessages() {
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
getLoaded() {
|
||||
return this.loaded;
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
export default () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
return {
|
||||
apiBaseUrl: process.env.apiBaseUrl,
|
||||
channels: [],
|
||||
bttvChannelEmotes: null,
|
||||
ffzChannelEmotes: null,
|
||||
bttvEmotes: null,
|
||||
logs: {},
|
||||
loading: false,
|
||||
twitchEmotes: {},
|
||||
channel: urlParams.get("channel") || "",
|
||||
username: urlParams.get("username") || "",
|
||||
settings: JSON.parse(localStorage.getItem('settings')) || { showEmotes: true, showDisplayName: false },
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
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_CURRENT":
|
||||
return { ...state, channel: action.channel, username: action.username };
|
||||
case "SET_TWITCH_EMOTES":
|
||||
return { ...state, twitchEmotes: action.twitchEmotes };
|
||||
case "SET_BTTV_CHANNEL_EMOTES":
|
||||
return { ...state, bttvChannelEmotes: action.bttvChannelEmotes };
|
||||
case "SET_FFZ_CHANNEL_EMOTES":
|
||||
return { ...state, ffzChannelEmotes: action.ffzChannelEmotes };
|
||||
case "SET_BTTV_EMOTES":
|
||||
return { ...state, bttvEmotes: action.bttvEmotes };
|
||||
case "SET_SETTINGS":
|
||||
return { ...state, settings: action.settings };
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
};
|
1
web/src/react-app-env.d.ts
vendored
Normal file
1
web/src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
|
@ -1,13 +0,0 @@
|
|||
@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";
|
||||
@import "modules/AutocompleteInput";
|
||||
@import "modules/LoadingSpinner";
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: $grey-dark;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
input {
|
||||
color: white;
|
||||
}
|
||||
|
||||
label {
|
||||
color: white;
|
||||
}
|
||||
|
||||
button {
|
||||
background: $grey-dark;
|
||||
outline: none;
|
||||
color: white;
|
||||
border: 0;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: $grey-medium-dark;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
background: $grey-dark;
|
||||
outline: none;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
|
||||
&:focus {
|
||||
background: $grey-medium-dark;
|
||||
}
|
||||
|
||||
&::-webkit-input-placeholder {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
background: $grey-dark;
|
||||
color: white;
|
||||
padding: 5px;
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
.AutocompleteInput {
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ul {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: white;
|
||||
list-style-type: none;
|
||||
background: $grey-medium;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@include box-shadow(2);
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
|
||||
&:hover, &.selected {
|
||||
background: $grey-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
.LoadingSpinner {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
||||
div {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 10px;
|
||||
border: 10px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: #fff transparent transparent transparent;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
|
||||
@keyframes lds-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
.filter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 600px;
|
||||
background: $grey-medium;
|
||||
padding: 0.5rem;
|
||||
margin: 0 auto;
|
||||
border-radius: 3px;
|
||||
font-size: 1rem;
|
||||
@include box-shadow(1);
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0 0.5rem;
|
||||
|
||||
&::-webkit-input-placeholder {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.AutocompleteInput {
|
||||
width: 100%;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.show-logs {
|
||||
width: 200px;
|
||||
height: 35px;
|
||||
margin-left: 0.5rem;
|
||||
background: $dark-blue;
|
||||
|
||||
&:hover, &:active {
|
||||
background: $light-blue;
|
||||
}
|
||||
|
||||
.LoadingSpinner {
|
||||
position: relative;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
div {
|
||||
margin: 2px;
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
.log-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
|
||||
.settings {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding: 5px;
|
||||
display: inline-block;
|
||||
color: white;
|
||||
user-select: none;
|
||||
|
||||
.setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,116 +0,0 @@
|
|||
.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;
|
||||
margin-top: 1rem;
|
||||
color: white;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
background: $grey-light;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.not-loaded {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&:hover button {
|
||||
background: $light-blue;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 90px;
|
||||
height: 35px;
|
||||
white-space: nowrap;
|
||||
padding: 0.5rem 1rem;
|
||||
background: $dark-blue;
|
||||
|
||||
.LoadingSpinner {
|
||||
position: relative;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
div {
|
||||
margin: 2px;
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.txt {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 0;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
padding: 0.25rem;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
min-height: 24px;
|
||||
|
||||
p {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
@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);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
|
||||
$grey-lighter: #919191;
|
||||
$grey-light: #616161;
|
||||
$grey-medium: #424242;
|
||||
$grey-medium-dark: #282828;
|
||||
$grey-dark: #212121;
|
||||
|
||||
$dark-blue: #2980b9;
|
||||
$light-blue: #3498db;
|
3
web/src/services/isUserId.ts
Normal file
3
web/src/services/isUserId.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function isUserId(value: string) {
|
||||
return value.startsWith("id:");
|
||||
}
|
84
web/src/store.tsx
Normal file
84
web/src/store.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import React, { createContext, useState } from "react";
|
||||
import { QueryCache } from "react-query";
|
||||
import { useLocalStorage } from "./hooks/useLocalStorage";
|
||||
|
||||
export interface Settings {
|
||||
showEmotes: Setting,
|
||||
showName: Setting,
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
displayName: string,
|
||||
value: boolean,
|
||||
}
|
||||
|
||||
export interface State {
|
||||
settings: Settings,
|
||||
queryCache: QueryCache,
|
||||
apiBaseUrl: string,
|
||||
currentChannel: string | null,
|
||||
currentUsername: string | null,
|
||||
error: boolean,
|
||||
}
|
||||
|
||||
export type Action = Record<string, unknown>;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const defaultContext = {
|
||||
state: {
|
||||
queryCache: new QueryCache(),
|
||||
apiBaseUrl: process.env.REACT_APP_API_BASE_URL ?? window.location.protocol + "//" + window.location.host,
|
||||
settings: {
|
||||
showEmotes: {
|
||||
displayName: "Show Emotes",
|
||||
value: false,
|
||||
},
|
||||
showName: {
|
||||
displayName: "Show Name",
|
||||
value: true,
|
||||
}
|
||||
},
|
||||
currentChannel: url.searchParams.get("channel"),
|
||||
currentUsername: url.searchParams.get("username"),
|
||||
error: false,
|
||||
} as State,
|
||||
setState: (state: State) => { },
|
||||
setCurrents: (currentChannel: string | null = null, currentUsername: string | null = null) => { },
|
||||
setSettings: (newSettings: Settings) => { },
|
||||
};
|
||||
|
||||
const store = createContext(defaultContext);
|
||||
const { Provider } = store;
|
||||
|
||||
const StateProvider = ({ children }: { children: JSX.Element }): JSX.Element => {
|
||||
|
||||
const [settings, setSettingsStorage] = useLocalStorage("justlog:settings", defaultContext.state.settings);
|
||||
const [state, setState] = useState({ ...defaultContext.state, settings });
|
||||
|
||||
const setSettings = (newSettings: Settings) => {
|
||||
setSettingsStorage(newSettings);
|
||||
setState({ ...state, settings: newSettings });
|
||||
}
|
||||
|
||||
const setCurrents = (currentChannel: string | null = null, currentUsername: string | null = null) => {
|
||||
setState({ ...state, currentChannel, currentUsername, error: false });
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
if (currentChannel) {
|
||||
url.searchParams.set("channel", currentChannel);
|
||||
}
|
||||
if (currentUsername) {
|
||||
url.searchParams.set("username", currentUsername);
|
||||
}
|
||||
|
||||
window.history.replaceState({}, "justlog", url.toString());
|
||||
}
|
||||
|
||||
return <Provider value={{ state, setState, setSettings, setCurrents }}>{children}</Provider>;
|
||||
};
|
||||
|
||||
export { store, StateProvider };
|
||||
|
||||
export const QueryDefaults = {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
};
|
28
web/src/types/Bttv.ts
Normal file
28
web/src/types/Bttv.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
export interface BttvChannelEmotesResponse {
|
||||
// id: string;
|
||||
// bots: string[];
|
||||
channelEmotes: Emote[];
|
||||
sharedEmotes: Emote[];
|
||||
}
|
||||
|
||||
export type BttvGlobalEmotesResponse = Emote[];
|
||||
|
||||
export interface Emote {
|
||||
id: string;
|
||||
code: string;
|
||||
// imageType: ImageType;
|
||||
// userId?: string;
|
||||
// user?: User;
|
||||
}
|
||||
|
||||
export enum ImageType {
|
||||
GIF = "gif",
|
||||
PNG = "png",
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
providerId: string;
|
||||
}
|
59
web/src/types/Ffz.ts
Normal file
59
web/src/types/Ffz.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
export interface FfzChannelEmotesResponse {
|
||||
// room: Room;
|
||||
sets: Sets;
|
||||
}
|
||||
|
||||
export interface FfzGlobalEmotesResponse {
|
||||
sets: Sets;
|
||||
}
|
||||
|
||||
// export interface Room {
|
||||
// _id: number;
|
||||
// css: null;
|
||||
// display_name: string;
|
||||
// id: string;
|
||||
// is_group: boolean;
|
||||
// mod_urls: null;
|
||||
// moderator_badge: null;
|
||||
// set: number;
|
||||
// twitch_id: number;
|
||||
// user_badges: UserBadges;
|
||||
// }
|
||||
|
||||
// export interface UserBadges {
|
||||
// }
|
||||
|
||||
export interface Sets {
|
||||
[key: string]: EmoteSet;
|
||||
}
|
||||
|
||||
export interface EmoteSet {
|
||||
// _type: number;
|
||||
// css: null;
|
||||
// description: null;
|
||||
emoticons: Emoticon[];
|
||||
// icon: null;
|
||||
// id: number;
|
||||
// title: string;
|
||||
}
|
||||
|
||||
export interface Emoticon {
|
||||
// css: null;
|
||||
// height: number;
|
||||
// hidden: boolean;
|
||||
id: number;
|
||||
// margins: null;
|
||||
// modifier: boolean;
|
||||
name: string;
|
||||
// offset: null;
|
||||
// owner: Owner;
|
||||
// public: boolean;
|
||||
urls: { [key: string]: string };
|
||||
// width: number;
|
||||
}
|
||||
|
||||
export interface Owner {
|
||||
_id: number;
|
||||
display_name: string;
|
||||
name: string;
|
||||
}
|
11
web/src/types/ThirdPartyEmote.ts
Normal file
11
web/src/types/ThirdPartyEmote.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export interface ThirdPartyEmote {
|
||||
code: string,
|
||||
id: string,
|
||||
urls: EmoteUrls,
|
||||
}
|
||||
|
||||
export interface EmoteUrls {
|
||||
small: string,
|
||||
medium: string,
|
||||
big: string,
|
||||
}
|
27
web/src/types/log.ts
Normal file
27
web/src/types/log.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
export interface UserLogResponse {
|
||||
messages: RawLogMessage[],
|
||||
}
|
||||
|
||||
export interface LogMessage extends Omit<RawLogMessage, "timestamp"> {
|
||||
timestamp: Date,
|
||||
emotes: Array<Emote>,
|
||||
}
|
||||
|
||||
export interface RawLogMessage {
|
||||
text: string,
|
||||
username: string,
|
||||
displayName: string,
|
||||
channel: string,
|
||||
timestamp: string,
|
||||
type: number,
|
||||
raw: string,
|
||||
id: string,
|
||||
tags: Record<string, string>,
|
||||
}
|
||||
|
||||
export interface Emote {
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
code: string,
|
||||
id: string,
|
||||
}
|
26
web/tsconfig.json
Normal file
26
web/tsconfig.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const fs = require("fs");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
|
||||
module.exports = (env, options) => {
|
||||
const plugins = [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': {
|
||||
'apiBaseUrl': options.mode === 'development' ? '"http://localhost:8025"' : '""',
|
||||
}
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.resolve(__dirname, 'src', 'index.html')
|
||||
})
|
||||
];
|
||||
|
||||
return {
|
||||
entry: './src/index.jsx',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'public'),
|
||||
filename: 'bundle.[hash].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'],
|
||||
},
|
||||
plugins: plugins,
|
||||
stats: {
|
||||
// Config for minimal console.log mess.
|
||||
assets: false,
|
||||
colors: true,
|
||||
version: false,
|
||||
hash: false,
|
||||
timings: true,
|
||||
chunks: false,
|
||||
chunkModules: false,
|
||||
entrypoints: false,
|
||||
modules: false,
|
||||
}
|
||||
}
|
||||
};
|
8300
web/yarn.lock
8300
web/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue