Add Webapp

This commit is contained in:
Clinton Moss 2026-04-03 12:35:13 +02:00
commit ed924ef2ac
76 changed files with 17054 additions and 0 deletions

41
webapp/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

45
webapp/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,45 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev -- --inspect"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug client-side (Firefox)",
"type": "firefox",
"request": "launch",
"url": "http://localhost:3000",
"reAttach": true,
"pathMappings": [
{
"url": "webpack://_N_E",
"path": "${workspaceFolder}"
}
]
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithEdge",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
}
}
]
}

25
webapp/BUILD.ps1 Normal file
View File

@ -0,0 +1,25 @@
# echo "Building"
## PSVersionTable.PSVersion
## winget install --id Microsoft.Powershell --source winget
mkdir dist
npm run build
cp -r .next/static .next/standalone/CoffeeChat/portal/.next
cp -r .next/standalone/CoffeeChat/portal dist
Compress-Archive -Path dist -DestinationPath dist.zip -Force
Remove-Item dist -Recurse -Force
# Create an SSH session
#$remoteUser = "cinton"
#$remoteHost = "rootbranch.co.za:922"
#$session = New-PSSession -HostName "rootbranch.co.za:922" -UserName "cinton" -SSHTransport
# Copy file from Windows to Linux
#Copy-Item -Path dist.zip -Destination "/home/clinton/dist.zip" -ToSession $session
# Copy file from Linux to Windows
# Copy-Item -Path "/home/username/file.txt" -Destination "C:\path\to\local\file.txt" -FromSession $session
# Close the session
#Remove-PSSession $session

42
webapp/README.md Normal file
View File

@ -0,0 +1,42 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Publish
npm config set @test:registry=https://gitea.example.com/api/packages/testuser/npm/
npm config set -- '//gitea.example.com/api/packages/testuser/npm/:_authToken' "personal_access_token"

View File

@ -0,0 +1,35 @@
"use server";
import { CallApi } from "@/helper/api/ApiConnector";
import { cookies } from "next/headers";
import { redirect, RedirectType } from "next/navigation";
export async function LoginFormAction(formData: FormData) {
console.log(`LoginFormAction`, formData);
const res = await fetch(`http://localhost:8081/api/auth/signin`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
email: formData.get("email") as string,
password: formData.get("password") as string,
}),
}).catch((e) => console.error(e));
// .then(async (res) => {
const resp = await (res as Response).json();
// console.log(resp)
if (!resp.error) {
const cj = await cookies();
cj.set("ccsession", resp.result);
redirect(`/dashboard`, RedirectType.replace);
}
}
export async function RegisterFormAction(formData: FormData) {
await CallApi(`/auth/signup`, "POST", {
name: "",
email: formData.get("email") as string,
password: formData.get("password") as string,
});
}

View File

@ -0,0 +1,7 @@
'use server'
import { CallApi } from "@/helper/api/ApiConnector"
export async function getRooms(){
return await CallApi("/rooms","GET")
}

View File

@ -0,0 +1,8 @@
"use server";
import { CallApi } from "@/helper/api/ApiConnector";
import { cookies } from "next/headers";
export async function ListUsers() {
return await CallApi(`/users`,"GET").catch(e => console.error(e))
}

19
webapp/app/call/page.tsx Normal file
View File

@ -0,0 +1,19 @@
import React from 'react'
export default function CallScreen() {
return (
<div className='w-full flex flex-col h-screen'>
<div className='flex justify-evenly'>
<button>Call</button>
<button>White board</button>
<button>Settings</button>
</div>
<div className='grow bg-blue-200 flex'>
<div className='grow'>
<video />
</div>
<div>Chat</div>
</div>
</div>
)
}

View File

@ -0,0 +1,11 @@
import { ListUsers } from '@/actions/users/UserActions'
import ChatBox from '@/components/chat/ChatBox'
import React from 'react'
export default async function LoggedIn() {
const users = await ListUsers()
return (<>
<div>{JSON.stringify(users)}</div>
<ChatBox />
</>)
}

BIN
webapp/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

138
webapp/app/globals.css Normal file
View File

@ -0,0 +1,138 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
::view-transition-group(.animate-in){
animation: animate-in ease-in 0.5s;
}
::view-transition-group(.animate-out){
animation: animate-out ease-in 0.5s;
}

65
webapp/app/layout.tsx Normal file
View File

