init
This commit is contained in:
3
.env
Normal file
3
.env
Normal file
@@ -0,0 +1,3 @@
|
||||
HEYGEN_API_KEY=your Heygen API key
|
||||
OPENAI_API_KEY=your OpenAI API key
|
||||
NEXT_PUBLIC_OPENAI_API_KEY=your OpenAI API key
|
||||
20
.eslintignore
Normal file
20
.eslintignore
Normal file
@@ -0,0 +1,20 @@
|
||||
.now/*
|
||||
*.css
|
||||
.changeset
|
||||
dist
|
||||
esm/*
|
||||
public/*
|
||||
tests/*
|
||||
scripts/*
|
||||
*.config.js
|
||||
.DS_Store
|
||||
node_modules
|
||||
coverage
|
||||
.next
|
||||
build
|
||||
!.commitlintrc.cjs
|
||||
!.lintstagedrc.cjs
|
||||
!jest.config.js
|
||||
!plopfile.js
|
||||
!react-shim.js
|
||||
!tsup.config.ts
|
||||
92
.eslintrc.json
Normal file
92
.eslintrc.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/eslintrc.json",
|
||||
"env": {
|
||||
"browser": false,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:react/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:jsx-a11y/recommended"
|
||||
],
|
||||
"plugins": ["react", "unused-imports", "import", "@typescript-eslint", "jsx-a11y", "prettier"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"react/prop-types": "off",
|
||||
"react/jsx-uses-react": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"jsx-a11y/click-events-have-key-events": "warn",
|
||||
"jsx-a11y/interactive-supports-focus": "warn",
|
||||
"prettier/prettier": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"unused-imports/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": false,
|
||||
"argsIgnorePattern": "^_.*?$"
|
||||
}
|
||||
],
|
||||
"import/order": [
|
||||
"warn",
|
||||
{
|
||||
"groups": [
|
||||
"type",
|
||||
"builtin",
|
||||
"object",
|
||||
"external",
|
||||
"internal",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index"
|
||||
],
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "~/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"newlines-between": "always"
|
||||
}
|
||||
],
|
||||
"react/self-closing-comp": "warn",
|
||||
"react/jsx-sort-props": [
|
||||
"warn",
|
||||
{
|
||||
"callbacksLast": true,
|
||||
"shorthandFirst": true,
|
||||
"noSortAlphabetically": false,
|
||||
"reservedFirst": true
|
||||
}
|
||||
],
|
||||
"padding-line-between-statements": [
|
||||
"warn",
|
||||
{"blankLine": "always", "prev": "*", "next": "return"},
|
||||
{"blankLine": "always", "prev": ["const", "let", "var"], "next": "*"},
|
||||
{
|
||||
"blankLine": "any",
|
||||
"prev": ["const", "let", "var"],
|
||||
"next": ["const", "let", "var"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
53
README.md
Normal file
53
README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Next.js & NextUI Template
|
||||
|
||||
This is a template for creating applications using Next.js 14 (app directory) and NextUI (v2).
|
||||
|
||||
[Try it on CodeSandbox](https://githubbox.com/nextui-org/next-app-template)
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- [Next.js 14](https://nextjs.org/docs/getting-started)
|
||||
- [NextUI v2](https://nextui.org/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Tailwind Variants](https://tailwind-variants.org)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Framer Motion](https://www.framer.com/motion/)
|
||||
- [next-themes](https://github.com/pacocoursey/next-themes)
|
||||
|
||||
## How to Use
|
||||
|
||||
### Use the template with create-next-app
|
||||
|
||||
To create a new project based on this template using `create-next-app`, run the following command:
|
||||
|
||||
```bash
|
||||
npx create-next-app -e https://github.com/nextui-org/next-app-template
|
||||
```
|
||||
|
||||
### Install dependencies
|
||||
|
||||
You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run the development server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Setup pnpm (optional)
|
||||
|
||||
If you are using `pnpm`, you need to add the following code to your `.npmrc` file:
|
||||
|
||||
```bash
|
||||
public-hoist-pattern[]=*@nextui-org/*
|
||||
```
|
||||
|
||||
After modifying the `.npmrc` file, you need to run `pnpm install` again to ensure that the dependencies are installed correctly.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT license](https://github.com/nextui-org/next-app-template/blob/main/LICENSE).
|
||||
16
app/api/chat/route.ts
Normal file
16
app/api/chat/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { streamText } from "ai";
|
||||
|
||||
// Allow streaming responses up to 30 seconds
|
||||
export const maxDuration = 30;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
|
||||
const result = await streamText({
|
||||
model: openai("gpt-4-turbo"),
|
||||
messages,
|
||||
});
|
||||
|
||||
return result.toAIStreamResponse();
|
||||
}
|
||||
30
app/api/get-access-token/route.ts
Normal file
30
app/api/get-access-token/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
const HEYGEN_API_KEY = process.env.HEYGEN_API_KEY;
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
if (!HEYGEN_API_KEY) {
|
||||
throw new Error("API key is missing from .env");
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
"https://api.heygen.com/v1/streaming.create_token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": HEYGEN_API_KEY,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
return new Response(data.data.token, {
|
||||
status: 200,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrieving access token:", error);
|
||||
|
||||
return new Response("Failed to retrieve access token", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
31
app/error.tsx
Normal file
31
app/error.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
/* eslint-disable no-console */
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong!</h2>
|
||||
<button
|
||||
onClick={
|
||||
// Attempt to recover by trying to re-render the segment
|
||||
() => reset()
|
||||
}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
app/layout.tsx
Normal file
59
app/layout.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import "@/styles/globals.css";
|
||||
import clsx from "clsx";
|
||||
import { Metadata, Viewport } from "next";
|
||||
|
||||
import { Providers } from "./providers";
|
||||
|
||||
import { Fira_Code as FontMono, Inter as FontSans } from "next/font/google";
|
||||
import NavBar from "@/components/NavBar";
|
||||
|
||||
const fontSans = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const fontMono = FontMono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "HeyGen Streaming Avatar SDK Demo",
|
||||
template: `%s - HeyGen Streaming Avatar SDK Demo`,
|
||||
},
|
||||
icons: {
|
||||
icon: "/heygen-logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
suppressHydrationWarning
|
||||
lang="en"
|
||||
className={`${fontSans.variable} ${fontMono.variable} font-sans`}
|
||||
>
|
||||
<head />
|
||||
<body className={clsx("min-h-screen bg-background antialiased")}>
|
||||
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
|
||||
<main className="relative flex flex-col h-screen">
|
||||
<NavBar />
|
||||
{children}
|
||||
</main>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
37
app/page.tsx
Normal file
37
app/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import NavBar from "@/components/NavBar";
|
||||
import StreamingAvatar from "@/components/StreamingAvatar";
|
||||
import StreamingAvatarCode from "@/components/StreamingAvatarCode";
|
||||
import { Tab, Tabs } from "@nextui-org/react";
|
||||
|
||||
export default function App() {
|
||||
const tabs = [
|
||||
{
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
content: <StreamingAvatar />,
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
label: "Code",
|
||||
content: <StreamingAvatarCode />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex flex-col">
|
||||
<div className="w-[900px] flex flex-col items-start justify-start gap-5 mx-auto pt-6 pb-20">
|
||||
<div className="w-full">
|
||||
<Tabs items={tabs}>
|
||||
{(items) => (
|
||||
<Tab key={items.id} title={items.label}>
|
||||
{items.content}
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
app/providers.tsx
Normal file
22
app/providers.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { NextUIProvider } from "@nextui-org/system";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
themeProps?: ThemeProviderProps;
|
||||
}
|
||||
|
||||
export function Providers({ children, themeProps }: ProvidersProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<NextUIProvider navigate={router.push}>
|
||||
<NextThemesProvider {...themeProps}>{children}</NextThemesProvider>
|
||||
</NextUIProvider>
|
||||
);
|
||||
}
|
||||
81
components/Icons.tsx
Normal file
81
components/Icons.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
export function HeyGenLogo() {
|
||||
return <img src="/heygen-logo.png" className="h-8" alt="HeyGen Logo" />;
|
||||
}
|
||||
|
||||
type IconSvgProps = {
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function GithubIcon({
|
||||
size = 24,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}: IconSvgProps) {
|
||||
return (
|
||||
<svg
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonFilledIcon({
|
||||
size = 24,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}: IconSvgProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
height={size || height}
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunFilledIcon({
|
||||
size = 24,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}: IconSvgProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
height={size || height}
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
|
||||
<path d="M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
46
components/NavBar.tsx
Normal file
46
components/NavBar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Link,
|
||||
Navbar,
|
||||
NavbarBrand,
|
||||
NavbarContent,
|
||||
NavbarItem,
|
||||
} from "@nextui-org/react";
|
||||
import { GithubIcon, HeyGenLogo } from "./Icons";
|
||||
import { ThemeSwitch } from "./ThemeSwitch";
|
||||
|
||||
export default function NavBar() {
|
||||
return (
|
||||
<Navbar>
|
||||
<NavbarBrand>
|
||||
<Link isExternal aria-label="HeyGen" href="https://app.heygen.com/">
|
||||
<HeyGenLogo />
|
||||
</Link>
|
||||
<div className="bg-gradient-to-br from-sky-300 to-indigo-500 bg-clip-text ml-4">
|
||||
<p className="text-xl font-semibold text-transparent">
|
||||
HeyGen Streaming Avatar SDK NextJS Demo
|
||||
</p>
|
||||
</div>
|
||||
</NavbarBrand>
|
||||
<NavbarContent justify="center">
|
||||
<NavbarItem className="flex flex-row items-center gap-10">
|
||||
<Link
|
||||
color="foreground"
|
||||
href="https://docs.heygen.com/reference/new-session-copy"
|
||||
>
|
||||
API Docs
|
||||
</Link>
|
||||
<Link
|
||||
isExternal
|
||||
aria-label="Github"
|
||||
href="https://github.com/HeyGen-Official/StreamingAvatarSDK"
|
||||
className="flex flex-row justify-center gap-2 text-foreground"
|
||||
>
|
||||
<GithubIcon className="text-default-500" />
|
||||
SDK Github
|
||||
</Link>
|
||||
<ThemeSwitch />
|
||||
</NavbarItem>
|
||||
</NavbarContent>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
332
components/StreamingAvatar.tsx
Normal file
332
components/StreamingAvatar.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
Configuration,
|
||||
NewSessionData,
|
||||
StreamingAvatarApi,
|
||||
} from "@heygen/streaming-avatar";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
Divider,
|
||||
Input,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
} from "@nextui-org/react";
|
||||
import { useChat } from "ai/react";
|
||||
import OpenAI from "openai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import StreamingAvatarTextInput from "./StreamingAvatarTextInput";
|
||||
import {
|
||||
Camera,
|
||||
Microphone,
|
||||
MicrophoneSlash,
|
||||
MicrophoneStage,
|
||||
Record,
|
||||
} from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
|
||||
export default function StreamingAvatar() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stream, setStream] = useState<MediaStream>();
|
||||
const [debug, setDebug] = useState<string>();
|
||||
const [avatarId, setAvatarId] = useState<string>("");
|
||||
const [voiceId, setVoiceId] = useState<string>("");
|
||||
const [data, setData] = useState<NewSessionData>();
|
||||
const [text, setText] = useState<string>("");
|
||||
const [initialized, setInitialized] = useState(false); // Track initialization
|
||||
const [recording, setRecording] = useState(false); // Track recording state
|
||||
const mediaStream = useRef<HTMLVideoElement>(null);
|
||||
const avatar = useRef<StreamingAvatarApi | null>(null);
|
||||
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
||||
const audioChunks = useRef<Blob[]>([]);
|
||||
const { input, setInput, isLoading, handleSubmit } = useChat({
|
||||
onFinish: async (message) => {
|
||||
console.log("ChatGPT Response:", message);
|
||||
|
||||
if (!initialized || !avatar.current) {
|
||||
setDebug("Avatar API not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
//send the ChatGPT response to the Streaming Avatar
|
||||
await avatar.current
|
||||
.speak({
|
||||
taskRequest: { text: message.content, sessionId: data?.sessionId },
|
||||
})
|
||||
.catch((e) => {
|
||||
setDebug(e.message);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function fetchAccessToken() {
|
||||
try {
|
||||
const response = await fetch("/api/get-access-token", {
|
||||
method: "POST",
|
||||
});
|
||||
const token = await response.text();
|
||||
console.log("Access Token:", token); // Log the token to verify
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error("Error fetching access token:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
setLoading(true);
|
||||
await updateToken();
|
||||
if (!avatar.current) {
|
||||
setDebug("Avatar API is not initialized");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await avatar.current.createStartAvatar(
|
||||
{
|
||||
newSessionRequest: {
|
||||
quality: "low",
|
||||
avatarName: avatarId,
|
||||
voice: { voiceId: voiceId },
|
||||
},
|
||||
},
|
||||
setDebug
|
||||
);
|
||||
setData(res);
|
||||
setStream(avatar.current.mediaStream);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Error starting avatar session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateToken() {
|
||||
const newToken = await fetchAccessToken();
|
||||
console.log("Updating Access Token:", newToken); // Log token for debugging
|
||||
avatar.current = new StreamingAvatarApi(
|
||||
new Configuration({ accessToken: newToken })
|
||||
);
|
||||
|
||||
const startTalkCallback = (e: any) => {
|
||||
console.log("Avatar started talking", e);
|
||||
};
|
||||
|
||||
const stopTalkCallback = (e: any) => {
|
||||
console.log("Avatar stopped talking", e);
|
||||
};
|
||||
|
||||
console.log("Adding event handlers:", avatar.current);
|
||||
avatar.current.addEventHandler("avatar_start_talking", startTalkCallback);
|
||||
avatar.current.addEventHandler("avatar_stop_talking", stopTalkCallback);
|
||||
|
||||
setInitialized(true);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!initialized || !avatar.current) {
|
||||
setDebug("Avatar API not initialized");
|
||||
return;
|
||||
}
|
||||
await avatar.current.stopAvatar(
|
||||
{ stopSessionRequest: { sessionId: data?.sessionId } },
|
||||
setDebug
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSpeak() {
|
||||
if (!initialized || !avatar.current) {
|
||||
setDebug("Avatar API not initialized");
|
||||
return;
|
||||
}
|
||||
await avatar.current
|
||||
.speak({ taskRequest: { text: text, sessionId: data?.sessionId } })
|
||||
.catch((e) => {
|
||||
setDebug(e.message);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
const newToken = await fetchAccessToken();
|
||||
console.log("Initializing with Access Token:", newToken); // Log token for debugging
|
||||
avatar.current = new StreamingAvatarApi(
|
||||
new Configuration({ accessToken: newToken, jitterBuffer: 200 })
|
||||
);
|
||||
setInitialized(true); // Set initialized to true
|
||||
}
|
||||
init();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (stream && mediaStream.current) {
|
||||
mediaStream.current.srcObject = stream;
|
||||
mediaStream.current.onloadedmetadata = () => {
|
||||
mediaStream.current!.play();
|
||||
setDebug("Playing");
|
||||
};
|
||||
}
|
||||
}, [mediaStream, stream]);
|
||||
|
||||
function startRecording() {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.then((stream) => {
|
||||
mediaRecorder.current = new MediaRecorder(stream);
|
||||
mediaRecorder.current.ondataavailable = (event) => {
|
||||
audioChunks.current.push(event.data);
|
||||
};
|
||||
mediaRecorder.current.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunks.current, {
|
||||
type: "audio/wav",
|
||||
});
|
||||
audioChunks.current = [];
|
||||
transcribeAudio(audioBlob);
|
||||
};
|
||||
mediaRecorder.current.start();
|
||||
setRecording(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error accessing microphone:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder.current) {
|
||||
mediaRecorder.current.stop();
|
||||
setRecording(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function transcribeAudio(audioBlob: Blob) {
|
||||
try {
|
||||
// Convert Blob to File
|
||||
const audioFile = new File([audioBlob], "recording.wav", {
|
||||
type: "audio/wav",
|
||||
});
|
||||
const response = await openai.audio.transcriptions.create({
|
||||
model: "whisper-1",
|
||||
file: audioFile,
|
||||
});
|
||||
const transcription = response.text;
|
||||
console.log("Transcription: ", transcription);
|
||||
setInput(transcription);
|
||||
} catch (error) {
|
||||
console.error("Error transcribing audio:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardBody className="h-[500px] flex flex-col justify-center items-center">
|
||||
{stream ? (
|
||||
<div className="h-[500px] w-[900px] justify-center items-center flex rounded-lg overflow-hidden">
|
||||
<video
|
||||
ref={mediaStream}
|
||||
autoPlay
|
||||
playsInline
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
) : !loading ? (
|
||||
<div className="h-full justify-center items-center flex flex-col gap-4 w-96 self-center">
|
||||
<Input
|
||||
value={avatarId}
|
||||
onChange={(e) => setAvatarId(e.target.value)}
|
||||
placeholder="Custom Avatar ID (optional)"
|
||||
/>
|
||||
<Input
|
||||
value={voiceId}
|
||||
onChange={(e) => setVoiceId(e.target.value)}
|
||||
placeholder="Custom Voice ID (optional)"
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={start}
|
||||
className="bg-gradient-to-tr from-indigo-500 to-indigo-300 w-full text-white"
|
||||
variant="shadow"
|
||||
>
|
||||
Start session
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Spinner size="lg" color="default" />
|
||||
)}
|
||||
</CardBody>
|
||||
<Divider />
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<StreamingAvatarTextInput
|
||||
label="Repeat"
|
||||
placeholder="Type something for the avatar to repeat"
|
||||
input={text}
|
||||
onSubmit={handleSpeak}
|
||||
setInput={setText}
|
||||
disabled={!stream}
|
||||
/>
|
||||
<StreamingAvatarTextInput
|
||||
label="Chat"
|
||||
placeholder="Chat with the avatar (uses ChatGPT)"
|
||||
input={input}
|
||||
onSubmit={() => {
|
||||
if (!input) {
|
||||
setDebug("Please enter text to send to ChatGPT");
|
||||
return;
|
||||
}
|
||||
handleSubmit();
|
||||
}}
|
||||
setInput={setInput}
|
||||
endContent={
|
||||
<Tooltip
|
||||
content={!recording ? "Start recording" : "Stop recording"}
|
||||
>
|
||||
<Button
|
||||
onClick={!recording ? startRecording : stopRecording}
|
||||
isDisabled={!stream}
|
||||
isIconOnly
|
||||
className={clsx(
|
||||
"mr-4 text-white",
|
||||
!recording
|
||||
? "bg-gradient-to-tr from-indigo-500 to-indigo-300"
|
||||
: ""
|
||||
)}
|
||||
size="sm"
|
||||
variant="shadow"
|
||||
>
|
||||
{!recording ? (
|
||||
<Microphone size={20} />
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute h-full w-full bg-gradient-to-tr from-indigo-500 to-indigo-300 animate-pulse -z-10"></div>
|
||||
<MicrophoneStage size={20} />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
disabled={!stream}
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<p className="font-mono text-right">
|
||||
<span className="font-bold">Console:</span>
|
||||
<br />
|
||||
{debug}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
components/StreamingAvatarCode.tsx
Normal file
98
components/StreamingAvatarCode.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Card, CardBody } from "@nextui-org/react";
|
||||
import { langs } from "@uiw/codemirror-extensions-langs";
|
||||
import ReactCodeMirror from "@uiw/react-codemirror";
|
||||
|
||||
export default function StreamingAvatarCode() {
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<p>This SDK supports the following behavior:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<div className="flex flex-row gap-2">
|
||||
<p className="text-indigo-400 font-semibold">Start:</p> Start the
|
||||
streaming avatar session
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="flex flex-row gap-2">
|
||||
<p className="text-indigo-400 font-semibold">Close:</p> Close the
|
||||
streaming avatar session
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="flex flex-row gap-2">
|
||||
<p className="text-indigo-400 font-semibold">Speak:</p> Repeat the
|
||||
input
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<ReactCodeMirror
|
||||
editable={false}
|
||||
extensions={[langs.typescript()]}
|
||||
height="700px"
|
||||
theme="dark"
|
||||
value={TEXT}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TEXT = `
|
||||
export default function App() {
|
||||
// Media stream used by the video player to display the avatar
|
||||
const [stream, setStream] = useState<MediaStream> ();
|
||||
const mediaStream = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Instantiate the streaming avatar api using your access token
|
||||
const avatar = useRef(new StreamingAvatarApi(
|
||||
new Configuration({accessToken: '<REPLACE_WITH_ACCESS_TOKEN>'})
|
||||
));
|
||||
|
||||
// State holding streaming avatar session data
|
||||
const [sessionData, setSessionData] = useState<NewSessionData>();
|
||||
|
||||
// Function to start the streaming avatar session
|
||||
async function start(){
|
||||
const res = await avatar.current.createStartAvatar(
|
||||
{ newSessionRequest:
|
||||
|
||||
// Define the session variables during creation
|
||||
{ quality: "medium", // low, medium, high
|
||||
avatarName: <REPLACE_WITH_AVATAR_ID>,
|
||||
voice:{voiceId: <REPLACE_WITH_VOICE_ID>}
|
||||
}
|
||||
|
||||
});
|
||||
setSessionData(res);
|
||||
}
|
||||
|
||||
// Function to stop the streaming avatar session
|
||||
async function stop(){
|
||||
await avatar.current.stopAvatar({stopSessionRequest: {sessionId: sessionData?.sessionId}});
|
||||
}
|
||||
|
||||
// Function which passes in text to the avatar to repeat
|
||||
async function handleSpeak(){
|
||||
await avatar.current.speak({taskRequest: {text: <TEXT_TO_SAY>, sessionId: sessionData?.sessionId}}).catch((e) => {
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(()=>{
|
||||
// Handles the display of the streaming avatar
|
||||
if(stream && mediaStream.current){
|
||||
mediaStream.current.srcObject = stream;
|
||||
mediaStream.current.onloadedmetadata = () => {
|
||||
mediaStream.current!.play();
|
||||
}
|
||||
}
|
||||
}, [mediaStream, stream])
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<video playsInline autoPlay width={500} ref={mediaStream}/>
|
||||
</div>
|
||||
}`;
|
||||
67
components/StreamingAvatarTextInput.tsx
Normal file
67
components/StreamingAvatarTextInput.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Input, Tooltip } from "@nextui-org/react";
|
||||
import { Airplane, ArrowRight, PaperPlaneRight } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface StreamingAvatarTextInputProps {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
input: string;
|
||||
onSubmit: () => void;
|
||||
setInput: (value: string) => void;
|
||||
endContent?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function StreamingAvatarTextInput({
|
||||
label,
|
||||
placeholder,
|
||||
input,
|
||||
onSubmit,
|
||||
setInput,
|
||||
endContent,
|
||||
disabled = false,
|
||||
}: StreamingAvatarTextInputProps) {
|
||||
function handleSubmit() {
|
||||
if (input.trim() === "") {
|
||||
return;
|
||||
}
|
||||
onSubmit();
|
||||
setInput("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
endContent={
|
||||
<div className="flex flex-row items-center h-full">
|
||||
{endContent}
|
||||
<Tooltip content="Send message">
|
||||
<button
|
||||
type="submit"
|
||||
className="focus:outline-none"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<PaperPlaneRight
|
||||
className={clsx(
|
||||
"text-indigo-300 hover:text-indigo-200",
|
||||
disabled && "opacity-50"
|
||||
)}
|
||||
size={24}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
size="sm"
|
||||
value={input}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
onValueChange={setInput}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
80
components/ThemeSwitch.tsx
Normal file
80
components/ThemeSwitch.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { VisuallyHidden } from "@react-aria/visually-hidden";
|
||||
import { SwitchProps, useSwitch } from "@nextui-org/switch";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useIsSSR } from "@react-aria/ssr";
|
||||
import clsx from "clsx";
|
||||
import { MoonFilledIcon, SunFilledIcon } from "./Icons";
|
||||
|
||||
export interface ThemeSwitchProps {
|
||||
className?: string;
|
||||
classNames?: SwitchProps["classNames"];
|
||||
}
|
||||
|
||||
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
|
||||
className,
|
||||
classNames,
|
||||
}) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isSSR = useIsSSR();
|
||||
|
||||
const onChange = () => {
|
||||
theme === "light" ? setTheme("dark") : setTheme("light");
|
||||
};
|
||||
|
||||
const {
|
||||
Component,
|
||||
slots,
|
||||
isSelected,
|
||||
getBaseProps,
|
||||
getInputProps,
|
||||
getWrapperProps,
|
||||
} = useSwitch({
|
||||
isSelected: theme === "light" || isSSR,
|
||||
"aria-label": `Switch to ${theme === "light" || isSSR ? "dark" : "light"} mode`,
|
||||
onChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...getBaseProps({
|
||||
className: clsx(
|
||||
"px-px transition-opacity hover:opacity-80 cursor-pointer",
|
||||
className,
|
||||
classNames?.base
|
||||
),
|
||||
})}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<input {...getInputProps()} />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
{...getWrapperProps()}
|
||||
className={slots.wrapper({
|
||||
class: clsx(
|
||||
[
|
||||
"w-auto h-auto",
|
||||
"bg-transparent",
|
||||
"rounded-lg",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[selected=true]:bg-transparent",
|
||||
"!text-default-500",
|
||||
"pt-px",
|
||||
"px-0",
|
||||
"mx-0",
|
||||
],
|
||||
classNames?.wrapper
|
||||
),
|
||||
})}
|
||||
>
|
||||
{!isSelected || isSSR ? (
|
||||
<SunFilledIcon size={24} />
|
||||
) : (
|
||||
<MoonFilledIcon size={24} />
|
||||
)}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
4
next.config.js
Normal file
4
next.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
64
package.json
Normal file
64
package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "next-app-template",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3234",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint . --ext .ts,.tsx -c .eslintrc.json --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^0.0.34",
|
||||
"@heygen/streaming-avatar": "^1.0.10",
|
||||
"@nextui-org/button": "2.0.34",
|
||||
"@nextui-org/code": "2.0.29",
|
||||
"@nextui-org/input": "2.2.2",
|
||||
"@nextui-org/kbd": "2.0.30",
|
||||
"@nextui-org/link": "2.0.32",
|
||||
"@nextui-org/listbox": "2.1.21",
|
||||
"@nextui-org/navbar": "2.0.33",
|
||||
"@nextui-org/react": "^2.4.2",
|
||||
"@nextui-org/snippet": "2.0.38",
|
||||
"@nextui-org/switch": "2.0.31",
|
||||
"@nextui-org/system": "2.2.1",
|
||||
"@nextui-org/theme": "2.2.5",
|
||||
"@phosphor-icons/react": "^2.1.5",
|
||||
"@react-aria/ssr": "3.9.4",
|
||||
"@react-aria/visually-hidden": "3.8.12",
|
||||
"@uiw/codemirror-extensions-langs": "^4.22.1",
|
||||
"@uiw/react-codemirror": "^4.22.1",
|
||||
"ai": "^3.2.15",
|
||||
"clsx": "2.1.1",
|
||||
"framer-motion": "~11.1.1",
|
||||
"intl-messageformat": "^10.5.0",
|
||||
"next": "14.2.4",
|
||||
"next-themes": "^0.2.1",
|
||||
"openai": "^4.52.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.5.7",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||
"@typescript-eslint/parser": "7.2.0",
|
||||
"autoprefixer": "10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.1",
|
||||
"eslint-config-prettier": "^8.2.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.23.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-unused-imports": "^3.2.0",
|
||||
"postcss": "8.4.38",
|
||||
"tailwind-variants": "0.1.20",
|
||||
"tailwindcss": "3.4.3",
|
||||
"typescript": "5.0.4"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
public/heygen-logo.png
Normal file
BIN
public/heygen-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal 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 |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
9
styles/globals.css
Normal file
9
styles/globals.css
Normal file
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
li {
|
||||
list-style-type: square;
|
||||
margin-left: 30px;
|
||||
padding: 2px;
|
||||
}
|
||||
20
tailwind.config.js
Normal file
20
tailwind.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import {nextui} from '@nextui-org/theme'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)"],
|
||||
mono: ["var(--font-geist-mono)"],
|
||||
},
|
||||
},
|
||||
},
|
||||
darkMode: "class",
|
||||
plugins: [nextui()],
|
||||
}
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user