feat: rewrite app system
Some checks reported warnings
arcad/arcast/pipeline/head This commit is unstable

This commit is contained in:
2024-04-24 17:32:01 +02:00
parent 7b8165a0ec
commit 74268493d6
63 changed files with 19722 additions and 610 deletions

0
apps/main/src/App.css Normal file
View File

36
apps/main/src/App.tsx Normal file
View File

@ -0,0 +1,36 @@
import React, { FunctionComponent } from "react";
import { createHashRouter, RouterProvider } from "react-router-dom";
import { Layout } from "./components/Layout/Layout";
import { HomePage } from "./pages/HomePage/HomePage";
import { ScreenSharingPage } from "./pages/ScreenSharingPage/ScreenSharingPage";
const router = createHashRouter([
{
path: "/",
element: <Layout />,
children: [
{
path: "",
element: <HomePage />,
},
{
path: "/screen-sharing",
element: <ScreenSharingPage />,
children: [
{
path: "sessions/:sessionId",
element: <ScreenSharingPage />,
},
],
},
],
},
]);
export const App: FunctionComponent = () => {
return <RouterProvider router={router} />;
};
export default App;

164
apps/main/src/api/arcast.ts Normal file
View File

@ -0,0 +1,164 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
const BASE_URL = process.env.REACT_APP_ARCAST_SERVER_BASE_URL ?? ""
export interface AppInfos {
defaultApp: string;
apps: App[];
}
export interface App {
id: string;
title: { [lang: string]: string };
description: { [lang: string]: string };
icon: string;
hidden: boolean;
}
export const usePlayerAppInfos = () => {
return useQuery({
queryKey: ["appsInfos"],
queryFn: getAppInfos,
select: (appInfos) => appInfos ?? { apps: [], defaultApp: "main" }
})
}
export async function getAppInfos() {
const response = await fetch(`${BASE_URL}/api/v1/apps`);
const result = await response.json();
const appInfos = result.data as AppInfos;
return appInfos;
}
export interface Status {
id: string
status: string
title: string
url: string
}
export const usePlayerStatus = () => {
return useQuery({
queryKey: ["playerStatus"],
queryFn: getStatus,
select: (status) => status ?? {}
})
}
export async function getStatus() {
const response = await fetch(`${BASE_URL}/api/v1/status`);
const result = await response.json();
const appInfos = result.data as Status;
return appInfos;
}
export interface BroadcastChannel {
opened: boolean
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void
close: () => void
}
export function getBroadcastChannel(channelId: string, onmessage?: (data: any) => void): BroadcastChannel {
let location: Location | URL = window.location
if (BASE_URL !== "") {
location = new URL(BASE_URL)
}
let scheme = "wss";
if (location.protocol === "http:") {
scheme = "ws";
}
var ws = new WebSocket(
`${scheme}://${location.host}/api/v1/broadcast/${channelId}`
);
const pending: (string | ArrayBufferLike | Blob | ArrayBufferView)[] = []
var channel = {
opened: false,
send: (message: string | ArrayBufferLike | Blob | ArrayBufferView) => {
if (channel.opened) {
ws.send(message);
} else {
pending.push(message)
}
},
close: () => {
ws.close();
},
};
ws.onmessage = (evt: MessageEvent<any>) => {
if (typeof onmessage !== "function") {
return;
}
onmessage(evt.data);
};
ws.onclose = () => {
ws.onmessage = null;
ws.onclose = null;
ws.onopen = null;
channel.opened = false;
};
ws.onopen = () => {
channel.opened = true;
while (pending.length > 0) {
const message = pending.shift()
if (message) ws.send(message)
}
};
return channel;
}
export async function cast(url: string): Promise<Status> {
const response = await fetch(`${BASE_URL}/api/v1/cast`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ url })
});
const result = await response.json();
const status = result.data as Status;
return status;
}
export function useCast() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (params: { url: string }) => cast(params.url),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['playerStatus'] })
}
})
}
export async function reset(): Promise<Status> {
const response = await fetch(`${BASE_URL}/api/v1/cast`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
});
const result = await response.json();
const status = result.data as Status;
return status;
}
export function useReset() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => reset(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['playerStatus'] })
}
})
}

275
apps/main/src/api/webrtc.ts Normal file
View File