@ -0,0 +1,65 @@
// import React from "react";
// export default function RootLayoutClient({ children }) {
// React.useEffect(() => {
// if ("serviceWorker" in navigator) {
// navigator.serviceWorker
// .register("/service-worker.js")
// .then((registration) => {
// console.log("Service Worker registered with scope:", registration.scope);
// })
// .catch((error) => {
// console.error("Service Worker registration failed:", error);
// });
// }
// }, []);
// return (
// <div className="text-white flex flex-col">
// <div className="container mx-auto px-4 max-w-[1024px]">
// {children}
// </div>
// </div>
// );
// }
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import React from "react";
import RootLayoutClient from "./layoutClient";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "CoffeeChat",
description: "Start chatting with your communities",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased h-full w-full`}
>
<RootLayoutClient>
{children}
</RootLayoutClient>
</body>
</html>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import React from "react";
// import React from "react";
// export default function RootLayoutClient({ children }) {
// React.useEffect(() => {
// if ("serviceWorker" in navigator) {
// navigator.serviceWorker
// .register("/service-worker.js")
// .then((registration) => {
// console.log("Service Worker registered with scope:", registration.scope);
// })
// .catch((error) => {
// console.error("Service Worker registration failed:", error);
// });
// }
// }, []);
// return (
// <div className="text-white flex flex-col">
// <div className="container mx-auto px-4 max-w-[1024px]">
// {children}
// </div>
// </div>
// );
// }
export default function RootLayoutClient({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
React.useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/service-worker.js")
.then((registration) => {
console.log("Service Worker registered with scope:", registration.scope);
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}
}, []);
return (children);
}

11
webapp/app/login/page.tsx Normal file
View File

@ -0,0 +1,11 @@
import { LoginForm } from "@/components/login-form"
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm />
</div>
</div>
)
}

81
webapp/app/manifest.ts Normal file
View File

@ -0,0 +1,81 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "CoffeeChat",
short_name: "CoffeeChat",
start_url: "chat.rootbranch.co.za",
display: "standalone",
description: "Start chatting with your communities",
lang: " en",
dir: "auto",
theme_color: "#59168b",
background_color: "#59168b",
orientation: "any",
icons: [
{
src: "/manifest-icon-192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/manifest-icon-192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/manifest-icon-512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/manifest-icon-512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
screenshots: [
{
src: "screen1.png",
sizes: "2880x1800",
type: "image/png",
form_factor: "wide",
label: "Communities Home Page",
},
{
src: "screen2.png",
sizes: "570x1265",
type: "image/png",
form_factor: "narrow",
label: "Communities Home Page",
},
],
related_applications: [
{
platform: "windows",
url: "https://chat.rootbranch.co.za",
},
],
prefer_related_applications: false,
shortcuts: [
{
name: "CoffeeChat",
url: "chat.rootbranch.co.za",
description: "Start chatting with your communities",
icons: [
{
src: "manifest-icon-96.maskable.png",
purpose: "any",
sizes: "96x96",
type: "image/png",
},
],
},
],
display_override: ["fullscreen"],
};
}

56
webapp/app/page.tsx Normal file
View File

@ -0,0 +1,56 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { LoginFormAction } from "@/actions/auth/AuthActions";
import { getRooms } from "@/actions/room/RoomActions";
import RoomCard from "@/components/RoomCard";
import { Handshake } from "lucide-react";
import { cookies } from "next/headers";
import Image from "next/image";
import Link from "next/link";
import { ViewTransition } from "react";
export default async function Home() {
const cj = await cookies()
const session = cj.get(`ccsession`)
const rooms = await getRooms()
return (
<div className="flex flex-col min-h-screen bg-black font-sans">
<form
className="flex flex-col lg:flex-row p-2 w-full bg-purple-900 text-white shadow-sm shadow-purple-400 z-20"
action={LoginFormAction}>
<div className="grow flex">
<Handshake size={30} className="me-2" />
<input className=" w-full focus:p-2 transition-all" placeholder="Find" />
</div>
{/* <input type="email" name="email" placeholder="Email" />
<input type="password" name="password" placeholder="Password" /> */}
{!session &&
<div className="flex">
<Link className="px-2" href={`/login`}>Login</Link>
<ViewTransition name="register-button" exit={'animate-out'}>
<Link className="px-2" href={`/register`}>Register</Link>
</ViewTransition>
</div>
}
</form>
<div className="grid grid-cols-1 lg:grid-cols-3 m-2">
{rooms.map((room: {
title: string;
room: string;
image: any;
color: string;
}, i: number) =>
<RoomCard
key={`Card-${i}`}
color={room.color}
image={room.image}
room={room.room}
title={room.title}
/>
)}
</div>
{/* {JSON.stringify(rooms)} */}
</div>
);
}

View File

@ -0,0 +1,10 @@
'use client'
import ErrorDialog from '@/components/Dialogs/ErrorDialog'
import React from 'react'
export default function error({ error }: { error: { message: string } }) {
return (
<ErrorDialog error={JSON.parse(error.message).error} />
// <div>error : {JSON.stringify(JSON.parse(error.message).error)}</div>
)
}

View File

@ -0,0 +1,57 @@
import { RegisterFormAction } from '@/actions/auth/AuthActions'
import Link from 'next/link'
import React, { ViewTransition } from 'react'
// import { ViewTransition } from 'react'
export default function Register() {
return (<ViewTransition enter={'auto'} exit={'auto'} default={'auto'}>
<div className='bg-black flex flex-col'>
<div
className='bg-white/10 h-svh max-w-[100vw] overflow-hidden backdrop-blur-lg flex flex-col border border-white/20 shadow-lg'
>
<div className='bg-purple-950 text-white flex justify-evenly p-2 font-bold'>
<div>Register Your Profile</div>
</div>
<form action={RegisterFormAction} className='m-2 flex flex-col grow'>
<div className='grow'>
<div className='text-white'>
<div className='flex'>
<label className='me-2 w-1/2'>Your name</label>
<input placeholder='Your name' type='text' name='name' className='grow' />
</div>
<small style={{ fontSize: '0.5rem' }} className='text-xs text-justify '>This will be used for your records but aliases are allowed while using chats</small>
</div>
<hr />
<div className='text-white'>
<div className='flex'>
<label className='me-2 w-1/2'>Email</label>
<input placeholder='Email' type='email' name='email' className='grow' />
</div>
<small style={{ fontSize: '0.5rem' }} className='text-xs text-justify '>This will be used to login to the system and gain access to restricted groups</small>
</div>
<hr />
<div className='text-white'>
<div className='flex'>
<label className='me-2 w-1/2'>Password</label>
<input placeholder='Password' type='password' name='password' className='grow' />
</div>
</div>
<hr />
</div>
<div className='flex w-full justify-between p-2'>
<Link href={`/`}><button type='button' className='self-end bg-purple-900 p-2 text-white'>Cancel</button></Link>
<ViewTransition name='register-button' enter={'animate-in'}>
<button className='self-end bg-purple-900 p-2 text-white'>Register</button>
</ViewTransition>
</div>
{/*
</div>
*/}
</form>
</div>
</div>
</ViewTransition>
)
}

View File

@ -0,0 +1,11 @@
import { SignupForm } from "@/components/signup-form"
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<SignupForm />
</div>
</div>
)
}

10
webapp/app/tesst/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import MediaSoupWidget from '@/components/chat/MediaSoupWidget'
import WebRTCChat from '@/components/chat/WebRTCChatUsingPeer'
import React from 'react'
export default function Tttest() {
return (
<div><WebRTCChat /></div>
// <div><MediaSoupWidget /></div>
)
}

16
webapp/app/testb/page.tsx Normal file
View File

@ -0,0 +1,16 @@
import ChatNoServerWidget from '@/components/chat/ChatNoServerWidget'
import ChatWS from '@/components/chat/ChatWS'
import MediaSoupWidget from '@/components/chat/MediaSoupWidget'
import WebRTCChatUsingPeer from '@/components/chat/WebRTCChatUsingPeer'
import WebRTCChat from '@/components/chat/WebRTCChatUsingPeer'
import WebRTComponent from '@/components/chat/WebRTComponent'
import React from 'react'
export default function Tttest() {
return (
// <div><WebRTCChat /></div>
// <div><ChatNoServerWidget /></div>
// <div><WebRTCChatUsingPeer /></div>
<div><ChatWS /></div>
)
}

33
webapp/app/testc/page.tsx Normal file
View File

@ -0,0 +1,33 @@
import Link from 'next/link'
import React from 'react'
export default function Search() {
return (
<div className='w-screen h-screen'
style={{ 'background': 'conic-gradient(from 210deg, #c6bcb9 0.000deg, #c6bcb9 24.000deg, #b9b6b9 calc(24.000deg + 0.1deg), #b9b6b9 48.000deg, #aab0b8 calc(48.000deg + 0.1deg), #aab0b8 72.000deg, #9ba9b6 calc(72.000deg + 0.1deg), #9ba9b6 96.000deg, #8ca2b4 calc(96.000deg + 0.1deg), #8ca2b4 120.000deg, #7e99b0 calc(120.000deg + 0.1deg), #7e99b0 144.000deg, #7191ac calc(144.000deg + 0.1deg), #7191ac 168.000deg, #6688a7 calc(168.000deg + 0.1deg), #6688a7 192.000deg, #5d7fa2 calc(192.000deg + 0.1deg), #5d7fa2 216.000deg, #56769c calc(216.000deg + 0.1deg), #56769c 240.000deg, #526d95 calc(240.000deg + 0.1deg), #526d95 264.000deg, #50648e calc(264.000deg + 0.1deg), #50648e 288.000deg, #515b86 calc(288.000deg + 0.1deg), #515b86 312.000deg, #55547e calc(312.000deg + 0.1deg), #55547e 336.000deg, #5c4c75 calc(336.000deg + 0.1deg) 360.000deg)' }}
>
<div className='bg-purple-900 flex p-2'>
<input placeholder='Find' className='text-2xl grow bg-white' />
</div>
<div className='grid grid-cols-3'>
<RoomCard title={'Dating'} room={"123"} />
<RoomCard title={'Business'} room={"123"} />
<RoomCard title={'Programming'} room={"123"} />
<RoomCard title={'Somthing Else'} room={"123"} />
</div>
</div>
)
}
function RoomCard({
title,
room,
}: {
title: string
room: string
}) {
return <Link href={`/testb?room=${room}`}><div>{title}</div></Link>
}

98
webapp/app/testd/page.tsx Normal file
View File

@ -0,0 +1,98 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import RoomCard from '@/components/RoomCard'
import { CallApi } from '@/helper/api/ApiConnector'
import Link from 'next/link'
import React, { useState } from 'react'
export default function Search() {
const [details, setDetails] = useState<{
id?: number;
image: any,
title: string,
room: string,
color: string
}>()
const handleSave = async () => {
console.log(await CallApi(`/room`, 'POST', { id: 0, ...details,image: details?.image }))
}
return (
<div className='w-screen h-screen bg-black'
// style={{ 'background': 'conic-gradient(from 210deg, #c6bcb9 0.000deg, #c6bcb9 24.000deg, #b9b6b9 calc(24.000deg + 0.1deg), #b9b6b9 48.000deg, #aab0b8 calc(48.000deg + 0.1deg), #aab0b8 72.000deg, #9ba9b6 calc(72.000deg + 0.1deg), #9ba9b6 96.000deg, #8ca2b4 calc(96.000deg + 0.1deg), #8ca2b4 120.000deg, #7e99b0 calc(120.000deg + 0.1deg), #7e99b0 144.000deg, #7191ac calc(144.000deg + 0.1deg), #7191ac 168.000deg, #6688a7 calc(168.000deg + 0.1deg), #6688a7 192.000deg, #5d7fa2 calc(192.000deg + 0.1deg), #5d7fa2 216.000deg, #56769c calc(216.000deg + 0.1deg), #56769c 240.000deg, #526d95 calc(240.000deg + 0.1deg), #526d95 264.000deg, #50648e calc(264.000deg + 0.1deg), #50648e 288.000deg, #515b86 calc(288.000deg + 0.1deg), #515b86 312.000deg, #55547e calc(312.000deg + 0.1deg), #55547e 336.000deg, #5c4c75 calc(336.000deg + 0.1deg) 360.000deg)' }}
>
<div className='grow flex flex-col items-center align-middle justify-center h-full w-full'>
<div
className='bg-white/10 w-full h-full lg:h-[90%] lg:w-[90%] m-2 backdrop-blur-lg flex flex-col border border-white/20 shadow-lg'
>
<div className='bg-purple-950 text-white flex justify-evenly'>
<button className='p-2 grow hover:backdrop-blur-lg hover:bg-white/10'>Room Details</button>
<button className='p-2 grow hover:backdrop-blur-lg hover:bg-white/10'>Audiance</button>
<button className='p-2 grow hover:backdrop-blur-lg hover:bg-white/10'>Rules</button>
<button className='p-2 grow hover:backdrop-blur-lg hover:bg-white/10'>Guest List</button>
</div>
<div>
<input placeholder='Room Name'
onChange={e => setDetails((d: any) => {
return { ...d, title: e.target.value }
})}
className='text-white w-full border-0 focus:p-2 transition-all focus:border-0' />
<button onClick={handleSave} className='text-white'>Save</button>
<div>
<label>Image</label>
<input
onChange={e => {
const file = e.target.files![0]
if (file && file.type.startsWith('image/')) {
const reader = new FileReader(); // Create a new FileReader instance
// Set up the onload event for the reader
reader.onload = function (e) {
// e.target.result contains the image data as a Base64 encoded URL (data URL)
console.log('Image Data URL:', e.target!.result);
// You can now set the src of an image tag to this data URL to display it
setDetails((d: any) => {
return { ...d, image: e.target!.result }
})
// imagePreview.src = e.target.result;
// To get pixel data (ImageData object), you would use a canvas element
// (See "Getting Pixel Data" below)
};
// Read the image file as a Data URL
reader.readAsDataURL(file);
} else {
// imagePreview.src = '#'; // Clear preview if not an image
console.error('Please select a valid image file.');
}
}}
className='text-white' type='file' name='image' />
</div>
<div>
<label>Colour</label>
<input
onChange={e => setDetails((d: any) => {
return { ...d, color: e.target.value }
})}
type='color' name='color' />
</div>
{details && <RoomCard
color={details.color}
image={details!.image}
room='TEST'
title={details.title}
/>}
</div>
</div>
</div>
</div>
)
}

38
webapp/app/teste/page.tsx Normal file
View File

@ -0,0 +1,38 @@
import Link from 'next/link'
import React from 'react'
export default function Profile() {
return (
<div className='w-screen h-screen bg-black'
// style={{ 'background': 'conic-gradient(from 210deg, #c6bcb9 0.000deg, #c6bcb9 24.000deg, #b9b6b9 calc(24.000deg + 0.1deg), #b9b6b9 48.000deg, #aab0b8 calc(48.000deg + 0.1deg), #aab0b8 72.000deg, #9ba9b6 calc(72.000deg + 0.1deg), #9ba9b6 96.000deg, #8ca2b4 calc(96.000deg + 0.1deg), #8ca2b4 120.000deg, #7e99b0 calc(120.000deg + 0.1deg), #7e99b0 144.000deg, #7191ac calc(144.000deg + 0.1deg), #7191ac 168.000deg, #6688a7 calc(168.000deg + 0.1deg), #6688a7 192.000deg, #5d7fa2 calc(192.000deg + 0.1deg), #5d7fa2 216.000deg, #56769c calc(216.000deg + 0.1deg), #56769c 240.000deg, #526d95 calc(240.000deg + 0.1deg), #526d95 264.000deg, #50648e calc(264.000deg + 0.1deg), #50648e 288.000deg, #515b86 calc(288.000deg + 0.1deg), #515b86 312.000deg, #55547e calc(312.000deg + 0.1deg), #55547e 336.000deg, #5c4c75 calc(336.000deg + 0.1deg) 360.000deg)' }}
>
<div className='grow flex flex-col items-center align-middle justify-center h-full w-full'>
<div
className='bg-white/10 w-full h-full lg:h-[90%] lg:w-[90%] m-2 backdrop-blur-lg flex flex-col border border-white/20 shadow-lg'
>
<div className='bg-purple-950 text-white flex justify-evenly'>
<button className='p-2 grow hover:backdrop-blur-lg hover:bg-white/10'>Room Details</button>
<button className='p-2 grow hover:backdrop-blur-lg hover:bg-white/10'>Audiance</button>
<button className='p-2 grow hover:backdrop-blur-lg hover:bg-white/10'>Rules</button>
<button className='p-2 grow hover:backdrop-blur-lg hover:bg-white/10'>Guest List</button>
</div>
</div>
</div>
</div>
)
}
function RoomCard({
title,
room,
}: {
title: string
room: string
}) {
return <Link href={`/testb?room=${room}`}><div>{title}</div></Link>
}

25
webapp/components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@ -0,0 +1,7 @@
import React from 'react'
export default function ErrorDialog({ error }: { error: string | Error }) {
return (
<div>{error instanceof Error ? error.message : error }</div>
)
}

View File

@ -0,0 +1,59 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Link from "next/link"
export default function RoomCard({
title,
room,
image,
color,
}: {
title: string
room: string
image: any,
color: string
}) {
if(!image) return "No Image Available"
return <Link href={`/testb?room=${room}`}>
{image && <div
style={{
// minWidth: 500,
width: '100%',
height: 275,
overflow: 'hidden',
objectFit: 'cover',
objectPosition: 'center'
}}
className='relative opacity-70 hover:opacity-90'
>
<div className='text-2xl text-center w-full text-shadow-black text-shadow-2xs text-white bg-transparent absolute z-20 top-0'>{title}</div>
<img
width={'100%'}
height={275}
style={{
filter: 'grayscale(100%)',
position: 'relative',
overflow: 'hidden'
}}
src={image}
className='absolute top-0'
/>
<div
style={{
backgroundColor: color,
opacity: '50%',
// backgroundColor: 'rgba(100,27,198,0.5',
// width: 500,
width: '100%',
height: 275
}}
className='absolute top-0 bottom-0 left-0 right-0'
>
</div>
</div>
}
</Link>
}

View File

@ -0,0 +1,33 @@
import React from 'react'
import { Input } from '../ui/input'
import { InputGroup, InputGroupAddon, InputGroupButton } from '../ui/input-group'
import { Textarea } from '../ui/textarea'
export default function ChatBox() {
return (
<div className='flex flex-col'>
<div className='grow flex flex-col'>
CHAT MESSAGES
<div className='p-1 rounded-2xl border max-w-1/2 self-start'>Placeholder message</div>
<div className='p-1 rounded-2xl border max-w-1/2 self-end'>Placeholder message</div>
</div>
<div>
<div className="grid w-full max-w-sm gap-6">
<InputGroup>
<Textarea
data-slot="input-group-control"
className="flex field-sizing-content min-h-16 w-full resize-none rounded-md bg-transparent px-3 py-2.5 text-base transition-[color,box-shadow] outline-none md:text-sm"
placeholder="Message..."
/>
<InputGroupAddon align="block-end">
<InputGroupButton className="ml-auto" size="sm" variant="default">
Send
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,175 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import React, { useRef } from 'react'
export default function ChatNoServerWidget() {
const localVideo = useRef<HTMLVideoElement | null>(null)
const remoteVideo = useRef<HTMLVideoElement | null>(null)
const localStream = useRef<MediaStream>(null)
const pc1 = useRef<RTCPeerConnection>(null)
const pc2 = useRef<RTCPeerConnection>(null)
const handleStart = async () => {
console.log('Requesting local stream');
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
console.log('Received local stream');
localVideo.current!.srcObject = stream;
localStream.current = stream;
} catch (e) {
console.error(e)
alert(`getUserMedia() error: ${(e as Error).name}`);
}
}
function getName(pc: RTCPeerConnection) {
return (pc === pc1.current) ? 'pc1' : 'pc2';
}
function getOtherPc(pc: RTCPeerConnection) {
return (pc === pc1.current) ? pc2.current : pc1.current;
}
async function onIceCandidate(pc: RTCPeerConnection, event: RTCPeerConnectionIceEvent) {
try {
await (getOtherPc(pc)!.addIceCandidate(event.candidate));
onAddIceCandidateSuccess(pc);
} catch (e) {
onAddIceCandidateError(pc, e);
}
console.log(`${getName(pc)} ICE candidate:\n${event.candidate ? event.candidate.candidate : '(null)'}`);
}
function onAddIceCandidateSuccess(pc: RTCPeerConnection) {
console.log(`${getName(pc)} addIceCandidate success`);
}
function onAddIceCandidateError(pc: RTCPeerConnection, error: any) {
console.log(`${getName(pc)} failed to add ICE Candidate: ${error.toString()}`);
}
function onIceStateChange(pc: RTCPeerConnection, event: Event) {
if (pc) {
console.log(`${getName(pc)} ICE state: ${pc.iceConnectionState}`);
console.log('ICE state change event: ', event);
}
}
function gotRemoteStream(e: RTCTrackEvent) {
if (remoteVideo.current!.srcObject !== e.streams[0]) {
remoteVideo.current!.srcObject = e.streams[0];
console.log('pc2 received remote stream');
}
}
async function call() {
console.log('Starting call');
const videoTracks = localStream.current!.getVideoTracks();
const audioTracks = localStream.current!.getAudioTracks();
if (videoTracks.length > 0) {
console.log(`Using video device: ${videoTracks[0].label}`);
}
if (audioTracks.length > 0) {
console.log(`Using audio device: ${audioTracks[0].label}`);
}
const configuration = {};
console.log('RTCPeerConnection configuration:', configuration);
pc1.current = new RTCPeerConnection(configuration);
console.log('Created local peer connection object pc1');
pc1.current.addEventListener('icecandidate', e => onIceCandidate(pc1.current!, e));
pc2.current = new RTCPeerConnection(configuration);
console.log('Created remote peer connection object pc2');
pc2.current.addEventListener('icecandidate', e => onIceCandidate(pc2.current!, e));
pc1.current.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc1.current!, e));
pc2.current.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc2.current!, e));
pc2.current.addEventListener('track', gotRemoteStream);
localStream.current!.getTracks().forEach(track => pc1.current!.addTrack(track, localStream.current!));
console.log('Added local stream to pc1');
try {
console.log('pc1 createOffer start');
const offer = await pc1.current.createOffer();
// const offer = await pc1.current.createOffer(offerOptions);
await onCreateOfferSuccess(offer);
} catch (e) {
onCreateSessionDescriptionError(e);
}
}
function onSetLocalSuccess(pc: RTCPeerConnection) {
console.log(`${getName(pc)} setLocalDescription complete`);
}
function onSetRemoteSuccess(pc: RTCPeerConnection) {
console.log(`${getName(pc)} setRemoteDescription complete`);
}
function onSetSessionDescriptionError(error: any) {
console.log(`Failed to set session description: ${error.toString()}`);
}
async function onCreateOfferSuccess(desc: RTCSessionDescriptionInit) {
console.log(`Offer from pc1\n${desc.sdp}`);
console.log('pc1 setLocalDescription start');
try {
await pc1.current!.setLocalDescription(desc);
onSetLocalSuccess(pc1.current!);
} catch (e) {
onSetSessionDescriptionError(e);
}
console.log('pc2 setRemoteDescription start');
try {
await pc2.current!.setRemoteDescription(desc);
onSetRemoteSuccess(pc2.current!);
} catch (e) {
onSetSessionDescriptionError(e);
}
console.log('pc2 createAnswer start');
// Since the 'remote' side has no media stream we need
// to pass in the right constraints in order for it to
// accept the incoming offer of audio and video.
try {
const answer = await pc2.current!.createAnswer();
await onCreateAnswerSuccess(answer);
} catch (e) {
onCreateSessionDescriptionError(e);
}
}
async function onCreateAnswerSuccess(desc: RTCSessionDescriptionInit) {
console.log(`Answer from pc2:\n${desc.sdp}`);
console.log('pc2 setLocalDescription start');
try {
await pc2.current!.setLocalDescription(desc);
onSetLocalSuccess(pc2.current!);
} catch (e) {
onSetSessionDescriptionError(e);
}
console.log('pc1 setRemoteDescription start');
try {
await pc1.current!.setRemoteDescription(desc);
onSetRemoteSuccess(pc1.current!);
} catch (e) {
onSetSessionDescriptionError(e);
}
}
function onCreateSessionDescriptionError(error: any) {
console.log(`Failed to create session description: ${error.toString()}`);
}
return (
<div>
<video ref={localVideo} id="localVideo" playsInline autoPlay muted></video>
<video ref={remoteVideo} id="remoteVideo" playsInline autoPlay></video>
<div className="box">
<button onClick={handleStart} id="startButton">Start</button>
<button onClick={() => call()} id="callButton">Call</button>
<button id="hangupButton">Hang Up</button>
</div>
</div>
)
}

View File

@ -0,0 +1,175 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import React, { useRef } from 'react'
export default function ChatNoServerWidget() {
const localVideo = useRef<HTMLVideoElement | null>(null)
const remoteVideo = useRef<HTMLVideoElement | null>(null)
const localStream = useRef<MediaStream>(null)
const pc1 = useRef<RTCPeerConnection>(null)
const pc2 = useRef<RTCPeerConnection>(null)
const handleStart = async () => {
console.log('Requesting local stream');
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
console.log('Received local stream');
localVideo.current!.srcObject = stream;
localStream.current = stream;
} catch (e) {
console.error(e)
alert(`getUserMedia() error: ${(e as Error).name}`);
}
}
function getName(pc: RTCPeerConnection) {
return (pc === pc1.current) ? 'pc1' : 'pc2';
}
function getOtherPc(pc: RTCPeerConnection) {
return (pc === pc1.current) ? pc2.current : pc1.current;
}
async function onIceCandidate(pc: RTCPeerConnection, event: RTCPeerConnectionIceEvent) {
try {
await (getOtherPc(pc)!.addIceCandidate(event.candidate));
onAddIceCandidateSuccess(pc);
} catch (e) {
onAddIceCandidateError(pc, e);
}
console.log(`${getName(pc)} ICE candidate:\n${event.candidate ? event.candidate.candidate : '(null)'}`);
}
function onAddIceCandidateSuccess(pc: RTCPeerConnection) {
console.log(`${getName(pc)} addIceCandidate success`);
}
function onAddIceCandidateError(pc: RTCPeerConnection, error: any) {
console.log(`${getName(pc)} failed to add ICE Candidate: ${error.toString()}`);
}
function onIceStateChange(pc: RTCPeerConnection, event: Event) {
if (pc) {
console.log(`${getName(pc)} ICE state: ${pc.iceConnectionState}`);
console.log('ICE state change event: ', event);
}
}
function gotRemoteStream(e: RTCTrackEvent) {
if (remoteVideo.current!.srcObject !== e.streams[0]) {
remoteVideo.current!.srcObject = e.streams[0];
console.log('pc2 received remote stream');
}
}
async function call() {
console.log('Starting call');
const videoTracks = localStream.current!.getVideoTracks();
const audioTracks = localStream.current!.getAudioTracks();
if (videoTracks.length > 0) {
console.log(`Using video device: ${videoTracks[0].label}`);
}
if (audioTracks.length > 0) {
console.log(`Using audio device: ${audioTracks[0].label}`);
}
const configuration = {};
console.log('RTCPeerConnection configuration:', configuration);
pc1.current = new RTCPeerConnection(configuration);
console.log('Created local peer connection object pc1');
pc1.current.addEventListener('icecandidate', e => onIceCandidate(pc1.current!, e));
pc2.current = new RTCPeerConnection(configuration);
console.log('Created remote peer connection object pc2');
pc2.current.addEventListener('icecandidate', e => onIceCandidate(pc2.current!, e));
pc1.current.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc1.current!, e));
pc2.current.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc2.current!, e));
pc2.current.addEventListener('track', gotRemoteStream);
localStream.current!.getTracks().forEach(track => pc1.current!.addTrack(track, localStream.current!));
console.log('Added local stream to pc1');
try {
console.log('pc1 createOffer start');
const offer = await pc1.current.createOffer();
// const offer = await pc1.current.createOffer(offerOptions);
await onCreateOfferSuccess(offer);
} catch (e) {
onCreateSessionDescriptionError(e);
}
}
function onSetLocalSuccess(pc: RTCPeerConnection) {
console.log(`${getName(pc)} setLocalDescription complete`);
}
function onSetRemoteSuccess(pc: RTCPeerConnection) {
console.log(`${getName(pc)} setRemoteDescription complete`);
}
function onSetSessionDescriptionError(error: any) {
console.log(`Failed to set session description: ${error.toString()}`);
}
async function onCreateOfferSuccess(desc: RTCSessionDescriptionInit) {
console.log(`Offer from pc1\n${desc.sdp}`);
console.log('pc1 setLocalDescription start');
try {
await pc1.current!.setLocalDescription(desc);
onSetLocalSuccess(pc1.current!);
} catch (e) {
onSetSessionDescriptionError(e);
}
console.log('pc2 setRemoteDescription start');
try {
await pc2.current!.setRemoteDescription(desc);
onSetRemoteSuccess(pc2.current!);
} catch (e) {
onSetSessionDescriptionError(e);
}
console.log('pc2 createAnswer start');
// Since the 'remote' side has no media stream we need
// to pass in the right constraints in order for it to
// accept the incoming offer of audio and video.
try {
const answer = await pc2.current!.createAnswer();
await onCreateAnswerSuccess(answer);
} catch (e) {
onCreateSessionDescriptionError(e);
}
}
async function onCreateAnswerSuccess(desc: RTCSessionDescriptionInit) {
console.log(`Answer from pc2:\n${desc.sdp}`);
console.log('pc2 setLocalDescription start');
try {
await pc2.current!.setLocalDescription(desc);
onSetLocalSuccess(pc2.current!);
} catch (e) {
onSetSessionDescriptionError(e);
}
console.log('pc1 setRemoteDescription start');
try {
await pc1.current!.setRemoteDescription(desc);
onSetRemoteSuccess(pc1.current!);
} catch (e) {
onSetSessionDescriptionError(e);
}
}
function onCreateSessionDescriptionError(error: any) {
console.log(`Failed to create session description: ${error.toString()}`);
}
return (
<div>
<video ref={localVideo} id="localVideo" playsInline autoPlay muted></video>
<video ref={remoteVideo} id="remoteVideo" playsInline autoPlay></video>
<div className="box">
<button onClick={handleStart} id="startButton">Start</button>
<button onClick={() => call()} id="callButton">Call</button>
<button id="hangupButton">Hang Up</button>
</div>
</div>
)
}

View File

@ -0,0 +1,134 @@
'use client'
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useRef, useState } from 'react'
import { Socket } from 'socket.io';
import { io } from 'socket.io-client';
import { socket } from './socket';
import Link from 'next/link';
// https://www.baeldung.com/webrtc
export default function ChatWS() {
const [session, setSession] = useState<{
who: string,
room?: string
}>()
const [messages, setMessages] = useState<{
who: string,
msg: string
}[]>([])
const nameRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
socket.onAny((ev, data) => {
console.log(`ANY`, ev, data)
// setMessages(prevMessages => [...prevMessages, data]);
});
socket.on('chat message', (data) => {
// console.log(`chat message`,data)
setMessages(prevMessages => [...prevMessages, data]);
});
// Clean up the listener when the component unmounts
return () => {
socket.off('chat message');
};
}, [])
if (!session?.who) return <div
style={{ 'background': 'conic-gradient(from 210deg, #c6bcb9 0.000deg, #c6bcb9 24.000deg, #b9b6b9 calc(24.000deg + 0.1deg), #b9b6b9 48.000deg, #aab0b8 calc(48.000deg + 0.1deg), #aab0b8 72.000deg, #9ba9b6 calc(72.000deg + 0.1deg), #9ba9b6 96.000deg, #8ca2b4 calc(96.000deg + 0.1deg), #8ca2b4 120.000deg, #7e99b0 calc(120.000deg + 0.1deg), #7e99b0 144.000deg, #7191ac calc(144.000deg + 0.1deg), #7191ac 168.000deg, #6688a7 calc(168.000deg + 0.1deg), #6688a7 192.000deg, #5d7fa2 calc(192.000deg + 0.1deg), #5d7fa2 216.000deg, #56769c calc(216.000deg + 0.1deg), #56769c 240.000deg, #526d95 calc(240.000deg + 0.1deg), #526d95 264.000deg, #50648e calc(264.000deg + 0.1deg), #50648e 288.000deg, #515b86 calc(288.000deg + 0.1deg), #515b86 312.000deg, #55547e calc(312.000deg + 0.1deg), #55547e 336.000deg, #5c4c75 calc(336.000deg + 0.1deg) 360.000deg)' }}
className='bg-gray-100 h-svh w-screen z-30 fixed flex items-center justify-center align-middle'>
<div
// onSubmit={ev => {
// ev.preventDefault()
// const form = new FormData(ev.target)
// alert(form.get(`name`))
// setSession((s: any) => {
// return {
// ...s,
// who: form.get(`name`)
// }
// })
// }}
className='bg-white/10 backdrop-blur-lg w-96 h-64 flex flex-col border border-white/20 shadow-lg p-2'>
<div className='grow font-black text-center'>
Enter a name to use in this chat
</div>
<div className='grow'>
<input
ref={nameRef}
className='w-full focus:p-2 transition-all focus:bg-white'
autoFocus
name='name'
placeholder='Your name' />
</div>
<button type='button'
onClick={() => {
setSession((s: any) => {
return {
...s,
who: nameRef.current?.value
}
})
}} className='shadow p-2'>Next</button>
</div>
</div>
return (
<div
/* https://grabient.com/HQNgrAHANMYIxhgJjAFigWlgTkVsaM2AzHJsMRAAzlKrTBwhJRwVgv4gjnYDsfcsWzpQSYq2BVpUKkA?style=angularSwatches&angle=210&steps=15 */
// background: conic-gradient(from 210deg, #7c6f6f 0.000deg, #7c6f6f 24.000deg, #6e6b6d calc(24.000deg + 0.1deg), #6e6b6d 48.000deg, #5f666a calc(48.000deg + 0.1deg), #5f666a 72.000deg, #505f67 calc(72.000deg + 0.1deg), #505f67 96.000deg, #3f5862 calc(96.000deg + 0.1deg), #3f5862 120.000deg, #2f515e calc(120.000deg + 0.1deg), #2f515e 144.000deg, #204858 calc(144.000deg + 0.1deg), #204858 168.000deg, #123f52 calc(168.000deg + 0.1deg), #123f52 192.000deg, #05364b calc(192.000deg + 0.1deg), #05364b 216.000deg, #002c43 calc(216.000deg + 0.1deg), #002c43 240.000deg, #00233b calc(240.000deg + 0.1deg), #00233b 264.000deg, #001933 calc(264.000deg + 0.1deg), #001933 288.000deg, #000f2a calc(288.000deg + 0.1deg), #000f2a 312.000deg, #000621 calc(312.000deg + 0.1deg), #000621 336.000deg, #000017 calc(336.000deg + 0.1deg) 360.000deg);
style={{ 'background': 'black' }}
// style={{ 'background': 'conic-gradient(from 210deg, #c6bcb9 0.000deg, #c6bcb9 24.000deg, #b9b6b9 calc(24.000deg + 0.1deg), #b9b6b9 48.000deg, #aab0b8 calc(48.000deg + 0.1deg), #aab0b8 72.000deg, #9ba9b6 calc(72.000deg + 0.1deg), #9ba9b6 96.000deg, #8ca2b4 calc(96.000deg + 0.1deg), #8ca2b4 120.000deg, #7e99b0 calc(120.000deg + 0.1deg), #7e99b0 144.000deg, #7191ac calc(144.000deg + 0.1deg), #7191ac 168.000deg, #6688a7 calc(168.000deg + 0.1deg), #6688a7 192.000deg, #5d7fa2 calc(192.000deg + 0.1deg), #5d7fa2 216.000deg, #56769c calc(216.000deg + 0.1deg), #56769c 240.000deg, #526d95 calc(240.000deg + 0.1deg), #526d95 264.000deg, #50648e calc(264.000deg + 0.1deg), #50648e 288.000deg, #515b86 calc(288.000deg + 0.1deg), #515b86 312.000deg, #55547e calc(312.000deg + 0.1deg), #55547e 336.000deg, #5c4c75 calc(336.000deg + 0.1deg) 360.000deg)' }}
// style={{ 'background': 'conic-gradient(from 210deg, #7c6f6f 0.000deg, #7c6f6f 24.000deg, #6e6b6d calc(24.000deg + 0.1deg), #6e6b6d 48.000deg, #5f666a calc(48.000deg + 0.1deg), #5f666a 72.000deg, #505f67 calc(72.000deg + 0.1deg), #505f67 96.000deg, #3f5862 calc(96.000deg + 0.1deg), #3f5862 120.000deg, #2f515e calc(120.000deg + 0.1deg), #2f515e 144.000deg, #204858 calc(144.000deg + 0.1deg), #204858 168.000deg, #123f52 calc(168.000deg + 0.1deg), #123f52 192.000deg, #05364b calc(192.000deg + 0.1deg), #05364b 216.000deg, #002c43 calc(216.000deg + 0.1deg), #002c43 240.000deg, #00233b calc(240.000deg + 0.1deg), #00233b 264.000deg, #001933 calc(264.000deg + 0.1deg), #001933 288.000deg, #000f2a calc(288.000deg + 0.1deg), #000f2a 312.000deg, #000621 calc(312.000deg + 0.1deg), #000621 336.000deg, #000017 calc(336.000deg + 0.1deg) 360.000deg)' }}
className='flex flex-col h-svh w-screen'>
<div className='bg-purple-900 shadow-sm shadow-purple-800'>
<Link href={`/`}>
<button className='bg-purple-950 text-white p-2 shadow hover:bg-purple-700'>Close Chat</button>
</Link>
</div>
<div className='grow flex flex-col overflow-auto gap-2'>
{messages.map((msg, i) =>
<div
className={`w-1/2 flex flex-col bg-gray-100 p-2 rounded-2xl shadow ${msg.who === session.who ? 'self-start' : 'self-end'}`}
key={`message-${i}`}>
<small>{msg.who}</small>
{msg.msg}
</div>
)}
</div>
{/* {JSON.stringify(messages)} */}
<div className='text-center text-amber-600 bg-amber-100'>
All message are removed once you leave this chat
</div>
<form onSubmit={ev => {
ev.preventDefault()
const form = new FormData(ev.target)
if (!form.get('msg')) return
socket.emit('chat message', {
who: session!.who ?? 'Annonymous',
msg: form.get('msg') as string
})
ev.currentTarget.reset()
}}
className='flex p-2 bg-gray-100 shadow-2xl opacity-90'
>
<input placeholder='Message' name='msg' className='grow' />
<button
className='shadow p-2 bg-gray-300'
onClick={() => {
// socket.emit('chat message', {
// who: 'Clint',
// msg: `Hello`
// })
// socket.send(`Hello MSG`)
}
}>Send</button>
</form>
</div>
)
}

View File

@ -0,0 +1,388 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { useEffect, useRef, useState } from "react"
import { Device } from 'mediasoup-client'
import { Transport } from "mediasoup-client/types"
const wsURL = 'ws://localhost:4000'
let remoteStream
export default function MediaSoupWidget() {
const socket = useRef<WebSocket | undefined>(undefined)
const device = useRef<Device | undefined>(undefined)
const videoRef = useRef<HTMLVideoElement | null>(null)
const videoBRef = useRef<HTMLVideoElement | null>(null)
const consumerTransport = useRef<Transport | null>(null)
const [connected, setConnected] = useState<boolean>(false)
const [states, setStates] = useState<string[]>([])
const loadDevices = async (routerCapabilities: any) => {
try {
device.current = new Device()
await device.current!.load({ routerRtpCapabilities: routerCapabilities })
// console.log(`Supported : `, device)
setConnected(true)
} catch (e) {
if ((e as any).name === 'UnsupportedErro') {
console.log(`Not Supported`)
}
console.error(`Faield to create device:`, e)
}
}
const getUserMedia = async (transport: any, isWebCam: boolean) => {
if (!device.current?.canProduce('video')) {
console.error(`Cant stream video`)
return
}
let stream
try {
stream = isWebCam ? await navigator.mediaDevices.getUserMedia({
video: true, audio: true
}) : await navigator.mediaDevices.getDisplayMedia({ video: true })
} catch (e) {
console.error(`Unable to get device to stream`, e)
}
return stream
}
const onProducerTransportCreated = async (producerDetails: any) => {
// 5. Createe Send Transport
setStates(s => [...s, 'createSendTransport'])
const transport = device.current?.createSendTransport({ ...producerDetails })
transport?.on('connect', async ({ dtlsParameters }, callback, errback) => {
// 6. connectProducerTransport
setStates(s => [...s, 'createSendTransport'])
socket.current?.send(JSON.stringify({
type: 'connectProducerTransport',
dtlsParameters
}))
socket.current?.addEventListener('message', ev => {
// 7. producerTransportConnected
// console.log(`SEOM TO CHK `, ev.data)
if (JSON.parse(ev.data).type === 'producerTransportConnected') {
setStates(s => [...s, 'producerTransportConnected'])
// console.log(`------->producerTransportConnected:onCB`)
callback()
}
})
})
transport?.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
// 8. Produce
setStates(s => [...s, 'produce'])
socket.current?.send(JSON.stringify({
type: 'produce',
transportId: transport.id,
kind,
rtpParameters
}))
socket.current?.addEventListener('message', ev => {
if (ev.data.type === 'published') {
callback(ev.data.id``)
}
})
})
transport?.on('connectionstatechange', async (state) => {
console.log(`connectionstatechange`, state)
switch (state) {
case 'connecting': break;
case 'closed': break;
case 'connected':
// Link stream here
break;
case 'disconnected': break;
case 'failed':
transport.close()
break;
case 'new': break;
}
})
transport?.on('icecandidateerror', async (error) => {
console.log('icecandidateerror', error)
})
transport?.on('icegatheringstatechange', async (error) => {
console.log('icegatheringstatechange', error)
})
transport?.on('producedata', async (error) => {
console.log('producedata', error)
})
let streamMedia
try {
streamMedia = await getUserMedia(transport, true) // is webcam
const track = streamMedia!.getVideoTracks()[0]
// const params - { track }
// videoRef.current?.h = track
console.log(`LocalStream`, streamMedia)
// videoRef.current!.srcObject = streamMedia
const producer = await transport?.produce({ track })
producer!.on("trackended", () => {
console.log("track ended");
});
producer!.on("transportclose", () => {
console.log("transport ended");
});
}
catch (e) {
console.log(`ERROR`, e)
}
}
const onSubTransportCreated = (consumerDetails: any) => {
consumerTransport.current = device.current!.createRecvTransport({ ...consumerDetails })
console.log(`onSubTransportCreated`, consumerDetails, consumerTransport.current.connectionState)
consumerTransport.current.on('connect', ({ dtlsParameters }, callback, errback) => {
//11 . Accept connect
setStates(s => [...s, 'connectConsumerTransport'])
socket.current?.send(JSON.stringify({
type: 'connectConsumerTransport',
transportId: consumerTransport.current!.id,
dtlsParameters
}))
socket.current?.addEventListener('message', ev => {
if (JSON.parse(ev.data).type === 'subConnected') {
console.log(`subConnected**`, JSON.parse(ev.data).type)
callback()
}
})
})
consumerTransport.current?.on('connectionstatechange', async (state) => {
console.log(`connectionstatechange:`, state)
switch (state) {
case 'connecting':
break;
case 'failed': console.log(`FILAED`); break;
case 'connected':
console.log(`remoteStream`, remoteStream)
// videoBRef.current!.srcObject = remoteStream
socket.current?.send(JSON.stringify({
type: 'resume',
}))
break;
default:
break;
}
})
consumerTransport.current?.on('icecandidateerror', () => {
console.log(`icecandidateerror`)
})
consumerTransport.current?.on('icegatheringstatechange', async (state) => {
console.log(`icegatheringstatechange`, state)
})
const stream = consumer(consumerTransport.current)
}
const onSubscribe = async (details: any) => {
console.log('onSubscribe', details)
const {
producerId,
id,
kind,
rtpParameters,
type,
producerPaused,
} = details
const codecOptions = {}
const consumer = await consumerTransport.current!.consume({
producerId,
id,
kind,
rtpParameters,
// type,
// producerPaused,
// codecOptions
})
const stream = new MediaStream()
stream.addTrack(consumer.track)
console.log(`TRYIN STREAM 1`,stream)
videoBRef.current!.srcObject = stream
}
const consumer = async (transport: any) => {
const rtpCapabilities = device.current?.recvRtpCapabilities
socket.current?.send(JSON.stringify({
type: 'consume',
rtpCapabilities
}))
}
// const connectSendTransport = async () => {
// const producer = await transport.produce(params);
// console.log("Producer created:", producer.id, producer.kind);
// producer.on("trackended", () => {
// console.log("track ended");
// });
// producer.on("transportclose", () => {
// console.log("transport ended");
// });
// };
const stream = () => {
// 4.1 Start creating producers steam
// 4. Producers stream
setStates(s => [...s, 'createProducerTransport'])
socket.current?.send(JSON.stringify({
type: 'createProducerTransport',
forceTcp: false,
rtpCapabilities: device.current?.sendRtpCapabilities
}))
}
const joinStream = () => {
setStates(s => [...s, 'createConsumerTransport'])
socket.current?.send(JSON.stringify({
type: 'createConsumerTransport',
forceTcp: false,
// rtpCapabilities: device.current?.sendRtpCapabilities
}))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parseWSMessage = (ev: any) => {
const recv = JSON.parse(ev)
// console.log(`-- parseWSMessage --`, recv.data)
switch (recv.type) {
case 'routerCapabilities':
// 3. Received capabilities
setStates(s => [...s, 'routerCapabilities'])
loadDevices(recv.data);
break;
case 'producerTransportCreated':
// 4.2 Received capabilities
setStates(s => [...s, 'producerTransportCreated'])
onProducerTransportCreated(recv.data);
break;
// case 'producerTransportConnected':
// // 7. producerTransportConnected but not with callback
// setStates(s => [...s, 'producerTransportCreated'])
// // callback()
// break;
case 'newProducer':
// 9 Found new Produce contents and send to all clients
setStates(s => [...s, 'newProducer'])
break;
case 'subTransportCreated':
// 10 Consumer joined
setStates(s => [...s, 'subTransportCreated'])
onSubTransportCreated(recv.data);
break;
case 'resumed':
// 10 Consumer joined
setStates(s => [...s, 'resumed'])
console.log(`resumed`, recv.data)
break;
case 'subscribed':
// 12 Consumer joined
setStates(s => [...s, 'subscribed'])
onSubscribe(recv.data)
break;
default:
console.log(`Received Uknown`, recv.type)
break;
}
// console.log(ev)
}
return (
<div>WebRTCChat
<div>
<div className={`${states.includes('ws-connected') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Connected To Server</div>
<small>Establish connection to the WS Server</small>
</div>
<div className={`${states.includes('getRouterRtpCapabilities') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Checking Server Capabilities</div>
<small>getRouterRtpCapabilities</small>
</div>
<div className={`${states.includes('routerCapabilities') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Has Server Capabilities</div>
<small>routerCapabilities</small>
</div>
<div className={`${states.includes('createProducerTransport') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Create Producers Transport (Waiting for user to start stream)</div>
<small>createProducerTransport</small>
</div>
<div className={`${states.includes('producerTransportCreated') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Created Producers Transport</div>
<small>producerTransportCreated</small>
</div>
<div className={`${states.includes('producerTransportCreated') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Created Producers Transport</div>
<small>producerTransportCreated</small>
</div>
<div className={`${states.includes('createSendTransport') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Created Send Transport</div>
<small>createSendTransport</small>
</div>
<div className={`${states.includes('producerTransportConnected') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Producer Transport Connected</div>
<small>producerTransportConnected</small>
</div>
<div className={`${states.includes('producerTransportConnected') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Send Produced</div>
<small>produce</small>
</div>
<div className={`${states.includes('newProducer') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Found new Produce contents and send to all clients</div>
<small>newProducer</small>
</div>
<div className={`${states.includes('subTransportCreated') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Cunsumber connected</div>
<small>subTransportCreated</small>
</div>
<div className={`${states.includes('connectConsumerTransport') ? 'text-green-400' : 'text-gray-400'} border`}>
<div>Accept Consumer Connection</div>
<small>connectConsumerTransport</small>
</div>
</div>
<button
onClick={() => {
socket.current = new WebSocket(wsURL)
socket.current.onopen = () => {
// 1. Create websocket connection
setStates(s => [...s, 'ws-connected'])
const msg = {
type: "getRouterRtpCapabilities"
}
try {
// 2. Get Server capabilities
setStates(s => [...s, 'getRouterRtpCapabilities'])
socket.current?.send(JSON.stringify(msg))
} catch (e) {
console.log(`Failed to send capabilities`, e)
}
}
socket.current.onmessage = e => parseWSMessage((e as any).data)
}}
disabled={connected}
>{connected ? 'Connected' : 'Connect'}</button>
<button onClick={() => stream()}>
Stream
</button>
<video ref={videoRef}></video>
<button
onClick={() => {
joinStream()
}}
>Join</button>
<video ref={videoBRef}></video>
</div>
)
}

View File

@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { CameraIcon, HdIcon, MicrochipIcon, Server, VideoIcon } from 'lucide-react';
import Peer, { DataConnection } from 'peerjs';
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
type FlowStateType = {
color: 'red' | 'blue' | 'green' | 'orange',
flow: 'MIC' | 'VIDEO' | 'SERVER' | 'NO_PEERS' | 'ROOM',
loading: boolean
}
export default function WebRTCChatUsingPeer() {
let mediaStream
// UI
const peerRef = useRef<Peer>(undefined)
const [peers, setPeers] = useState<string[]>([])
const connection = useRef<DataConnection>(undefined)
const videoRef = useRef<HTMLVideoElement>(null)
// const [mediaStream, setMediaStream] = useState<MediaStream>(null)
const [received, setReceivved] = useState<string>('')
const [peerId, setPeerId] = useState<string>('')
const [theirPeerId, setTheirPeerId] = useState<string>('')
const [myStream, setMyStream] = useState<MediaStream>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
const [selectedDevice, setSelectedDevice] = useState<{ video: { deviceId: { exact: string } }, audio: { deviceId: { exact: string } } }>()
const peer = new Peer({
host: "localhost",
port: 4000,
path: "/myapp",
secure: false, // Use true with HTTPS
});
useEffect(() => {
peer.on("open", function (id) {
setPeerId(id)
console.log("My peer ID is: " + id);
});
}, [])
peer.on('connection', function (conn) {
console.log(`Got Connection`, conn)
conn.on("data", function (data) {
setReceivved(data as string)
console.log("Received", data);
});
conn.send("Hello!");
});
// useEffect(() => {
// // Get user media
// navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// .then(stream => {
// // setMyStream(stream);
// // if (videoRef.current) {
// // videoRef.current.srcObject = stream;
// // }
// })
// .catch(err => console.error("Failed to get local stream", err));
// // Initialize PeerJS
// // const peer = new Peer(); // PeerJS manages server connection
// // currentPeer.current = peer;
// peer.on('open', (id) => {
// setPeerId(id);
// console.log('My peer ID is:', id);
// });
// // Listen for incoming calls
// peer.on('call', (call) => {
// if (myStream) {
// // Answer the call with your stream
// call.answer(myStream);
// call.on('stream', (remoteStream) => {
// // Show stream in remote video element
// if (videoRef.current) {
// videoRef.current.srcObject = remoteStream;
// }
// });
// }
// });
// peer.on('error', (err) => console.error(err));
// return () => {
// if (peer) {
// peer.destroy();
// }
// };
// }, [myStream]);
// useEffect(() => {
// const load = async () => {
// // Load media devices
// const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
// setDevices(await navigator.mediaDevices.enumerateDevices())
// // )
// // setFlowIdx(1)
// // setFlowIdx(2)
// // console.log(await navigator.mediaDevices.enumerateDevices())
// // console.log(await navigator.mediaDevices.getUserMedia().)
// }
// load()
// peer.on("call", function (call) {
// alert(`You have a call`)
// // Answer the call, providing our mediaStream
// // if (mediaStream) {
// call.answer(mediaStream);
// call.on('stream', (remoteStream) => {
// // Show stream in remote video element
// if (videoRef.current) {
// videoRef.current.srcObject = remoteStream;
// }
// });
// // }
// });
// }, [])
return (
<div>
{JSON.stringify(peerId)}
<div>
<b>Settings</b>
<b>Video</b>
{devices.filter(a => a.kind === 'videoinput').map(device =>
<div key={device.deviceId}
onClick={() => setSelectedDevice((d: any) => {
return { ...d, video: { deviceId: { exact: device.deviceId } } }
})}
className={`${selectedDevice && selectedDevice.video && device.deviceId === selectedDevice?.video.deviceId.exact ? 'bg-green-300' : ''}`}
>
{device.label}
</div>
)}
<b>Audio</b>
{devices.filter(a => a.kind === 'audioinput').map(device =>
<div key={device.deviceId}
onClick={() => setSelectedDevice((d: any) => {
return { ...d, audio: { deviceId: { exact: device.deviceId } } }
})}
className={`${selectedDevice && selectedDevice.audio && device.deviceId === selectedDevice?.audio.deviceId.exact ? 'bg-green-300' : ''}`}
>
{device.label}
</div>
)}
</div>
<input placeholder='Cannect to' onChange={e => setTheirPeerId(e.target.value)} />
<button onClick={() => {
console.log(`Connecting`)
connection.current
= peer.connect(theirPeerId, {
});
}}>Start</button>
<button
className='p-2'
onClick={() => {
// const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
navigator.mediaDevices.getUserMedia(selectedDevice)
.then(stream => peer.call(theirPeerId, stream))
// const call = peer.call(theirPeerId, stream);
// const stream = await navigator.mediaDevices.getUserMedia({
// video: { deviceId: { exact: selectedVideoId } }
// });
}}
>Call</button>
<textarea onChange={e => connection.current?.send(e.target.value)} placeholder='Message' rows={6} />
{JSON.stringify(received)}
<video ref={videoRef} />
</div>
)
}

View File

@ -0,0 +1,530 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { CameraIcon, HdIcon, MicrochipIcon, Server, VideoIcon } from 'lucide-react';
import Peer, { DataConnection } from 'peerjs';
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
type FlowStateType = {
color: 'red' | 'blue' | 'green' | 'orange',
flow: 'MIC' | 'VIDEO' | 'SERVER' | 'NO_PEERS' | 'ROOM',
loading: boolean
}
export default function WebRTCChatUsingPeer() {
let mediaStream
// UI
// const [flowSection, setFlowSection] = useState<'MIC' | 'VIDEO' | 'SERVER'>('MIC')
const [flowState, setFlowState] = useState<FlowStateType>({
color: 'blue',
flow: 'SERVER',
loading: false
})
// const [flow, setFlow] = useState<{
// screen: ReactNode,
// loading: boolean,
// color: 'red' | 'blue' | 'green' | 'orange',
// options?: any
// }[]>([
// // {
// // loading: false,
// // screen: <SelectMicrophone setLoading={setLoading} />,
// // color: 'orange',
// // options: [{
// // label: 'Next',
// // onSelect: (a: any) => { }
// // }]
// // },
// {
// loading: false,
// screen: <>
// <CameraIcon size={45} />
// Select video device
// <select>
// <option>Select</option>
// </select>
// </>,
// color: 'orange'
// }, {
// loading: true,
// screen: <ConnectingToSerever />,
// color: 'blue'
// }])
const peerRef = useRef<Peer>(undefined)
const [peers, setPeers] = useState<string[]>([])
const connection = useRef<DataConnection>(undefined)
const videoRef = useRef<HTMLVideoElement>(null)
// const [mediaStream, setMediaStream] = useState<MediaStream>(null)
const [received, setReceivved] = useState<string>('')
const [peerId, setPeerId] = useState<string>('')
const [theirPeerId, setTheirPeerId] = useState<string>('')
const [myStream, setMyStream] = useState<MediaStream>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
const [selectedDevice, setSelectedDevice] = useState<{ video: { deviceId: { exact: string } }, audio: { deviceId: { exact: string } } }>()
const handleFindPeers = () => {
peerRef.current?.listAllPeers(peers => {
setPeers(() => peers)
console.log(`PEERS`, peers)
if (peers.length > 1) {
console.log(`CONNECT TO: `, peers.filter(a => a !== peerId)[0])
if (!connection.current)
peerRef.current?.connect(peers.filter(a => a !== peerId)[0])
setFlowState(s => { return { ...s, flow: 'ROOM', color: 'blue', loading: true } })
} else {
setFlowState(s => { return { ...s, flow: 'NO_PEERS', color: 'blue', loading: true } })
}
})
}
const handleConnectServer = () => {
setFlowState(s => { return { ...s, flow: 'SERVER' } })
peerRef.current = new Peer({
host: "localhost",
port: 4000,
path: "/myapp",
secure: false, // Use true with HTTPS
})
// peerRef.current.socket.on('message')
peerRef.current.on("open", function (id) {
setPeerId(id)
setFlowState(s => { return { ...s, color: 'green', loading: false } })
console.log("My peer ID is: " + id, 'Connected Peers:',);
handleFindPeers()
});
peerRef.current.on('error', function (err) {
setFlowState(s => { return { ...s, color: 'red', loading: false } })
})
peerRef.current.on('connection', function (conn) {
console.log(`Got Connection`, conn)
connection.current = conn
// handleFindPeers()
setFlowState(s => { return { ...s, flow: 'ROOM' } })
connection.current.on("data", function (data) {
// setReceivved(data as string)
console.log("Received", data);
});
connection.current.send("Hello!");
});
}
useEffect(() => {
handleConnectServer()
}, [])
// const peer = new Peer({
// host: "localhost",
// port: 4000,
// path: "/myapp",
// secure: false, // Use true with HTTPS
// });
// useEffect(() => {
// peer.on("open", function (id) {
// setPeerId(id)
// console.log("My peer ID is: " + id);
// });
// }, [])
// peer.on('connection', function (conn) {
// console.log(`Got Connection`, conn)
// conn.on("data", function (data) {
// setReceivved(data as string)
// console.log("Received", data);
// });
// conn.send("Hello!");
// });
// useEffect(() => {
// // Get user media
// navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// .then(stream => {
// // setMyStream(stream);
// // if (videoRef.current) {
// // videoRef.current.srcObject = stream;
// // }
// })
// .catch(err => console.error("Failed to get local stream", err));
// // Initialize PeerJS
// // const peer = new Peer(); // PeerJS manages server connection
// // currentPeer.current = peer;
// peer.on('open', (id) => {
// setPeerId(id);
// console.log('My peer ID is:', id);
// });
// // Listen for incoming calls
// peer.on('call', (call) => {
// if (myStream) {
// // Answer the call with your stream
// call.answer(myStream);
// call.on('stream', (remoteStream) => {
// // Show stream in remote video element
// if (videoRef.current) {
// videoRef.current.srcObject = remoteStream;
// }
// });
// }
// });
// peer.on('error', (err) => console.error(err));
// return () => {
// if (peer) {
// peer.destroy();
// }
// };
// }, [myStream]);
// useEffect(() => {
// const load = async () => {
// // Load media devices
// const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
// setDevices(await navigator.mediaDevices.enumerateDevices())
// // )
// // setFlowIdx(1)
// // setFlowIdx(2)
// // console.log(await navigator.mediaDevices.enumerateDevices())
// // console.log(await navigator.mediaDevices.getUserMedia().)
// }
// load()
// peer.on("call", function (call) {
// alert(`You have a call`)
// // Answer the call, providing our mediaStream
// // if (mediaStream) {
// call.answer(mediaStream);
// call.on('stream', (remoteStream) => {
// // Show stream in remote video element
// if (videoRef.current) {
// videoRef.current.srcObject = remoteStream;
// }
// });
// // }
// });
// }, [])
if (flowState.flow === 'ROOM') {
return <div className='flex w-screen h-screen'>
<div className='grow'></div>
<form onSubmit={(form) => {
form.preventDefault()
const formData = new FormData(form.target);
// const det = Object.fromEntries(formData.entries())
try {
console.log(`Sending:`, connection.current, formData.get('message') as string)
connection.current?.send(formData.get('message') as string)
} catch (e) {
console.error('Failed to send message', e)
}
}}>
<input name='message' placeholder='message' />
<button type='submit'>Send</button>
</form>
</div>
}
return (<Screen
color={flowState.color}
loading={flowState.loading}>
{
{
'MIC': <SelectMicrophone setFlowState={setFlowState}
onChange={e => {
setSelectedDevice((d: any) => {
return { ...d, audio: { deviceId: { exact: e } } }
})
setFlowState(s => { return { ...s, flow: 'VIDEO' } })
// setFlowSection('VIDEO')
}}
/>,
'VIDEO': <SelectVideo setFlowState={setFlowState}
onChange={e => {
setSelectedDevice((d: any) => {
return { ...d, video: { deviceId: { exact: e } } }
})
handleConnectServer()
}}
/>,
'SERVER': <ConnectingToSerever
setFlowState={setFlowState}
/>,
'NO_PEERS': <>Waiting for others to join</>,
'ROOM': <></>
}[flowState.flow]
}
{/* {flow[flowIdx].screen} */}
</Screen>)
// return (
// <div>
// {JSON.stringify(peerId)}
// <div>
// <b>Settings</b>
// <b>Video</b>
// {devices.filter(a => a.kind === 'videoinput').map(device =>
// <div key={device.deviceId}
// onClick={() => setSelectedDevice((d: any) => {
// return { ...d, video: { deviceId: { exact: device.deviceId } } }
// })}
// className={`${selectedDevice && selectedDevice.video && device.deviceId === selectedDevice?.video.deviceId.exact ? 'bg-green-300' : ''}`}
// >
// {device.label}
// </div>
// )}
// <b>Audio</b>
// {devices.filter(a => a.kind === 'audioinput').map(device =>
// <div key={device.deviceId}
// onClick={() => setSelectedDevice((d: any) => {
// return { ...d, audio: { deviceId: { exact: device.deviceId } } }
// })}
// className={`${selectedDevice && selectedDevice.audio && device.deviceId === selectedDevice?.audio.deviceId.exact ? 'bg-green-300' : ''}`}
// >
// {device.label}
// </div>
// )}
// </div>
// <input placeholder='Cannect to' onChange={e => setTheirPeerId(e.target.value)} />
// <button onClick={() => {
// console.log(`Connecting`)
// connection.current
// = peer.connect(theirPeerId, {
// });
// }}>Start</button>
// <button
// className='p-2'
// onClick={() => {
// // const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
// navigator.mediaDevices.getUserMedia(selectedDevice)
// .then(stream => peer.call(theirPeerId, stream))
// // const call = peer.call(theirPeerId, stream);
// // const stream = await navigator.mediaDevices.getUserMedia({
// // video: { deviceId: { exact: selectedVideoId } }
// // });
// }}
// >Call</button>
// <textarea onChange={e => connection.current?.send(e.target.value)} placeholder='Message' rows={6} />
// {JSON.stringify(received)}
// <video ref={videoRef} />
// </div>
// )
}
export function Screen({ color, loading, children }: {
color: 'red' | 'blue' | 'green' | 'orange',
loading: boolean
children: ReactNode
}) {
return <div
style={{
background: color == 'orange' ? '#441306' : color == 'green' ? '#032e15' : color == 'red' ? '#460809' : '#162456'
}}
className={`h-screen w-screen flex items-center align-middle justify-center`}>
<>
<div className='relative '>
{/* <div className='absolute flex items-center align-middle justify-center -left-20 -top-10 bg-blue-900 hover:bg-blue-800 shadow shadow-white shadow-2xl rounded-full grow w-64 h-64'>
ITEM 1
</div>
<div className='absolute right-0 bg-blue-950 hover:bg-blue-900 w-64 h-64'>
ITEM 2
</div>
<div className='absolute bottom-0 bg-blue-950 hover:bg-blue-900'>
ITEM 3
</div> */}
<div
style={{
background: color == 'orange' ?
'radial-gradient(circle, oklch(40.8% 0.123 38.172) 0%, rgba(0,0,0,0.3) 100%)'
: color == 'green' ?
'radial-gradient(circle, oklch(39.3% 0.095 152.535) 0%, rgba(0,0,0,0.3) 100%)' :
color == 'red' ?
'radial-gradient(circle,oklch(39.6% 0.141 25.723) 0%, rgba(0,0,0,0.3) 100%)'
:
'radial-gradient(circle,oklch(37.9% 0.146 265.522) 0%, rgba(0,0,0,0.3) 100%)'
}}
className={`w-96 h-95 rounded-full relative border-8 shadow-lg ${loading && `animate-spin shadow-purple-300/75`} text-3xl text-white flex items-center justify-center align-middle `}>
<div className="absolute inset-0 flex items-center justify-center animate-[inherit] direction-reverse">
<span className="flex gap-2 flex-col items-center font-bold text-white text-shadow-white text-shadow-2xs">
{children}
</span>
</div>
</div>
</div>
</>
</div>
}
export function ConnectingToSerever({
setFlowState,
}: {
setFlowState: React.Dispatch<React.SetStateAction<FlowStateType>>
}) {
useEffect(() => { setFlowState(s => { return { ...s, color: 'blue', loading: true } }) }, [])
// const peerConn = useCallback(() => {
// const peer = new Peer({
// host: "localhost",
// port: 4000,
// path: "/myapp",
// secure: false, // Use true with HTTPS
// });
// peer.on("open", function (id) {
// setPeerId(id)
// setColor('green')
// console.log("My peer ID is: " + id);
// });
// peer.on('error', function (err) {
// setColor('red')
// setLoading(false)
// })
// peer.on('connection', function (conn) {
// console.log(`Got Connection`, conn)
// conn.on("data", function (data) {
// // setReceivved(data as string)
// console.log("Received", data);
// });
// conn.send("Hello!");
// });
// }, [])
// useEffect(() => {
// const load = async () => {
// peer.on("open", function (id) {
// setPeerId(id)
// setColor('green')
// console.log("My peer ID is: " + id);
// });
// peer.on('error', function (err) {
// setColor('red')
// setLoading(false)
// })
// peer.on('connection', function (conn) {
// console.log(`Got Connection`, conn)
// conn.on("data", function (data) {
// // setReceivved(data as string)
// console.log("Received", data);
// });
// conn.send("Hello!");
// });
// }
// setColor('green')
// setLoading(true)
// load()
// }, [])
return <>
<Server size={45} />
Connecting to Server
</>
}
export function SelectMicrophone({
setFlowState,
onChange
}: {
setFlowState: React.Dispatch<React.SetStateAction<FlowStateType>>
onChange: (device: string) => void
}) {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
const load = async () => {
// Load media devices
// setTimeout(() => setLoading(false), 3000)
// setLoading(false)
const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
setDevices(await navigator.mediaDevices.enumerateDevices())
setFlowState(s => { return { ...s, color: 'orange', loading: false } })
}
setFlowState(s => { return { ...s, color: 'green', loading: true } })
load()
}, [])
return <>
<MicrochipIcon size={45} />
{
devices.length == 0 ? 'Finding Audio Devices' : <>
Select audio device
<select className='text-sm w-64 bg-white p-2 text-black'
onChange={e => onChange(e.target.value)}
>
<option>Select</option>
{devices.filter(a => a.kind === 'audioinput').map((device, i) =>
<option key={`device-${i}`} value={device.deviceId}>{device.label}</option>
)}
</select>
</>
}
</>
}
export function SelectVideo({
setFlowState,
onChange
}: {
setFlowState: React.Dispatch<React.SetStateAction<FlowStateType>>
onChange: (device: string) => void
}) {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
const load = async () => {
// Load media devices
// setTimeout(() => setLoading(false), 3000)
// setLoading(false)
const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
setDevices(await navigator.mediaDevices.enumerateDevices())
setFlowState(s => { return { ...s, color: 'orange', loading: false } })
}
setFlowState(s => { return { ...s, color: 'green', loading: true } })
load()
}, [])
return <>
<VideoIcon size={45} />
{
devices.length == 0 ? 'Finding Video Devices' : <>
Select video device
<select className='text-sm w-64 bg-white p-2 text-black'
onChange={e => onChange(e.target.value)}
>
<option>Select</option>
{devices.filter(a => a.kind === 'videoinput').map((device, i) =>
<option key={`device-${i}`} value={device.deviceId}>{device.label}</option>
)}
</select>
</>
}
</>
}

View File

@ -0,0 +1,506 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { CameraIcon, HdIcon, MicrochipIcon, Server, VideoIcon } from 'lucide-react';
import Peer, { DataConnection } from 'peerjs';
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
type FlowStateType = {
color: 'red' | 'blue' | 'green' | 'orange',
flow: 'MIC' | 'VIDEO' | 'SERVER' | 'NO_PEERS' | 'ROOM',
loading: boolean
}
export default function WebRTCChatUsingPeer() {
let mediaStream
// UI
// const [flowSection, setFlowSection] = useState<'MIC' | 'VIDEO' | 'SERVER'>('MIC')
const [flowState, setFlowState] = useState<FlowStateType>({
color: 'blue',
flow: 'SERVER',
loading: false
})
// const [flow, setFlow] = useState<{
// screen: ReactNode,
// loading: boolean,
// color: 'red' | 'blue' | 'green' | 'orange',
// options?: any
// }[]>([
// // {
// // loading: false,
// // screen: <SelectMicrophone setLoading={setLoading} />,
// // color: 'orange',
// // options: [{
// // label: 'Next',
// // onSelect: (a: any) => { }
// // }]
// // },
// {
// loading: false,
// screen: <>
// <CameraIcon size={45} />
// Select video device
// <select>
// <option>Select</option>
// </select>
// </>,
// color: 'orange'
// }, {
// loading: true,
// screen: <ConnectingToSerever />,
// color: 'blue'
// }])
const peerRef = useRef<Peer>(undefined)
const [peers, setPeers] = useState<string[]>([])
const connection = useRef<DataConnection>(undefined)
const videoRef = useRef<HTMLVideoElement>(null)
// const [mediaStream, setMediaStream] = useState<MediaStream>(null)
const [received, setReceivved] = useState<string>('')
const [peerId, setPeerId] = useState<string>('')
const [theirPeerId, setTheirPeerId] = useState<string>('')
const [myStream, setMyStream] = useState<MediaStream>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
const [selectedDevice, setSelectedDevice] = useState<{ video: { deviceId: { exact: string } }, audio: { deviceId: { exact: string } } }>()
const handleFindPeers = () => {
peerRef.current?.listAllPeers(peers => {
setPeers(() => peers)
console.log(`PEERS`, peers)
if (peers.length > 1) {
peerRef.current?.connect(peers[1])
setFlowState(s => { return { ...s, flow: 'ROOM', color: 'blue', loading: true } })
} else {
setFlowState(s => { return { ...s, flow: 'NO_PEERS', color: 'blue', loading: true } })
}
})
}
const handleConnectServer = () => {
setFlowState(s => { return { ...s, flow: 'SERVER' } })
peerRef.current = new Peer({
host: "localhost",
port: 4000,
path: "/myapp",
secure: false, // Use true with HTTPS
})
// peerRef.current.socket.on('message')
peerRef.current.on("open", function (id) {
setPeerId(id)
setFlowState(s => { return { ...s, color: 'green', loading: false } })
console.log("My peer ID is: " + id, 'Connected Peers:',);
handleFindPeers()
});
peerRef.current.on('error', function (err) {
setFlowState(s => { return { ...s, color: 'red', loading: false } })
})
peerRef.current.on('connection', function (conn) {
console.log(`Got Connection`, conn)
conn.on("data", function (data) {
// setReceivved(data as string)
console.log("Received", data);
});
conn.send("Hello!");
});
}
useEffect(() => {
handleConnectServer()
}, [])
// const peer = new Peer({
// host: "localhost",
// port: 4000,
// path: "/myapp",
// secure: false, // Use true with HTTPS
// });
// useEffect(() => {
// peer.on("open", function (id) {
// setPeerId(id)
// console.log("My peer ID is: " + id);
// });
// }, [])
// peer.on('connection', function (conn) {
// console.log(`Got Connection`, conn)
// conn.on("data", function (data) {
// setReceivved(data as string)
// console.log("Received", data);
// });
// conn.send("Hello!");
// });
// useEffect(() => {
// // Get user media
// navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// .then(stream => {
// // setMyStream(stream);
// // if (videoRef.current) {
// // videoRef.current.srcObject = stream;
// // }
// })
// .catch(err => console.error("Failed to get local stream", err));
// // Initialize PeerJS
// // const peer = new Peer(); // PeerJS manages server connection
// // currentPeer.current = peer;
// peer.on('open', (id) => {
// setPeerId(id);
// console.log('My peer ID is:', id);
// });
// // Listen for incoming calls
// peer.on('call', (call) => {
// if (myStream) {
// // Answer the call with your stream
// call.answer(myStream);
// call.on('stream', (remoteStream) => {
// // Show stream in remote video element
// if (videoRef.current) {
// videoRef.current.srcObject = remoteStream;
// }
// });
// }
// });
// peer.on('error', (err) => console.error(err));
// return () => {
// if (peer) {
// peer.destroy();
// }
// };
// }, [myStream]);
// useEffect(() => {
// const load = async () => {
// // Load media devices
// const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
// setDevices(await navigator.mediaDevices.enumerateDevices())
// // )
// // setFlowIdx(1)
// // setFlowIdx(2)
// // console.log(await navigator.mediaDevices.enumerateDevices())
// // console.log(await navigator.mediaDevices.getUserMedia().)
// }
// load()
// peer.on("call", function (call) {
// alert(`You have a call`)
// // Answer the call, providing our mediaStream
// // if (mediaStream) {
// call.answer(mediaStream);
// call.on('stream', (remoteStream) => {
// // Show stream in remote video element
// if (videoRef.current) {
// videoRef.current.srcObject = remoteStream;
// }
// });
// // }
// });
// }, [])
return (<Screen
color={flowState.color}
loading={flowState.loading}>
{
{
'MIC': <SelectMicrophone setFlowState={setFlowState}
onChange={e => {
setSelectedDevice((d: any) => {
return { ...d, audio: { deviceId: { exact: e } } }
})
setFlowState(s => { return { ...s, flow: 'VIDEO' } })
// setFlowSection('VIDEO')
}}
/>,
'VIDEO': <SelectVideo setFlowState={setFlowState}
onChange={e => {
setSelectedDevice((d: any) => {
return { ...d, video: { deviceId: { exact: e } } }
})
handleConnectServer()
}}
/>,
'SERVER': <ConnectingToSerever
setFlowState={setFlowState}
/>,
'NO_PEERS': <>Waiting for others to join</>,
'ROOM': <></>
}[flowState.flow]
}
{/* {flow[flowIdx].screen} */}
</Screen>)
// return (
// <div>
// {JSON.stringify(peerId)}
// <div>
// <b>Settings</b>
// <b>Video</b>
// {devices.filter(a => a.kind === 'videoinput').map(device =>
// <div key={device.deviceId}
// onClick={() => setSelectedDevice((d: any) => {
// return { ...d, video: { deviceId: { exact: device.deviceId } } }
// })}
// className={`${selectedDevice && selectedDevice.video && device.deviceId === selectedDevice?.video.deviceId.exact ? 'bg-green-300' : ''}`}
// >
// {device.label}
// </div>
// )}
// <b>Audio</b>
// {devices.filter(a => a.kind === 'audioinput').map(device =>
// <div key={device.deviceId}
// onClick={() => setSelectedDevice((d: any) => {
// return { ...d, audio: { deviceId: { exact: device.deviceId } } }
// })}
// className={`${selectedDevice && selectedDevice.audio && device.deviceId === selectedDevice?.audio.deviceId.exact ? 'bg-green-300' : ''}`}
// >
// {device.label}
// </div>
// )}
// </div>
// <input placeholder='Cannect to' onChange={e => setTheirPeerId(e.target.value)} />
// <button onClick={() => {
// console.log(`Connecting`)
// connection.current
// = peer.connect(theirPeerId, {
// });
// }}>Start</button>
// <button
// className='p-2'
// onClick={() => {
// // const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
// navigator.mediaDevices.getUserMedia(selectedDevice)
// .then(stream => peer.call(theirPeerId, stream))
// // const call = peer.call(theirPeerId, stream);
// // const stream = await navigator.mediaDevices.getUserMedia({
// // video: { deviceId: { exact: selectedVideoId } }
// // });
// }}
// >Call</button>
// <textarea onChange={e => connection.current?.send(e.target.value)} placeholder='Message' rows={6} />
// {JSON.stringify(received)}
// <video ref={videoRef} />
// </div>
// )
}
export function Screen({ color, loading, children }: {
color: 'red' | 'blue' | 'green' | 'orange',
loading: boolean
children: ReactNode
}) {
return <div
style={{
background: color == 'orange' ? '#441306' : color == 'green' ? '#032e15' : color == 'red' ? '#460809' : '#162456'
}}
className={`h-screen w-screen flex items-center align-middle justify-center`}>
<>
<div className='relative '>
{/* <div className='absolute flex items-center align-middle justify-center -left-20 -top-10 bg-blue-900 hover:bg-blue-800 shadow shadow-white shadow-2xl rounded-full grow w-64 h-64'>
ITEM 1
</div>
<div className='absolute right-0 bg-blue-950 hover:bg-blue-900 w-64 h-64'>
ITEM 2
</div>
<div className='absolute bottom-0 bg-blue-950 hover:bg-blue-900'>
ITEM 3
</div> */}
<div
style={{
background: color == 'orange' ?
'radial-gradient(circle, oklch(40.8% 0.123 38.172) 0%, rgba(0,0,0,0.3) 100%)'
: color == 'green' ?
'radial-gradient(circle, oklch(39.3% 0.095 152.535) 0%, rgba(0,0,0,0.3) 100%)' :
color == 'red' ?
'radial-gradient(circle,oklch(39.6% 0.141 25.723) 0%, rgba(0,0,0,0.3) 100%)'
:
'radial-gradient(circle,oklch(37.9% 0.146 265.522) 0%, rgba(0,0,0,0.3) 100%)'
}}
className={`w-96 h-95 rounded-full relative border-8 shadow-lg ${loading && `animate-spin shadow-purple-300/75`} text-3xl text-white flex items-center justify-center align-middle `}>
<div className="absolute inset-0 flex items-center justify-center animate-[inherit] direction-reverse">
<span className="flex gap-2 flex-col items-center font-bold text-white text-shadow-white text-shadow-2xs">
{children}
</span>
</div>
</div>
</div>
</>
</div>
}
export function ConnectingToSerever({
setFlowState,
}: {
setFlowState: React.Dispatch<React.SetStateAction<FlowStateType>>
}) {
useEffect(() => { setFlowState(s => { return { ...s, color: 'blue', loading: true } }) }, [])
// const peerConn = useCallback(() => {
// const peer = new Peer({
// host: "localhost",
// port: 4000,
// path: "/myapp",
// secure: false, // Use true with HTTPS
// });
// peer.on("open", function (id) {
// setPeerId(id)
// setColor('green')
// console.log("My peer ID is: " + id);
// });
// peer.on('error', function (err) {
// setColor('red')
// setLoading(false)
// })
// peer.on('connection', function (conn) {
// console.log(`Got Connection`, conn)
// conn.on("data", function (data) {
// // setReceivved(data as string)
// console.log("Received", data);
// });
// conn.send("Hello!");
// });
// }, [])
// useEffect(() => {
// const load = async () => {
// peer.on("open", function (id) {
// setPeerId(id)
// setColor('green')
// console.log("My peer ID is: " + id);
// });
// peer.on('error', function (err) {
// setColor('red')
// setLoading(false)
// })
// peer.on('connection', function (conn) {
// console.log(`Got Connection`, conn)
// conn.on("data", function (data) {
// // setReceivved(data as string)
// console.log("Received", data);
// });
// conn.send("Hello!");
// });
// }
// setColor('green')
// setLoading(true)
// load()
// }, [])
return <>
<Server size={45} />
Connecting to Server
</>
}
export function SelectMicrophone({
setFlowState,
onChange
}: {
setFlowState: React.Dispatch<React.SetStateAction<FlowStateType>>
onChange: (device: string) => void
}) {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
const load = async () => {
// Load media devices
// setTimeout(() => setLoading(false), 3000)
// setLoading(false)
const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
setDevices(await navigator.mediaDevices.enumerateDevices())
setFlowState(s => { return { ...s, color: 'orange', loading: false } })
}
setFlowState(s => { return { ...s, color: 'green', loading: true } })
load()
}, [])
return <>
<MicrochipIcon size={45} />
{
devices.length == 0 ? 'Finding Audio Devices' : <>
Select audio device
<select className='text-sm w-64 bg-white p-2 text-black'
onChange={e => onChange(e.target.value)}
>
<option>Select</option>
{devices.filter(a => a.kind === 'audioinput').map((device, i) =>
<option key={`device-${i}`} value={device.deviceId}>{device.label}</option>
)}
</select>
</>
}
</>
}
export function SelectVideo({
setFlowState,
onChange
}: {
setFlowState: React.Dispatch<React.SetStateAction<FlowStateType>>
onChange: (device: string) => void
}) {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
const load = async () => {
// Load media devices
// setTimeout(() => setLoading(false), 3000)
// setLoading(false)
const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
setDevices(await navigator.mediaDevices.enumerateDevices())
setFlowState(s => { return { ...s, color: 'orange', loading: false } })
}
setFlowState(s => { return { ...s, color: 'green', loading: true } })
load()
}, [])
return <>
<VideoIcon size={45} />
{
devices.length == 0 ? 'Finding Video Devices' : <>
Select video device
<select className='text-sm w-64 bg-white p-2 text-black'
onChange={e => onChange(e.target.value)}
>
<option>Select</option>
{devices.filter(a => a.kind === 'videoinput').map((device, i) =>
<option key={`device-${i}`} value={device.deviceId}>{device.label}</option>
)}
</select>
</>
}
</>
}

View File

@ -0,0 +1,538 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import { CameraIcon, HdIcon, MicrochipIcon, Server, VideoIcon } from 'lucide-react';
import Peer, { DataConnection } from 'peerjs';
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
type FlowStateType = {
color: 'red' | 'blue' | 'green' | 'orange',
flow: 'MIC' | 'VIDEO' | 'SERVER' | 'NO_PEERS' | 'ROOM',
loading: boolean
}
export default function WebRTCChatUsingPeer() {
let mediaStream
// UI
// const [flowSection, setFlowSection] = useState<'MIC' | 'VIDEO' | 'SERVER'>('MIC')
const [flowState, setFlowState] = useState<FlowStateType>({
color: 'blue',
flow: 'SERVER',
loading: false
})
// const [flow, setFlow] = useState<{
// screen: ReactNode,
// loading: boolean,
// color: 'red' | 'blue' | 'green' | 'orange',
// options?: any
// }[]>([
// // {
// // loading: false,
// // screen: <SelectMicrophone setLoading={setLoading} />,
// // color: 'orange',
// // options: [{
// // label: 'Next',
// // onSelect: (a: any) => { }
// // }]
// // },
// {
// loading: false,
// screen: <>
// <CameraIcon size={45} />
// Select video device
// <select>
// <option>Select</option>
// </select>
// </>,
// color: 'orange'
// }, {
// loading: true,
// screen: <ConnectingToSerever />,
// color: 'blue'
// }])
const peerRef = useRef<Peer>(undefined)
const [peers, setPeers] = useState<string[]>([])
const connection = useRef<DataConnection>(undefined)
const videoRef = useRef<HTMLVideoElement>(null)
// const [mediaStream, setMediaStream] = useState<MediaStream>(null)
const [received, setReceivved] = useState<string>('')
const [peerId, setPeerId] = useState<string>('')
const [removepeerId, setRemovePeerId] = useState<string>('')
const [theirPeerId, setTheirPeerId] = useState<string>('')
const [myStream, setMyStream] = useState<MediaStream|null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
const [selectedDevice, setSelectedDevice] = useState<{ video: { deviceId: { exact: string } }, audio: { deviceId: { exact: string } } }>()
const handleFindPeers = () => {
peerRef.current?.listAllPeers(peers => {
setPeers(() => peers)
console.log(`PEERS`, peers)
if (peers.length > 1) {
console.log(`CONNECT TO: `, peers.filter(a => a !== peerId)[0])
if (!connection.current) {
connection.current = peerRef.current?.connect(peers.filter(a => a !== peerId)[0])
setRemovePeerId(() => peers.filter(a => a !== peerId)[0])
}
setFlowState(s => { return { ...s, flow: 'ROOM', color: 'blue', loading: true } })
} else {
setFlowState(s => { return { ...s, flow: 'NO_PEERS', color: 'blue', loading: true } })
}
})
}
const handleConnectServer = () => {
setFlowState(s => { return { ...s, flow: 'SERVER' } })
peerRef.current = new Peer({
host: "localhost",
port: 4000,
path: "/myapp",
secure: false, // Use true with HTTPS
})
// peerRef.current.socket.on('message')
peerRef.current.on("open", function (id) {
setPeerId(id)
setFlowState(s => { return { ...s, color: 'green', loading: false } })
console.log("My peer ID is: " + id, 'Connected Peers:',);
handleFindPeers()
});
peerRef.current.on('error', function (err) {
setFlowState(s => { return { ...s, color: 'red', loading: false } })
})
peerRef.current.on('close', function () {
console.log(`Closing connection`)
})
peerRef.current.on('connection', function (conn) {
console.log(`Got Connection`, conn)
// handleFindPeers()
setFlowState(s => { return { ...s, flow: 'ROOM' } })
connection.current!.on("data", function (data) {
// setReceivved(data as string)
console.log("Received", data);
});
conn.send("Hello!");
});
}
useEffect(() => {
handleConnectServer()
}, [])
// const peer = new Peer({
// host: "localhost",
// port: 4000,
// path: "/myapp",
// secure: false, // Use true with HTTPS
// });
// useEffect(() => {
// peer.on("open", function (id) {
// setPeerId(id)
// console.log("My peer ID is: " + id);
// });
// }, [])
// peer.on('connection', function (conn) {
// console.log(`Got Connection`, conn)
// conn.on("data", function (data) {
// setReceivved(data as string)
// console.log("Received", data);
// });
// conn.send("Hello!");
// });
// useEffect(() => {
// // Get user media
// navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// .then(stream => {
// // setMyStream(stream);
// // if (videoRef.current) {
// // videoRef.current.srcObject = stream;
// // }
// })
// .catch(err => console.error("Failed to get local stream", err));
// // Initialize PeerJS
// // const peer = new Peer(); // PeerJS manages server connection
// // currentPeer.current = peer;
// peer.on('open', (id) => {
// setPeerId(id);
// console.log('My peer ID is:', id);
// });
// // Listen for incoming calls
// peer.on('call', (call) => {
// if (myStream) {
// // Answer the call with your stream
// call.answer(myStream);
// call.on('stream', (remoteStream) => {
// // Show stream in remote video element
// if (videoRef.current) {
// videoRef.current.srcObject = remoteStream;
// }
// });
// }
// });
// peer.on('error', (err) => console.error(err));
// return () => {
// if (peer) {
// peer.destroy();
// }
// };
// }, [myStream]);
// useEffect(() => {
// const load = async () => {
// // Load media devices
// const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
// setDevices(await navigator.mediaDevices.enumerateDevices())
// // )
// // setFlowIdx(1)
// // setFlowIdx(2)
// // console.log(await navigator.mediaDevices.enumerateDevices())
// // console.log(await navigator.mediaDevices.getUserMedia().)
// }
// load()
// peer.on("call", function (call) {
// alert(`You have a call`)
// // Answer the call, providing our mediaStream
// // if (mediaStream) {
// call.answer(mediaStream);
// call.on('stream', (remoteStream) => {
// // Show stream in remote video element
// if (videoRef.current) {
// videoRef.current.srcObject = remoteStream;
// }
// });
// // }
// });
// }, [])
if (flowState.flow === 'ROOM') {
return <div className='flex flex-col w-screen h-screen'>
<div className='flex justify-between w-full flex-col'>
<div>LOCAL PEER:{peerId}</div>
<div>REMOVE PEER:{removepeerId}</div>
</div>
<div className='grow'></div>
<form onSubmit={(form) => {
form.preventDefault()
const formData = new FormData(form.target);
// const det = Object.fromEntries(formData.entries())
try {
console.log(`Sending:`, connection.current, formData.get('message') as string)
connection.current?.send(formData.get('message') as string)
} catch (e) {
console.error('Failed to send message', e)
}
}}>
<input name='message' placeholder='message' />
<button type='submit'>Send</button>
</form>
</div>
}
return (<Screen
color={flowState.color}
loading={flowState.loading}>
{
{
'MIC': <SelectMicrophone setFlowState={setFlowState}
onChange={e => {
setSelectedDevice((d: any) => {
return { ...d, audio: { deviceId: { exact: e } } }
})
setFlowState(s => { return { ...s, flow: 'VIDEO' } })
// setFlowSection('VIDEO')
}}
/>,
'VIDEO': <SelectVideo setFlowState={setFlowState}
onChange={e => {
setSelectedDevice((d: any) => {
return { ...d, video: { deviceId: { exact: e } } }
})
handleConnectServer()
}}
/>,
'SERVER': <ConnectingToSerever
setFlowState={setFlowState}
/>,
'NO_PEERS': <>Waiting for others to join</>,
'ROOM': <></>
}[flowState.flow]
}
{/* {flow[flowIdx].screen} */}
</Screen>)
// return (
// <div>
// {JSON.stringify(peerId)}
// <div>
// <b>Settings</b>
// <b>Video</b>
// {devices.filter(a => a.kind === 'videoinput').map(device =>
// <div key={device.deviceId}
// onClick={() => setSelectedDevice((d: any) => {
// return { ...d, video: { deviceId: { exact: device.deviceId } } }
// })}
// className={`${selectedDevice && selectedDevice.video && device.deviceId === selectedDevice?.video.deviceId.exact ? 'bg-green-300' : ''}`}
// >
// {device.label}
// </div>
// )}
// <b>Audio</b>
// {devices.filter(a => a.kind === 'audioinput').map(device =>
// <div key={device.deviceId}
// onClick={() => setSelectedDevice((d: any) => {
// return { ...d, audio: { deviceId: { exact: device.deviceId } } }
// })}
// className={`${selectedDevice && selectedDevice.audio && device.deviceId === selectedDevice?.audio.deviceId.exact ? 'bg-green-300' : ''}`}
// >
// {device.label}
// </div>
// )}
// </div>
// <input placeholder='Cannect to' onChange={e => setTheirPeerId(e.target.value)} />
// <button onClick={() => {
// console.log(`Connecting`)
// connection.current
// = peer.connect(theirPeerId, {
// });
// }}>Start</button>
// <button
// className='p-2'
// onClick={() => {
// // const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
// navigator.mediaDevices.getUserMedia(selectedDevice)
// .then(stream => peer.call(theirPeerId, stream))
// // const call = peer.call(theirPeerId, stream);
// // const stream = await navigator.mediaDevices.getUserMedia({
// // video: { deviceId: { exact: selectedVideoId } }
// // });
// }}
// >Call</button>
// <textarea onChange={e => connection.current?.send(e.target.value)} placeholder='Message' rows={6} />
// {JSON.stringify(received)}
// <video ref={videoRef} />
// </div>
// )
}
export function Screen({ color, loading, children }: {
color: 'red' | 'blue' | 'green' | 'orange',
loading: boolean
children: ReactNode
}) {
return <div
style={{
background: color == 'orange' ? '#441306' : color == 'green' ? '#032e15' : color == 'red' ? '#460809' : '#162456'
}}
className={`h-screen w-screen flex items-center align-middle justify-center`}>
<>
<div className='relative '>
{/* <div className='absolute flex items-center align-middle justify-center -left-20 -top-10 bg-blue-900 hover:bg-blue-800 shadow shadow-white shadow-2xl rounded-full grow w-64 h-64'>
ITEM 1
</div>
<div className='absolute right-0 bg-blue-950 hover:bg-blue-900 w-64 h-64'>
ITEM 2
</div>
<div className='absolute bottom-0 bg-blue-950 hover:bg-blue-900'>
ITEM 3
</div> */}
<div
style={{
background: color == 'orange' ?
'radial-gradient(circle, oklch(40.8% 0.123 38.172) 0%, rgba(0,0,0,0.3) 100%)'
: color == 'green' ?
'radial-gradient(circle, oklch(39.3% 0.095 152.535) 0%, rgba(0,0,0,0.3) 100%)' :
color == 'red' ?
'radial-gradient(circle,oklch(39.6% 0.141 25.723) 0%, rgba(0,0,0,0.3) 100%)'
:
'radial-gradient(circle,oklch(37.9% 0.146 265.522) 0%, rgba(0,0,0,0.3) 100%)'
}}
className={`w-96 h-95 rounded-full relative border-8 shadow-lg ${loading && `animate-spin shadow-purple-300/75`} text-3xl text-white flex items-center justify-center align-middle `}>
<div className="absolute inset-0 flex items-center justify-center animate-[inherit] direction-reverse">
<span className="flex gap-2 flex-col items-center font-bold text-white text-shadow-white text-shadow-2xs">
{children}
</span>
</div>
</div>
</div>
</>
</div>
}
export function ConnectingToSerever({
setFlowState,
}: {
setFlowState: React.Dispatch<React.SetStateAction<FlowStateType>>
}) {
useEffect(() => { setFlowState(s => { return { ...s, color: 'blue', loading: true } }) }, [])
// const peerConn = useCallback(() => {
// const peer = new Peer({
// host: "localhost",
// port: 4000,
// path: "/myapp",
// secure: false, // Use true with HTTPS
// });
// peer.on("open", function (id) {
// setPeerId(id)
// setColor('green')
// console.log("My peer ID is: " + id);
// });
// peer.on('error', function (err) {
// setColor('red')
// setLoading(false)
// })
// peer.on('connection', function (conn) {
// console.log(`Got Connection`, conn)
// conn.on("data", function (data) {
// // setReceivved(data as string)
// console.log("Received", data);
// });
// conn.send("Hello!");
// });
// }, [])
// useEffect(() => {
// const load = async () => {
// peer.on("open", function (id) {
// setPeerId(id)
// setColor('green')
// console.log("My peer ID is: " + id);
// });
// peer.on('error', function (err) {
// setColor('red')
// setLoading(false)
// })
// peer.on('connection', function (conn) {
// console.log(`Got Connection`, conn)
// conn.on("data", function (data) {
// // setReceivved(data as string)
// console.log("Received", data);
// });
// conn.send("Hello!");
// });
// }
// setColor('green')
// setLoading(true)
// load()
// }, [])
return <>
<Server size={45} />
Connecting to Server
</>
}
export function SelectMicrophone({
setFlowState,
onChange
}: {
setFlowState: React.Dispatch<React.SetStateAction<FlowStateType>>
onChange: (device: string) => void
}) {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
const load = async () => {
// Load media devices
// setTimeout(() => setLoading(false), 3000)
// setLoading(false)
const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
setDevices(await navigator.mediaDevices.enumerateDevices())
setFlowState(s => { return { ...s, color: 'orange', loading: false } })
}
setFlowState(s => { return { ...s, color: 'green', loading: true } })
load()
}, [])
return <>
<MicrochipIcon size={45} />
{
devices.length == 0 ? 'Finding Audio Devices' : <>
Select audio device
<select className='text-sm w-64 bg-white p-2 text-black'
onChange={e => onChange(e.target.value)}
>
<option>Select</option>
{devices.filter(a => a.kind === 'audioinput').map((device, i) =>
<option key={`device-${i}`} value={device.deviceId}>{device.label}</option>
)}
</select>
</>
}
</>
}
export function SelectVideo({
setFlowState,
onChange
}: {
setFlowState: React.Dispatch<React.SetStateAction<FlowStateType>>
onChange: (device: string) => void
}) {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
const load = async () => {
// Load media devices
// setTimeout(() => setLoading(false), 3000)
// setLoading(false)
const res = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// // .then(async res =>
setDevices(await navigator.mediaDevices.enumerateDevices())
setFlowState(s => { return { ...s, color: 'orange', loading: false } })
}
setFlowState(s => { return { ...s, color: 'green', loading: true } })
load()
}, [])
return <>
<VideoIcon size={45} />
{
devices.length == 0 ? 'Finding Video Devices' : <>
Select video device
<select className='text-sm w-64 bg-white p-2 text-black'
onChange={e => onChange(e.target.value)}
>
<option>Select</option>
{devices.filter(a => a.kind === 'videoinput').map((device, i) =>
<option key={`device-${i}`} value={device.deviceId}>{device.label}</option>
)}
</select>
</>
}
</>
}

