diff --git a/src/App.tsx b/src/App.tsx index cf0ebf87..7a78ee63 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 { IOBridgePage } from "./components/io-bridge-page/io-bridge-page.tsx"; const DisplayBoxPositioningContainer = styled(FlexContainer)` justify-content: center; @@ -152,6 +153,13 @@ const AppContent = ({ } errorElement={} /> + setApiError(true)} /> + } + errorElement={} + /> } diff --git a/src/api/api.ts b/src/api/api.ts index 532b1f90..ccdc23b9 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,11 +1,18 @@ import { handleFetchRequest } from "./handle-fetch-request.ts"; const API_VERSION = import.meta.env.VITE_BACKEND_API_VERSION ?? "api/v1/"; -const API_URL = - `${import.meta.env.VITE_BACKEND_URL.replace(/\/+$/, "")}/${API_VERSION}` || - `${window.location.origin}/${API_VERSION}`; +const API_URL = import.meta.env.VITE_BACKEND_URL + ? `${import.meta.env.VITE_BACKEND_URL.replace(/\/+$/, "")}/${API_VERSION}` + : `${window.location.origin}/${API_VERSION}`; const API_KEY = import.meta.env.VITE_BACKEND_API_KEY; +// Helper to get backend base URL for constructing WHIP/WHEP URLs +export const getBackendBaseUrl = (): string => { + return import.meta.env.VITE_BACKEND_URL + ? import.meta.env.VITE_BACKEND_URL.replace(/\/+$/, "") + : window.location.origin; +}; + type TCreateProductionOptions = { name: string; lines: { name: string; programOutputLine?: boolean }[]; @@ -33,6 +40,74 @@ export type TBasicProductionResponse = { lines: TLine[]; }; +export type TSavedTransmitter = { + _id: string; + label?: string; + port: number; + productionId: number; + lineId: number; + whipUrl: string; + passThroughUrl?: string; + mode: "caller" | "listener"; + srtUrl?: string; + status: "idle" | "running" | "stopped" | "failed"; + createdAt?: string; + updatedAt?: string; +}; + +export type TSavedReceiver = { + _id: string; + label?: string; + productionId: number; + lineId: number; + whepUrl: string; + srtUrl: string; + status: "idle" | "running" | "stopped" | "failed"; + createdAt?: string; + updatedAt?: string; +}; + +export enum TSrtMode { + CALLER = "caller", + LISTENER = "listener", +} + +export enum TBridgeState { + IDLE = "idle", + RUNNING = "running", + STOPPED = "stopped", + FAILED = "failed", +} + +export type TEditTransmitter = { + id: string; + state: TBridgeState; +}; + +export type TEditReceiver = { + id: string; + state: TBridgeState; +}; + +export type TPatchTransmitter = { + id: string; + label?: string; + productionId?: number; + lineId?: number; +}; + +export type TPatchReceiver = { + id: string; + label?: string; + productionId?: number; + lineId?: number; +}; + +export type TBridgeConfig = { + whipGatewayEnabled: boolean; + whepGatewayEnabled: boolean; +}; + export type TListProductionsResponse = { productions: TBasicProductionResponse[]; offset: 0; @@ -292,4 +367,166 @@ export const API = { }) ); }, + + // Bridge configuration + fetchBridgeConfig: (): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/config`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + + // Transmitter (Bridge TX) endpoints - via intercom-manager + createTransmitter: async (data: { + label?: string; + port: number; + productionId: number; + lineId: number; + whipUrl: string; + passThroughUrl?: string; + mode: "caller" | "listener"; + srtUrl?: string; + }) => + handleFetchRequest( + fetch(`${API_URL}bridge/tx`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify(data), + }) + ), + fetchTransmitterList: (): Promise => + handleFetchRequest<{ transmitters: TSavedTransmitter[] }>( + fetch(`${API_URL}bridge/tx`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ).then((res) => res?.transmitters || []), + fetchTransmitter: (id: string): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${id}`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + updateTransmitterState: async (data: TEditTransmitter) => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${data.id}/state`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + desired: data.state, + }), + }) + ), + updateTransmitter: async (data: TPatchTransmitter) => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${data.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + label: data.label, + productionId: data.productionId, + lineId: data.lineId, + }), + }) + ), + deleteTransmitter: async (id: string): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${id}`, { + method: "DELETE", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + + // Receiver (Bridge RX) endpoints - via intercom-manager + createReceiver: async (data: { + label?: string; + productionId: number; + lineId: number; + whepUrl: string; + srtUrl: string; + }) => + handleFetchRequest( + fetch(`${API_URL}bridge/rx`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify(data), + }) + ), + fetchReceiverList: (): Promise => + handleFetchRequest<{ receivers: TSavedReceiver[] }>( + fetch(`${API_URL}bridge/rx`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ).then((res) => res?.receivers || []), + fetchReceiver: (id: string): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/rx/${id}`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + updateReceiverState: async (data: TEditReceiver) => + handleFetchRequest( + fetch(`${API_URL}bridge/rx/${data.id}/state`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + desired: data.state, + }), + }) + ), + updateReceiver: async (data: TPatchReceiver) => + handleFetchRequest( + fetch(`${API_URL}bridge/rx/${data.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + label: data.label, + productionId: data.productionId, + lineId: data.lineId, + }), + }) + ), + deleteReceiver: async (id: string): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/rx/${id}`, { + method: "DELETE", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), }; diff --git a/src/assets/icons/icon.tsx b/src/assets/icons/icon.tsx index 26aeeec1..2fc1383d 100644 --- a/src/assets/icons/icon.tsx +++ b/src/assets/icons/icon.tsx @@ -25,6 +25,8 @@ import VolumeOff from "./volume_off.svg?react"; import VolumeOn from "./volume_on.svg?react"; import Warning from "./warning.svg?react"; import WhipSvg from "./whip_user.svg?react"; +import Stop from "./stop.svg?react"; +import Play from "./play.svg?react"; export const MicMuted = () => ; @@ -79,3 +81,7 @@ export const SaveIcon = () => ; export const HelpIcon = () => ; export const WarningIcon = () => ; + +export const StopIcon = () => ; + +export const PlayIcon = () => ; diff --git a/src/assets/icons/play.svg b/src/assets/icons/play.svg new file mode 100644 index 00000000..e7b4cb00 --- /dev/null +++ b/src/assets/icons/play.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/stop.svg b/src/assets/icons/stop.svg new file mode 100644 index 00000000..cf7e5027 --- /dev/null +++ b/src/assets/icons/stop.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/delete-button/delete-button-components.ts b/src/components/delete-button/delete-button-components.ts index 5ef000e4..bbd9f94f 100644 --- a/src/components/delete-button/delete-button-components.ts +++ b/src/components/delete-button/delete-button-components.ts @@ -5,6 +5,7 @@ export const ButtonsWrapper = styled.div` display: flex; justify-content: flex-end; margin: 1rem 0 1rem 0; + gap: 1rem; `; export const DeleteButton = styled(SecondaryButton)` diff --git a/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx b/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx new file mode 100644 index 00000000..89e7dc2c --- /dev/null +++ b/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx @@ -0,0 +1,215 @@ +import { SubmitHandler, useForm, useWatch } from "react-hook-form"; +import { useEffect, useMemo, useState } from "react"; +import { + BoldHeader, + BoldText, + FieldHeader, + FormWrapper, + SubmitButton, +} from "../io-bridge-components"; +import { FormItem } from "../../user-settings-form/form-item"; +import { + DecorativeLabel, + FormInput, + FormSelect, +} from "../../form-elements/form-elements"; +import { useFetchProductionList } from "../../landing-page/use-fetch-production-list"; +import { TLine } from "../../production-line/types"; +import { ButtonWrapper } from "../../generic-components"; +import { useCreateReceiver } from "../../../hooks/use-create-receiver"; +import { useSubmitOnEnter } from "../../../hooks/use-submit-form-enter-press"; +import { SpinnerWrapper } from "../../delete-button/delete-button-components"; +import { Spinner } from "../../loader/loader"; + +type FormValues = { + label?: string; + productionId: number; + lineId: number; + whepUsername: string; + srtUrl: string; +}; + +type AddReceiverFormProps = { + onSave?: () => void; +}; + +export const AddReceiverForm = ({ onSave }: AddReceiverFormProps) => { + const [createReceiver, setCreateReceiver] = useState(null); + const { + formState: { errors }, + register, + handleSubmit, + control, + setValue, + } = useForm({ + resetOptions: { keepDirtyValues: true, keepErrors: true }, + }); + + const { productions } = useFetchProductionList({ extended: "true" }); + const { loading, success } = useCreateReceiver({ createReceiver }); + + useEffect(() => { + if (success) { + setCreateReceiver(null); + if (onSave) onSave(); + } + }, [success, onSave]); + + const onSubmit: SubmitHandler = (data) => { + setCreateReceiver(data); + }; + + useSubmitOnEnter({ + handleSubmit, + submitHandler: onSubmit, + shouldSubmitOnEnter: true, + }); + + const selectedProductionId = useWatch({ control, name: "productionId" }); + + const selectedProduction = useMemo(() => { + const list = productions?.productions ?? []; + return list.find( + (p) => String(p.productionId) === String(selectedProductionId) + ); + }, [productions, selectedProductionId]); + + const lines = selectedProduction?.lines; + + useEffect(() => { + const list = productions?.productions ?? []; + if (!list.length) return; + if (!selectedProductionId) { + setValue("productionId", Number(list[0].productionId), { + shouldValidate: true, + shouldDirty: false, + }); + } + }, [productions, selectedProductionId, setValue]); + + useEffect(() => { + if (lines && lines[0]) { + setValue("lineId", Number(lines[0].id), { + shouldValidate: true, + shouldDirty: false, + }); + } + }, [lines, setValue]); + + return ( + <> + Add WHEP to SRT receiver + + + + Label(Optional) + + + + + + Production + {productions?.productions?.length ? ( + Number(v), + })} + > + {productions.productions.map((p) => ( + + ))} + + ) : ( + No productions available + )} + + + + Line + Number(v), + })} + disabled={!selectedProductionId || !lines} + > + {!lines ? ( + + ) : ( + lines.map((line: TLine) => ( + + )) + )} + + + + + WHEP Username + + + + + SRT Output URL + +
+ Default SRT mode is caller.