@ -0,0 +1,275 @@
import { BroadcastChannel, cast, getBroadcastChannel } from "./arcast"
export class ScreenSharing extends EventTarget {
signaling: BroadcastChannel
handlers: { [messageType: string]: (data: { type: string, data: any }) => void }
constructor() {
super()
this.handlers = {}
this.signaling = getBroadcastChannel("arcast.screen-sharing", this.onSignal.bind(this))
}
onSignal(data: any) {
let message: any
try {
message = JSON.parse(data)
} catch (err) {
console.warn(err)
return
}
if (typeof message.type !== "string") return
const handler = this.handlers[message.type]
if (!handler) return
handler(message.data)
}
send(type: string, data: any) {
this.signaling.send(JSON.stringify({ type, data }));
}
}
export class ScreenSharingClient extends ScreenSharing {
sessionId: string
clientId: string
peerConnection: RTCPeerConnection | undefined
constructor(sessionId: string) {
super()
this.sessionId = sessionId
this.clientId = window.crypto.randomUUID()
this.handlers['server-offer'] = this.onServerOffer.bind(this)
this.handlers['ice-candidate'] = this.onIceCandidate.bind(this)
}
connect() {
this.send("client-request", { clientId: this.clientId, sessionId: this.sessionId })
}
close() {
this.signaling.close()
}
async onServerOffer(message: any) {
if (!this.checkMessage(message)) return
const conn = new RTCPeerConnection()
conn.onicecandidate = this.sendCandidate.bind(this)
conn.ontrack = this.onTrack.bind(this)
conn.onconnectionstatechange = (evt: Event) => {
const state = conn.connectionState
this.dispatchEvent(new StateChangedEvent(state))
}
await conn.setRemoteDescription(new RTCSessionDescription(message.offer))
const answer = await conn.createAnswer()
await conn.setLocalDescription(answer)
this.peerConnection = conn
this.send("client-answer", { clientId: this.clientId, sessionId: this.sessionId, answer })
}
sendCandidate(evt: RTCPeerConnectionIceEvent) {
this.send("ice-candidate", {
clientId: this.clientId,
sessionId: this.sessionId,
candidate: evt.candidate
})
}
onTrack(evt: RTCTrackEvent) {
if (!evt.streams || evt.streams.length < 1) return;
const stream = evt.streams[0]
this.dispatchEvent(new StreamChangedEvent(stream))
}
async onIceCandidate(message: any) {
if (!this.checkMessage(message)) return
if (!this.peerConnection) return
if (!message.candidate) return
await this.peerConnection.addIceCandidate(message.candidate)
}
checkMessage(message: any) {
const sessionId = message.sessionId;
if (!this.sessionId || sessionId !== this.sessionId) return false;
if (this.clientId !== message.clientId) return false;
return true
}
}
export enum ScreenSharingServerState {
Idle = "idle",
Sharing = "sharing"
}
export class ScreenSharingServer extends ScreenSharing {
stream: MediaStream | undefined
sessionId: string | undefined
peerConnections: {
[key: string]: RTCPeerConnection
}
state: ScreenSharingServerState
constructor() {
super()
this.peerConnections = {}
this.state = ScreenSharingServerState.Idle
this.handlers['client-request'] = this.onClientRequest.bind(this)
this.handlers['client-answer'] = this.onClientAnswer.bind(this)
this.handlers['ice-candidate'] = this.onIceCandidate.bind(this)
}
async shareScreen(options?: DisplayMediaStreamOptions) {
try {
this.stream = await navigator.mediaDevices.getDisplayMedia(options)
} catch (err) {
console.warn(err)
return
}
this.sessionId = window.crypto.randomUUID()
const url = `http://127.0.0.1:45555/apps/main/#screen-sharing/sessions/${this.sessionId}`
cast(url)
if (process.env.NODE_ENV === "development") {
window.open(`http://localhost:3000/apps/main/#screen-sharing/sessions/${this.sessionId}`, "_blank")
}
this.state = ScreenSharingServerState.Sharing
this.dispatchEvent(new StateChangedEvent(ScreenSharingServerState.Sharing))
}
onClientRequest(message: any) {
if (!this.checkMessage(message)) return
this.addNewClient(message.clientId)
}
async onIceCandidate(message: any) {
if (!this.checkMessage(message)) return
const conn = this.peerConnections[message.clientId]
if (!conn) return
if (!message.candidate) return
await conn.addIceCandidate(message.candidate)
}
async onClientAnswer(message: any) {
if (!this.checkMessage(message)) return
const conn = this.peerConnections[message.clientId]
if (!conn) return
await conn.setRemoteDescription(new RTCSessionDescription(message.answer));
}
checkMessage(message: any) {
const sessionId = message.sessionId;
if (!this.sessionId || sessionId !== this.sessionId) return false;
const clientId = message.clientId;
if (!clientId) return false;
return true
}
async addNewClient(clientId: string) {
if (!this.stream) return
const conn = new RTCPeerConnection();
const tracks = this.stream?.getVideoTracks()
if (!tracks || tracks.length === 0) {
return
}
conn.addTrack(tracks[0], this.stream)
const offer = await conn.createOffer()
await conn.setLocalDescription(offer);
conn.onicecandidate = this.sendCandidate.bind(this, clientId)
conn.onconnectionstatechange = (evt: Event) => {
const isClosed = conn.connectionState === "disconnected" ||
conn.connectionState === "failed"
if (!isClosed) return;
conn.onicecandidate = null;
conn.onconnectionstatechange = null;
conn.close();
console.log("Removing client", clientId);
delete this.peerConnections[clientId];
}
console.log("Adding client", clientId);
this.peerConnections[clientId] = conn;
this.send("server-offer", { sessionId: this.sessionId, clientId, offer })
}
sendCandidate(clientId: string, evt: RTCPeerConnectionIceEvent) {
this.send("ice-candidate", {
clientId,
sessionId: this.sessionId,
candidate: evt.candidate
})
}
close() {
this.signaling.close()
Object.keys(this.peerConnections).forEach(clientId => {
const conn = this.peerConnections[clientId];
conn.onicecandidate = null;
conn.onconnectionstatechange = null;
conn.close();
delete this.peerConnections[clientId]
})
const tracks = this.stream?.getTracks()
if (!tracks) return
tracks.forEach(track => track.stop())
this.state = ScreenSharingServerState.Idle
this.dispatchEvent(new StateChangedEvent(ScreenSharingServerState.Idle))
}
}
export const StreamChangedEventType = "stream-changed"
export class StreamChangedEvent extends Event {
stream: MediaStream
constructor(stream: MediaStream) {
super(StreamChangedEventType)
this.stream = stream
}
}
export const StateChangedEventType = "state-changed"
export class StateChangedEvent extends Event {
state: string
constructor(state: string) {
super(StateChangedEventType)
this.state = state
}
}