View File

@ -0,0 +1,197 @@
'use client'
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useRef, useState } from 'react'
// https://www.baeldung.com/webrtc
export default function WebRTComponent() {
const WSRef = useRef<WebSocket>(undefined)
const videoRef1 = useRef<HTMLVideoElement | null>(null)
const localStreamRef1 = useRef<MediaStream | null>(null)
const [offers, setOffers] = useState<RTCSessionDescriptionInit[]>([])
const peerConnection = useRef<RTCPeerConnection>(undefined)
const dataChannel = useRef<RTCDataChannel>(undefined)
const send = (message: any) =>
WSRef.current!.send(JSON.stringify(message));
const onWSMessage = async (message: MessageEvent) => {
console.log(`Received : `, message.data)
const parsed = JSON.parse(message.data)
switch (parsed.type) {
case 'Offers':
// setOffers(o => parsed.data as RTCSessionDescriptionInit[])
console.log('parsed.data',parsed.data)
await acceptOffer(parsed.data)
// await acceptOffer(parsed.data[parsed.data.length - 1])
break;
case 'Answers':
console.log(`Answers:`,parsed.data)
peerConnection.current!
.setRemoteDescription(new RTCSessionDescription({ ...parsed.data.sdp }))
.catch((err) => console.error(err)); //
break;
}
}
const handleConectWS = async () => {
WSRef.current = new WebSocket('ws://localhost:4000/socket');
WSRef.current.onopen = async function (event) {
peerConnection.current = new RTCPeerConnection(undefined) // <-- Later add Stun servers as backup
await setup()
}
WSRef.current.onmessage = async (ev) => onWSMessage(ev)
}
// 1. Get user media
const Start = async () => {
console.log('Requesting local stream');
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
console.log(`Start Local Stream`)
if (!videoRef1.current) console.error(`Video not yet available`)
videoRef1.current!.srcObject = stream
localStreamRef1.current = stream;
console.log(`Set local stream`)
}
const joinServer = async () => {
const configuration = {};
console.log('RTCPeerConnection configuration:', configuration);
peerConnection.current = new RTCPeerConnection(configuration)
peerConnection.current.createOffer().then(offer => {
send({
type: 'OfferSDP',
data: offer
})
peerConnection.current?.setLocalDescription(offer)
})
peerConnection.current.onicecandidate = function (event) {
console.log(`Received ICE Candidate :`, event)
}
// pc.addEventListener('icecandidate', e => console.log(`icecandidate`, pc, e));
}
const acceptOffer = async (recv: any) => {
console.log(`acceptOffer`, recv)
peerConnection.current!.setRemoteDescription(new RTCSessionDescription(recv))
.then(() => peerConnection.current!.createAnswer())
.then((answer) => peerConnection.current!.setLocalDescription(answer))
.then(() => send({ type: 'AnswerSDP', data: { sdp: peerConnection.current!.localDescription } }))
.catch((err) => console.error('Failed to handle offer', err));
}
// 2. Make call
const makeCall = () => {
// Get available streams
const videoTracks = localStreamRef1.current!.getVideoTracks();
const audioTracks = localStreamRef1.current!.getAudioTracks();
if (videoTracks.length > 0) {
console.log(`Using video device: ${videoTracks[0].label}`);
}
if (audioTracks.length > 0) {
console.log(`Using audio device: ${audioTracks[0].label}`);
}
const configuration = {};
// 3. Start WebRTC
console.log('RTCPeerConnection configuration:', configuration);
// const pc1 = new RTCPeerConnection(configuration);
// console.log('Created local peer connection object pc1');
// pc1.addEventListener('icecandidate', e => onIceCandidate(pc1, e));
// const pc2 = new RTCPeerConnection(configuration);
// console.log('Created remote peer connection object pc2');
// pc2.addEventListener('icecandidate', e => onIceCandidate(pc2, e));
// pc1.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc1, e));
// pc2.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc2, e));
// pc2.addEventListener('track', gotRemoteStream);
}
// 4. On ICE Candidate
// const onIceCandidate = async (peerConnection: RTCPeerConnection, e: Event) => {
// try {
// await(getOtherPc(pc).addIceCandidate(event.candidate));
// onAddIceCandidateSuccess(pc);
// } catch (e) {
// onAddIceCandidateError(pc, e);
// }
// console.log(`${getName(pc)} ICE candidate:\n${event.candidate ? event.candidate.candidate : '(null)'}`);
// }
async function setup() {
// 1. Get local media stream
// await Start()
// 2. Join WS Server
const handleJoin = () => {
send({ type: 'Join' })
}
handleJoin()
}
useEffect(() => {
handleConectWS()
// const load = async () => {
// WSRef.current.onmessage = (ev) => {
// // '{"event":"candidate","data":{"candidate":"candidate:1041930304 1 udp 2113937151 b7178aee-2d1d-4762-b709-1b25cf436cb5.local 61551 typ host generation 0 ufrag bEe6 network-cost 999","sdpMid":"0","sdpMLineIndex":0,"usernameFragment":"bEe6"}}'
// const data = JSON.parse(ev.data)
// // console.log(`Recevied WS : `, Object.keys(data))
// console.log(`Recevied WS : `, data.event)
// switch (data.event) {
// case 'offer':
// console.log(`Check foreign offer`, data.data)
// // Need to handle answer here
// peerConnection.current.setRemoteDescription(new RTCSessionDescription({ sdp: data.data.sdp, type: 'offer' }))
// .then(() => peerConnection.current.createAnswer())
// .then((answer) => peerConnection.current.setLocalDescription(answer))
// .then(() => send({ event: 'answer', data: { sdp: peerConnection.current.localDescription } }))
// .catch((err) => console.error('Failed to handle offer', err));
// break;
// case 'answer':
// console.log(`Check foreign answer`, data.data)
// peerConnection.current.setRemoteDescription(new RTCSessionDescription({ ...data.data.sdp })).catch((err) => console.error(err)); //
// break;
// case 'candidate':
// console.log(`Check foreign candidate`, { ...data.data })
// if (data.data.candidate) {
// peerConnection.current.addIceCandidate(new RTCIceCandidate({ ...data.data })).catch((err) => console.warn('Bad candidate', err));
// }
// break;
// }
// }
// }
// load()
}, [])
return (
<div>WebRTCChat
<video ref={videoRef1} playsInline autoPlay muted className='border w-96 h-96 bg-red-100' />
<button onClick={() => Start()}>Start</button>
<button onClick={() => joinServer()}>Join</button>
<button onClick={() => makeCall()}>Make Call</button>
<button onClick={() => {
{ alert('SEND'); dataChannel.current!.send("message"); }
}} >Send</button>
<div>
<b>Offers</b>
{/* <div className='flex flex-col'>
{offers.map((o, i) => <div key={`Offer-${i}`}>
{JSON.stringify(o.sdp)}
<button onClick={() => {
acceptOffer(o.sdp ?? "")
}}>Connect</button>
</div>)}
</div> */}
</div>
</div>
)
}