+ SRT Listener URL example: srt://0.0.0.0:10000?mode=listener +
+
+ + + + Add Receiver + {loading && ( + + + + )} + + +
+ + ); +}; diff --git a/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx b/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx new file mode 100644 index 00000000..02761f64 --- /dev/null +++ b/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx @@ -0,0 +1,296 @@ +import { SubmitHandler, useForm, useWatch } from "react-hook-form"; +import { useEffect, useMemo, useState } from "react"; +import { + BoldHeader, + BoldText, + Collapsible, + FieldHeader, + FormWrapper, + SubmitButton, +} from "../io-bridge-components"; +import { FormItem } from "../../user-settings-form/form-item"; +import { + DecorativeLabel, + FormInput, + FormSelect, +} from "../../form-elements/form-elements"; +import { useFetchProductionList } from "../../landing-page/use-fetch-production-list"; +import { TLine } from "../../production-line/types"; +import { ButtonWrapper } from "../../generic-components"; +import { useCreateTransmitter } from "../../../hooks/use-create-transmitter"; +import { useSubmitOnEnter } from "../../../hooks/use-submit-form-enter-press"; +import { SpinnerWrapper } from "../../delete-button/delete-button-components"; +import { Spinner } from "../../loader/loader"; + +type FormValues = { + label?: string; + port: number; + mode: "caller" | "listener"; + srtUrl?: string; + productionId: number; + lineId: number; + passThroughUrl?: string; + whipUsername: string; + status: "idle" | "running" | "stopped" | "failed"; +}; + +type AddTransmitterFormProps = { + onSave?: () => void; +}; + +export const AddTransmitterForm = ({ onSave }: AddTransmitterFormProps) => { + const srtModeOptions = ["caller", "listener"]; + const [createTransmitter, setCreateTransmitter] = useState( + null + ); + const { + formState: { errors }, + register, + handleSubmit, + control, + setValue, + clearErrors, + } = useForm({ + resetOptions: { keepDirtyValues: true, keepErrors: true }, + defaultValues: { mode: "listener" }, + }); + + const { productions } = useFetchProductionList({ extended: "true" }); + const { loading, success } = useCreateTransmitter({ createTransmitter }); + const mode = useWatch({ control, name: "mode" }); + + useEffect(() => { + if (mode === "caller") { + setValue("port", 0); + clearErrors("port"); + clearErrors("srtUrl"); + } else { + setValue("srtUrl", ""); + clearErrors("srtUrl"); + clearErrors("port"); + } + }, [mode, setValue, clearErrors]); + + useEffect(() => { + if (success) { + setCreateTransmitter(null); + if (onSave) onSave(); + } + }, [success, onSave]); + + const onSubmit: SubmitHandler = (data) => { + setCreateTransmitter(data); + }; + + useSubmitOnEnter({ + handleSubmit, + submitHandler: onSubmit, + shouldSubmitOnEnter: true, + }); + + const selectedProductionId = useWatch({ control, name: "productionId" }); + + const selectedProduction = useMemo(() => { + const list = productions?.productions ?? []; + return list.find( + (p) => String(p.productionId) === String(selectedProductionId) + ); + }, [productions, selectedProductionId]); + + const lines = selectedProduction?.lines; + + useEffect(() => { + const list = productions?.productions ?? []; + if (!list.length) return; + if (!selectedProductionId) { + setValue("productionId", Number(list[0].productionId), { + shouldValidate: true, + shouldDirty: false, + }); + } + }, [productions, selectedProductionId, setValue]); + + useEffect(() => { + if (lines && lines[0]) { + setValue("lineId", Number(lines[0].id), { + shouldValidate: true, + shouldDirty: false, + }); + } + }, [lines, setValue]); + + return ( + <> + Add SRT to WHIP transmitter + + + + Label(Optional) + + + + + + Production + {productions?.productions?.length ? ( + Number(v), + })} + > + {productions.productions.map((p) => ( + + ))} + + ) : ( + No productions available + )} + + + + Line + Number(v), + })} + disabled={!selectedProductionId || !lines} + > + {!lines ? ( + + ) : ( + lines.map((line: TLine) => ( + + )) + )} + + + + + WHIP Username + + + + + SRT Mode + + {srtModeOptions.map((opt) => ( + + ))} + + + + +
+ + SRT Port (Local) + + mode !== "listener" || + (v && v > 0) || + "SRT Port is required in Listener mode", + valueAsNumber: true, + })} + placeholder="SRT Port" + disabled={mode !== "listener"} + tabIndex={mode === "listener" ? 0 : -1} + /> + +
+
+ + +
+ + SRT Destination URL + + mode !== "caller" || + (v && v.length > 0) || + "SRT URL is required in Caller mode (e.g., srt://host:port)", + setValueAs: (v) => { + // Add srt:// prefix if user didn't specify it + if (v && !v.startsWith("srt://")) { + return `srt://${v}`; + } + return v; + }, + })} + placeholder="srt://host:port" + disabled={mode !== "caller"} + tabIndex={mode === "caller" ? 0 : -1} + /> + +
+
+ + + + SRT Restream URL (Optional) + + + + + + + Add Transmitter + {loading && ( + + + + )} + + +
+ + ); +}; diff --git a/src/components/io-bridge-page/io-bridge-components.tsx b/src/components/io-bridge-page/io-bridge-components.tsx new file mode 100644 index 00000000..1a9fdbea --- /dev/null +++ b/src/components/io-bridge-page/io-bridge-components.tsx @@ -0,0 +1,158 @@ +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; + gap: 2rem; + margin-top: 1rem; +`; + +export const ListWrapper = styled.div` + display: flex; + flex-wrap: wrap; + padding: 0; + align-items: flex-start; +`; + +export const FormWrapper = styled.div` + display: flex; + flex-direction: column; + width: 40rem; +`; + +export const StatusIndicator = styled.div<{ bgColor: string }>` + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: ${({ bgColor }) => bgColor}; + margin-right: 1rem; +`; + +export const SubmitButton = styled(PrimaryButton)<{ + shouldSubmitOnEnter?: boolean; +}>` + outline: ${({ shouldSubmitOnEnter }) => + shouldSubmitOnEnter ? "2px solid #007bff" : "none"}; + outline-offset: ${({ shouldSubmitOnEnter }) => + shouldSubmitOnEnter ? "2px" : "0"}; +`; + +export const StateChangeButton = styled(PrimaryButton)<{ bgColor: string }>` + color: white; + background-color: ${({ bgColor }) => bgColor}; +`; + +export const StatusWrapper = styled.div` + display: flex; + flex-direction: row; + gap: 1rem; + align-items: center; + justify-content: space-between; +`; + +export const TextWrapper = styled.div` + display: flex; + flex-direction: column; +`; + +export const ContentWrapper = styled.div` + display: flex; + flex-direction: row; + margin-bottom: 1rem; + gap: 0.5rem; +`; + +export const ButtonContentWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; +`; + +export const IconWrapper = styled.div` + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + svg { + height: 3rem; + width: 3rem; + fill: white; + } +`; + +export const BoldHeader = styled.p` + font-weight: bold; + font-size: 2rem; + margin-bottom: 2rem; +`; + +export const BoldText = styled.p` + font-weight: bold; + margin-bottom: 0.5rem; +`; + +export const Collapsible = styled.div<{ open: boolean }>` + display: grid; + grid-template-rows: ${(p) => (p.open ? "1fr" : "0fr")}; + transition: + grid-template-rows 220ms ease, + opacity 220ms ease, + margin-top 220ms ease; + opacity: ${(p) => (p.open ? 1 : 0)}; + margin-top: ${(p) => (p.open ? "16px" : "0")}; + > div { + overflow: hidden; + } +`; + +export const FieldHeader = styled.div` + display: flex; + flex-direction: row; + gap: 1.5rem; +`; + +export const SectionBox = styled.div` + border: 1px solid #424242; + border-radius: 0.5rem; + padding: 2rem; + margin-bottom: 2rem; + background-color: transparent; +`; + +export const SectionHeader = styled.h2` + font-size: 1.5rem; + font-weight: bold; + margin: 0 0 1.5rem 0; + color: #fff; +`; diff --git a/src/components/io-bridge-page/io-bridge-page.tsx b/src/components/io-bridge-page/io-bridge-page.tsx new file mode 100644 index 00000000..cea4dc1d --- /dev/null +++ b/src/components/io-bridge-page/io-bridge-page.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; +import { useGlobalState } from "../../global-state/context-provider"; +import { useListTransmitters } from "../../hooks/use-transmitter-list"; +import { useListReceivers } from "../../hooks/use-receiver-list"; +import { useBridgeConfig } from "../../hooks/use-bridge-config"; +import { PageHeader } from "../page-layout/page-header"; +import { PrimaryButton } from "../form-elements/form-elements"; +import { LocalError } from "../error"; +import { ListWrapper, SectionBox, SectionHeader } from "./io-bridge-components"; +import { TransmitterItem } from "./transmitter-item"; +import { ReceiverItem } from "./receiver-item"; +import { AddTransmitterForm } from "./add-transmitter-modal/add-transmitter-form"; +import { AddReceiverForm } from "./add-receiver-modal/add-receiver-form"; +import { Modal } from "../modal/modal"; + +export const IOBridgePage = ({ setApiError }: { setApiError: () => void }) => { + const [showAddTransmitterModal, setShowAddTransmitterModal] = useState(false); + const [showAddReceiverModal, setShowAddReceiverModal] = useState(false); + const [{ apiError }] = useGlobalState(); + + const { config, loading: configLoading } = useBridgeConfig(); + + // Only fetch transmitters if WHIP gateway is enabled + const { transmitters, error, setIntervalLoad, refresh } = useListTransmitters( + config?.whipGatewayEnabled ?? false + ); + + // Only fetch receivers if WHEP gateway is enabled + const { + receivers, + error: receiverError, + setIntervalLoad: setReceiverIntervalLoad, + refresh: refreshReceivers, + } = useListReceivers(config?.whepGatewayEnabled ?? false); + + const list = Array.isArray(transmitters) ? transmitters : []; + const receiverList = Array.isArray(receivers) ? receivers : []; + + useEffect(() => { + if (apiError) setApiError(); + }, [apiError, setApiError]); + + // Only poll transmitters if WHIP gateway is enabled + useEffect(() => { + if (!config?.whipGatewayEnabled) return undefined; + const interval = window.setInterval( + () => setIntervalLoad((prev) => prev + 1), + 1000 + ); + return () => window.clearInterval(interval); + }, [setIntervalLoad, config?.whipGatewayEnabled]); + + // Only poll receivers if WHEP gateway is enabled + useEffect(() => { + if (!config?.whepGatewayEnabled) return undefined; + const interval = window.setInterval( + () => setReceiverIntervalLoad((prev) => prev + 1), + 1000 + ); + return () => window.clearInterval(interval); + }, [setReceiverIntervalLoad, config?.whepGatewayEnabled]); + + // If no gateways are configured, don't show the page + if (configLoading) { + return null; // or a loading spinner + } + + if (!config || (!config.whipGatewayEnabled && !config.whepGatewayEnabled)) { + return null; + } + + return ( + <> + +
+ {config.whipGatewayEnabled && ( + setShowAddTransmitterModal(true)} + > + Add SRT to WHIP transmitter + + )} + {config.whepGatewayEnabled && ( + setShowAddReceiverModal(true)} + > + Add WHEP to SRT receiver + + )} +
+
+ + {error && } + {receiverError && } + +
+ {config.whipGatewayEnabled && ( + + SRT to WHIP Transmitters + + {list.map((t) => ( + + ))} + {list.length === 0 &&

No transmitters configured

} +
+
+ )} + + {config.whepGatewayEnabled && ( + + WHEP to SRT Receivers + + {receiverList.map((r) => ( + + ))} + {receiverList.length === 0 &&

No receivers configured

} +
+
+ )} +
+ + {showAddTransmitterModal && ( + setShowAddTransmitterModal(false)}> + { + setShowAddTransmitterModal(false); + await refresh(); + }} + /> + + )} + + {showAddReceiverModal && ( + setShowAddReceiverModal(false)}> + { + setShowAddReceiverModal(false); + await refreshReceivers(); + }} + /> + + )} + + ); +}; diff --git a/src/components/io-bridge-page/receiver-expanded-content.tsx b/src/components/io-bridge-page/receiver-expanded-content.tsx new file mode 100644 index 00000000..da284ccc --- /dev/null +++ b/src/components/io-bridge-page/receiver-expanded-content.tsx @@ -0,0 +1,440 @@ +import { useMemo, useState, useEffect } from "react"; +import { TSavedReceiver, TBridgeState } from "../../api/api"; +import { + ButtonsWrapper, + DeleteButton, + SpinnerWrapper, +} from "../delete-button/delete-button-components"; +import { Spinner } from "../loader/loader"; +import { ConfirmationModal } from "../verify-decision/confirmation-modal"; +import { useFetchProduction } from "../landing-page/use-fetch-production"; +import { useFetchProductionList } from "../landing-page/use-fetch-production-list"; +import { + BoldText, + ButtonContentWrapper, + ContentWrapper, + IconWrapper, + StateChangeButton, + TextWrapper, +} from "./io-bridge-components"; +import { + StopIcon, + PlayIcon, + ChevronDownIcon, + ChevronUpIcon, + EditIcon, + SaveIcon, +} from "../../assets/icons/icon"; +import { useToggleReceiver } from "../../hooks/use-edit-receiver"; +import { useUpdateReceiver } from "../../hooks/use-update-receiver"; +import { FormInput, FormSelect } from "../form-elements/form-elements"; +import { TLine } from "../production-line/types"; + +type ReceiverExpandedContentProps = { + receiver: TSavedReceiver; + displayConfirmationModal: boolean; + deleteReceiverLoading: boolean; + setDisplayConfirmationModal: (displayConfirmationModal: boolean) => void; + setRemoveReceiverId: (receiverId: string | null) => void; + refresh?: () => void; +}; + +export const ReceiverExpandedContent = ({ + receiver, + displayConfirmationModal, + deleteReceiverLoading, + setDisplayConfirmationModal, + setRemoveReceiverId, + refresh, +}: ReceiverExpandedContentProps) => { + const [showAllParameters, setShowAllParameters] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [editLabel, setEditLabel] = useState(receiver.label || ""); + const [editProductionId, setEditProductionId] = useState( + receiver.productionId + ); + const [editLineId, setEditLineId] = useState(receiver.lineId); + + const isDeleteReceiverDisabled = receiver.status === "running"; + + const { toggle, loading: editReceiverLoading } = useToggleReceiver( + // eslint-disable-next-line no-underscore-dangle + receiver._id + ); + const { updateReceiver, loading: updateLoading } = useUpdateReceiver(); + const { productions } = useFetchProductionList({ extended: "true" }); + + const productionIdNum = receiver.productionId || null; + const lineIdStr = receiver.lineId?.toString() || ""; + + const { production } = useFetchProduction(productionIdNum); + + const line = useMemo( + () => + production && lineIdStr + ? production.lines.find((l) => l.id === lineIdStr) + : undefined, + [production, lineIdStr] + ); + + const selectedProduction = useMemo(() => { + const list = productions?.productions ?? []; + return list.find( + (p) => String(p.productionId) === String(editProductionId) + ); + }, [productions, editProductionId]); + + const availableLines = selectedProduction?.lines || []; + + useEffect(() => { + // Only update edit values when not in edit mode to prevent polling from overwriting user changes + if (!isEditMode) { + setEditLabel(receiver.label || ""); + setEditProductionId(receiver.productionId); + setEditLineId(receiver.lineId); + } + }, [receiver, isEditMode]); + + const handleSave = async () => { + // eslint-disable-next-line no-underscore-dangle + const updated = await updateReceiver({ + id: receiver._id, + label: editLabel, + productionId: editProductionId, + lineId: editLineId, + }); + + if (updated) { + setIsEditMode(false); + if (refresh) { + refresh(); + } + } + }; + + const handleCancel = () => { + setEditLabel(receiver.label || ""); + setEditProductionId(receiver.productionId); + setEditLineId(receiver.lineId); + setIsEditMode(false); + }; + + const renderStateChangeButtonColor = (status: TBridgeState) => { + switch (status) { + case "running": + return "#f96c6c"; + case "failed": + return "#ebca6a"; + case "stopped": + return "#22c55e"; + case "idle": + return "#22c55e"; + default: + return "#59cbe8"; + } + }; + + const renderButtonContent = (status: TBridgeState) => { + switch (status) { + case "running": + return ( + + + + +

Stop

+
+ ); + default: + return ( + + + + +

Run

+
+ ); + } + }; + + return ( + <> + + +
+ {isEditMode ? ( +
+
+ Label: + setEditLabel(e.target.value)} + placeholder="Label" + style={{ marginTop: "4px", width: "100%" }} + /> +
+
+ Production: + { + const newProdId = Number(e.target.value); + setEditProductionId(newProdId); + const newProd = productions?.productions?.find( + (p) => String(p.productionId) === e.target.value + ); + if (newProd?.lines?.[0]) { + setEditLineId(Number(newProd.lines[0].id)); + } + }} + style={{ marginTop: "4px", width: "100%" }} + > + {productions?.productions?.map((p) => ( + + ))} + +
+
+ Line: + setEditLineId(Number(e.target.value))} + style={{ marginTop: "4px", width: "100%" }} + > + {availableLines.map((line: TLine) => ( + + ))} + +
+
+ + +
+
+ ) : ( +
+
+ + Label: {receiver.label || "N/A"} + + + Production: {production?.name} + + + Line: {line?.name} + +
+ +
+ )} +
+
+ +
+ {showAllParameters && ( +
+
+ ID: {receiver._id} +
+
+ Label:{" "} + {receiver.label || "N/A"} +
+
+ Production ID:{" "} + {receiver.productionId} +
+
+ Line ID:{" "} + {receiver.lineId} +
+
+ WHEP URL:{" "} + + {receiver.whepUrl} + +
+
+ SRT URL:{" "} + + {receiver.srtUrl} + +
+
+ Status:{" "} + {receiver.status} +
+
+ Created:{" "} + {receiver.createdAt + ? new Date(receiver.createdAt).toLocaleString() + : "N/A"} +
+
+ Updated:{" "} + {receiver.updatedAt + ? new Date(receiver.updatedAt).toLocaleString() + : "N/A"} +
+
+ )} +
+
+ + toggle(receiver.status as TBridgeState)} + disabled={editReceiverLoading} + type="button" + bgColor={renderStateChangeButtonColor( + receiver.status as TBridgeState + )} + > + {renderButtonContent(receiver.status as TBridgeState)} + + setDisplayConfirmationModal(true)} + > + Delete Receiver + {deleteReceiverLoading && ( + + + + )} + + + {displayConfirmationModal && ( + setDisplayConfirmationModal(false)} + // eslint-disable-next-line no-underscore-dangle + onConfirm={() => setRemoveReceiverId(receiver._id)} + /> + )} + + ); +}; diff --git a/src/components/io-bridge-page/receiver-item.tsx b/src/components/io-bridge-page/receiver-item.tsx new file mode 100644 index 00000000..75c15d7e --- /dev/null +++ b/src/components/io-bridge-page/receiver-item.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { TSavedReceiver, TBridgeState } from "../../api/api"; +import { CollapsibleItem } from "../shared/collapsible-item"; +import { ReceiverExpandedContent } from "./receiver-expanded-content"; +import { useDeleteReceiver } from "../../hooks/use-delete-receiver"; +import { + BoldText, + StatusIndicator, + StatusWrapper, +} from "./io-bridge-components"; + +type ReceiverItemProps = { + receiver: TSavedReceiver; + refresh: () => void; +}; + +export const ReceiverItem = ({ receiver, refresh }: ReceiverItemProps) => { + const [displayConfirmationModal, setDisplayConfirmationModal] = + useState(false); + const [removeReceiverId, setRemoveReceiverId] = useState(null); + + const { loading: deleteReceiverLoading, success: successfulDeleteReceiver } = + useDeleteReceiver(removeReceiverId); + + useEffect(() => { + if (successfulDeleteReceiver) { + setRemoveReceiverId(null); + setDisplayConfirmationModal(false); + refresh(); + } + }, [successfulDeleteReceiver, refresh]); + + const renderStatusIndicator = (status: TBridgeState) => { + switch (status) { + case "running": + return "#22c55e"; + case "idle": + return "#22c55e"; + case "stopped": + return "#ebca6a"; + case "failed": + return "#f96c6c"; + default: + return "#59cbe8"; + } + }; + + const headerContent = ( + <> + {receiver.label ?? "Receiver"} + + Status: {receiver.status as TBridgeState} + + + + ); + + const expandedContent = ( + + ); + + return ( + + ); +}; diff --git a/src/components/io-bridge-page/transmitter-expanded-content.tsx b/src/components/io-bridge-page/transmitter-expanded-content.tsx new file mode 100644 index 00000000..70b9c591 --- /dev/null +++ b/src/components/io-bridge-page/transmitter-expanded-content.tsx @@ -0,0 +1,451 @@ +import { useMemo, useState, useEffect } from "react"; +import { TSavedTransmitter, TBridgeState } from "../../api/api"; +import { + ButtonsWrapper, + DeleteButton, + SpinnerWrapper, +} from "../delete-button/delete-button-components"; +import { Spinner } from "../loader/loader"; +import { ConfirmationModal } from "../verify-decision/confirmation-modal"; +import { useFetchProduction } from "../landing-page/use-fetch-production"; +import { useFetchProductionList } from "../landing-page/use-fetch-production-list"; +import { + BoldText, + ButtonContentWrapper, + ContentWrapper, + IconWrapper, + StateChangeButton, + TextWrapper, +} from "./io-bridge-components"; +import { + StopIcon, + PlayIcon, + ChevronDownIcon, + ChevronUpIcon, + EditIcon, + SaveIcon, +} from "../../assets/icons/icon"; +import { useToggleTransmitter } from "../../hooks/use-edit-transmitter"; +import { useUpdateTransmitter } from "../../hooks/use-update-transmitter"; +import { FormInput, FormSelect } from "../form-elements/form-elements"; +import { TLine } from "../production-line/types"; + +type ExpandedContentProps = { + transmitter: TSavedTransmitter; + displayConfirmationModal: boolean; + deleteTransmitterLoading: boolean; + setDisplayConfirmationModal: (displayConfirmationModal: boolean) => void; + setRemoveTransmitterId: (transmitterId: string | null) => void; + refresh?: () => void; +}; + +export const ExpandedContent = ({ + transmitter, + displayConfirmationModal, + deleteTransmitterLoading, + setDisplayConfirmationModal, + setRemoveTransmitterId, + refresh, +}: ExpandedContentProps) => { + const [showAllParameters, setShowAllParameters] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [editLabel, setEditLabel] = useState(transmitter.label || ""); + const [editProductionId, setEditProductionId] = useState( + transmitter.productionId + ); + const [editLineId, setEditLineId] = useState(transmitter.lineId); + + const isDeleteTransmitterDisabled = transmitter.status === "running"; + + const { toggle, loading: editTransmitterLoading } = useToggleTransmitter( + transmitter._id + ); + const { updateTransmitter, loading: updateLoading } = useUpdateTransmitter(); + const { productions } = useFetchProductionList({ extended: "true" }); + + const productionIdNum = transmitter.productionId || null; + const lineIdStr = transmitter.lineId?.toString() || ""; + + const { production } = useFetchProduction(productionIdNum); + + const line = useMemo( + () => + production && lineIdStr + ? production.lines.find((l) => l.id === lineIdStr) + : undefined, + [production, lineIdStr] + ); + + const selectedProduction = useMemo(() => { + const list = productions?.productions ?? []; + return list.find( + (p) => String(p.productionId) === String(editProductionId) + ); + }, [productions, editProductionId]); + + const availableLines = selectedProduction?.lines || []; + + useEffect(() => { + // Only update edit values when not in edit mode to prevent polling from overwriting user changes + if (!isEditMode) { + setEditLabel(transmitter.label || ""); + setEditProductionId(transmitter.productionId); + setEditLineId(transmitter.lineId); + } + }, [transmitter, isEditMode]); + + const handleSave = async () => { + const updated = await updateTransmitter({ + id: transmitter._id, + label: editLabel, + productionId: editProductionId, + lineId: editLineId, + }); + + if (updated) { + setIsEditMode(false); + if (refresh) { + refresh(); + } + } + }; + + const handleCancel = () => { + setEditLabel(transmitter.label || ""); + setEditProductionId(transmitter.productionId); + setEditLineId(transmitter.lineId); + setIsEditMode(false); + }; + + const renderStateChangeButtonColor = (status: TBridgeState) => { + switch (status) { + case "running": + return "#f96c6c"; + case "failed": + return "#ebca6a"; + case "stopped": + return "#22c55e"; + case "idle": + return "#22c55e"; + default: + return "#59cbe8"; + } + }; + + const renderButtonContent = (status: TBridgeState) => { + switch (status) { + case "running": + return ( + + + + +

Stop

+
+ ); + default: + return ( + + + + +

Run

+
+ ); + } + }; + + return ( + <> + + +
+ {isEditMode ? ( +
+
+ Label: + setEditLabel(e.target.value)} + placeholder="Label" + style={{ marginTop: "4px", width: "100%" }} + /> +
+
+ Production: + { + const newProdId = Number(e.target.value); + setEditProductionId(newProdId); + const newProd = productions?.productions?.find( + (p) => String(p.productionId) === e.target.value + ); + if (newProd?.lines?.[0]) { + setEditLineId(Number(newProd.lines[0].id)); + } + }} + style={{ marginTop: "4px", width: "100%" }} + > + {productions?.productions?.map((p) => ( + + ))} + +
+
+ Line: + setEditLineId(Number(e.target.value))} + style={{ marginTop: "4px", width: "100%" }} + > + {availableLines.map((line: TLine) => ( + + ))} + +
+
+ + +
+
+ ) : ( +
+
+ + Label: {transmitter.label || "N/A"} + + + Production: {production?.name} + + + Line: {line?.name} + +
+ +
+ )} +
+
+ +
+ {showAllParameters && ( +
+
+ ID:{" "} + {transmitter._id} +
+
+ Label:{" "} + {transmitter.label || "N/A"} +
+
+ Port:{" "} + {transmitter.port} +
+
+ Production ID:{" "} + {transmitter.productionId} +
+
+ Line ID:{" "} + {transmitter.lineId} +
+
+ WHIP URL:{" "} + + {transmitter.whipUrl} + +
+
+ SRT URL:{" "} + + {transmitter.srtUrl || "N/A"} + +
+
+ Pass-through URL:{" "} + + {transmitter.passThroughUrl || "N/A"} + +
+
+ Mode:{" "} + {transmitter.mode} +
+
+ Status:{" "} + {transmitter.status} +
+
+ Created:{" "} + {transmitter.createdAt + ? new Date(transmitter.createdAt).toLocaleString() + : "N/A"} +
+
+ Updated:{" "} + {transmitter.updatedAt + ? new Date(transmitter.updatedAt).toLocaleString() + : "N/A"} +
+
+ )} +
+
+ + + {renderButtonContent(transmitter.status as TBridgeState)} + + setDisplayConfirmationModal(true)} + > + Delete Transmitter + {deleteTransmitterLoading && ( + + + + )} + + + {displayConfirmationModal && ( + setDisplayConfirmationModal(false)} + onConfirm={() => setRemoveTransmitterId(transmitter._id)} + /> + )} + + ); +}; diff --git a/src/components/io-bridge-page/transmitter-item.tsx b/src/components/io-bridge-page/transmitter-item.tsx new file mode 100644 index 00000000..de28238a --- /dev/null +++ b/src/components/io-bridge-page/transmitter-item.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { TSavedTransmitter, TBridgeState } from "../../api/api"; +import { CollapsibleItem } from "../shared/collapsible-item"; +import { ExpandedContent } from "./transmitter-expanded-content"; +import { useDeleteTransmitter } from "../../hooks/use-delete-transmitter"; +import { + BoldText, + StatusIndicator, + StatusWrapper, +} from "./io-bridge-components"; + +type TransmitterItemProps = { + transmitter: TSavedTransmitter; + refresh: () => void; +}; + +export const TransmitterItem = ({ + transmitter, + refresh, +}: TransmitterItemProps) => { + const [displayConfirmationModal, setDisplayConfirmationModal] = + useState(false); + const [removeTransmitterId, setRemoveTransmitterId] = useState( + null + ); + + const { + loading: deleteTransmitterLoading, + success: successfulDeleteTransmitter, + } = useDeleteTransmitter(removeTransmitterId); + + useEffect(() => { + if (successfulDeleteTransmitter) { + setRemoveTransmitterId(null); + setDisplayConfirmationModal(false); + refresh(); + } + }, [successfulDeleteTransmitter, refresh]); + + const renderStatusIndicator = (status: TBridgeState) => { + switch (status) { + case "running": + return "#22c55e"; + case "idle": + return "#22c55e"; + case "stopped": + return "#ebca6a"; + case "failed": + return "#f96c6c"; + default: + return "#59cbe8"; + } + }; + + const headerContent = ( + <> + {transmitter.label ?? "Label"} + + Status: {transmitter.status as TBridgeState} + + + + ); + + const expandedContent = ( + + ); + + return ( + + ); +}; diff --git a/src/components/landing-page/productions-list-container.tsx b/src/components/landing-page/productions-list-container.tsx index 8716fb2f..a5f683a2 100644 --- a/src/components/landing-page/productions-list-container.tsx +++ b/src/components/landing-page/productions-list-container.tsx @@ -9,6 +9,7 @@ import { PageHeader } from "../page-layout/page-header.tsx"; import { AddIcon, EditIcon } from "../../assets/icons/icon.tsx"; import { PrimaryButton } from "../form-elements/form-elements"; import { isMobile } from "../../bowser.ts"; +import { useBridgeConfig } from "../../hooks/use-bridge-config.tsx"; const HeaderButton = styled(PrimaryButton)` margin-left: 1rem; @@ -43,6 +44,8 @@ export const ProductionsListContainer = () => { extended: "true", }); + const { config } = useBridgeConfig(); + const showRefreshing = useRefreshAnimation({ reloadProductionList, doInitialLoad, @@ -66,14 +69,28 @@ export const ProductionsListContainer = () => { navigate("/manage-productions"); }; + const goToIO = () => { + navigate("/manage-io-bridge"); + }; + + // Show I/O Bridges button only if at least one gateway is enabled + const showIOBridgeButton = + config?.whipGatewayEnabled || config?.whepGatewayEnabled; + return ( <> {!isMobile && ( <> + {showIOBridgeButton && ( + + I/O Bridges + + + )} {!!productions?.productions.length && ( - Manage + Productions )} diff --git a/src/hooks/use-bridge-config.tsx b/src/hooks/use-bridge-config.tsx new file mode 100644 index 00000000..714af498 --- /dev/null +++ b/src/hooks/use-bridge-config.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { API, TBridgeConfig } from "../api/api"; + +export const useBridgeConfig = () => { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadConfig = async () => { + try { + const data = await API.fetchBridgeConfig(); + setConfig(data); + setError(null); + } catch (err) { + console.error("Failed to fetch bridge config:", err); + setError( + err instanceof Error ? err.message : "Failed to fetch bridge config" + ); + // If config endpoint fails, assume no gateways are available + setConfig({ whipGatewayEnabled: false, whepGatewayEnabled: false }); + } finally { + setLoading(false); + } + }; + + loadConfig(); + }, []); + + return { config, loading, error }; +}; diff --git a/src/hooks/use-create-receiver.tsx b/src/hooks/use-create-receiver.tsx new file mode 100644 index 00000000..75b8f718 --- /dev/null +++ b/src/hooks/use-create-receiver.tsx @@ -0,0 +1,39 @@ +import { useRequest } from "./use-request"; +import { API, TSavedReceiver, getBackendBaseUrl } from "../api/api"; + +type FormValues = { + label?: string; + productionId: number; + lineId: number; + whepUsername: string; + srtUrl: string; +}; + +export const useCreateReceiver = ({ + createReceiver, +}: { + createReceiver: FormValues | null; +}) => { + return useRequest< + { + label?: string; + productionId: number; + lineId: number; + whepUrl: string; + srtUrl: string; + }, + TSavedReceiver + >({ + params: createReceiver + ? { + label: createReceiver.label, + productionId: createReceiver.productionId, + lineId: createReceiver.lineId, + whepUrl: `${getBackendBaseUrl()}/api/v1/whep/${createReceiver.productionId}/${createReceiver.lineId}/${encodeURIComponent(createReceiver.whepUsername)}`, + srtUrl: createReceiver.srtUrl, + } + : null, + apiCall: API.createReceiver, + errorMessage: (t) => `Failed to create receiver: ${t.label || ""}`, + }); +}; diff --git a/src/hooks/use-create-transmitter.tsx b/src/hooks/use-create-transmitter.tsx new file mode 100644 index 00000000..0106ea0a --- /dev/null +++ b/src/hooks/use-create-transmitter.tsx @@ -0,0 +1,48 @@ +import { useRequest } from "./use-request"; +import { API, TSavedTransmitter, getBackendBaseUrl } from "../api/api"; + +type FormValues = { + label?: string; + productionId: number; + lineId: number; + whipUsername: string; + port: number; + passThroughUrl?: string; + mode: "caller" | "listener"; + srtUrl?: string; +}; + +export const useCreateTransmitter = ({ + createTransmitter, +}: { + createTransmitter: FormValues | null; +}) => { + return useRequest< + { + label?: string; + port: number; + productionId: number; + lineId: number; + whipUrl: string; + mode: "caller" | "listener"; + srtUrl?: string; + passThroughUrl?: string; + }, + TSavedTransmitter + >({ + params: createTransmitter + ? { + label: createTransmitter.label, + port: createTransmitter.port, + productionId: createTransmitter.productionId, + lineId: createTransmitter.lineId, + whipUrl: `${getBackendBaseUrl()}/api/v1/whip/${createTransmitter.productionId}/${createTransmitter.lineId}/${encodeURIComponent(createTransmitter.whipUsername)}`, + passThroughUrl: createTransmitter.passThroughUrl, + mode: createTransmitter.mode, + srtUrl: createTransmitter.srtUrl, + } + : null, + apiCall: API.createTransmitter, + errorMessage: (t) => `Failed to create transmitter: ${t.label || ""}`, + }); +}; diff --git a/src/hooks/use-delete-receiver.tsx b/src/hooks/use-delete-receiver.tsx new file mode 100644 index 00000000..98cc9d69 --- /dev/null +++ b/src/hooks/use-delete-receiver.tsx @@ -0,0 +1,10 @@ +import { API } from "../api/api"; +import { useRequest } from "./use-request"; + +export const useDeleteReceiver = (receiverId: string | null) => { + return useRequest({ + params: receiverId, + apiCall: API.deleteReceiver, + errorMessage: () => "Failed to delete receiver", + }); +}; diff --git a/src/hooks/use-delete-transmitter.tsx b/src/hooks/use-delete-transmitter.tsx new file mode 100644 index 00000000..eb63c651 --- /dev/null +++ b/src/hooks/use-delete-transmitter.tsx @@ -0,0 +1,10 @@ +import { API } from "../api/api"; +import { useRequest } from "./use-request"; + +export const useDeleteTransmitter = (id: string | null) => { + return useRequest({ + params: id, + apiCall: API.deleteTransmitter, + errorMessage: () => "Failed to delete transmitter", + }); +}; diff --git a/src/hooks/use-edit-receiver.tsx b/src/hooks/use-edit-receiver.tsx new file mode 100644 index 00000000..c3365347 --- /dev/null +++ b/src/hooks/use-edit-receiver.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import { API, TBridgeState } from "../api/api"; + +export const useToggleReceiver = (receiverId: string) => { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const toggle = async (currentState: TBridgeState) => { + setLoading(true); + setError(null); + setSuccess(false); + + try { + const newState = + currentState === TBridgeState.RUNNING + ? TBridgeState.STOPPED + : TBridgeState.RUNNING; + + await API.updateReceiverState({ + id: receiverId, + state: newState, + }); + setSuccess(true); + } catch (e: unknown) { + setError(e as Error); + } finally { + setLoading(false); + } + }; + + return { toggle, loading, success, error }; +}; diff --git a/src/hooks/use-edit-transmitter.tsx b/src/hooks/use-edit-transmitter.tsx new file mode 100644 index 00000000..ac2c54bd --- /dev/null +++ b/src/hooks/use-edit-transmitter.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import { API, TEditTransmitter, TBridgeState } from "../api/api"; +import { useFetchTransmitter } from "./use-fetch-transmitter"; + +export const useToggleTransmitter = (id: string) => { + const { transmitter, refetch, setTransmitter } = useFetchTransmitter(id); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const toggle = async () => { + if (!transmitter) return; + + const next = + transmitter.status === TBridgeState.RUNNING + ? TBridgeState.STOPPED + : TBridgeState.RUNNING; + + const prev = transmitter; + setTransmitter((t) => (t ? { ...t, status: next } : t)); + + setLoading(true); + setError(null); + setSuccess(false); + + try { + const payload: TEditTransmitter = { id, state: next }; + await API.updateTransmitterState(payload); + setSuccess(true); + await refetch(); + } catch (e: unknown) { + setTransmitter(prev); + setError(e as Error); + } finally { + setLoading(false); + } + }; + + return { toggle, loading, success, error }; +}; diff --git a/src/hooks/use-fetch-transmitter.tsx b/src/hooks/use-fetch-transmitter.tsx new file mode 100644 index 00000000..e9a0c490 --- /dev/null +++ b/src/hooks/use-fetch-transmitter.tsx @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { API, TSavedTransmitter } from "../api/api"; + +type TUseFetchTransmitter = (id: string | null) => { + transmitter: TSavedTransmitter | null; + error: Error | null; + loading: boolean; + refetch: () => Promise; + setTransmitter: React.Dispatch< + React.SetStateAction + >; +}; + +export const useFetchTransmitter: TUseFetchTransmitter = (id) => { + const [transmitter, setTransmitter] = useState( + null + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const abortedRef = useRef(false); + + const doFetch = useCallback(async () => { + setLoading(true); + setError(null); + + if (!id) { + setTransmitter(null); + setLoading(false); + return; + } + + const localAbortedToken = { aborted: false }; + abortedRef.current = false; + + try { + const t = await API.fetchTransmitter(id); + if (abortedRef.current || localAbortedToken.aborted) return; + setTransmitter(t); + } catch (e: unknown) { + if (abortedRef.current || localAbortedToken.aborted) return; + setTransmitter(null); + setError(e as Error); + } finally { + if (!abortedRef.current && !localAbortedToken.aborted) { + setLoading(false); + } + } + }, [id]); + + useEffect(() => { + abortedRef.current = false; + setTransmitter(null); + doFetch(); + return () => { + abortedRef.current = true; + }; + }, [doFetch]); + + return { + error, + transmitter, + loading, + refetch: doFetch, + setTransmitter, + }; +}; diff --git a/src/hooks/use-receiver-list.tsx b/src/hooks/use-receiver-list.tsx new file mode 100644 index 00000000..28679c5c --- /dev/null +++ b/src/hooks/use-receiver-list.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { API, TSavedReceiver } from "../api/api"; + +export const useListReceivers = (enabled: boolean = true) => { + const [receivers, setReceivers] = useState([]); + const [intervalLoad, setIntervalLoad] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + // Don't fetch if disabled + if (!enabled) return undefined; + + let aborted = false; + setError(null); + + API.fetchReceiverList() + .then((result) => { + if (aborted) return; + setReceivers(result); + setError(null); + }) + .catch((e) => { + setError( + e instanceof Error ? e : new Error("Failed to fetch receivers") + ); + }); + + return () => { + aborted = true; + }; + }, [intervalLoad, enabled]); + + const refresh = async () => { + try { + const result = await API.fetchReceiverList(); + setReceivers(result); + setError(null); + } catch (e) { + setError(e instanceof Error ? e : new Error("Failed to fetch receivers")); + } + }; + + return { + receivers, + error, + setIntervalLoad, + refresh, + }; +}; diff --git a/src/hooks/use-transmitter-list.tsx b/src/hooks/use-transmitter-list.tsx new file mode 100644 index 00000000..d116f00e --- /dev/null +++ b/src/hooks/use-transmitter-list.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { API, TSavedTransmitter } from "../api/api"; + +export const useListTransmitters = (enabled: boolean = true) => { + const [transmitters, setTransmitters] = useState([]); + const [intervalLoad, setIntervalLoad] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + // Don't fetch if disabled + if (!enabled) return undefined; + + let aborted = false; + setError(null); + + API.fetchTransmitterList() + .then((result) => { + if (aborted) return; + setTransmitters(result); + setError(null); + }) + .catch((e) => { + setError( + e instanceof Error ? e : new Error("Failed to fetch transmitters") + ); + }); + + return () => { + aborted = true; + }; + }, [intervalLoad, enabled]); + + const refresh = async () => { + try { + const result = await API.fetchTransmitterList(); + setTransmitters(result); + setError(null); + } catch (e) { + setError( + e instanceof Error ? e : new Error("Failed to fetch transmitters") + ); + } + }; + + return { + transmitters, + error, + setIntervalLoad, + refresh, + }; +}; diff --git a/src/hooks/use-update-receiver.tsx b/src/hooks/use-update-receiver.tsx new file mode 100644 index 00000000..260875cc --- /dev/null +++ b/src/hooks/use-update-receiver.tsx @@ -0,0 +1,29 @@ +import { useState } from "react"; +import { API, TPatchReceiver, TSavedReceiver } from "../api/api"; + +export const useUpdateReceiver = () => { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const updateReceiver = async ( + data: TPatchReceiver + ): Promise => { + setLoading(true); + setError(null); + setSuccess(false); + + try { + const updatedReceiver = await API.updateReceiver(data); + setSuccess(true); + return updatedReceiver; + } catch (e: unknown) { + setError(e as Error); + return null; + } finally { + setLoading(false); + } + }; + + return { updateReceiver, loading, success, error }; +}; diff --git a/src/hooks/use-update-transmitter.tsx b/src/hooks/use-update-transmitter.tsx new file mode 100644 index 00000000..947927af --- /dev/null +++ b/src/hooks/use-update-transmitter.tsx @@ -0,0 +1,29 @@ +import { useState } from "react"; +import { API, TPatchTransmitter, TSavedTransmitter } from "../api/api"; + +export const useUpdateTransmitter = () => { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const updateTransmitter = async ( + data: TPatchTransmitter + ): Promise => { + setLoading(true); + setError(null); + setSuccess(false); + + try { + const updatedTransmitter = await API.updateTransmitter(data); + setSuccess(true); + return updatedTransmitter; + } catch (e: unknown) { + setError(e as Error); + return null; + } finally { + setLoading(false); + } + }; + + return { updateTransmitter, loading, success, error }; +};