introducing standalone client UI for examples that use Daily prebuilt

This commit is contained in:
Jon Taylor
2024-05-16 17:24:07 +01:00
parent 9447b32f3e
commit 27c019c25a
31 changed files with 4843 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
examples/client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
examples/client/README.md Normal file
View File

@@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

View File

@@ -0,0 +1,2 @@
VITE_SERVER_URL... #optional: if serving frontend independetely from backend (otherwise relative)
VITE_TRANSPORT_ROOM_URL=... #optional: use the same room each time (vs. creating a new one)

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<title>Pipecat Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
{
"name": "pipecatdemo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@daily-co/daily-js": "^0.64.0",
"@daily-co/daily-react": "^0.19.0",
"class-variance-authority": "^0.7.0",
"lucide-react": "^0.378.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recoil": "^0.7.7"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"i": "^0.3.7",
"npm": "^10.8.0",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-plugin-webfont-dl": "^3.9.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

142
examples/client/src/App.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { useState } from "react";
import { useDaily } from "@daily-co/daily-react";
import { Alert } from "./components/alert";
import { Button } from "./components/button";
import { ArrowRight, Loader2 } from "lucide-react";
import { DeviceSelect } from "./components/DeviceSelect";
import Session from "./components/Session";
type State =
| "idle"
| "configuring"
| "requesting_agent"
| "connecting"
| "connected"
| "started"
| "finished"
| "error";
export default function App() {
// Use Daily as our agent transport
const daily = useDaily();
const [state, setState] = useState<State>("idle");
const [error, setError] = useState<string | null>(null);
//const [room, setRoom] = useState<string | null>(null);
async function start() {
if (!daily) return;
setState("requesting_agent");
const serverUrl =
import.meta.env.VITE_SERVER_URL || import.meta.env.BASE_URL;
// Request a bot to join your session
let data;
try {
const res = await fetch(`${serverUrl}/start_bot`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room_url: import.meta.env.VITE_TRANSPORT_ROOM_URL || null,
}),
});
data = await res.json();
if (!res.ok) {
setError(data.detail);
setState("error");
}
} catch (e) {
setError(
`Unable to connect to the server at '${serverUrl}' - is it running?`
);
setState("error");
return;
}
setState("connecting");
await daily.join({
url: data.room_url,
token: data.user_token,
videoSource: false,
startAudioOff: true,
});
setState("connected");
}
/*async function leave() {
await daily?.leave();
setState("finished");
}*/
if (state === "error") {
return (
<Alert intent="danger" title="An error occurred">
{error}
</Alert>
);
}
if (state === "connected") {
return <Session />;
}
const status_text = {
configuring: "Start",
requesting_agent: "Requesting agent...",
connecting: "Connecting to agent...",
};
if (state !== "idle") {
return (
<div className="card card-appear">
<div className="card-inner">
<h1 className="card-header">Configure your devices</h1>
<p className="card-text">
Please configure your microphone and speakers below
</p>
<DeviceSelect />
<Button
key="start"
onClick={() => start()}
disabled={state !== "configuring"}
>
{state !== "configuring" && <Loader2 className="animate-spin" />}
{status_text[state as keyof typeof status_text]}
</Button>
</div>
</div>
);
}
return (
<div className="card card-appear">
<div className="card-inner">
<h1 className="card-header">Pipecat Simple Chatbot</h1>
<p className="card-text">
Please ensure you microphone and speakers are connected and ready to
go
</p>
{import.meta.env.DEV && !import.meta.env.VITE_SERVER_URL && (
<div>
Warning: you have not set a server URL for local development. Please
set <code>VITE_SERVER_URL</code> in{" "}
<code>.env.development.local</code>
</div>
)}
<Button key="next" onClick={() => setState("configuring")}>
Next <ArrowRight />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
<svg width="332" height="192" viewBox="0 0 332 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45.7718 0.769635C50.4477 -0.990844 55.7252 0.330188 59.0204 4.08595L101.936 53H230.064L272.98 4.08595C276.275 0.330188 281.552 -0.990844 286.228 0.769635C290.904 2.53011 294 7.00367 294 12V120H332V144H270V43.8728L244.52 72.9141C242.242 75.5111 238.955 77 235.5 77H96.5C93.0452 77 89.7581 75.5111 87.4796 72.9141L62 43.8728V144H0V120H38V12C38 7.00367 41.0958 2.53011 45.7718 0.769635Z" fill="black"/>
<path d="M270 168H332V192H270V168Z" fill="black"/>
<path d="M0 168H62V192H0V168Z" fill="black"/>
<path d="M128 128C128 136.837 120.837 144 112 144C103.163 144 96 136.837 96 128C96 119.164 103.163 112 112 112C120.837 112 128 119.164 128 128Z" fill="black"/>
<path d="M236 128C236 136.837 228.837 144 220 144C211.163 144 204 136.837 204 128C204 119.164 211.163 112 220 112C228.837 112 236 119.164 236 128Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 937 B

View File

@@ -0,0 +1,31 @@
import {
useAudioLevel,
useAudioTrack,
useLocalSessionId,
} from "@daily-co/daily-react";
import { useCallback, useRef } from "react";
import styles from "./styles.module.css";
export const AudioIndicatorBar: React.FC = () => {
const localSessionId = useLocalSessionId();
const audioTrack = useAudioTrack(localSessionId);
const volRef = useRef<HTMLDivElement>(null);
useAudioLevel(
audioTrack?.persistentTrack,
useCallback((volume) => {
if (volRef.current)
volRef.current.style.width = Math.max(2, volume * 100) + "%";
}, [])
);
return (
<div className={styles.bar}>
<div ref={volRef} />
</div>
);
};
export default AudioIndicatorBar;

View File

@@ -0,0 +1,15 @@
.bar {
background: var(--color-gray-200);
height: 8px;
width: 100%;
border-radius: 999px;
overflow: hidden;
> div {
background: var(--color-green-500);
height: 8px;
width: 0px;
border-radius: 999px;
transition: width 0.1s ease;
}
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect } from "react";
import { DailyMeetingState } from "@daily-co/daily-js";
import { useDaily, useDevices } from "@daily-co/daily-react";
import { Mic, Speaker } from "lucide-react";
import { AudioIndicatorBar } from "../AudioIndicator";
import styles from "./styles.module.css";
import { Alert } from "../alert";
export function DeviceSelect() {
const daily = useDaily();
const {
currentMic,
hasMicError,
micState,
microphones,
setMicrophone,
currentSpeaker,
speakers,
setSpeaker,
} = useDevices();
const handleMicrophoneChange = (value: string) => {
setMicrophone(value);
};
const handleSpeakerChange = (value: string) => {
setSpeaker(value);
};
useEffect(() => {
if (microphones.length > 0 || !daily || daily.isDestroyed()) return;
const meetingState = daily.meetingState();
const meetingStatesBeforeJoin: DailyMeetingState[] = [
"new",
"loading",
"loaded",
];
if (meetingStatesBeforeJoin.includes(meetingState)) {
daily.startCamera({ startVideoOff: true, startAudioOff: false });
}
}, [daily, microphones]);
return (
<div className={styles.deviceSelect}>
{hasMicError && (
<Alert intent="danger" title="Device error">
{micState === "blocked" ? (
<>
Please check your browser and system permissions. Make sure that
this app is allowed to access your microphone and refresh the
page.
</>
) : micState === "in-use" ? (
<>
Your microphone is being used by another app. Please close any
other apps using your microphone and restart this app.
</>
) : micState === "not-found" ? (
<>
No microphone seems to be connected. Please connect a microphone.
</>
) : micState === "not-supported" ? (
<>
This app is not supported on your device. Please update your
software or use a different device.
</>
) : (
<>
There seems to be an issue accessing your microphone. Try
restarting the app or consult a system administrator.
</>
)}
</Alert>
)}
<section className={styles.field}>
<label className={styles.label}>Microphone:</label>
<div className={styles.selectContainer}>
<Mic size={24} />
<select
onChange={(e) => handleMicrophoneChange(e.target.value)}
defaultValue={currentMic?.device.deviceId}
className={styles.deviceSelectField}
>
{microphones.length === 0 ? (
<option value="">Loading devices...</option>
) : (
microphones.map((m) => (
<option key={m.device.deviceId} value={m.device.deviceId}>
{m.device.label}
</option>
))
)}
</select>
</div>
<AudioIndicatorBar />
</section>
<section className={styles.field}>
<label className={styles.label}>Speakers:</label>
<div className={styles.selectContainer}>
<Speaker size={24} />
<select
onChange={(e) => handleSpeakerChange(e.target.value)}
defaultValue={currentSpeaker?.device.deviceId}
className={styles.deviceSelectField}
>
{speakers.length === 0 ? (
<option value="">Loading devices...</option>
) : (
speakers.map((m) => (
<option key={m.device.deviceId} value={m.device.deviceId}>
{m.device.label}
</option>
))
)}
</select>
</div>
</section>
</div>
);
}
export default DeviceSelect;

View File

@@ -0,0 +1,39 @@
.deviceSelect {
display: flex;
flex-flow: column wrap;
width: 100%;
gap: 1rem;
}
.field {
display: flex;
flex-flow: column wrap;
align-items: flex-start;
gap: 0.5rem;
}
.label {
font-weight: 600;
font-size: 0.875rem;
}
.selectContainer {
position: relative;
width: 100%;
> svg {
position: absolute;
size: 24px;
top: 0;
bottom: 0;
margin: auto 0;
left: 0.75rem;
color: var(--color-gray-400);
}
}
.deviceSelectField {
width: 100%;
padding-left: 2.875rem;
margin-top: auto;
}

View File

@@ -0,0 +1,22 @@
import React from "react";
import Status from "./status";
import styles from "./styles.module.css";
import { useParticipantIds } from "@daily-co/daily-react";
export const Agent: React.FC = () => {
const participantIds = useParticipantIds({ filter: "remote" });
const status = participantIds.length > 0 ? "connected" : "connecting";
return (
<div className={styles.agent}>
<div className={styles.agentWindow}></div>
<footer className={styles.agentFooter}>
<Status>User status</Status>
<Status variant={status}>Agent status</Status>
</footer>
</div>
);
};
export default Agent;

View File

@@ -0,0 +1,60 @@
import React, { useEffect, useRef, useState } from "react";
import { LogOut, Settings } from "lucide-react";
import { DailyAudio } from "@daily-co/daily-react";
import DeviceSelect from "../DeviceSelect";
import Agent from "./agent";
import { Button } from "../button";
import styles from "./styles.module.css";
import UserMicBubble from "../UserMicBubble";
export const Session: React.FC = () => {
const [showDevices, setShowDevices] = useState(false);
const [canTalk, setCanTalk] = useState(false);
const modalRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const current = modalRef.current;
// Backdrop doesn't currently work with dialog open, so we use setModal instead
if (current && showDevices) {
current.inert = true;
current.showModal();
current.inert = false;
}
return () => current?.close();
}, [showDevices]);
return (
<>
<dialog ref={modalRef}>
<h2>Configure devices</h2>
<DeviceSelect />
<Button onClick={() => setShowDevices(false)}>Close</Button>
</dialog>
<div className={styles.agentContainer}>
<Agent />
<UserMicBubble active={canTalk} />
<DailyAudio />
</div>
<footer className={styles.footer}>
<div className={styles.controls}>
<Button
variant="ghost"
size="icon"
onClick={() => setShowDevices(true)}
>
<Settings />
</Button>
<Button>
<LogOut size={16} />
End
</Button>
</div>
</footer>
</>
);
};
export default Session;