View File

@ -0,0 +1,214 @@
/*
* Copyright (c) 2015 The WebRTC project authors. All Rights Reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree.
*/
'use strict';
const startButton = document.getElementById('startButton');
const callButton = document.getElementById('callButton');
const hangupButton = document.getElementById('hangupButton');
callButton.disabled = true;
hangupButton.disabled = true;
startButton.addEventListener('click', start);
callButton.addEventListener('click', call);
hangupButton.addEventListener('click', hangup);
let startTime;
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
localVideo.addEventListener('loadedmetadata', function() {
console.log(`Local video videoWidth: ${this.videoWidth}px, videoHeight: ${this.videoHeight}px`);
});
remoteVideo.addEventListener('loadedmetadata', function() {
console.log(`Remote video videoWidth: ${this.videoWidth}px, videoHeight: ${this.videoHeight}px`);
});
remoteVideo.addEventListener('resize', () => {
console.log(`Remote video size changed to ${remoteVideo.videoWidth}x${remoteVideo.videoHeight} - Time since pageload ${performance.now().toFixed(0)}ms`);
// We'll use the first onsize callback as an indication that video has started
// playing out.
if (startTime) {
const elapsedTime = window.performance.now() - startTime;
console.log('Setup time: ' + elapsedTime.toFixed(3) + 'ms');
startTime = null;
}
});
let localStream;
let pc1;
let pc2;
const offerOptions = {
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
};
function getName(pc) {
return (pc === pc1) ? 'pc1' : 'pc2';
}
function getOtherPc(pc) {
return (pc === pc1) ? pc2 : pc1;
}
async function start() {
console.log('Requesting local stream');
startButton.disabled = true;
try {
const stream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
console.log('Received local stream');
localVideo.srcObject = stream;
localStream = stream;
callButton.disabled = false;
} catch (e) {
alert(`getUserMedia() error: ${e.name}`);
}
}
async function call() {
callButton.disabled = true;
hangupButton.disabled = false;
console.log('Starting call');
startTime = window.performance.now();
const videoTracks = localStream.getVideoTracks();
const audioTracks = localStream.getAudioTracks();
if (videoTracks.length > 0) {
console.log(`Using video device: ${videoTracks[0].label}`);
}
if (audioTracks.length > 0) {
console.log(`Using audio device: ${audioTracks[0].label}`);
}
const configuration = {};
console.log('RTCPeerConnection configuration:', configuration);
pc1 = new RTCPeerConnection(configuration);
console.log('Created local peer connection object pc1');
pc1.addEventListener('icecandidate', e => onIceCandidate(pc1, e));
pc2 = new RTCPeerConnection(configuration);
console.log('Created remote peer connection object pc2');
pc2.addEventListener('icecandidate', e => onIceCandidate(pc2, e));
pc1.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc1, e));
pc2.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc2, e));
pc2.addEventListener('track', gotRemoteStream);
localStream.getTracks().forEach(track => pc1.addTrack(track, localStream));
console.log('Added local stream to pc1');
try {
console.log('pc1 createOffer start');
const offer = await pc1.createOffer(offerOptions);
await onCreateOfferSuccess(offer);
} catch (e) {
onCreateSessionDescriptionError(e);
}
}
function onCreateSessionDescriptionError(error) {
console.log(`Failed to create session description: ${error.toString()}`);
}
async function onCreateOfferSuccess(desc) {
console.log(`Offer from pc1\n${desc.sdp}`);
console.log('pc1 setLocalDescription start');
try {
await pc1.setLocalDescription(desc);
onSetLocalSuccess(pc1);
} catch (e) {
onSetSessionDescriptionError();
}
console.log('pc2 setRemoteDescription start');
try {
await pc2.setRemoteDescription(desc);
onSetRemoteSuccess(pc2);
} catch (e) {
onSetSessionDescriptionError();
}
console.log('pc2 createAnswer start');
// Since the 'remote' side has no media stream we need
// to pass in the right constraints in order for it to
// accept the incoming offer of audio and video.
try {
const answer = await pc2.createAnswer();
await onCreateAnswerSuccess(answer);
} catch (e) {
onCreateSessionDescriptionError(e);
}
}
function onSetLocalSuccess(pc) {
console.log(`${getName(pc)} setLocalDescription complete`);
}
function onSetRemoteSuccess(pc) {
console.log(`${getName(pc)} setRemoteDescription complete`);
}
function onSetSessionDescriptionError(error) {
console.log(`Failed to set session description: ${error.toString()}`);
}
function gotRemoteStream(e) {
if (remoteVideo.srcObject !== e.streams[0]) {
remoteVideo.srcObject = e.streams[0];
console.log('pc2 received remote stream');
}
}
async function onCreateAnswerSuccess(desc) {
console.log(`Answer from pc2:\n${desc.sdp}`);
console.log('pc2 setLocalDescription start');
try {
await pc2.setLocalDescription(desc);
onSetLocalSuccess(pc2);
} catch (e) {
onSetSessionDescriptionError(e);
}
console.log('pc1 setRemoteDescription start');
try {
await pc1.setRemoteDescription(desc);
onSetRemoteSuccess(pc1);
} catch (e) {
onSetSessionDescriptionError(e);
}
}
async function onIceCandidate(pc, event) {
try {
await (getOtherPc(pc).addIceCandidate(event.candidate));
onAddIceCandidateSuccess(pc);
} catch (e) {
onAddIceCandidateError(pc, e);
}
console.log(`${getName(pc)} ICE candidate:\n${event.candidate ? event.candidate.candidate : '(null)'}`);
}
function onAddIceCandidateSuccess(pc) {
console.log(`${getName(pc)} addIceCandidate success`);
}
function onAddIceCandidateError(pc, error) {
console.log(`${getName(pc)} failed to add ICE Candidate: ${error.toString()}`);
}
function onIceStateChange(pc, event) {
if (pc) {
console.log(`${getName(pc)} ICE state: ${pc.iceConnectionState}`);
console.log('ICE state change event: ', event);
}
}
function hangup() {
console.log('Ending call');
pc1.close();
pc2.close();
pc1 = null;
pc2 = null;
hangupButton.disabled = true;
callButton.disabled = false;
}