View File

@ -0,0 +1,20 @@
.root {
display: inline-block;
background-color: hsl(208, 46%, 52%);
cursor: pointer;
padding: 10px 15px;
border-radius: 5px;
color: hsl(208, 46%, 15%);;
font-weight: bolder;
font-size: 1.2em;
}
.root:hover {
background-color: hsl(208, 60%, 52%);
color: hsl(208, 60%, 15%);;;
}
.root > a {
text-decoration: none;
color: inherit;
}

View File

@ -0,0 +1,16 @@
import { FunctionComponent, PropsWithChildren } from "react";
import styles from "./Button.module.css";
export interface ButtonProps extends PropsWithChildren {
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined;
}
export const Button: FunctionComponent<ButtonProps> = ({
children,
onClick,
}) => {
return (
<div className={styles.root} onClick={onClick}>
{children}
</div>
);
};

View File

@ -0,0 +1,17 @@
.root {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 90%;
}
@media only screen and (max-width: 768px) {
.container {
width: 90%;
height: 90%;
}
}

View File

@ -0,0 +1,15 @@
import { FunctionComponent, PropsWithChildren } from "react";
import styles from "./Layout.module.css";
import { Outlet } from "react-router-dom";
export interface LayoutProps extends PropsWithChildren {}
export const Layout: FunctionComponent<LayoutProps> = ({ children }) => {
return (
<div className={styles.root}>
<div className={styles.container}>
<Outlet />
</div>
</div>
);
};

View File

@ -0,0 +1,10 @@
.root {
display: block;
background-color: #fff;
border-radius: 15px;
box-shadow: 10px 10px 10px #33333361;
position: relative;
padding: 30px 30px 30px 30px;
min-width: 33%;
color: #333;
}

View File

@ -0,0 +1,7 @@
import { FunctionComponent, PropsWithChildren } from "react";
import styles from "./Panel.module.css";
export interface PanelProps extends PropsWithChildren {}
export const Panel: FunctionComponent<PanelProps> = ({ children }) => {
return <div className={styles.root}>{children}</div>;
};

View File

@ -0,0 +1,3 @@
.root {
}

View File

@ -0,0 +1,7 @@
import { FunctionComponent, PropsWithChildren } from "react";
import styles from "./Tabs.module.css";
export interface TabsProps extends PropsWithChildren {}
export const Tabs: FunctionComponent<TabsProps> = ({ children }) => {
return <div className={styles.root}>Tabs</div>;
};

105
apps/main/src/index.css Normal file
View File

