Files
watchparty/admin/src/Dashboard.jsx
T

868 lines
26 KiB
React

import React, { Component } from "react";
import firebase from "firebase/app";
import "firebase/auth";
import "firebase/firestore";
import {
Button,
Card,
Table,
Tabs,
Tag,
Switch,
Input,
notification,
Divider,
Row,
Col,
PageHeader,
InputNumber,
Descriptions,
Badge,
Collapse,
Modal,
Select,
} from "antd";
import {
getPartyDashboardData,
getServerDashboardData,
postUpdateVersionActive,
postUpdateVideoSelectors,
postUpdateVideoNameSelectors,
postSendMessage,
} from "./Api";
import {
CheckCircleTwoTone,
CloseCircleTwoTone,
ReloadOutlined,
LoadingOutlined,
EnterOutlined,
DeleteOutlined,
PlusCircleFilled,
SaveFilled,
SaveOutlined,
LogoutOutlined,
SendOutlined,
PlusCircleOutlined,
} from "@ant-design/icons";
const PLAYER_SOURCES = [
{ text: "Netflix", value: "netflix." },
{ text: "Amazon", value: "amazon." },
{ text: "Hulu", value: "hulu." },
{ text: "Disney+", value: "disneyplus." },
{ text: "YouTube", value: "youtube." },
{ text: "HBO Now", value: "hbonow." },
{ text: "HBO Max", value: "hbomax." },
{ text: "Peacock", value: "peacocktv." },
{ text: "Google Play", value: "play.google." },
{ text: "Crunchyroll", value: "crunchyroll." },
];
const MESSAGE_TYPES = [
{
text: "REPLY",
value: "interactive/text",
},
{
text: "LINK",
value: "interactive/link",
},
{
text: "MESSAGE",
value: "",
},
];
const emptyMessage = {
type: "",
text: "",
uid: "<system>",
name: "",
endpoint: "",
// time set on send,
};
export default class Dashboard extends Component {
constructor(props) {
super(props);
this.state = {
partiesOverview: null,
activeTab: "1",
refreshing: true,
selectedParties: [],
nameSelectors: {},
selectors: {},
versions: [],
activeLimit: 5,
afkLimit: 10,
messageModel: false,
newMessage: emptyMessage,
};
this.serverStatusListener = null;
}
componentDidMount() {
this.getPartiesOverview();
this.getVersionsOverview();
}
getPartiesOverview = async () => {
getPartyDashboardData()
.then((res) => res.json())
.then((res) => {
this.setState({ partiesOverview: res.partyData, refreshing: false });
});
};
getVersionsOverview = () => {
getServerDashboardData()
.then((res) => res.json())
.then((res) => this.setState({ ...res.serverData, refreshing: false }));
};
refreshData = () => {
this.setState({ refreshing: true });
const { activeTab } = this.state;
switch (activeTab) {
case "1":
this.getPartiesOverview();
break;
case "2":
case "3":
case "4":
this.getVersionsOverview();
break;
default:
break;
}
};
tabChange = (activeTab) => {
this.setState({ activeTab });
};
updateVersionMessage = (message, version) => {
this.setState({ refreshing: true });
const { versions } = this.state;
const versionData = versions.filter(
(v) => v.version.split(".").join("") === version
)[0];
versionData.message = message;
this.updateVersion(version, versionData);
};
updateVersionActive = (active, version) => {
this.setState({ refreshing: true });
const { versions } = this.state;
const versionData = versions.filter(
(v) => v.version.split(".").join("") === version
)[0];
versionData.active = active;
this.updateVersion(version, versionData);
};
updateVersion = (version, versionData) => {
postUpdateVersionActive({ version, versionData })
.then(() => this.refreshData())
.catch((err) =>
notification.error({
description: err.message,
message: "Unable To Update",
})
);
};
getSourceName(source) {
return PLAYER_SOURCES.filter((p) => p.value === source)[0].text;
}
handleVideoSelectorChange = (e, s) => {
const { selectors } = this.state;
selectors[s] = e.target.value;
this.setState({ selectors });
};
updateVideoSelector = () => {
this.setState({ refreshing: true });
const { selectors } = this.state;
postUpdateVideoSelectors({ selectors })
.then(() => this.refreshData())
.catch((err) => {
notification.error({
description: err.message,
message: "Unable To Update",
});
this.setState({ refreshing: false });
});
};
handleVersionMessageChange = (e, v) => {
const { versions } = this.state;
versions.filter((vs) => vs.version === v)[0].message = e.target.value;
this.setState({ versions });
};
handleAlertTitleChange = (value, version, index) => {
const { versions } = this.state;
versions.filter((vs) => vs.version === version)[0].alerts[
index
].title = value;
this.setState({ versions });
};
handleAlertDescriptionChange = (value, version, index) => {
const { versions } = this.state;
versions.filter((vs) => vs.version === version)[0].alerts[
index
].description = value;
this.setState({ versions });
};
handleAlertLinkChange = (value, version, index) => {
const { versions } = this.state;
versions.filter((vs) => vs.version === version)[0].alerts[
index
].link = value;
this.setState({ versions });
};
handleAlertLinkTextChange = (value, version, index) => {
const { versions } = this.state;
versions.filter((vs) => vs.version === version)[0].alerts[
index
].linkText = value;
this.setState({ versions });
};
deleteAlert = (version, index) => {
const { versions } = this.state;
versions.filter((vs) => vs.version === version)[0].alerts.splice(index, 1);
if (
versions.filter((vs) => vs.version === version)[0].importantAlert ===
index ||
versions.filter((vs) => vs.version === version)[0].alerts.length < index
) {
versions.filter(
(vs) => vs.version === version
)[0].importantAlert = undefined;
}
this.setState({ versions });
};
addEmptyAlert = (version) => {
const { versions } = this.state;
versions
.filter((vs) => vs.version === version)[0]
.alerts.push({
title: "",
description: "",
link: "",
linkText: "",
});
this.setState({ versions });
};
saveAlerts = (version) => {
this.setState({ refreshing: true });
const { versions } = this.state;
const versionData = versions.filter((v) => v.version === version)[0];
if (versionData.alerts.length > 0)
this.updateVersion(version.split(".").join(""), versionData);
else {
notification.error({
message: "Missing Alerts",
description: "Must have atleast one alert",
});
this.setState({ refreshing: false });
}
};
changeImportantAlert = (active, version, index) => {
const { versions } = this.state;
if (active === false) {
versions.filter(
(vs) => vs.version === version
)[0].importantAlert = undefined;
} else {
versions.filter((vs) => vs.version === version)[0].importantAlert = index;
}
this.setState({ versions });
};
getAddMessageText = () => {
const { activeTab } = this.state;
switch (activeTab) {
case "1":
return "Send Message";
case "2":
return "Add Version";
case "3":
return "Add Video Selector";
case "4":
return "Add Name Selector";
default:
return "";
}
};
getPrettyTime = (t) => {
return t > 3600
? new Date(t * 1000).toISOString().substr(11, 8)
: new Date(t * 1000).toISOString().substr(14, 5);
};
handleVideoNameSelectorChange = (event, source, type) => {
const { nameSelectors } = this.state;
nameSelectors[source][type] = event.target.value;
this.setState({ nameSelectors });
};
updateVideoNameSelector = () => {
this.setState({ refreshing: true });
const { nameSelectors } = this.state;
postUpdateVideoNameSelectors({ nameSelectors })
.then(() => this.refreshData())
.catch((err) => {
notification.error({
description: err.message,
message: "Unable To Update",
});
this.setState({ refreshing: false });
});
};
sendOrAdd = () => {
const { activeTab } = this.state;
switch (activeTab) {
case "1":
this.setState({ messageModel: true });
break;
default:
break;
}
};
editMessage = (value, field) => {
const { newMessage } = this.state;
newMessage[field] = value;
this.setState({ newMessage });
};
sendMessage = async () => {
this.setState({ refreshing: true });
const { selectedParties, newMessage } = this.state;
newMessage.time = new Date().toLocaleString();
await postSendMessage({ selectedParties, newMessage }).catch((err) => {
notification.error({
description: err.message,
message: "Unable To Send Message",
});
});
this.setState({
refreshing: false,
newMessage: {
type: "",
text: "",
uid: "<system>",
name: "",
endpoint: "",
},
messageModel: false,
});
};
render() {
const {
partiesOverview,
activeTab,
refreshing,
selectedParties,
versions,
nameSelectors,
selectors,
activeLimit,
afkLimit,
messageModel,
newMessage,
} = this.state;
const partiesColumns = [
{
title: "Party Name",
dataIndex: "id",
sorter: (a, b) => (a.id.toLowerCase() < b.id.toLowerCase() ? -1 : 1),
},
{
title: "Source",
dataIndex: "source",
sorter: (a, b) =>
a.source.toLowerCase() < b.source.toLowerCase() ? -1 : 1,
filters: [
...PLAYER_SOURCES,
{
text: "Is Playing",
value: "PLAYING",
},
{
text: "Is Paused",
value: "PAUSED",
},
],
onFilter: (v, r) => r.source === v || r.status === v,
render: (a, r) => (
<Badge
status={
r.status === "PLAYING"
? "success"
: r.status === "PAUSED"
? "warning"
: "error"
}
text={this.getSourceName(a)}
/>
),
},
{
title: "Created At",
dataIndex: "createTime",
defaultSortOrder: "descend",
sorter: (a, b) => Date.parse(a.createTime) - Date.parse(b.createTime),
render: (a) => new Date(Date.parse(a)).toLocaleString(),
},
{
title: "Updated At",
dataIndex: "updateTime",
filters: [
{
text: "Active",
value: "a",
},
{
text: "Inactive",
value: "i",
},
],
onFilter: (v, r) =>
v === "a"
? (Date.now() - Date.parse(r.updateTime)) / 60000 < afkLimit
: (Date.now() - Date.parse(r.updateTime)) / 60000 > afkLimit,
sorter: (a, b) => Date.parse(a.updateTime) - Date.parse(b.updateTime),
render: (a) => {
const minutes = (Date.now() - Date.parse(a)) / 60000;
const dS = new Date(a).toLocaleString();
return (
<Tag
color={
minutes <= activeLimit
? "success"
: minutes <= afkLimit
? "processing"
: "default"
}
>
{dS}
</Tag>
);
},
},
{
title: "On Call",
dataIndex: "onCall",
filters: [
{
text: "On Call",
value: true,
},
{
text: "Not On Call",
value: false,
},
],
render: (a) =>
a ? (
<CheckCircleTwoTone twoToneColor="#c0f300" id="call-icon" />
) : (
<CloseCircleTwoTone twoToneColor="#ff5f5f" id="call-icon" />
),
onFilter: (v, r) => r.onCall === v,
},
];
const versionsColumns = [
{
title: "Version",
dataIndex: "version",
defaultSortOrder: "descend",
sorter: (a, b) =>
parseInt(a.version.split(".").join("")) -
parseInt(b.version.split(".").join("")),
},
{
title: "Active",
dataIndex: "active",
filters: [
{
text: "Active",
value: true,
},
{
text: "Inactive",
value: false,
},
],
onFilter: (v, r) => r.active === v,
render: (a, r) => (
<Switch
disabled={refreshing}
onChange={(c) =>
this.updateVersionActive(c, r.version.split(".").join(""))
}
checked={a}
/>
),
},
{
title: "Inactive Message",
dataIndex: "message",
render: (a, r) => (
<Input.Search
disabled={refreshing}
onSearch={(v) =>
this.updateVersionMessage(v, r.version.split(".").join(""))
}
value={a}
onChange={(e) => this.handleVersionMessageChange(e, r.version)}
enterButton={<EnterOutlined />}
/>
),
},
];
return (
<div className="dashboard-wrapper">
<PageHeader
className="title-header"
title="WATCH PARTY"
subTitle="ADMIN DASHBOARD"
extra={[
<Button
onClick={() => firebase.auth().signOut()}
key="1"
type="primary"
danger
icon={<LogoutOutlined />}
>
Logout
</Button>,
]}
/>
<Modal
visible={messageModel}
closable={false}
destroyOnClose
maskClosable={false}
cancelButtonProps={{
disabled: refreshing,
onClick: () => this.setState({ messageModel: false }),
}}
okButtonProps={{
disabled: refreshing,
icon: <SendOutlined />,
onClick: this.sendMessage,
}}
okText="Send Message"
>
<Select
onChange={(e) => this.editMessage(e, "type")}
value={newMessage.type}
placeholder="Message Type"
disabled={refreshing}
>
{MESSAGE_TYPES.map((v) => (
<Select.Option key={`msg-type-option-${v.value}`} value={v.value}>
{v.text}
</Select.Option>
))}
</Select>
<Input.TextArea
disabled={refreshing}
value={newMessage.text}
onChange={(e) => this.editMessage(e.target.value, "text")}
placeholder="Message"
/>
{(newMessage.type === "interactive/link" ||
newMessage.type === "interactive/text") && (
<Input
disabled={refreshing}
value={newMessage.endpoint}
onChange={(e) => this.editMessage(e.target.value, "endpoint")}
placeholder={
newMessage.type === "interactive/link" ? "Link" : "Endpoint"
}
/>
)}
{newMessage.type === "interactive/link" && (
<Input
disabled={refreshing}
value={newMessage.name}
onChange={(e) => this.editMessage(e.target.value, "name")}
placeholder="Link Text"
/>
)}
</Modal>
<Card>
<PageHeader
extra={[
activeTab === "1" && (
<Badge key="5" count="ACTIVE" className="active">
<InputNumber
prefix="Active"
value={activeLimit}
onChange={(activeLimit) => this.setState({ activeLimit })}
/>
</Badge>
),
activeTab === "1" && (
<Badge key="4" count="AFK" className="afk">
<InputNumber
prefix="AFK"
key="4"
value={afkLimit}
onChange={(afkLimit) => this.setState({ afkLimit })}
/>
</Badge>
),
<Button
key="3"
loading={refreshing}
disabled={refreshing}
onClick={this.refreshData}
shape="circle"
icon={refreshing ? <LoadingOutlined /> : <ReloadOutlined />}
/>,
<Button
key="2"
onClick={this.sendOrAdd}
icon={
activeTab === "1" ? <SendOutlined /> : <PlusCircleOutlined />
}
disabled={activeTab === "1" && selectedParties.length === 0}
>
{this.getAddMessageText()}
</Button>,
activeTab === "3" && (
<Button
type="primary"
onClick={this.updateVideoSelector}
icon={<SaveOutlined />}
key="6"
loading={refreshing}
>
Save Video Selectors
</Button>
),
activeTab === "4" && (
<Button
type="primary"
onClick={this.updateVideoNameSelector}
icon={<SaveOutlined />}
key="7"
loading={refreshing}
>
Save Video Name Selectors
</Button>
),
]}
/>
<Tabs activeKey={activeTab} onChange={this.tabChange}>
<Tabs.TabPane tab="Parties" key="1">
<Table
rowSelection={{
selectedRowKeys: selectedParties,
onChange: (selectedParties) =>
this.setState({ selectedParties }),
}}
loading={partiesOverview === null || refreshing}
pagination={{
position: ["bottomCenter"],
total: partiesOverview ? partiesOverview.length : 0,
showTotal: (t, r) => `${r[0]} to ${r[1]} of ${t}`,
defaultPageSize: 10,
showSizeChanger: true,
pageSizeOptions: [10, 25, 50],
}}
rowKey={(p) => p.id}
dataSource={partiesOverview}
columns={partiesColumns}
expandable={{
rowExpandable: (r) => true,
expandedRowClassName: () => "party-details-wrapper",
expandedRowRender: (r) => (
<Descriptions bordered column={5}>
<Descriptions.Item span={2} label="# Of Users">
{r.userCount}
</Descriptions.Item>
<Descriptions.Item span={3} label="# Of Messages">
{r.messageCount}
</Descriptions.Item>
<Descriptions.Item span={5} label="Show Name">
{r.showName}
</Descriptions.Item>
<Descriptions.Item span={2} label="Show Time">
{this.getPrettyTime(r.time)}
</Descriptions.Item>
<Descriptions.Item span={3} label="Show Status">
<Badge
status={
r.status === "PLAYING"
? "success"
: r.status === "PAUSED"
? "warning"
: "error"
}
text={r.status}
/>
</Descriptions.Item>
</Descriptions>
),
}}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="Versions" key="2">
<Table
loading={refreshing}
pagination={{ position: ["bottomCenter"] }}
rowKey={(p) => p.version}
dataSource={versions}
columns={versionsColumns}
expandable={{
rowExpandable: (r) => r.alerts,
expandedRowClassName: () => "alerts-wrapper",
expandedRowRender: (r) => (
<Row>
{r.alerts.map((a, i) => (
<Col key={`${r.version}-alerts-${i}`}>
<Card
actions={[
<Button
onClick={() => this.deleteAlert(r.version, i)}
danger
icon={<DeleteOutlined />}
shape="circle"
color="danger"
/>,
<Switch
onClick={(active) =>
this.changeImportantAlert(
active,
r.version,
i
)
}
checked={
r.importantAlert && r.importantAlert === i
}
/>,
]}
>
<Input
placeholder="title"
value={a.title}
onChange={(e) =>
this.handleAlertTitleChange(
e.target.value,
r.version,
i
)
}
/>
<Input.TextArea
placeholder="Description"
autoSize={{ minRows: 2, maxRows: 2 }}
value={a.description}
onChange={(e) =>
this.handleAlertDescriptionChange(
e.target.value,
r.version,
i
)
}
/>
<Divider orientation="left">Link</Divider>
<Input.Group>
<Input
value={a.linkText}
placeholder="Link Text"
onChange={(e) =>
this.handleAlertLinkTextChange(
e.target.value,
r.version,
i
)
}
/>
<Input
value={a.link}
placeholder="Link"
onChange={(e) =>
this.handleAlertLinkChange(
e.target.value,
r.version,
i
)
}
/>
</Input.Group>
</Card>
</Col>
))}
<Col>
<Card
onClick={() => this.addEmptyAlert(r.version)}
className="add-alert-card"
>
<PlusCircleFilled />
</Card>
</Col>
<Col>
<Card
onClick={() => this.saveAlerts(r.version)}
className="add-alert-card"
>
<SaveFilled />
</Card>
</Col>
</Row>
),
}}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="Video Selectors" key="3">
{Object.keys(selectors)
.sort()
.map((source) => (
<Input
key={`video-selector-${source}`}
className="video-selector-input"
addonBefore={this.getSourceName(source)}
value={selectors[source]}
onChange={(e) => this.handleVideoSelectorChange(e, source)}
/>
))}
</Tabs.TabPane>
<Tabs.TabPane tab="Name Selectors" key="4">
<Collapse ghost>
{Object.keys(nameSelectors)
.sort()
.map((source) => (
<Collapse.Panel
key={`name-selector-${source}`}
header={this.getSourceName(source)}
>
<Input
key={`video-selector-${source}-show`}
className="video-selector-input"
addonBefore={"Show"}
value={nameSelectors[source]["show"]}
onChange={(e) =>
this.handleVideoNameSelectorChange(e, source, "show")
}
/>
<Input
key={`video-selector-${source}-episode`}
className="video-selector-input"
addonBefore={"Episode"}
value={nameSelectors[source]["episode"]}
onChange={(e) =>
this.handleVideoNameSelectorChange(
e,
source,
"episode"
)
}
/>
</Collapse.Panel>
))}
</Collapse>
</Tabs.TabPane>
</Tabs>
</Card>
</div>
);
}
}