View File

@@ -0,0 +1,42 @@
import { VariantProps, cva } from "class-variance-authority";
import styles from "./styles.module.css";
const statusVariants = cva(styles.statusIndicator, {
variants: {
variant: {
default: styles.statusDefault,
connecting: styles.statusOrange,
connected: styles.statusGreen,
},
},
defaultVariants: {
variant: "default",
},
});
export interface StatusProps
extends React.HTMLAttributes<HTMLElement>,
VariantProps<typeof statusVariants> {}
const status_text = {
default: "Idle",
connecting: "Connecting",
connected: "Connected",
};
export const Status: React.FC<StatusProps> = ({
children,
variant = "default",
}) => {
return (
<div className={styles.status}>
<span>{children}</span>
<div className={statusVariants({ variant })}>
<span />
{status_text[variant as keyof typeof status_text]}
</div>
</div>
);
};
export default Status;

View File

@@ -0,0 +1,110 @@
.footer {
display: flex;
flex-flow: row nowrap;
margin-top: auto;
align-self: flex-end;
}
.controls {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
gap: 0.75rem;
}
.agentContainer {
flex: 1;
display: flex;
flex-flow: column wrap;
align-items: center;
justify-content: center;
}
.agent {
margin-top: auto;
min-width: 420px;
padding: 0.75rem;
border-radius: var(--borderRadius-lg);
border-radius: 24px;
border: 1px solid #e2e8f0;
background: #fff;
box-shadow: 0px 360px 101px 0px rgba(0, 0, 0, 0),
0px 231px 92px 0px rgba(0, 0, 0, 0), 0px 130px 78px 0px rgba(0, 0, 0, 0.02),
0px 58px 58px 0px rgba(0, 0, 0, 0.03), 0px 14px 32px 0px rgba(0, 0, 0, 0.03);
background-image: linear-gradient(90deg, white, white),
linear-gradient(0deg, var(--color-gray-300), var(--color-gray-200));
background-clip: padding-box, border-box;
background-origin: border-box;
border: 1px solid transparent;
}
.agentWindow {
min-width: 400px;
aspect-ratio: 1;
background: var(--color-gray-200);
border-radius: var(--borderRadius-md);
}
.agentFooter {
margin: 1.5rem 0 0.75rem 0;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
width: 100%;
> :first-child {
border-right: 1px solid var(--color-gray-200);
}
}
.status {
flex: 1;
line-height: 1;
display: flex;
flex-flow: column wrap;
align-items: center;
gap: 0.5rem;
> span {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
line-height: 1;
letter-spacing: 0.7px;
}
}
.statusIndicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 700;
padding: 0.5rem 0.75rem;
border-radius: var(--borderRadius-xs);
> span {
display: block;
width: 9px;
height: 9px;
border-radius: 9px;
background: red;
}
}
.statusDefault {
color: var(--color-gray-500);
background: var(--color-gray-100);
> span {
background: var(--color-gray-400);
}
}
.statusOrange {
color: var(--color-orange-800);
background: var(--color-orange-100);
> span {
background: var(--color-orange-400);
}
}

