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": <Picture/>, - "Scoreboard": <ScoreboardManager/>, - "Ticker": <TickerMessage/>, - "Full screen clock": <FullScreenClockManager/>, -}; +import { ReloadHandleContext, useReloadHandleService } from "@/services/reloadHandler.ts"; +import ScoreboardPage from "@/components/pages/ScoreboardPage.tsx"; const title_elements = { "Advertisement": <Advertisement/>, @@ -86,36 +76,39 @@ function App() { }); }, []); + const reloadHandleService = useReloadHandleService(); + return ( <BrowserRouter basename={import.meta.env.BASE_URL ?? ""}> - <SnackbarProvider maxSnack={5}> - <div className="App"> - <ThemeProvider theme={getTheme(contestColor)}> - <AppNav showOrHideOverlayPerview={() => setIsOverlayPreviewShown(!isOverlayPreviewShown)}/> - </ThemeProvider> - <Routes> - <Route path="/" element={<Controls/>}/> - <Route path="/controls" element={<Controls/>}/> - {/* <Route path="/advertisement" element={<Advertisement/>}/> */} - {/* <Route path="/title" element={<Title/>}/> */} - <Route path="/titles" - element={<Dashboard elements={title_elements} layout="oneColumn" maxWidth="lg"/>}/> - {/* <Route path="/picture" element={<Picture/>}/> */} - <Route path="/teamview" element={<TeamView/>}/> - {/*<Route path="/teampvp" element={<TeamPVP/>}/>*/} - {/*<Route path="/splitscreen" element={<SplitScreen/>}/>*/} - <Route path="/scoreboard" element={<ScoreboardManager/>}/> - <Route path="/ticker" element={<TickerMessage/>}/> - <Route path="/dashboard" element={<Dashboard elements={dashboard_elements}/>}/> - <Route path="/log" element={<BackendLog/>}/> - <Route path="/analytics" element={<Analytics/>}/> - <Route path="/teamSpotlight" element={<TeamSpotlight/>}/> - <Route path="/advancedJson" element={<AdvancedJson/>}/> - <Route path="/media" element={<MediaFiles/>}/> - </Routes> - <Overlay isOverlayPreviewShown={isOverlayPreviewShown}/> - </div> - </SnackbarProvider> + <ReloadHandleContext.Provider value={reloadHandleService}> + <SnackbarProvider maxSnack={5}> + <div className="App"> + <ThemeProvider theme={getTheme(contestColor)}> + <AppNav showOrHideOverlayPerview={() => setIsOverlayPreviewShown(!isOverlayPreviewShown)}/> + </ThemeProvider> + <Routes> + <Route path="/" element={<ControlsPage/>}/> + <Route path="/controls" element={<ControlsPage/>}/> + {/* <Route path="/advertisement" element={<Advertisement/>}/> */} + {/* <Route path="/title" element={<Title/>}/> */} + <Route path="/titles" + element={<Dashboard elements={title_elements} layout="oneColumn" maxWidth="lg"/>}/> + {/* <Route path="/picture" element={<Picture/>}/> */} + <Route path="/teamview" element={<TeamView/>}/> + {/*<Route path="/teampvp" element={<TeamPVP/>}/>*/} + {/*<Route path="/splitscreen" element={<SplitScreen/>}/>*/} + <Route path="/scoreboard" element={<ScoreboardPage/>}/> + <Route path="/ticker" element={<TickerMessage/>}/> + <Route path="/log" element={<BackendLog/>}/> + <Route path="/analytics" element={<Analytics/>}/> + <Route path="/teamSpotlight" element={<TeamSpotlight/>}/> + <Route path="/advancedJson" element={<AdvancedJson/>}/> + <Route path="/media" element={<MediaFiles/>}/> + </Routes> + <Overlay isOverlayPreviewShown={isOverlayPreviewShown}/> + </div> + </SnackbarProvider> + </ReloadHandleContext.Provider> </BrowserRouter> ); } 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 ( - <Container maxWidth="md" sx={{ pt: 2 }} className="Controls"> - <PresetsManager service={service} tableKeys={["text"]} isImmutable={true}/> - </Container> - ); -} - -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 (<Container maxWidth="md" sx={{ display: "flex", flexDirection: "column", pt: 2 }}> - <Table align="center" sx={{ my: 2 }} size="small"> - <TableBody> - <TableRow> - <SlimTableCell> - Global time instead contest - </SlimTableCell> - <SlimTableCell align={"center"}> - <Switch checked={settings.globalTimeMode} - onChange={(e) => setSettings(s => ({ ...s, globalTimeMode: e.target.checked }))}/> - </SlimTableCell> - </TableRow> - <TableRow> - <SlimTableCell> - Contest time countdown - </SlimTableCell> - <SlimTableCell align={"center"}> - <Switch checked={settings.contestCountdownMode} - onChange={(e) => setSettings(s => ({ ...s, contestCountdownMode: e.target.checked }))}/> - </SlimTableCell> - </TableRow> - <TableRow> - <SlimTableCell> - Quiet mode (seconds only in countdown) - </SlimTableCell> - <SlimTableCell align={"center"}> - <Switch checked={settings.quietMode} - onChange={(e) => setSettings(s => ({ ...s, quietMode: e.target.checked }))}/> - </SlimTableCell> - </TableRow> - </TableBody> - </Table> - <div> - <ButtonGroup variant="contained" sx={{ m: 2 }}> - <Button color="primary" onClick={() => service.showPresetWithSettings(null, settings)}>Show</Button> - <Button color="error" disabled={!isShown} onClick={() => service.hidePreset()}>Hide</Button> - </ButtonGroup> - </div> - </Container>); -} - -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 (<Box display="flex" justifyContent="space-between" alignItems="center" ref={ref}> - {isPossibleArrows && <IconButton onClick={() => onChange(value - arrowsDelta)}><ArrowBackIosIcon/></IconButton>} - <TextField type="number" size="small" onChange={e => onChange(e.target.value)} value={value} - sx={{ maxWidth: isPossibleArrows ? blockWidth - 100 : 1 }}/> - {isPossibleArrows && <IconButton onClick={() => onChange(value + arrowsDelta)}><ArrowForwardIosIcon/></IconButton>} - </Box>); -} - -NumericField.propTypes = { - value: PropTypes.number.isRequired, - minValue: PropTypes.number, - arrowsDelta: PropTypes.number, - onChange: PropTypes.func.isRequired, -}; - -function ScoreboardSettings({ isShown, onClickShow, onClickHide, settings, setSettings }) { - return (<Table align="center" sx={{ my: 2 }} size="small"> - <TableHead> - <TableRow> - {["", "Start from row", "Amount of rows", "Infinity"].map(val => - <SlimTableCell key={val} align={"center"}> - <Typography variant="h6">{val}</Typography> - </SlimTableCell> - )} - </TableRow> - </TableHead> - <TableBody> - <TableRow> - <SlimTableCell align={"center"}> - <ButtonGroup variant="contained" sx={{ m: 2 }}> - <Button color="primary" disabled={isShown} onClick={onClickShow}>Show</Button> - <Button color="error" disabled={!isShown} onClick={onClickHide}>Hide</Button> - </ButtonGroup> - </SlimTableCell> - <SlimTableCell align={"center"}> - <NumericField value={settings.startFromRow} minValue={1} arrowsDelta={settings.startFromRow} - onChange={v => setSettings(s => ({ ...s, startFromRow: v }))}/> - </SlimTableCell> - <SlimTableCell align={"center"}> - <NumericField value={settings.numRows} minValue={0} arrowsDelta={settings.numRows} - onChange={v => setSettings(s => ({ ...s, numRows: v }))}/> - </SlimTableCell> - <SlimTableCell align={"center"}> - <Switch checked={settings.isInfinite} - onChange={t => setSettings(s => ({ ...s, isInfinite: t.target.checked }))}/> - </SlimTableCell> - </TableRow> - </TableBody> - </Table>); -} - -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 => - <SlimTableCell key={type} align="center"> - <Checkbox - icon={<CircleUncheckedIcon/>} - checkedIcon={<CircleCheckedIcon/>} - checked={settings.group === group && settings.optimismLevel === type} - sx={{ "& .MuiSvgIcon-root": { fontSize: 24 } }} - onChange={() => setSettings(state => ({ - ...state, - optimismLevel: type, - group: group - }))} - /> - </SlimTableCell> - ); -} - -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 (<Table sx={{ m: 2 }} size="small"> - <TableHead> - <TableRow> - <TableCell> - <Typography variant="h6">Groups</Typography> - </TableCell> - {["Normal", "Optimistic", "Pessimistic"].map(val => - <TableCell key={val} align="center"> - <Typography variant="h6">{val}</Typography> - </TableCell> - )} - </TableRow> - </TableHead> - <TableBody> - <TableRow key={"__all__"}> - <TableCell> - <Box display="flex" justifyContent="space-between" alignItems="center"> - <Box>All groups</Box> - <Button onClick={() => setIsGroupsExpand(!isGroupsExpand)}> - {isGroupsExpand ? <ExpandLess/> : <ExpandMore/>}</Button> - </Box> - </TableCell> - <ScoreboardOptLevelCells settings={settings} setSettings={setSettings} group={"all"}/> - </TableRow> - {isGroupsExpand && groupsList.map(group => - <TableRow key={group.id}> - <TableCell> - {group.displayName} - </TableCell> - <ScoreboardOptLevelCells settings={settings} setSettings={setSettings} group={group.id}/> - </TableRow> - )} - </TableBody> - </Table>); -} - -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 ( - <Container maxWidth="md" sx={{ display: "flex", width: "75%", flexDirection: "column", pt: 2 }} - className="ScoreboardSettings"> - <ScoreboardSettings isShown={isShown} onClickShow={onClickShow} onClickHide={onClickHide} - settings={settings} setSettings={setSettings}/> - <ScoreboardGroupSetting groupsList={groupsList} settings={settings} setSettings={setSettings}/> - </Container> - ); -} - -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 ( + <Box + display="flex" + justifyContent="space-between" + alignItems="center" + ref={ref} + > + {isPossibleArrows && ( + <IconButton onClick={() => onChange(value - arrowsDelta)}><ArrowBackIosIcon/></IconButton> + )} + <TextField + type="number" + size="small" + onChange={e => onChange(e.target.value)} + value={value} + sx={{ maxWidth: isPossibleArrows ? (blockWidth - 100) : 1 }} + /> + {isPossibleArrows && ( + <IconButton onClick={() => onChange(value + arrowsDelta)}><ArrowForwardIosIcon/></IconButton> + )} + </Box> + ); +}; + +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 <Button color={checked ? "error" : "primary"} startIcon={checked ? <VisibilityOffIcon/> : <VisibilityIcon/>} - 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<FullScreenClockSettings>; + isShown: boolean; + settings: FullScreenClockSettings; + setSettings: Dispatch<SetStateAction<FullScreenClockSettings>>; +} + +const FullScreenClockManager = ({ service, isShown, settings, setSettings }: FullScreenClockManagerProps) => { + return (<> + <Table align="center" size="small"> + <TableBody> + <TableRow> + <SlimTableCell> + Global time instead contest + </SlimTableCell> + <SlimTableCell align={"center"}> + <Switch + checked={settings.globalTimeMode} + onChange={(e) => setSettings(s => ({ ...s, globalTimeMode: e.target.checked }))} + /> + </SlimTableCell> + </TableRow> + <TableRow> + <SlimTableCell> + Contest time countdown + </SlimTableCell> + <SlimTableCell align={"center"}> + <Switch + checked={settings.contestCountdownMode} + onChange={(e) => setSettings(s => ({ ...s, contestCountdownMode: e.target.checked }))} + /> + </SlimTableCell> + </TableRow> + <TableRow> + <SlimTableCell> + Quiet mode (seconds only in countdown) + </SlimTableCell> + <SlimTableCell align={"center"}> + <Switch + checked={settings.quietMode} + onChange={(e) => setSettings(s => ({ ...s, quietMode: e.target.checked }))} + /> + </SlimTableCell> + </TableRow> + </TableBody> + </Table> + <div> + <ButtonGroup variant="contained" sx={{ m: 2 }}> + <Button color="primary" onClick={() => service.showWithSettings(settings)}>Show</Button> + <Button color="error" disabled={!isShown} onClick={() => service.hide()}>Hide</Button> + </ButtonGroup> + </div> + </>); +}; + +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<SetStateAction<ScoreboardSettings>>; +} + +const ScoreboardSettingsTab = ({ isShown, onClickShow, onClickHide, settings, setSettings }: ScoreboardSettingsTabProps) => { + return (<Table align="center" sx={{ my: 0 }} size="small"> + <TableHead> + <TableRow> + {["", "Start from row", "Amount of rows", "Infinity"].map(val => + <SlimTableCell key={val} align={"center"}> + <Typography variant="h6">{val}</Typography> + </SlimTableCell> + )} + </TableRow> + </TableHead> + <TableBody> + <TableRow> + <SlimTableCell align={"center"}> + <ButtonGroup variant="contained" sx={{ m: 2 }}> + <Button color="primary" disabled={isShown} onClick={onClickShow}>Show</Button> + <Button color="error" disabled={!isShown} onClick={onClickHide}>Hide</Button> + </ButtonGroup> + </SlimTableCell> + <SlimTableCell align={"center"}> + <NumericField value={settings.startFromRow} minValue={1} arrowsDelta={settings.startFromRow} + onChange={v => setSettings(s => ({ ...s, startFromRow: v }))}/> + </SlimTableCell> + <SlimTableCell align={"center"}> + <NumericField value={settings.numRows} minValue={0} arrowsDelta={settings.numRows} + onChange={v => setSettings(s => ({ ...s, numRows: v }))}/> + </SlimTableCell> + <SlimTableCell align={"center"}> + <Switch checked={settings.isInfinite} + onChange={t => setSettings(s => ({ ...s, isInfinite: t.target.checked }))}/> + </SlimTableCell> + </TableRow> + </TableBody> + </Table>); +}; + +type ScoreboardOptLevelCellsProps = { + settings: ScoreboardSettings; + setSettings: Dispatch<SetStateAction<ScoreboardSettings>>; + group: string; +} + +const ScoreboardOptLevelCells = ({ settings, setSettings, group }: ScoreboardOptLevelCellsProps) => { + return [OptimismLevel.normal, OptimismLevel.optimistic, OptimismLevel.pessimistic].map(type => + <SlimTableCell key={type} align="center"> + <Checkbox + icon={<CircleUncheckedIcon/>} + checkedIcon={<CircleCheckedIcon/>} + checked={settings.group === group && settings.optimismLevel === type} + sx={{ "& .MuiSvgIcon-root": { fontSize: 24 } }} + onChange={() => setSettings(state => ({ + ...state, + optimismLevel: type, + group: group + }))} + /> + </SlimTableCell> + ); +}; + +type ScoreboardGroupSettingProps = { + settings: ScoreboardSettings; + setSettings: Dispatch<SetStateAction<ScoreboardSettings>>; + groupsList: GroupInfo[]; +} + +const ScoreboardGroupSetting = ({ settings, setSettings, groupsList }: ScoreboardGroupSettingProps) => { + const [isGroupsExpand, setIsGroupsExpand] = useState(false); + useEffect(() => setIsGroupsExpand(s => s || settings.group !== "all"), [settings.group]); + + return (<Table sx={{ m: 2 }} size="small"> + <TableHead> + <TableRow> + <TableCell> + <Typography variant="h6">Groups</Typography> + </TableCell> + {["Normal", "Optimistic", "Pessimistic"].map(val => + <TableCell key={val} align="center"> + <Typography variant="h6">{val}</Typography> + </TableCell> + )} + </TableRow> + </TableHead> + <TableBody> + <TableRow key={"__all__"}> + <TableCell> + <Box display="flex" justifyContent="space-between" alignItems="center"> + <Box>All groups</Box> + <Button onClick={() => setIsGroupsExpand(!isGroupsExpand)}> + {isGroupsExpand ? <ExpandLess/> : <ExpandMore/>}</Button> + </Box> + </TableCell> + <ScoreboardOptLevelCells settings={settings} setSettings={setSettings} group={"all"}/> + </TableRow> + {isGroupsExpand && groupsList.map(group => + <TableRow key={group.id}> + <TableCell> + {group.displayName} + </TableCell> + <ScoreboardOptLevelCells settings={settings} setSettings={setSettings} group={group.id}/> + </TableRow> + )} + </TableBody> + </Table>); +}; + +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<SetStateAction<ScoreboardSettings>>; +} + +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 ( + <> + <ScoreboardSettingsTab isShown={isShown} onClickShow={onClickShow} onClickHide={onClickHide} + settings={settings} setSettings={setSettings}/> + <ScoreboardGroupSetting groupsList={groupsList} settings={settings} setSettings={setSettings}/> + </> + ); +}; + +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 ( + <Accordion + slotProps={{ transition: { unmountOnExit: true } }} + expanded={expanded && !!children} + onChange={(_, e) => setExpanded(e)} + className="WidgetGroupAccordion" + > + <AccordionSummary expandIcon={children && <ExpandMoreIcon />}> + <ShowPresetButton + onClick={onClickShow} + checked={isShown} + /> + <Typography className="aboba" variant="body2" gutterBottom>{title}</Typography> + </AccordionSummary> + <AccordionDetails sx={{ py: 1 }}> + {children} + </AccordionDetails> + </Accordion> + ); +}; + +type NoSettingsWidgetProps = { + title: string; + apiPath: string; +}; + +const SimpleWidgetGroup = ({ title, apiPath }: NoSettingsWidgetProps) => { + const service = useSingleWidgetService<ObjectSettings>(apiPath); + const { isShown } = useServiceLoadStatus(service, undefined); + + return ( + <WidgetGroup title={title} isShown={isShown} onClickShow={s => s ? service.show() : service.hide()}/> + ); +}; + +const ScoreboardWidgetGroup = () => { + const service = useScoreboardWidgetService(); + const { isShown, settings, setSettings } = + useServiceLoadStatus(service, DEFAULT_SCOREBOARD_SETTINGS); + + return ( + <WidgetGroup title={"Scoreboard"} isShown={isShown} onClickShow={s => s ? service.show() : service.hide()}> + <ScoreboardManager service={service} isShown={isShown} settings={settings} setSettings={setSettings} /> + </WidgetGroup> + ); +}; + +const FullScreenClockWidgetGroup = () => { + const service = useSingleWidgetService<FullScreenClockSettings>("/fullScreenClock"); + const { isShown, settings, setSettings } = + useServiceLoadStatus(service, DEFAULT_FULL_SCREEN_CLOCK_SETTINGS); + + return ( + <WidgetGroup title={"Full screen clock"} isShown={isShown} onClickShow={s => s ? service.show() : service.hide()}> + <FullScreenClockManager service={service} isShown={isShown} settings={settings} setSettings={setSettings} /> + </WidgetGroup> + ); +}; + +const ControlsPage = () => { + return ( + <Container maxWidth="md" sx={{ pt: 2 }} className="Controls"> + <ScoreboardWidgetGroup/> + <SimpleWidgetGroup title={"Queue"} apiPath={"/queue"}/> + <SimpleWidgetGroup title={"Statistic"} apiPath={"/statistics"}/> + <SimpleWidgetGroup title={"Ticker"} apiPath={"/ticker"}/> + <FullScreenClockWidgetGroup/> + </Container> + ); +}; + +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<FullScreenClockSettings>("/fullScreenClock"); + const { isShown, settings, setSettings } = + useServiceLoadStatus(service, DEFAULT_FULL_SCREEN_CLOCK_SETTINGS); + + return ( + <Container maxWidth="md" sx={{ display: "flex", flexDirection: "column", pt: 2 }}> + <FullScreenClockManager service={service} isShown={isShown} settings={settings} setSettings={setSettings} /> + </Container> + ); +}; + +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 ( + <Container maxWidth="md" sx={{ display: "flex", width: "75%", flexDirection: "column", pt: 2 }} + className="ScoreboardSettings"> + <ScoreboardManager service={service} isShown={isShown} settings={settings} setSettings={setSettings} /> + </Container> + ); +}; + +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<SettingsType extends ObjectSettings> { + shown: boolean; + settings: SettingsType +} + +export class AbstractSingleWidgetService<Settings extends ObjectSettings> { + apiPath: string; + apiGet: ApiGetClient; + apiPost: ApiPostClient; + errorHandler: ErrorHandler = cause => e => this.handleError(cause, e); // why it's not function? + errorHandlers: Set<ErrorHandler> = new Set(); + reloadDataHandlers: Set<ReloadHandler> = 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<Settings>); + } + + 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>, + 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<Settings extends ObjectSettings>(apiPath: string) { + const service = useMemo( + () => new AbstractSingleWidgetService<Settings>(apiPath), + [apiPath]); + useServiceSnackbarErrorHandler(service); + + return service; +} + +export function useServiceLoadStatus< + Service extends AbstractSingleWidgetService<Settings>, + Settings extends ObjectSettings +>(service: Service, defaultSettings: Settings) { + const [isShown, setIsShown] = useState<boolean>(false); + const [settings, setSettings] = useState<Settings>(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<ReloadHandler>; + + 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<WebSocket>(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<ReloadHandleService>(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<ScoreboardSettings> { + 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", ),