View File

@ -0,0 +1,6 @@
import { io } from 'socket.io-client';
// "undefined" means the URL will be computed from the window.location object
const URL = process.env.NODE_ENV === 'production' ? 'http://192.168.0.131:4000' : 'http://192.168.0.131:4000';
export const socket = io(URL);

View File

@ -0,0 +1,72 @@
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { LoginFormAction } from "@/actions/auth/AuthActions"
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle>Login to your account</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<form action={LoginFormAction}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
name="email"
placeholder="m@example.com"
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel>
<a
href="#"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" name="password" required />
</Field>
<Field>
<Button type="submit">Login</Button>
<Button variant="outline" type="button">
Login with Passkey
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account? <a href="/signup">Sign up</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,76 @@
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function SignupForm({ ...props }: React.ComponentProps<typeof Card>) {
return (
<Card {...props}>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>
Enter your information below to create your account
</CardDescription>
</CardHeader>
<CardContent>
<form>
<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Full Name</FieldLabel>
<Input id="name" type="text" placeholder="John Doe" required />
</Field>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
<FieldDescription>
We&apos;ll use this to contact you. We will not share your email
with anyone else.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" type="password" required />
<FieldDescription>
Must be at least 8 characters long.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="confirm-password">
Confirm Password
</FieldLabel>
<Input id="confirm-password" type="password" required />
<FieldDescription>Please confirm your password.</FieldDescription>
</Field>
<FieldGroup>
<Field>
<Button type="submit">Create Account</Button>
<Button variant="outline" type="button">
Sign up with Google
</Button>
<FieldDescription className="px-6 text-center">
Already have an account? <a href="/login">Sign in</a>
</FieldDescription>
</Field>
</FieldGroup>
</FieldGroup>
</form>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

18
webapp/eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use server";
import { cookies } from "next/headers";
const _URL = `http://localhost:8081/api`;
export async function CallApi<T>(
endPoint: string,
method: "GET" | "POST",
body?: any,
){
const session = (await cookies()).get("ccsession")?.value ?? ("" as string);
const res = await fetch(`${_URL}${endPoint}`, {
method,
headers: {
"content-type": "application/json",
Authorization: `Bearer ${session}`,
},
body: JSON.stringify(body),
}).catch((e) => console.error(e));
if (!((res as Response).status === 200))
throw Error(JSON.stringify(await (res as Response).json()));
return await (res as Response).json();
}

6
webapp/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

14
webapp/next.config.ts Normal file
View File

@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
viewTransition: true
},
output: 'standalone'
};
export default nextConfig;

