added examples back

This commit is contained in:
Jon Taylor
2024-05-13 17:09:46 +01:00
parent 27ba50cbbf
commit f1c02f8554
154 changed files with 7918 additions and 1287 deletions

View File

@@ -0,0 +1,90 @@
"use client";
import React, { useState } from "react";
import { useDaily } from "@daily-co/daily-react";
import Setup from "./Setup";
import Story from "./Story";
type State =
| "idle"
| "connecting"
| "connected"
| "started"
| "finished"
| "error";
export default function Call() {
const daily = useDaily();
const [state, setState] = useState<State>("idle");
const [room, setRoom] = useState<string | null>(null);
async function start() {
setState("connecting");
if (!daily) return;
// Create a new room for the story session
try {
const response = await fetch("/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room_url: process.env.NEXT_PUBLIC_ROOM_URL || null,
}),
});
const { room_url, token } = await response.json();
// Keep a reference to the room url for later
setRoom(room_url);
// Join the WebRTC session
await daily.join({
url: room_url,
token,
videoSource: false,
startAudioOff: true,
});
setState("connected");
// Disable local audio, the bot will say hello first
daily.setLocalAudio(false);
// Start the bot
const resp = await fetch("/start", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room_url,
}),
});
setState("started");
} catch (error) {
setState("error");
leave();
}
}
async function leave() {
await daily?.leave();
setState("finished");
}
if (state === "error") {
return <div>An Error occured</div>;
}
if (state === "started") {
return <Story handleLeave={() => leave()} />;
}
return <Setup handleStart={() => start()} />;
}

View File

@@ -0,0 +1,69 @@
import {
useAudioLevel,
useAudioTrack,
useLocalSessionId,
} from "@daily-co/daily-react";
import { useCallback, useRef } from "react";
export const AudioIndicator: 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 className="vol bg-teal-700" ref={volRef} />
<style jsx>{`
.vol {
position: absolute;
overflow: hidden;
inset: 0px;
z-index: 0;
border-radius: 999px;
transition: all 0.1s ease;
transform: scale(0);
}
`}</style>
</>
);
};
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="flex-1 bg-gray-200 h-[8px] rounded-full overflow-hidden">
<div
className="bg-green-500 h-[8px] w-[0] rounded-full transition-all duration-100 ease"
ref={volRef}
/>
</div>
);
};
export default AudioIndicator;

View File

