diff --git a/src/App.tsx b/src/App.tsx index 2caae6a2..51008142 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,13 +2,12 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import styled from "@emotion/styled"; import { useState } from "react"; import { ErrorPage } from "./components/router-error.tsx"; -import { useDevicePermissions } from "./use-device-permission.ts"; +import { useDevicePermissions } from "./hooks/use-device-permission.ts"; import { LandingPage } from "./components/landing-page/landing-page.tsx"; import { useInitializeGlobalStateReducer } from "./global-state/global-state-reducer.ts"; import { GlobalStateContext } from "./global-state/context-provider.tsx"; -import { Header } from "./components/header.tsx"; import { ErrorBanner } from "./components/error"; -import { useFetchDevices } from "./use-fetch-devices.ts"; +import { useFetchDevices } from "./hooks/use-fetch-devices.ts"; import { DisplayContainer, FlexContainer, @@ -19,6 +18,9 @@ import { isValidBrowser } from "./bowser.ts"; import { DisplayContainerHeader } from "./components/landing-page/display-container-header.tsx"; import { NavigateToRootButton } from "./components/navigate-to-root-button/navigate-to-root-button.tsx"; import { CallsPage } from "./components/calls-page/calls-page.tsx"; +import { CreateProductionPage } from "./components/create-production/create-production-page.tsx"; +import { Header } from "./components/header.tsx"; +import { useLocalUserSettings } from "./hooks/use-local-user-settings.ts"; const DisplayBoxPositioningContainer = styled(FlexContainer)` justify-content: center; @@ -57,6 +59,8 @@ const App = () => { permission, }); + useLocalUserSettings({ dispatch }); + return ( @@ -120,6 +124,15 @@ const App = () => { } errorElement={} /> + setApiError(true)} + /> + } + errorElement={} + /> } diff --git a/src/api/api.ts b/src/api/api.ts index a5254b32..763da589 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -28,6 +28,7 @@ type TLine = { export type TBasicProductionResponse = { name: string; productionId: string; + lines?: TLine[]; }; type TFetchProductionResponse = TBasicProductionResponse & { diff --git a/src/assets/icons/add.svg b/src/assets/icons/add.svg new file mode 100644 index 00000000..122d14a2 --- /dev/null +++ b/src/assets/icons/add.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/edit.svg b/src/assets/icons/edit.svg new file mode 100644 index 00000000..86a45710 --- /dev/null +++ b/src/assets/icons/edit.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/icon.tsx b/src/assets/icons/icon.tsx index 1f7e1127..6c69256a 100644 --- a/src/assets/icons/icon.tsx +++ b/src/assets/icons/icon.tsx @@ -9,9 +9,14 @@ import ConfirmSvg from "./done.svg?react"; import StepLeftSvg from "./chevron_left.svg?react"; import StepRightSvg from "./navigate_next.svg?react"; import Settings from "./settings.svg?react"; +import Headset from "./headset.svg?react"; +import UserSettings from "./user_settings.svg?react"; import ChevronDown from "./chevron_down.svg?react"; import ChevronUp from "./chevron_up.svg?react"; -import Headset from "./headset.svg?react"; +import Person from "./person.svg?react"; +import Users from "./users.svg?react"; +import Add from "./add.svg?react"; +import Edit from "./edit.svg?react"; export const MicMuted = () => ; @@ -35,8 +40,18 @@ export const StepRightIcon = () => ; export const SettingsIcon = () => ; +export const UserSettingsIcon = () => ; + export const ChevronDownIcon = () => ; export const ChevronUpIcon = () => ; export const HeadsetIcon = () => ; + +export const PersonIcon = () => ; + +export const UsersIcon = () => ; + +export const AddIcon = () => ; + +export const EditIcon = () => ; diff --git a/src/assets/icons/person.svg b/src/assets/icons/person.svg new file mode 100644 index 00000000..1b505d25 --- /dev/null +++ b/src/assets/icons/person.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/user_settings.svg b/src/assets/icons/user_settings.svg new file mode 100644 index 00000000..50946657 --- /dev/null +++ b/src/assets/icons/user_settings.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/users.svg b/src/assets/icons/users.svg new file mode 100644 index 00000000..e2c477bb --- /dev/null +++ b/src/assets/icons/users.svg @@ -0,0 +1 @@ + diff --git a/src/components/create-production/create-production-page.tsx b/src/components/create-production/create-production-page.tsx new file mode 100644 index 00000000..053e98dc --- /dev/null +++ b/src/components/create-production/create-production-page.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useGlobalState } from "../../global-state/context-provider"; +import { CreateProduction } from "./create-production"; + +export const CreateProductionPage = ({ + setApiError, +}: { + setApiError: () => void; +}) => { + const [{ apiError }] = useGlobalState(); + + useEffect(() => { + if (apiError) { + setApiError(); + } + }, [apiError, setApiError]); + + return ; +}; diff --git a/src/components/landing-page/create-production.tsx b/src/components/create-production/create-production.tsx similarity index 89% rename from src/components/landing-page/create-production.tsx rename to src/components/create-production/create-production.tsx index cb1db8b4..db5af25d 100644 --- a/src/components/landing-page/create-production.tsx +++ b/src/components/create-production/create-production.tsx @@ -2,23 +2,25 @@ import { SubmitHandler, useFieldArray, useForm } from "react-hook-form"; import { useEffect, useState } from "react"; import styled from "@emotion/styled"; import { ErrorMessage } from "@hookform/error-message"; -import { DisplayContainerHeader } from "./display-container-header.tsx"; +import { DisplayContainerHeader } from "../landing-page/display-container-header.tsx"; import { DecorativeLabel, - FormContainer, FormInput, FormLabel, StyledWarningMessage, PrimaryButton, SecondaryButton, -} from "./form-elements.tsx"; +} from "../landing-page/form-elements.tsx"; import { API } from "../../api/api.ts"; import { useGlobalState } from "../../global-state/context-provider.tsx"; import { Spinner } from "../loader/loader.tsx"; import { FlexContainer } from "../generic-components.ts"; import { RemoveLineButton } from "../remove-line-button/remove-line-button.tsx"; -import { useFetchProduction } from "./use-fetch-production.ts"; +import { useFetchProduction } from "../landing-page/use-fetch-production.ts"; import { darkText, errorColour } from "../../css-helpers/defaults.ts"; +import { NavigateToRootButton } from "../navigate-to-root-button/navigate-to-root-button.tsx"; +import { ResponsiveFormContainer } from "../user-settings/user-settings.tsx"; +import { isMobile } from "../../bowser.ts"; type FormValues = { productionName: string; @@ -26,6 +28,16 @@ type FormValues = { lines: { name: string }[]; }; +const HeaderWrapper = styled.div` + display: flex; + margin-bottom: 2rem; + align-items: center; + h2 { + margin: 0; + margin-left: 1rem; + } +`; + const ListItemWrapper = styled.div` position: relative; `; @@ -151,8 +163,11 @@ export const CreateProduction = () => { }; return ( - - Create Production + + + + Create Production + Production Name { )} )} - + ); }; diff --git a/src/components/header.tsx b/src/components/header.tsx index 27684ec0..352609fc 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -26,7 +26,7 @@ const Logo = styled.svg` width: 2.4rem; height: 2.4rem; margin-right: 1rem; - margin-left: 2rem; + margin-left: 1rem; `; export const Header: FC = () => { @@ -51,7 +51,7 @@ export const Header: FC = () => { }; const returnToRoot = () => { - if (location.pathname.includes("/production")) { + if (location.pathname.includes("/line")) { setConfirmExitModalOpen(true); } else { navigate("/"); diff --git a/src/components/landing-page/form-elements.tsx b/src/components/landing-page/form-elements.tsx index f535dd62..35e2d09d 100644 --- a/src/components/landing-page/form-elements.tsx +++ b/src/components/landing-page/form-elements.tsx @@ -117,8 +117,8 @@ export const ActionButton = styled.button` `; export const PrimaryButton = styled(ActionButton)` + background: rgba(89, 203, 232, 1); &:active:enabled { - background: #1db954; transform: translateY(0.125rem); } @@ -157,13 +157,14 @@ export const PrimaryButton = styled(ActionButton)` `; export const SecondaryButton = styled(ActionButton)` + // background: #1db954; + background: white; &:before, &:after { border-radius: 0.5rem; } &:before { - background: rgba(89, 203, 232, 1); content: ""; display: block; height: 100%; @@ -176,7 +177,6 @@ export const SecondaryButton = styled(ActionButton)` } &:after { - background: rgba(89, 203, 232, 1) content: ""; display: block; overflow: hidden; diff --git a/src/components/landing-page/landing-page.tsx b/src/components/landing-page/landing-page.tsx index db631848..2248c08e 100644 --- a/src/components/landing-page/landing-page.tsx +++ b/src/components/landing-page/landing-page.tsx @@ -1,13 +1,12 @@ -import { useEffect } from "react"; -import { JoinProduction } from "./join-production.tsx"; -import { CreateProduction } from "./create-production.tsx"; +import { useEffect, useState } from "react"; import { ProductionsListContainer } from "./productions-list-container.tsx"; -import { DisplayContainer, FlexContainer } from "../generic-components.ts"; import { useGlobalState } from "../../global-state/context-provider.tsx"; -import { isMobile } from "../../bowser.ts"; +import { UserSettings } from "../user-settings/user-settings.tsx"; +import { UserSettingsButton } from "./user-settings-button.tsx"; export const LandingPage = ({ setApiError }: { setApiError: () => void }) => { const [{ apiError }] = useGlobalState(); + const [showSettings, setShowSettings] = useState(false); useEffect(() => { if (apiError) { @@ -16,18 +15,18 @@ export const LandingPage = ({ setApiError }: { setApiError: () => void }) => { }, [apiError, setApiError]); return ( - <> - - - - - {!isMobile && ( - - - - )} - - - +
+ {((showSettings || !window.localStorage?.getItem("username")) && ( + setShowSettings(false)} + /> + )) || ( + <> + setShowSettings(!showSettings)} /> + + + )} +
); }; diff --git a/src/components/landing-page/production-list-header.tsx b/src/components/landing-page/production-list-header.tsx new file mode 100644 index 00000000..5e422533 --- /dev/null +++ b/src/components/landing-page/production-list-header.tsx @@ -0,0 +1,107 @@ +import styled from "@emotion/styled"; +import { FC } from "react"; +import { useNavigate } from "react-router-dom"; +import { DisplayContainer } from "../generic-components"; +import { DisplayContainerHeader } from "./display-container-header"; +import { LoaderDots } from "../loader/loader"; +import { isMobile } from "../../bowser"; +import { AddIcon, EditIcon } from "../../assets/icons/icon"; + +const HeaderContainer = styled(DisplayContainer)` + padding: 2rem; + padding-top: 1rem; + display: flex; + flex-wrap: wrap; + width: 100%; + max-width: 100%; + justify-content: space-between; + svg { + height: 2.5rem; + width: 2.5rem; + } +`; + +const HeaderLeftSide = styled.div` + display: flex; + align-items: center; +`; + +const CustomContainerHeader = styled(DisplayContainerHeader)` + margin: 0; + display: inline-flex; +`; + +const LoaderWrapper = styled.div` + width: 1rem; + height: 3rem; + display: inline-flex; + flex-direction: column; + justify-content: flex-end; + margin-right: 1rem; +`; + +const HeaderButton = styled.div` + width: 10rem; + border: 1px solid white; + border-radius: 1rem; + margin-left: 1rem; + padding: 1rem; + display: inline-flex; + justify-content: center; + align-items: center; + &:hover { + cursor: pointer; + } +`; + +const HeaderButtonText = styled.p` + display: inline-block; + margin-right: 0.5rem; + font-weight: bold; +`; + +interface ProductionsListHeaderProps { + loading?: boolean; + hasProductions?: boolean; +} + +export const ProductionsListHeader: FC = ( + props +) => { + const { loading = false, hasProductions = false } = props; + + const navigate = useNavigate(); + + const goToCreate = () => { + navigate("/create-production"); + }; + + const goToManage = () => { + navigate("/manage-productions"); + }; + + return ( + + + Productions + + + + + {!isMobile && ( +
+ {hasProductions && ( + + Edit + + + )} + + Create + + +
+ )} +
+ ); +}; diff --git a/src/components/landing-page/productions-list-container.tsx b/src/components/landing-page/productions-list-container.tsx index df2b5e1e..d83f79fb 100644 --- a/src/components/landing-page/productions-list-container.tsx +++ b/src/components/landing-page/productions-list-container.tsx @@ -1,34 +1,17 @@ -import styled from "@emotion/styled"; import { useEffect } from "react"; import { useGlobalState } from "../../global-state/context-provider.tsx"; -import { LoaderDots } from "../loader/loader.tsx"; import { useRefreshAnimation } from "./use-refresh-animation.ts"; -import { DisplayContainerHeader } from "./display-container-header.tsx"; -import { DisplayContainer } from "../generic-components.ts"; -import { ManageProductionButton } from "./manage-production-button.tsx"; import { useFetchProductionList } from "./use-fetch-production-list.ts"; -import { ProductionsList } from "../productions-list.tsx"; - -const HeaderContainer = styled(DisplayContainer)` - padding: 0 2rem 0 2rem; -`; - -const LoaderWrapper = styled.div` - padding-bottom: 1rem; -`; - -const ListWrapper = styled.div` - display: flex; - flex-wrap: wrap; - padding: 0 0 0 2rem; -`; +import { ProductionsList } from "../production-list/productions-list.tsx"; +import { ProductionsListHeader } from "./production-list-header.tsx"; export const ProductionsListContainer = () => { - const [{ reloadProductionList }, dispatch] = useGlobalState(); + const [{ reloadProductionList }] = useGlobalState(); const { productions, doInitialLoad, error, setIntervalLoad } = useFetchProductionList({ limit: "10", + extended: "true", }); const showRefreshing = useRefreshAnimation({ @@ -48,28 +31,13 @@ export const ProductionsListContainer = () => { return ( <> - - - - - Production List - - - - dispatch({ - type: "SELECT_PRODUCTION_ID", - payload: input, - }) - } - /> - - {!!productions?.productions.length && } + + {!!productions?.productions.length && ( + + )} ); }; diff --git a/src/components/landing-page/use-fetch-production-list.ts b/src/components/landing-page/use-fetch-production-list.ts index 8dd9dbbf..6a0f146e 100644 --- a/src/components/landing-page/use-fetch-production-list.ts +++ b/src/components/landing-page/use-fetch-production-list.ts @@ -5,6 +5,7 @@ import { API, TListProductionsResponse } from "../../api/api.ts"; export type GetProductionListFilter = { limit?: string; offset?: string; + extended?: string; }; export const useFetchProductionList = (filter?: GetProductionListFilter) => { @@ -18,9 +19,9 @@ export const useFetchProductionList = (filter?: GetProductionListFilter) => { const manageProdPaginationUpdate = filter?.offset !== productions?.offset.toString(); + // TODO improve performance: this makes the call 3 times useEffect(() => { let aborted = false; - if ( reloadProductionList || intervalLoad || @@ -29,7 +30,6 @@ export const useFetchProductionList = (filter?: GetProductionListFilter) => { (filter?.offset ? manageProdPaginationUpdate : false) ) { const searchParams = new URLSearchParams(filter).toString(); - API.listProductions({ searchParams }) .then((result) => { if (aborted) return; diff --git a/src/components/landing-page/user-settings-button.tsx b/src/components/landing-page/user-settings-button.tsx new file mode 100644 index 00000000..739946e3 --- /dev/null +++ b/src/components/landing-page/user-settings-button.tsx @@ -0,0 +1,36 @@ +import styled from "@emotion/styled"; +import { FC } from "react"; +import { UserSettingsIcon } from "../../assets/icons/icon"; + +const UserSettingsWrapper = styled.div` + position: absolute; + top: 0; + right: 0; + padding: 1.6rem 2rem 1rem 0; + font-size: 2rem; + display: flex; + align-items: center; + font-weight: bold; + &:hover { + cursor: pointer; + } +`; + +const Username = styled.div` + margin-right: 0.5rem; +`; + +interface UserSettingsButtonProps { + onClick?: () => void; +} + +export const UserSettingsButton: FC = (props) => { + const { onClick } = props; + + return ( + + {window.localStorage?.getItem("username") || "Guest"} + + + ); +}; diff --git a/src/components/loader/loader.tsx b/src/components/loader/loader.tsx index 69a9b120..6f676956 100644 --- a/src/components/loader/loader.tsx +++ b/src/components/loader/loader.tsx @@ -91,7 +91,7 @@ const Dots = styled.span` type TSpinner = { className: string }; -type TLoaderDots = { className: string; text: string }; +type TLoaderDots = { className: string; text?: string }; export const Spinner: FC = ({ className }: TSpinner) => { return ; @@ -115,7 +115,7 @@ export const LoaderDots: FC = ({ return (
- {text} + {text && {text}} {dots}
); diff --git a/src/components/manage-productions/paginated-list.tsx b/src/components/manage-productions/paginated-list.tsx index 08027089..1d5aa518 100644 --- a/src/components/manage-productions/paginated-list.tsx +++ b/src/components/manage-productions/paginated-list.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import { useEffect, useState } from "react"; -import { ProductionsList } from "../productions-list"; +import { ProductionsList } from "../production-list/productions-list"; import { LoaderDots } from "../loader/loader"; import { TListProductionsResponse } from "../../api/api"; import { StepLeftIcon, StepRightIcon } from "../../assets/icons/icon"; @@ -49,7 +49,6 @@ type TPaginatedList = { showRefreshing: boolean; productions: TListProductionsResponse | undefined; error: Error | null; - manageProduction: (v: string) => void; }; export const PaginatedList = ({ @@ -57,7 +56,6 @@ export const PaginatedList = ({ showRefreshing, productions, error, - manageProduction, }: TPaginatedList) => { const [pagesArray, setPagesArray] = useState(); const totalPages = productions @@ -98,7 +96,6 @@ export const PaginatedList = ({ manageProduction(v)} /> {totalPages > 1 && ( diff --git a/src/components/production-line/types.ts b/src/components/production-line/types.ts index acb1b1b9..4825116e 100644 --- a/src/components/production-line/types.ts +++ b/src/components/production-line/types.ts @@ -1,3 +1,4 @@ +// TODO split user settings from join production options export type TJoinProductionOptions = { productionId: string; lineId: string; diff --git a/src/components/production-list/production-list-item.tsx b/src/components/production-list/production-list-item.tsx new file mode 100644 index 00000000..2550f599 --- /dev/null +++ b/src/components/production-list/production-list-item.tsx @@ -0,0 +1,229 @@ +import styled from "@emotion/styled"; +import { useMemo, useState } from "react"; +import { isMobile } from "../../bowser"; +import { TBasicProductionResponse } from "../../api/api"; +import { useGlobalState } from "../../global-state/context-provider"; +import { + ChevronDownIcon, + ChevronUpIcon, + PersonIcon, + UsersIcon, +} from "../../assets/icons/icon"; +import { SecondaryButton } from "../landing-page/form-elements"; +import { useNavigate } from "react-router-dom"; + +const ProductionItemWrapper = styled.div` + text-align: start; + color: #ffffff; + background-color: transparent; + flex: 0 0 calc(25% - 2rem); + ${() => (isMobile ? `flex-grow: 1;` : `flex-grow: 0;`)} + justify-content: start; + min-width: 20rem; + border: 1px solid #424242; + border-radius: 0.5rem; + margin: 0 2rem 2rem 0; + cursor: pointer; +`; + +const ProductionName = styled.div` + font-size: 1.4rem; + font-weight: bold; + margin-right: 1rem; + word-break: break-word; +`; + +const ParticipantCount = styled.div` + font-size: 1.5rem; + color: #9e9e9e; +`; + +const HeaderWrapper = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + padding: 2rem; +`; + +const HeaderTexts = styled.div` + display: flex; + align-items: center; + svg { + height: 1.5rem; + width: 1.5rem; + margin-right: 0.5rem; + } + &.active { + svg { + fill: #73d16d; + } + } +`; + +const HeaderIcon = styled.div` + display: flex; + align-items: center; + height: 2rem; + width: 2rem; +`; + +const ProductionLines = styled.div` + display: grid; + padding: 0 2rem; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease-out; + + &.expanded { + grid-template-rows: 1fr; + padding-bottom: 2rem; + } +`; + +const InnerDiv = styled.div` + overflow: hidden; + display: flex; + flex-direction: column; +`; + +const Lineblock = styled.div` + margin-top: 1rem; + background-color: #4d4d4d; + border-radius: 1rem; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const LineBlockTexts = styled.div``; + +const LineBlockTitle = styled.div` + font-weight: bold; + font-size: 1.5rem; +`; + +const LineBlockParticipants = styled.div` + // margin-top: 0.5rem; +`; + +const LineBlockParticipant = styled.div` + margin-top: 0.5rem; + display: flex; + align-items: center; + + svg { + height: 2rem; + width: 2rem; + } +`; + +const PersonText = styled.div` + margin-left: 0.5rem; +`; + +type ProductionsListItemProps = { + production: TBasicProductionResponse; +}; + +export const ProductionsListItem = ({ + production, +}: ProductionsListItemProps) => { + const [{ userSettings }, dispatch] = useGlobalState(); + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + + const totalParticipants = useMemo(() => { + return ( + production.lines + ?.map((line) => line.participants.length || 0) + .reduce((partialSum, a) => partialSum + a, 0) || 0 + ); + }, [production]); + + const goToProduction = (lineId: string) => { + // TODO add some visual feedback here if somehow userSettings is not configured + if ( + userSettings?.username && + userSettings?.audioinput && + userSettings?.audiooutput + ) { + const payload = { + productionId: production.productionId, + lineId, + username: userSettings.username, + audioinput: userSettings.audioinput, + audiooutput: userSettings.audiooutput, + }; + + const uuid = globalThis.crypto.randomUUID(); + + dispatch({ + type: "ADD_CALL", + payload: { + id: uuid, + callState: { + production: null, + reloadProductionList: false, + devices: null, + joinProductionOptions: payload, + mediaStreamInput: null, + dominantSpeaker: null, + audioLevelAboveThreshold: false, + connectionState: null, + audioElements: null, + sessionId: null, + }, + }, + }); + dispatch({ + type: "SELECT_PRODUCTION_ID", + payload: payload.productionId, + }); + navigate( + `/production-calls/production/${payload.productionId}/line/${lineId}` + ); + } + }; + + return ( + + setOpen(!open)}> + 0 ? "active" : ""}> + {production.name} + + {totalParticipants} + + + {open ? : } + + + + + {production.lines?.map((l) => ( + + + {l.name} + + {l.participants.map((participant) => ( + + + {participant.name} + + ))} + + + goToProduction(l.id)} + > + Join + + + ))} + + + + ); +}; diff --git a/src/components/production-list/productions-list.tsx b/src/components/production-list/productions-list.tsx new file mode 100644 index 00000000..65a4fb07 --- /dev/null +++ b/src/components/production-list/productions-list.tsx @@ -0,0 +1,29 @@ +import styled from "@emotion/styled"; +import { LocalError } from "../error.tsx"; +import { ProductionsListItem } from "./production-list-item.tsx"; +import { TBasicProductionResponse } from "../../api/api.ts"; + +const ListWrapper = styled.div` + display: flex; + flex-wrap: wrap; + padding: 0 0 0 2rem; + align-items: flex-start; +`; + +type TProductionsList = { + productions: TBasicProductionResponse[]; + error: Error | null; +}; + +export const ProductionsList = ({ productions, error }: TProductionsList) => { + return ( + + {error && } + {!error && + productions && + productions.map((p) => ( + + ))} + + ); +}; diff --git a/src/components/productions-list.tsx b/src/components/productions-list.tsx deleted file mode 100644 index d6a3c034..00000000 --- a/src/components/productions-list.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import styled from "@emotion/styled"; -import { TBasicProduction } from "./production-line/types.ts"; -import { LocalError } from "./error.tsx"; - -const ProductionItem = styled.button` - text-align: start; - color: #ffffff; - background-color: transparent; - flex: 1 0 calc(25% - 2rem); - justify-content: start; - min-width: 20rem; - border: 1px solid #424242; - border-radius: 0.5rem; - padding: 2rem; - margin: 0 2rem 2rem 0; - cursor: pointer; -`; - -const ProductionName = styled.div` - font-size: 1.4rem; - font-weight: bold; - margin: 0 0 1rem; - word-break: break-word; -`; - -const ProductionId = styled.div` - font-size: 2rem; - color: #9e9e9e; -`; - -type TProductionsList = { - productions: TBasicProduction[] | undefined; - error: Error | null; - setProductionId: (v: string) => void; -}; - -export const ProductionsList = ({ - productions, - error, - setProductionId, -}: TProductionsList) => { - return ( - <> - {error && } - {!error && - productions && - productions.map((p) => ( - setProductionId(p.productionId)} - > - {p.name} - {p.productionId} - - ))} - - ); -}; diff --git a/src/components/user-settings/types.ts b/src/components/user-settings/types.ts new file mode 100644 index 00000000..d6047c79 --- /dev/null +++ b/src/components/user-settings/types.ts @@ -0,0 +1,7 @@ +export type TUserSettings = { + username: string; + // Not all devices have input available + audioinput?: string; + // Not all devices allow choosing output + audiooutput?: string; +}; diff --git a/src/components/user-settings/user-settings.tsx b/src/components/user-settings/user-settings.tsx new file mode 100644 index 00000000..3dbad12a --- /dev/null +++ b/src/components/user-settings/user-settings.tsx @@ -0,0 +1,164 @@ +import { FC } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { ErrorMessage } from "@hookform/error-message"; +import styled from "@emotion/styled"; +import { useGlobalState } from "../../global-state/context-provider"; +import { uniqBy } from "../../helpers"; +import { TUserSettings } from "./types"; +import { + DecorativeLabel, + FormContainer, + FormInput, + FormLabel, + FormSelect, + PrimaryButton, + StyledWarningMessage, +} from "../landing-page/form-elements"; +import { DisplayContainerHeader } from "../landing-page/display-container-header"; +import { isMobile } from "../../bowser"; + +type FormValues = TUserSettings; + +export const ResponsiveFormContainer = styled(FormContainer)` + padding: 0 2rem; + + &.desktop { + margin: auto; + margin-top: 15rem; + width: 50rem; + } +`; + +const ButtonWrapper = styled.div` + margin: 2rem 0 2rem 0; + display: flex; + justify-content: flex-end; +`; + +interface UserSettingsProps { + buttonText?: string; + onSave?: () => void; +} + +export const UserSettings: FC = (props) => { + const { buttonText, onSave } = props; + + const [{ devices, userSettings }, dispatch] = useGlobalState(); + + const { + formState: { errors }, + register, + handleSubmit, + } = useForm({ + defaultValues: { + username: userSettings?.username, + audioinput: userSettings?.audioinput, + audiooutput: userSettings?.audiooutput, + }, + resetOptions: { + keepDirtyValues: true, // user-interacted input will be retained + keepErrors: true, // input errors will be retained with value update + }, + }); + + const onSubmit: SubmitHandler = (payload) => { + if (payload.username) { + window.localStorage?.setItem("username", payload.username); + } + + if (payload.audioinput) { + window.localStorage?.setItem("audioinput", payload.audioinput); + } + + if (payload.audiooutput) { + window.localStorage?.setItem("audiooutput", payload.audiooutput); + } + + dispatch({ + type: "UPDATE_USER_SETTINGS", + payload, + }); + if (onSave) onSave(); + }; + + const outputDevices = devices + ? uniqBy( + devices.filter((d) => d.kind === "audiooutput"), + (item) => item.deviceId + ) + : []; + + const inputDevices = devices + ? uniqBy( + devices.filter((d) => d.kind === "audioinput"), + (item) => item.deviceId + ) + : []; + + return ( + + User Settings + {devices && ( + <> + + Username + + + + + Input + + {inputDevices.length > 0 ? ( + inputDevices.map((device) => ( + + )) + ) : ( + + )} + + + + Output + {outputDevices.length > 0 ? ( + + {outputDevices.map((device) => ( + + ))} + + ) : ( + + Controlled by operating system + + )} + + + + {buttonText || "Save"} + + + + )} + + ); +}; diff --git a/src/global-state/global-state-actions.ts b/src/global-state/global-state-actions.ts index 74e2b956..2b81c5fa 100644 --- a/src/global-state/global-state-actions.ts +++ b/src/global-state/global-state-actions.ts @@ -1,10 +1,12 @@ import { CallState } from "./types.ts"; +import { TUserSettings } from "../components/user-settings/types.ts"; export type TGlobalStateAction = | TPublishError | TProductionCreated | TApiNotAvailable | TProductionListFetched + | TUpdateUserSettings | TUpdateDevicesAction | TSelectProductionId | TAddCallState @@ -52,3 +54,8 @@ export type TRemoveCallState = { type: "REMOVE_CALL"; payload: { id: string }; }; + +export type TUpdateUserSettings = { + type: "UPDATE_USER_SETTINGS"; + payload: TUserSettings | null; +}; diff --git a/src/global-state/global-state-reducer.ts b/src/global-state/global-state-reducer.ts index faedc376..c6c15685 100644 --- a/src/global-state/global-state-reducer.ts +++ b/src/global-state/global-state-reducer.ts @@ -7,6 +7,7 @@ const initialGlobalState: TGlobalState = { error: { callErrors: null, globalError: null }, reloadProductionList: false, devices: null, + userSettings: null, selectedProductionId: null, calls: {}, apiError: false, @@ -104,6 +105,11 @@ const globalReducer: Reducer = ( production: null, }; } + case "UPDATE_USER_SETTINGS": + return { + ...state, + userSettings: action.payload, + }; default: return state; } diff --git a/src/global-state/types.ts b/src/global-state/types.ts index 053a99ba..b66c2962 100644 --- a/src/global-state/types.ts +++ b/src/global-state/types.ts @@ -2,6 +2,7 @@ import { TJoinProductionOptions, TProduction, } from "../components/production-line/types.ts"; +import { TUserSettings } from "../components/user-settings/types.ts"; export interface ErrorState { globalError?: Error | null; @@ -25,6 +26,7 @@ export type TGlobalState = { calls: { [key: string]: CallState; }; + userSettings: TUserSettings | null; production: TProduction | null; error: ErrorState; reloadProductionList: boolean; diff --git a/src/use-device-permission.ts b/src/hooks/use-device-permission.ts similarity index 100% rename from src/use-device-permission.ts rename to src/hooks/use-device-permission.ts diff --git a/src/use-fetch-devices.ts b/src/hooks/use-fetch-devices.ts similarity index 89% rename from src/use-fetch-devices.ts rename to src/hooks/use-fetch-devices.ts index 8ffd21e5..b49f4798 100644 --- a/src/use-fetch-devices.ts +++ b/src/hooks/use-fetch-devices.ts @@ -1,5 +1,5 @@ import { Dispatch, useEffect } from "react"; -import { TGlobalStateAction } from "./global-state/global-state-actions"; +import { TGlobalStateAction } from "../global-state/global-state-actions"; type TUseFetchDevices = { permission: boolean; diff --git a/src/hooks/use-local-user-settings.ts b/src/hooks/use-local-user-settings.ts new file mode 100644 index 00000000..b1923c87 --- /dev/null +++ b/src/hooks/use-local-user-settings.ts @@ -0,0 +1,21 @@ +import { Dispatch, useEffect } from "react"; +import { TGlobalStateAction } from "../global-state/global-state-actions"; + +type TUseLocalUserSettings = { + dispatch: Dispatch; +}; + +export const useLocalUserSettings = ({ dispatch }: TUseLocalUserSettings) => { + // TODO check if device still exists + useEffect(() => { + const payload = { + username: window.localStorage.getItem("username") || "", + audioinput: window.localStorage.getItem("audioinput") || undefined, + audiooutput: window.localStorage.getItem("audiooutput") || undefined, + }; + dispatch({ + type: "UPDATE_USER_SETTINGS", + payload, + }); + }, [dispatch]); +};