11875
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
webapp/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "webapp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"mediasoup-client": "^3.18.7",
"next": "^16.2.1",
"peerjs": "^1.5.5",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sdp-transform": "^3.0.0",
"shadcn": "^4.1.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

1
webapp/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
webapp/public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

View File

@ -0,0 +1,60 @@
{
"name": "CoffeeChat",
"short_name": "CoffeeChat",
"start_url": "chat.rootbranch.co.za",
"display": "standalone",
"description": "Start chatting with your communities",
"lang": " en",
"dir": "auto",
"theme_color": "#59168b",
"background_color": "#59168b",
"orientation": "any",
"icons": [
{
"src": "/manifest-icon-192.maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/manifest-icon-192.maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/manifest-icon-512.maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/manifest-icon-512.maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "https://www.pwabuilder.com/assets/screenshots/screen1.png",
"sizes": "2880x1800",
"type": "image/png",
"description": "Communities Home Page"
}
],
"related_applications": [
{
"platform": "windows",
"url": "https://chat.rootbranch.co.za"
}
],
"prefer_related_applications": false,
"shortcuts": [
{
"name": "CoffeeChat",
"url": "chat.rootbranch.co.za",
"description": "Start chatting with your communities"
}
]
}

1
webapp/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
webapp/public/screen1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