View File

@@ -0,0 +1,70 @@
import React, { useCallback, useRef, useState, useEffect } from "react";
import {
useAudioLevel,
useAudioTrack,
useLocalSessionId,
useAppMessage,
} from "@daily-co/daily-react";
import { DailyEventObjectAppMessage } from "@daily-co/daily-js";
import { Mic, MicOff } from "lucide-react";
//import { TypewriterEffect } from "../ui/typewriter";
import styles from "./styles.module.css";
const AudioIndicatorBubble: React.FC = () => {
const localSessionId = useLocalSessionId();
const audioTrack = useAudioTrack(localSessionId);
const volRef = useRef<HTMLDivElement>(null);
useAudioLevel(
audioTrack?.persistentTrack,
useCallback((volume) => {
// this volume number will be between 0 and 1
// give it a minimum scale of 0.15 to not completely disappear 👻
if (volRef.current) {
const v = volume * 1.75;
volRef.current.style.transform = `scale(${Math.max(0.1, v)})`;
}
}, [])
);
// Your audio track's audio volume visualized in a small circle,
// whose size changes depending on the volume level
return <div ref={volRef} className={styles.volume} />;
};
interface Props {
active: boolean;
}
export default function UserMicBubble({ active }: Props) {
const [transcription, setTranscription] = useState<string[]>([]);
useAppMessage({
onAppMessage: (e: DailyEventObjectAppMessage<any>) => {
if (e.fromId && e.fromId === "transcription") {
if (e.data.user_id === "" && e.data.is_final) {
setTranscription((t) => [...t, ...e.data.text.split(" ")]);
}
}
},
});
useEffect(() => {
if (active) return;
const t = setTimeout(() => setTranscription([]), 4000);
return () => clearTimeout(t);
}, [active]);
return (
<div className={`${styles.bubbleContainer} ${active ? styles.active : ""}`}>
<div
className={`${styles.micIcon} ${active ? styles.micIconActive : ""}`}
>
{active ? <Mic size={42} /> : <MicOff size={42} />}
{active && <AudioIndicatorBubble />}
</div>
<footer className={styles.transcript}></footer>
</div>
);
}

