Merge pull request #76 from gempir/render

Frontend v2
This commit is contained in:
gempir 2020-11-08 16:45:20 +01:00 committed by GitHub
commit ede371aae2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 8087 additions and 3234 deletions

View file

@ -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

View file

@ -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,
}
}

View file

@ -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 {

View file

@ -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,
}
}

View file

@ -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
View file

@ -0,0 +1 @@
REACT_APP_API_BASE_URL=http://localhost:8025

19
web/.gitignore vendored
View file

@ -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*

View file

@ -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
View 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>

View 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>
}

View 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>
}

View 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>
}

View 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>
}

View 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>;
};

View 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>;
}

View 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 />}&nbsp;&nbsp;{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>
);
}

View 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>;
}

View 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 ?? [];
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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;
}

View 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;
}

View file

@ -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>

View file

@ -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
View 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')
);

View file

@ -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>
);
}
}

View file

@ -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);
});
});
};
}

View file

@ -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);
});
});
};
}

View file

@ -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();
});
});
};
}

View file

@ -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);
});
});
};
}

View file

@ -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);
});
});
};
}

View file

@ -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()
});
}

View file

@ -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();
});
});
};
}

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
export default (channel, username) => (dispatch) => {
dispatch({
type: 'SET_CURRENT',
channel: channel.trim(),
username: username.trim()
});
}

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
export default (settings) => (dispatch) => {
localStorage.setItem('settings', JSON.stringify(settings));
dispatch({
type: 'SET_SETTINGS',
settings: settings
});
}

View file

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

View file

@ -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;

View file

@ -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();
};
}

View file

@ -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);

View file

@ -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>
);
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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

View file

@ -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;
}
}

View file

@ -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 },
}
}

View file

@ -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
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -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";

View file

@ -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;
}

View file

@ -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;
}

View file

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

View file

@ -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;
}
}
}
}

View file

@ -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);
}
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;

View file

@ -0,0 +1,3 @@
export function isUserId(value: string) {
return value.startsWith("id:");
}

84
web/src/store.tsx Normal file
View 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
View 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
View 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;
}

View 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
View 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
View 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"
]
}

View file

@ -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,
}
}
};

File diff suppressed because it is too large Load diff