BIN
webapp/public/screen2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

View File

@ -0,0 +1,93 @@
// Based off of https://github.com/pwa-builder/PWABuilder/blob/main/docs/sw.js
/*
Welcome to our basic Service Worker! This Service Worker offers a basic offline experience
while also being easily customizeable. You can add in your own code to implement the capabilities
listed below, or change anything else you would like.
Need an introduction to Service Workers? Check our docs here: https://docs.pwabuilder.com/#/home/sw-intro
Want to learn more about how our Service Worker generation works? Check our docs here: https://docs.pwabuilder.com/#/studio/existing-app?id=add-a-service-worker
Did you know that Service Workers offer many more capabilities than just offline?
- Background Sync: https://microsoft.github.io/win-student-devs/#/30DaysOfPWA/advanced-capabilities/06
- Periodic Background Sync: https://web.dev/periodic-background-sync/
- Push Notifications: https://microsoft.github.io/win-student-devs/#/30DaysOfPWA/advanced-capabilities/07?id=push-notifications-on-the-web
- Badges: https://microsoft.github.io/win-student-devs/#/30DaysOfPWA/advanced-capabilities/07?id=application-badges
*/
const HOSTNAME_WHITELIST = [
self.location.hostname,
'fonts.gstatic.com',
'fonts.googleapis.com',
'cdn.jsdelivr.net'
]
// The Util Function to hack URLs of intercepted requests
const getFixedUrl = (req) => {
var now = Date.now()
var url = new URL(req.url)
// 1. fixed http URL
// Just keep syncing with location.protocol
// fetch(httpURL) belongs to active mixed content.
// And fetch(httpRequest) is not supported yet.
url.protocol = self.location.protocol
// 2. add query for caching-busting.
// Github Pages served with Cache-Control: max-age=600
// max-age on mutable content is error-prone, with SW life of bugs can even extend.
// Until cache mode of Fetch API landed, we have to workaround cache-busting with query string.
// Cache-Control-Bug: https://bugs.chromium.org/p/chromium/issues/detail?id=453190
if (url.hostname === self.location.hostname) {
url.search += (url.search ? '&' : '?') + 'cache-bust=' + now
}
return url.href
}
/**
* @Lifecycle Activate
* New one activated when old isnt being used.
*
* waitUntil(): activating ====> activated
*/
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim())
})
/**
* @Functional Fetch
* All network requests are being intercepted here.
*
* void respondWith(Promise<Response> r)
*/
self.addEventListener('fetch', event => {
// Skip some of cross-origin requests, like those for Google Analytics.
// if (HOSTNAME_WHITELIST.indexOf(new URL(event.request.url).hostname) > -1) {
// // Stale-while-revalidate
// // similar to HTTP's stale-while-revalidate: https://www.mnot.net/blog/2007/12/12/stale
// // Upgrade from Jake's to Surma's: https://gist.github.com/surma/eb441223daaedf880801ad80006389f1
// const cached = caches.match(event.request)
// const fixedUrl = getFixedUrl(event.request)
// const fetched = fetch(fixedUrl, { cache: 'no-store' })
// const fetchedCopy = fetched.then(resp => resp.clone())
// // Call respondWith() with whatever we get first.
// // If the fetch fails (e.g disconnected), wait for the cache.
// // If theres nothing in cache, wait for the fetch.
// // If neither yields a response, return offline pages.
// event.respondWith(
// Promise.race([fetched.catch(_ => cached), cached])
// .then(resp => resp || fetched)
// .catch(_ => { /* eat any errors */ })
// )
// // Update the cache with the version we fetched (only for ok status)
// event.waitUntil(
// Promise.all([fetchedCopy, caches.open("pwa-cache")])
// .then(([response, cache]) => response.ok && cache.put(event.request, response))
// .catch(_ => { /* eat any errors */ })
// )
// }
})

1
webapp/public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
webapp/public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
webapp/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
, "server/src/index.cts" ],
"exclude": ["node_modules"]
}