@@ -0,0 +1,139 @@
"use client";
import { useEffect } from "react";
import { DailyMeetingState } from "@daily-co/daily-js";
import { useDaily, useDevices } from "@daily-co/daily-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { IconMicrophone, IconDeviceSpeaker } from "@tabler/icons-react";
import { AudioIndicatorBar } from "../AudioIndicator";
interface Props {}
export default function DevicePicker({}: Props) {
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="flex flex-col gap-5">
<section>
<label className="uppercase text-sm tracking-wider text-gray-500">
Microphone:
</label>
<div className="flex flex-row gap-4 items-center mt-2">
<IconMicrophone size={24} />
<div className="flex flex-col flex-1 gap-3">
<Select onValueChange={handleMicrophoneChange}>
<SelectTrigger className="">
<SelectValue
placeholder={
hasMicError ? "error" : currentMic?.device?.label
}
/>
</SelectTrigger>
<SelectContent>
{hasMicError && (
<option value="error" disabled>
No microphone access.
</option>
)}
{microphones.map((m) => (
<SelectItem key={m.device.deviceId} value={m.device.deviceId}>
{m.device.label}
</SelectItem>
))}
</SelectContent>
</Select>
<AudioIndicatorBar />
</div>
</div>
</section>
<section>
<label className="uppercase text-sm tracking-wider text-gray-500">
Speakers:
</label>
<div className="flex flex-row gap-4 items-center mt-2">
<IconDeviceSpeaker size={24} />
<Select onValueChange={handleSpeakerChange}>
<SelectTrigger className="">
<SelectValue placeholder={currentSpeaker?.device?.label} />
</SelectTrigger>
<SelectContent>
{speakers.map((m) => (
<SelectItem key={m.device.deviceId} value={m.device.deviceId}>
{m.device.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</section>
{hasMicError && (
<div className="error">
{micState === "blocked" ? (
<p>
Please check your browser and system permissions. Make sure that
this app is allowed to access your microphone.
</p>
) : micState === "in-use" ? (
<p>
Your microphone is being used by another app. Please close any
other apps using your microphone and restart this app.
</p>
) : micState === "not-found" ? (
<p>
No microphone seems to be connected. Please connect a microphone.
</p>
) : micState === "not-supported" ? (
<p>
This app is not supported on your device. Please update your
software or use a different device.
</p>
) : (
<p>
There seems to be an issue accessing your microphone. Try
restarting the app or consult a system administrator.
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import {
useDaily,
useLocalSessionId,
useMediaTrack,
} from "@daily-co/daily-react";
import { useCallback } from "react";
import { IconMicrophone, IconMicrophoneOff } from "@tabler/icons-react";
export const MicToggle: React.FC = () => {
const daily = useDaily();
const localSessionId = useLocalSessionId();
const audioTrack = useMediaTrack(localSessionId, "audio");
const isMicMuted =
audioTrack.state === "blocked" || audioTrack.state === "off";
const handleClick = useCallback(() => {
if (!daily) return;
daily.setLocalAudio(isMicMuted);
}, [daily, isMicMuted]);
const text = isMicMuted ? (
<IconMicrophone size={21} />
) : (
<IconMicrophoneOff size={21} />
);
return (
<button className="MicToggle UIButton" onClick={handleClick}>
{text}
</button>
);
};
export default MicToggle;

View File

@@ -0,0 +1,93 @@
import React from "react";
import { Button } from "@/components/ui/button";
import DevicePicker from "@/components/DevicePicker";
import { IconEar, IconLoader2 } from "@tabler/icons-react";
type SetupProps = {
handleStart: () => void;
};
const buttonLabel = {
intro: "Next",
setup: "Let's begin!",
loading: "Joining...",
};
export const Setup: React.FC<SetupProps> = ({ handleStart }) => {
const [state, setState] = React.useState<"intro" | "setup" | "loading">(
"intro"
);
return (
<div className="w-full flex flex-col items-center justify-between">
<div className="bg-white rounded-3xl cardAnim cardShadow p-9 max-w-screen-sm mx-auto outline outline-[5px] outline-gray-600/10 my-auto">
<div className="flex flex-col gap-6">
<h1 className="text-4xl font-bold text-pretty tracking-tighter mb-4">
Welcome to <span className="text-sky-500">Storytime</span>
</h1>
{state === "intro" ? (
<>
<p className="text-gray-600 leading-relaxed text-pretty">
This app demos a voice-controlled storytelling chatbot. It will
start with the bot asking you what kind of story you&apos;d like
to hear (e.g. a fairy tale, a mystery, etc.). After each scene,
the bot will pause to ask for your input. Direct the story any
way you choose!
</p>
<p className="flex flex-row gap-2 text-gray-600 font-medium">
<IconEar size={24} /> For best results, try in a quiet
environment!
</p>
</>
) : (
<>
<p className="text-gray-600 leading-relaxed text-pretty">
Since you&apos;ll be talking to Storybot, we need to make sure
it can hear you! Please configure your microphone and speakers
below.
</p>
<DevicePicker />
</>
)}
<hr className="border-gray-150 my-2" />
<Button
size="lg"
disabled={state === "loading"}
onClick={() => {
if (state === "intro") {
setState("setup");
} else {
setState("loading");
handleStart();
}
}}
>
{state === "loading" && (
<IconLoader2
size={21}
stroke={2}
className="mr-2 h-4 w-4 animate-spin"
/>
)}
{buttonLabel[state]}
</Button>
</div>
</div>
<footer className="flex-0 text-center font-mono text-sm text-gray-100 py-6">
<span className="bg-gray-800/70 px-3 py-1 rounded-md">
Created with{" "}
<a
href="https://git.new/ai"
className="text-violet-300 underline decoration-violet-400 hover:text-violet-100"
>
git.new/ai
</a>
</span>
</footer>
</div>
);
};
export default Setup;

View File

@@ -0,0 +1,79 @@
import React, { useState } from "react";
import {
useDaily,
useParticipantIds,
useAppMessage,
DailyAudio,
} from "@daily-co/daily-react";
import { IconLogout, IconLoader2 } from "@tabler/icons-react";
import VideoTile from "@/components/VideoTile";
import { Button } from "@/components/ui/button";
import UserInputIndicator from "@/components/UserInputIndicator";
import WaveText from "@/components/WaveText";
interface StoryProps {
handleLeave: () => void;
}
const Story: React.FC<StoryProps> = ({ handleLeave }) => {
const daily = useDaily();
const participantIds = useParticipantIds({ filter: "remote" });
const [storyState, setStoryState] = useState<"user" | "assistant">(
"assistant"
);
useAppMessage({
onAppMessage: (e) => {
if (!daily || !e.data?.cue) return;
// Determine the UI state from the cue sent by the bot
if (e.data?.cue === "user_turn") {
// Delay enabling local mic input to avoid feedback from LLM
setTimeout(() => daily.setLocalAudio(true), 500);
setStoryState("user");
} else {
daily.setLocalAudio(false);
setStoryState("assistant");
}
},
});
return (
<div className="w-full flex flex-col flex-1 self-stretch">
{/* Absolute elements */}
<div className="absolute top-20 w-full text-center z-50">
<WaveText active={storyState === "user"} />
</div>
<header className="flex absolute top-0 w-full z-50 p-6 justify-end">
<Button variant="secondary" onClick={() => handleLeave()}>
<IconLogout size={21} className="mr-2" />
Exit
</Button>
</header>
<div className="absolute inset-0 bg-gray-800 bg-opacity-90 z-10 fade-in"></div>
{/* Static elements */}
<div className="relative z-20 flex-1 flex items-center justify-center">
{participantIds.length >= 1 ? (
<VideoTile
sessionId={participantIds[0]}
inactive={storyState === "user"}
/>
) : (
<span className="p-3 rounded-full bg-gray-900/60 animate-pulse">
<IconLoader2
size={42}
stroke={2}
className="animate-spin text-white z-20 self-center"
/>
</span>
)}
<DailyAudio />
</div>
<UserInputIndicator active={storyState === "user"} />
</div>
);
};
export default Story;

View File

@@ -0,0 +1,42 @@
.container{
position: absolute;
color:white;
z-index: 50;
margin: 0 auto;
display: flex;
flex-direction: column;
@apply gap-4 inset-6;
align-items: center;
justify-content: end;
text-align: center;
}
.transcript{
@apply font-semibold;
}
.transcript span{
box-decoration-break: clone;
@apply bg-gray-900/80 rounded-md px-4 py-2;
}
.sentence{
opacity: 1;
animation: fadeOut 2.5s linear forwards;
animation-delay: 1s;
}
@keyframes fadeOut {
0% {
opacity: 1;
transform: scale(1);
}
20% {
transform: scale(1);
filter: blur(0);
}
100% {
transform: scale(0.8) translateY(-50%);
filter: blur(25px);
opacity: 0;
}
}

View File

@@ -0,0 +1,55 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { useAppMessage } from "@daily-co/daily-react";
import { DailyEventObjectAppMessage } from "@daily-co/daily-js";
import styles from "./StoryTranscript.module.css";
export default function StoryTranscript() {
const [partialText, setPartialText] = useState<string>("");
const [sentences, setSentences] = useState<string[]>([]);
const intervalRef = useRef<any | null>(null);
useEffect(() => {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
if (sentences.length > 2) {
setSentences((s) => s.slice(1));
}
}, 2500);
return () => clearInterval(intervalRef.current);
}, [sentences]);
useAppMessage({
onAppMessage: (e: DailyEventObjectAppMessage<any>) => {
if (e.fromId && e.fromId === "transcription") {
// Check for LLM transcripts only
if (e.data.user_id !== "") {
setPartialText(e.data.text);
if (e.data.is_final) {
setPartialText("");
setSentences((s) => [...s, e.data.text]);
}
}
}
},
});
return (
<div className={styles.container}>
{sentences.map((sentence, index) => (
<p key={index} className={`${styles.transcript} ${styles.sentence}`}>
<span>{sentence}</span>
</p>
))}
{partialText && (
<p className={`${styles.transcript}`}>
<span>{partialText}</span>
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,82 @@
.panel{
width: 100%;
pointer-events: none;
color: #ffffff;
text-align: center;
position: relative;
@apply pb-8;
}
.panel::after{
content: "";
position: absolute;
inset: 0px;
z-index: 10;
opacity: 0;
transition: all 0.3s ease;
@apply bg-gradient-to-t from-gray-950 to-transparent;
}
.active::after{ opacity: 1;}
.micIcon{
position: relative;
width: 120px;
height: 120px;
border-radius: 120px;
border: 6px solid;
outline: 6px solid;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto;
z-index: 20;
transition: all 0.5s ease;
@apply bg-gray-700/60 border-gray-600 outline-gray-900/20;
}
.micIcon svg{
position: relative;
z-index: 20;
opacity: 0.25;
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{
@apply bg-teal-950 border-teal-500 outline-teal-500/20;
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;
@apply bg-gray-900/90 font-medium py-1 px-2 rounded-sm mt-4;
}
.active .transcript{
opacity: 1;
}

View File

@@ -0,0 +1,48 @@
import React, { useState, useEffect } from "react";
import { useAppMessage } from "@daily-co/daily-react";
import { DailyEventObjectAppMessage } from "@daily-co/daily-js";
import styles from "./UserInputIndicator.module.css";
import { IconMicrophone } from "@tabler/icons-react";
import { TypewriterEffect } from "../ui/typewriter";
import AudioIndicator from "../AudioIndicator";
interface Props {
active: boolean;
}
export default function UserInputIndicator({ 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.panel} ${active ? styles.active : ""}`}>
<div className="relative z-20 flex flex-col">
<div
className={`${styles.micIcon} ${active ? styles.micIconActive : ""}`}
>
<IconMicrophone size={42} />
{active && <AudioIndicator />}
</div>
<footer className={styles.transcript}>
<TypewriterEffect words={transcription} />
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
.container{
position: relative;
animation: fadeIn 3s ease;
transition: all 3s ease-out;
}
.videoTile{
@apply bg-gray-950;
width: 560px;
height: 560px;
mask-image: url('/alpha-mask.gif');
mask-size: cover;
mask-repeat: no-repeat;
margin:0 auto;
z-index: 10;
position: relative;
}
.inactive{
opacity: 0.7;
filter:blur(3px);
transform: scale(0.95)
}
@keyframes fadeIn {
0% {
filter: blur(100px);
opacity: 0;
}
100% {
filter: blur(0px);
opacity: 1;
}
}

View File

@@ -0,0 +1,27 @@
import React from "react";
import styles from "./VideoTile.module.css";
import { DailyVideo } from "@daily-co/daily-react";
import StoryTranscript from "@/components/StoryTranscript";
interface Props {
sessionId: string;
inactive: boolean;
}
const VideoTile = ({ sessionId, inactive }: Props) => {
return (
<div className={`${styles.container} ${inactive ? styles.inactive : ""} `}>
<StoryTranscript />
<div className={styles.videoTile}>
<DailyVideo
sessionId={sessionId}
type={"video"}
className="aspect-square"
/>
</div>
</div>
);
};
export default VideoTile;

View File

@@ -0,0 +1,68 @@
.waveText{
color: white;
text-shadow: 3px 3px 0px rgba(0,0,0,0.5);
opacity: 0;
transition: opacity 2s ease;
position: relative;
@apply text-4xl font-bold;
}
.active{
opacity: 1;
}
@keyframes jump {
0% {
opacity: 0.5;
transform:translateY(0px)
}
50% {
opacity: 1;
transform:translateY(-30px);
}
100% {
opacity: 0.55;
transform:translateY(0px)
}
}
.waveText span{
display:inline-block;
animation:jump 2s infinite ease-in-out;
}
.waveText span:nth-child(1) {
animation-delay:0s;
}
.waveText span:nth-child(1) {
animation-delay:0.1s;
}
.waveText span:nth-child(2) {
animation-delay:0.2s;
}
.waveText span:nth-child(3) {
animation-delay:0.3s;
}
.waveText span:nth-child(4) {
animation-delay:0.4s;
}
.waveText span:nth-child(5) {
animation-delay:0.5s;
}
.waveText span:nth-child(6) {
animation-delay:0.6s;
}
.waveText span:nth-child(7) {
animation-delay:0.7s;
}
.waveText span:nth-child(8) {
animation-delay:0.8s;
}
.waveText span:nth-child(9) {
animation-delay:0.9s;
}
.waveText span:nth-child(10) {
animation-delay:1s;
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import styles from "./WaveText.module.css";
interface Props {
active: boolean;
}
export default function WaveText({ active }: Props) {
return (
<div className={`${styles.waveText} ${active ? styles.active : ""}`}>
<span>W</span>
<span>h</span>
<span>a</span>
<span>t</span>
<span>&nbsp;&nbsp;</span>
<span>n</span>
<span>e</span>
<span>x</span>
<span>t</span>
<span>?</span>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/app/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-11 rounded-lg px-6 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-12 rounded-xl px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { cn } from "@/app/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex gap-3 h-12 w-full items-center justify-between rounded-xl border border-input bg-background px-4 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<IconChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<IconChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<IconChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<IconCheck className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,61 @@
"use client";
import { cn } from "@/app/utils";
import { motion } from "framer-motion";
export const TypewriterEffect = ({
words,
className,
}: {
words: string[];
className?: string;
cursorClassName?: string;
}) => {
const renderWords = () => {
return (
<div>
{words.map((word, idx) => {
return (
<div key={`word-${idx}`} className="inline-block">
{word.split("").map((char, index) => (
<span key={`char-${index}`}>{char}</span>
))}
&nbsp;
</div>
);
})}
</div>
);
};
return (
<div className={cn("flex", className)}>
{words.length < 1 ? (
<span>...</span>
) : (
<motion.div
className="overflow-hidden"
initial={{
width: "0%",
}}
whileInView={{
width: "fit-content",
}}
transition={{
duration: 0.5,
ease: "linear",
delay: 1,
}}
>
<div
style={{
whiteSpace: "nowrap",
}}
>
{renderWords()}
</div>
</motion.div>
)}
</div>
);
};