From 7a6be1ec36a2a833bca6a090e5ef7ccef2b47af1 Mon Sep 17 00:00:00 2001
From: Konstantin Bats <37730459+kbats183@users.noreply.github.com>
Date: Tue, 6 Aug 2024 10:20:25 +0300
Subject: [PATCH] admin: rework controls (#191)
* admin: rework controls
* fix generated api.ts
---
src/frontend/admin/src/App.css | 4 +
src/frontend/admin/src/App.tsx | 75 +++---
src/frontend/admin/src/AppNav.jsx | 1 -
.../admin/src/components/Controls.jsx | 19 --
.../src/components/FullScreenClockManager.jsx | 69 -----
.../src/components/ScoreboardManager.jsx | 240 ------------------
.../components/atoms/{Table.jsx => Table.tsx} | 0
.../src/components/controls/NumericField.tsx | 46 ++++
.../components/controls/ShowPresetButton.tsx | 7 +-
.../managers/FullScreenClockManager.tsx | 68 +++++
.../components/managers/ScoreboardManager.tsx | 179 +++++++++++++
.../src/components/pages/ControlsPage.tsx | 97 +++++++
.../components/pages/FullScreenClockPage.tsx | 20 ++
.../src/components/pages/ScoreboardPage.tsx | 19 ++
.../admin/src/{config.js => config.ts} | 0
.../src/services/abstractSingleWidget.ts | 131 ++++++++++
.../src/services/fullScreenClockWidget.js | 35 ---
.../admin/src/services/reloadHandler.ts | 71 ++++++
.../admin/src/services/scoreboardService.ts | 24 ++
src/frontend/build.gradle.kts | 2 +-
src/frontend/common/package.json | 2 +-
src/frontend/common/src/errors.js | 7 -
src/frontend/common/src/errors.ts | 13 +
src/frontend/generated/api.ts | 11 +
src/schema-generator/build.gradle.kts | 4 +-
25 files changed, 727 insertions(+), 417 deletions(-)
delete mode 100644 src/frontend/admin/src/components/Controls.jsx
delete mode 100644 src/frontend/admin/src/components/FullScreenClockManager.jsx
delete mode 100644 src/frontend/admin/src/components/ScoreboardManager.jsx
rename src/frontend/admin/src/components/atoms/{Table.jsx => Table.tsx} (100%)
create mode 100644 src/frontend/admin/src/components/controls/NumericField.tsx
create mode 100644 src/frontend/admin/src/components/managers/FullScreenClockManager.tsx
create mode 100644 src/frontend/admin/src/components/managers/ScoreboardManager.tsx
create mode 100644 src/frontend/admin/src/components/pages/ControlsPage.tsx
create mode 100644 src/frontend/admin/src/components/pages/FullScreenClockPage.tsx
create mode 100644 src/frontend/admin/src/components/pages/ScoreboardPage.tsx
rename src/frontend/admin/src/{config.js => config.ts} (100%)
create mode 100644 src/frontend/admin/src/services/abstractSingleWidget.ts
delete mode 100644 src/frontend/admin/src/services/fullScreenClockWidget.js
create mode 100644 src/frontend/admin/src/services/reloadHandler.ts
create mode 100644 src/frontend/admin/src/services/scoreboardService.ts
delete mode 100644 src/frontend/common/src/errors.js
create mode 100644 src/frontend/common/src/errors.ts
diff --git a/src/frontend/admin/src/App.css b/src/frontend/admin/src/App.css
index edbba5f21..b5ff13f87 100644
--- a/src/frontend/admin/src/App.css
+++ b/src/frontend/admin/src/App.css
@@ -8,3 +8,7 @@ body {
.media-files-uploader {
max-width: 100% !important;
}
+
+.WidgetGroupAccordion > .MuiAccordionSummary-root > .MuiAccordionSummary-content {
+ align-items: center;
+}
diff --git a/src/frontend/admin/src/App.tsx b/src/frontend/admin/src/App.tsx
index d615832dc..910a748c1 100644
--- a/src/frontend/admin/src/App.tsx
+++ b/src/frontend/admin/src/App.tsx
@@ -4,35 +4,25 @@ import AppNav from "./AppNav";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Overlay } from "./components/Overlay";
import TickerMessage from "./components/TickerMessage";
-import Controls from "./components/Controls";
+import ControlsPage from "./components/pages/ControlsPage.tsx";
import Advertisement from "./components/Advertisement";
import Title from "./components/Title";
import Picture from "./components/Picture";
import TeamView from "./components/TeamView";
import { SnackbarProvider } from "notistack";
-import ScoreboardManager from "./components/ScoreboardManager";
import BackendLog from "./components/BackendLog";
import Dashboard from "./components/Dashboard";
import Analytics from "./components/Analytics";
import TeamSpotlight from "./components/TeamSpotlight";
import { createApiGet } from "@shared/utils";
import { setFavicon, isShouldUseDarkColor, useLocalStorageState } from "./utils";
-import FullScreenClockManager from "./components/FullScreenClockManager";
import AdvancedJson from "./components/AdvancedJson";
import MediaFiles from "./components/MediaFiles";
import { createTheme, ThemeProvider } from "@mui/material";
import { BACKEND_ROOT } from "./config";
import { faviconTemplate } from "./styles";
-
-const dashboard_elements = {
- "Controls": ,
- "Advertisement": ,
- "Title":
,
- "Picture": ,
- "Scoreboard": ,
- "Ticker": ,
- "Full screen clock": ,
-};
+import { ReloadHandleContext, useReloadHandleService } from "@/services/reloadHandler.ts";
+import ScoreboardPage from "@/components/pages/ScoreboardPage.tsx";
const title_elements = {
"Advertisement": ,
@@ -86,36 +76,39 @@ function App() {
});
}, []);
+ const reloadHandleService = useReloadHandleService();
+
return (
-
-
-
- setIsOverlayPreviewShown(!isOverlayPreviewShown)}/>
-
-
- }/>
- }/>
- {/* }/> */}
- {/* }/> */}
- }/>
- {/* }/> */}
- }/>
- {/*}/>*/}
- {/*}/>*/}
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
-
-
-
-
+
+
+
+
+ setIsOverlayPreviewShown(!isOverlayPreviewShown)}/>
+
+
+ }/>
+ }/>
+ {/* }/> */}
+ {/* }/> */}
+ }/>
+ {/* }/> */}
+ }/>
+ {/*}/>*/}
+ {/*}/>*/}
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+
+
+
+
+
);
}
diff --git a/src/frontend/admin/src/AppNav.jsx b/src/frontend/admin/src/AppNav.jsx
index 06dc347cb..c346f8550 100644
--- a/src/frontend/admin/src/AppNav.jsx
+++ b/src/frontend/admin/src/AppNav.jsx
@@ -23,7 +23,6 @@ const defaultPages = {
"TeamView": "teamview",
"Scoreboard": "scoreboard",
"Ticker": "ticker",
- "Dashboard": "dashboard",
"Analytics": "analytics",
"Spotlight": "teamSpotlight",
"Advanced": "advancedJson",
diff --git a/src/frontend/admin/src/components/Controls.jsx b/src/frontend/admin/src/components/Controls.jsx
deleted file mode 100644
index 60cc81bfa..000000000
--- a/src/frontend/admin/src/components/Controls.jsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from "react";
-import Container from "@mui/material/Container";
-import { errorHandlerWithSnackbar } from "shared-code/errors";
-import { useSnackbar } from "notistack";
-import { useControlsWidgetService } from "../services/controlsWidget";
-import { PresetsManager } from "./PresetsManager";
-
-
-function Controls() {
- const { enqueueSnackbar, } = useSnackbar();
- const service = useControlsWidgetService(errorHandlerWithSnackbar(enqueueSnackbar));
- return (
-
-
-
- );
-}
-
-export default Controls;
diff --git a/src/frontend/admin/src/components/FullScreenClockManager.jsx b/src/frontend/admin/src/components/FullScreenClockManager.jsx
deleted file mode 100644
index 1a69b4f0d..000000000
--- a/src/frontend/admin/src/components/FullScreenClockManager.jsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { SlimTableCell } from "./atoms/Table";
-import { Button, ButtonGroup, Container, Switch, Table, TableBody, TableRow } from "@mui/material";
-import { useSnackbar } from "notistack";
-import React, { useEffect, useState } from "react";
-import { errorHandlerWithSnackbar } from "shared-code/errors";
-import { useFullScreenClockWidget } from "../services/fullScreenClockWidget";
-
-
-function FullScreenClockManager() {
- const { enqueueSnackbar, } = useSnackbar();
- const service = useFullScreenClockWidget();
- useEffect(() => {
- const h = errorHandlerWithSnackbar(enqueueSnackbar);
- service.addErrorHandler(h);
- return () => service.deleteErrorHandler(h);
- }, [service, enqueueSnackbar]);
-
- const [isShown, setIsShown] = useState(false);
- const [settings, setSettings] = useState({ globalTimeMode: false, quietMode: false, contestCountdownMode: false });
- const loadSettings = () => { service.loadOne().then((info) => setIsShown(info.shown));};
-
- useEffect(loadSettings, []);
- useEffect(() => {
- service.addReloadDataHandler(loadSettings);
- return () => service.deleteReloadDataHandler(loadSettings);
- }, []);
-
- return (
-
-
-
-
- Global time instead contest
-
-
- setSettings(s => ({ ...s, globalTimeMode: e.target.checked }))}/>
-
-
-
-
- Contest time countdown
-
-
- setSettings(s => ({ ...s, contestCountdownMode: e.target.checked }))}/>
-
-
-
-
- Quiet mode (seconds only in countdown)
-
-
- setSettings(s => ({ ...s, quietMode: e.target.checked }))}/>
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export default FullScreenClockManager;
diff --git a/src/frontend/admin/src/components/ScoreboardManager.jsx b/src/frontend/admin/src/components/ScoreboardManager.jsx
deleted file mode 100644
index b0637ce79..000000000
--- a/src/frontend/admin/src/components/ScoreboardManager.jsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import { ExpandLess, ExpandMore } from "@mui/icons-material";
-import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
-import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
-import CircleCheckedIcon from "@mui/icons-material/CheckCircleOutline";
-import CircleUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
-import {
- Box,
- Button,
- ButtonGroup,
- Checkbox,
- Container,
- IconButton,
- Switch,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableRow,
- TextField,
- Typography
-} from "@mui/material";
-import { useSnackbar } from "notistack";
-import PropTypes from "prop-types";
-import React, { useCallback, useEffect, useRef, useState } from "react";
-import { BASE_URL_BACKEND } from "../config";
-import { errorHandlerWithSnackbar } from "shared-code/errors";
-import { createApiGet, createApiPost } from "shared-code/utils";
-import { SlimTableCell } from "./atoms/Table";
-
-function NumericField({ onChange : _onChange, value, minValue, arrowsDelta }) {
- arrowsDelta = arrowsDelta ?? 1;
- const ref = useRef(null);
- const blockWidth = ref.current?.offsetWidth ?? 0;
- const isPossibleArrows = blockWidth > 150;
- const onChange = useCallback(v => {
- const newV = Number.parseInt(v);
- _onChange(minValue === undefined || newV >= minValue ? newV : minValue);
- }, [_onChange, minValue]);
- return (
- {isPossibleArrows && onChange(value - arrowsDelta)}>}
- onChange(e.target.value)} value={value}
- sx={{ maxWidth: isPossibleArrows ? blockWidth - 100 : 1 }}/>
- {isPossibleArrows && onChange(value + arrowsDelta)}>}
- );
-}
-
-NumericField.propTypes = {
- value: PropTypes.number.isRequired,
- minValue: PropTypes.number,
- arrowsDelta: PropTypes.number,
- onChange: PropTypes.func.isRequired,
-};
-
-function ScoreboardSettings({ isShown, onClickShow, onClickHide, settings, setSettings }) {
- return (
-
-
- {["", "Start from row", "Amount of rows", "Infinity"].map(val =>
-
- {val}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
- setSettings(s => ({ ...s, startFromRow: v }))}/>
-
-
- setSettings(s => ({ ...s, numRows: v }))}/>
-
-
- setSettings(s => ({ ...s, isInfinite: t.target.checked }))}/>
-
-
-
-
);
-}
-
-ScoreboardSettings.propTypes = {
- isShown: PropTypes.bool.isRequired,
- onClickShow: PropTypes.func.isRequired,
- onClickHide: PropTypes.func.isRequired,
- settings: PropTypes.shape({
- startFromRow: PropTypes.number.isRequired,
- numRows: PropTypes.number.isRequired,
- isInfinite: PropTypes.bool.isRequired,
- }).isRequired,
- setSettings: PropTypes.func.isRequired,
-};
-
-function ScoreboardOptLevelCells({ settings, setSettings, group }) {
- return ["normal", "optimistic", "pessimistic"].map(type =>
-
- }
- checkedIcon={}
- checked={settings.group === group && settings.optimismLevel === type}
- sx={{ "& .MuiSvgIcon-root": { fontSize: 24 } }}
- onChange={() => setSettings(state => ({
- ...state,
- optimismLevel: type,
- group: group
- }))}
- />
-
- );
-}
-
-ScoreboardOptLevelCells.propTypes = {
- settings: PropTypes.shape({
- group: PropTypes.string,
- optimismLevel: PropTypes.string.isRequired,
- }).isRequired,
- setSettings: PropTypes.func.isRequired,
- group: PropTypes.string.isRequired,
-};
-
-function ScoreboardGroupSetting({ settings, setSettings, groupsList }) {
- const [isGroupsExpand, setIsGroupsExpand] = useState(false);
- useEffect(() => setIsGroupsExpand(s => s || settings.group !== "all"), [settings.group]);
-
- return (
-
-
-
- Groups
-
- {["Normal", "Optimistic", "Pessimistic"].map(val =>
-
- {val}
-
- )}
-
-
-
-
-
-
- All groups
-
-
-
-
-
- {isGroupsExpand && groupsList.map(group =>
-
-
- {group.displayName}
-
-
-
- )}
-
-
);
-}
-
-ScoreboardGroupSetting.propTypes = {
- settings: PropTypes.shape({
- group: PropTypes.string,
- optimismLevel: PropTypes.string.isRequired,
- }).isRequired,
- setSettings: PropTypes.func.isRequired,
- groupsList: PropTypes.arrayOf(PropTypes.shape({
- displayName: PropTypes.string.isRequired,
- id: PropTypes.string.isRequired,
- })).isRequired,
-};
-
-const apiPost = createApiPost(BASE_URL_BACKEND + "/scoreboard");
-const apiGet = createApiGet(BASE_URL_BACKEND + "/scoreboard");
-
-function ScoreboardManager() {
- const { enqueueSnackbar, } = useSnackbar();
- const createErrorHandler = errorHandlerWithSnackbar(enqueueSnackbar);
-
- const [isShown, setIsShown] = useState(false);
- const [settings, setSettings] = useState({
- isInfinite: true,
- optimismLevel: "Normal",
- group: "all",
- startFromRow: 1,
- numRows: 0,
- });
- const [groupsList, setGroupsList] = useState([]);
-
- const update = (isFirstLoad) => {
- apiGet("")
- .then(
- (result) => {
- setIsShown(result.shown);
- if (isFirstLoad) {
- result.settings.group = result.settings.group ?? "all";
- result.settings.numRows = result.settings.numRows ?? 100;
- setSettings(result.settings);
- }
- })
- .catch(createErrorHandler("Failed to load list of presets"));
- apiGet("/regions")
- .then((result) => setGroupsList(result))
- .catch(createErrorHandler("Failed to load info"));
- };
-
- useEffect(() => update(true), []);
-
- const onClickHide = () => {
- apiPost("/hide")
- .then(() => update())
- .catch(createErrorHandler("Failed to hide scoreboard"));
- };
-
- const onClickShow = () => {
- apiPost("/show_with_settings", settings)
- .then(() => setIsShown(true))
- .then(() => update())
- .catch(createErrorHandler("Failed to show scoreboard"));
- };
-
- return (
-
-
-
-
- );
-}
-
-export default ScoreboardManager;
diff --git a/src/frontend/admin/src/components/atoms/Table.jsx b/src/frontend/admin/src/components/atoms/Table.tsx
similarity index 100%
rename from src/frontend/admin/src/components/atoms/Table.jsx
rename to src/frontend/admin/src/components/atoms/Table.tsx
diff --git a/src/frontend/admin/src/components/controls/NumericField.tsx b/src/frontend/admin/src/components/controls/NumericField.tsx
new file mode 100644
index 000000000..22bb23a12
--- /dev/null
+++ b/src/frontend/admin/src/components/controls/NumericField.tsx
@@ -0,0 +1,46 @@
+import { useCallback, useRef } from "react";
+import { Box, IconButton, TextField } from "@mui/material";
+import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
+import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
+
+export type NumericFieldProps = {
+ value: number;
+ minValue: number;
+ arrowsDelta: number;
+ onChange: (newValue: number) => void,
+};
+
+export const NumericField = ({ onChange: _onChange, value, minValue, arrowsDelta }: NumericFieldProps) => {
+ arrowsDelta = arrowsDelta ?? 1;
+ const ref = useRef(null);
+ const blockWidth = ref.current?.offsetWidth ?? 0;
+ const isPossibleArrows = blockWidth > 150;
+ const onChange = useCallback(v => {
+ const newV = Number.parseInt(v);
+ _onChange(minValue === undefined || newV >= minValue ? newV : minValue);
+ }, [_onChange, minValue]);
+ return (
+
+ {isPossibleArrows && (
+ onChange(value - arrowsDelta)}>
+ )}
+ onChange(e.target.value)}
+ value={value}
+ sx={{ maxWidth: isPossibleArrows ? (blockWidth - 100) : 1 }}
+ />
+ {isPossibleArrows && (
+ onChange(value + arrowsDelta)}>
+ )}
+
+ );
+};
+
+export default NumericField;
diff --git a/src/frontend/admin/src/components/controls/ShowPresetButton.tsx b/src/frontend/admin/src/components/controls/ShowPresetButton.tsx
index 6a747745e..93f87aaa3 100644
--- a/src/frontend/admin/src/components/controls/ShowPresetButton.tsx
+++ b/src/frontend/admin/src/components/controls/ShowPresetButton.tsx
@@ -6,7 +6,7 @@ type ShowPresetButtonProps = {
checked: boolean;
onClick: (newState: boolean) => void;
disabled?: boolean;
- sx?: ButtonProps['sx'];
+ sx?: ButtonProps["sx"];
}
const ShowPresetButton = ({ checked, onClick, disabled, sx = {} }: ShowPresetButtonProps) => {
@@ -14,7 +14,10 @@ const ShowPresetButton = ({ checked, onClick, disabled, sx = {} }: ShowPresetBut
: }
- onClick={() => onClick(!checked)}
+ onClick={(event) => {
+ onClick(!checked);
+ event.stopPropagation();
+ }}
disabled={disabled}
sx={{ width: "100px", ...sx }}>
{checked ? "Hide" : "Show"}
diff --git a/src/frontend/admin/src/components/managers/FullScreenClockManager.tsx b/src/frontend/admin/src/components/managers/FullScreenClockManager.tsx
new file mode 100644
index 000000000..0725eacbc
--- /dev/null
+++ b/src/frontend/admin/src/components/managers/FullScreenClockManager.tsx
@@ -0,0 +1,68 @@
+import { SlimTableCell } from "../atoms/Table.js";
+import { Button, ButtonGroup, Switch, Table, TableBody, TableRow } from "@mui/material";
+import { Dispatch, SetStateAction } from "react";
+import { FullScreenClockSettings } from "../../../../generated/api.ts";
+import { AbstractSingleWidgetService } from "@/services/abstractSingleWidget.ts";
+
+export const DEFAULT_FULL_SCREEN_CLOCK_SETTINGS: FullScreenClockSettings = {
+ globalTimeMode: false,
+ quietMode: false,
+ contestCountdownMode: false
+};
+
+export type FullScreenClockManagerProps = {
+ service: AbstractSingleWidgetService;
+ isShown: boolean;
+ settings: FullScreenClockSettings;
+ setSettings: Dispatch>;
+}
+
+const FullScreenClockManager = ({ service, isShown, settings, setSettings }: FullScreenClockManagerProps) => {
+ return (<>
+
+
+
+
+ Global time instead contest
+
+
+ setSettings(s => ({ ...s, globalTimeMode: e.target.checked }))}
+ />
+
+
+
+
+ Contest time countdown
+
+
+ setSettings(s => ({ ...s, contestCountdownMode: e.target.checked }))}
+ />
+
+
+
+
+ Quiet mode (seconds only in countdown)
+
+
+ setSettings(s => ({ ...s, quietMode: e.target.checked }))}
+ />
+
+
+
+
+
+
+
+
+
+
+ >);
+};
+
+export default FullScreenClockManager;
diff --git a/src/frontend/admin/src/components/managers/ScoreboardManager.tsx b/src/frontend/admin/src/components/managers/ScoreboardManager.tsx
new file mode 100644
index 000000000..9fb67b90e
--- /dev/null
+++ b/src/frontend/admin/src/components/managers/ScoreboardManager.tsx
@@ -0,0 +1,179 @@
+import { ExpandLess, ExpandMore } from "@mui/icons-material";
+import CircleCheckedIcon from "@mui/icons-material/CheckCircleOutline";
+import CircleUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
+import {
+ Box,
+ Button,
+ ButtonGroup,
+ Checkbox,
+ Switch,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ Typography
+} from "@mui/material";
+import { Dispatch, SetStateAction, useEffect, useState } from "react";
+import { SlimTableCell } from "../atoms/Table.tsx";
+import NumericField from "../controls/NumericField.tsx";
+import { GroupInfo, OptimismLevel, ScoreboardSettings } from "@shared/api.ts";
+import { ScoreboardWidgetService } from "@/services/scoreboardService.ts";
+
+
+type ScoreboardSettingsTabProps = {
+ isShown: boolean;
+ onClickShow: () => void;
+ onClickHide: () => void;
+ settings: ScoreboardSettings;
+ setSettings: Dispatch>;
+}
+
+const ScoreboardSettingsTab = ({ isShown, onClickShow, onClickHide, settings, setSettings }: ScoreboardSettingsTabProps) => {
+ return (
+
+
+ {["", "Start from row", "Amount of rows", "Infinity"].map(val =>
+
+ {val}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ setSettings(s => ({ ...s, startFromRow: v }))}/>
+
+
+ setSettings(s => ({ ...s, numRows: v }))}/>
+
+
+ setSettings(s => ({ ...s, isInfinite: t.target.checked }))}/>
+
+
+
+
);
+};
+
+type ScoreboardOptLevelCellsProps = {
+ settings: ScoreboardSettings;
+ setSettings: Dispatch>;
+ group: string;
+}
+
+const ScoreboardOptLevelCells = ({ settings, setSettings, group }: ScoreboardOptLevelCellsProps) => {
+ return [OptimismLevel.normal, OptimismLevel.optimistic, OptimismLevel.pessimistic].map(type =>
+
+ }
+ checkedIcon={}
+ checked={settings.group === group && settings.optimismLevel === type}
+ sx={{ "& .MuiSvgIcon-root": { fontSize: 24 } }}
+ onChange={() => setSettings(state => ({
+ ...state,
+ optimismLevel: type,
+ group: group
+ }))}
+ />
+
+ );
+};
+
+type ScoreboardGroupSettingProps = {
+ settings: ScoreboardSettings;
+ setSettings: Dispatch>;
+ groupsList: GroupInfo[];
+}
+
+const ScoreboardGroupSetting = ({ settings, setSettings, groupsList }: ScoreboardGroupSettingProps) => {
+ const [isGroupsExpand, setIsGroupsExpand] = useState(false);
+ useEffect(() => setIsGroupsExpand(s => s || settings.group !== "all"), [settings.group]);
+
+ return (
+
+
+
+ Groups
+
+ {["Normal", "Optimistic", "Pessimistic"].map(val =>
+
+ {val}
+
+ )}
+
+
+
+
+
+
+ All groups
+
+
+
+
+
+ {isGroupsExpand && groupsList.map(group =>
+
+
+ {group.displayName}
+
+
+
+ )}
+
+
);
+};
+
+export const DEFAULT_SCOREBOARD_SETTINGS: ScoreboardSettings = {
+ isInfinite: true,
+ optimismLevel: OptimismLevel.normal,
+ group: "all",
+ startFromRow: 1,
+ numRows: 0,
+};
+
+// TODO: create generic type for all managers that has service, settings and setSettings
+export type ScoreboardManagerProps = {
+ service: ScoreboardWidgetService;
+ isShown: boolean;
+ settings: ScoreboardSettings;
+ setSettings: Dispatch>;
+}
+
+const ScoreboardManager = ({ service, isShown, settings, setSettings }: ScoreboardManagerProps) => {
+ const [groupsList, setGroupsList] = useState([]);
+
+ useEffect(() => {
+ service.groups().then((result) => setGroupsList(result));
+ }, [service]);
+
+ const onClickHide = () => {
+ // TODO: when hide not reload settings from server, because I want set new settings and than hide + show
+ service.hide();
+ };
+
+ const onClickShow = () => {
+ service.showWithSettings(settings);
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default ScoreboardManager;
diff --git a/src/frontend/admin/src/components/pages/ControlsPage.tsx b/src/frontend/admin/src/components/pages/ControlsPage.tsx
new file mode 100644
index 000000000..4ce0c56f6
--- /dev/null
+++ b/src/frontend/admin/src/components/pages/ControlsPage.tsx
@@ -0,0 +1,97 @@
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Container, Typography,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import ShowPresetButton from "@/components/controls/ShowPresetButton.tsx";
+import { ReactNode, useState } from "react";
+import { useServiceLoadStatus, useSingleWidgetService } from "@/services/abstractSingleWidget.ts";
+import { FullScreenClockSettings, ObjectSettings } from "@shared/api.ts";
+import { useScoreboardWidgetService } from "@/services/scoreboardService.ts";
+import ScoreboardManager, { DEFAULT_SCOREBOARD_SETTINGS } from "@/components/managers/ScoreboardManager.tsx";
+import FullScreenClockManager, { DEFAULT_FULL_SCREEN_CLOCK_SETTINGS } from "@/components/managers/FullScreenClockManager.tsx";
+
+type WidgetGroupProp = {
+ title: string;
+ children?: ReactNode;
+ isShown: boolean;
+ onClickShow: (newState: boolean) => void;
+};
+
+const WidgetGroup = ({ title, children, isShown, onClickShow }: WidgetGroupProp) => {
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+ setExpanded(e)}
+ className="WidgetGroupAccordion"
+ >
+ }>
+
+ {title}
+
+
+ {children}
+
+
+ );
+};
+
+type NoSettingsWidgetProps = {
+ title: string;
+ apiPath: string;
+};
+
+const SimpleWidgetGroup = ({ title, apiPath }: NoSettingsWidgetProps) => {
+ const service = useSingleWidgetService(apiPath);
+ const { isShown } = useServiceLoadStatus(service, undefined);
+
+ return (
+ s ? service.show() : service.hide()}/>
+ );
+};
+
+const ScoreboardWidgetGroup = () => {
+ const service = useScoreboardWidgetService();
+ const { isShown, settings, setSettings } =
+ useServiceLoadStatus(service, DEFAULT_SCOREBOARD_SETTINGS);
+
+ return (
+ s ? service.show() : service.hide()}>
+
+
+ );
+};
+
+const FullScreenClockWidgetGroup = () => {
+ const service = useSingleWidgetService("/fullScreenClock");
+ const { isShown, settings, setSettings } =
+ useServiceLoadStatus(service, DEFAULT_FULL_SCREEN_CLOCK_SETTINGS);
+
+ return (
+ s ? service.show() : service.hide()}>
+
+
+ );
+};
+
+const ControlsPage = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default ControlsPage;
diff --git a/src/frontend/admin/src/components/pages/FullScreenClockPage.tsx b/src/frontend/admin/src/components/pages/FullScreenClockPage.tsx
new file mode 100644
index 000000000..2ae0d3db3
--- /dev/null
+++ b/src/frontend/admin/src/components/pages/FullScreenClockPage.tsx
@@ -0,0 +1,20 @@
+import { Container } from "@mui/material";
+import { useServiceLoadStatus, useSingleWidgetService } from "@/services/abstractSingleWidget.ts";
+import FullScreenClockManager, {
+ DEFAULT_FULL_SCREEN_CLOCK_SETTINGS
+} from "@/components/managers/FullScreenClockManager.tsx";
+import { FullScreenClockSettings } from "@shared/api.ts";
+
+const FullScreenClockPage = () => {
+ const service = useSingleWidgetService("/fullScreenClock");
+ const { isShown, settings, setSettings } =
+ useServiceLoadStatus(service, DEFAULT_FULL_SCREEN_CLOCK_SETTINGS);
+
+ return (
+
+
+
+ );
+};
+
+export default FullScreenClockPage;
diff --git a/src/frontend/admin/src/components/pages/ScoreboardPage.tsx b/src/frontend/admin/src/components/pages/ScoreboardPage.tsx
new file mode 100644
index 000000000..46118f74c
--- /dev/null
+++ b/src/frontend/admin/src/components/pages/ScoreboardPage.tsx
@@ -0,0 +1,19 @@
+import ScoreboardManager, { DEFAULT_SCOREBOARD_SETTINGS } from "@/components/managers/ScoreboardManager.tsx";
+import { useScoreboardWidgetService } from "@/services/scoreboardService.ts";
+import { useServiceLoadStatus } from "@/services/abstractSingleWidget.ts";
+import { Container } from "@mui/material";
+
+const ScoreboardPage = () => {
+ const service = useScoreboardWidgetService();
+ const { isShown, settings, setSettings } =
+ useServiceLoadStatus(service, DEFAULT_SCOREBOARD_SETTINGS);
+
+ return (
+
+
+
+ );
+};
+
+export default ScoreboardPage;
diff --git a/src/frontend/admin/src/config.js b/src/frontend/admin/src/config.ts
similarity index 100%
rename from src/frontend/admin/src/config.js
rename to src/frontend/admin/src/config.ts
diff --git a/src/frontend/admin/src/services/abstractSingleWidget.ts b/src/frontend/admin/src/services/abstractSingleWidget.ts
new file mode 100644
index 000000000..363111b1a
--- /dev/null
+++ b/src/frontend/admin/src/services/abstractSingleWidget.ts
@@ -0,0 +1,131 @@
+import { ApiGetClient, ApiPostClient, createApiGet, createApiPost } from "@shared/utils";
+import { BASE_URL_BACKEND } from "@/config.ts";
+import { useReloadHandler } from "@/services/reloadHandler.ts";
+import { ErrorHandler, ReloadHandler } from "@shared/abstractWidget.ts";
+import { ObjectSettings } from "@shared/api.ts";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useSnackbar } from "notistack";
+import { errorHandlerWithSnackbar } from "@shared/errors.ts";
+
+export interface ObjectStatus {
+ shown: boolean;
+ settings: SettingsType
+}
+
+export class AbstractSingleWidgetService {
+ apiPath: string;
+ apiGet: ApiGetClient;
+ apiPost: ApiPostClient;
+ errorHandler: ErrorHandler = cause => e => this.handleError(cause, e); // why it's not function?
+ errorHandlers: Set = new Set();
+ reloadDataHandlers: Set = new Set();
+ ws?: WebSocket;
+
+ constructor(apiPath: string) {
+ this.apiPath = apiPath;
+ this.apiGet = createApiGet(BASE_URL_BACKEND + apiPath);
+ this.apiPost = createApiPost(BASE_URL_BACKEND + apiPath);
+ }
+
+ addReloadDataHandler(handler: ReloadHandler) {
+ this.reloadDataHandlers.add(handler);
+ }
+
+ deleteReloadDataHandler(handler: ReloadHandler) {
+ this.reloadDataHandlers.delete(handler);
+ }
+
+ addErrorHandler(handler: ErrorHandler) {
+ this.errorHandlers.add(handler);
+ }
+
+ deleteErrorHandler(handler: ErrorHandler) {
+ this.errorHandlers.delete(handler);
+ }
+
+ handleError(cause: string, e: Error) {
+ if (this.errorHandlers.size === 0) {
+ console.error(cause + ": " + e);
+ }
+ this.errorHandlers.forEach(h => h(cause)(e));
+ }
+
+ isMessageRequireReload(url: string): boolean {
+ return url.startsWith("/api/admin" + this.apiPath);
+ }
+
+ loadStatus() {
+ return this.apiGet("/")
+ .catch(this.errorHandler("Failed to load status of " + this.apiPath))
+ .then(j => j as ObjectStatus);
+ }
+
+ show() {
+ return this.apiPost("/show").catch(this.errorHandler("Failed to show " + this.apiPath));
+ }
+
+ hide() {
+ return this.apiPost("/hide").catch(this.errorHandler("Failed to hide " + this.apiPath));
+ }
+
+ showWithSettings(settings: Settings) {
+ return this.apiPost("/show_with_settings", settings).catch(this.errorHandler("Failed to show " + this.apiPath));
+ }
+
+ setSettings(settings: Settings) {
+ return this.apiPost("/", settings).catch(this.errorHandler("Failed to set settings " + this.apiPath));
+ }
+}
+
+// TODO: move to another file?
+export function useServiceSnackbarErrorHandler<
+ Service extends AbstractSingleWidgetService,
+ Settings extends ObjectSettings
+>(service: Service) {
+ const { enqueueSnackbar, } = useSnackbar();
+ useEffect(() => {
+ const handler = errorHandlerWithSnackbar(enqueueSnackbar);
+ service.addErrorHandler(handler);
+
+ return () => {
+ service.deleteErrorHandler(handler);
+ };
+ }, [enqueueSnackbar, service]);
+}
+
+export function useSingleWidgetService(apiPath: string) {
+ const service = useMemo(
+ () => new AbstractSingleWidgetService(apiPath),
+ [apiPath]);
+ useServiceSnackbarErrorHandler(service);
+
+ return service;
+}
+
+export function useServiceLoadStatus<
+ Service extends AbstractSingleWidgetService,
+ Settings extends ObjectSettings
+>(service: Service, defaultSettings: Settings) {
+ const [isShown, setIsShown] = useState(false);
+ const [settings, setSettings] = useState(defaultSettings);
+
+ const loadStatus = useCallback(() => {
+ service.loadStatus().then(s => {
+ setIsShown(s.shown);
+ setSettings(s.settings);
+ });
+ }, [service, setIsShown, setSettings]);
+ useEffect(() => {
+ loadStatus();
+ }, [loadStatus]);
+
+ const reloadHandler = useReloadHandler();
+ useEffect(() => {
+ const handler = (path: string) => service.isMessageRequireReload(path) && loadStatus();
+ reloadHandler.subscribe(handler);
+
+ return () => reloadHandler.unsubscribe(handler);
+ }, [reloadHandler, service, loadStatus]);
+
+ return { isShown, settings, setSettings, loadStatus };
+}
diff --git a/src/frontend/admin/src/services/fullScreenClockWidget.js b/src/frontend/admin/src/services/fullScreenClockWidget.js
deleted file mode 100644
index e73622168..000000000
--- a/src/frontend/admin/src/services/fullScreenClockWidget.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { AbstractWidgetImpl } from "./abstractWidgetImpl";
-import { useMemo } from "react";
-
-export class FullScreenClockService extends AbstractWidgetImpl {
- constructor(errorHandler, listenWS = true) {
- super("/fullScreenClock", errorHandler, listenWS);
- }
-
- isMessageRequireReload(data) {
- return data.startsWith("/api/admin" + this.apiPath);
- }
-
- presetSubPath(/* presetId */) {
- return "";
- }
-
- loadOne(element) {
- return this.apiGet(this.presetSubPath(element)).catch(this.errorHandler("Failed to load " + element + " info"));
- }
-
- // loadElements() {
- // return Promise.all(
- // this.instances.map(id =>
- // this.loadOne(id).then(r => [id, r])))
- // .then(els => els.reduce((s, el) => ({ ...s, [el[0]]: el[1] }), {}));
- // }
-
- editPreset(element, settings) {
- return this.apiPost(this.presetSubPath(element), settings).catch(this.errorHandler("Failed to edit element"));
- }
-}
-
-export const useFullScreenClockWidget = (errorHandler, listenWS) =>
- useMemo(() => new FullScreenClockService(errorHandler, listenWS),
- [errorHandler, listenWS]);
diff --git a/src/frontend/admin/src/services/reloadHandler.ts b/src/frontend/admin/src/services/reloadHandler.ts
new file mode 100644
index 000000000..69b9932e8
--- /dev/null
+++ b/src/frontend/admin/src/services/reloadHandler.ts
@@ -0,0 +1,71 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from "react";
+import { ADMIN_ACTIONS_WS_URL, WEBSOCKET_RECONNECT_TIME } from "@/config.ts";
+
+export type ReloadHandler = (url: string) => void;
+
+export interface ReloadHandleService {
+ subscribe: (handler: ReloadHandler) => void;
+ unsubscribe: (handler: ReloadHandler) => void;
+}
+
+export class ReloadHandleServiceImpl {
+ handlers = new Set;
+
+ subscribe(handler: ReloadHandler) {
+ this.handlers.add(handler);
+ }
+
+ unsubscribe(handler: ReloadHandler) {
+ this.handlers.delete(handler);
+ }
+
+ handle(url: string) {
+ this.handlers.forEach(h => h(url));
+ }
+}
+
+export const useReloadHandleService: () => ReloadHandleService = () => {
+ const reloadHandleService = useMemo(() => {
+ console.debug("Create new ReloadHandleServiceImpl");
+ return new ReloadHandleServiceImpl();
+ }, []);
+
+ const wsRef = useRef(null);
+ const closedRef = useRef(false);
+ const openWS = useCallback(() => {
+ if (closedRef.current) {
+ return;
+ }
+ const ws = new WebSocket(ADMIN_ACTIONS_WS_URL);
+ ws.onmessage = ({ data }) => reloadHandleService.handle(data as string);
+ ws.onclose = () => {
+ wsRef.current = null;
+ setTimeout(() => {
+ console.debug("Reconnecting WebSocket for admin actions");
+ openWS();
+ }, WEBSOCKET_RECONNECT_TIME);
+ };
+ wsRef.current = ws;
+ }, [reloadHandleService]);
+
+ useEffect(() => {
+ console.info("Connecting WebSocket for admin actions");
+ openWS();
+
+ return () => {
+ console.info("Destroyed WebSocket for admin actions");
+ closedRef.current = true;
+ if (wsRef.current?.readyState === 1) {
+ wsRef.current?.close();
+ }
+ };
+ }, [openWS]);
+
+ return reloadHandleService;
+};
+
+export const ReloadHandleContext = createContext(null);
+
+export const useReloadHandler = () => {
+ return useContext(ReloadHandleContext);
+};
diff --git a/src/frontend/admin/src/services/scoreboardService.ts b/src/frontend/admin/src/services/scoreboardService.ts
new file mode 100644
index 000000000..fe1b6655f
--- /dev/null
+++ b/src/frontend/admin/src/services/scoreboardService.ts
@@ -0,0 +1,24 @@
+import { GroupInfo, ScoreboardSettings } from "@shared/api.ts";
+import { AbstractSingleWidgetService, useServiceSnackbarErrorHandler } from "@/services/abstractSingleWidget.ts";
+import { useMemo } from "react";
+
+export class ScoreboardWidgetService extends AbstractSingleWidgetService {
+ constructor() {
+ super("/scoreboard");
+ }
+
+ groups() {
+ return this.apiGet("/regions")
+ .catch(this.errorHandler("Failed to load list of groups of " + this.apiPath))
+ .then(r => r as GroupInfo[]);
+ }
+}
+
+export function useScoreboardWidgetService() {
+ const service = useMemo(
+ () => new ScoreboardWidgetService(),
+ []);
+ useServiceSnackbarErrorHandler(service);
+
+ return service;
+}
diff --git a/src/frontend/build.gradle.kts b/src/frontend/build.gradle.kts
index 913153009..c7825a5ff 100644
--- a/src/frontend/build.gradle.kts
+++ b/src/frontend/build.gradle.kts
@@ -7,7 +7,7 @@ plugins {
node {
version.set("20.11.0")
- pnpmVersion.set("9.1.1")
+ pnpmVersion.set("9.5.0")
download.set(rootProject.findProperty("npm.download") == "true")
}
diff --git a/src/frontend/common/package.json b/src/frontend/common/package.json
index a8d7d1cf3..7fc92b22c 100644
--- a/src/frontend/common/package.json
+++ b/src/frontend/common/package.json
@@ -2,7 +2,7 @@
"name": "shared-code",
"exports": {
"./utils": "./src/utils.ts",
- "./errors": "./src/errors.js",
+ "./errors": "./src/errors.ts",
"./abstractWidget": "./src/abstractWidget.ts",
"./package.json": "./package.json"
}
diff --git a/src/frontend/common/src/errors.js b/src/frontend/common/src/errors.js
deleted file mode 100644
index ecec73217..000000000
--- a/src/frontend/common/src/errors.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export const errorHandlerWithSnackbar = (snackBarEnqueue) =>
- (cause) => {
- return (error) => {
- console.error(cause + ": " + error);
- snackBarEnqueue(cause, { variant: "error" });
- };
- };
diff --git a/src/frontend/common/src/errors.ts b/src/frontend/common/src/errors.ts
new file mode 100644
index 000000000..721c2a032
--- /dev/null
+++ b/src/frontend/common/src/errors.ts
@@ -0,0 +1,13 @@
+interface SnackBarEnqueueProps {
+ variant: "error"
+}
+
+export type SnackBarEnqueue = (cause: string, props: SnackBarEnqueueProps) => void
+
+export const errorHandlerWithSnackbar = (snackBarEnqueue: SnackBarEnqueue) =>
+ (cause: string) => {
+ return (error: Error) => {
+ console.error(cause + ": " + error);
+ snackBarEnqueue(cause, { variant: "error" });
+ };
+ };
diff --git a/src/frontend/generated/api.ts b/src/frontend/generated/api.ts
index f25d1b4b4..79006d447 100644
--- a/src/frontend/generated/api.ts
+++ b/src/frontend/generated/api.ts
@@ -831,3 +831,14 @@ export interface IOIProblemEntity {
count: number;
score: number;
}
+
+export interface ExternalTeamViewSettings {
+ teamId?: TeamId | null;
+ mediaTypes?: TeamMediaType[];
+ showTaskStatus?: boolean;
+ showAchievement?: boolean;
+ showTimeLine?: boolean;
+ position?: TeamViewPosition;
+}
+
+export type ObjectSettings = any;
diff --git a/src/schema-generator/build.gradle.kts b/src/schema-generator/build.gradle.kts
index 9415d4be0..49fca6d9e 100644
--- a/src/schema-generator/build.gradle.kts
+++ b/src/schema-generator/build.gradle.kts
@@ -95,7 +95,9 @@ tasks {
"org.icpclive.api.QueueEvent",
"org.icpclive.api.AnalyticsEvent",
"org.icpclive.api.TickerEvent",
- "org.icpclive.api.SolutionsStatistic"
+ "org.icpclive.api.SolutionsStatistic",
+ "org.icpclive.api.ExternalTeamViewSettings",
+ "org.icpclive.api.ObjectSettings"
),
"api",
),