@ -0,0 +1,105 @@
html {
box-sizing: border-box;
font-size: 16px;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
width: 100%;
height: 100%;
background: rgb(76, 96, 188);
background: linear-gradient(
415deg,
rgba(4, 168, 243, 1),
rgb(76, 136, 188, 1),
rgba(76, 96, 188, 1),
rgb(115, 76, 188, 1),
rgb(87, 76, 188, 1)
);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
#root {
height: 100%;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
ol,
ul {
list-style: none;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.panel {
display: block;
background-color: #fff;
border-radius: 5px;
padding: 10px 20px;
box-shadow: 2px 2px #3333331d;
}
.panel p, .panel ul {
margin-top: 10px;
}
.text-centered {
text-align: center;
}
.text-italic {
font-style: italic;
}
.text-small {
font-size: 0.8em;
}
.fullwidth {
width: 100%;
}

18
apps/main/src/index.tsx Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,77 @@
.root {
height: 100%;
}
.title {
margin-top: 10px;
margin-bottom: 20px;
}
.columns {
display: flex;
flex-direction: row;
}
.statusBlock {
margin-top: 10px;
}
@media only screen and (max-width: 768px) {
.columns {
flex-direction: column;
}
}
.columns > * {
flex-grow: 1;
height: 100%;
overflow: auto;
}
.separator {
margin: 20px 0 10px 0;
}
.castField {
width: 100%;
display: flex;
flex-direction: row;
height: 40px;
overflow: auto;
}
.castField > input {
flex-grow: 1;
height: 100%;
border-right: 0;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
font-size: 100%;
padding-left: 10px;
}
.castField > button {
flex-shrink: 1;
height: 100%;
padding: 0 10px;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
border-right: 0;
border-left: 1px solid #ccc;
border-radius: 0;
font-weight: bold;
cursor: pointer;
}
.castField > button:hover {
background-color: #96b4ff;
}
.castField > button:last-child {
border-right: 1px solid #ccc;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}

View File

@ -0,0 +1,90 @@
import { ChangeEvent, FunctionComponent, useCallback, useState } from "react";
import styles from "./HomePage.module.css";
import { Panel } from "../../components/Panel/Panel";
import { useCast, usePlayerStatus, useReset } from "../../api/arcast";
import { Link } from "react-router-dom";
import { Button } from "../../components/Button/Button";
export const HomePage: FunctionComponent = () => {
const playerStatusQuery = usePlayerStatus();
const [castUrl, setCastUrl] = useState<string>("");
const castMutation = useCast();
const resetMutation = useReset();
const onCastUrlChange = useCallback(
(evt: ChangeEvent<HTMLInputElement>) => {
setCastUrl(evt.target.value);
},
[setCastUrl]
);
const onCastClick = useCallback(() => {
if (!castUrl) return;
castMutation.mutate({ url: castUrl });
setCastUrl("");
}, [castUrl]);
const onResetClick = useCallback(() => {
resetMutation.mutate();
}, []);
return (
<div className={styles.root}>
<Panel>
<h1 className={styles.title}>
<abbr title="Arcast Player Manager">A.P.M.</abbr>
</h1>
<div className={styles.columns}>
<div>
<h2 className={styles.title}>Status</h2>
<p className={styles.statusBlock}>
<b>Instance ID: </b>
<br />
<code>{playerStatusQuery.data?.id}</code>
</p>
<p className={styles.statusBlock}>
<b>Current status: </b>
<br />
<code>{playerStatusQuery.data?.status}</code>
</p>
<p className={styles.statusBlock}>
<b>Current page title: </b>
<br />
<span>"{playerStatusQuery.data?.title}"</span>
</p>
<p className={styles.statusBlock}>
<b>Current page URL: </b>
<br />
<a target="_blank" href={playerStatusQuery.data?.url ?? "#"}>
{playerStatusQuery.data?.url
? playerStatusQuery.data?.url.slice(0, 32) + "..."
: "--"}
</a>
</p>
</div>
<div>
<h2 className={styles.title}>Control</h2>
<div className={styles.controlBlock}>
<div className={styles.castField}>
<input
type="url"
placeholder="http://example.net"
value={castUrl}
onChange={onCastUrlChange}
/>
<button onClick={onCastClick}>Cast</button>
<button onClick={onResetClick}>Reset</button>
</div>
</div>
</div>
</div>
<hr className={styles.separator} />
<h2 className={styles.title}>Tools</h2>
<Button>
<Link to="/screen-sharing">Screen sharing</Link>
</Button>
</Panel>
</div>
);
};

View File

@ -0,0 +1,8 @@
.root {
}
.panelTitle {
margin-bottom: 20px;
margin-top: 10px;
}

View File

@ -0,0 +1,74 @@
import { FunctionComponent, useCallback, useEffect, useState } from "react";
import styles from "./ScreenSharingPage.module.css";
import { Panel } from "../../components/Panel/Panel";
import { Link, useParams } from "react-router-dom";
import { ScreenViewer } from "./ScreenViewer";
import { Button } from "../../components/Button/Button";
import {
ScreenSharingServer,
ScreenSharingServerState,
StateChangedEvent,
StateChangedEventType,
} from "../../api/webrtc";
export const ScreenSharingPage: FunctionComponent = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const [server, setServer] = useState<ScreenSharingServer | null>(null);
const [state, setState] = useState<string | null>(null);
const onServerStateChanged = useCallback((evt: Event) => {
setState((evt as StateChangedEvent).state);
}, []);
console.log("Server state", state);
const onShareScreenClick = useCallback(() => {
const server = new ScreenSharingServer();
server.addEventListener(StateChangedEventType, onServerStateChanged);
server.shareScreen({
video: true,
audio: false,
});
setServer(server);
return () => {
server.removeEventListener(StateChangedEventType, onServerStateChanged);
server.close();
setServer(null);
};
}, [onServerStateChanged]);
const onStopSharingClick = useCallback(() => {
if (!server) return;
server.close();
setServer(null);
setState(null);
}, [server]);
useEffect(() => {
if (!server) return;
return () => {
if (!server) return;
console.log("Closing screen sharing server");
server.close();
setServer(null);
};
}, [server]);
if (sessionId !== undefined) {
return <ScreenViewer sessionId={sessionId} />;
}
return (
<div className={styles.root}>
<Panel>
<Link to="/">Back to homepage</Link>
<h1 className={styles.panelTitle}>Screen sharing</h1>
{state === ScreenSharingServerState.Sharing ? (
<Button onClick={onStopSharingClick}>Stop sharing</Button>
) : (
<Button onClick={onShareScreenClick}>Share screen</Button>
)}
</Panel>
</div>
);
};

View File

@ -0,0 +1,21 @@
.root {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
.video {
object-fit: cover;
width: 100%;
height: auto;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}

View File

@ -0,0 +1,113 @@
import {
FunctionComponent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import styles from "./ScreenViewer.module.css";
import {
ScreenSharingClient,
StateChangedEvent,
StateChangedEventType,
StreamChangedEvent,
StreamChangedEventType,
} from "../../api/webrtc";
export interface ScreenViewerProps {
sessionId: string;
}
export const ScreenViewer: FunctionComponent<ScreenViewerProps> = ({
sessionId,
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [client, setClient] = useState<ScreenSharingClient | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [state, setState] = useState<string | null>(null);
const onStreamChanged = useCallback((evt: Event) => {
setStream((evt as StreamChangedEvent).stream);
}, []);
const onStateChanged = useCallback((evt: Event) => {
setState((evt as StateChangedEvent).state);
}, []);
useEffect(() => {
const client = new ScreenSharingClient(sessionId);
client.addEventListener(StreamChangedEventType, onStreamChanged);
client.addEventListener(StateChangedEventType, onStateChanged);
client.connect();
setClient(client);
return () => {
client.removeEventListener(StreamChangedEventType, onStreamChanged);
client.removeEventListener(StateChangedEventType, onStateChanged);
client.close();
setClient(null);
};
}, [sessionId, onStreamChanged, onStateChanged]);
useEffect(() => {
if (!videoRef.current) return;
const video = videoRef.current;
console.log("Changing video stream", video, stream);
video.autoplay = true;
video.playsInline = true;
video.srcObject = stream;
video.muted = true;
}, [videoRef, stream]);
const isVideoReady = state === "connected" && stream !== null;
console.log("isVideoReady", isVideoReady);
return (
<div className={styles.root}>
<h1
style={{
display: `${isVideoReady ? "none" : "block"}`,
}}
>
<ConnectionState state={state} />
</h1>
<video
ref={videoRef}
className={styles.video}
style={{
display: `${isVideoReady ? "block" : "none"}`,
}}
></video>
</div>
);
};
export interface ConnectionStateProps {
state: string | null;
}
export const ConnectionState: FunctionComponent<ConnectionStateProps> = ({
state,
}) => {
switch (state) {
case null:
return <span>Connecting...</span>;
case "Connecté":
return <span>Connection established !</span>;
case "disconnected":
return <span>Connection lost !</span>;
case "failed":
return <span>Connection failed !</span>;
default:
return (
<span>
État inconnu (<code>{state}</code>)
</span>
);
}
};

1
apps/main/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';