View File

@@ -0,0 +1,96 @@
.active::after {
opacity: 1;
}
.bubbleContainer {
color: #ffffff;
position: relative;
z-index: 20;
display: flex;
flex-direction: column;
margin: auto;
}
.micIcon {
position: relative;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
width: 120px;
height: 120px;
border-radius: 120px;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto;
z-index: 20;
transition: all 0.5s ease;
border: 6px solid color-mix(in srgb, var(--color-gray-300), transparent 70%);
outline: 6px solid color-mix(in srgb, var(--color-gray-300), transparent 70%);
background-color: var(--color-gray-500);
background-image: radial-gradient(
var(--color-gray-300),
var(--color-gray-400)
);
}
.micIcon svg {
position: relative;
z-index: 20;
opacity: 0.3;
transition: opacity 0.5s ease;
}
@keyframes pulse {
0% {
outline-width: 6px;
@apply outline-teal-500/10;
}
50% {
outline-width: 24px;
@apply outline-teal-500/50;
}
100% {
outline-width: 6px;
@apply outline-teal-500/10;
}
}
.micIconActive {
background-color: var(--color-green-500);
background-image: radial-gradient(
var(--color-green-500),
var(--color-green-600)
);
border: 6px solid color-mix(in srgb, var(--color-green-200), transparent 60%);
outline: 6px solid color-mix(in srgb, var(--color-green-400), transparent 70%);
animation: pulse 2s infinite ease-in-out;
}
.micIconActive svg {
opacity: 1;
}
.transcript {
flex: 0;
align-self: center;
opacity: 0.25;
transition: opacity 1s ease;
transition-delay: 2.5s;
}
.active .transcript {
opacity: 1;
}
.volume {
position: absolute;
overflow: hidden;
inset: 0px;
z-index: 0;
border-radius: 999px;
transition: all 0.1s ease;
transform: scale(0);
opacity: 0.4;
background-color: var(--color-green-200);
}

