diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 221b5380..af7b0a97 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -48,5 +48,6 @@ module.exports = { { allowConstantExport: true }, ], "no-console": "off", + "no-underscore-dangle": "off", }, }; diff --git a/src/App.tsx b/src/App.tsx index b9dd51f3..dbe4c270 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { ManageProductionsPage } from "./components/manage-productions-page/mana import { CreateProductionPage } from "./components/create-production/create-production-page.tsx"; import { useSetupTokenRefresh } from "./hooks/use-reauth.tsx"; import { TUserSettings } from "./components/user-settings/types"; +import { IngestsPage } from "./components/ingests-page/ingests-page.tsx"; const DisplayBoxPositioningContainer = styled(FlexContainer)` justify-content: center; @@ -150,6 +151,13 @@ const AppContent = ({ } errorElement={} /> + setApiError(true)} /> + } + errorElement={} + /> } diff --git a/src/api/api.ts b/src/api/api.ts index 5d744c57..62627e5a 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -32,6 +32,43 @@ export type TBasicProductionResponse = { lines: TLine[]; }; +export type TAudioDevice = { + name: string; + maxInputChannels: number; + maxOutputChannels: number; + defaultSampleRate: number; + defaultLowInputLatency: number; + defaultLowOutputLatency: number; + defaultHighInputLatency: number; + defaultHighOutputLatency: number; + isInput: boolean; + isOutput: boolean; + hostApiName: string; + label?: string; +}; + +export type TSavedIngest = { + _id: string; + label: string; + ipAddress: string; + deviceOutput: TAudioDevice[]; + deviceInput: TAudioDevice[]; +}; + +export type TEditIngest = { + _id: string; + label?: string; + deviceOutput?: TAudioDevice; + deviceInput?: TAudioDevice; +}; + +export type TListIngestResponse = { + ingests: TSavedIngest[]; + offset: 0; + limit: 0; + totalItems: 0; +}; + export type TListProductionsResponse = { productions: TBasicProductionResponse[]; offset: 0; @@ -291,4 +328,60 @@ export const API = { }) ); }, + fetchIngestList: (): Promise => + handleFetchRequest( + fetch(`${API_URL}ingest`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + createIngest: async (data: { label: string; ipAddress: string }) => + handleFetchRequest( + fetch(`${API_URL}ingest/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + label: data.label, + ipAddress: data.ipAddress, + }), + }) + ), + fetchIngest: (id: number): Promise => + handleFetchRequest( + fetch(`${API_URL}ingest/${id}`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + updateIngest: async (data: TEditIngest) => + handleFetchRequest( + fetch(`${API_URL}ingest/${data._id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + label: data.label, + deviceOutput: data.deviceOutput, + deviceInput: data.deviceInput, + }), + }) + ), + deleteIngest: async (id: string): Promise => + handleFetchRequest( + fetch(`${API_URL}ingest/${id}`, { + method: "DELETE", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), }; diff --git a/src/components/audio-feed-modal/audio-feed-modal.tsx b/src/components/audio-feed-modal/audio-feed-modal.tsx index 639d4142..48cf5549 100644 --- a/src/components/audio-feed-modal/audio-feed-modal.tsx +++ b/src/components/audio-feed-modal/audio-feed-modal.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import { Modal } from "../modal/modal"; -import { PrimaryButton } from "../landing-page/form-elements"; +import { PrimaryButton } from "../form-elements/form-elements"; import { Checkbox } from "../checkbox/checkbox"; const ContentWrapper = styled.div` diff --git a/src/components/calls-page/connect-to-ws-button.tsx b/src/components/calls-page/connect-to-ws-button.tsx index f978e50b..f1a08c85 100644 --- a/src/components/calls-page/connect-to-ws-button.tsx +++ b/src/components/calls-page/connect-to-ws-button.tsx @@ -5,7 +5,7 @@ import { useGlobalState } from "../../global-state/context-provider"; import { useWebSocket } from "../../hooks/use-websocket"; import { useWebsocketActions } from "../../hooks/use-websocket-actions"; import { useWebsocketReconnect } from "../../hooks/use-websocket-reconnect"; -import { PrimaryButton } from "../landing-page/form-elements"; +import { PrimaryButton } from "../form-elements/form-elements"; import { Spinner } from "../loader/loader"; import { ConnectToWsModal } from "./connect-to-ws-modal"; diff --git a/src/components/calls-page/connect-to-ws-modal.tsx b/src/components/calls-page/connect-to-ws-modal.tsx index d9110c8c..d0838f02 100644 --- a/src/components/calls-page/connect-to-ws-modal.tsx +++ b/src/components/calls-page/connect-to-ws-modal.tsx @@ -4,7 +4,7 @@ import { FormInput, PrimaryButton, SecondaryButton, -} from "../landing-page/form-elements"; +} from "../form-elements/form-elements"; import { Modal } from "../modal/modal"; const ButtonWrapper = styled.div` diff --git a/src/components/calls-page/header-actions.tsx b/src/components/calls-page/header-actions.tsx index e6f79e43..507e0f21 100644 --- a/src/components/calls-page/header-actions.tsx +++ b/src/components/calls-page/header-actions.tsx @@ -1,7 +1,7 @@ import styled from "@emotion/styled"; import { MicMuted, MicUnmuted } from "../../assets/icons/icon"; import { isMobile, isTablet } from "../../bowser"; -import { PrimaryButton, SecondaryButton } from "../landing-page/form-elements"; +import { PrimaryButton, SecondaryButton } from "../form-elements/form-elements"; import { ConnectToWSButton } from "./connect-to-ws-button"; import { useGlobalMuteToggle } from "./use-global-mute-toggle"; diff --git a/src/components/copy-button/copy-all-links-button.tsx b/src/components/copy-button/copy-all-links-button.tsx index 8d49c4c8..14114124 100644 --- a/src/components/copy-button/copy-all-links-button.tsx +++ b/src/components/copy-button/copy-all-links-button.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { PrimaryButton } from "../landing-page/form-elements"; +import { PrimaryButton } from "../form-elements/form-elements"; import { useCopyLinks } from "./use-copy-links"; import { TProduction } from "../production-line/types"; import { CheckIcon } from "../../assets/icons/icon"; diff --git a/src/components/create-production/create-production-buttons.tsx b/src/components/create-production/create-production-buttons.tsx index 31098e96..58e24aaf 100644 --- a/src/components/create-production/create-production-buttons.tsx +++ b/src/components/create-production/create-production-buttons.tsx @@ -1,4 +1,4 @@ -import { PrimaryButton, SecondaryButton } from "../landing-page/form-elements"; +import { PrimaryButton, SecondaryButton } from "../form-elements/form-elements"; import { Spinner } from "../loader/loader"; import { ButtonContainer, ButtonWrapper } from "./create-production-components"; diff --git a/src/components/create-production/create-production-page.tsx b/src/components/create-production/create-production-page.tsx index 06f2d1ce..b5de7539 100644 --- a/src/components/create-production/create-production-page.tsx +++ b/src/components/create-production/create-production-page.tsx @@ -6,7 +6,7 @@ import { } from "react-hook-form"; import { useEffect, useState } from "react"; import { DisplayContainerHeader } from "../landing-page/display-container-header.tsx"; -import { FormInput } from "../landing-page/form-elements.tsx"; +import { FormInput } from "../form-elements/form-elements.tsx"; import { useGlobalState } from "../../global-state/context-provider.tsx"; import { ListItemWrapper, diff --git a/src/components/delete-button/delete-button-components.ts b/src/components/delete-button/delete-button-components.ts new file mode 100644 index 00000000..5ef000e4 --- /dev/null +++ b/src/components/delete-button/delete-button-components.ts @@ -0,0 +1,25 @@ +import styled from "@emotion/styled"; +import { SecondaryButton } from "../form-elements/form-elements"; + +export const ButtonsWrapper = styled.div` + display: flex; + justify-content: flex-end; + margin: 1rem 0 1rem 0; +`; + +export const DeleteButton = styled(SecondaryButton)` + display: flex; + align-items: center; + background: #d15c5c; + color: white; + + &:disabled { + background: #ab5252; + } +`; + +export const SpinnerWrapper = styled.div` + position: relative; + width: 2rem; + height: 2rem; +`; diff --git a/src/components/display-box.tsx b/src/components/display-box.tsx index dfb2c64c..4004f5d6 100644 --- a/src/components/display-box.tsx +++ b/src/components/display-box.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { PrimaryButton } from "./landing-page/form-elements"; +import { PrimaryButton } from "./form-elements/form-elements"; const borderRadius = 0.5; diff --git a/src/components/landing-page/form-elements.tsx b/src/components/form-elements/form-elements.tsx similarity index 96% rename from src/components/landing-page/form-elements.tsx rename to src/components/form-elements/form-elements.tsx index cd02f652..7af46c7e 100644 --- a/src/components/landing-page/form-elements.tsx +++ b/src/components/form-elements/form-elements.tsx @@ -40,6 +40,10 @@ export const FormInput = styled.input` &.edit-name { margin: 0; + + &.device-label { + font-size: 1.2rem; + } } `; @@ -52,6 +56,12 @@ export const FormSelect = styled.select` border-radius: 0.5rem; background: #32383b; color: white; + + &.ingest { + display: flex; + align-items: center; + margin: 0 1rem 0 0; + } `; export const FormLabel = styled.label` diff --git a/src/components/generic-components.ts b/src/components/generic-components.ts index 6f4e943e..05c557d0 100644 --- a/src/components/generic-components.ts +++ b/src/components/generic-components.ts @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { FormContainer } from "./landing-page/form-elements"; +import { FormContainer } from "./form-elements/form-elements"; import { isMobile } from "../bowser"; // Screen size breakpoints based on width diff --git a/src/components/ingests-page/add-ingest-modal/add-ingest-form.tsx b/src/components/ingests-page/add-ingest-modal/add-ingest-form.tsx new file mode 100644 index 00000000..bc4e9cea --- /dev/null +++ b/src/components/ingests-page/add-ingest-modal/add-ingest-form.tsx @@ -0,0 +1,92 @@ +import { SubmitHandler, useForm } from "react-hook-form"; +import { useEffect, useState } from "react"; +import { useSubmitOnEnter } from "../../../hooks/use-submit-form-enter-press"; +import { ButtonWrapper } from "../../generic-components"; +import { FormInput } from "../../form-elements/form-elements"; +import { FormItem } from "../../user-settings-form/form-item"; +import { FormWrapper, SubmitButton } from "../ingest-components"; +import { useCreateIngest } from "./use-create-ingest"; +import { Spinner } from "../../loader/loader"; +import { SpinnerWrapper } from "../../delete-button/delete-button-components"; + +type FormValues = { + ingestLabel: string; + ipAddress: string; +}; + +type AddIngestFormProps = { + onSave?: () => void; +}; + +export const AddIngestForm = ({ onSave }: AddIngestFormProps) => { + const [createIngest, setCreateIngest] = useState(null); + const { + formState: { errors, isValid }, + register, + handleSubmit, + } = useForm({ + resetOptions: { + keepDirtyValues: true, // user-interacted input will be retained + keepErrors: true, // input errors will be retained with value update + }, + }); + + const { loading, success } = useCreateIngest({ createIngest }); + + useEffect(() => { + if (success) { + setCreateIngest(null); + if (onSave) onSave(); + } + }, [success, onSave]); + + const onSubmit: SubmitHandler = (data) => { + setCreateIngest(data); + }; + + useSubmitOnEnter({ + handleSubmit, + submitHandler: onSubmit, + shouldSubmitOnEnter: true, + }); + + return ( + + + + + + + + + + Add Ingest + {loading && ( + + + + )} + + + + ); +}; diff --git a/src/components/ingests-page/add-ingest-modal/ingest-form.tsx b/src/components/ingests-page/add-ingest-modal/ingest-form.tsx new file mode 100644 index 00000000..b27f1a00 --- /dev/null +++ b/src/components/ingests-page/add-ingest-modal/ingest-form.tsx @@ -0,0 +1,21 @@ +import { FC } from "react"; +import { DisplayContainerHeader } from "../../landing-page/display-container-header"; +import { ResponsiveFormContainer } from "../../generic-components"; +import { AddIngestForm } from "./add-ingest-form"; + +interface IngestFormModalProps { + className?: string; + onSave?: () => void; +} + +export const IngestFormModal: FC = (props) => { + const { className, onSave } = props; + + return ( + + Add New Ingest + + + + ); +}; diff --git a/src/components/ingests-page/add-ingest-modal/use-create-ingest.tsx b/src/components/ingests-page/add-ingest-modal/use-create-ingest.tsx new file mode 100644 index 00000000..da43ad29 --- /dev/null +++ b/src/components/ingests-page/add-ingest-modal/use-create-ingest.tsx @@ -0,0 +1,24 @@ +import { API } from "../../../api/api"; +import { useRequest } from "../../../hooks/use-request"; + +type FormValues = { + ingestLabel: string; + ipAddress: string; +}; + +export const useCreateIngest = ({ + createIngest, +}: { + createIngest: FormValues | null; +}) => { + return useRequest<{ label: string; ipAddress: string }, boolean>({ + params: createIngest + ? { + label: createIngest.ingestLabel, + ipAddress: createIngest.ipAddress, + } + : null, + apiCall: API.createIngest, + errorMessage: (i) => `Failed to create ingest: ${i.label}`, + }); +}; diff --git a/src/components/ingests-page/expanded-content.tsx b/src/components/ingests-page/expanded-content.tsx new file mode 100644 index 00000000..934e6aba --- /dev/null +++ b/src/components/ingests-page/expanded-content.tsx @@ -0,0 +1,147 @@ +import { TAudioDevice, TSavedIngest } from "../../api/api"; +import { + ButtonsWrapper, + DeleteButton, + SpinnerWrapper, +} from "../delete-button/delete-button-components"; +import { DecorativeLabel } from "../form-elements/form-elements"; +import { Spinner } from "../loader/loader"; +import { EditNameForm } from "../shared/edit-name-form"; +import { FormItem } from "../user-settings-form/form-item"; +import { ConfirmationModal } from "../verify-decision/confirmation-modal"; +import { + DeviceSection, + DeviceTable, + DeviceTableCell, + DeviceTableHeader, + DeviceTableHeaderCell, + DeviceTableRow, + NoDevices, + StatusDot, + Text, + Wrapper, +} from "./ingest-components"; + +type ExpandedContentProps = { + ingest: TSavedIngest; + displayConfirmationModal: boolean; + deleteIngestLoading: boolean; + setDisplayConfirmationModal: (displayConfirmationModal: boolean) => void; + setEditNameOpen: (editNameOpen: boolean) => void; + setRemoveIngestId: (removeIngestId: string | null) => void; + refresh: () => void; +}; + +export const ExpandedContent = ({ + ingest, + displayConfirmationModal, + deleteIngestLoading, + setDisplayConfirmationModal, + setEditNameOpen, + setRemoveIngestId, + refresh, +}: ExpandedContentProps) => { + const deviceTypes = ["deviceInput", "deviceOutput"]; + + // TODO: add disabled state when ingest is in use + const isDeleteIngestDisabled = false; + + return ( + <> + + + {ingest.ipAddress.length > 40 + ? `${ingest.ipAddress.slice(0, 40)}...` + : ingest.ipAddress} + + + + {deviceTypes.map((deviceType) => { + const devices = + deviceType === "deviceOutput" || deviceType === "deviceInput" + ? ingest[deviceType] + : []; + return ( + + + {deviceType === "deviceInput" ? "Input" : "Output"} + + + + Name + Label + Status + + {devices.length === 0 ? ( + No devices + ) : ( + devices.map((d: TAudioDevice) => ( + + + {d.name.length > 40 + ? `${d.name.slice(0, 40)}...` + : d.name} + + + ( + + {d.label && d.label.length > 40 + ? `${d.label.slice(0, 40)}...` + : d.label} + + )} + refresh={refresh} + /> + + + + + + )) + )} + + + ); + })} + + + setDisplayConfirmationModal(true)} + > + Delete Ingest + {deleteIngestLoading && ( + + + + )} + + + {displayConfirmationModal && ( + setDisplayConfirmationModal(false)} + onConfirm={() => setRemoveIngestId(ingest._id)} + /> + )} + + ); +}; diff --git a/src/components/ingests-page/ingest-components.ts b/src/components/ingests-page/ingest-components.ts new file mode 100644 index 00000000..2b60fd16 --- /dev/null +++ b/src/components/ingests-page/ingest-components.ts @@ -0,0 +1,134 @@ +import styled from "@emotion/styled"; +import { PrimaryButton } from "../form-elements/form-elements"; + +export const HeaderWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding-right: 1rem; +`; + +export const HeaderText = styled.div` + font-size: 2rem; + font-weight: bold; + margin-right: 0.5rem; + + .production-name-container { + display: inline-block; + width: 100%; + } +`; + +export const Text = styled.p` + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 1rem; + font-weight: bold; + font-size: 1.5rem; + font-weight: 300; + line-height: 3.2rem; +`; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 2rem; + margin-top: 1rem; +`; + +export const NoDevices = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #888; + font-size: 0.9rem; + text-transform: uppercase; + padding: 1rem; +`; + +export const DeviceWrapper = styled.div` + background-color: #2a2a2a; + border-radius: 0.5rem; + overflow: hidden; +`; + +export const DeviceSection = styled.div` + margin-bottom: 2rem; +`; + +export const DeviceTable = styled.div` + background-color: #2a2a2a; + border-radius: 0.5rem; + overflow: hidden; +`; + +export const DeviceTableHeader = styled.div` + display: grid; + grid-template-columns: 1fr 1fr auto auto; + background-color: #1a1a1a; + padding: 0.75rem 1rem; + border-bottom: 1px solid #404040; +`; + +export const DeviceTableHeaderCell = styled.div` + color: #888; + font-size: 0.9rem; + font-weight: 500; + text-transform: uppercase; +`; + +export const DeviceTableRow = styled.div` + display: grid; + grid-template-columns: 1fr 1fr auto auto; + padding: 0.75rem 1rem; + border-bottom: 1px solid #404040; + align-items: center; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: #333; + } +`; + +export const DeviceTableCell = styled.div` + color: white; + font-size: 0.95rem; + margin-right: 1rem; +`; + +export const FormWrapper = styled.div` + display: flex; + flex-direction: column; + width: 40rem; +`; + +export const ListWrapper = styled.div` + display: flex; + flex-wrap: wrap; + padding: 0 0 0 2rem; + align-items: flex-start; +`; + +export const SubmitButton = styled(PrimaryButton)<{ + shouldSubmitOnEnter?: boolean; +}>` + outline: ${({ shouldSubmitOnEnter }) => + shouldSubmitOnEnter ? "2px solid #007bff" : "none"}; + outline-offset: ${({ shouldSubmitOnEnter }) => + shouldSubmitOnEnter ? "2px" : "0"}; +`; + +export const StatusDot = styled.div<{ isActive: boolean }>` + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: ${({ isActive }) => (isActive ? "#22c55e" : "#ef4444")}; + margin-right: 1rem; +`; diff --git a/src/components/ingests-page/ingest-item.tsx b/src/components/ingests-page/ingest-item.tsx new file mode 100644 index 00000000..83cf2407 --- /dev/null +++ b/src/components/ingests-page/ingest-item.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import { TSavedIngest } from "../../api/api"; + +import { CollapsibleItem } from "../shared/collapsible-item"; +import { EditNameForm } from "../shared/edit-name-form"; +import { HeaderText, StatusDot, HeaderWrapper } from "./ingest-components"; +import { ExpandedContent } from "./expanded-content"; +import { useHandleHeaderClick } from "../shared/use-handle-header-click"; +import { useDeleteIngest } from "./use-delete-ingest"; + +type IngestItemProps = { + ingest: TSavedIngest; + refresh: () => void; +}; + +export const IngestItem = ({ ingest, refresh }: IngestItemProps) => { + const [editNameOpen, setEditNameOpen] = useState(false); + const [displayConfirmationModal, setDisplayConfirmationModal] = + useState(false); + const [removeIngestId, setRemoveIngestId] = useState(null); + + const { loading: deleteIngestLoading, success: successfullDeleteIngest } = + useDeleteIngest(removeIngestId); + + const handleHeaderClick = useHandleHeaderClick(editNameOpen); + + useEffect(() => { + if (successfullDeleteIngest) { + setRemoveIngestId(null); + setDisplayConfirmationModal(false); + refresh(); + } + }, [successfullDeleteIngest, refresh]); + + const headerContent = ( + + ( + + {ingest.label.length > 40 + ? `${ingest.label.slice(0, 40)}...` + : ingest.label} + + )} + refresh={refresh} + /> + + + ); + + const expandedContent = ( + + ); + + return ( + + ); +}; diff --git a/src/components/ingests-page/ingests-page.tsx b/src/components/ingests-page/ingests-page.tsx new file mode 100644 index 00000000..d6f18788 --- /dev/null +++ b/src/components/ingests-page/ingests-page.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { PageHeader } from "../page-layout/page-header"; +import { useGlobalState } from "../../global-state/context-provider"; +import { SecondaryButton } from "../form-elements/form-elements"; +import { IngestItem } from "./ingest-item"; +import { LocalError } from "../error"; +import { Modal } from "../modal/modal"; +import { IngestFormModal } from "./add-ingest-modal/ingest-form"; +import { ListWrapper } from "./ingest-components"; +// TODO: remove this, but keep for now for testing because real outputs/inputs are not yet implemented +// import { mockedIngestData, mockedError } from "./mocked-data"; +import { useListIngest } from "./use-list-ingests"; + +export const IngestsPage = ({ setApiError }: { setApiError: () => void }) => { + const [showAddIngestModal, setShowAddIngestModal] = useState(false); + const [{ apiError }] = useGlobalState(); + + const { ingests, error, setIntervalLoad, refresh } = useListIngest(); + + useEffect(() => { + if (apiError) { + setApiError(); + } + }, [apiError, setApiError]); + + useEffect(() => { + const interval = window.setInterval(() => { + setIntervalLoad(true); + }, 10000); + + return () => { + window.clearInterval(interval); + }; + }, [setIntervalLoad]); + + return ( + <> + + setShowAddIngestModal(true)} + > + Add Ingest + + + {!!ingests?.length && ( + + {error && } + {!error && + ingests && + ingests.map((i) => ( + + ))} + {/* TODO: remove this, but keep for now for testing because real outputs/inputs are not yet implemented */} + {/* {mockedError && } + {!mockedError && + mockedIngestData && + mockedIngestData.map((i) => ( + + ))} */} + + )} + {showAddIngestModal && ( + setShowAddIngestModal(false)}> + { + setShowAddIngestModal(false); + refresh(); + }} + /> + + )} + + ); +}; diff --git a/src/components/ingests-page/mocked-data.ts b/src/components/ingests-page/mocked-data.ts new file mode 100644 index 00000000..351f1aa6 --- /dev/null +++ b/src/components/ingests-page/mocked-data.ts @@ -0,0 +1,33 @@ +export const mockedIngestData = [ + { + _id: "1", + label: "Ingest 1", + ipAddress: "192.168.1.1", + deviceOutput: [ + { name: "Output 1", label: "Output 1" }, + { name: "Output 2", label: "Output 2" }, + { name: "Output 3", label: "Output 3" }, + ], + deviceInput: [ + { name: "Input 1", label: "Input 1" }, + { name: "Input 2", label: "Input 2" }, + { name: "Input 3", label: "Input 3" }, + ], + }, + { + _id: "2", + label: "Ingest 2", + ipAddress: "192.168.1.2", + deviceOutput: [ + { name: "Output 1", label: "Output 1" }, + { name: "Output 2", label: "Output 2" }, + { name: "Output 3", label: "Output 3" }, + ], + deviceInput: [ + { name: "Input 1", label: "Input 1" }, + { name: "Input 2", label: "Input 2" }, + { name: "Input 3", label: "Input 3" }, + ], + }, +]; +export const mockedError = null; diff --git a/src/components/ingests-page/use-delete-ingest.ts b/src/components/ingests-page/use-delete-ingest.ts new file mode 100644 index 00000000..95fcc88f --- /dev/null +++ b/src/components/ingests-page/use-delete-ingest.ts @@ -0,0 +1,10 @@ +import { API } from "../../api/api"; +import { useRequest } from "../../hooks/use-request"; + +export const useDeleteIngest = (id: string | null) => { + return useRequest({ + params: id, + apiCall: API.deleteIngest, + errorMessage: () => "Failed to delete ingest", + }); +}; diff --git a/src/components/ingests-page/use-edit-ingest.tsx b/src/components/ingests-page/use-edit-ingest.tsx new file mode 100644 index 00000000..f226eea4 --- /dev/null +++ b/src/components/ingests-page/use-edit-ingest.tsx @@ -0,0 +1,11 @@ +import { API, TEditIngest } from "../../api/api"; +import { useRequest } from "../../hooks/use-request"; + +export const useEditIngest = (params: TEditIngest | null) => { + return useRequest({ + params, + apiCall: API.updateIngest, + errorMessage: (i) => + `Failed to edit ingest: ${i.label} ${i.deviceOutput?.label} ${i.deviceInput?.label}`, + }); +}; diff --git a/src/components/ingests-page/use-list-ingests.tsx b/src/components/ingests-page/use-list-ingests.tsx new file mode 100644 index 00000000..db9e8760 --- /dev/null +++ b/src/components/ingests-page/use-list-ingests.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; +import { API, TSavedIngest } from "../../api/api"; + +export const useListIngest = () => { + const [ingests, setIngests] = useState([]); + const [intervalLoad, setIntervalLoad] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let aborted = false; + setError(null); + + if (intervalLoad) { + API.fetchIngestList() + .then((result) => { + if (aborted) return; + setIngests(result.ingests); + setIntervalLoad(false); + setError(null); + }) + .catch((e) => { + setError( + e instanceof Error ? e : new Error("Failed to fetch ingests") + ); + }); + } + + return () => { + aborted = true; + }; + }, [intervalLoad]); + + return { + ingests, + error, + setIntervalLoad, + refresh: () => setIntervalLoad(true), + }; +}; diff --git a/src/components/landing-page/manage-production-button.tsx b/src/components/landing-page/manage-production-button.tsx index 18861e41..497b5afa 100644 --- a/src/components/landing-page/manage-production-button.tsx +++ b/src/components/landing-page/manage-production-button.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router-dom"; import styled from "@emotion/styled"; -import { SecondaryButton } from "./form-elements"; +import { SecondaryButton } from "../form-elements/form-elements"; const ButtonWrapper = styled.div` display: flex; diff --git a/src/components/landing-page/productions-list-container.tsx b/src/components/landing-page/productions-list-container.tsx index 6f82d305..dac85fc0 100644 --- a/src/components/landing-page/productions-list-container.tsx +++ b/src/components/landing-page/productions-list-container.tsx @@ -7,7 +7,7 @@ import { useFetchProductionList } from "./use-fetch-production-list.ts"; import { ProductionsList } from "../production-list/productions-list.tsx"; import { PageHeader } from "../page-layout/page-header.tsx"; import { AddIcon, EditIcon } from "../../assets/icons/icon.tsx"; -import { PrimaryButton } from "./form-elements.tsx"; +import { PrimaryButton } from "../form-elements/form-elements.tsx"; import { isMobile } from "../../bowser.ts"; const HeaderButton = styled(PrimaryButton)` @@ -66,14 +66,22 @@ export const ProductionsListContainer = () => { navigate("/manage-productions"); }; + const goToIngests = () => { + navigate("/ingests"); + }; + return ( <> {!isMobile && ( <> + + Ingests + + {!!productions?.productions.length && ( - Manage + Productions )} diff --git a/src/components/production-line/call-header.tsx b/src/components/production-line/call-header.tsx index ab3c4855..e59cc0fa 100644 --- a/src/components/production-line/call-header.tsx +++ b/src/components/production-line/call-header.tsx @@ -5,12 +5,11 @@ import { TVIcon, } from "../../assets/icons/icon"; import { - HeaderTexts, ProductionName, ParticipantCount, - HeaderIcon, ParticipantCountWrapper, } from "../production-list/production-list-components"; +import { HeaderTexts, HeaderIcon } from "../shared/shared-components"; import { AudioFeedIcon, CallHeader } from "./production-line-components"; import { TLine, TProduction } from "./types"; diff --git a/src/components/production-line/exit-call-button.tsx b/src/components/production-line/exit-call-button.tsx index 9564d5d0..382652e9 100644 --- a/src/components/production-line/exit-call-button.tsx +++ b/src/components/production-line/exit-call-button.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import { LogoutIcon } from "../../assets/icons/icon"; -import { PrimaryButton } from "../landing-page/form-elements"; +import { PrimaryButton } from "../form-elements/form-elements"; const StyledBackBtn = styled(PrimaryButton)` margin-top: 1rem; diff --git a/src/components/production-line/long-press-to-talk-button.tsx b/src/components/production-line/long-press-to-talk-button.tsx index 0ec529c2..18e3dcec 100644 --- a/src/components/production-line/long-press-to-talk-button.tsx +++ b/src/components/production-line/long-press-to-talk-button.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import { isMobile } from "../../bowser"; -import { PrimaryButton } from "../landing-page/form-elements"; +import { PrimaryButton } from "../form-elements/form-elements"; type TLongPressToTalkButton = { isTalking: boolean; diff --git a/src/components/production-line/production-line-components.ts b/src/components/production-line/production-line-components.ts index 3262bfae..6e6daf5b 100644 --- a/src/components/production-line/production-line-components.ts +++ b/src/components/production-line/production-line-components.ts @@ -4,11 +4,11 @@ import { FlexContainer, mediaQueries, } from "../generic-components"; -import { ActionButton } from "../landing-page/form-elements"; +import { ActionButton } from "../form-elements/form-elements"; import { HeaderWrapper, - ProductionItemWrapper, -} from "../production-list/production-list-components"; + CollapsibleItemWrapper, +} from "../shared/shared-components"; import { isIpad, isMobile } from "../../bowser"; export const CallInfo = styled.div` @@ -179,7 +179,7 @@ export const CallWrapper = styled.div<{ isSomeoneSpeaking: boolean }>` } `; -export const CallContainer = styled(ProductionItemWrapper)<{ +export const CallContainer = styled(CollapsibleItemWrapper)<{ isProgramLine?: boolean; }>` margin: 0; diff --git a/src/components/production-line/production-line.tsx b/src/components/production-line/production-line.tsx index 4bc48f2e..99c9e9a1 100644 --- a/src/components/production-line/production-line.tsx +++ b/src/components/production-line/production-line.tsx @@ -12,10 +12,7 @@ import { DisplayWarning } from "../display-box.tsx"; import { FlexContainer } from "../generic-components.ts"; import { useFetchProduction } from "../landing-page/use-fetch-production.ts"; import { Spinner } from "../loader/loader.tsx"; -import { - InnerDiv, - ProductionLines, -} from "../production-list/production-list-components.ts"; +import { ExpandableSection, InnerDiv } from "../shared/shared-components.ts"; import { ConfirmationModal } from "../verify-decision/confirmation-modal.tsx"; import { CallHeaderComponent } from "./call-header.tsx"; import { CollapsableSection } from "./collapsable-section.tsx"; @@ -472,7 +469,7 @@ export const ProductionLine = ({ productionId={joinProductionOptions.productionId} /> )} - + {joinProductionOptions && !loading && ( @@ -596,7 +593,7 @@ export const ProductionLine = ({ /> )} - + )} diff --git a/src/components/production-line/select-devices.tsx b/src/components/production-line/select-devices.tsx index efda7ba7..3413b2f0 100644 --- a/src/components/production-line/select-devices.tsx +++ b/src/components/production-line/select-devices.tsx @@ -9,7 +9,7 @@ import { FormSelect, PrimaryButton, StyledWarningMessage, -} from "../landing-page/form-elements"; +} from "../form-elements/form-elements"; import { ReloadDevicesButton } from "../reload-devices-button.tsx/reload-devices-button"; import { DeviceButtonWrapper } from "./production-line-components"; import { TJoinProductionOptions, TLine } from "./types"; diff --git a/src/components/production-line/settings-modal-components.ts b/src/components/production-line/settings-modal-components.ts index 06f2ea4d..889d1acf 100644 --- a/src/components/production-line/settings-modal-components.ts +++ b/src/components/production-line/settings-modal-components.ts @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { ActionButton } from "../landing-page/form-elements"; +import { ActionButton } from "../form-elements/form-elements"; export const ModalOverlay = styled.div` position: fixed; diff --git a/src/components/production-line/settings-modal.tsx b/src/components/production-line/settings-modal.tsx index 5b736147..f1cffb8f 100644 --- a/src/components/production-line/settings-modal.tsx +++ b/src/components/production-line/settings-modal.tsx @@ -6,7 +6,7 @@ import { FormInput, FormContainer, StyledWarningMessage, -} from "../landing-page/form-elements"; +} from "../form-elements/form-elements"; import { useUpdateGlobalHotkey } from "./use-update-global-hotkey"; import { useCheckForDuplicateHotkey } from "./use-check-for-duplicate-hotkey"; import { Hotkeys } from "./types"; diff --git a/src/components/production-list/edit-name-form.tsx b/src/components/production-list/edit-name-form.tsx deleted file mode 100644 index 446f3a4e..00000000 --- a/src/components/production-list/edit-name-form.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { useEffect, useState, useRef } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; -import { FormInput, FormLabel } from "../landing-page/form-elements"; -import { EditNameWrapper, NameEditButton } from "./production-list-components"; -import { SaveIcon, EditIcon } from "../../assets/icons/icon"; -import { TBasicProductionResponse } from "../../api/api"; -import { useSubmitOnEnter } from "../../hooks/use-submit-form-enter-press"; -import { useEditLineName } from "../manage-productions-page/use-edit-line-name"; -import { useEditProductionName } from "../manage-productions-page/use-edit-production-name"; -import { useGlobalState } from "../../global-state/context-provider"; -import { Spinner } from "../loader/loader"; -import { useOutsideClickHandler } from "../../hooks/use-outside-click-handler"; -import { LabelField } from "./labelField"; - -type FormValues = { - productionName: string; - [key: `lineName-${string}`]: string; -}; - -type EditNameFormProps = { - production: TBasicProductionResponse; - formSubmitType: `lineName-${string}` | "productionName"; - managementMode: boolean; - setEditNameOpen: (editNameOpen: boolean) => void; -}; - -export const EditNameForm = ({ - production, - formSubmitType, - managementMode, - setEditNameOpen, -}: EditNameFormProps) => { - const [isEditingName, setIsEditingName] = useState(false); - const [savedProduction, setSavedProduction] = - useState(null); - const wrapperRef = useRef(null); - - const [editLineId, setEditLineId] = useState<{ - productionId: string; - lineId: string; - name: string; - } | null>(null); - const [editProductionId, setEditProductionId] = useState<{ - productionId: string; - name: string; - } | null>(null); - - const [, dispatch] = useGlobalState(); - - const { loading: editProductionLoading, success: successfullEditProduction } = - useEditProductionName(editProductionId); - - const { loading: editLineLoading, success: successfullEditLine } = - useEditLineName(editLineId); - - useOutsideClickHandler(wrapperRef, () => { - setIsEditingName(false); - setSavedProduction(null); - }); - - const lineIndex = parseInt(formSubmitType.toString().split("-")[1], 10); - const line = production.lines[lineIndex]; - - const isCurrentLine = editLineId?.lineId === line?.id; - - const { register, handleSubmit, setValue, watch } = useForm({ - resetOptions: { - keepDirtyValues: true, - keepErrors: true, - }, - }); - - const formValues = watch(); - const [productionName] = watch(["productionName"]); - - const hasLineChanges = () => { - if (!savedProduction?.lines) return false; - return savedProduction.lines.some( - (l, index) => - formValues[`lineName-${index}`] && - formValues[`lineName-${index}`] !== l.name - ); - }; - - const isUpdated = - productionName !== savedProduction?.name || hasLineChanges(); - - useEffect(() => { - if (savedProduction?.name) { - setValue(`productionName`, savedProduction.name); - } - if (savedProduction?.lines) { - savedProduction.lines.forEach((l, index) => { - setValue(`lineName-${index}`, l.name); - }); - } - }, [savedProduction, setValue]); - - useEffect(() => { - if (successfullEditLine || successfullEditProduction) { - setEditLineId(null); - setEditProductionId(null); - setEditNameOpen(false); - setIsEditingName(false); - setSavedProduction(null); - } - dispatch({ - type: "PRODUCTION_UPDATED", - }); - }, [ - successfullEditLine, - successfullEditProduction, - setEditNameOpen, - dispatch, - ]); - - const onSubmit: SubmitHandler = (data) => { - if ( - data.productionName !== "" && - data.productionName !== savedProduction?.name && - savedProduction?.name - ) { - setEditProductionId({ - productionId: savedProduction.productionId, - name: data.productionName, - }); - return; // Exit early if we're updating production name - } - - // Only update the line that matches our current label - if (formSubmitType !== "productionName" && savedProduction) { - const currentLineIndex = parseInt( - formSubmitType.toString().split("-")[1], - 10 - ); - const currentLine = savedProduction.lines[currentLineIndex]; - const newName = data[`lineName-${currentLineIndex}`]; - - if (currentLine && newName !== "" && currentLine.name !== newName) { - setEditLineId({ - productionId: savedProduction.productionId, - lineId: currentLine.id, - name: newName, - }); - } - } - }; - - useSubmitOnEnter({ - handleSubmit, - submitHandler: onSubmit, - shouldSubmitOnEnter: isUpdated, - }); - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); // Prevent click from bubbling to HeaderWrapper - if (isEditingName) { - handleSubmit(onSubmit)(); - } else { - setSavedProduction(production); - setIsEditingName(true); - } - }; - - const saveButton = - (formSubmitType === "productionName" && editProductionLoading) || - (formSubmitType !== "productionName" && - editLineLoading && - isCurrentLine) ? ( - - ) : ( - - ); - - return ( - - - {!isEditingName && ( - - )} - {isEditingName && ( - - - - )} - - {managementMode && ( - - {isEditingName ? saveButton : } - - )} - - ); -}; diff --git a/src/components/production-list/expanded-content.tsx b/src/components/production-list/expanded-content.tsx new file mode 100644 index 00000000..47512dd2 --- /dev/null +++ b/src/components/production-list/expanded-content.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { TBasicProductionResponse } from "../../api/api"; +import { AudioFeedModal } from "../audio-feed-modal/audio-feed-modal"; +import { + DeleteButton, + SpinnerWrapper, +} from "../delete-button/delete-button-components"; +import { + SecondaryButton, + StyledWarningMessage, +} from "../form-elements/form-elements"; +import { Spinner } from "../loader/loader"; +import { EditNameForm } from "../shared/edit-name-form"; +import { LabelField } from "./labelField"; +import { ManageProductionButtons } from "./manage-production-buttons"; +import { Lineblock } from "./production-list-components"; +import { useRemoveProductionLine } from "../manage-productions-page/use-remove-production-line"; +import { useGlobalState } from "../../global-state/context-provider"; +import { useInitiateProductionCall } from "../../hooks/use-initiate-production-call"; +import { ConfirmationModal } from "../verify-decision/confirmation-modal"; +import { TLine } from "../production-line/types"; + +type ExpandedContentProps = { + production: TBasicProductionResponse; + managementMode: boolean; + totalParticipants: number; +}; + +export const ExpandedContent = ({ + production, + managementMode, + totalParticipants, +}: ExpandedContentProps) => { + const [editNameOpen, setEditNameOpen] = useState(false); + const [{ userSettings }, dispatch] = useGlobalState(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalLineId, setModalLineId] = useState(null); + const [isProgramUser, setIsProgramUser] = useState(false); + + const navigate = useNavigate(); + + const [selectedLine, setSelectedLine] = useState(); + const [lineRemoveId, setLineRemoveId] = useState(""); + + const { initiateProductionCall } = useInitiateProductionCall({ + dispatch, + }); + + const { + loading: deleteLineLoading, + successfullDelete: successfullDeleteLine, + error: lineDeleteError, + } = useRemoveProductionLine(production.productionId, lineRemoveId); + + useEffect(() => { + if (successfullDeleteLine) { + dispatch({ + type: "PRODUCTION_UPDATED", + }); + } + }, [successfullDeleteLine, dispatch]); + + useEffect(() => { + if (successfullDeleteLine) { + setLineRemoveId(""); + setSelectedLine(null); + } + }, [successfullDeleteLine]); + + const getLineByLineId = (lineId: string) => { + return production.lines?.find((l) => l.id === lineId); + }; + + const goToProduction = async (lineId: string) => { + if (userSettings?.username) { + const payload = { + productionId: production.productionId, + lineId, + username: userSettings.username, + audioinput: userSettings?.audioinput, + lineUsedForProgramOutput: + getLineByLineId(lineId)?.programOutputLine || false, + isProgramUser, + }; + + const callPayload = { + joinProductionOptions: payload, + audiooutput: userSettings?.audiooutput, + }; + + const success = await initiateProductionCall({ payload: callPayload }); + + if (success) { + navigate( + `/production-calls/production/${payload.productionId}/line/${lineId}` + ); + } + } + }; + + return ( + <> + {production.lines?.map((l, index) => ( + + ( + + )} + /> + {managementMode ? ( + setSelectedLine(l)} + > + Delete + {deleteLineLoading && ( + + + + )} + + ) : ( + { + if (l.programOutputLine) { + setModalLineId(l.id); + setIsModalOpen(true); + } else { + goToProduction(l.id); + } + }} + > + Join + + )} + {isModalOpen && modalLineId && ( + setIsModalOpen(false)} + onJoin={() => { + setIsModalOpen(false); + goToProduction(modalLineId); + }} + setIsProgramUser={setIsProgramUser} + isProgramUser={isProgramUser} + /> + )} + + ))} + {lineDeleteError && ( + + {lineDeleteError.message} + + )} + {managementMode && ( + 0} + /> + )} + {selectedLine && ( + setSelectedLine(null)} + onConfirm={() => + selectedLine?.id ? setLineRemoveId(selectedLine.id) : null + } + /> + )} + + ); +}; diff --git a/src/components/production-list/manage-production-buttons.tsx b/src/components/production-list/manage-production-buttons.tsx index 54a1ba73..3cd7e41d 100644 --- a/src/components/production-list/manage-production-buttons.tsx +++ b/src/components/production-list/manage-production-buttons.tsx @@ -11,7 +11,7 @@ import { FormLabel, SecondaryButton, StyledWarningMessage, -} from "../landing-page/form-elements"; +} from "../form-elements/form-elements"; import { Spinner } from "../loader/loader"; import { useAddProductionLine } from "../manage-productions-page/use-add-production-line"; import { useDeleteProduction } from "../manage-productions-page/use-delete-production"; @@ -21,11 +21,13 @@ import { AddLineSectionForm, CheckboxWrapper, CreateLineButton, - DeleteButton, - ManageButtons, RemoveIconWrapper, - SpinnerWrapper, } from "./production-list-components"; +import { + ButtonsWrapper, + DeleteButton, + SpinnerWrapper, +} from "../delete-button/delete-button-components"; interface ManageProductionButtonsProps { production: TBasicProductionResponse; @@ -107,7 +109,7 @@ export const ManageProductionButtons: FC = ( return ( <> - + {!addLineOpen && ( = ( )} - + {addLineOpen && ( diff --git a/src/components/production-list/production-list-components.ts b/src/components/production-list/production-list-components.ts index b958d856..51b0c42d 100644 --- a/src/components/production-list/production-list-components.ts +++ b/src/components/production-list/production-list-components.ts @@ -1,33 +1,5 @@ import styled from "@emotion/styled"; -import { isMobile } from "../../bowser"; -import { PrimaryButton, SecondaryButton } from "../landing-page/form-elements"; -import { mediaQueries } from "../generic-components"; - -export 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: 34rem; - border: 1px solid #424242; - border-radius: 0.5rem; - margin: 0 2rem 2rem 0; - cursor: pointer; - - ${mediaQueries.isLargeScreen} { - flex: 0 0 calc(33.333% - 2rem); - } - - ${mediaQueries.isMediumScreen} { - flex: 0 0 calc(50% - 2rem); - } - - ${mediaQueries.isSmallScreen} { - flex: 0 0 calc(100%); - } -`; +import { PrimaryButton } from "../form-elements/form-elements"; export const ProductionName = styled.div` font-size: 1.4rem; @@ -64,55 +36,6 @@ export const ParticipantCount = styled.div` color: #9e9e9e; `; -export const HeaderWrapper = styled.div` - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - padding: 2rem; -`; - -export const HeaderTexts = styled.div` - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - margin-left: ${({ - open, - isProgramOutputLine, - }: { - open: boolean; - isProgramOutputLine: boolean; - }) => (!open && isProgramOutputLine ? "1.5rem" : "0")}; -`; - -export const HeaderIcon = styled.div` - display: flex; - align-items: center; - height: 2rem; - width: 2rem; - flex-shrink: 0; -`; - -export 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; - } -`; - -export const InnerDiv = styled.div` - overflow: hidden; - display: flex; - flex-direction: column; - position: relative; -`; - export const Lineblock = styled.div` margin-top: 1rem; background-color: ${({ isProgramOutput }: { isProgramOutput?: boolean }) => @@ -185,56 +108,15 @@ export const PersonText = styled.div` margin-left: 0.5rem; `; -export const DeleteButton = styled(SecondaryButton)` - display: flex; - align-items: center; - background: #d15c5c; - color: white; - - &:disabled { - background: #ab5252; - } -`; - export const CheckboxWrapper = styled.div` margin-bottom: 3rem; margin-top: 0.5rem; `; -export const ManageButtons = styled.div` - display: flex; - justify-content: flex-end; - margin: 1rem 0 1rem 0; -`; - -export const NameEditButton = styled.button` - background: transparent; - border: none; - padding: 0; - cursor: pointer; - font: inherit; - display: flex; - justify-content: center; - align-self: center; - margin: 1rem 0; - flex-shrink: 0; - height: 2rem; - width: 2rem; - - svg { - width: 100%; - height: 100%; - } - - &:hover svg { - transform: scale(1.2); - } -`; - export const AddLineSectionForm = styled.form` margin-top: 1rem; border: 1px grey solid; - border-radius: 5px; + border-radius: 0.5rem; padding: 1rem; position: relative; `; @@ -260,12 +142,6 @@ export const RemoveIconWrapper = styled.div` } `; -export const SpinnerWrapper = styled.div` - position: relative; - width: 2rem; - height: 2rem; -`; - export const IconWrapper = styled.div` height: 2rem; width: 2rem; @@ -280,18 +156,6 @@ export const IconWrapper = styled.div` } `; -export const EditNameWrapper = styled.div` - display: flex; - align-items: center; - justify-content: start; - max-width: 30rem; - pointer-events: none; - - > * { - pointer-events: auto; - } -`; - export const ProductionNameWrapper = styled.div` display: flex; align-items: center; diff --git a/src/components/production-list/production-list-item.tsx b/src/components/production-list/production-list-item.tsx index 2608335c..2d8126b5 100644 --- a/src/components/production-list/production-list-item.tsx +++ b/src/components/production-list/production-list-item.tsx @@ -1,37 +1,16 @@ -import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useMemo, useState } from "react"; import { TBasicProductionResponse } from "../../api/api"; -import { - ChevronDownIcon, - ChevronUpIcon, - UsersIcon, -} from "../../assets/icons/icon"; -import { useGlobalState } from "../../global-state/context-provider"; -import { AudioFeedModal } from "../audio-feed-modal/audio-feed-modal"; -import { - SecondaryButton, - StyledWarningMessage, -} from "../landing-page/form-elements"; -import { Spinner } from "../loader/loader"; -import { useRemoveProductionLine } from "../manage-productions-page/use-remove-production-line"; +import { UsersIcon } from "../../assets/icons/icon"; import { TLine } from "../production-line/types"; -import { ConfirmationModal } from "../verify-decision/confirmation-modal"; -import { ManageProductionButtons } from "./manage-production-buttons"; import { - DeleteButton, - HeaderIcon, - HeaderTexts, - HeaderWrapper, - InnerDiv, - Lineblock, ParticipantCount, ParticipantCountWrapper, - ProductionItemWrapper, - ProductionLines, - SpinnerWrapper, } from "./production-list-components"; -import { useInitiateProductionCall } from "../../hooks/use-initiate-production-call"; -import { EditNameForm } from "./edit-name-form"; +import { EditNameForm } from "../shared/edit-name-form"; +import { CollapsibleItem } from "../shared/collapsible-item"; +import { LabelField } from "./labelField"; +import { ExpandedContent } from "./expanded-content"; +import { useHandleHeaderClick } from "../shared/use-handle-header-click"; type ProductionsListItemProps = { production: TBasicProductionResponse; @@ -42,42 +21,9 @@ export const ProductionsListItem = ({ production, managementMode = false, }: ProductionsListItemProps) => { - const [{ userSettings }, dispatch] = useGlobalState(); - const [open, setOpen] = useState(false); - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalLineId, setModalLineId] = useState(null); - const [isProgramUser, setIsProgramUser] = useState(false); const [editNameOpen, setEditNameOpen] = useState(false); - const navigate = useNavigate(); - - const [selectedLine, setSelectedLine] = useState(); - const [lineRemoveId, setLineRemoveId] = useState(""); - - const { initiateProductionCall } = useInitiateProductionCall({ - dispatch, - }); - - const { - loading: deleteLineLoading, - successfullDelete: successfullDeleteLine, - error: lineDeleteError, - } = useRemoveProductionLine(production.productionId, lineRemoveId); - - useEffect(() => { - if (successfullDeleteLine) { - dispatch({ - type: "PRODUCTION_UPDATED", - }); - } - }, [successfullDeleteLine, dispatch]); - - useEffect(() => { - if (successfullDeleteLine) { - setLineRemoveId(""); - setSelectedLine(null); - } - }, [successfullDeleteLine]); + const handleHeaderClick = useHandleHeaderClick(editNameOpen); const totalParticipants = useMemo(() => { return ( @@ -87,148 +33,45 @@ export const ProductionsListItem = ({ ); }, [production]); - const getLineByLineId = (lineId: string) => { - return production.lines?.find((l) => l.id === lineId); - }; - - const goToProduction = async (lineId: string) => { - if (userSettings?.username) { - const payload = { - productionId: production.productionId, - lineId, - username: userSettings.username, - audioinput: userSettings?.audioinput, - lineUsedForProgramOutput: - getLineByLineId(lineId)?.programOutputLine || false, - isProgramUser, - }; - - const callPayload = { - joinProductionOptions: payload, - audiooutput: userSettings?.audiooutput, - }; - - const success = await initiateProductionCall({ payload: callPayload }); + const headerContent = ( + <> + ( + + )} + /> + 0 ? "active" : ""} + > + + {totalParticipants} + + + ); - if (success) { - navigate( - `/production-calls/production/${payload.productionId}/line/${lineId}` - ); - } - } - }; + const expandedContent = ( + + ); return ( - - { - // Only handle click if not clicking the edit/save button or the name-change input - if ( - !editNameOpen && - !(e.target as HTMLElement).closest(".name-edit-button") - ) { - setOpen(!open); - } - }} - > - 0 ? "active" : ""} - > - - 0 ? "active" : ""} - > - - {totalParticipants} - - - - {open ? : } - - - - - {production.lines?.map((l, index) => ( - - - {managementMode ? ( - setSelectedLine(l)} - > - Delete - {deleteLineLoading && ( - - - - )} - - ) : ( - { - if (l.programOutputLine) { - setModalLineId(l.id); - setIsModalOpen(true); - } else { - goToProduction(l.id); - } - }} - > - Join - - )} - {isModalOpen && modalLineId && ( - setIsModalOpen(false)} - onJoin={() => { - setIsModalOpen(false); - goToProduction(modalLineId); - }} - setIsProgramUser={setIsProgramUser} - isProgramUser={isProgramUser} - /> - )} - - ))} - {lineDeleteError && ( - - {lineDeleteError.message} - - )} - {managementMode && ( - 0} - /> - )} - - - {selectedLine && ( - setSelectedLine(null)} - onConfirm={() => setLineRemoveId(selectedLine.id)} - /> - )} - + 0 ? "active" : ""} + /> ); }; diff --git a/src/components/reload-devices-button.tsx/reload-devices-button.tsx b/src/components/reload-devices-button.tsx/reload-devices-button.tsx index c539868d..1b151af4 100644 --- a/src/components/reload-devices-button.tsx/reload-devices-button.tsx +++ b/src/components/reload-devices-button.tsx/reload-devices-button.tsx @@ -6,7 +6,7 @@ import { useGlobalState } from "../../global-state/context-provider"; import { useDevicePermissions } from "../../hooks/use-device-permission"; import { useFetchDevices } from "../../hooks/use-fetch-devices"; import { DisplayContainerHeader } from "../landing-page/display-container-header"; -import { PrimaryButton } from "../landing-page/form-elements"; +import { PrimaryButton } from "../form-elements/form-elements"; import { Spinner } from "../loader/loader"; import { Modal } from "../modal/modal"; diff --git a/src/components/remove-button/remove-button.tsx b/src/components/remove-button/remove-button.tsx index d6075510..324355df 100644 --- a/src/components/remove-button/remove-button.tsx +++ b/src/components/remove-button/remove-button.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import React from "react"; -import { ActionButton } from "../landing-page/form-elements"; +import { ActionButton } from "../form-elements/form-elements"; const RemoveBtn = styled(ActionButton)<{ shouldSubmitOnEnter?: boolean }>` cursor: pointer; diff --git a/src/components/share-line-link/share-line-button.tsx b/src/components/share-line-link/share-line-button.tsx index 906c4c6c..2c2c91b2 100644 --- a/src/components/share-line-link/share-line-button.tsx +++ b/src/components/share-line-link/share-line-button.tsx @@ -1,7 +1,7 @@ import styled from "@emotion/styled"; import { useState } from "react"; import { ShareIcon } from "../../assets/icons/icon"; -import { PrimaryButton } from "../landing-page/form-elements"; +import { PrimaryButton } from "../form-elements/form-elements"; import { ShareLineLinkModal } from "./share-line-link-modal"; import { useShareUrl } from "../../hooks/use-share-url"; diff --git a/src/components/share-line-link/share-line-components.ts b/src/components/share-line-link/share-line-components.ts index dbf55bd8..2eec62b3 100644 --- a/src/components/share-line-link/share-line-components.ts +++ b/src/components/share-line-link/share-line-components.ts @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { FormLabel } from "../landing-page/form-elements"; +import { FormLabel } from "../form-elements/form-elements"; export const Wrapper = styled.div` margin-top: 2rem; diff --git a/src/components/share-line-link/share-line-link-modal.tsx b/src/components/share-line-link/share-line-link-modal.tsx index b3e59eb1..2e16c54d 100644 --- a/src/components/share-line-link/share-line-link-modal.tsx +++ b/src/components/share-line-link/share-line-link-modal.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { DecorativeLabel, FormInput } from "../landing-page/form-elements"; +import { DecorativeLabel, FormInput } from "../form-elements/form-elements"; import { Modal } from "../modal/modal"; import { CopyButton } from "../copy-button/copy-button"; import { RefreshButton } from "../refresh-button/refresh-button"; diff --git a/src/components/shared/collapsible-item.tsx b/src/components/shared/collapsible-item.tsx new file mode 100644 index 00000000..083d916c --- /dev/null +++ b/src/components/shared/collapsible-item.tsx @@ -0,0 +1,54 @@ +import { useState, ReactNode } from "react"; +import { + CollapsibleItemWrapper, + HeaderWrapper, + HeaderTexts, + HeaderIcon, + ExpandableSection, + InnerDiv, +} from "./shared-components"; +import { ChevronUpIcon, ChevronDownIcon } from "../../assets/icons/icon"; + +type CollapsibleItemProps = { + headerContent: ReactNode; + expandedContent: ReactNode; + onHeaderClick?: ( + e: React.MouseEvent, + open: boolean, + setOpen: (open: boolean) => void + ) => void; + className?: string; +}; + +export const CollapsibleItem = ({ + headerContent, + expandedContent, + onHeaderClick, + className, +}: CollapsibleItemProps) => { + const [open, setOpen] = useState(false); + + const handleHeaderClick = (e: React.MouseEvent) => { + if (onHeaderClick) { + onHeaderClick(e, open, setOpen); + } else { + setOpen(!open); + } + }; + + return ( + + + + {headerContent} + + + {open ? : } + + + + {expandedContent} + + + ); +}; diff --git a/src/components/shared/edit-name-form.tsx b/src/components/shared/edit-name-form.tsx new file mode 100644 index 00000000..37806a3f --- /dev/null +++ b/src/components/shared/edit-name-form.tsx @@ -0,0 +1,347 @@ +import { useEffect, useRef, useState } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { TAudioDevice, TEditIngest } from "../../api/api"; +import { EditIcon, SaveIcon } from "../../assets/icons/icon"; +import { useGlobalState } from "../../global-state/context-provider"; +import { useOutsideClickHandler } from "../../hooks/use-outside-click-handler"; +import { useSubmitOnEnter } from "../../hooks/use-submit-form-enter-press"; +import { FormInput, FormLabel } from "../form-elements/form-elements"; +import { useEditIngest } from "../ingests-page/use-edit-ingest"; +import { Spinner } from "../loader/loader"; +import { useEditLineName } from "../manage-productions-page/use-edit-line-name"; +import { useEditProductionName } from "../manage-productions-page/use-edit-production-name"; +import { TLine } from "../production-line/types"; +import { EditNameWrapper, NameEditButton } from "./shared-components"; + +type FormValues = { + productionName: string; + [key: `lineName-${string}`]: string; + ingestLabel: string; + deviceOutputLabel: string; + deviceInputLabel: string; + currentDeviceLabel: string; +}; + +type BaseItem = { + name: string; +}; + +type ProductionItem = BaseItem & { + productionId: string; + lines?: TLine[]; +}; + +type IngestItem = { + _id: string; + label: string; + deviceOutput: TAudioDevice[]; + deviceInput: TAudioDevice[]; + currentDeviceLabel?: string; +}; + +type EditableItem = ProductionItem | IngestItem; + +type EditNameFormProps = { + item: T; + formSubmitType: + | `lineName-${string}` + | "productionName" + | "ingestLabel" + | "deviceOutputLabel" + | "deviceInputLabel" + | "currentDeviceLabel"; + managementMode: boolean; + setEditNameOpen: (editNameOpen: boolean) => void; + renderLabel: ( + item: T, + line?: TLine, + managementMode?: boolean + ) => React.ReactNode; + className?: string; + deviceType?: "input" | "output"; + refresh?: () => void; +}; + +const isProduction = (item: EditableItem): item is ProductionItem => { + return "productionId" in item; +}; + +export const EditNameForm = ({ + item, + formSubmitType, + managementMode, + setEditNameOpen, + renderLabel, + className, + deviceType, + refresh, +}: EditNameFormProps) => { + const [isEditingName, setIsEditingName] = useState(false); + const [savedItem, setSavedItem] = useState(null); + const wrapperRef = useRef(null); + + const [editLineId, setEditLineId] = useState<{ + productionId: string; + lineId: string; + name: string; + } | null>(null); + const [editProductionId, setEditProductionId] = useState<{ + productionId: string; + name: string; + } | null>(null); + const [editIngestId, setEditIngestId] = useState(null); + + const [, dispatch] = useGlobalState(); + + const { loading: editProductionLoading, success: successfullEditProduction } = + useEditProductionName(editProductionId); + + const { loading: editLineLoading, success: successfullEditLine } = + useEditLineName(editLineId); + + const { loading: editIngestLoading, success: successfullEditIngest } = + useEditIngest(editIngestId); + + useOutsideClickHandler(wrapperRef, () => { + if (isEditingName) { + setIsEditingName(false); + setSavedItem(null); + } + }); + + const lineIndex = parseInt(formSubmitType.toString().split("-")[1], 10); + const line = + isProduction(item) && item.lines ? item.lines[lineIndex] : undefined; + + const isCurrentLine = editLineId?.lineId === line?.id; + + const { register, handleSubmit, setValue, watch } = useForm({ + resetOptions: { + keepDirtyValues: true, + keepErrors: true, + }, + }); + + const formValues = watch(); + const [productionName] = watch(["productionName"]); + + const hasLineChanges = () => { + if (!savedItem || !isProduction(savedItem) || !savedItem.lines) + return false; + return savedItem.lines.some( + (l, index) => + formValues[`lineName-${index}`] && + formValues[`lineName-${index}`] !== l.name + ); + }; + + const isUpdated = + savedItem && + "name" in savedItem && + (productionName !== savedItem?.name || hasLineChanges()); + + useEffect(() => { + if (!savedItem) return; + + if ( + formSubmitType === "productionName" && + "name" in savedItem && + savedItem.name + ) { + setValue(formSubmitType, savedItem.name); + } + + if (formSubmitType === "ingestLabel" && "label" in savedItem) { + setValue(formSubmitType, savedItem.label); + } + + if ( + savedItem && + formSubmitType === "currentDeviceLabel" && + "currentDeviceLabel" in savedItem + ) { + setValue(formSubmitType, savedItem.currentDeviceLabel || ""); + } + if (savedItem && isProduction(savedItem) && savedItem.lines) { + savedItem.lines.forEach((l, index) => { + setValue(`lineName-${index}`, l.name); + }); + } + }, [savedItem, setValue, formSubmitType]); + + useEffect(() => { + if (successfullEditIngest && refresh) { + setEditIngestId(null); + setIsEditingName(false); + setSavedItem(null); + refresh(); + } + }, [successfullEditIngest, refresh]); + + useEffect(() => { + if (successfullEditLine || successfullEditProduction) { + setEditLineId(null); + setEditProductionId(null); + setEditNameOpen?.(false); + setIsEditingName(false); + setSavedItem(null); + } + dispatch({ + type: "PRODUCTION_UPDATED", + }); + }, [ + successfullEditLine, + successfullEditProduction, + setEditNameOpen, + dispatch, + ]); + + const onSubmit: SubmitHandler = (data) => { + if ( + savedItem && + "name" in savedItem && + data.productionName && + data.productionName !== "" && + data.productionName !== savedItem.name + ) { + setEditProductionId({ + productionId: savedItem.productionId, + name: data.productionName, + }); + return; + } + + if ( + formSubmitType.startsWith("lineName-") && + savedItem && + isProduction(savedItem) + ) { + const currentLineIndex = parseInt( + formSubmitType.toString().split("-")[1], + 10 + ); + const currentLine = savedItem.lines?.[currentLineIndex]; + const newName = data[`lineName-${currentLineIndex}`]; + + if (currentLine && newName !== "" && currentLine.name !== newName) { + setEditLineId({ + productionId: savedItem.productionId, + lineId: currentLine.id, + name: newName, + }); + } + } + + if ( + savedItem && + "_id" in savedItem && + "label" in savedItem && + data.ingestLabel && + data.ingestLabel !== "" && + data.ingestLabel !== savedItem.label + ) { + setEditIngestId({ + _id: savedItem._id, + label: data.ingestLabel, + }); + } + + if ( + data && + data.currentDeviceLabel !== "" && + savedItem && + "_id" in savedItem && + "currentDeviceLabel" in item + ) { + if (deviceType === "input") { + const deviceInput = savedItem.deviceInput.find( + (d) => + d.label === item.currentDeviceLabel || + d.name === item.currentDeviceLabel + ); + if (deviceInput) { + setEditIngestId({ + _id: savedItem._id, + deviceInput: { ...deviceInput, label: data.currentDeviceLabel }, + }); + } + } else if (deviceType === "output") { + const deviceOutput = savedItem.deviceOutput.find( + (d) => + d.label === item.currentDeviceLabel || + d.name === item.currentDeviceLabel + ); + if (deviceOutput) { + setEditIngestId({ + _id: savedItem._id, + deviceOutput: { ...deviceOutput, label: data.currentDeviceLabel }, + }); + } + } + } + + setSavedItem(null); + setIsEditingName(false); + }; + + useSubmitOnEnter({ + handleSubmit, + submitHandler: onSubmit, + shouldSubmitOnEnter: isUpdated ?? false, + }); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (isEditingName) { + handleSubmit(onSubmit)(); + } else { + setSavedItem(item); + setIsEditingName(true); + } + }; + + const saveButton = + (formSubmitType === "productionName" && editProductionLoading) || + ((formSubmitType === "ingestLabel" || + formSubmitType === "currentDeviceLabel") && + editIngestLoading) || + (formSubmitType !== "productionName" && + editLineLoading && + isCurrentLine) ? ( + + ) : ( + + ); + + return ( + + + {!isEditingName && renderLabel(item, line, managementMode)} + {isEditingName && ( + + + + )} + + {managementMode && ( + + {isEditingName ? saveButton : } + + )} + + ); +}; diff --git a/src/components/shared/shared-components.ts b/src/components/shared/shared-components.ts new file mode 100644 index 00000000..a066e254 --- /dev/null +++ b/src/components/shared/shared-components.ts @@ -0,0 +1,121 @@ +import styled from "@emotion/styled"; +import { mediaQueries } from "../generic-components"; +import { isMobile } from "../../bowser"; + +export const HeaderWrapper = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 2rem; +`; + +export const HeaderTexts = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + margin-left: ${({ + open, + isProgramOutputLine, + }: { + open: boolean; + isProgramOutputLine: boolean; + }) => (!open && isProgramOutputLine ? "1.5rem" : "0")}; +`; + +export const HeaderIcon = styled.div` + display: flex; + align-items: center; + height: 2rem; + width: 2rem; + flex-shrink: 0; +`; + +export const CollapsibleItemWrapper = 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: 34rem; + border: 1px solid #424242; + border-radius: 0.5rem; + margin: 0 2rem 2rem 0; + cursor: pointer; + + ${mediaQueries.isLargeScreen} { + flex: 0 0 calc(33.333% - 2rem); + } + + ${mediaQueries.isMediumScreen} { + flex: 0 0 calc(50% - 2rem); + } + + ${mediaQueries.isSmallScreen} { + flex: 0 0 calc(100%); + } +`; + +export const ExpandableSection = 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; + } +`; + +export const InnerDiv = styled.div` + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; +`; + +export const NameEditButton = styled.button` + background: transparent; + border: none; + padding: 0; + cursor: pointer; + font: inherit; + display: flex; + justify-content: center; + align-self: center; + margin: 1rem 0; + flex-shrink: 0; + height: 2rem; + width: 2rem; + + svg { + width: 100%; + height: 100%; + } + + &:hover svg { + transform: scale(1.2); + } +`; + +export const EditNameWrapper = styled.div` + display: flex; + align-items: center; + justify-content: start; + max-width: 30rem; + pointer-events: none; + + &.ingests { + background-color: #484848; + padding: 1rem 1.5rem; + border-radius: 0.5rem; + margin: 0 1rem 2rem 1rem; + } + + > * { + pointer-events: auto; + } +`; diff --git a/src/components/shared/use-handle-header-click.ts b/src/components/shared/use-handle-header-click.ts new file mode 100644 index 00000000..d0f4b3eb --- /dev/null +++ b/src/components/shared/use-handle-header-click.ts @@ -0,0 +1,17 @@ +import { useCallback } from "react"; + +export const useHandleHeaderClick = (editNameOpen: boolean) => { + const handleHeaderClick = useCallback( + (e: React.MouseEvent, open: boolean, setOpen: (open: boolean) => void) => { + if ( + !editNameOpen && + !(e.target as HTMLElement).closest(".name-edit-button") + ) { + setOpen(!open); + } + }, + [editNameOpen] + ); + + return handleHeaderClick; +}; diff --git a/src/components/user-settings-form/form-item.tsx b/src/components/user-settings-form/form-item.tsx index 49e8793d..7b73b6ac 100644 --- a/src/components/user-settings-form/form-item.tsx +++ b/src/components/user-settings-form/form-item.tsx @@ -5,7 +5,7 @@ import { DecorativeLabel, FormLabel, StyledWarningMessage, -} from "../landing-page/form-elements"; +} from "../form-elements/form-elements"; import { FormValues } from "../create-production/use-create-production"; import { TUserSettings } from "../user-settings/types"; diff --git a/src/components/user-settings-form/user-settings-form.tsx b/src/components/user-settings-form/user-settings-form.tsx index 4f883ceb..da73987c 100644 --- a/src/components/user-settings-form/user-settings-form.tsx +++ b/src/components/user-settings-form/user-settings-form.tsx @@ -11,7 +11,7 @@ import { FormSelect, PrimaryButton, StyledWarningMessage, -} from "../landing-page/form-elements"; +} from "../form-elements/form-elements"; import { CheckboxWrapper, FetchErrorMessage, diff --git a/src/components/verify-decision/confirmation-modal.tsx b/src/components/verify-decision/confirmation-modal.tsx index 9eee91ad..d1ff1704 100644 --- a/src/components/verify-decision/confirmation-modal.tsx +++ b/src/components/verify-decision/confirmation-modal.tsx @@ -7,7 +7,7 @@ import { VerifyDecision } from "./verify-decision"; interface ConfirmationModalProps { title: string; - description: string; + description: React.ReactNode; confirmationText?: string; shouldSubmitOnEnter?: boolean; onCancel: () => void; diff --git a/src/components/verify-decision/verify-decision.tsx b/src/components/verify-decision/verify-decision.tsx index ded247b3..0dc01ff3 100644 --- a/src/components/verify-decision/verify-decision.tsx +++ b/src/components/verify-decision/verify-decision.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { ActionButton } from "../landing-page/form-elements"; +import { ActionButton } from "../form-elements/form-elements"; import { Spinner } from "../loader/loader"; import { RemoveButton } from "../remove-button/remove-button";