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";