View File

@@ -0,0 +1,27 @@
import React from "react";
import { cva, VariantProps } from "class-variance-authority";
const alertVariants = cva("alert", {
variants: {
intent: {
info: "alert-info",
danger: "alert-danger",
},
},
defaultVariants: {
intent: "info",
},
});
export interface AlertProps
extends React.HTMLAttributes<HTMLElement>,
VariantProps<typeof alertVariants> {}
export const Alert: React.FC<AlertProps> = ({ children, intent, title }) => {
return (
<div className={alertVariants({ intent })}>
<span>{title}</span>
{children}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import React from "react";
import { cva, VariantProps } from "class-variance-authority";
const buttonVariants = cva("button", {
variants: {
variant: {
primary: "button-primary",
ghost: "button-ghost",
},
size: {
base: "",
icon: "button-icon",
},
},
defaultVariants: {
variant: "primary",
size: "base",
},
});
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button: React.FC<ButtonProps> = ({ variant, size, ...props }) => {
return <button className={buttonVariants({ variant, size })} {...props} />;
};

View File

@@ -0,0 +1,22 @@
import Logo from "./logo";
function Header() {
return (
<header className="header">
<span className="logo-button">
<Logo />
</span>
<nav>
<a href="https://git.new/ai" target="_blank">
GitHub
</a>
<a href="https://discord.gg/pipecat" target="_blank">
Discord
</a>
</nav>
</header>
);
}
export default Header;

View File

@@ -0,0 +1,46 @@
import React from "react";
const Logo: React.FC = () => {
return (
<svg
width="332"
height="192"
viewBox="0 0 332 192"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="logo"
>
<path
d="M45.7718 0.769635C50.4477 -0.990844 55.7252 0.330188 59.0204 4.08595L101.936 53H230.064L272.98 4.08595C276.275 0.330188 281.552 -0.990844 286.228 0.769635C290.904 2.53011 294 7.00367 294 12V120H332V144H270V43.8728L244.52 72.9141C242.242 75.5111 238.955 77 235.5 77H96.5C93.0452 77 89.7581 75.5111 87.4796 72.9141L62 43.8728V144H0V120H38V12C38 7.00367 41.0958 2.53011 45.7718 0.769635Z"
fill="black"
/>
<path d="M270 168H332V192H270V168Z" fill="black" />
<path d="M0 168H62V192H0V168Z" fill="black" />
<g id="eyes_1">
<path
d="M128 128C128 136.837 120.837 144 112 144C103.163 144 96 136.837 96 128C96 119.164 103.163 112 112 112C120.837 112 128 119.164 128 128Z"
fill="black"
/>
<path
d="M236 128C236 136.837 228.837 144 220 144C211.163 144 204 136.837 204 128C204 119.164 211.163 112 220 112C228.837 112 236 119.164 236 128Z"
fill="black"
/>
</g>
<g id="eyes_2" visibility="hidden">
<path
d="M128 128C128 136.837 120.837 144 112 144C103.163 144 96 136.837 96 128C96 119.163 103.163 112 112 112C120.837 112 128 119.163 128 128Z"
stroke="black"
strokeWidth="12"
/>
<path
d="M236 128C236 136.837 228.837 144 220 144C211.163 144 204 136.837 204 128C204 119.163 211.163 112 220 112C228.837 112 236 119.163 236 128Z"
stroke="black"
strokeWidth="12"
/>
</g>
</svg>
);
};
export default Logo;

View File

@@ -0,0 +1,387 @@
:root {
--color-gray-50: #f8fafc;
--color-gray-100: #f1f5f9;
--color-gray-200: #e2e8f0;
--color-gray-300: #cbd5e1;
--color-gray-400: #94a3b8;
--color-gray-500: #64748b;
--color-gray-600: #475569;
--color-gray-700: #334155;
--color-gray-800: #1e293b;
--color-gray-900: #0f172a;
--color-gray-950: #020617;
--color-orange-50: #fff7ed;
--color-orange-100: #ffedd5;
--color-orange-200: #fed7aa;
--color-orange-300: #fdba74;
--color-orange-400: #fb923c;
--color-orange-500: #f97316;
--color-orange-600: #ea580c;
--color-orange-700: #c2410c;
--color-orange-800: #9a3412;
--color-orange-900: #7c2d12;
--color-orange-950: #431407;
--color-green-50: #f0fdf4;
--color-green-100: #dcfce7;
--color-green-200: #bbf7d0;
--color-green-300: #86efac;
--color-green-400: #4ade80;
--color-green-500: #22c55e;
--color-green-600: #16a34a;
--color-green-700: #15803d;
--color-green-800: #166534;
--color-green-900: #14532d;
--color-green-950: #052e16;
--borderRadius-xs: 6px;
--borderRadius-sm: 9px;
--borderRadius-md: 12px;
--borderRadius-lg: 24px;
--input-height: 48px;
--font-sans: "Inter", system-ui, Avenir, Helvetica, Arial, sans-serif;
--font-mono: "Space Mono", monospace;
--font-size-base: 1rem;
--font-size-sm: 0.875rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
}
* {
font-family: var(--font-sans);
}
#root {
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0 auto;
padding: 1rem;
text-align: center;
display: flex;
flex-direction: column;
flex: 1;
}
* {
outline-color: black;
outline-offset: 4px;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
display: flex;
color: var(--color-gray-950);
background-color: var(--color-gray-50);
}
a {
color: var(--color-gray-950);
text-decoration: inherit;
}
a:hover {
text-decoration-line: underline;
}
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
.header {
display: flex;
align-self: flex-start;
align-items: center;
gap: 2rem;
}
.header .logo-button {
box-sizing: border-box;
border: 1px solid var(--color-gray-200);
padding: 0.5rem;
display: flex;
place-items: center;
border-radius: var(--borderRadius-md);
transition: all 0.3s ease;
background: white;
box-shadow: 0px 7px 2px 0px rgba(0, 0, 0, 0),
0px 5px 2px 0px rgba(0, 0, 0, 0.01), 0px 3px 2px 0px rgba(0, 0, 0, 0.03),
0px 1px 1px 0px rgba(0, 0, 0, 0.04), 0px 0px 1px 0px rgba(0, 0, 0, 0.05);
}
.header .logo {
width: 42px;
height: auto;
aspect-ratio: 1;
}
.header nav {
pointer-events: none;
flex-flow: row nowrap;
align-items: center;
gap: 2rem;
display: none;
a {
font-size: 1.125rem;
line-height: 1.75rem;
text-underline-offset: 4px;
text-decoration: underline solid transparent;
transition: text-decoration 0.5s ease;
&:hover {
text-decoration: underline solid var(--color-gray-400);
}
}
}
.header:hover {
.logo {
animation: wiggle 0.2s 1;
}
#eyes_2 {
visibility: visible;
}
#eyes_1 {
visibility: hidden;
}
nav {
display: flex;
pointer-events: all;
display: flex;
animation: fadeIn 0.5s ease;
}
.logo-button {
border-color: var(--color-gray-200);
box-shadow: 0px 100px 28px 0px rgba(0, 0, 0, 0),
0px 64px 26px 0px rgba(0, 0, 0, 0.01),
0px 36px 22px 0px rgba(0, 0, 0, 0.03),
0px 16px 16px 0px rgba(0, 0, 0, 0.04), 0px 4px 9px 0px rgba(0, 0, 0, 0.05);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes wiggle {
0% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
50% {
transform: translateX(5px);
}
75% {
transform: translateX(-5px);
}
100% {
transform: translateX(0);
}
}
.button {
border-radius: var(--borderRadius-md);
border: 1px solid transparent;
padding: 0 1.5rem;
height: var(--input-height);
font-size: 1rem;
font-weight: 600;
font-family: inherit;
background-color: var(--color-gray-950);
color: white;
cursor: pointer;
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
}
.button > svg {
width: 20px;
height: 20px;
}
.button:hover:not(:disabled) {
background-color: var(--color-gray-600);
}
.button:disabled {
cursor: not-allowed;
background-color: var(--color-gray-300);
color: var(--color-gray-100);
}
.button-ghost {
border: 1px solid var(--color-gray-300);
background-color: white;
color: var(--color-gray-950);
}
.button-ghost:hover:not(:disabled) {
background-color: white;
border-color: var(--color-gray-600);
}
.button-icon {
padding: 1rem;
}
.alert {
border: 1px solid black;
border-radius: var(--borderRadius-md);
padding: 1rem;
display: flex;
flex-flow: column wrap;
gap: 2;
font-size: 0.875rem;
span {
font-size: 1rem;
font-weight: bold;
}
}
.alert-danger {
border-color: red;
color: red;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.controls {
}
.card {
padding: 12px;
border-radius: var(--borderRadius-lg);
background: #fff;
box-shadow: 0px 360px 101px 0px rgba(0, 0, 0, 0),
0px 231px 92px 0px rgba(0, 0, 0, 0), 0px 130px 78px 0px rgba(0, 0, 0, 0.02),
0px 58px 58px 0px rgba(0, 0, 0, 0.03), 0px 14px 32px 0px rgba(0, 0, 0, 0.03);
background-image: linear-gradient(90deg, white, white),
linear-gradient(0deg, var(--color-gray-300), var(--color-gray-200));
background-clip: padding-box, border-box;
background-origin: border-box;
border: 1px solid transparent;
}
.card-appear {
animation: appear 0.5s ease-out forwards;
}
.card-inner {
padding: 20px;
max-width: 420px;
display: flex;
gap: 24px;
flex-direction: column;
justify-content: center;
place-items: center;
}
.card-header {
font-size: 1.5rem;
font-weight: 600;
margin: 0px;
}
.card-text {
font-size: 1.125rem;
color: var(--color-gray-500);
text-wrap: pretty;
margin: 0px;
}
@keyframes appear {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
select {
appearance: none;
background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQgNkw4IDEwTDEyIDYiIHN0cm9rZT0iIzY0NzQ4QiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=");
background-repeat: no-repeat;
background-position: right 1rem top 50%;
background-size: 16px auto;
border-radius: var(--borderRadius-md);
border: 1px solid var(--color-gray-300);
padding: 0 1rem;
height: var(--input-height);
font-size: var(--font-size-sm);
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 3rem;
}
dialog {
max-width: 420px;
padding: 2rem;
border-radius: var(--borderRadius-lg);
background: #fff;
box-shadow: 0px 360px 101px 0px rgba(0, 0, 0, 0),
0px 231px 92px 0px rgba(0, 0, 0, 0), 0px 130px 78px 0px rgba(0, 0, 0, 0.02),
0px 58px 58px 0px rgba(0, 0, 0, 0.03), 0px 14px 32px 0px rgba(0, 0, 0, 0.03);
background-image: linear-gradient(90deg, white, white),
linear-gradient(0deg, var(--color-gray-300), var(--color-gray-200));
background-clip: padding-box, border-box;
background-origin: border-box;
border: 1px solid transparent;
animation: appear 0.5s ease-out forwards;
> h2 {
font-size: var(--font-size-xl);
margin: 0;
margin-bottom: 2rem;
}
> button {
margin: 2rem auto 0 auto;
}
}
dialog::backdrop {
background-color: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(5px);
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import Header from "./components/header.tsx";
import { DailyProvider } from "@daily-co/daily-react";
import "./global.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Header />
<main>
<DailyProvider>
<App />
</DailyProvider>
</main>
</React.StrictMode>
);

View File

1
examples/client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import webfontDownload from "vite-plugin-webfont-dl";
export default defineConfig({
plugins: [react(), webfontDownload()],
});

3384
examples/client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff