Revert "Upgrade linter and formatter to Ultracite" (#1226)

This commit is contained in:
josh 2025-09-21 12:03:29 +01:00 committed by GitHub
parent 0e320b391d
commit 1aff7d9868
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
177 changed files with 8334 additions and 6943 deletions

View file

@ -1,12 +1,12 @@
"use client";
'use client';
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { PlusIcon } from "@/components/icons";
import { SidebarHistory } from "@/components/sidebar-history";
import { SidebarUserNav } from "@/components/sidebar-user-nav";
import { Button } from "@/components/ui/button";
import type { User } from 'next-auth';
import { useRouter } from 'next/navigation';
import { PlusIcon } from '@/components/icons';
import { SidebarHistory } from '@/components/sidebar-history';
import { SidebarUserNav } from '@/components/sidebar-user-nav';
import { Button } from '@/components/ui/button';
import {
Sidebar,
SidebarContent,
@ -14,8 +14,9 @@ import {
SidebarHeader,
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
} from '@/components/ui/sidebar';
import Link from 'next/link';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
export function AppSidebar({ user }: { user: User | undefined }) {
const router = useRouter();
@ -27,11 +28,11 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<SidebarMenu>
<div className="flex flex-row items-center justify-between">
<Link
className="flex flex-row items-center gap-3"
href="/"
onClick={() => {
setOpenMobile(false);
}}
className="flex flex-row items-center gap-3"
>
<span className="cursor-pointer rounded-md px-2 font-semibold text-lg hover:bg-muted">
Chatbot
@ -40,14 +41,14 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
type="button"
className="h-8 p-1 md:h-fit md:p-2"
onClick={() => {
setOpenMobile(false);
router.push("/");
router.push('/');
router.refresh();
}}
type="button"
variant="ghost"
>
<PlusIcon />
</Button>

View file

@ -1,20 +1,20 @@
import { type Dispatch, memo, type SetStateAction, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { artifactDefinitions, type UIArtifact } from "./artifact";
import type { ArtifactActionContext } from "./create-artifact";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { artifactDefinitions, type UIArtifact } from './artifact';
import { type Dispatch, memo, type SetStateAction, useState } from 'react';
import type { ArtifactActionContext } from './create-artifact';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
type ArtifactActionsProps = {
interface ArtifactActionsProps {
artifact: UIArtifact;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: "edit" | "diff";
mode: 'edit' | 'diff';
metadata: any;
setMetadata: Dispatch<SetStateAction<any>>;
};
}
function PureArtifactActions({
artifact,
@ -28,11 +28,11 @@ function PureArtifactActions({
const [isLoading, setIsLoading] = useState(false);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
(definition) => definition.kind === artifact.kind,
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
throw new Error('Artifact definition not found!');
}
const actionContext: ArtifactActionContext = {
@ -51,29 +51,29 @@ function PureArtifactActions({
<Tooltip key={action.description}>
<TooltipTrigger asChild>
<Button
className={cn("h-fit dark:hover:bg-zinc-700", {
"p-2": !action.label,
"px-2 py-1.5": action.label,
variant="outline"
className={cn('h-fit dark:hover:bg-zinc-700', {
'p-2': !action.label,
'px-2 py-1.5': action.label,
})}
disabled={
isLoading || artifact.status === "streaming"
? true
: action.isDisabled
? action.isDisabled(actionContext)
: false
}
onClick={async () => {
setIsLoading(true);
try {
await Promise.resolve(action.onClick(actionContext));
} catch (_error) {
toast.error("Failed to execute action");
} catch (error) {
toast.error('Failed to execute action');
} finally {
setIsLoading(false);
}
}}
variant="outline"
disabled={
isLoading || artifact.status === 'streaming'
? true
: action.isDisabled
? action.isDisabled(actionContext)
: false
}
>
{action.icon}
{action.label}
@ -89,19 +89,12 @@ function PureArtifactActions({
export const ArtifactActions = memo(
PureArtifactActions,
(prevProps, nextProps) => {
if (prevProps.artifact.status !== nextProps.artifact.status) {
if (prevProps.artifact.status !== nextProps.artifact.status) return false;
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex)
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.artifact.content !== nextProps.artifact.content) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) return false;
if (prevProps.artifact.content !== nextProps.artifact.content) return false;
return true;
}
},
);

View file

@ -1,26 +1,26 @@
import { memo } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { CrossIcon } from "./icons";
import { Button } from "./ui/button";
import { memo } from 'react';
import { CrossIcon } from './icons';
import { Button } from './ui/button';
import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
function PureArtifactCloseButton() {
const { setArtifact } = useArtifact();
return (
<Button
className="h-fit p-2 dark:hover:bg-zinc-700"
data-testid="artifact-close-button"
variant="outline"
className="h-fit p-2 dark:hover:bg-zinc-700"
onClick={() => {
setArtifact((currentArtifact) =>
currentArtifact.status === "streaming"
currentArtifact.status === 'streaming'
? {
...currentArtifact,
isVisible: false,
}
: { ...initialArtifactData, status: "idle" }
: { ...initialArtifactData, status: 'idle' },
);
}}
variant="outline"
>
<CrossIcon size={18} />
</Button>

View file

@ -1,23 +1,23 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { motion } from "framer-motion";
import { memo } from "react";
import { useMessages } from "@/hooks/use-messages";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import type { UIArtifact } from "./artifact";
import { PreviewMessage, ThinkingMessage } from "./message";
import { PreviewMessage, ThinkingMessage } from './message';
import type { Vote } from '@/lib/db/schema';
import { memo } from 'react';
import equal from 'fast-deep-equal';
import type { UIArtifact } from './artifact';
import type { UseChatHelpers } from '@ai-sdk/react';
import { motion } from 'framer-motion';
import { useMessages } from '@/hooks/use-messages';
import type { ChatMessage } from '@/lib/types';
type ArtifactMessagesProps = {
interface ArtifactMessagesProps {
chatId: string;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
status: UseChatHelpers<ChatMessage>['status'];
votes: Array<Vote> | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
isReadonly: boolean;
artifactStatus: UIArtifact["status"];
};
artifactStatus: UIArtifact['status'];
}
function PureArtifactMessages({
chatId,
@ -35,43 +35,45 @@ function PureArtifactMessages({
onViewportLeave,
hasSentMessage,
} = useMessages({
chatId,
status,
});
return (
<div
className="flex h-full flex-col items-center gap-4 overflow-y-scroll px-4 pt-20"
ref={messagesContainerRef}
className="flex h-full flex-col items-center gap-4 overflow-y-scroll px-4 pt-20"
>
{messages.map((message, index) => (
<PreviewMessage
chatId={chatId}
isLoading={status === "streaming" && index === messages.length - 1}
isReadonly={isReadonly}
key={message.id}
message={message}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
isLoading={status === 'streaming' && index === messages.length - 1}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
setMessages={setMessages}
regenerate={regenerate}
isReadonly={isReadonly}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
isArtifactVisible={true}
/>
))}
{status === "submitted" &&
{status === 'submitted' &&
messages.length > 0 &&
messages.at(-1)?.role === "user" && <ThinkingMessage />}
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
<motion.div
className="min-h-[24px] min-w-[24px] shrink-0"
onViewportEnter={onViewportEnter}
onViewportLeave={onViewportLeave}
ref={messagesEndRef}
className="min-h-[24px] min-w-[24px] shrink-0"
onViewportLeave={onViewportLeave}
onViewportEnter={onViewportEnter}
/>
</div>
);
@ -79,27 +81,18 @@ function PureArtifactMessages({
function areEqual(
prevProps: ArtifactMessagesProps,
nextProps: ArtifactMessagesProps
nextProps: ArtifactMessagesProps,
) {
if (
prevProps.artifactStatus === "streaming" &&
nextProps.artifactStatus === "streaming"
) {
prevProps.artifactStatus === 'streaming' &&
nextProps.artifactStatus === 'streaming'
)
return true;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.status && nextProps.status) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
if (prevProps.status !== nextProps.status) return false;
if (prevProps.status && nextProps.status) return false;
if (prevProps.messages.length !== nextProps.messages.length) return false;
if (!equal(prevProps.votes, nextProps.votes)) return false;
return true;
}

View file

@ -1,7 +1,5 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import { formatDistance } from "date-fns";
import equal from "fast-deep-equal";
import { AnimatePresence, motion } from "framer-motion";
import { formatDistance } from 'date-fns';
import { AnimatePresence, motion } from 'framer-motion';
import {
type Dispatch,
memo,
@ -9,25 +7,27 @@ import {
useCallback,
useEffect,
useState,
} from "react";
import useSWR, { useSWRConfig } from "swr";
import { useDebounceCallback, useWindowSize } from "usehooks-ts";
import { codeArtifact } from "@/artifacts/code/client";
import { imageArtifact } from "@/artifacts/image/client";
import { sheetArtifact } from "@/artifacts/sheet/client";
import { textArtifact } from "@/artifacts/text/client";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document, Vote } from "@/lib/db/schema";
import type { Attachment, ChatMessage } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import { ArtifactActions } from "./artifact-actions";
import { ArtifactCloseButton } from "./artifact-close-button";
import { ArtifactMessages } from "./artifact-messages";
import { MultimodalInput } from "./multimodal-input";
import { Toolbar } from "./toolbar";
import { useSidebar } from "./ui/sidebar";
import { VersionFooter } from "./version-footer";
import type { VisibilityType } from "./visibility-selector";
} from 'react';
import useSWR, { useSWRConfig } from 'swr';
import { useDebounceCallback, useWindowSize } from 'usehooks-ts';
import type { Document, Vote } from '@/lib/db/schema';
import { fetcher } from '@/lib/utils';
import { MultimodalInput } from './multimodal-input';
import { Toolbar } from './toolbar';
import { VersionFooter } from './version-footer';
import { ArtifactActions } from './artifact-actions';
import { ArtifactCloseButton } from './artifact-close-button';
import { ArtifactMessages } from './artifact-messages';
import { useSidebar } from './ui/sidebar';
import { useArtifact } from '@/hooks/use-artifact';
import { imageArtifact } from '@/artifacts/image/client';
import { codeArtifact } from '@/artifacts/code/client';
import { sheetArtifact } from '@/artifacts/sheet/client';
import { textArtifact } from '@/artifacts/text/client';
import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { VisibilityType } from './visibility-selector';
import type { Attachment, ChatMessage } from '@/lib/types';
export const artifactDefinitions = [
textArtifact,
@ -35,22 +35,22 @@ export const artifactDefinitions = [
imageArtifact,
sheetArtifact,
];
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
export type ArtifactKind = (typeof artifactDefinitions)[number]['kind'];
export type UIArtifact = {
export interface UIArtifact {
title: string;
documentId: string;
kind: ArtifactKind;
content: string;
isVisible: boolean;
status: "streaming" | "idle";
status: 'streaming' | 'idle';
boundingBox: {
top: number;
left: number;
width: number;
height: number;
};
};
}
function PureArtifact({
chatId,
@ -72,15 +72,15 @@ function PureArtifact({
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>["status"];
stop: UseChatHelpers<ChatMessage>["stop"];
status: UseChatHelpers<ChatMessage>['status'];
stop: UseChatHelpers<ChatMessage>['stop'];
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
votes: Vote[] | undefined;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
votes: Array<Vote> | undefined;
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
isReadonly: boolean;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
@ -91,14 +91,14 @@ function PureArtifact({
data: documents,
isLoading: isDocumentsFetching,
mutate: mutateDocuments,
} = useSWR<Document[]>(
artifact.documentId !== "init" && artifact.status !== "streaming"
} = useSWR<Array<Document>>(
artifact.documentId !== 'init' && artifact.status !== 'streaming'
? `/api/document?id=${artifact.documentId}`
: null,
fetcher
fetcher,
);
const [mode, setMode] = useState<"edit" | "diff">("edit");
const [mode, setMode] = useState<'edit' | 'diff'>('edit');
const [document, setDocument] = useState<Document | null>(null);
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
@ -113,7 +113,7 @@ function PureArtifact({
setCurrentVersionIndex(documents.length - 1);
setArtifact((currentArtifact) => ({
...currentArtifact,
content: mostRecentDocument.content ?? "",
content: mostRecentDocument.content ?? '',
}));
}
}
@ -121,23 +121,19 @@ function PureArtifact({
useEffect(() => {
mutateDocuments();
}, [mutateDocuments]);
}, [artifact.status, mutateDocuments]);
const { mutate } = useSWRConfig();
const [isContentDirty, setIsContentDirty] = useState(false);
const handleContentChange = useCallback(
(updatedContent: string) => {
if (!artifact) {
return;
}
if (!artifact) return;
mutate<Document[]>(
mutate<Array<Document>>(
`/api/document?id=${artifact.documentId}`,
async (currentDocuments) => {
if (!currentDocuments) {
return [];
}
if (!currentDocuments) return undefined;
const currentDocument = currentDocuments.at(-1);
@ -148,7 +144,7 @@ function PureArtifact({
if (currentDocument.content !== updatedContent) {
await fetch(`/api/document?id=${artifact.documentId}`, {
method: "POST",
method: 'POST',
body: JSON.stringify({
title: artifact.title,
content: updatedContent,
@ -168,15 +164,15 @@ function PureArtifact({
}
return currentDocuments;
},
{ revalidate: false }
{ revalidate: false },
);
},
[artifact, mutate]
[artifact, mutate],
);
const debouncedHandleContentChange = useDebounceCallback(
handleContentChange,
2000
2000,
);
const saveContent = useCallback(
@ -191,39 +187,35 @@ function PureArtifact({
}
}
},
[document, debouncedHandleContentChange, handleContentChange]
[document, debouncedHandleContentChange, handleContentChange],
);
function getDocumentContentById(index: number) {
if (!documents) {
return "";
}
if (!documents[index]) {
return "";
}
return documents[index].content ?? "";
if (!documents) return '';
if (!documents[index]) return '';
return documents[index].content ?? '';
}
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
if (!documents) {
return;
}
const handleVersionChange = (type: 'next' | 'prev' | 'toggle' | 'latest') => {
if (!documents) return;
if (type === "latest") {
if (type === 'latest') {
setCurrentVersionIndex(documents.length - 1);
setMode("edit");
setMode('edit');
}
if (type === "toggle") {
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
if (type === 'toggle') {
setMode((mode) => (mode === 'edit' ? 'diff' : 'edit'));
}
if (type === "prev") {
if (type === 'prev') {
if (currentVersionIndex > 0) {
setCurrentVersionIndex((index) => index - 1);
}
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
setCurrentVersionIndex((index) => index + 1);
} else if (type === 'next') {
if (currentVersionIndex < documents.length - 1) {
setCurrentVersionIndex((index) => index + 1);
}
}
};
@ -244,19 +236,21 @@ function PureArtifact({
const isMobile = windowWidth ? windowWidth < 768 : false;
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
(definition) => definition.kind === artifact.kind,
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
throw new Error('Artifact definition not found!');
}
useEffect(() => {
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
artifactDefinition.initialize({
documentId: artifact.documentId,
setMetadata,
});
if (artifact.documentId !== 'init') {
if (artifactDefinition.initialize) {
artifactDefinition.initialize({
documentId: artifact.documentId,
setMetadata,
});
}
}
}, [artifact.documentId, artifactDefinition, setMetadata]);
@ -264,21 +258,21 @@ function PureArtifact({
<AnimatePresence>
{artifact.isVisible && (
<motion.div
animate={{ opacity: 1 }}
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
data-testid="artifact"
exit={{ opacity: 0, transition: { delay: 0.4 } }}
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { delay: 0.4 } }}
>
{!isMobile && (
<motion.div
animate={{ width: windowWidth, right: 0 }}
className="fixed h-dvh bg-background"
exit={{
initial={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
initial={{
animate={{ width: windowWidth, right: 0 }}
exit={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
@ -287,64 +281,64 @@ function PureArtifact({
{!isMobile && (
<motion.div
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
initial={{ opacity: 0, x: 10, scale: 1 }}
animate={{
opacity: 1,
x: 0,
scale: 1,
transition: {
delay: 0.1,
type: "spring",
type: 'spring',
stiffness: 300,
damping: 30,
},
}}
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
exit={{
opacity: 0,
x: 0,
scale: 1,
transition: { duration: 0 },
}}
initial={{ opacity: 0, x: 10, scale: 1 }}
>
<AnimatePresence>
{!isCurrentVersion && (
<motion.div
animate={{ opacity: 1 }}
className="absolute top-0 left-0 z-50 h-dvh w-[400px] bg-zinc-900/50"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<div className="flex h-full flex-col items-center justify-between">
<ArtifactMessages
artifactStatus={artifact.status}
chatId={chatId}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
setMessages={setMessages}
status={status}
votes={votes}
messages={messages}
setMessages={setMessages}
regenerate={regenerate}
isReadonly={isReadonly}
artifactStatus={artifact.status}
/>
<div className="relative flex w-full flex-row items-end gap-2 px-4 pb-4">
<MultimodalInput
attachments={attachments}
chatId={chatId}
className="bg-background dark:bg-muted"
input={input}
messages={messages}
selectedModelId={selectedModelId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
messages={messages}
sendMessage={sendMessage}
className="bg-background dark:bg-muted"
setMessages={setMessages}
selectedVisibilityType={selectedVisibilityType}
selectedModelId={selectedModelId}
/>
</div>
</div>
@ -352,52 +346,7 @@ function PureArtifact({
)}
<motion.div
animate={
isMobile
? {
opacity: 1,
x: 0,
y: 0,
height: windowHeight,
width: windowWidth ? windowWidth : "calc(100dvw)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
: {
opacity: 1,
x: 400,
y: 0,
height: windowHeight,
width: windowWidth
? windowWidth - 400
: "calc(100dvw-400px)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
}
className="fixed flex h-dvh flex-col overflow-y-scroll border-zinc-200 bg-background md:border-l dark:border-zinc-700 dark:bg-muted"
exit={{
opacity: 0,
scale: 0.5,
transition: {
delay: 0.1,
type: "spring",
stiffness: 600,
damping: 30,
},
}}
initial={
isMobile
? {
@ -417,6 +366,51 @@ function PureArtifact({
borderRadius: 50,
}
}
animate={
isMobile
? {
opacity: 1,
x: 0,
y: 0,
height: windowHeight,
width: windowWidth ? windowWidth : 'calc(100dvw)',
borderRadius: 0,
transition: {
delay: 0,
type: 'spring',
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
: {
opacity: 1,
x: 400,
y: 0,
height: windowHeight,
width: windowWidth
? windowWidth - 400
: 'calc(100dvw-400px)',
borderRadius: 0,
transition: {
delay: 0,
type: 'spring',
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
}
exit={{
opacity: 0,
scale: 0.5,
transition: {
delay: 0.1,
type: 'spring',
stiffness: 600,
damping: 30,
},
}}
>
<div className="flex flex-row items-start justify-between p-2">
<div className="flex flex-row items-start gap-4">
@ -436,7 +430,7 @@ function PureArtifact({
new Date(),
{
addSuffix: true,
}
},
)}`}
</div>
) : (
@ -450,43 +444,43 @@ function PureArtifact({
currentVersionIndex={currentVersionIndex}
handleVersionChange={handleVersionChange}
isCurrentVersion={isCurrentVersion}
metadata={metadata}
mode={mode}
metadata={metadata}
setMetadata={setMetadata}
/>
</div>
<div className="h-full max-w-full! items-center overflow-y-scroll bg-background dark:bg-muted">
<artifactDefinition.content
title={artifact.title}
content={
isCurrentVersion
? artifact.content
: getDocumentContentById(currentVersionIndex)
}
mode={mode}
status={artifact.status}
currentVersionIndex={currentVersionIndex}
getDocumentContentById={getDocumentContentById}
isCurrentVersion={isCurrentVersion}
suggestions={[]}
onSaveContent={saveContent}
isInline={false}
isCurrentVersion={isCurrentVersion}
getDocumentContentById={getDocumentContentById}
isLoading={isDocumentsFetching && !artifact.content}
metadata={metadata}
mode={mode}
onSaveContent={saveContent}
setMetadata={setMetadata}
status={artifact.status}
suggestions={[]}
title={artifact.title}
/>
<AnimatePresence>
{isCurrentVersion && (
<Toolbar
artifactKind={artifact.kind}
isToolbarVisible={isToolbarVisible}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setMessages={setMessages}
sendMessage={sendMessage}
status={status}
stop={stop}
setMessages={setMessages}
artifactKind={artifact.kind}
/>
)}
</AnimatePresence>
@ -509,21 +503,12 @@ function PureArtifact({
}
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) {
if (prevProps.status !== nextProps.status) return false;
if (!equal(prevProps.votes, nextProps.votes)) return false;
if (prevProps.input !== nextProps.input) return false;
if (!equal(prevProps.messages, nextProps.messages.length)) return false;
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
if (prevProps.input !== nextProps.input) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages.length)) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
});

View file

@ -1,12 +1,12 @@
import Form from "next/form";
import Form from 'next/form';
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Input } from './ui/input';
import { Label } from './ui/label';
export function AuthForm({
action,
children,
defaultEmail = "",
defaultEmail = '',
}: {
action: NonNullable<
string | ((formData: FormData) => void | Promise<void>) | undefined
@ -18,39 +18,39 @@ export function AuthForm({
<Form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
<div className="flex flex-col gap-2">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="email"
className="font-normal text-zinc-600 dark:text-zinc-400"
>
Email Address
</Label>
<Input
autoComplete="email"
autoFocus
className="bg-muted text-md md:text-sm"
defaultValue={defaultEmail}
id="email"
name="email"
placeholder="user@acme.com"
required
className="bg-muted text-md md:text-sm"
type="email"
placeholder="user@acme.com"
autoComplete="email"
required
autoFocus
defaultValue={defaultEmail}
/>
</div>
<div className="flex flex-col gap-2">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="password"
className="font-normal text-zinc-600 dark:text-zinc-400"
>
Password
</Label>
<Input
className="bg-muted text-md md:text-sm"
id="password"
name="password"
required
className="bg-muted text-md md:text-sm"
type="password"
required
/>
</div>

View file

@ -1,23 +1,27 @@
"use client";
'use client';
import Link from "next/link";
import { useRouter } from "next/navigation";
import { memo } from "react";
import { useWindowSize } from "usehooks-ts";
import { SidebarToggle } from "@/components/sidebar-toggle";
import { Button } from "@/components/ui/button";
import { PlusIcon, VercelIcon } from "./icons";
import { useSidebar } from "./ui/sidebar";
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useWindowSize } from 'usehooks-ts';
import { SidebarToggle } from '@/components/sidebar-toggle';
import { Button } from '@/components/ui/button';
import { PlusIcon, VercelIcon } from './icons';
import { useSidebar } from './ui/sidebar';
import { memo } from 'react';
import { type VisibilityType, VisibilitySelector } from './visibility-selector';
import type { Session } from 'next-auth';
function PureChatHeader({
chatId,
selectedVisibilityType,
isReadonly,
session,
}: {
chatId: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
session: Session;
}) {
const router = useRouter();
const { open } = useSidebar();
@ -30,12 +34,12 @@ function PureChatHeader({
{(!open || windowWidth < 768) && (
<Button
variant="outline"
className="order-2 ml-auto h-8 px-2 md:order-1 md:ml-0 md:h-fit md:px-2"
onClick={() => {
router.push("/");
router.push('/');
router.refresh();
}}
variant="outline"
>
<PlusIcon />
<span className="md:sr-only">New Chat</span>
@ -45,19 +49,19 @@ function PureChatHeader({
{!isReadonly && (
<VisibilitySelector
chatId={chatId}
className="order-1 md:order-2"
selectedVisibilityType={selectedVisibilityType}
className="order-1 md:order-2"
/>
)}
<Button
asChild
className="order-3 hidden bg-zinc-900 px-2 text-zinc-50 hover:bg-zinc-800 md:ml-auto md:flex md:h-fit dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
asChild
>
<Link
href={"https://vercel.com/templates/next.js/nextjs-ai-chatbot"}
rel="noreferrer"
href={`https://vercel.com/templates/next.js/nextjs-ai-chatbot`}
target="_noblank"
rel="noreferrer"
>
<VercelIcon size={16} />
Deploy with Vercel

View file

@ -1,12 +1,28 @@
"use client";
'use client';
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { ChatHeader } from "@/components/chat-header";
import { DefaultChatTransport } from 'ai';
import { useChat } from '@ai-sdk/react';
import { useEffect, useState, useRef } from 'react';
import useSWR, { useSWRConfig } from 'swr';
import { ChatHeader } from '@/components/chat-header';
import type { Vote } from '@/lib/db/schema';
import { fetcher, fetchWithErrorHandlers, generateUUID } from '@/lib/utils';
import { Artifact } from './artifact';
import { MultimodalInput } from './multimodal-input';
import { Messages } from './messages';
import type { VisibilityType } from './visibility-selector';
import { useArtifactSelector } from '@/hooks/use-artifact';
import { unstable_serialize } from 'swr/infinite';
import { getChatHistoryPaginationKey } from './sidebar-history';
import { toast } from './toast';
import type { Session } from 'next-auth';
import { useSearchParams } from 'next/navigation';
import { useChatVisibility } from '@/hooks/use-chat-visibility';
import { useAutoResume } from '@/hooks/use-auto-resume';
import { ChatSDKError } from '@/lib/errors';
import type { Attachment, ChatMessage } from '@/lib/types';
import type { AppUsage } from '@/lib/usage';
import { useDataStream } from './data-stream-provider';
import {
AlertDialog,
AlertDialogAction,
@ -16,22 +32,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { useAutoResume } from "@/hooks/use-auto-resume";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Vote } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "./artifact";
import { useDataStream } from "./data-stream-provider";
import { Messages } from "./messages";
import { MultimodalInput } from "./multimodal-input";
import { getChatHistoryPaginationKey } from "./sidebar-history";
import { toast } from "./toast";
import type { VisibilityType } from "./visibility-selector";
} from '@/components/ui/alert-dialog';
export function Chat({
id,
@ -39,6 +40,7 @@ export function Chat({
initialChatModel,
initialVisibilityType,
isReadonly,
session,
autoResume,
initialLastContext,
}: {
@ -47,6 +49,7 @@ export function Chat({
initialChatModel: string;
initialVisibilityType: VisibilityType;
isReadonly: boolean;
session: Session;
autoResume: boolean;
initialLastContext?: AppUsage;
}) {
@ -58,12 +61,12 @@ export function Chat({
const { mutate } = useSWRConfig();
const { setDataStream } = useDataStream();
const [input, setInput] = useState<string>("");
const [input, setInput] = useState<string>('');
const [usage, setUsage] = useState<AppUsage | undefined>(initialLastContext);
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
const [currentModelId, setCurrentModelId] = useState(initialChatModel);
const currentModelIdRef = useRef(currentModelId);
useEffect(() => {
currentModelIdRef.current = currentModelId;
}, [currentModelId]);
@ -82,25 +85,23 @@ export function Chat({
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
api: '/api/chat',
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
prepareSendMessagesRequest({ messages, id, body }) {
return {
body: {
id: request.id,
message: request.messages.at(-1),
id,
message: messages.at(-1),
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: visibilityType,
...request.body,
...body,
},
};
},
}),
onData: (dataPart) => {
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
if (dataPart.type === "data-usage") {
setUsage(dataPart.data);
}
if (dataPart.type === 'data-usage') setUsage(dataPart.data);
},
onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
@ -109,12 +110,12 @@ export function Chat({
if (error instanceof ChatSDKError) {
// Check if it's a credit card error
if (
error.message?.includes("AI Gateway requires a valid credit card")
error.message?.includes('AI Gateway requires a valid credit card')
) {
setShowCreditCardAlert(true);
} else {
toast({
type: "error",
type: 'error',
description: error.message,
});
}
@ -123,28 +124,28 @@ export function Chat({
});
const searchParams = useSearchParams();
const query = searchParams.get("query");
const query = searchParams.get('query');
const [hasAppendedQuery, setHasAppendedQuery] = useState(false);
useEffect(() => {
if (query && !hasAppendedQuery) {
sendMessage({
role: "user" as const,
parts: [{ type: "text", text: query }],
role: 'user' as const,
parts: [{ type: 'text', text: query }],
});
setHasAppendedQuery(true);
window.history.replaceState({}, "", `/chat/${id}`);
window.history.replaceState({}, '', `/chat/${id}`);
}
}, [query, sendMessage, hasAppendedQuery, id]);
const { data: votes } = useSWR<Vote[]>(
const { data: votes } = useSWR<Array<Vote>>(
messages.length >= 2 ? `/api/vote?chatId=${id}` : null,
fetcher
fetcher,
);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
useAutoResume({
@ -159,38 +160,39 @@ export function Chat({
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
<ChatHeader
chatId={id}
isReadonly={isReadonly}
selectedVisibilityType={initialVisibilityType}
isReadonly={isReadonly}
session={session}
/>
<Messages
chatId={id}
isArtifactVisible={isArtifactVisible}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={initialChatModel}
setMessages={setMessages}
status={status}
votes={votes}
messages={messages}
setMessages={setMessages}
regenerate={regenerate}
isReadonly={isReadonly}
isArtifactVisible={isArtifactVisible}
selectedModelId={initialChatModel}
/>
<div className="sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4">
{!isReadonly && (
<MultimodalInput
attachments={attachments}
chatId={id}
input={input}
messages={messages}
onModelChange={setCurrentModelId}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
messages={messages}
setMessages={setMessages}
sendMessage={sendMessage}
selectedVisibilityType={visibilityType}
selectedModelId={currentModelId}
onModelChange={setCurrentModelId}
usage={usage}
/>
)}
@ -198,33 +200,33 @@ export function Chat({
</div>
<Artifact
attachments={attachments}
chatId={id}
input={input}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
sendMessage={sendMessage}
messages={messages}
setMessages={setMessages}
regenerate={regenerate}
votes={votes}
isReadonly={isReadonly}
selectedVisibilityType={visibilityType}
selectedModelId={currentModelId}
/>
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
onOpenChange={setShowCreditCardAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Activate AI Gateway</AlertDialogTitle>
<AlertDialogDescription>
This application requires{" "}
{process.env.NODE_ENV === "production" ? "the owner" : "you"} to
This application requires{' '}
{process.env.NODE_ENV === 'production' ? 'the owner' : 'you'} to
activate Vercel AI Gateway.
</AlertDialogDescription>
</AlertDialogHeader>
@ -233,10 +235,10 @@ export function Chat({
<AlertDialogAction
onClick={() => {
window.open(
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
"_blank"
'https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card',
'_blank',
);
window.location.href = "/";
window.location.href = '/';
}}
>
Activate

View file

@ -1,20 +1,20 @@
"use client";
'use client';
import { python } from "@codemirror/lang-python";
import { EditorState, Transaction } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { memo, useEffect, useRef } from "react";
import type { Suggestion } from "@/lib/db/schema";
import { EditorView } from '@codemirror/view';
import { EditorState, Transaction } from '@codemirror/state';
import { python } from '@codemirror/lang-python';
import { oneDark } from '@codemirror/theme-one-dark';
import { basicSetup } from 'codemirror';
import React, { memo, useEffect, useRef } from 'react';
import type { Suggestion } from '@/lib/db/schema';
type EditorProps = {
content: string;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
status: "streaming" | "idle";
status: 'streaming' | 'idle';
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Suggestion[];
suggestions: Array<Suggestion>;
};
function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
@ -42,14 +42,14 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
}, []);
useEffect(() => {
if (editorRef.current) {
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
const transaction = update.transactions.find(
(tr) => !tr.annotation(Transaction.remote)
(tr) => !tr.annotation(Transaction.remote),
);
if (transaction) {
@ -75,7 +75,7 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
if (editorRef.current && content) {
const currentContent = editorRef.current.state.doc.toString();
if (status === "streaming" || currentContent !== content) {
if (status === 'streaming' || currentContent !== content) {
const transaction = editorRef.current.state.update({
changes: {
from: 0,
@ -99,21 +99,13 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
}
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
if (prevProps.suggestions !== nextProps.suggestions) {
if (prevProps.suggestions !== nextProps.suggestions) return false;
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex)
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) return false;
if (prevProps.status === 'streaming' && nextProps.status === 'streaming')
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
return false;
}
if (prevProps.content !== nextProps.content) {
return false;
}
if (prevProps.content !== nextProps.content) return false;
return true;
}

View file

@ -1,3 +1,6 @@
import { TerminalWindowIcon, CrossSmallIcon } from './icons';
import { Loader } from './elements/loader';
import { Button } from './ui/button';
import {
type Dispatch,
type SetStateAction,
@ -5,28 +8,25 @@ import {
useEffect,
useRef,
useState,
} from "react";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { cn } from "@/lib/utils";
import { Loader } from "./elements/loader";
import { CrossSmallIcon, TerminalWindowIcon } from "./icons";
import { Button } from "./ui/button";
} from 'react';
import { cn } from '@/lib/utils';
import { useArtifactSelector } from '@/hooks/use-artifact';
export type ConsoleOutputContent = {
type: "text" | "image";
export interface ConsoleOutputContent {
type: 'text' | 'image';
value: string;
};
}
export type ConsoleOutput = {
export interface ConsoleOutput {
id: string;
status: "in_progress" | "loading_packages" | "completed" | "failed";
contents: ConsoleOutputContent[];
};
status: 'in_progress' | 'loading_packages' | 'completed' | 'failed';
contents: Array<ConsoleOutputContent>;
}
type ConsoleProps = {
consoleOutputs: ConsoleOutput[];
setConsoleOutputs: Dispatch<SetStateAction<ConsoleOutput[]>>;
};
interface ConsoleProps {
consoleOutputs: Array<ConsoleOutput>;
setConsoleOutputs: Dispatch<SetStateAction<Array<ConsoleOutput>>>;
}
export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
const [height, setHeight] = useState<number>(300);
@ -55,21 +55,21 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
}
}
},
[isResizing]
[isResizing],
);
useEffect(() => {
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
};
}, [resize, stopResizing]);
useEffect(() => {
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
consoleEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [consoleOutputs]);
useEffect(() => {
if (!isArtifactVisible) {
@ -80,31 +80,19 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
return consoleOutputs.length > 0 ? (
<>
<div
aria-label="Resize console"
aria-orientation="horizontal"
aria-valuemax={maxHeight}
aria-valuemin={minHeight}
aria-valuenow={height}
className="fixed z-50 h-2 w-full cursor-ns-resize"
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
setHeight((prev) => Math.min(prev + 10, maxHeight));
} else if (e.key === "ArrowDown") {
setHeight((prev) => Math.max(prev - 10, minHeight));
}
}}
onMouseDown={startResizing}
role="slider"
style={{ bottom: height - 4 }}
tabIndex={0}
role="slider"
aria-valuenow={minHeight}
/>
<div
className={cn(
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-scroll border-zinc-200 border-t bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900",
'fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-scroll border-zinc-200 border-t bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900',
{
"select-none": isResizing,
}
'select-none': isResizing,
},
)}
style={{ height }}
>
@ -116,10 +104,10 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
<div>Console</div>
</div>
<Button
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
onClick={() => setConsoleOutputs([])}
size="icon"
variant="ghost"
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
size="icon"
onClick={() => setConsoleOutputs([])}
>
<CrossSmallIcon />
</Button>
@ -128,58 +116,57 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
<div>
{consoleOutputs.map((consoleOutput, index) => (
<div
className="flex flex-row border-zinc-200 border-b bg-zinc-50 px-4 py-2 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-900"
key={consoleOutput.id}
className="flex flex-row border-zinc-200 border-b bg-zinc-50 px-4 py-2 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-900"
>
<div
className={cn("w-12 shrink-0", {
"text-muted-foreground": [
"in_progress",
"loading_packages",
className={cn('w-12 shrink-0', {
'text-muted-foreground': [
'in_progress',
'loading_packages',
].includes(consoleOutput.status),
"text-emerald-500": consoleOutput.status === "completed",
"text-red-400": consoleOutput.status === "failed",
'text-emerald-500': consoleOutput.status === 'completed',
'text-red-400': consoleOutput.status === 'failed',
})}
>
[{index + 1}]
</div>
{["in_progress", "loading_packages"].includes(
consoleOutput.status
{['in_progress', 'loading_packages'].includes(
consoleOutput.status,
) ? (
<div className="flex flex-row gap-2">
<div className="mt-0.5 mb-auto size-fit self-center">
<Loader size={16} />
</div>
<div className="text-muted-foreground">
{consoleOutput.status === "in_progress"
? "Initializing..."
: consoleOutput.status === "loading_packages"
{consoleOutput.status === 'in_progress'
? 'Initializing...'
: consoleOutput.status === 'loading_packages'
? consoleOutput.contents.map((content) =>
content.type === "text" ? content.value : null
content.type === 'text' ? content.value : null,
)
: null}
</div>
</div>
) : (
<div className="flex w-full flex-col gap-2 overflow-x-scroll text-zinc-900 dark:text-zinc-50">
{consoleOutput.contents.map((content, contentIndex) =>
content.type === "image" ? (
<picture key={`${consoleOutput.id}-${contentIndex}`}>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
{consoleOutput.contents.map((content, index) =>
content.type === 'image' ? (
<picture key={`${consoleOutput.id}-${index}`}>
<img
src={content.value}
alt="output"
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
src={content.value}
/>
</picture>
) : (
<div
key={`${consoleOutput.id}-${index}`}
className="w-full whitespace-pre-line break-words"
key={`${consoleOutput.id}-${contentIndex}`}
>
{content.value}
</div>
)
),
)}
</div>
)}

View file

@ -1,16 +1,16 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import type { DataUIPart } from "ai";
import type { ComponentType, Dispatch, ReactNode, SetStateAction } from "react";
import type { Suggestion } from "@/lib/db/schema";
import type { ChatMessage, CustomUIDataTypes } from "@/lib/types";
import type { UIArtifact } from "./artifact";
import type { Suggestion } from '@/lib/db/schema';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { ComponentType, Dispatch, ReactNode, SetStateAction } from 'react';
import type { UIArtifact } from './artifact';
import type { ChatMessage, CustomUIDataTypes } from '@/lib/types';
import type { DataUIPart } from 'ai';
export type ArtifactActionContext<M = any> = {
content: string;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: "edit" | "diff";
mode: 'edit' | 'diff';
metadata: M;
setMetadata: Dispatch<SetStateAction<M>>;
};
@ -24,7 +24,7 @@ type ArtifactAction<M = any> = {
};
export type ArtifactToolbarContext = {
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
};
export type ArtifactToolbarItem = {
@ -33,32 +33,32 @@ export type ArtifactToolbarItem = {
onClick: (context: ArtifactToolbarContext) => void;
};
type ArtifactContent<M = any> = {
interface ArtifactContent<M = any> {
title: string;
content: string;
mode: "edit" | "diff";
mode: 'edit' | 'diff';
isCurrentVersion: boolean;
currentVersionIndex: number;
status: "streaming" | "idle";
suggestions: Suggestion[];
status: 'streaming' | 'idle';
suggestions: Array<Suggestion>;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
isInline: boolean;
getDocumentContentById: (index: number) => string;
isLoading: boolean;
metadata: M;
setMetadata: Dispatch<SetStateAction<M>>;
};
}
type InitializeParameters<M = any> = {
interface InitializeParameters<M = any> {
documentId: string;
setMetadata: Dispatch<SetStateAction<M>>;
};
}
type ArtifactConfig<T extends string, M = any> = {
kind: T;
description: string;
content: ComponentType<ArtifactContent<M>>;
actions: ArtifactAction<M>[];
actions: Array<ArtifactAction<M>>;
toolbar: ArtifactToolbarItem[];
initialize?: (parameters: InitializeParameters<M>) => void;
onStreamPart: (args: {
@ -72,7 +72,7 @@ export class Artifact<T extends string, M = any> {
readonly kind: T;
readonly description: string;
readonly content: ComponentType<ArtifactContent<M>>;
readonly actions: ArtifactAction<M>[];
readonly actions: Array<ArtifactAction<M>>;
readonly toolbar: ArtifactToolbarItem[];
readonly initialize?: (parameters: InitializeParameters) => void;
readonly onStreamPart: (args: {

View file

@ -1,9 +1,9 @@
"use client";
'use client';
import { useEffect, useRef } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { artifactDefinitions } from "./artifact";
import { useDataStream } from "./data-stream-provider";
import { useEffect, useRef } from 'react';
import { artifactDefinitions } from './artifact';
import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
import { useDataStream } from './data-stream-provider';
export function DataStreamHandler() {
const { dataStream } = useDataStream();
@ -12,17 +12,14 @@ export function DataStreamHandler() {
const lastProcessedIndex = useRef(-1);
useEffect(() => {
if (!dataStream?.length) {
return;
}
if (!dataStream?.length) return;
const newDeltas = dataStream.slice(lastProcessedIndex.current + 1);
lastProcessedIndex.current = dataStream.length - 1;
for (const delta of newDeltas) {
newDeltas.forEach((delta) => {
const artifactDefinition = artifactDefinitions.find(
(currentArtifactDefinition) =>
currentArtifactDefinition.kind === artifact.kind
(artifactDefinition) => artifactDefinition.kind === artifact.kind,
);
if (artifactDefinition?.onStreamPart) {
@ -35,49 +32,49 @@ export function DataStreamHandler() {
setArtifact((draftArtifact) => {
if (!draftArtifact) {
return { ...initialArtifactData, status: "streaming" };
return { ...initialArtifactData, status: 'streaming' };
}
switch (delta.type) {
case "data-id":
case 'data-id':
return {
...draftArtifact,
documentId: delta.data,
status: "streaming",
status: 'streaming',
};
case "data-title":
case 'data-title':
return {
...draftArtifact,
title: delta.data,
status: "streaming",
status: 'streaming',
};
case "data-kind":
case 'data-kind':
return {
...draftArtifact,
kind: delta.data,
status: "streaming",
status: 'streaming',
};
case "data-clear":
case 'data-clear':
return {
...draftArtifact,
content: "",
status: "streaming",
content: '',
status: 'streaming',
};
case "data-finish":
case 'data-finish':
return {
...draftArtifact,
status: "idle",
status: 'idle',
};
default:
return draftArtifact;
}
});
}
});
}, [dataStream, setArtifact, setMetadata, artifact]);
return null;

View file

@ -1,16 +1,15 @@
"use client";
'use client';
import type { DataUIPart } from "ai";
import type React from "react";
import { createContext, useContext, useMemo, useState } from "react";
import type { CustomUIDataTypes } from "@/lib/types";
import React, { createContext, useContext, useMemo, useState } from 'react';
import type { DataUIPart } from 'ai';
import type { CustomUIDataTypes } from '@/lib/types';
type DataStreamContextValue = {
interface DataStreamContextValue {
dataStream: DataUIPart<CustomUIDataTypes>[];
setDataStream: React.Dispatch<
React.SetStateAction<DataUIPart<CustomUIDataTypes>[]>
>;
};
}
const DataStreamContext = createContext<DataStreamContextValue | null>(null);
@ -20,7 +19,7 @@ export function DataStreamProvider({
children: React.ReactNode;
}) {
const [dataStream, setDataStream] = useState<DataUIPart<CustomUIDataTypes>[]>(
[]
[],
);
const value = useMemo(() => ({ dataStream, setDataStream }), [dataStream]);
@ -35,7 +34,7 @@ export function DataStreamProvider({
export function useDataStream() {
const context = useContext(DataStreamContext);
if (!context) {
throw new Error("useDataStream must be used within a DataStreamProvider");
throw new Error('useDataStream must be used within a DataStreamProvider');
}
return context;
}

View file

@ -1,42 +1,42 @@
import OrderedMap from "orderedmap";
import OrderedMap from 'orderedmap';
import {
DOMParser,
type MarkSpec,
type Node as ProsemirrorNode,
Schema,
} from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useEffect, useRef } from "react";
import { renderToString } from "react-dom/server";
import { Streamdown } from "streamdown";
type Node as ProsemirrorNode,
type MarkSpec,
DOMParser,
} from 'prosemirror-model';
import { schema } from 'prosemirror-schema-basic';
import { addListNodes } from 'prosemirror-schema-list';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import React, { useEffect, useRef } from 'react';
import { renderToString } from 'react-dom/server';
import { Streamdown } from 'streamdown';
import { DiffType, diffEditor } from "@/lib/editor/diff";
import { diffEditor, DiffType } from '@/lib/editor/diff';
const diffSchema = new Schema({
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
marks: OrderedMap.from({
...schema.spec.marks.toObject(),
diffMark: {
attrs: { type: { default: "" } },
attrs: { type: { default: '' } },
toDOM(mark) {
let className = "";
let className = '';
switch (mark.attrs.type) {
case DiffType.Inserted:
className =
"bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300";
'bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300';
break;
case DiffType.Deleted:
className =
"bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300";
'bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300';
break;
default:
className = "";
className = '';
}
return ["span", { class: className }, 0];
return ['span', { class: className }, 0];
},
} as MarkSpec,
}),
@ -60,16 +60,16 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
const parser = DOMParser.fromSchema(diffSchema);
const oldHtmlContent = renderToString(
<Streamdown>{oldContent}</Streamdown>
<Streamdown>{oldContent}</Streamdown>,
);
const newHtmlContent = renderToString(
<Streamdown>{newContent}</Streamdown>
<Streamdown>{newContent}</Streamdown>,
);
const oldContainer = document.createElement("div");
const oldContainer = document.createElement('div');
oldContainer.innerHTML = oldHtmlContent;
const newContainer = document.createElement("div");
const newContainer = document.createElement('div');
newContainer.innerHTML = newHtmlContent;
const oldDoc = parser.parse(oldContainer);

View file

@ -1,32 +1,32 @@
"use client";
'use client';
import equal from "fast-deep-equal";
import {
type MouseEvent,
memo,
type MouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import useSWR from "swr";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document } from "@/lib/db/schema";
import { cn, fetcher } from "@/lib/utils";
import type { ArtifactKind, UIArtifact } from "./artifact";
import { CodeEditor } from "./code-editor";
import { DocumentToolCall, DocumentToolResult } from "./document";
import { InlineDocumentSkeleton } from "./document-skeleton";
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from "./icons";
import { ImageEditor } from "./image-editor";
import { SpreadsheetEditor } from "./sheet-editor";
import { Editor } from "./text-editor";
} from 'react';
import type { ArtifactKind, UIArtifact } from './artifact';
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from './icons';
import { cn, fetcher } from '@/lib/utils';
import type { Document } from '@/lib/db/schema';
import { InlineDocumentSkeleton } from './document-skeleton';
import useSWR from 'swr';
import { Editor } from './text-editor';
import { DocumentToolCall, DocumentToolResult } from './document';
import { CodeEditor } from './code-editor';
import { useArtifact } from '@/hooks/use-artifact';
import equal from 'fast-deep-equal';
import { SpreadsheetEditor } from './sheet-editor';
import { ImageEditor } from './image-editor';
type DocumentPreviewProps = {
interface DocumentPreviewProps {
isReadonly: boolean;
result?: any;
args?: any;
};
}
export function DocumentPreview({
isReadonly,
@ -36,7 +36,7 @@ export function DocumentPreview({
const { artifact, setArtifact } = useArtifact();
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
Document[]
Array<Document>
>(result ? `/api/document?id=${result.id}` : null, fetcher);
const previewDocument = useMemo(() => documents?.[0], [documents]);
@ -46,8 +46,8 @@ export function DocumentPreview({
const boundingBox = hitboxRef.current?.getBoundingClientRect();
if (artifact.documentId && boundingBox) {
setArtifact((currentArtifact) => ({
...currentArtifact,
setArtifact((artifact) => ({
...artifact,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
@ -62,9 +62,9 @@ export function DocumentPreview({
if (result) {
return (
<DocumentToolResult
isReadonly={isReadonly}
result={{ id: result.id, title: result.title, kind: result.kind }}
type="create"
result={{ id: result.id, title: result.title, kind: result.kind }}
isReadonly={isReadonly}
/>
);
}
@ -72,9 +72,9 @@ export function DocumentPreview({
if (args) {
return (
<DocumentToolCall
type="create"
args={{ title: args.title, kind: args.kind }}
isReadonly={isReadonly}
type="create"
/>
);
}
@ -86,20 +86,18 @@ export function DocumentPreview({
const document: Document | null = previewDocument
? previewDocument
: artifact.status === "streaming"
: artifact.status === 'streaming'
? {
title: artifact.title,
kind: artifact.kind,
content: artifact.content,
id: artifact.documentId,
createdAt: new Date(),
userId: "noop",
userId: 'noop',
}
: null;
if (!document) {
return <LoadingSkeleton artifactKind={artifact.kind} />;
}
if (!document) return <LoadingSkeleton artifactKind={artifact.kind} />;
return (
<div className="relative w-full cursor-pointer">
@ -109,9 +107,9 @@ export function DocumentPreview({
setArtifact={setArtifact}
/>
<DocumentHeader
isStreaming={artifact.status === "streaming"}
kind={document.kind}
title={document.title}
kind={document.kind}
isStreaming={artifact.status === 'streaming'}
/>
<DocumentContent document={document} />
</div>
@ -131,7 +129,7 @@ const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
<FullscreenIcon />
</div>
</div>
{artifactKind === "image" ? (
{artifactKind === 'image' ? (
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted dark:border-zinc-700">
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/20" />
</div>
@ -151,7 +149,7 @@ const PureHitboxLayer = ({
hitboxRef: React.RefObject<HTMLDivElement>;
result: any;
setArtifact: (
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact),
) => void;
}) => {
const handleClick = useCallback(
@ -159,7 +157,7 @@ const PureHitboxLayer = ({
const boundingBox = event.currentTarget.getBoundingClientRect();
setArtifact((artifact) =>
artifact.status === "streaming"
artifact.status === 'streaming'
? { ...artifact, isVisible: true }
: {
...artifact,
@ -173,19 +171,19 @@ const PureHitboxLayer = ({
width: boundingBox.width,
height: boundingBox.height,
},
}
},
);
},
[setArtifact, result]
[setArtifact, result],
);
return (
<div
aria-hidden="true"
className="absolute top-0 left-0 z-10 size-full rounded-xl"
onClick={handleClick}
ref={hitboxRef}
onClick={handleClick}
role="presentation"
aria-hidden="true"
>
<div className="flex w-full items-center justify-end p-4">
<div className="absolute top-[13px] right-[9px] rounded-md p-2 hover:bg-zinc-100 dark:hover:bg-zinc-700">
@ -197,9 +195,7 @@ const PureHitboxLayer = ({
};
const HitboxLayer = memo(PureHitboxLayer, (prevProps, nextProps) => {
if (!equal(prevProps.result, nextProps.result)) {
return false;
}
if (!equal(prevProps.result, nextProps.result)) return false;
return true;
});
@ -219,7 +215,7 @@ const PureDocumentHeader = ({
<div className="animate-spin">
<LoaderIcon />
</div>
) : kind === "image" ? (
) : kind === 'image' ? (
<ImageIcon />
) : (
<FileIcon />
@ -232,12 +228,8 @@ const PureDocumentHeader = ({
);
const DocumentHeader = memo(PureDocumentHeader, (prevProps, nextProps) => {
if (prevProps.title !== nextProps.title) {
return false;
}
if (prevProps.isStreaming !== nextProps.isStreaming) {
return false;
}
if (prevProps.title !== nextProps.title) return false;
if (prevProps.isStreaming !== nextProps.isStreaming) return false;
return true;
});
@ -246,48 +238,46 @@ const DocumentContent = ({ document }: { document: Document }) => {
const { artifact } = useArtifact();
const containerClassName = cn(
"h-[257px] overflow-y-scroll rounded-b-2xl border border-t-0 dark:border-zinc-700 dark:bg-muted",
'h-[257px] overflow-y-scroll border rounded-b-2xl dark:bg-muted border-t-0 dark:border-zinc-700',
{
"p-4 sm:px-14 sm:py-16": document.kind === "text",
"p-0": document.kind === "code",
}
'p-4 sm:px-14 sm:py-16': document.kind === 'text',
'p-0': document.kind === 'code',
},
);
const commonProps = {
content: document.content ?? "",
content: document.content ?? '',
isCurrentVersion: true,
currentVersionIndex: 0,
status: artifact.status,
saveContent: () => null,
saveContent: () => {},
suggestions: [],
};
const handleSaveContent = () => null;
return (
<div className={containerClassName}>
{document.kind === "text" ? (
<Editor {...commonProps} onSaveContent={handleSaveContent} />
) : document.kind === "code" ? (
{document.kind === 'text' ? (
<Editor {...commonProps} onSaveContent={() => {}} />
) : document.kind === 'code' ? (
<div className="relative flex w-full flex-1">
<div className="absolute inset-0">
<CodeEditor {...commonProps} onSaveContent={handleSaveContent} />
<CodeEditor {...commonProps} onSaveContent={() => {}} />
</div>
</div>
) : document.kind === "sheet" ? (
) : document.kind === 'sheet' ? (
<div className="relative flex size-full flex-1 p-4">
<div className="absolute inset-0">
<SpreadsheetEditor {...commonProps} />
</div>
</div>
) : document.kind === "image" ? (
) : document.kind === 'image' ? (
<ImageEditor
content={document.content ?? ""}
currentVersionIndex={0}
isCurrentVersion={true}
isInline={true}
status={artifact.status}
title={document.title}
content={document.content ?? ''}
isCurrentVersion={true}
currentVersionIndex={0}
status={artifact.status}
isInline={true}
/>
) : null}
</div>

View file

@ -1,13 +1,13 @@
"use client";
'use client';
import type { ArtifactKind } from "./artifact";
import type { ArtifactKind } from './artifact';
export const DocumentSkeleton = ({
artifactKind,
}: {
artifactKind: ArtifactKind;
}) => {
return artifactKind === "image" ? (
return artifactKind === 'image' ? (
<div className="flex h-[calc(100dvh-60px)] w-full flex-col items-center justify-center gap-4">
<div className="size-96 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>

View file

@ -1,32 +1,33 @@
import { memo } from "react";
import { toast } from "sonner";
import { useArtifact } from "@/hooks/use-artifact";
import type { ArtifactKind } from "./artifact";
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from "./icons";
import { memo } from 'react';
import type { ArtifactKind } from './artifact';
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from './icons';
import { toast } from 'sonner';
import { useArtifact } from '@/hooks/use-artifact';
const getActionText = (
type: "create" | "update" | "request-suggestions",
tense: "present" | "past"
type: 'create' | 'update' | 'request-suggestions',
tense: 'present' | 'past',
) => {
switch (type) {
case "create":
return tense === "present" ? "Creating" : "Created";
case "update":
return tense === "present" ? "Updating" : "Updated";
case "request-suggestions":
return tense === "present"
? "Adding suggestions"
: "Added suggestions to";
case 'create':
return tense === 'present' ? 'Creating' : 'Created';
case 'update':
return tense === 'present' ? 'Updating' : 'Updated';
case 'request-suggestions':
return tense === 'present'
? 'Adding suggestions'
: 'Added suggestions to';
default:
return null;
}
};
type DocumentToolResultProps = {
type: "create" | "update" | "request-suggestions";
interface DocumentToolResultProps {
type: 'create' | 'update' | 'request-suggestions';
result: { id: string; title: string; kind: ArtifactKind };
isReadonly: boolean;
};
}
function PureDocumentToolResult({
type,
@ -37,11 +38,12 @@ function PureDocumentToolResult({
return (
<button
type="button"
className="flex w-fit cursor-pointer flex-row items-start gap-3 rounded-xl border bg-background px-3 py-2"
onClick={(event) => {
if (isReadonly) {
toast.error(
"Viewing files in shared chats is currently not supported."
'Viewing files in shared chats is currently not supported.',
);
return;
}
@ -58,26 +60,25 @@ function PureDocumentToolResult({
setArtifact({
documentId: result.id,
kind: result.kind,
content: "",
content: '',
title: result.title,
isVisible: true,
status: "idle",
status: 'idle',
boundingBox,
});
}}
type="button"
>
<div className="mt-1 text-muted-foreground">
{type === "create" ? (
{type === 'create' ? (
<FileIcon />
) : type === "update" ? (
) : type === 'update' ? (
<PencilEditIcon />
) : type === "request-suggestions" ? (
) : type === 'request-suggestions' ? (
<MessageIcon />
) : null}
</div>
<div className="text-left">
{`${getActionText(type, "past")} "${result.title}"`}
{`${getActionText(type, 'past')} "${result.title}"`}
</div>
</button>
);
@ -85,14 +86,14 @@ function PureDocumentToolResult({
export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
type DocumentToolCallProps = {
type: "create" | "update" | "request-suggestions";
interface DocumentToolCallProps {
type: 'create' | 'update' | 'request-suggestions';
args:
| { title: string; kind: ArtifactKind } // for create
| { id: string; description: string } // for update
| { documentId: string }; // for request-suggestions
isReadonly: boolean;
};
}
function PureDocumentToolCall({
type,
@ -103,11 +104,12 @@ function PureDocumentToolCall({
return (
<button
type="button"
className="cursor pointer flex w-fit flex-row items-start justify-between gap-3 rounded-xl border px-3 py-2"
onClick={(event) => {
if (isReadonly) {
toast.error(
"Viewing files in shared chats is currently not supported."
'Viewing files in shared chats is currently not supported.',
);
return;
}
@ -127,28 +129,27 @@ function PureDocumentToolCall({
boundingBox,
}));
}}
type="button"
>
<div className="flex flex-row items-start gap-3">
<div className="mt-1 text-zinc-500">
{type === "create" ? (
{type === 'create' ? (
<FileIcon />
) : type === "update" ? (
) : type === 'update' ? (
<PencilEditIcon />
) : type === "request-suggestions" ? (
) : type === 'request-suggestions' ? (
<MessageIcon />
) : null}
</div>
<div className="text-left">
{`${getActionText(type, "present")} ${
type === "create" && "title" in args && args.title
{`${getActionText(type, 'present')} ${
type === 'create' && 'title' in args && args.title
? `"${args.title}"`
: type === "update" && "description" in args
: type === 'update' && 'description' in args
? `"${args.description}"`
: type === "request-suggestions"
? "for document"
: ""
: type === 'request-suggestions'
? 'for document'
: ''
}`}
</div>
</div>

View file

@ -1,19 +1,19 @@
"use client";
'use client';
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
export type ActionsProps = ComponentProps<"div">;
export type ActionsProps = ComponentProps<'div'>;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
<div className={cn('flex items-center gap-1', className)} {...props}>
{children}
</div>
);
@ -28,15 +28,15 @@ export const Action = ({
children,
label,
className,
variant = "ghost",
size = "sm",
variant = 'ghost',
size = 'sm',
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground",
className
'relative size-9 p-1.5 text-muted-foreground hover:text-foreground',
className,
)}
size={size}
type="button"

View file

@ -1,11 +1,11 @@
"use client";
'use client';
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { UIMessage } from 'ai';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
import { createContext, useContext, useEffect, useState, useMemo } from 'react';
type BranchContextType = {
currentBranch: number;
@ -22,7 +22,7 @@ const useBranch = () => {
const context = useContext(BranchContext);
if (!context) {
throw new Error("Branch components must be used within Branch");
throw new Error('Branch components must be used within Branch');
}
return context;
@ -71,7 +71,7 @@ export const Branch = ({
return (
<BranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
{...props}
/>
</BranchContext.Provider>
@ -84,7 +84,7 @@ export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
[children],
);
// Use useEffect to update branches when they change
@ -97,8 +97,8 @@ export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
'grid gap-2 overflow-hidden [&>div]:pb-0',
index === currentBranch ? 'block' : 'hidden',
)}
key={branch.key}
{...props}
@ -109,7 +109,7 @@ export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
};
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
from: UIMessage['role'];
};
export const BranchSelector = ({
@ -127,9 +127,9 @@ export const BranchSelector = ({
return (
<div
className={cn(
"flex items-center gap-2 self-end px-10",
from === "assistant" ? "justify-start" : "justify-end",
className
'flex items-center gap-2 self-end px-10',
from === 'assistant' ? 'justify-start' : 'justify-end',
className,
)}
{...props}
/>
@ -149,10 +149,10 @@ export const BranchPrevious = ({
<Button
aria-label="Previous branch"
className={cn(
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className,
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
@ -179,10 +179,10 @@ export const BranchNext = ({
<Button
aria-label="Next branch"
className={cn(
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className,
)}
disabled={totalBranches <= 1}
onClick={goToNext}
@ -204,8 +204,8 @@ export const BranchPage = ({ className, ...props }: BranchPageProps) => {
return (
<span
className={cn(
"font-medium text-muted-foreground text-xs tabular-nums",
className
'font-medium text-muted-foreground text-xs tabular-nums',
className,
)}
{...props}
>

View file

@ -1,22 +1,22 @@
"use client";
'use client';
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { CheckIcon, CopyIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
} from 'react-syntax-highlighter/dist/esm/styles/prism';
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
code: '',
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
@ -37,8 +37,8 @@ export const CodeBlock = ({
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
"relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
className,
)}
{...props}
>
@ -46,23 +46,23 @@ export const CodeBlock = ({
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: "font-mono text-sm",
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
overflowX: "auto",
overflowWrap: "break-word",
wordBreak: "break-all",
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
overflowX: 'auto',
overflowWrap: 'break-word',
wordBreak: 'break-all',
}}
language={language}
lineNumberStyle={{
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneLight}
@ -72,23 +72,23 @@ export const CodeBlock = ({
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: "font-mono text-sm",
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
overflowX: "auto",
overflowWrap: "break-word",
wordBreak: "break-all",
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
overflowX: 'auto',
overflowWrap: 'break-word',
wordBreak: 'break-all',
}}
language={language}
lineNumberStyle={{
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneDark}
@ -123,8 +123,8 @@ export const CodeBlockCopyButton = ({
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
onError?.(new Error("Clipboard API not available"));
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
onError?.(new Error('Clipboard API not available'));
return;
}
@ -142,7 +142,7 @@ export const CodeBlockCopyButton = ({
return (
<Button
className={cn("shrink-0", className)}
className={cn('shrink-0', className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"

View file

@ -1,24 +1,24 @@
"use client";
'use client';
import type { ComponentProps } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils";
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
import { Separator } from '@/components/ui/separator';
import { Progress } from '@/components/ui/progress';
import type { AppUsage } from '@/lib/usage';
export type ContextProps = ComponentProps<"button"> & {
export type ContextProps = ComponentProps<'button'> & {
/** Optional full usage payload to enable breakdown view */
usage?: AppUsage;
};
const _THOUSAND = 1000;
const _MILLION = 1_000_000;
const _BILLION = 1_000_000_000;
const THOUSAND = 1000;
const MILLION = 1_000_000;
const BILLION = 1_000_000_000;
const PERCENT_MAX = 100;
// Lucide CircleIcon geometry
@ -41,7 +41,7 @@ export const ContextIcon = ({ percent }: ContextIconProps) => {
aria-label={`${percent.toFixed(2)}% of model context used`}
height="28"
role="img"
style={{ color: "currentcolor" }}
style={{ color: 'currentcolor' }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="28"
>
@ -71,6 +71,7 @@ export const ContextIcon = ({ percent }: ContextIconProps) => {
);
};
function InfoRow({
label,
tokens,
@ -84,16 +85,14 @@ function InfoRow({
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2 font-mono">
<span className="min-w-[4ch] text-right">
{tokens === undefined ? "—" : tokens.toLocaleString()}
<span className="text-right min-w-[4ch]">
{tokens === undefined ? '—' : tokens.toLocaleString()}
</span>
{costText !== undefined &&
costText !== null &&
!Number.isNaN(Number.parseFloat(costText)) && (
<span className="text-muted-foreground">
${Number.parseFloat(costText).toFixed(6)}
</span>
)}
{costText !== undefined && costText !== null && !isNaN(parseFloat(costText)) && (
<span className="text-muted-foreground">
${parseFloat(costText).toFixed(6)}
</span>
)}
</div>
</div>
);
@ -105,16 +104,16 @@ export const Context = ({ className, usage, ...props }: ContextProps) => {
usage?.context?.totalMax ??
usage?.context?.combinedMax ??
usage?.context?.inputMax;
const hasMax = typeof max === "number" && Number.isFinite(max) && max > 0;
const hasMax = typeof max === 'number' && Number.isFinite(max) && max > 0;
const usedPercent = hasMax ? Math.min(100, (used / max) * 100) : 0;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"inline-flex select-none items-center gap-1 rounded-md text-sm",
"cursor-pointer bg-background text-foreground",
className
'inline-flex items-center gap-1 select-none rounded-md text-sm',
'cursor-pointer bg-background text-foreground',
className,
)}
type="button"
{...props}
@ -125,7 +124,7 @@ export const Context = ({ className, usage, ...props }: ContextProps) => {
<ContextIcon percent={usedPercent} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit p-3" side="top">
<DropdownMenuContent align="end" side="top" className="w-fit p-3">
<div className="min-w-[240px] space-y-2">
<div className="flex items-start justify-between text-sm">
<span>{usedPercent.toFixed(1)}%</span>
@ -139,43 +138,42 @@ export const Context = ({ className, usage, ...props }: ContextProps) => {
<div className="mt-1 space-y-1">
{usage?.cachedInputTokens && usage.cachedInputTokens > 0 && (
<InfoRow
costText={usage?.costUSD?.cacheReadUSD?.toString()}
label="Cache Hits"
tokens={usage?.cachedInputTokens}
costText={usage?.costUSD?.cacheReadUSD?.toString()}
/>
)}
<InfoRow
costText={usage?.costUSD?.inputUSD?.toString()}
label="Input"
tokens={usage?.inputTokens}
costText={usage?.costUSD?.inputUSD?.toString()}
/>
<InfoRow
costText={usage?.costUSD?.outputUSD?.toString()}
label="Output"
tokens={usage?.outputTokens}
costText={usage?.costUSD?.outputUSD?.toString()}
/>
<InfoRow
costText={usage?.costUSD?.reasoningUSD?.toString()}
label="Reasoning"
tokens={
usage?.reasoningTokens && usage.reasoningTokens > 0
? usage.reasoningTokens
: undefined
}
costText={usage?.costUSD?.reasoningUSD?.toString()}
/>
{usage?.costUSD?.totalUSD !== undefined && (
<>
<Separator className="mt-1" />
<div className="flex items-center justify-between pt-1 text-xs">
<div className="flex justify-between items-center pt-1 text-xs">
<span className="text-muted-foreground">Total cost</span>
<div className="flex items-center gap-2 font-mono">
<span className="min-w-[4ch] text-right" />
<span className="text-right min-w-[4ch]"></span>
<span>
{Number.isNaN(
Number.parseFloat(usage.costUSD.totalUSD.toString())
)
? "—"
: `$${Number.parseFloat(usage.costUSD.totalUSD.toString()).toFixed(6)}`}
{!isNaN(parseFloat(usage.costUSD.totalUSD.toString()))
? `$${parseFloat(usage.costUSD.totalUSD.toString()).toFixed(6)}`
: '—'
}
</span>
</div>
</div>

View file

@ -1,19 +1,19 @@
"use client";
'use client';
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ArrowDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn(
"relative flex-1 touch-pan-y overflow-y-auto will-change-scroll",
className
'relative flex-1 touch-pan-y overflow-y-auto will-change-scroll',
className,
)}
initial="smooth"
resize="smooth"
@ -30,7 +30,7 @@ export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn("p-4", className)} {...props} />
<StickToBottom.Content className={cn('p-4', className)} {...props} />
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
@ -49,8 +49,8 @@ export const ConversationScrollButton = ({
!isAtBottom && (
<Button
className={cn(
"-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full shadow-lg",
className
'-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full shadow-lg',
className,
)}
onClick={handleScrollToBottom}
size="icon"

View file

@ -1,5 +1,5 @@
import type { Experimental_GeneratedImage } from "ai";
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
import type { Experimental_GeneratedImage } from 'ai';
export type ImageProps = Experimental_GeneratedImage & {
className?: string;
@ -12,14 +12,13 @@ export const Image = ({
mediaType,
...props
}: ImageProps) => (
// biome-ignore lint/nursery/useImageSize: "Generated image without explicit size"
// biome-ignore lint/performance/noImgElement: "Generated image without explicit size"
// eslint-disable-next-line @next/next/no-img-element
<img
{...props}
alt={props.alt}
className={cn(
"h-auto max-w-full overflow-hidden rounded-md",
props.className
'h-auto max-w-full overflow-hidden rounded-md',
props.className,
)}
src={`data:${mediaType};base64,${base64}`}
/>

View file

@ -1,6 +1,19 @@
"use client";
'use client';
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { Badge } from '@/components/ui/badge';
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from '@/components/ui/carousel';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { cn } from '@/lib/utils';
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
import {
type ComponentProps,
createContext,
@ -8,41 +21,28 @@ import {
useContext,
useEffect,
useState,
} from "react";
import { Badge } from "@/components/ui/badge";
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
} from 'react';
export type InlineCitationProps = ComponentProps<"span">;
export type InlineCitationProps = ComponentProps<'span'>;
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn("group inline items-center gap-1", className)}
className={cn('group inline items-center gap-1', className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<"span">;
export type InlineCitationTextProps = ComponentProps<'span'>;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn("transition-colors group-hover:bg-accent", className)}
className={cn('transition-colors group-hover:bg-accent', className)}
{...props}
/>
);
@ -64,29 +64,29 @@ export const InlineCitationCardTrigger = ({
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn("ml-1 rounded-full", className)}
className={cn('ml-1 rounded-full', className)}
variant="secondary"
{...props}
>
{sources.length ? (
<>
{new URL(sources[0]).hostname}{" "}
{new URL(sources[0]).hostname}{' '}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
"unknown"
'unknown'
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<"div">;
export type InlineCitationCardBodyProps = ComponentProps<'div'>;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
@ -107,32 +107,32 @@ export const InlineCitationCarousel = ({
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
props: InlineCitationCarouselContentProps,
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn("w-full space-y-2 p-4 pl-8", className)}
className={cn('w-full space-y-2 p-4 pl-8', className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
export const InlineCitationCarouselHeader = ({
className,
@ -140,14 +140,14 @@ export const InlineCitationCarouselHeader = ({
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
className
'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
className,
)}
{...props}
/>
);
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
export const InlineCitationCarouselIndex = ({
children,
@ -166,7 +166,7 @@ export const InlineCitationCarouselIndex = ({
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
api.on('select', () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
@ -174,8 +174,8 @@ export const InlineCitationCarouselIndex = ({
return (
<div
className={cn(
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
className
'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
className,
)}
{...props}
>
@ -184,7 +184,7 @@ export const InlineCitationCarouselIndex = ({
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
export const InlineCitationCarouselPrev = ({
className,
@ -201,7 +201,7 @@ export const InlineCitationCarouselPrev = ({
return (
<button
aria-label="Previous"
className={cn("shrink-0", className)}
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
@ -211,7 +211,7 @@ export const InlineCitationCarouselPrev = ({
);
};
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
export const InlineCitationCarouselNext = ({
className,
@ -228,7 +228,7 @@ export const InlineCitationCarouselNext = ({
return (
<button
aria-label="Next"
className={cn("shrink-0", className)}
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
@ -238,7 +238,7 @@ export const InlineCitationCarouselNext = ({
);
};
export type InlineCitationSourceProps = ComponentProps<"div"> & {
export type InlineCitationSourceProps = ComponentProps<'div'> & {
title?: string;
url?: string;
description?: string;
@ -252,7 +252,7 @@ export const InlineCitationSource = ({
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn("space-y-1", className)} {...props}>
<div className={cn('space-y-1', className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
@ -268,7 +268,7 @@ export const InlineCitationSource = ({
</div>
);
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
export const InlineCitationQuote = ({
children,
@ -277,8 +277,8 @@ export const InlineCitationQuote = ({
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
className
'border-muted border-l-2 pl-3 text-muted-foreground text-sm italic',
className,
)}
{...props}
>

View file

@ -1,5 +1,5 @@
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
import type { HTMLAttributes } from 'react';
type LoaderIconProps = {
size?: number;
@ -9,7 +9,7 @@ const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: "currentcolor" }}
style={{ color: 'currentcolor' }}
viewBox="0 0 16 16"
width={size}
>
@ -86,8 +86,8 @@ export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
"inline-flex animate-spin items-center justify-center",
className
'inline-flex animate-spin items-center justify-center',
className,
)}
{...props}
>

View file

@ -1,19 +1,19 @@
import type { UIMessage } from "ai";
import type { ComponentProps, HTMLAttributes } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import type { UIMessage } from 'ai';
import type { ComponentProps, HTMLAttributes } from 'react';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
from: UIMessage['role'];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full items-end justify-end gap-2 py-4",
from === "user" ? "is-user" : "is-assistant flex-row-reverse justify-end",
"[&>div]:max-w-[80%]",
className
'group flex w-full items-end justify-end gap-2 py-4',
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
'[&>div]:max-w-[80%]',
className,
)}
{...props}
/>
@ -28,11 +28,11 @@ export const MessageContent = ({
}: MessageContentProps) => (
<div
className={cn(
"flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm",
"group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground",
"group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground",
"is-user:dark",
className
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
'is-user:dark',
className,
)}
{...props}
>
@ -51,8 +51,8 @@ export const MessageAvatar = ({
className,
...props
}: MessageAvatarProps) => (
<Avatar className={cn("size-8 ring-1 ring-border", className)} {...props}>
<Avatar className={cn('size-8 ring-1 ring-border', className)} {...props}>
<AvatarImage alt="" className="my-0" src={src} />
<AvatarFallback>{name?.slice(0, 2) || "ME"}</AvatarFallback>
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
</Avatar>
);

View file

@ -1,31 +1,31 @@
"use client";
'use client';
import type { ChatStatus } from "ai";
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from "lucide-react";
import type {
ComponentProps,
HTMLAttributes,
KeyboardEventHandler,
} from "react";
import { Children } from "react";
import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { ChatStatus } from 'ai';
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
import type {
ComponentProps,
HTMLAttributes,
KeyboardEventHandler,
} from 'react';
import { Children } from 'react';
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
<form
className={cn(
"w-full overflow-hidden rounded-xl border bg-background shadow-xs",
className
'w-full overflow-hidden rounded-xl border bg-background shadow-xs',
className,
)}
{...props}
/>
@ -41,7 +41,7 @@ export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
export const PromptInputTextarea = ({
onChange,
className,
placeholder = "What would you like to know?",
placeholder = 'What would you like to know?',
minHeight = 48,
maxHeight = 164,
disableAutoResize = false,
@ -49,7 +49,7 @@ export const PromptInputTextarea = ({
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
if (e.key === 'Enter') {
// Don't submit if IME composition is in progress
if (e.nativeEvent.isComposing) {
return;
@ -72,15 +72,15 @@ export const PromptInputTextarea = ({
return (
<Textarea
className={cn(
"w-full resize-none rounded-none border-none p-3 shadow-none outline-hidden ring-0",
'w-full resize-none rounded-none border-none p-3 shadow-none outline-hidden ring-0',
disableAutoResize
? "field-sizing-fixed"
? 'field-sizing-fixed'
: resizeOnNewLinesOnly
? "field-sizing-fixed"
: "field-sizing-content max-h-[6lh]",
"bg-transparent dark:bg-transparent",
"focus-visible:ring-0",
className
? 'field-sizing-fixed'
: 'field-sizing-content max-h-[6lh]',
'bg-transparent dark:bg-transparent',
'focus-visible:ring-0',
className,
)}
name="message"
onChange={(e) => {
@ -100,7 +100,7 @@ export const PromptInputToolbar = ({
...props
}: PromptInputToolbarProps) => (
<div
className={cn("flex items-center justify-between p-1", className)}
className={cn('flex items-center justify-between p-1', className)}
{...props}
/>
);
@ -113,9 +113,9 @@ export const PromptInputTools = ({
}: PromptInputToolsProps) => (
<div
className={cn(
"flex items-center gap-1",
"[&_button:first-child]:rounded-bl-xl",
className
'flex items-center gap-1',
'[&_button:first-child]:rounded-bl-xl',
className,
)}
{...props}
/>
@ -124,21 +124,21 @@ export const PromptInputTools = ({
export type PromptInputButtonProps = ComponentProps<typeof Button>;
export const PromptInputButton = ({
variant = "ghost",
variant = 'ghost',
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
(size ?? Children.count(props.children) > 1) ? "default" : "icon";
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
return (
<Button
className={cn(
"shrink-0 gap-1.5 rounded-lg",
variant === "ghost" && "text-muted-foreground",
newSize === "default" && "px-3",
className
'shrink-0 gap-1.5 rounded-lg',
variant === 'ghost' && 'text-muted-foreground',
newSize === 'default' && 'px-3',
className,
)}
size={newSize}
type="button"
@ -154,25 +154,25 @@ export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
export const PromptInputSubmit = ({
className,
variant = "default",
size = "icon",
variant = 'default',
size = 'icon',
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <SendIcon className="size-4" />;
if (status === "submitted") {
if (status === 'submitted') {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === "streaming") {
} else if (status === 'streaming') {
Icon = <SquareIcon className="size-4" />;
} else if (status === "error") {
} else if (status === 'error') {
Icon = <XIcon className="size-4" />;
}
return (
<Button
className={cn("gap-1.5 rounded-lg", className)}
className={cn('gap-1.5 rounded-lg', className)}
size={size}
type="submit"
variant={variant}
@ -199,10 +199,10 @@ export const PromptInputModelSelectTrigger = ({
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
className={cn(
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
"h-auto px-2 py-1.5",
className
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground',
'h-auto px-2 py-1.5',
className,
)}
{...props}
/>

View file

@ -1,16 +1,16 @@
"use client";
'use client';
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { Response } from "./response";
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { createContext, memo, useContext, useEffect, useState } from 'react';
import { Response } from './response';
type ReasoningContextValue = {
isStreaming: boolean;
@ -24,7 +24,7 @@ const ReasoningContext = createContext<ReasoningContextValue | null>(null);
const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
throw new Error('Reasoning components must be used within Reasoning');
}
return context;
};
@ -98,7 +98,7 @@ export const Reasoning = memo(
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn("not-prose", className)}
className={cn('not-prose', className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
@ -107,7 +107,7 @@ export const Reasoning = memo(
</Collapsible>
</ReasoningContext.Provider>
);
}
},
);
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
@ -119,8 +119,8 @@ export const ReasoningTrigger = memo(
return (
<CollapsibleTrigger
className={cn(
"flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground",
className
'flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground',
className,
)}
{...props}
>
@ -134,15 +134,15 @@ export const ReasoningTrigger = memo(
)}
<ChevronDownIcon
className={cn(
"size-3 text-muted-foreground transition-transform",
isOpen ? "rotate-180" : "rotate-0"
'size-3 text-muted-foreground transition-transform',
isOpen ? 'rotate-180' : 'rotate-0',
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
},
);
export type ReasoningContentProps = ComponentProps<
@ -155,17 +155,17 @@ export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-2 text-muted-foreground text-xs",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
'mt-2 text-muted-foreground text-xs',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
>
<Response className="grid gap-2">{children}</Response>
</CollapsibleContent>
)
),
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
Reasoning.displayName = 'Reasoning';
ReasoningTrigger.displayName = 'ReasoningTrigger';
ReasoningContent.displayName = 'ReasoningContent';

View file

@ -1,8 +1,8 @@
"use client";
'use client';
import { type ComponentProps, memo } from "react";
import { Streamdown } from "streamdown";
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
import { type ComponentProps, memo } from 'react';
import { Streamdown } from 'streamdown';
type ResponseProps = ComponentProps<typeof Streamdown>;
@ -10,13 +10,13 @@ export const Response = memo(
({ className, ...props }: ResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
className
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto',
className,
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
(prevProps, nextProps) => prevProps.children === nextProps.children,
);
Response.displayName = "Response";
Response.displayName = 'Response';

View file

@ -1,19 +1,19 @@
"use client";
'use client';
import { BookIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { BookIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
export type SourcesProps = ComponentProps<"div">;
export type SourcesProps = ComponentProps<'div'>;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn("not-prose mb-4 text-primary text-xs", className)}
className={cn('not-prose mb-4 text-primary text-xs', className)}
{...props}
/>
);
@ -46,15 +46,15 @@ export const SourcesContent = ({
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
"mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
'mt-3 flex w-fit flex-col gap-2',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
/>
);
export type SourceProps = ComponentProps<"a">;
export type SourceProps = ComponentProps<'a'>;
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a

View file

@ -1,9 +1,9 @@
"use client";
'use client';
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
@ -13,14 +13,14 @@ export const Suggestions = ({
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
@ -29,8 +29,8 @@ export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
variant = 'outline',
size = 'sm',
children,
...props
}: SuggestionProps) => {
@ -40,7 +40,7 @@ export const Suggestion = ({
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
className={cn('cursor-pointer rounded-full px-4', className)}
onClick={handleClick}
size={size}
type="button"

View file

@ -1,15 +1,15 @@
"use client";
'use client';
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { ChevronDownIcon, SearchIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
export type TaskItemFileProps = ComponentProps<"div">;
export type TaskItemFileProps = ComponentProps<'div'>;
export const TaskItemFile = ({
children,
@ -18,8 +18,8 @@ export const TaskItemFile = ({
}: TaskItemFileProps) => (
<div
className={cn(
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
className
'inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs',
className,
)}
{...props}
>
@ -27,10 +27,10 @@ export const TaskItemFile = ({
</div>
);
export type TaskItemProps = ComponentProps<"div">;
export type TaskItemProps = ComponentProps<'div'>;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
{children}
</div>
);
@ -44,8 +44,8 @@ export const Task = ({
}: TaskProps) => (
<Collapsible
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in",
className
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
defaultOpen={defaultOpen}
{...props}
@ -62,7 +62,7 @@ export const TaskTrigger = ({
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
{children ?? (
<div className="flex cursor-pointer items-center gap-2 text-muted-foreground hover:text-foreground">
<SearchIcon className="size-4" />
@ -82,8 +82,8 @@ export const TaskContent = ({
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
>

View file

@ -1,6 +1,13 @@
"use client";
'use client';
import type { ToolUIPart } from "ai";
import { Badge } from '@/components/ui/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type { ToolUIPart } from 'ai';
import {
CheckCircleIcon,
ChevronDownIcon,
@ -8,45 +15,38 @@ import {
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { CodeBlock } from "./code-block";
} from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { CodeBlock } from './code-block';
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("not-prose mb-4 w-full rounded-md border", className)}
className={cn('not-prose mb-4 w-full rounded-md border', className)}
{...props}
/>
);
export type ToolHeaderProps = {
type: ToolUIPart["type"];
state: ToolUIPart["state"];
type: ToolUIPart['type'];
state: ToolUIPart['state'];
className?: string;
};
const getStatusBadge = (status: ToolUIPart["state"]) => {
const getStatusBadge = (status: ToolUIPart['state']) => {
const labels = {
"input-streaming": "Pending",
"input-available": "Running",
"output-available": "Completed",
"output-error": "Error",
'input-streaming': 'Pending',
'input-available': 'Running',
'output-available': 'Completed',
'output-error': 'Error',
} as const;
const icons = {
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
'input-streaming': <CircleIcon className="size-4" />,
'input-available': <ClockIcon className="size-4 animate-pulse" />,
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
'output-error': <XCircleIcon className="size-4 text-red-600" />,
} as const;
return (
@ -68,8 +68,8 @@ export const ToolHeader = ({
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
"flex w-full min-w-0 items-center justify-between gap-2 p-3",
className
'flex w-full min-w-0 items-center justify-between gap-2 p-3',
className,
)}
{...props}
>
@ -89,19 +89,19 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolUIPart["input"];
export type ToolInputProps = ComponentProps<'div'> & {
input: ToolUIPart['input'];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
@ -111,9 +111,9 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
export type ToolOutputProps = ComponentProps<'div'> & {
output: ReactNode;
errorText: ToolUIPart["errorText"];
errorText: ToolUIPart['errorText'];
};
export const ToolOutput = ({
@ -127,16 +127,16 @@ export const ToolOutput = ({
}
return (
<div className={cn("space-y-2 p-4", className)} {...props}>
<div className={cn('space-y-2 p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
{errorText ? 'Error' : 'Result'}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
? 'bg-destructive/10 text-destructive'
: 'bg-muted/50 text-foreground',
)}
>
{errorText && <div>{errorText}</div>}

View file

@ -1,22 +1,22 @@
"use client";
'use client';
import { ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { ChevronDownIcon } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
export type WebPreviewContextValue = {
url: string;
@ -30,12 +30,12 @@ const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => {
const context = useContext(WebPreviewContext);
if (!context) {
throw new Error("WebPreview components must be used within a WebPreview");
throw new Error('WebPreview components must be used within a WebPreview');
}
return context;
};
export type WebPreviewProps = ComponentProps<"div"> & {
export type WebPreviewProps = ComponentProps<'div'> & {
defaultUrl?: string;
onUrlChange?: (url: string) => void;
};
@ -43,7 +43,7 @@ export type WebPreviewProps = ComponentProps<"div"> & {
export const WebPreview = ({
className,
children,
defaultUrl = "",
defaultUrl = '',
onUrlChange,
...props
}: WebPreviewProps) => {
@ -66,8 +66,8 @@ export const WebPreview = ({
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
"flex size-full flex-col rounded-lg border bg-card",
className
'flex size-full flex-col rounded-lg border bg-card',
className,
)}
{...props}
>
@ -77,7 +77,7 @@ export const WebPreview = ({
);
};
export type WebPreviewNavigationProps = ComponentProps<"div">;
export type WebPreviewNavigationProps = ComponentProps<'div'>;
export const WebPreviewNavigation = ({
className,
@ -85,7 +85,7 @@ export const WebPreviewNavigation = ({
...props
}: WebPreviewNavigationProps) => (
<div
className={cn("flex items-center gap-1 border-b p-2", className)}
className={cn('flex items-center gap-1 border-b p-2', className)}
{...props}
>
{children}
@ -135,7 +135,7 @@ export const WebPreviewUrl = ({
const { url, setUrl } = useWebPreview();
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
@ -154,7 +154,7 @@ export const WebPreviewUrl = ({
);
};
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
loading?: ReactNode;
};
@ -169,7 +169,7 @@ export const WebPreviewBody = ({
return (
<div className="flex-1">
<iframe
className={cn("size-full", className)}
className={cn('size-full', className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
@ -180,9 +180,9 @@ export const WebPreviewBody = ({
);
};
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
logs?: Array<{
level: "log" | "warn" | "error";
level: 'log' | 'warn' | 'error';
message: string;
timestamp: Date;
}>;
@ -198,7 +198,7 @@ export const WebPreviewConsole = ({
return (
<Collapsible
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
className={cn('border-t bg-muted/50 font-mono text-sm', className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
@ -211,16 +211,16 @@ export const WebPreviewConsole = ({
Console
<ChevronDownIcon
className={cn(
"h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180"
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180',
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
"px-4 pb-4",
"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 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in"
'px-4 pb-4',
'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 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
@ -230,16 +230,16 @@ export const WebPreviewConsole = ({
logs.map((log, index) => (
<div
className={cn(
"text-xs",
log.level === "error" && "text-destructive",
log.level === "warn" && "text-yellow-600",
log.level === "log" && "text-foreground"
'text-xs',
log.level === 'error' && 'text-destructive',
log.level === 'warn' && 'text-yellow-600',
log.level === 'log' && 'text-foreground',
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{" "}
</span>{' '}
{log.message}
</div>
))

View file

@ -1,26 +1,26 @@
import { motion } from "framer-motion";
import { motion } from 'framer-motion';
export const Greeting = () => {
return (
<div
className="mx-auto mt-4 flex size-full max-w-3xl flex-col justify-center px-4 md:mt-16 md:px-8"
key="overview"
className="mx-auto mt-4 flex size-full max-w-3xl flex-col justify-center px-4 md:mt-16 md:px-8"
>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="font-semibold text-xl md:text-2xl"
exit={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.5 }}
className="font-semibold text-xl md:text-2xl"
>
Hello there!
</motion.div>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="text-xl text-zinc-500 md:text-2xl"
exit={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.6 }}
className="text-xl text-zinc-500 md:text-2xl"
>
How can I help you today?
</motion.div>

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
import cn from "classnames";
import { LoaderIcon } from "./icons";
import { LoaderIcon } from './icons';
import cn from 'classnames';
type ImageEditorProps = {
interface ImageEditorProps {
title: string;
content: string;
isCurrentVersion: boolean;
currentVersionIndex: number;
status: string;
isInline: boolean;
};
}
export function ImageEditor({
title,
@ -18,12 +18,12 @@ export function ImageEditor({
}: ImageEditorProps) {
return (
<div
className={cn("flex w-full flex-row items-center justify-center", {
"h-[calc(100dvh-60px)]": !isInline,
"h-[200px]": isInline,
className={cn('flex w-full flex-row items-center justify-center', {
'h-[calc(100dvh-60px)]': !isInline,
'h-[200px]': isInline,
})}
>
{status === "streaming" ? (
{status === 'streaming' ? (
<div className="flex flex-row items-center gap-4">
{!isInline && (
<div className="animate-spin">
@ -34,13 +34,12 @@ export function ImageEditor({
</div>
) : (
<picture>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
<img
alt={title}
className={cn("h-fit w-full max-w-[800px]", {
"p-0 md:p-20": !isInline,
className={cn('h-fit w-full max-w-[800px]', {
'p-0 md:p-20': !isInline,
})}
src={`data:image/png;base64,${content}`}
alt={title}
/>
</picture>
)}

View file

@ -1,12 +1,14 @@
import equal from "fast-deep-equal";
import { memo } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { useCopyToClipboard } from "usehooks-ts";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { Action, Actions } from "./elements/actions";
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
import { useSWRConfig } from 'swr';
import { useCopyToClipboard } from 'usehooks-ts';
import type { Vote } from '@/lib/db/schema';
import { CopyIcon, ThumbDownIcon, ThumbUpIcon, PencilEditIcon } from './icons';
import { Actions, Action } from './elements/actions';
import { memo } from 'react';
import equal from 'fast-deep-equal';
import { toast } from 'sonner';
import type { ChatMessage } from '@/lib/types';
export function PureMessageActions({
chatId,
@ -19,19 +21,17 @@ export function PureMessageActions({
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMode?: (mode: "view" | "edit") => void;
setMode?: (mode: 'view' | 'edit') => void;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) {
return null;
}
if (isLoading) return null;
const textFromParts = message.parts
?.filter((part) => part.type === "text")
?.filter((part) => part.type === 'text')
.map((part) => part.text)
.join("\n")
.join('\n')
.trim();
const handleCopy = async () => {
@ -41,24 +41,24 @@ export function PureMessageActions({
}
await copyToClipboard(textFromParts);
toast.success("Copied to clipboard!");
toast.success('Copied to clipboard!');
};
// User messages get edit (on hover) and copy actions
if (message.role === "user") {
if (message.role === 'user') {
return (
<Actions className="-mr-0.5 justify-end">
<div className="relative">
{setMode && (
<Action
className="-left-10 absolute top-0 opacity-0 transition-opacity group-hover/message:opacity-100"
onClick={() => setMode("edit")}
tooltip="Edit"
onClick={() => setMode('edit')}
className="-left-10 absolute top-0 opacity-0 transition-opacity group-hover/message:opacity-100"
>
<PencilEditIcon />
</Action>
)}
<Action onClick={handleCopy} tooltip="Copy">
<Action tooltip="Copy" onClick={handleCopy}>
<CopyIcon />
</Action>
</div>
@ -68,35 +68,34 @@ export function PureMessageActions({
return (
<Actions className="-ml-0.5">
<Action onClick={handleCopy} tooltip="Copy">
<Action tooltip="Copy" onClick={handleCopy}>
<CopyIcon />
</Action>
<Action
tooltip="Upvote Response"
data-testid="message-upvote"
disabled={vote?.isUpvoted}
onClick={() => {
const upvote = fetch("/api/vote", {
method: "PATCH",
onClick={async () => {
const upvote = fetch('/api/vote', {
method: 'PATCH',
body: JSON.stringify({
chatId,
messageId: message.id,
type: "up",
type: 'up',
}),
});
toast.promise(upvote, {
loading: "Upvoting Response...",
loading: 'Upvoting Response...',
success: () => {
mutate<Vote[]>(
mutate<Array<Vote>>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) {
return [];
}
if (!currentVotes) return [];
const votesWithoutCurrent = currentVotes.filter(
(currentVote) => currentVote.messageId !== message.id
(vote) => vote.messageId !== message.id,
);
return [
@ -108,44 +107,42 @@ export function PureMessageActions({
},
];
},
{ revalidate: false }
{ revalidate: false },
);
return "Upvoted Response!";
return 'Upvoted Response!';
},
error: "Failed to upvote response.",
error: 'Failed to upvote response.',
});
}}
tooltip="Upvote Response"
>
<ThumbUpIcon />
</Action>
<Action
tooltip="Downvote Response"
data-testid="message-downvote"
disabled={vote && !vote.isUpvoted}
onClick={() => {
const downvote = fetch("/api/vote", {
method: "PATCH",
onClick={async () => {
const downvote = fetch('/api/vote', {
method: 'PATCH',
body: JSON.stringify({
chatId,
messageId: message.id,
type: "down",
type: 'down',
}),
});
toast.promise(downvote, {
loading: "Downvoting Response...",
loading: 'Downvoting Response...',
success: () => {
mutate<Vote[]>(
mutate<Array<Vote>>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) {
return [];
}
if (!currentVotes) return [];
const votesWithoutCurrent = currentVotes.filter(
(currentVote) => currentVote.messageId !== message.id
(vote) => vote.messageId !== message.id,
);
return [
@ -157,15 +154,14 @@ export function PureMessageActions({
},
];
},
{ revalidate: false }
{ revalidate: false },
);
return "Downvoted Response!";
return 'Downvoted Response!';
},
error: "Failed to downvote response.",
error: 'Failed to downvote response.',
});
}}
tooltip="Downvote Response"
>
<ThumbDownIcon />
</Action>
@ -176,13 +172,9 @@ export function PureMessageActions({
export const MessageActions = memo(
PureMessageActions,
(prevProps, nextProps) => {
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
if (prevProps.isLoading !== nextProps.isLoading) {
return false;
}
if (!equal(prevProps.vote, nextProps.vote)) return false;
if (prevProps.isLoading !== nextProps.isLoading) return false;
return true;
}
},
);

View file

@ -1,25 +1,24 @@
"use client";
'use client';
import type { UseChatHelpers } from "@ai-sdk/react";
import { Button } from './ui/button';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { deleteTrailingMessages } from "@/app/(chat)/actions";
import type { ChatMessage } from "@/lib/types";
import { getTextFromMessage } from "@/lib/utils";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
} from 'react';
import { Textarea } from './ui/textarea';
import { deleteTrailingMessages } from '@/app/(chat)/actions';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { ChatMessage } from '@/lib/types';
import { getTextFromMessage } from '@/lib/utils';
export type MessageEditorProps = {
message: ChatMessage;
setMode: Dispatch<SetStateAction<"view" | "edit">>;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
setMode: Dispatch<SetStateAction<'view' | 'edit'>>;
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
};
export function MessageEditor({
@ -31,22 +30,22 @@ export function MessageEditor({
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [draftContent, setDraftContent] = useState<string>(
getTextFromMessage(message)
getTextFromMessage(message),
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
}
}, []);
useEffect(() => {
if (textareaRef.current) {
adjustHeight();
}
}, [adjustHeight]);
}, []);
const adjustHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
}
};
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraftContent(event.target.value);
@ -56,26 +55,27 @@ export function MessageEditor({
return (
<div className="flex w-full flex-col gap-2">
<Textarea
className="w-full resize-none overflow-hidden rounded-xl bg-transparent text-base! outline-hidden"
data-testid="message-editor"
onChange={handleInput}
ref={textareaRef}
className="w-full resize-none overflow-hidden rounded-xl bg-transparent text-base! outline-hidden"
value={draftContent}
onChange={handleInput}
/>
<div className="flex flex-row justify-end gap-2">
<Button
variant="outline"
className="h-fit px-3 py-2"
onClick={() => {
setMode("view");
setMode('view');
}}
variant="outline"
>
Cancel
</Button>
<Button
className="h-fit px-3 py-2"
data-testid="message-editor-send-button"
variant="default"
className="h-fit px-3 py-2"
disabled={isSubmitting}
onClick={async () => {
setIsSubmitting(true);
@ -90,7 +90,7 @@ export function MessageEditor({
if (index !== -1) {
const updatedMessage: ChatMessage = {
...message,
parts: [{ type: "text", text: draftContent }],
parts: [{ type: 'text', text: draftContent }],
};
return [...messages.slice(0, index), updatedMessage];
@ -99,12 +99,11 @@ export function MessageEditor({
return messages;
});
setMode("view");
setMode('view');
regenerate();
}}
variant="default"
>
{isSubmitting ? "Sending..." : "Send"}
{isSubmitting ? 'Sending...' : 'Send'}
</Button>
</div>
</div>

View file

@ -1,16 +1,16 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import { useState, useEffect } from 'react';
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "./elements/reasoning";
ReasoningContent,
} from './elements/reasoning';
type MessageReasoningProps = {
interface MessageReasoningProps {
isLoading: boolean;
reasoning: string;
};
}
export function MessageReasoning({
isLoading,
@ -23,12 +23,12 @@ export function MessageReasoning({
setHasBeenStreaming(true);
}
}, [isLoading]);
return (
<Reasoning
data-testid="message-reasoning"
defaultOpen={hasBeenStreaming}
isStreaming={isLoading}
defaultOpen={hasBeenStreaming}
data-testid="message-reasoning"
>
<ReasoningTrigger />
<ReasoningContent>{reasoning}</ReasoningContent>

View file

@ -1,29 +1,29 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { motion } from "framer-motion";
import { memo, useState } from "react";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils";
import { useDataStream } from "./data-stream-provider";
import { DocumentToolResult } from "./document";
import { DocumentPreview } from "./document-preview";
import { MessageContent } from "./elements/message";
import { Response } from "./elements/response";
'use client';
import { motion } from 'framer-motion';
import { memo, useState } from 'react';
import type { Vote } from '@/lib/db/schema';
import { DocumentToolResult } from './document';
import { SparklesIcon } from './icons';
import { Response } from './elements/response';
import { MessageContent } from './elements/message';
import {
Tool,
ToolContent,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from "./elements/tool";
import { SparklesIcon } from "./icons";
import { MessageActions } from "./message-actions";
import { MessageEditor } from "./message-editor";
import { MessageReasoning } from "./message-reasoning";
import { PreviewAttachment } from "./preview-attachment";
import { Weather } from "./weather";
} from './elements/tool';
import { MessageActions } from './message-actions';
import { PreviewAttachment } from './preview-attachment';
import { Weather } from './weather';
import equal from 'fast-deep-equal';
import { cn, sanitizeText } from '@/lib/utils';
import { MessageEditor } from './message-editor';
import { DocumentPreview } from './document-preview';
import { MessageReasoning } from './message-reasoning';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { ChatMessage } from '@/lib/types';
import { useDataStream } from './data-stream-provider';
const PurePreviewMessage = ({
chatId,
@ -34,73 +34,75 @@ const PurePreviewMessage = ({
regenerate,
isReadonly,
requiresScrollPadding,
isArtifactVisible,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
isReadonly: boolean;
requiresScrollPadding: boolean;
isArtifactVisible: boolean;
}) => {
const [mode, setMode] = useState<"view" | "edit">("view");
const [mode, setMode] = useState<'view' | 'edit'>('view');
const attachmentsFromMessage = message.parts.filter(
(part) => part.type === "file"
(part) => part.type === 'file',
);
useDataStream();
return (
<motion.div
animate={{ opacity: 1 }}
className="group/message w-full"
data-role={message.role}
data-testid={`message-${message.role}`}
className="group/message w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
data-role={message.role}
>
<div
className={cn("flex w-full items-start gap-2 md:gap-3", {
"justify-end": message.role === "user" && mode !== "edit",
"justify-start": message.role === "assistant",
className={cn('flex w-full items-start gap-2 md:gap-3', {
'justify-end': message.role === 'user' && mode !== 'edit',
'justify-start': message.role === 'assistant',
})}
>
{message.role === "assistant" && (
{message.role === 'assistant' && (
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
<SparklesIcon size={14} />
</div>
)}
<div
className={cn("flex flex-col", {
"gap-2 md:gap-4": message.parts?.some(
(p) => p.type === "text" && p.text?.trim()
className={cn('flex flex-col', {
'gap-2 md:gap-4': message.parts?.some(
(p) => p.type === 'text' && p.text?.trim(),
),
"min-h-96": message.role === "assistant" && requiresScrollPadding,
"w-full":
(message.role === "assistant" &&
'min-h-96': message.role === 'assistant' && requiresScrollPadding,
'w-full':
(message.role === 'assistant' &&
message.parts?.some(
(p) => p.type === "text" && p.text?.trim()
(p) => p.type === 'text' && p.text?.trim(),
)) ||
mode === "edit",
"max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]":
message.role === "user" && mode !== "edit",
mode === 'edit',
'max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]':
message.role === 'user' && mode !== 'edit',
})}
>
{attachmentsFromMessage.length > 0 && (
<div
data-testid={`message-attachments`}
className="flex flex-row justify-end gap-2"
data-testid={"message-attachments"}
>
{attachmentsFromMessage.map((attachment) => (
<PreviewAttachment
key={attachment.url}
attachment={{
name: attachment.filename ?? "file",
name: attachment.filename ?? 'file',
contentType: attachment.mediaType,
url: attachment.url,
}}
key={attachment.url}
/>
))}
</div>
@ -110,31 +112,31 @@ const PurePreviewMessage = ({
const { type } = part;
const key = `message-${message.id}-part-${index}`;
if (type === "reasoning" && part.text?.trim().length > 0) {
if (type === 'reasoning' && part.text?.trim().length > 0) {
return (
<MessageReasoning
isLoading={isLoading}
key={key}
isLoading={isLoading}
reasoning={part.text}
/>
);
}
if (type === "text") {
if (mode === "view") {
if (type === 'text') {
if (mode === 'view') {
return (
<div key={key}>
<MessageContent
className={cn({
"w-fit break-words rounded-2xl px-3 py-2 text-right text-white":
message.role === "user",
"bg-transparent px-0 py-0 text-left":
message.role === "assistant",
})}
data-testid="message-content"
className={cn({
'w-fit break-words rounded-2xl px-3 py-2 text-right text-white':
message.role === 'user',
'bg-transparent px-0 py-0 text-left':
message.role === 'assistant',
})}
style={
message.role === "user"
? { backgroundColor: "#006cff" }
message.role === 'user'
? { backgroundColor: '#006cff' }
: undefined
}
>
@ -144,20 +146,20 @@ const PurePreviewMessage = ({
);
}
if (mode === "edit") {
if (mode === 'edit') {
return (
<div
className="flex w-full flex-row items-start gap-3"
key={key}
className="flex w-full flex-row items-start gap-3"
>
<div className="size-8" />
<div className="min-w-0 flex-1">
<MessageEditor
key={message.id}
message={message}
regenerate={regenerate}
setMessages={setMessages}
setMode={setMode}
setMessages={setMessages}
regenerate={regenerate}
/>
</div>
</div>
@ -165,20 +167,20 @@ const PurePreviewMessage = ({
}
}
if (type === "tool-getWeather") {
if (type === 'tool-getWeather') {
const { toolCallId, state } = part;
return (
<Tool defaultOpen={true} key={toolCallId}>
<ToolHeader state={state} type="tool-getWeather" />
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-getWeather" state={state} />
<ToolContent>
{state === "input-available" && (
{state === 'input-available' && (
<ToolInput input={part.input} />
)}
{state === "output-available" && (
{state === 'output-available' && (
<ToolOutput
errorText={undefined}
output={<Weather weatherAtLocation={part.output} />}
errorText={undefined}
/>
)}
</ToolContent>
@ -186,14 +188,14 @@ const PurePreviewMessage = ({
);
}
if (type === "tool-createDocument") {
if (type === 'tool-createDocument') {
const { toolCallId } = part;
if (part.output && "error" in part.output) {
if (part.output && 'error' in part.output) {
return (
<div
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
>
Error creating document: {String(part.output.error)}
</div>
@ -202,21 +204,21 @@ const PurePreviewMessage = ({
return (
<DocumentPreview
isReadonly={isReadonly}
key={toolCallId}
isReadonly={isReadonly}
result={part.output}
/>
);
}
if (type === "tool-updateDocument") {
if (type === 'tool-updateDocument') {
const { toolCallId } = part;
if (part.output && "error" in part.output) {
if (part.output && 'error' in part.output) {
return (
<div
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
>
Error updating document: {String(part.output.error)}
</div>
@ -224,60 +226,58 @@ const PurePreviewMessage = ({
}
return (
<div className="relative" key={toolCallId}>
<div key={toolCallId} className="relative">
<DocumentPreview
args={{ ...part.output, isUpdate: true }}
isReadonly={isReadonly}
result={part.output}
args={{ ...part.output, isUpdate: true }}
/>
</div>
);
}
if (type === "tool-requestSuggestions") {
if (type === 'tool-requestSuggestions') {
const { toolCallId, state } = part;
return (
<Tool defaultOpen={true} key={toolCallId}>
<ToolHeader state={state} type="tool-requestSuggestions" />
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-requestSuggestions" state={state} />
<ToolContent>
{state === "input-available" && (
{state === 'input-available' && (
<ToolInput input={part.input} />
)}
{state === "output-available" && (
{state === 'output-available' && (
<ToolOutput
errorText={undefined}
output={
"error" in part.output ? (
'error' in part.output ? (
<div className="rounded border p-2 text-red-500">
Error: {String(part.output.error)}
</div>
) : (
<DocumentToolResult
isReadonly={isReadonly}
result={part.output}
type="request-suggestions"
result={part.output}
isReadonly={isReadonly}
/>
)
}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
);
}
return null;
})}
{!isReadonly && (
<MessageActions
chatId={chatId}
isLoading={isLoading}
key={`action-${message.id}`}
chatId={chatId}
message={message}
setMode={setMode}
vote={vote}
isLoading={isLoading}
setMode={setMode}
/>
)}
</div>
@ -289,36 +289,27 @@ const PurePreviewMessage = ({
export const PreviewMessage = memo(
PurePreviewMessage,
(prevProps, nextProps) => {
if (prevProps.isLoading !== nextProps.isLoading) {
if (prevProps.isLoading !== nextProps.isLoading) return false;
if (prevProps.message.id !== nextProps.message.id) return false;
if (prevProps.requiresScrollPadding !== nextProps.requiresScrollPadding)
return false;
}
if (prevProps.message.id !== nextProps.message.id) {
return false;
}
if (prevProps.requiresScrollPadding !== nextProps.requiresScrollPadding) {
return false;
}
if (!equal(prevProps.message.parts, nextProps.message.parts)) {
return false;
}
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
if (!equal(prevProps.vote, nextProps.vote)) return false;
return false;
}
},
);
export const ThinkingMessage = () => {
const role = "assistant";
const role = 'assistant';
return (
<motion.div
animate={{ opacity: 1 }}
className="group/message w-full"
data-role={role}
data-testid="message-assistant-loading"
className="group/message w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
data-role={role}
>
<div className="flex items-start justify-start gap-3">
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
@ -338,20 +329,20 @@ export const ThinkingMessage = () => {
const LoadingText = ({ children }: { children: React.ReactNode }) => {
return (
<motion.div
animate={{ backgroundPosition: ["100% 50%", "-100% 50%"] }}
className="flex items-center text-transparent"
style={{
background:
"linear-gradient(90deg, hsl(var(--muted-foreground)) 0%, hsl(var(--muted-foreground)) 35%, hsl(var(--foreground)) 50%, hsl(var(--muted-foreground)) 65%, hsl(var(--muted-foreground)) 100%)",
backgroundSize: "200% 100%",
WebkitBackgroundClip: "text",
backgroundClip: "text",
}}
animate={{ backgroundPosition: ['100% 50%', '-100% 50%'] }}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
ease: 'linear',
}}
style={{
background:
'linear-gradient(90deg, hsl(var(--muted-foreground)) 0%, hsl(var(--muted-foreground)) 35%, hsl(var(--foreground)) 50%, hsl(var(--muted-foreground)) 65%, hsl(var(--muted-foreground)) 100%)',
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
}}
className="flex items-center text-transparent"
>
{children}
</motion.div>

View file

@ -1,26 +1,26 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { ArrowDownIcon } from "lucide-react";
import { memo, useEffect } from "react";
import { useMessages } from "@/hooks/use-messages";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { useDataStream } from "./data-stream-provider";
import { Conversation, ConversationContent } from "./elements/conversation";
import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message";
import { PreviewMessage, ThinkingMessage } from './message';
import { Greeting } from './greeting';
import { memo, useEffect } from 'react';
import type { Vote } from '@/lib/db/schema';
import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react';
import { useMessages } from '@/hooks/use-messages';
import type { ChatMessage } from '@/lib/types';
import { useDataStream } from './data-stream-provider';
import { Conversation, ConversationContent } from './elements/conversation';
import { ArrowDownIcon } from 'lucide-react';
type MessagesProps = {
interface MessagesProps {
chatId: string;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
status: UseChatHelpers<ChatMessage>['status'];
votes: Array<Vote> | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
regenerate: UseChatHelpers<ChatMessage>['regenerate'];
isReadonly: boolean;
isArtifactVisible: boolean;
selectedModelId: string;
};
}
function PureMessages({
chatId,
@ -30,6 +30,7 @@ function PureMessages({
setMessages,
regenerate,
isReadonly,
isArtifactVisible,
selectedModelId,
}: MessagesProps) {
const {
@ -39,19 +40,20 @@ function PureMessages({
scrollToBottom,
hasSentMessage,
} = useMessages({
chatId,
status,
});
useDataStream();
useEffect(() => {
if (status === "submitted") {
if (status === 'submitted') {
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
behavior: 'smooth',
});
}
});
@ -60,9 +62,9 @@ function PureMessages({
return (
<div
className="overscroll-behavior-contain -webkit-overflow-scrolling-touch flex-1 touch-pan-y overflow-y-scroll"
ref={messagesContainerRef}
style={{ overflowAnchor: "none" }}
className="overscroll-behavior-contain -webkit-overflow-scrolling-touch flex-1 touch-pan-y overflow-y-scroll"
style={{ overflowAnchor: 'none' }}
>
<Conversation className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 md:gap-6">
<ConversationContent className="flex flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
@ -70,44 +72,45 @@ function PureMessages({
{messages.map((message, index) => (
<PreviewMessage
chatId={chatId}
isLoading={
status === "streaming" && messages.length - 1 === index
}
isReadonly={isReadonly}
key={message.id}
chatId={chatId}
message={message}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
isLoading={
status === 'streaming' && messages.length - 1 === index
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
setMessages={setMessages}
regenerate={regenerate}
isReadonly={isReadonly}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
isArtifactVisible={isArtifactVisible}
/>
))}
{status === "submitted" &&
{status === 'submitted' &&
messages.length > 0 &&
messages.at(-1)?.role === "user" &&
selectedModelId !== "chat-model-reasoning" && <ThinkingMessage />}
messages[messages.length - 1].role === 'user' &&
selectedModelId !== 'chat-model-reasoning' && <ThinkingMessage />}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
className="min-h-[24px] min-w-[24px] shrink-0"
/>
</ConversationContent>
</Conversation>
{!isAtBottom && (
<button
aria-label="Scroll to bottom"
className="-translate-x-1/2 absolute bottom-40 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-colors hover:bg-muted"
onClick={() => scrollToBottom("smooth")}
onClick={() => scrollToBottom('smooth')}
type="button"
aria-label="Scroll to bottom"
>
<ArrowDownIcon className="size-4" />
</button>
@ -117,25 +120,13 @@ function PureMessages({
}
export const Messages = memo(PureMessages, (prevProps, nextProps) => {
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) {
return true;
}
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) return true;
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages)) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
if (prevProps.status !== nextProps.status) return false;
if (prevProps.selectedModelId !== nextProps.selectedModelId) return false;
if (prevProps.messages.length !== nextProps.messages.length) return false;
if (!equal(prevProps.messages, nextProps.messages)) return false;
if (!equal(prevProps.votes, nextProps.votes)) return false;
return false;
});

View file

@ -1,19 +1,21 @@
"use client";
'use client';
import type { Session } from "next-auth";
import { startTransition, useMemo, useOptimistic, useState } from "react";
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
import { Button } from "@/components/ui/button";
import { startTransition, useMemo, useOptimistic, useState } from 'react';
import { saveChatModelAsCookie } from '@/app/(chat)/actions';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { chatModels } from "@/lib/ai/models";
import { cn } from "@/lib/utils";
import { CheckCircleFillIcon, ChevronDownIcon } from "./icons";
} from '@/components/ui/dropdown-menu';
import { chatModels } from '@/lib/ai/models';
import { cn } from '@/lib/utils';
import { CheckCircleFillIcon, ChevronDownIcon } from './icons';
import { entitlementsByUserType } from '@/lib/ai/entitlements';
import type { Session } from 'next-auth';
export function ModelSelector({
session,
@ -31,30 +33,30 @@ export function ModelSelector({
const { availableChatModelIds } = entitlementsByUserType[userType];
const availableChatModels = chatModels.filter((chatModel) =>
availableChatModelIds.includes(chatModel.id)
availableChatModelIds.includes(chatModel.id),
);
const selectedChatModel = useMemo(
() =>
availableChatModels.find(
(chatModel) => chatModel.id === optimisticModelId
(chatModel) => chatModel.id === optimisticModelId,
),
[optimisticModelId, availableChatModels]
[optimisticModelId, availableChatModels],
);
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
asChild
className={cn(
"w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
'w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className,
)}
>
<Button
className="md:h-[34px] md:px-2"
data-testid="model-selector"
variant="outline"
className="md:h-[34px] md:px-2"
>
{selectedChatModel?.name}
<ChevronDownIcon />
@ -69,8 +71,6 @@ export function ModelSelector({
return (
<DropdownMenuItem
asChild
data-active={id === optimisticModelId}
data-testid={`model-selector-item-${id}`}
key={id}
onSelect={() => {
@ -81,14 +81,16 @@ export function ModelSelector({
saveChatModelAsCookie(id);
});
}}
data-active={id === optimisticModelId}
asChild
>
<button
className="group/item flex w-full flex-row items-center justify-between gap-2 sm:gap-4"
type="button"
className="flex flex-row gap-2 justify-between items-center w-full group/item sm:gap-4"
>
<div className="flex flex-col items-start gap-1">
<div className="flex flex-col gap-1 items-start">
<div className="text-sm sm:text-base">{chatModel.name}</div>
<div className="line-clamp-2 text-muted-foreground text-xs">
<div className="text-xs line-clamp-2 text-muted-foreground">
{chatModel.description}
</div>
</div>
@ -103,4 +105,4 @@ export function ModelSelector({
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View file

@ -1,51 +1,51 @@
"use client";
'use client';
import type { UseChatHelpers } from "@ai-sdk/react";
import { Trigger } from "@radix-ui/react-select";
import type { UIMessage } from "ai";
import equal from "fast-deep-equal";
import type { UIMessage } from 'ai';
import {
type ChangeEvent,
type Dispatch,
memo,
type SetStateAction,
startTransition,
useCallback,
useEffect,
useMemo,
useRef,
useEffect,
useState,
} from "react";
import { toast } from "sonner";
import { useLocalStorage, useWindowSize } from "usehooks-ts";
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
import { SelectItem } from "@/components/ui/select";
import { chatModels } from "@/lib/ai/models";
import { myProvider } from "@/lib/ai/providers";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils";
import { Context } from "./elements/context";
useCallback,
type Dispatch,
type SetStateAction,
type ChangeEvent,
memo,
useMemo,
} from 'react';
import { toast } from 'sonner';
import { useLocalStorage, useWindowSize } from 'usehooks-ts';
import {
ArrowUpIcon,
PaperclipIcon,
CpuIcon,
StopIcon,
ChevronDownIcon,
} from './icons';
import { PreviewAttachment } from './preview-attachment';
import { Button } from './ui/button';
import { SuggestedActions } from './suggested-actions';
import {
PromptInput,
PromptInputModelSelect,
PromptInputModelSelectContent,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
} from "./elements/prompt-input";
import {
ArrowUpIcon,
ChevronDownIcon,
CpuIcon,
PaperclipIcon,
StopIcon,
} from "./icons";
import { PreviewAttachment } from "./preview-attachment";
import { SuggestedActions } from "./suggested-actions";
import { Button } from "./ui/button";
import type { VisibilityType } from "./visibility-selector";
PromptInputSubmit,
PromptInputModelSelect,
PromptInputModelSelectContent,
} from './elements/prompt-input';
import { SelectItem } from '@/components/ui/select';
import * as SelectPrimitive from '@radix-ui/react-select';
import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { VisibilityType } from './visibility-selector';
import type { Attachment, ChatMessage } from '@/lib/types';
import type { AppUsage } from '@/lib/usage';
import { chatModels } from '@/lib/ai/models';
import { saveChatModelAsCookie } from '@/app/(chat)/actions';
import { startTransition } from 'react';
import { Context } from './elements/context';
import { myProvider } from '@/lib/ai/providers';
function PureMultimodalInput({
chatId,
@ -67,13 +67,13 @@ function PureMultimodalInput({
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>["status"];
status: UseChatHelpers<ChatMessage>['status'];
stop: () => void;
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: UIMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
attachments: Array<Attachment>;
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
messages: Array<UIMessage>;
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
className?: string;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
@ -83,40 +83,40 @@ function PureMultimodalInput({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { width } = useWindowSize();
const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "44px";
}
}, []);
useEffect(() => {
if (textareaRef.current) {
adjustHeight();
}
}, [adjustHeight]);
const resetHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "44px";
}
}, []);
const adjustHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = '44px';
}
};
const resetHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = '44px';
}
};
const [localStorageInput, setLocalStorageInput] = useLocalStorage(
"input",
""
'input',
'',
);
useEffect(() => {
if (textareaRef.current) {
const domValue = textareaRef.current.value;
// Prefer DOM value over localStorage to handle hydration
const finalValue = domValue || localStorageInput || "";
const finalValue = domValue || localStorageInput || '';
setInput(finalValue);
adjustHeight();
}
// Only run once after hydration
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adjustHeight, localStorageInput, setInput]);
}, []);
useEffect(() => {
setLocalStorageInput(input);
@ -127,31 +127,31 @@ function PureMultimodalInput({
};
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);
const submitForm = useCallback(() => {
window.history.replaceState({}, "", `/chat/${chatId}`);
window.history.replaceState({}, '', `/chat/${chatId}`);
sendMessage({
role: "user",
role: 'user',
parts: [
...attachments.map((attachment) => ({
type: "file" as const,
type: 'file' as const,
url: attachment.url,
name: attachment.name,
mediaType: attachment.contentType,
})),
{
type: "text",
type: 'text',
text: input,
},
],
});
setAttachments([]);
setLocalStorageInput("");
setLocalStorageInput('');
resetHeight();
setInput("");
setInput('');
if (width && width > 768) {
textareaRef.current?.focus();
@ -165,16 +165,15 @@ function PureMultimodalInput({
setLocalStorageInput,
width,
chatId,
resetHeight,
]);
const uploadFile = useCallback(async (file: File) => {
const uploadFile = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
formData.append('file', file);
try {
const response = await fetch("/api/files/upload", {
method: "POST",
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
});
@ -185,17 +184,17 @@ function PureMultimodalInput({
return {
url,
name: pathname,
contentType,
contentType: contentType,
};
}
const { error } = await response.json();
toast.error(error);
} catch (_error) {
toast.error("Failed to upload file, please try again!");
} catch (error) {
toast.error('Failed to upload file, please try again!');
}
}, []);
};
const _modelResolver = useMemo(() => {
const modelResolver = useMemo(() => {
return myProvider.languageModel(selectedModelId);
}, [selectedModelId]);
@ -203,7 +202,7 @@ function PureMultimodalInput({
() => ({
usage,
}),
[usage]
[usage],
);
const handleFileChange = useCallback(
@ -216,7 +215,7 @@ function PureMultimodalInput({
const uploadPromises = files.map((file) => uploadFile(file));
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) => attachment !== undefined
(attachment) => attachment !== undefined,
);
setAttachments((currentAttachments) => [
@ -224,41 +223,42 @@ function PureMultimodalInput({
...successfullyUploadedAttachments,
]);
} catch (error) {
console.error("Error uploading files!", error);
console.error('Error uploading files!', error);
} finally {
setUploadQueue([]);
}
},
[setAttachments, uploadFile]
[setAttachments],
);
return (
<div className={cn("relative flex w-full flex-col gap-4", className)}>
<div className="flex relative flex-col gap-4 w-full">
{messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
<SuggestedActions
sendMessage={sendMessage}
chatId={chatId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
/>
)}
<input
type="file"
className="-top-4 -left-4 pointer-events-none fixed size-0.5 opacity-0"
ref={fileInputRef}
multiple
onChange={handleFileChange}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<PromptInput
className="rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50"
className="p-3 rounded-xl border transition-all duration-200 border-border bg-background shadow-xs focus-within:border-border hover:border-muted-foreground/50"
onSubmit={(event) => {
event.preventDefault();
if (status !== "ready") {
toast.error("Please wait for the model to finish its response!");
if (status !== 'ready') {
toast.error('Please wait for the model to finish its response!');
} else {
submitForm();
}
@ -266,19 +266,19 @@ function PureMultimodalInput({
>
{(attachments.length > 0 || uploadQueue.length > 0) && (
<div
className="flex flex-row items-end gap-2 overflow-x-scroll"
data-testid="attachments-preview"
className="flex overflow-x-scroll flex-row gap-2 items-end"
>
{attachments.map((attachment) => (
<PreviewAttachment
attachment={attachment}
key={attachment.url}
attachment={attachment}
onRemove={() => {
setAttachments((currentAttachments) =>
currentAttachments.filter((a) => a.url !== attachment.url)
currentAttachments.filter((a) => a.url !== attachment.url),
);
if (fileInputRef.current) {
fileInputRef.current.value = "";
fileInputRef.current.value = '';
}
}}
/>
@ -286,53 +286,50 @@ function PureMultimodalInput({
{uploadQueue.map((filename) => (
<PreviewAttachment
key={filename}
attachment={{
url: "",
url: '',
name: filename,
contentType: "",
contentType: '',
}}
isUploading={true}
key={filename}
/>
))}
</div>
)}
<div className="flex flex-row items-start gap-1 sm:gap-2">
<div className="flex flex-row gap-1 items-start sm:gap-2">
<PromptInputTextarea
autoFocus
className="grow resize-none border-0! border-none! bg-transparent p-2 text-sm outline-none ring-0 [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden"
data-testid="multimodal-input"
disableAutoResize={true}
maxHeight={200}
minHeight={44}
onChange={handleInput}
placeholder="Send a message..."
ref={textareaRef}
rows={1}
placeholder="Send a message..."
value={input}
/>{" "}
onChange={handleInput}
minHeight={44}
maxHeight={200}
disableAutoResize={true}
className="grow resize-none border-0! p-2 border-none! bg-transparent text-sm outline-none ring-0 [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden"
rows={1}
autoFocus
/>{' '}
<Context {...contextProps} />
</div>
<PromptInputToolbar className="!border-top-0 border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!">
<PromptInputTools className="gap-0 sm:gap-0.5">
<AttachmentsButton
fileInputRef={fileInputRef}
selectedModelId={selectedModelId}
status={status}
/>
<ModelSelectorCompact
onModelChange={onModelChange}
selectedModelId={selectedModelId}
/>
<ModelSelectorCompact selectedModelId={selectedModelId} onModelChange={onModelChange} />
</PromptInputTools>
{status === "submitted" ? (
<StopButton setMessages={setMessages} stop={stop} />
{status === 'submitted' ? (
<StopButton stop={stop} setMessages={setMessages} />
) : (
<PromptInputSubmit
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
disabled={!input.trim() || uploadQueue.length > 0}
status={status}
disabled={!input.trim() || uploadQueue.length > 0}
className="rounded-full transition-colors duration-200 size-8 bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
>
<ArrowUpIcon size={14} />
</PromptInputSubmit>
@ -346,24 +343,15 @@ function PureMultimodalInput({
export const MultimodalInput = memo(
PureMultimodalInput,
(prevProps, nextProps) => {
if (prevProps.input !== nextProps.input) {
if (prevProps.input !== nextProps.input) return false;
if (prevProps.status !== nextProps.status) return false;
if (!equal(prevProps.attachments, nextProps.attachments)) return false;
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
return false;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (!equal(prevProps.attachments, nextProps.attachments)) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) return false;
return true;
}
},
);
function PureAttachmentsButton({
@ -372,20 +360,20 @@ function PureAttachmentsButton({
selectedModelId,
}: {
fileInputRef: React.MutableRefObject<HTMLInputElement | null>;
status: UseChatHelpers<ChatMessage>["status"];
status: UseChatHelpers<ChatMessage>['status'];
selectedModelId: string;
}) {
const isReasoningModel = selectedModelId === "chat-model-reasoning";
const isReasoningModel = selectedModelId === 'chat-model-reasoning';
return (
<Button
className="aspect-square h-8 rounded-lg p-1 transition-colors hover:bg-accent"
data-testid="attachments-button"
disabled={status !== "ready" || isReasoningModel}
className="p-1 h-8 rounded-lg transition-colors aspect-square hover:bg-accent"
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
}}
disabled={status !== 'ready' || isReasoningModel}
variant="ghost"
>
<PaperclipIcon size={14} style={{ width: 14, height: 14 }} />
@ -409,11 +397,12 @@ function PureModelSelectorCompact({
}, [selectedModelId]);
const selectedModel = chatModels.find(
(model) => model.id === optimisticModelId
(model) => model.id === optimisticModelId,
);
return (
<PromptInputModelSelect
value={selectedModel?.name}
onValueChange={(modelName) => {
const model = chatModels.find((m) => m.name === modelName);
if (model) {
@ -424,29 +413,28 @@ function PureModelSelectorCompact({
});
}
}}
value={selectedModel?.name}
>
<Trigger
className="flex h-8 items-center gap-2 rounded-lg border-0 bg-background px-2 text-foreground shadow-none transition-colors hover:bg-accent focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
<SelectPrimitive.Trigger
type="button"
className="flex gap-2 items-center px-2 h-8 rounded-lg border-0 shadow-none transition-colors bg-background text-foreground hover:bg-accent focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<CpuIcon size={16} />
<span className="hidden font-medium text-xs sm:block">
<span className="hidden text-xs font-medium sm:block">
{selectedModel?.name}
</span>
<ChevronDownIcon size={16} />
</Trigger>
</SelectPrimitive.Trigger>
<PromptInputModelSelectContent className="min-w-[260px] p-0">
<div className="flex flex-col gap-px">
{chatModels.map((model) => (
<SelectItem
className="px-3 py-2 text-xs"
key={model.id}
value={model.name}
className="px-3 py-2 text-xs"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="truncate font-medium text-xs">{model.name}</div>
<div className="truncate text-[10px] text-muted-foreground leading-tight">
<div className="flex flex-col flex-1 gap-1 min-w-0">
<div className="text-xs font-medium truncate">{model.name}</div>
<div className="text-[10px] text-muted-foreground truncate leading-tight">
{model.description}
</div>
</div>
@ -465,12 +453,12 @@ function PureStopButton({
setMessages,
}: {
stop: () => void;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
}) {
return (
<Button
className="size-7 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="stop-button"
className="p-1 rounded-full transition-colors duration-200 size-7 bg-foreground text-background hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground"
onClick={(event) => {
event.preventDefault();
stop();

View file

@ -1,32 +1,34 @@
import Image from "next/image";
import type { Attachment } from "@/lib/types";
import { Loader } from "./elements/loader";
import { CrossSmallIcon } from "./icons";
import { Button } from "./ui/button";
import type { Attachment } from '@/lib/types';
import { Loader } from './elements/loader';
import { CrossSmallIcon } from './icons';
import { Button } from './ui/button';
import Image from 'next/image';
export const PreviewAttachment = ({
attachment,
isUploading = false,
onRemove,
onEdit,
}: {
attachment: Attachment;
isUploading?: boolean;
onRemove?: () => void;
onEdit?: () => void;
}) => {
const { name, url, contentType } = attachment;
return (
<div
className="group relative size-16 overflow-hidden rounded-lg border bg-muted"
data-testid="input-attachment-preview"
className="group relative size-16 overflow-hidden rounded-lg border bg-muted"
>
{contentType?.startsWith("image") ? (
{contentType?.startsWith('image') ? (
<Image
alt={name ?? "An image attachment"}
className="size-full object-cover"
height={64}
src={url}
alt={name ?? 'An image attachment'}
className="size-full object-cover"
width={64}
height={64}
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
@ -42,10 +44,10 @@ export const PreviewAttachment = ({
{onRemove && !isUploading && (
<Button
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={onRemove}
size="sm"
variant="destructive"
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
>
<CrossSmallIcon size={8} />
</Button>

View file

@ -1,43 +1,46 @@
"use client";
'use client';
import { useTheme } from "next-themes";
import { parse, unparse } from "papaparse";
import { memo, useEffect, useMemo, useState } from "react";
import DataGrid, { textEditor } from "react-data-grid";
import { cn } from "@/lib/utils";
import React, { memo, useEffect, useMemo, useState } from 'react';
import DataGrid, { textEditor } from 'react-data-grid';
import { parse, unparse } from 'papaparse';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import "react-data-grid/lib/styles.css";
import 'react-data-grid/lib/styles.css';
type SheetEditorProps = {
content: string;
saveContent: (content: string, isCurrentVersion: boolean) => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
status: string;
isCurrentVersion: boolean;
currentVersionIndex: number;
};
const MIN_ROWS = 50;
const MIN_COLS = 26;
const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
const PureSpreadsheetEditor = ({
content,
saveContent,
status,
isCurrentVersion,
}: SheetEditorProps) => {
const { resolvedTheme } = useTheme();
const parseData = useMemo(() => {
if (!content) {
return new Array(MIN_ROWS).fill(new Array(MIN_COLS).fill(""));
}
if (!content) return Array(MIN_ROWS).fill(Array(MIN_COLS).fill(''));
const result = parse<string[]>(content, { skipEmptyLines: true });
const paddedData = result.data.map((row) => {
const paddedRow = [...row];
while (paddedRow.length < MIN_COLS) {
paddedRow.push("");
paddedRow.push('');
}
return paddedRow;
});
while (paddedData.length < MIN_ROWS) {
paddedData.push(new Array(MIN_COLS).fill(""));
paddedData.push(Array(MIN_COLS).fill(''));
}
return paddedData;
@ -45,13 +48,13 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
const columns = useMemo(() => {
const rowNumberColumn = {
key: "rowNumber",
name: "",
key: 'rowNumber',
name: '',
frozen: true,
width: 50,
renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1,
cellClass: "border-t border-r dark:bg-zinc-950 dark:text-zinc-50",
headerCellClass: "border-t border-r dark:bg-zinc-900 dark:text-zinc-50",
cellClass: 'border-t border-r dark:bg-zinc-950 dark:text-zinc-50',
headerCellClass: 'border-t border-r dark:bg-zinc-900 dark:text-zinc-50',
};
const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({
@ -59,11 +62,11 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
name: String.fromCharCode(65 + i),
renderEditCell: textEditor,
width: 120,
cellClass: cn("border-t dark:bg-zinc-950 dark:text-zinc-50", {
"border-l": i !== 0,
cellClass: cn(`border-t dark:bg-zinc-950 dark:text-zinc-50`, {
'border-l': i !== 0,
}),
headerCellClass: cn("border-t dark:bg-zinc-900 dark:text-zinc-50", {
"border-l": i !== 0,
headerCellClass: cn(`border-t dark:bg-zinc-900 dark:text-zinc-50`, {
'border-l': i !== 0,
}),
}));
@ -78,7 +81,7 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
};
columns.slice(1).forEach((col, colIndex) => {
rowData[col.key] = row[colIndex] || "";
rowData[col.key] = row[colIndex] || '';
});
return rowData;
@ -99,7 +102,7 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
setLocalRows(newRows);
const updatedData = newRows.map((row) => {
return columns.slice(1).map((col) => row[col.key] || "");
return columns.slice(1).map((col) => row[col.key] || '');
});
const newCsvContent = generateCsv(updatedData);
@ -108,21 +111,21 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
return (
<DataGrid
className={resolvedTheme === "dark" ? "rdg-dark" : "rdg-light"}
className={resolvedTheme === 'dark' ? 'rdg-dark' : 'rdg-light'}
columns={columns}
rows={localRows}
enableVirtualization
onRowsChange={handleRowsChange}
onCellClick={(args) => {
if (args.column.key !== 'rowNumber') {
args.selectCell(true);
}
}}
style={{ height: '100%' }}
defaultColumnOptions={{
resizable: true,
sortable: true,
}}
enableVirtualization
onCellClick={(args) => {
if (args.column.key !== "rowNumber") {
args.selectCell(true);
}
}}
onRowsChange={handleRowsChange}
rows={localRows}
style={{ height: "100%" }}
/>
);
};
@ -131,7 +134,7 @@ function areEqual(prevProps: SheetEditorProps, nextProps: SheetEditorProps) {
return (
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
!(prevProps.status === 'streaming' && nextProps.status === 'streaming') &&
prevProps.content === nextProps.content &&
prevProps.saveContent === nextProps.saveContent
);

View file

@ -1,15 +1,10 @@
import Link from "next/link";
import { memo } from "react";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Chat } from "@/lib/db/schema";
import type { Chat } from '@/lib/db/schema';
import {
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from "./icons";
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from './ui/sidebar';
import Link from 'next/link';
import {
DropdownMenu,
DropdownMenuContent,
@ -19,12 +14,17 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
} from './ui/dropdown-menu';
import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "./ui/sidebar";
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from './icons';
import { memo } from 'react';
import { useChatVisibility } from '@/hooks/use-chat-visibility';
const PureChatItem = ({
chat,
@ -61,7 +61,7 @@ const PureChatItem = ({
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<ShareIcon />
@ -72,28 +72,28 @@ const PureChatItem = ({
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType("private");
setVisibilityType('private');
}}
>
<div className="flex flex-row items-center gap-2">
<LockIcon size={12} />
<span>Private</span>
</div>
{visibilityType === "private" ? (
{visibilityType === 'private' ? (
<CheckCircleFillIcon />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType("public");
setVisibilityType('public');
}}
>
<div className="flex flex-row items-center gap-2">
<GlobeIcon />
<span>Public</span>
</div>
{visibilityType === "public" ? <CheckCircleFillIcon /> : null}
{visibilityType === 'public' ? <CheckCircleFillIcon /> : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
@ -113,8 +113,6 @@ const PureChatItem = ({
};
export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
if (prevProps.isActive !== nextProps.isActive) {
return false;
}
if (prevProps.isActive !== nextProps.isActive) return false;
return true;
});

View file

@ -1,12 +1,11 @@
"use client";
'use client';
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
import { motion } from "framer-motion";
import { useParams, useRouter } from "next/navigation";
import type { User } from "next-auth";
import { useState } from "react";
import { toast } from "sonner";
import useSWRInfinite from "swr/infinite";
import { isToday, isYesterday, subMonths, subWeeks } from 'date-fns';
import { useParams, useRouter } from 'next/navigation';
import type { User } from 'next-auth';
import { useState } from 'react';
import { toast } from 'sonner';
import { motion } from 'framer-motion';
import {
AlertDialog,
AlertDialogAction,
@ -16,17 +15,18 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
} from '@/components/ui/alert-dialog';
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import type { Chat } from "@/lib/db/schema";
import { fetcher } from "@/lib/utils";
import { LoaderIcon } from "./icons";
import { ChatItem } from "./sidebar-history-item";
} from '@/components/ui/sidebar';
import type { Chat } from '@/lib/db/schema';
import { fetcher } from '@/lib/utils';
import { ChatItem } from './sidebar-history-item';
import useSWRInfinite from 'swr/infinite';
import { LoaderIcon } from './icons';
type GroupedChats = {
today: Chat[];
@ -36,10 +36,10 @@ type GroupedChats = {
older: Chat[];
};
export type ChatHistory = {
chats: Chat[];
export interface ChatHistory {
chats: Array<Chat>;
hasMore: boolean;
};
}
const PAGE_SIZE = 20;
@ -72,27 +72,23 @@ const groupChatsByDate = (chats: Chat[]): GroupedChats => {
lastWeek: [],
lastMonth: [],
older: [],
} as GroupedChats
} as GroupedChats,
);
};
export function getChatHistoryPaginationKey(
pageIndex: number,
previousPageData: ChatHistory
previousPageData: ChatHistory,
) {
if (previousPageData && previousPageData.hasMore === false) {
return null;
}
if (pageIndex === 0) {
return `/api/history?limit=${PAGE_SIZE}`;
}
if (pageIndex === 0) return `/api/history?limit=${PAGE_SIZE}`;
const firstChatFromPage = previousPageData.chats.at(-1);
if (!firstChatFromPage) {
return null;
}
if (!firstChatFromPage) return null;
return `/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
}
@ -123,13 +119,13 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
? paginatedChatHistories.every((page) => page.chats.length === 0)
: false;
const handleDelete = () => {
const handleDelete = async () => {
const deletePromise = fetch(`/api/chat?id=${deleteId}`, {
method: "DELETE",
method: 'DELETE',
});
toast.promise(deletePromise, {
loading: "Deleting chat...",
loading: 'Deleting chat...',
success: () => {
mutate((chatHistories) => {
if (chatHistories) {
@ -140,15 +136,15 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
}
});
return "Chat deleted successfully";
return 'Chat deleted successfully';
},
error: "Failed to delete chat",
error: 'Failed to delete chat',
});
setShowDeleteDialog(false);
if (deleteId === id) {
router.push("/");
router.push('/');
}
};
@ -174,14 +170,14 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
<div className="flex flex-col">
{[44, 32, 28, 64, 52].map((item) => (
<div
className="flex h-8 items-center gap-2 rounded-md px-2"
key={item}
className="flex h-8 items-center gap-2 rounded-md px-2"
>
<div
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
style={
{
"--skeleton-width": `${item}%`,
'--skeleton-width': `${item}%`,
} as React.CSSProperties
}
/>
@ -213,7 +209,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
{paginatedChatHistories &&
(() => {
const chatsFromHistory = paginatedChatHistories.flatMap(
(paginatedChatHistory) => paginatedChatHistory.chats
(paginatedChatHistory) => paginatedChatHistory.chats,
);
const groupedChats = groupChatsByDate(chatsFromHistory);
@ -227,9 +223,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.today.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -247,9 +243,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.yesterday.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -267,9 +263,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.lastWeek.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -287,9 +283,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.lastMonth.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -307,9 +303,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</div>
{groupedChats.older.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
@ -347,7 +343,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
</SidebarGroupContent>
</SidebarGroup>
<AlertDialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>

View file

@ -1,14 +1,14 @@
import type { ComponentProps } from "react";
import type { ComponentProps } from 'react';
import { type SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import { type SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { SidebarLeftIcon } from "./icons";
import { Button } from "./ui/button";
} from '@/components/ui/tooltip';
import { SidebarLeftIcon } from './icons';
import { Button } from './ui/button';
export function SidebarToggle({
className,
@ -19,10 +19,10 @@ export function SidebarToggle({
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn("h-8 px-2 md:h-fit md:px-2", className)}
data-testid="sidebar-toggle-button"
onClick={toggleSidebar}
variant="outline"
className="h-8 px-2 md:h-fit md:px-2"
>
<SidebarLeftIcon size={16} />
</Button>

View file

@ -1,40 +1,41 @@
"use client";
'use client';
import { ChevronUp } from 'lucide-react';
import Image from 'next/image';
import type { User } from 'next-auth';
import { signOut, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { ChevronUp } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { signOut, useSession } from "next-auth/react";
import { useTheme } from "next-themes";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { guestRegex } from "@/lib/constants";
import { LoaderIcon } from "./icons";
import { toast } from "./toast";
} from '@/components/ui/sidebar';
import { useRouter } from 'next/navigation';
import { toast } from './toast';
import { LoaderIcon } from './icons';
import { guestRegex } from '@/lib/constants';
export function SidebarUserNav({ user }: { user: User }) {
const router = useRouter();
const { data, status } = useSession();
const { setTheme, resolvedTheme } = useTheme();
const isGuest = guestRegex.test(data?.user?.email ?? "");
const isGuest = guestRegex.test(data?.user?.email ?? '');
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{status === "loading" ? (
{status === 'loading' ? (
<SidebarMenuButton className="h-10 justify-between bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div className="flex flex-row gap-2">
<div className="size-6 animate-pulse rounded-full bg-zinc-500/30" />
@ -48,63 +49,63 @@ export function SidebarUserNav({ user }: { user: User }) {
</SidebarMenuButton>
) : (
<SidebarMenuButton
className="h-10 bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-testid="user-nav-button"
className="h-10 bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Image
alt={user.email ?? "User Avatar"}
className="rounded-full"
height={24}
src={`https://avatar.vercel.sh/${user.email}`}
alt={user.email ?? 'User Avatar'}
width={24}
height={24}
className="rounded-full"
/>
<span className="truncate" data-testid="user-email">
{isGuest ? "Guest" : user?.email}
<span data-testid="user-email" className="truncate">
{isGuest ? 'Guest' : user?.email}
</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-popper-anchor-width)"
data-testid="user-nav-menu"
side="top"
className="w-(--radix-popper-anchor-width)"
>
<DropdownMenuItem
className="cursor-pointer"
data-testid="user-nav-item-theme"
className="cursor-pointer"
onSelect={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
}
>
{`Toggle ${resolvedTheme === "light" ? "dark" : "light"} mode`}
{`Toggle ${resolvedTheme === 'light' ? 'dark' : 'light'} mode`}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button
type="button"
className="w-full cursor-pointer"
onClick={() => {
if (status === "loading") {
if (status === 'loading') {
toast({
type: "error",
type: 'error',
description:
"Checking authentication status, please try again!",
'Checking authentication status, please try again!',
});
return;
}
if (isGuest) {
router.push("/login");
router.push('/login');
} else {
signOut({
redirectTo: "/",
redirectTo: '/',
});
}
}}
type="button"
>
{isGuest ? "Login to your account" : "Sign out"}
{isGuest ? 'Login to your account' : 'Sign out'}
</button>
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -1,22 +1,22 @@
import Form from "next/form";
import Form from 'next/form';
import { signOut } from "@/app/(auth)/auth";
import { signOut } from '@/app/(auth)/auth';
export const SignOutForm = () => {
return (
<Form
className="w-full"
action={async () => {
"use server";
'use server';
await signOut({
redirectTo: "/",
redirectTo: '/',
});
}}
className="w-full"
>
<button
className="w-full px-1 py-0.5 text-left text-red-500"
type="submit"
className="w-full px-1 py-0.5 text-left text-red-500"
>
Sign out
</button>

View file

@ -1,10 +1,10 @@
"use client";
'use client';
import { useFormStatus } from "react-dom";
import { useFormStatus } from 'react-dom';
import { LoaderIcon } from "@/components/icons";
import { LoaderIcon } from '@/components/icons';
import { Button } from "./ui/button";
import { Button } from './ui/button';
export function SubmitButton({
children,
@ -17,10 +17,10 @@ export function SubmitButton({
return (
<Button
type={pending ? 'button' : 'submit'}
aria-disabled={pending || isSuccessful}
className="relative"
disabled={pending || isSuccessful}
type={pending ? "button" : "submit"}
className="relative"
>
{children}
@ -31,7 +31,7 @@ export function SubmitButton({
)}
<output aria-live="polite" className="sr-only">
{pending || isSuccessful ? "Loading" : "Submit form"}
{pending || isSuccessful ? 'Loading' : 'Submit form'}
</output>
</Button>
);

View file

@ -1,49 +1,53 @@
"use client";
'use client';
import type { UseChatHelpers } from "@ai-sdk/react";
import { motion } from "framer-motion";
import { memo } from "react";
import type { ChatMessage } from "@/lib/types";
import { Suggestion } from "./elements/suggestion";
import type { VisibilityType } from "./visibility-selector";
import { motion } from 'framer-motion';
import { memo } from 'react';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { VisibilityType } from './visibility-selector';
import type { ChatMessage } from '@/lib/types';
import { Suggestion } from './elements/suggestion';
type SuggestedActionsProps = {
interface SuggestedActionsProps {
chatId: string;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
selectedVisibilityType: VisibilityType;
};
}
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
function PureSuggestedActions({
chatId,
sendMessage,
selectedVisibilityType,
}: SuggestedActionsProps) {
const suggestedActions = [
"What are the advantages of using Next.js?",
'What are the advantages of using Next.js?',
"Write code to demonstrate Dijkstra's algorithm",
"Help me write an essay about Silicon Valley",
"What is the weather in San Francisco?",
'Help me write an essay about Silicon Valley',
'What is the weather in San Francisco?',
];
return (
<div
className="grid w-full gap-2 sm:grid-cols-2"
data-testid="suggested-actions"
className="grid w-full gap-2 sm:grid-cols-2"
>
{suggestedActions.map((suggestedAction, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 20 }}
key={suggestedAction}
transition={{ delay: 0.05 * index }}
key={suggestedAction}
>
<Suggestion
className="h-auto w-full whitespace-normal p-3 text-left"
suggestion={suggestedAction}
onClick={(suggestion) => {
window.history.replaceState({}, "", `/chat/${chatId}`);
window.history.replaceState({}, '', `/chat/${chatId}`);
sendMessage({
role: "user",
parts: [{ type: "text", text: suggestion }],
role: 'user',
parts: [{ type: 'text', text: suggestion }],
});
}}
suggestion={suggestedAction}
className="h-auto w-full whitespace-normal p-3 text-left"
>
{suggestedAction}
</Suggestion>
@ -56,13 +60,10 @@ function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
export const SuggestedActions = memo(
PureSuggestedActions,
(prevProps, nextProps) => {
if (prevProps.chatId !== nextProps.chatId) {
if (prevProps.chatId !== nextProps.chatId) return false;
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
}
},
);

View file

@ -1,14 +1,15 @@
"use client";
'use client';
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { useWindowSize } from "usehooks-ts";
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import { useWindowSize } from 'usehooks-ts';
import type { UISuggestion } from "@/lib/editor/suggestions";
import { cn } from "@/lib/utils";
import type { ArtifactKind } from "./artifact";
import { CrossIcon, MessageIcon } from "./icons";
import { Button } from "./ui/button";
import type { UISuggestion } from '@/lib/editor/suggestions';
import { CrossIcon, MessageIcon } from './icons';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
import type { ArtifactKind } from './artifact';
export const Suggestion = ({
suggestion,
@ -24,45 +25,11 @@ export const Suggestion = ({
return (
<AnimatePresence>
{isExpanded ? (
{!isExpanded ? (
<motion.div
animate={{ opacity: 1, y: -20 }}
className="-right-12 md:-right-16 absolute z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl"
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key={suggestion.id}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
whileHover={{ scale: 1.05 }}
>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<div className="size-4 rounded-full bg-muted-foreground/25" />
<div className="font-medium">Assistant</div>
</div>
<button
className="cursor-pointer text-gray-500 text-xs"
onClick={() => {
setIsExpanded(false);
}}
type="button"
>
<CrossIcon size={12} />
</button>
</div>
<div>{suggestion.description}</div>
<Button
className="w-fit rounded-full px-3 py-1.5"
onClick={onApply}
variant="outline"
>
Apply
</Button>
</motion.div>
) : (
<motion.div
className={cn("cursor-pointer p-1 text-muted-foreground", {
"-right-8 absolute": artifactKind === "text",
"sticky top-0 right-4": artifactKind === "code",
className={cn('cursor-pointer p-1 text-muted-foreground', {
'-right-8 absolute': artifactKind === 'text',
'sticky top-0 right-4': artifactKind === 'code',
})}
onClick={() => {
setIsExpanded(true);
@ -71,6 +38,40 @@ export const Suggestion = ({
>
<MessageIcon size={windowWidth && windowWidth < 768 ? 16 : 14} />
</motion.div>
) : (
<motion.div
key={suggestion.id}
className="-right-12 md:-right-16 absolute z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: -20 }}
exit={{ opacity: 0, y: -10 }}
whileHover={{ scale: 1.05 }}
>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<div className="size-4 rounded-full bg-muted-foreground/25" />
<div className="font-medium">Assistant</div>
</div>
<button
type="button"
className="cursor-pointer text-gray-500 text-xs"
onClick={() => {
setIsExpanded(false);
}}
>
<CrossIcon size={12} />
</button>
</div>
<div>{suggestion.description}</div>
<Button
variant="outline"
className="w-fit rounded-full px-3 py-1.5"
onClick={onApply}
>
Apply
</Button>
</motion.div>
)}
</AnimatePresence>
);

View file

@ -1,35 +1,35 @@
"use client";
'use client';
import { exampleSetup } from "prosemirror-example-setup";
import { inputRules } from "prosemirror-inputrules";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { memo, useEffect, useRef } from "react";
import { exampleSetup } from 'prosemirror-example-setup';
import { inputRules } from 'prosemirror-inputrules';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import React, { memo, useEffect, useRef } from 'react';
import type { Suggestion } from "@/lib/db/schema";
import type { Suggestion } from '@/lib/db/schema';
import {
documentSchema,
handleTransaction,
headingRule,
} from "@/lib/editor/config";
} from '@/lib/editor/config';
import {
buildContentFromDocument,
buildDocumentFromContent,
createDecorations,
} from "@/lib/editor/functions";
} from '@/lib/editor/functions';
import {
projectWithPositions,
suggestionsPlugin,
suggestionsPluginKey,
} from "@/lib/editor/suggestions";
} from '@/lib/editor/suggestions';
type EditorProps = {
content: string;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
status: "streaming" | "idle";
status: 'streaming' | 'idle';
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Suggestion[];
suggestions: Array<Suggestion>;
};
function PureEditor({
@ -74,7 +74,7 @@ function PureEditor({
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
}, []);
useEffect(() => {
if (editorRef.current) {
@ -93,19 +93,19 @@ function PureEditor({
useEffect(() => {
if (editorRef.current && content) {
const currentContent = buildContentFromDocument(
editorRef.current.state.doc
editorRef.current.state.doc,
);
if (status === "streaming") {
if (status === 'streaming') {
const newDocument = buildDocumentFromContent(content);
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content
newDocument.content,
);
transaction.setMeta("no-save", true);
transaction.setMeta('no-save', true);
editorRef.current.dispatch(transaction);
return;
}
@ -116,10 +116,10 @@ function PureEditor({
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content
newDocument.content,
);
transaction.setMeta("no-save", true);
transaction.setMeta('no-save', true);
editorRef.current.dispatch(transaction);
}
}
@ -129,14 +129,14 @@ function PureEditor({
if (editorRef.current?.state.doc && content) {
const projectedSuggestions = projectWithPositions(
editorRef.current.state.doc,
suggestions
suggestions,
).filter(
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd,
);
const decorations = createDecorations(
projectedSuggestions,
editorRef.current
editorRef.current,
);
const transaction = editorRef.current.state.tr;
@ -155,7 +155,7 @@ function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
prevProps.suggestions === nextProps.suggestions &&
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
!(prevProps.status === 'streaming' && nextProps.status === 'streaming') &&
prevProps.content === nextProps.content &&
prevProps.onSaveContent === nextProps.onSaveContent
);

View file

@ -1,7 +1,7 @@
"use client";
'use client';
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes/dist/types";
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View file

@ -1,18 +1,18 @@
"use client";
'use client';
import { type ReactNode, useEffect, useRef, useState } from "react";
import { toast as sonnerToast } from "sonner";
import { cn } from "@/lib/utils";
import { CheckCircleFillIcon, WarningIcon } from "./icons";
import React, { useEffect, useRef, useState, type ReactNode } from 'react';
import { toast as sonnerToast } from 'sonner';
import { CheckCircleFillIcon, WarningIcon } from './icons';
import { cn } from '@/lib/utils';
const iconsByType: Record<"success" | "error", ReactNode> = {
const iconsByType: Record<'success' | 'error', ReactNode> = {
success: <CheckCircleFillIcon />,
error: <WarningIcon />,
};
export function toast(props: Omit<ToastProps, "id">) {
export function toast(props: Omit<ToastProps, 'id'>) {
return sonnerToast.custom((id) => (
<Toast description={props.description} id={id} type={props.type} />
<Toast id={id} type={props.type} description={props.description} />
));
}
@ -24,9 +24,7 @@ function Toast(props: ToastProps) {
useEffect(() => {
const el = descriptionRef.current;
if (!el) {
return;
}
if (!el) return;
const update = () => {
const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
@ -39,28 +37,28 @@ function Toast(props: ToastProps) {
ro.observe(el);
return () => ro.disconnect();
}, []);
}, [description]);
return (
<div className="flex toast-mobile:w-[356px] w-full justify-center">
<div
className={cn(
"flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-zinc-100 p-3",
multiLine ? "items-start" : "items-center"
)}
data-testid="toast"
key={id}
className={cn(
'flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-zinc-100 p-3',
multiLine ? 'items-start' : 'items-center',
)}
>
<div
className={cn(
"data-[type=error]:text-red-600 data-[type=success]:text-green-600",
{ "pt-1": multiLine }
)}
data-type={type}
className={cn(
'data-[type=error]:text-red-600 data-[type=success]:text-green-600',
{ 'pt-1': multiLine },
)}
>
{iconsByType[type]}
</div>
<div className="text-sm text-zinc-950" ref={descriptionRef}>
<div ref={descriptionRef} className="text-sm text-zinc-950">
{description}
</div>
</div>
@ -68,8 +66,8 @@ function Toast(props: ToastProps) {
);
}
type ToastProps = {
interface ToastProps {
id: string | number;
type: "success" | "error";
type: 'success' | 'error';
description: string;
};
}

View file

@ -1,13 +1,11 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import cx from "classnames";
'use client';
import cx from 'classnames';
import {
AnimatePresence,
motion,
useMotionValue,
useTransform,
} from "framer-motion";
import { nanoid } from "nanoid";
} from 'framer-motion';
import {
type Dispatch,
memo,
@ -16,18 +14,21 @@ import {
useEffect,
useRef,
useState,
} from "react";
import { useOnClickOutside } from "usehooks-ts";
} from 'react';
import { useOnClickOutside } from 'usehooks-ts';
import { nanoid } from 'nanoid';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ChatMessage } from "@/lib/types";
import { type ArtifactKind, artifactDefinitions } from "./artifact";
import type { ArtifactToolbarItem } from "./create-artifact";
import { ArrowUpIcon, StopIcon, SummarizeIcon } from "./icons";
} from '@/components/ui/tooltip';
import { ArrowUpIcon, StopIcon, SummarizeIcon } from './icons';
import { artifactDefinitions, type ArtifactKind } from './artifact';
import type { ArtifactToolbarItem } from './create-artifact';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { ChatMessage } from '@/lib/types';
type ToolProps = {
description: string;
@ -37,11 +38,11 @@ type ToolProps = {
isToolbarVisible?: boolean;
setIsToolbarVisible?: Dispatch<SetStateAction<boolean>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
onClick: ({
sendMessage,
}: {
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
}) => void;
};
@ -88,42 +89,40 @@ const Tool = ({
<Tooltip open={isHovered && !isAnimating}>
<TooltipTrigger asChild>
<motion.div
animate={{ opacity: 1, transition: { delay: 0.1 } }}
className={cx("rounded-full p-3", {
"bg-primary text-primary-foreground!": selectedTool === description,
className={cx('rounded-full p-3', {
'bg-primary text-primary-foreground!': selectedTool === description,
})}
onHoverStart={() => {
setIsHovered(true);
}}
onHoverEnd={() => {
if (selectedTool !== description) setIsHovered(false);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleSelect();
}
}}
initial={{ scale: 1, opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.1 } }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
exit={{
scale: 0.9,
opacity: 0,
transition: { duration: 0.1 },
}}
initial={{ scale: 1, opacity: 0 }}
onClick={() => {
handleSelect();
}}
onHoverEnd={() => {
if (selectedTool !== description) {
setIsHovered(false);
}
}}
onHoverStart={() => {
setIsHovered(true);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleSelect();
}
}}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
{selectedTool === description ? <ArrowUpIcon /> : icon}
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background"
side="left"
sideOffset={16}
className="rounded-2xl bg-foreground p-3 px-4 text-background"
>
{description}
</TooltipContent>
@ -131,7 +130,7 @@ const Tool = ({
);
};
const randomArr = [...new Array(6)].map((_x) => nanoid(5));
const randomArr = [...Array(6)].map((x) => nanoid(5));
const ReadingLevelSelector = ({
setSelectedTool,
@ -140,15 +139,15 @@ const ReadingLevelSelector = ({
}: {
setSelectedTool: Dispatch<SetStateAction<string | null>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
}) => {
const LEVELS = [
"Elementary",
"Middle School",
"Keep current level",
"High School",
"College",
"Graduate",
'Elementary',
'Middle School',
'Keep current level',
'High School',
'College',
'Graduate',
];
const y = useMotionValue(-40 * 2);
@ -160,7 +159,7 @@ const ReadingLevelSelector = ({
useState<boolean>(false);
useEffect(() => {
const unsubscribe = yToLevel.on("change", (latest) => {
const unsubscribe = yToLevel.on('change', (latest) => {
const level = Math.min(5, Math.max(0, Math.round(Math.abs(latest))));
setCurrentLevel(level);
});
@ -172,11 +171,11 @@ const ReadingLevelSelector = ({
<div className="relative flex flex-col items-center justify-end">
{randomArr.map((id) => (
<motion.div
animate={{ opacity: 1 }}
className="flex size-[40px] flex-row items-center justify-center"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key={id}
className="flex size-[40px] flex-row items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ delay: 0.1 }}
>
<div className="size-2 rounded-full bg-muted-foreground/40" />
@ -188,30 +187,22 @@ const ReadingLevelSelector = ({
<TooltipTrigger asChild>
<motion.div
className={cx(
"absolute flex flex-row items-center rounded-full border bg-background p-3",
'absolute flex flex-row items-center rounded-full border bg-background p-3',
{
"bg-primary text-primary-foreground": currentLevel !== 2,
"bg-background text-foreground": currentLevel === 2,
}
'bg-primary text-primary-foreground': currentLevel !== 2,
'bg-background text-foreground': currentLevel === 2,
},
)}
style={{ y }}
drag="y"
dragConstraints={{ top: -dragConstraints, bottom: 0 }}
dragElastic={0}
dragMomentum={false}
onClick={() => {
if (currentLevel !== 2 && hasUserSelectedLevel) {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: `Please adjust the reading level to ${LEVELS[currentLevel]} level.`,
},
],
});
setSelectedTool(null);
}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.1 }}
dragConstraints={{ top: -dragConstraints, bottom: 0 }}
onDragStart={() => {
setHasUserSelectedLevel(false);
}}
onDragEnd={() => {
if (currentLevel === 2) {
@ -220,21 +211,29 @@ const ReadingLevelSelector = ({
setHasUserSelectedLevel(true);
}
}}
onDragStart={() => {
setHasUserSelectedLevel(false);
onClick={() => {
if (currentLevel !== 2 && hasUserSelectedLevel) {
sendMessage({
role: 'user',
parts: [
{
type: 'text',
text: `Please adjust the reading level to ${LEVELS[currentLevel]} level.`,
},
],
});
setSelectedTool(null);
}
}}
style={{ y }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{currentLevel === 2 ? <SummarizeIcon /> : <ArrowUpIcon />}
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background text-sm"
side="left"
sideOffset={16}
className="rounded-2xl bg-foreground p-3 px-4 text-background text-sm"
>
{LEVELS[currentLevel]}
</TooltipContent>
@ -256,32 +255,32 @@ export const Tools = ({
isToolbarVisible: boolean;
selectedTool: string | null;
setSelectedTool: Dispatch<SetStateAction<string | null>>;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
isAnimating: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
tools: ArtifactToolbarItem[];
tools: Array<ArtifactToolbarItem>;
}) => {
const [primaryTool, ...secondaryTools] = tools;
return (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col gap-1.5"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
>
<AnimatePresence>
{isToolbarVisible &&
secondaryTools.map((secondaryTool) => (
<Tool
key={secondaryTool.description}
description={secondaryTool.description}
icon={secondaryTool.icon}
isAnimating={isAnimating}
key={secondaryTool.description}
onClick={secondaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
sendMessage={sendMessage}
isAnimating={isAnimating}
onClick={secondaryTool.onClick}
/>
))}
</AnimatePresence>
@ -289,13 +288,13 @@ export const Tools = ({
<Tool
description={primaryTool.description}
icon={primaryTool.icon}
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
onClick={primaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
isToolbarVisible={isToolbarVisible}
setIsToolbarVisible={setIsToolbarVisible}
sendMessage={sendMessage}
isAnimating={isAnimating}
onClick={primaryTool.onClick}
/>
</motion.div>
);
@ -312,10 +311,10 @@ const PureToolbar = ({
}: {
isToolbarVisible: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
status: UseChatHelpers<ChatMessage>["status"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
stop: UseChatHelpers<ChatMessage>["stop"];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
status: UseChatHelpers<ChatMessage>['status'];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
stop: UseChatHelpers<ChatMessage>['stop'];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
artifactKind: ArtifactKind;
}) => {
const toolbarRef = useRef<HTMLDivElement>(null);
@ -355,17 +354,17 @@ const PureToolbar = ({
}, []);
useEffect(() => {
if (status === "streaming") {
if (status === 'streaming') {
setIsToolbarVisible(false);
}
}, [status, setIsToolbarVisible]);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifactKind
(definition) => definition.kind === artifactKind,
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
throw new Error('Artifact definition not found!');
}
const toolsByArtifactKind = artifactDefinition.toolbar;
@ -377,9 +376,11 @@ const PureToolbar = ({
return (
<TooltipProvider delayDuration={0}>
<motion.div
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
initial={{ opacity: 0, y: -20, scale: 1 }}
animate={
isToolbarVisible
? selectedTool === "adjust-reading-level"
? selectedTool === 'adjust-reading-level'
? {
opacity: 1,
y: 0,
@ -396,40 +397,34 @@ const PureToolbar = ({
}
: { opacity: 1, y: 0, height: 54, transition: { delay: 0 } }
}
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
initial={{ opacity: 0, y: -20, scale: 1 }}
onAnimationComplete={() => {
setIsAnimating(false);
}}
onAnimationStart={() => {
setIsAnimating(true);
}}
onHoverEnd={() => {
if (status === "streaming") {
return;
}
startCloseTimer();
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
onHoverStart={() => {
if (status === "streaming") {
return;
}
if (status === 'streaming') return;
cancelCloseTimer();
setIsToolbarVisible(true);
}}
onHoverEnd={() => {
if (status === 'streaming') return;
startCloseTimer();
}}
onAnimationStart={() => {
setIsAnimating(true);
}}
onAnimationComplete={() => {
setIsAnimating(false);
}}
ref={toolbarRef}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{status === "streaming" ? (
{status === 'streaming' ? (
<motion.div
animate={{ scale: 1.4 }}
className="p-3"
exit={{ scale: 1 }}
initial={{ scale: 1 }}
key="stop-icon"
initial={{ scale: 1 }}
animate={{ scale: 1.4 }}
exit={{ scale: 1 }}
className="p-3"
onClick={() => {
stop();
setMessages((messages) => messages);
@ -437,20 +432,20 @@ const PureToolbar = ({
>
<StopIcon />
</motion.div>
) : selectedTool === "adjust-reading-level" ? (
) : selectedTool === 'adjust-reading-level' ? (
<ReadingLevelSelector
isAnimating={isAnimating}
key="reading-level-selector"
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
isAnimating={isAnimating}
/>
) : (
<Tools
key="tools"
sendMessage={sendMessage}
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
key="tools"
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
tools={toolsByArtifactKind}
@ -462,15 +457,9 @@ const PureToolbar = ({
};
export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.isToolbarVisible !== nextProps.isToolbarVisible) {
return false;
}
if (prevProps.artifactKind !== nextProps.artifactKind) {
return false;
}
if (prevProps.status !== nextProps.status) return false;
if (prevProps.isToolbarVisible !== nextProps.isToolbarVisible) return false;
if (prevProps.artifactKind !== nextProps.artifactKind) return false;
return true;
});

View file

@ -1,16 +1,16 @@
"use client"
'use client';
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
@ -18,14 +18,14 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
@ -36,14 +36,14 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
'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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
@ -51,13 +51,13 @@ const AlertDialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
@ -65,13 +65,13 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
@ -79,11 +79,11 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
className={cn('font-semibold text-lg', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
@ -91,12 +91,12 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
))
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
@ -107,8 +107,8 @@ const AlertDialogAction = React.forwardRef<
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
@ -117,14 +117,14 @@ const AlertDialogCancel = React.forwardRef<
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className,
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
@ -138,4 +138,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
};

View file

@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
@ -12,13 +12,13 @@ const Avatar = React.forwardRef<
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
@ -26,11 +26,11 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
@ -39,12 +39,12 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback }
export { Avatar, AvatarImage, AvatarFallback };

View file

@ -1,27 +1,27 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View file

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

View file

@ -1,6 +1,6 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
@ -9,13 +9,13 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
'rounded-lg border bg-card text-card-foreground shadow-xs',
className,
)}
{...props}
/>
))
Card.displayName = "Card"
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
@ -23,11 +23,11 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLDivElement,
@ -36,13 +36,13 @@ const CardTitle = React.forwardRef<
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
'font-semibold text-2xl leading-none tracking-tight',
className,
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLDivElement,
@ -50,19 +50,19 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
@ -70,10 +70,17 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -1,45 +1,45 @@
"use client"
'use client';
import * as React from "react"
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext)
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
throw new Error('useCarousel must be used within a <Carousel />');
}
return context
return context;
}
const Carousel = React.forwardRef<
@ -48,7 +48,7 @@ const Carousel = React.forwardRef<
>(
(
{
orientation = "horizontal",
orientation = 'horizontal',
opts,
setApi,
plugins,
@ -56,69 +56,69 @@ const Carousel = React.forwardRef<
children,
...props
},
ref
ref,
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
return;
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
)
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return
return;
}
setApi(api)
}, [api, setApi])
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return
return;
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
@ -127,7 +127,7 @@ const Carousel = React.forwardRef<
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
@ -137,7 +137,7 @@ const Carousel = React.forwardRef<
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
@ -145,38 +145,38 @@ const Carousel = React.forwardRef<
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
);
},
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
const { orientation } = useCarousel();
return (
<div
@ -184,21 +184,21 @@ const CarouselItem = React.forwardRef<
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
@ -206,11 +206,11 @@ const CarouselPrevious = React.forwardRef<
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 -translate-y-1/2 top-1/2'
: '-top-12 -translate-x-1/2 left-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
@ -219,15 +219,15 @@ const CarouselPrevious = React.forwardRef<
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
@ -235,11 +235,11 @@ const CarouselNext = React.forwardRef<
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 -translate-y-1/2 top-1/2'
: '-bottom-12 -translate-x-1/2 left-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
@ -248,9 +248,9 @@ const CarouselNext = React.forwardRef<
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
@ -259,4 +259,4 @@ export {
CarouselItem,
CarouselPrevious,
CarouselNext,
}
};

View file

@ -1,11 +1,11 @@
"use client"
'use client';
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -1,44 +1,44 @@
"use client"
'use client';
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -47,14 +47,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
'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 z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
/>
))
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -65,32 +65,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
'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 z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -99,8 +99,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
checked={checked}
{...props}
@ -112,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -123,8 +123,8 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
@ -135,26 +135,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
'px-2 py-1.5 font-semibold text-sm',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -162,11 +162,11 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
@ -174,12 +174,12 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
@ -197,4 +197,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
};

View file

@ -1,29 +1,29 @@
"use client"
'use client';
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const HoverCard = HoverCardPrimitive.Root
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 origin-[--radix-hover-card-content-transform-origin]",
className
'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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent }
export { HoverCard, HoverCardTrigger, HoverCardContent };

View file

@ -1,22 +1,22 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
);
},
);
Input.displayName = 'Input';
export { Input }
export { Input };

View file

@ -1,14 +1,14 @@
"use client"
'use client';
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
@ -20,7 +20,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label }
export { Label };

View file

@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
@ -12,8 +12,8 @@ const Progress = React.forwardRef<
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className,
)}
{...props}
>
@ -22,7 +22,7 @@ const Progress = React.forwardRef<
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress }
export { Progress };

View file

@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
@ -11,7 +11,7 @@ const ScrollArea = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
@ -20,29 +20,29 @@ const ScrollArea = React.forwardRef<
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-px',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar }
export { ScrollArea, ScrollBar };

View file

@ -1,16 +1,16 @@
"use client"
'use client';
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@ -19,8 +19,8 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[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
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
@ -29,8 +29,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@ -39,15 +39,15 @@ const SelectScrollUpButton = React.forwardRef<
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@ -56,29 +56,29 @@ const SelectScrollDownButton = React.forwardRef<
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-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 origin-[--radix-select-content-transform-origin]",
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
'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 relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
position === 'popper' &&
'data-[side=left]:-translate-x-1 data-[side=top]:-translate-y-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1',
className,
)}
position={position}
{...props}
@ -86,9 +86,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
'p-2',
position === 'popper' &&
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)',
)}
>
{children}
@ -96,8 +96,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@ -105,11 +105,11 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
className={cn('py-1.5 pr-2 pl-8 font-semibold text-sm', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@ -118,21 +118,21 @@ const SelectItem = React.forwardRef<
<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
'relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pr-8 pl-3 text-sm outline-hidden hover:bg-muted/50 focus:bg-muted data-[state=checked]:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50 transition-colors',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute right-3 flex h-4 w-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
<Check className="h-4 w-4 text-foreground" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@ -140,11 +140,11 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
@ -157,4 +157,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
};

View file

@ -1,31 +1,31 @@
"use client"
'use client';
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
{ className, orientation = 'horizontal', decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };

View file

@ -1,19 +1,19 @@
"use client"
'use client';
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
@ -21,33 +21,33 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: "right",
side: 'right',
},
}
)
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
@ -56,7 +56,7 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
@ -65,14 +65,14 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
@ -80,13 +80,13 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
@ -94,13 +94,13 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
@ -108,11 +108,11 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
className={cn('font-semibold text-foreground text-lg', className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
@ -120,11 +120,11 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
@ -137,4 +137,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};

View file

@ -1,64 +1,58 @@
"use client"
'use client';
import * as React from "react"
import { Slot as SlotPrimitive } from "radix-ui"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import { PanelLeft } from 'lucide-react';
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
} from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SIDEBAR_COOKIE_NAME = 'sidebar:state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
type SidebarContext = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
@ -71,36 +65,36 @@ const SidebarProvider = React.forwardRef<
children,
...props
},
ref
ref,
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
)
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@ -109,20 +103,20 @@ const SidebarProvider = React.forwardRef<
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContextProps>(
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
@ -132,8 +126,16 @@ const SidebarProvider = React.forwardRef<
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
[
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
);
return (
<SidebarContext.Provider value={contextValue}>
@ -141,14 +143,14 @@ const SidebarProvider = React.forwardRef<
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
className,
)}
ref={ref}
{...props}
@ -157,45 +159,45 @@ const SidebarProvider = React.forwardRef<
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
);
},
);
SidebarProvider.displayName = 'SidebarProvider';
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
},
ref
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
if (collapsible === 'none') {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
className,
)}
ref={ref}
{...props}
>
{children}
</div>
)
);
}
if (isMobile) {
@ -204,22 +206,19 @@ const Sidebar = React.forwardRef<
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
@ -227,53 +226,53 @@ const Sidebar = React.forwardRef<
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
'relative h-svh w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
);
},
);
Sidebar.displayName = 'Sidebar';
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<Button
@ -281,25 +280,25 @@ const SidebarTrigger = React.forwardRef<
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
className={cn('h-7 w-7', className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
);
});
SidebarTrigger.displayName = 'SidebarTrigger';
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
React.ComponentProps<'button'>
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<button
@ -310,37 +309,37 @@ const SidebarRail = React.forwardRef<
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
'-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
);
});
SidebarRail.displayName = 'SidebarRail';
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
React.ComponentProps<'main'>
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
'relative flex min-h-svh flex-1 flex-col bg-background',
'peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm',
className,
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
);
});
SidebarInset.displayName = 'SidebarInset';
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
@ -351,44 +350,44 @@ const SidebarInput = React.forwardRef<
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
className,
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
);
});
SidebarInput.displayName = 'SidebarInput';
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
);
});
SidebarHeader.displayName = 'SidebarHeader';
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
);
});
SidebarFooter.displayName = 'SidebarFooter';
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
@ -398,173 +397,173 @@ const SidebarSeparator = React.forwardRef<
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
);
});
SidebarSeparator.displayName = 'SidebarSeparator';
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
);
});
SidebarContent.displayName = 'SidebarContent';
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
);
});
SidebarGroup.displayName = 'SidebarGroup';
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
React.ComponentProps<'div'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "div"
const Comp = asChild ? Slot : 'div';
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
'flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className,
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
);
});
SidebarGroupLabel.displayName = 'SidebarGroupLabel';
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
React.ComponentProps<'button'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
'after:-inset-2 after:absolute md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
);
});
SidebarGroupAction.displayName = 'SidebarGroupAction';
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
className={cn('w-full text-sm', className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
));
SidebarGroupContent.displayName = 'SidebarGroupContent';
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
));
SidebarMenu.displayName = 'SidebarMenu';
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
className={cn('group/menu-item relative', className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
));
SidebarMenuItem.displayName = 'SidebarMenuItem';
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
variant = 'default',
size = 'default',
tooltip,
className,
...props
},
ref
ref,
) => {
const Comp = asChild ? SlotPrimitive.Slot : "button"
const { isMobile, state } = useSidebar()
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
@ -575,16 +574,16 @@ const SidebarMenuButton = React.forwardRef<
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
);
if (!tooltip) {
return button
return button;
}
if (typeof tooltip === "string") {
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
}
};
}
return (
@ -593,83 +592,83 @@ const SidebarMenuButton = React.forwardRef<
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
);
},
);
SidebarMenuButton.displayName = 'SidebarMenuButton';
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
'after:-inset-2 after:absolute md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
className,
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
);
});
SidebarMenuAction.displayName = 'SidebarMenuAction';
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
));
SidebarMenuBadge.displayName = 'SidebarMenuBadge';
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
React.ComponentProps<'div'> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
@ -679,51 +678,51 @@ const SidebarMenuSkeleton = React.forwardRef<
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
);
});
SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
));
SidebarMenuSub.displayName = 'SidebarMenuSub';
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
React.ComponentProps<'li'>
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "a"
>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
@ -732,18 +731,18 @@ const SidebarMenuSubButton = React.forwardRef<
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
'-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
);
});
SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
export {
Sidebar,
@ -770,4 +769,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
};

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Skeleton({
className,
@ -6,10 +6,10 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };

View file

@ -1,22 +1,22 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
React.ComponentProps<'textarea'>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
);
});
Textarea.displayName = 'Textarea';
export { Textarea }
export { Textarea };

View file

@ -1,15 +1,15 @@
"use client"
'use client';
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
@ -19,12 +19,12 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-[--radix-tooltip-content-transform-origin]",
className
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 animate-in overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-popover-foreground text-sm shadow-md data-[state=closed]:animate-out',
className,
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -1,21 +1,23 @@
"use client";
'use client';
import { isAfter } from "date-fns";
import { motion } from "framer-motion";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { useWindowSize } from "usehooks-ts";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document } from "@/lib/db/schema";
import { getDocumentTimestampByIndex } from "@/lib/utils";
import { LoaderIcon } from "./icons";
import { Button } from "./ui/button";
import { isAfter } from 'date-fns';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { useSWRConfig } from 'swr';
import { useWindowSize } from 'usehooks-ts';
type VersionFooterProps = {
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
documents: Document[] | undefined;
import type { Document } from '@/lib/db/schema';
import { getDocumentTimestampByIndex } from '@/lib/utils';
import { LoaderIcon } from './icons';
import { Button } from './ui/button';
import { useArtifact } from '@/hooks/use-artifact';
interface VersionFooterProps {
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
documents: Array<Document> | undefined;
currentVersionIndex: number;
};
}
export const VersionFooter = ({
handleVersionChange,
@ -30,17 +32,15 @@ export const VersionFooter = ({
const { mutate } = useSWRConfig();
const [isMutating, setIsMutating] = useState(false);
if (!documents) {
return;
}
if (!documents) return;
return (
<motion.div
animate={{ y: 0 }}
className="absolute bottom-0 z-50 flex w-full flex-col justify-between gap-4 border-t bg-background p-4 lg:flex-row"
exit={{ y: isMobile ? 200 : 77 }}
initial={{ y: isMobile ? 200 : 77 }}
transition={{ type: "spring", stiffness: 140, damping: 20 }}
animate={{ y: 0 }}
exit={{ y: isMobile ? 200 : 77 }}
transition={{ type: 'spring', stiffness: 140, damping: 20 }}
>
<div>
<div>You are viewing a previous version</div>
@ -60,11 +60,11 @@ export const VersionFooter = ({
await fetch(
`/api/document?id=${artifact.documentId}&timestamp=${getDocumentTimestampByIndex(
documents,
currentVersionIndex
currentVersionIndex,
)}`,
{
method: "DELETE",
}
method: 'DELETE',
},
),
{
optimisticData: documents
@ -75,14 +75,14 @@ export const VersionFooter = ({
new Date(
getDocumentTimestampByIndex(
documents,
currentVersionIndex
)
)
)
currentVersionIndex,
),
),
),
),
]
: [],
}
},
);
}}
>
@ -94,10 +94,10 @@ export const VersionFooter = ({
)}
</Button>
<Button
onClick={() => {
handleVersionChange("latest");
}}
variant="outline"
onClick={() => {
handleVersionChange('latest');
}}
>
Back to latest version
</Button>

View file

@ -1,23 +1,23 @@
"use client";
'use client';
import { type ReactNode, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { type ReactNode, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import { cn } from "@/lib/utils";
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import {
CheckCircleFillIcon,
ChevronDownIcon,
GlobeIcon,
LockIcon,
} from "./icons";
} from './icons';
import { useChatVisibility } from '@/hooks/use-chat-visibility';
export type VisibilityType = "private" | "public";
export type VisibilityType = 'private' | 'public';
const visibilities: Array<{
id: VisibilityType;
@ -26,15 +26,15 @@ const visibilities: Array<{
icon: ReactNode;
}> = [
{
id: "private",
label: "Private",
description: "Only you can access this chat",
id: 'private',
label: 'Private',
description: 'Only you can access this chat',
icon: <LockIcon />,
},
{
id: "public",
label: "Public",
description: "Anyone with the link can access this chat",
id: 'public',
label: 'Public',
description: 'Anyone with the link can access this chat',
icon: <GlobeIcon />,
},
];
@ -56,40 +56,40 @@ export function VisibilitySelector({
const selectedVisibility = useMemo(
() => visibilities.find((visibility) => visibility.id === visibilityType),
[visibilityType]
[visibilityType],
);
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
asChild
className={cn(
"w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
'w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className,
)}
>
<Button
className="hidden h-8 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 md:flex md:h-fit md:px-2"
data-testid="visibility-selector"
variant="outline"
className="hidden h-8 md:flex md:h-fit md:px-2 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
>
{selectedVisibility?.icon}
<span className="md:sr-only">{selectedVisibility?.label}</span>
<ChevronDownIcon />
<ChevronDownIcon/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[300px]">
{visibilities.map((visibility) => (
<DropdownMenuItem
className="group/item flex flex-row items-center justify-between gap-4"
data-active={visibility.id === visibilityType}
data-testid={`visibility-selector-item-${visibility.id}`}
key={visibility.id}
onSelect={() => {
setVisibilityType(visibility.id);
setOpen(false);
}}
className="group/item flex flex-row items-center justify-between gap-4"
data-active={visibility.id === visibilityType}
>
<div className="flex flex-col items-start gap-1">
{visibility.label}

View file

@ -1,10 +1,10 @@
"use client";
'use client';
import cx from "classnames";
import { format, isWithinInterval } from "date-fns";
import { useEffect, useState } from "react";
import cx from 'classnames';
import { format, isWithinInterval } from 'date-fns';
import { useEffect, useState } from 'react';
type WeatherAtLocation = {
interface WeatherAtLocation {
latitude: number;
longitude: number;
generationtime_ms: number;
@ -40,121 +40,121 @@ type WeatherAtLocation = {
sunrise: string[];
sunset: string[];
};
};
}
const SAMPLE = {
latitude: 37.763_283,
longitude: -122.412_86,
generationtime_ms: 0.027_894_973_754_882_812,
latitude: 37.763283,
longitude: -122.41286,
generationtime_ms: 0.027894973754882812,
utc_offset_seconds: 0,
timezone: "GMT",
timezone_abbreviation: "GMT",
timezone: 'GMT',
timezone_abbreviation: 'GMT',
elevation: 18,
current_units: { time: "iso8601", interval: "seconds", temperature_2m: "°C" },
current: { time: "2024-10-07T19:30", interval: 900, temperature_2m: 29.3 },
hourly_units: { time: "iso8601", temperature_2m: "°C" },
current_units: { time: 'iso8601', interval: 'seconds', temperature_2m: '°C' },
current: { time: '2024-10-07T19:30', interval: 900, temperature_2m: 29.3 },
hourly_units: { time: 'iso8601', temperature_2m: '°C' },
hourly: {
time: [
"2024-10-07T00:00",
"2024-10-07T01:00",
"2024-10-07T02:00",
"2024-10-07T03:00",
"2024-10-07T04:00",
"2024-10-07T05:00",
"2024-10-07T06:00",
"2024-10-07T07:00",
"2024-10-07T08:00",
"2024-10-07T09:00",
"2024-10-07T10:00",
"2024-10-07T11:00",
"2024-10-07T12:00",
"2024-10-07T13:00",
"2024-10-07T14:00",
"2024-10-07T15:00",
"2024-10-07T16:00",
"2024-10-07T17:00",
"2024-10-07T18:00",
"2024-10-07T19:00",
"2024-10-07T20:00",
"2024-10-07T21:00",
"2024-10-07T22:00",
"2024-10-07T23:00",
"2024-10-08T00:00",
"2024-10-08T01:00",
"2024-10-08T02:00",
"2024-10-08T03:00",
"2024-10-08T04:00",
"2024-10-08T05:00",
"2024-10-08T06:00",
"2024-10-08T07:00",
"2024-10-08T08:00",
"2024-10-08T09:00",
"2024-10-08T10:00",
"2024-10-08T11:00",
"2024-10-08T12:00",
"2024-10-08T13:00",
"2024-10-08T14:00",
"2024-10-08T15:00",
"2024-10-08T16:00",
"2024-10-08T17:00",
"2024-10-08T18:00",
"2024-10-08T19:00",
"2024-10-08T20:00",
"2024-10-08T21:00",
"2024-10-08T22:00",
"2024-10-08T23:00",
"2024-10-09T00:00",
"2024-10-09T01:00",
"2024-10-09T02:00",
"2024-10-09T03:00",
"2024-10-09T04:00",
"2024-10-09T05:00",
"2024-10-09T06:00",
"2024-10-09T07:00",
"2024-10-09T08:00",
"2024-10-09T09:00",
"2024-10-09T10:00",
"2024-10-09T11:00",
"2024-10-09T12:00",
"2024-10-09T13:00",
"2024-10-09T14:00",
"2024-10-09T15:00",
"2024-10-09T16:00",
"2024-10-09T17:00",
"2024-10-09T18:00",
"2024-10-09T19:00",
"2024-10-09T20:00",
"2024-10-09T21:00",
"2024-10-09T22:00",
"2024-10-09T23:00",
"2024-10-10T00:00",
"2024-10-10T01:00",
"2024-10-10T02:00",
"2024-10-10T03:00",
"2024-10-10T04:00",
"2024-10-10T05:00",
"2024-10-10T06:00",
"2024-10-10T07:00",
"2024-10-10T08:00",
"2024-10-10T09:00",
"2024-10-10T10:00",
"2024-10-10T11:00",
"2024-10-10T12:00",
"2024-10-10T13:00",
"2024-10-10T14:00",
"2024-10-10T15:00",
"2024-10-10T16:00",
"2024-10-10T17:00",
"2024-10-10T18:00",
"2024-10-10T19:00",
"2024-10-10T20:00",
"2024-10-10T21:00",
"2024-10-10T22:00",
"2024-10-10T23:00",
"2024-10-11T00:00",
"2024-10-11T01:00",
"2024-10-11T02:00",
"2024-10-11T03:00",
'2024-10-07T00:00',
'2024-10-07T01:00',
'2024-10-07T02:00',
'2024-10-07T03:00',
'2024-10-07T04:00',
'2024-10-07T05:00',
'2024-10-07T06:00',
'2024-10-07T07:00',
'2024-10-07T08:00',
'2024-10-07T09:00',
'2024-10-07T10:00',
'2024-10-07T11:00',
'2024-10-07T12:00',
'2024-10-07T13:00',
'2024-10-07T14:00',
'2024-10-07T15:00',
'2024-10-07T16:00',
'2024-10-07T17:00',
'2024-10-07T18:00',
'2024-10-07T19:00',
'2024-10-07T20:00',
'2024-10-07T21:00',
'2024-10-07T22:00',
'2024-10-07T23:00',
'2024-10-08T00:00',
'2024-10-08T01:00',
'2024-10-08T02:00',
'2024-10-08T03:00',
'2024-10-08T04:00',
'2024-10-08T05:00',
'2024-10-08T06:00',
'2024-10-08T07:00',
'2024-10-08T08:00',
'2024-10-08T09:00',
'2024-10-08T10:00',
'2024-10-08T11:00',
'2024-10-08T12:00',
'2024-10-08T13:00',
'2024-10-08T14:00',
'2024-10-08T15:00',
'2024-10-08T16:00',
'2024-10-08T17:00',
'2024-10-08T18:00',
'2024-10-08T19:00',
'2024-10-08T20:00',
'2024-10-08T21:00',
'2024-10-08T22:00',
'2024-10-08T23:00',
'2024-10-09T00:00',
'2024-10-09T01:00',
'2024-10-09T02:00',
'2024-10-09T03:00',
'2024-10-09T04:00',
'2024-10-09T05:00',
'2024-10-09T06:00',
'2024-10-09T07:00',
'2024-10-09T08:00',
'2024-10-09T09:00',
'2024-10-09T10:00',
'2024-10-09T11:00',
'2024-10-09T12:00',
'2024-10-09T13:00',
'2024-10-09T14:00',
'2024-10-09T15:00',
'2024-10-09T16:00',
'2024-10-09T17:00',
'2024-10-09T18:00',
'2024-10-09T19:00',
'2024-10-09T20:00',
'2024-10-09T21:00',
'2024-10-09T22:00',
'2024-10-09T23:00',
'2024-10-10T00:00',
'2024-10-10T01:00',
'2024-10-10T02:00',
'2024-10-10T03:00',
'2024-10-10T04:00',
'2024-10-10T05:00',
'2024-10-10T06:00',
'2024-10-10T07:00',
'2024-10-10T08:00',
'2024-10-10T09:00',
'2024-10-10T10:00',
'2024-10-10T11:00',
'2024-10-10T12:00',
'2024-10-10T13:00',
'2024-10-10T14:00',
'2024-10-10T15:00',
'2024-10-10T16:00',
'2024-10-10T17:00',
'2024-10-10T18:00',
'2024-10-10T19:00',
'2024-10-10T20:00',
'2024-10-10T21:00',
'2024-10-10T22:00',
'2024-10-10T23:00',
'2024-10-11T00:00',
'2024-10-11T01:00',
'2024-10-11T02:00',
'2024-10-11T03:00',
],
temperature_2m: [
36.6, 32.8, 29.5, 28.6, 29.2, 28.2, 27.5, 26.6, 26.5, 26, 25, 23.5, 23.9,
@ -168,31 +168,31 @@ const SAMPLE = {
],
},
daily_units: {
time: "iso8601",
sunrise: "iso8601",
sunset: "iso8601",
time: 'iso8601',
sunrise: 'iso8601',
sunset: 'iso8601',
},
daily: {
time: [
"2024-10-07",
"2024-10-08",
"2024-10-09",
"2024-10-10",
"2024-10-11",
'2024-10-07',
'2024-10-08',
'2024-10-09',
'2024-10-10',
'2024-10-11',
],
sunrise: [
"2024-10-07T07:15",
"2024-10-08T07:16",
"2024-10-09T07:17",
"2024-10-10T07:18",
"2024-10-11T07:19",
'2024-10-07T07:15',
'2024-10-08T07:16',
'2024-10-09T07:17',
'2024-10-10T07:18',
'2024-10-11T07:19',
],
sunset: [
"2024-10-07T19:00",
"2024-10-08T18:58",
"2024-10-09T18:57",
"2024-10-10T18:55",
"2024-10-11T18:54",
'2024-10-07T19:00',
'2024-10-08T18:58',
'2024-10-09T18:57',
'2024-10-10T18:55',
'2024-10-11T18:54',
],
},
};
@ -207,10 +207,10 @@ export function Weather({
weatherAtLocation?: WeatherAtLocation;
}) {
const currentHigh = Math.max(
...weatherAtLocation.hourly.temperature_2m.slice(0, 24)
...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
);
const currentLow = Math.min(
...weatherAtLocation.hourly.temperature_2m.slice(0, 24)
...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
);
const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), {
@ -226,51 +226,51 @@ export function Weather({
};
handleResize();
window.addEventListener("resize", handleResize);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener("resize", handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const hoursToShow = isMobile ? 5 : 6;
// Find the index of the current time or the next closest time
const currentTimeIndex = weatherAtLocation.hourly.time.findIndex(
(time) => new Date(time) >= new Date(weatherAtLocation.current.time)
(time) => new Date(time) >= new Date(weatherAtLocation.current.time),
);
// Slice the arrays to get the desired number of items
const displayTimes = weatherAtLocation.hourly.time.slice(
currentTimeIndex,
currentTimeIndex + hoursToShow
currentTimeIndex + hoursToShow,
);
const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice(
currentTimeIndex,
currentTimeIndex + hoursToShow
currentTimeIndex + hoursToShow,
);
return (
<div
className={cx(
"skeleton-bg flex max-w-[500px] flex-col gap-4 rounded-2xl p-4",
'skeleton-bg flex max-w-[500px] flex-col gap-4 rounded-2xl p-4',
{
"bg-blue-400": isDay,
'bg-blue-400': isDay,
},
{
"bg-indigo-900": !isDay,
}
'bg-indigo-900': !isDay,
},
)}
>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<div
className={cx(
"skeleton-div size-10 rounded-full",
'skeleton-div size-10 rounded-full',
{
"bg-yellow-300": isDay,
'bg-yellow-300': isDay,
},
{
"bg-indigo-100": !isDay,
}
'bg-indigo-100': !isDay,
},
)}
/>
<div className="font-medium text-4xl text-blue-50">
@ -284,19 +284,19 @@ export function Weather({
<div className="flex flex-row justify-between">
{displayTimes.map((time, index) => (
<div className="flex flex-col items-center gap-1" key={time}>
<div key={time} className="flex flex-col items-center gap-1">
<div className="text-blue-100 text-xs">
{format(new Date(time), "ha")}
{format(new Date(time), 'ha')}
</div>
<div
className={cx(
"skeleton-div size-6 rounded-full",
'skeleton-div size-6 rounded-full',
{
"bg-yellow-300": isDay,
'bg-yellow-300': isDay,
},
{
"bg-indigo-200": !isDay,
}
'bg-indigo-200': !isDay,
},
)}
/>
<div className="text-blue-50 text-sm">