parent
7faa5f1c9f
commit
a68eb2a011
41 changed files with 2350 additions and 800 deletions
99
components/custom/app-sidebar.tsx
Normal file
99
components/custom/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
'use client';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { type User } from 'next-auth';
|
||||
|
||||
import { VercelIcon } from '@/components/custom/icons';
|
||||
import { SidebarHistory } from '@/components/custom/sidebar-history';
|
||||
import { SidebarUserNav } from '@/components/custom/sidebar-user-nav';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { BetterTooltip } from '@/components/ui/tooltip';
|
||||
|
||||
export function AppSidebar({ user }: { user: User | undefined }) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<Sidebar className="group-data-[side=left]:border-r-0">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="/" onClick={() => setOpenMobile(false)}>
|
||||
<span className="text-lg font-semibold font-mono tracking-tighter">
|
||||
Chatbot
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<BetterTooltip content="New Chat">
|
||||
<SidebarMenuAction asChild>
|
||||
<Link href="/" onClick={() => setOpenMobile(false)}>
|
||||
<Plus />
|
||||
</Link>
|
||||
</SidebarMenuAction>
|
||||
</BetterTooltip>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarHistory user={user} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="gap-0">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<Card className="p-4 flex flex-col gap-4 relative rounded-md border-none shadow-none hover:shadow transition-shadow">
|
||||
<a
|
||||
href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot&env=AUTH_SECRET,OPENAI_API_KEY&envDescription=Learn%20more%20about%20how%20to%20get%20the%20API%20Keys%20for%20the%20application&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&demo-title=AI%20Chatbot&demo-description=An%20Open-Source%20AI%20Chatbot%20Template%20Built%20With%20Next.js%20and%20the%20AI%20SDK%20by%20Vercel.&demo-url=https%3A%2F%2Fchat.vercel.ai&stores=[{%22type%22:%22postgres%22},{%22type%22:%22blob%22}]"
|
||||
className="absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="sr-only">Deploy</span>
|
||||
</a>
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="text-base">Deploy your own</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Open Source Chatbot template built with Next.js and the AI SDK
|
||||
by Vercel.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="p-0">
|
||||
<Button size="sm" className="w-full h-8 py-0 justify-start">
|
||||
<VercelIcon size={16} />
|
||||
Deploy with Vercel
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{user && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarUserNav user={user} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
36
components/custom/chat-header.tsx
Normal file
36
components/custom/chat-header.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Plus } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ModelSelector } from '@/components/custom/model-selector';
|
||||
import { SidebarToggle } from '@/components/custom/sidebar-toggle';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BetterTooltip } from '@/components/ui/tooltip';
|
||||
import { Model } from '@/lib/model';
|
||||
|
||||
export function ChatHeader({
|
||||
selectedModelName,
|
||||
}: {
|
||||
selectedModelName: Model['name'];
|
||||
}) {
|
||||
return (
|
||||
<header className="flex h-16 sticky top-0 bg-background md:h-12 items-center px-2 md:px-2 z-10">
|
||||
<SidebarToggle />
|
||||
<BetterTooltip content="New Chat">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-auto md:size-8 [&>svg]:!size-5 md:[&>svg]:!size-4 pl-2 md:p-0 order-2 md:order-1 ml-auto md:ml-0 md:hidden group-data-[state=collapsed]/sidebar-wrapper:flex"
|
||||
asChild
|
||||
>
|
||||
<Link href="/">
|
||||
<Plus />
|
||||
<span className="md:sr-only">New Chat</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</BetterTooltip>
|
||||
<ModelSelector
|
||||
selectedModelName={selectedModelName}
|
||||
className="order-1 md:order-2"
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,32 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Attachment, Message } from "ai";
|
||||
import { useChat } from "ai/react";
|
||||
import { useState } from "react";
|
||||
import { Attachment, Message } from 'ai';
|
||||
import { useChat } from 'ai/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Message as PreviewMessage } from "@/components/custom/message";
|
||||
import { useScrollToBottom } from "@/components/custom/use-scroll-to-bottom";
|
||||
import { ChatHeader } from '@/components/custom/chat-header';
|
||||
import { Message as PreviewMessage } from '@/components/custom/message';
|
||||
import { useScrollToBottom } from '@/components/custom/use-scroll-to-bottom';
|
||||
import { Model } from '@/lib/model';
|
||||
|
||||
import { MultimodalInput } from "./multimodal-input";
|
||||
import { Overview } from "./overview";
|
||||
import { MultimodalInput } from './multimodal-input';
|
||||
import { Overview } from './overview';
|
||||
|
||||
export function Chat({
|
||||
id,
|
||||
initialMessages,
|
||||
selectedModelName,
|
||||
}: {
|
||||
id: string;
|
||||
initialMessages: Array<Message>;
|
||||
selectedModelName: Model['name'];
|
||||
}) {
|
||||
const { messages, handleSubmit, input, setInput, append, isLoading, stop } =
|
||||
useChat({
|
||||
body: { id },
|
||||
body: { id, model: selectedModelName },
|
||||
initialMessages,
|
||||
onFinish: () => {
|
||||
window.history.replaceState({}, "", `/chat/${id}`);
|
||||
window.history.replaceState({}, '', `/chat/${id}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -32,44 +36,42 @@ export function Chat({
|
|||
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center pb-4 md:pb-8 h-dvh bg-background">
|
||||
<div className="flex flex-col justify-between items-center gap-4">
|
||||
<div className="flex flex-col min-w-0 h-dvh bg-background">
|
||||
<ChatHeader selectedModelName={selectedModelName} />
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll"
|
||||
>
|
||||
{messages.length === 0 && <Overview />}
|
||||
|
||||
{messages.map((message) => (
|
||||
<PreviewMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
attachments={message.experimental_attachments}
|
||||
toolInvocations={message.toolInvocations}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex flex-col gap-4 h-full w-dvw items-center overflow-y-scroll"
|
||||
>
|
||||
{messages.length === 0 && <Overview />}
|
||||
|
||||
{messages.map((message) => (
|
||||
<PreviewMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
attachments={message.experimental_attachments}
|
||||
toolInvocations={message.toolInvocations}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
ref={messagesEndRef}
|
||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-row gap-2 relative items-end w-full md:max-w-[500px] max-w-[calc(100dvw-32px) px-4 md:px-0">
|
||||
<MultimodalInput
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
attachments={attachments}
|
||||
setAttachments={setAttachments}
|
||||
messages={messages}
|
||||
append={append}
|
||||
/>
|
||||
</form>
|
||||
ref={messagesEndRef}
|
||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
|
||||
<MultimodalInput
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
attachments={attachments}
|
||||
setAttachments={setAttachments}
|
||||
messages={messages}
|
||||
append={append}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,241 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import cx from "classnames";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { User } from "next-auth";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Chat } from "@/db/schema";
|
||||
import { fetcher, getTitleFromChat } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
InfoIcon,
|
||||
MenuIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilEditIcon,
|
||||
TrashIcon,
|
||||
} from "./icons";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "../ui/sheet";
|
||||
|
||||
export const History = ({ user }: { user: User | undefined }) => {
|
||||
const { id } = useParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [isHistoryVisible, setIsHistoryVisible] = useState(false);
|
||||
const {
|
||||
data: history,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<Array<Chat>>(user ? "/api/history" : null, fetcher, {
|
||||
fallbackData: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mutate();
|
||||
}, [pathname, mutate]);
|
||||
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const deletePromise = fetch(`/api/chat?id=${deleteId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
toast.promise(deletePromise, {
|
||||
loading: "Deleting chat...",
|
||||
success: () => {
|
||||
mutate((history) => {
|
||||
if (history) {
|
||||
return history.filter((h) => h.id !== id);
|
||||
}
|
||||
});
|
||||
return "Chat deleted successfully";
|
||||
},
|
||||
error: "Failed to delete chat",
|
||||
});
|
||||
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="p-1.5 h-fit"
|
||||
onClick={() => {
|
||||
setIsHistoryVisible(true);
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</Button>
|
||||
|
||||
<Sheet
|
||||
open={isHistoryVisible}
|
||||
onOpenChange={(state) => {
|
||||
setIsHistoryVisible(state);
|
||||
}}
|
||||
>
|
||||
<SheetContent side="left" className="p-3 w-80 bg-muted">
|
||||
<SheetHeader>
|
||||
<VisuallyHidden.Root>
|
||||
<SheetTitle className="text-left">History</SheetTitle>
|
||||
<SheetDescription className="text-left">
|
||||
{history === undefined ? "loading" : history.length} chats
|
||||
</SheetDescription>
|
||||
</VisuallyHidden.Root>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="text-sm flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="dark:text-zinc-300">History</div>
|
||||
|
||||
<div className="dark:text-zinc-400 text-zinc-500">
|
||||
{history === undefined ? "loading" : history.length} chats
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col">
|
||||
{user && (
|
||||
<Button
|
||||
className="font-normal text-sm flex flex-row justify-between"
|
||||
asChild
|
||||
>
|
||||
<Link href="/">
|
||||
<div>Start a new chat</div>
|
||||
<PencilEditIcon size={14} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col overflow-y-scroll p-1 h-[calc(100dvh-124px)]">
|
||||
{!user ? (
|
||||
<div className="text-zinc-500 h-dvh w-full flex flex-row justify-center items-center text-sm gap-2">
|
||||
<InfoIcon />
|
||||
<div>Login to save and revisit previous chats!</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && history?.length === 0 && user ? (
|
||||
<div className="text-zinc-500 h-dvh w-full flex flex-row justify-center items-center text-sm gap-2">
|
||||
<InfoIcon />
|
||||
<div>No chats found</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading && user ? (
|
||||
<div className="flex flex-col">
|
||||
{[44, 32, 28, 52].map((item) => (
|
||||
<div key={item} className="p-2 my-[2px]">
|
||||
<div
|
||||
className={`w-${item} h-[20px] rounded-md bg-zinc-200 dark:bg-zinc-600 animate-pulse`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{history &&
|
||||
history.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-6 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-md pr-2",
|
||||
{ "bg-zinc-200 dark:bg-zinc-700": chat.id === id },
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cx(
|
||||
"hover:bg-zinc-200 dark:hover:bg-zinc-700 justify-between p-0 text-sm font-normal flex flex-row items-center gap-2 pr-2 w-full transition-none",
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/chat/${chat.id}`}
|
||||
className="text-ellipsis overflow-hidden text-left py-2 pl-2 rounded-lg outline-zinc-900"
|
||||
>
|
||||
{getTitleFromChat(chat)}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu modal={true}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="p-0 h-fit font-normal text-zinc-500 transition-none hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="left" className="z-[60]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex flex-row gap-2 items-center justify-start w-full h-fit font-normal p-1.5 rounded-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDeleteId(chat.id);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
<div>Delete</div>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your
|
||||
chat and remove it from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Attachment, ToolInvocation } from "ai";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
import { Attachment, ToolInvocation } from 'ai';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { BotIcon, UserIcon } from "./icons";
|
||||
import { Markdown } from "./markdown";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import { Weather } from "./weather";
|
||||
import { Markdown } from './markdown';
|
||||
import { PreviewAttachment } from './preview-attachment';
|
||||
import { Weather } from './weather';
|
||||
|
||||
export const Message = ({
|
||||
role,
|
||||
|
|
@ -22,54 +22,61 @@ export const Message = ({
|
|||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
className={`flex flex-row gap-4 px-4 w-full md:w-[500px] md:px-0 first-of-type:pt-20`}
|
||||
className="w-full mx-auto max-w-3xl px-4 group/message "
|
||||
initial={{ y: 5, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
data-role={role}
|
||||
>
|
||||
<div className="size-[24px] flex flex-col justify-center items-center shrink-0 text-zinc-400">
|
||||
{role === "assistant" ? <BotIcon /> : <UserIcon />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{content && (
|
||||
<div className="text-zinc-800 dark:text-zinc-300 flex flex-col gap-4">
|
||||
<Markdown>{content as string}</Markdown>
|
||||
<div className="flex gap-4 group-data-[role=user]/message:px-5 w-full group-data-[role=user]/message:w-fit group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl group-data-[role=user]/message:py-3.5 group-data-[role=user]/message:bg-muted rounded-xl">
|
||||
{role === 'assistant' && (
|
||||
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{content && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Markdown>{content as string}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolInvocations && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{toolInvocations.map((toolInvocation) => {
|
||||
const { toolName, toolCallId, state } = toolInvocation;
|
||||
{toolInvocations && toolInvocations.length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{toolInvocations.map((toolInvocation) => {
|
||||
const { toolName, toolCallId, state } = toolInvocation;
|
||||
|
||||
if (state === "result") {
|
||||
const { result } = toolInvocation;
|
||||
if (state === 'result') {
|
||||
const { result } = toolInvocation;
|
||||
|
||||
return (
|
||||
<div key={toolCallId}>
|
||||
{toolName === "getWeather" ? (
|
||||
<Weather weatherAtLocation={result} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={toolCallId} className="skeleton">
|
||||
{toolName === "getWeather" ? <Weather /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div key={toolCallId}>
|
||||
{toolName === 'getWeather' ? (
|
||||
<Weather weatherAtLocation={result} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={toolCallId} className="skeleton">
|
||||
{toolName === 'getWeather' ? <Weather /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachments && (
|
||||
<div className="flex flex-row gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<PreviewAttachment key={attachment.url} attachment={attachment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{attachments && (
|
||||
<div className="flex flex-row gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<PreviewAttachment
|
||||
key={attachment.url}
|
||||
attachment={attachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
|
|||
75
components/custom/model-selector.tsx
Normal file
75
components/custom/model-selector.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use client';
|
||||
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { startTransition, useMemo, useOptimistic, useState } from 'react';
|
||||
|
||||
import { saveModel } from '@/app/(chat)/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { type Model, models } from '@/lib/model';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ModelSelector({
|
||||
selectedModelName,
|
||||
className,
|
||||
}: {
|
||||
selectedModelName: Model['name'];
|
||||
} & React.ComponentProps<typeof Button>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [optimisticModelName, setOptimisticModelName] =
|
||||
useOptimistic(selectedModelName);
|
||||
|
||||
const selectModel = useMemo(
|
||||
() => models.find((model) => model.name === optimisticModelName),
|
||||
[optimisticModelName]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
'w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground md:h-8 [&>svg]:!size-5 md:[&>svg]:!size-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Button variant="ghost">
|
||||
{selectModel?.label}
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[300px]">
|
||||
{models.map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.name}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
|
||||
startTransition(() => {
|
||||
setOptimisticModelName(model.name);
|
||||
saveModel(model.name);
|
||||
});
|
||||
}}
|
||||
className="gap-4 group/item"
|
||||
data-active={model.name === optimisticModelName}
|
||||
>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
{model.label}
|
||||
{model.description && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Check className="size-4 ml-auto opacity-0 group-data-[active=true]/item:opacity-100" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Attachment, ChatRequestOptions, CreateMessage, Message } from "ai";
|
||||
import { motion } from "framer-motion";
|
||||
import { Attachment, ChatRequestOptions, CreateMessage, Message } from 'ai';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
|
|
@ -10,24 +10,24 @@ import React, {
|
|||
Dispatch,
|
||||
SetStateAction,
|
||||
ChangeEvent,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
} from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import useWindowSize from "./use-window-size";
|
||||
import { Button } from "../ui/button";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
|
||||
import { PreviewAttachment } from './preview-attachment';
|
||||
import useWindowSize from './use-window-size';
|
||||
import { Button } from '../ui/button';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
|
||||
const suggestedActions = [
|
||||
{
|
||||
title: "What is the weather",
|
||||
label: "in San Francisco?",
|
||||
action: "What is the weather in San Francisco?",
|
||||
title: 'What is the weather',
|
||||
label: 'in San Francisco?',
|
||||
action: 'What is the weather in San Francisco?',
|
||||
},
|
||||
{
|
||||
title: "Answer like I'm 5,",
|
||||
label: "why is the sky blue?",
|
||||
label: 'why is the sky blue?',
|
||||
action: "Answer like I'm 5, why is the sky blue?",
|
||||
},
|
||||
];
|
||||
|
|
@ -52,13 +52,13 @@ export function MultimodalInput({
|
|||
messages: Array<Message>;
|
||||
append: (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: ChatRequestOptions,
|
||||
chatRequestOptions?: ChatRequestOptions
|
||||
) => Promise<string | null | undefined>;
|
||||
handleSubmit: (
|
||||
event?: {
|
||||
preventDefault?: () => void;
|
||||
},
|
||||
chatRequestOptions?: ChatRequestOptions,
|
||||
chatRequestOptions?: ChatRequestOptions
|
||||
) => void;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
|
@ -72,7 +72,7 @@ export function MultimodalInput({
|
|||
|
||||
const adjustHeight = () => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
|
||||
}
|
||||
};
|
||||
|
|
@ -99,11 +99,11 @@ export function MultimodalInput({
|
|||
|
||||
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",
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ export function MultimodalInput({
|
|||
toast.error(error);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to upload file, please try again!");
|
||||
toast.error('Failed to upload file, please try again!');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ export function MultimodalInput({
|
|||
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) => [
|
||||
|
|
@ -143,12 +143,12 @@ export function MultimodalInput({
|
|||
...successfullyUploadedAttachments,
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading files!", error);
|
||||
console.error('Error uploading files!', error);
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
}
|
||||
},
|
||||
[setAttachments],
|
||||
[setAttachments]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -156,7 +156,7 @@ export function MultimodalInput({
|
|||
{messages.length === 0 &&
|
||||
attachments.length === 0 &&
|
||||
uploadQueue.length === 0 && (
|
||||
<div className="grid sm:grid-cols-2 gap-2 w-full md:px-0 mx-auto md:max-w-[500px]">
|
||||
<div className="grid sm:grid-cols-2 gap-2 w-full">
|
||||
{suggestedActions.map((suggestedAction, index) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
|
@ -164,22 +164,23 @@ export function MultimodalInput({
|
|||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ delay: 0.05 * index }}
|
||||
key={index}
|
||||
className={index > 1 ? "hidden sm:block" : "block"}
|
||||
className={index > 1 ? 'hidden sm:block' : 'block'}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
append({
|
||||
role: "user",
|
||||
role: 'user',
|
||||
content: suggestedAction.action,
|
||||
});
|
||||
}}
|
||||
className="w-full text-left border border-zinc-200 dark:border-zinc-800 text-zinc-800 dark:text-zinc-300 rounded-lg p-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors flex flex-col"
|
||||
className="text-left border rounded-xl px-4 py-3.5 text-sm flex-1 gap-1 sm:flex-col w-full h-auto justify-start items-start"
|
||||
>
|
||||
<span className="font-medium">{suggestedAction.title}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">
|
||||
<span className="text-muted-foreground">
|
||||
{suggestedAction.label}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -204,9 +205,9 @@ export function MultimodalInput({
|
|||
<PreviewAttachment
|
||||
key={filename}
|
||||
attachment={{
|
||||
url: "",
|
||||
url: '',
|
||||
name: filename,
|
||||
contentType: "",
|
||||
contentType: '',
|
||||
}}
|
||||
isUploading={true}
|
||||
/>
|
||||
|
|
@ -219,14 +220,14 @@ export function MultimodalInput({
|
|||
placeholder="Send a message..."
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
className="min-h-[24px] overflow-hidden resize-none rounded-lg text-base bg-muted"
|
||||
className="min-h-[24px] overflow-hidden resize-none rounded-xl p-4 focus-visible:ring-0 focus-visible:ring-offset-0 text-base bg-muted border-none"
|
||||
rows={3}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isLoading) {
|
||||
toast.error("Please wait for the model to finish its response!");
|
||||
toast.error('Please wait for the model to finish its response!');
|
||||
} else {
|
||||
submitForm();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import Form from 'next/form';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { auth, signOut } from '@/app/(auth)/auth';
|
||||
|
||||
import { History } from './history';
|
||||
import { ThemeToggle } from './theme-toggle';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
|
||||
export const Navbar = async () => {
|
||||
let session = await auth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-background absolute top-0 left-0 w-dvw py-2 px-3 justify-between flex flex-row items-center z-30">
|
||||
<div className="flex flex-row gap-3 items-center">
|
||||
<History user={session?.user} />
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="text-sm dark:text-zinc-300">Next.js Chatbot</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="py-1.5 px-2 h-fit font-normal"
|
||||
variant="secondary"
|
||||
>
|
||||
{session.user?.email}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<ThemeToggle />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="p-1 z-50">
|
||||
<Form
|
||||
className="w-full"
|
||||
action={async () => {
|
||||
'use server';
|
||||
|
||||
await signOut({
|
||||
redirectTo: '/',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full text-left px-1 py-0.5 text-red-500"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</Form>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button className="py-1.5 px-2 h-fit font-normal" asChild>
|
||||
<Link href="/login">Login</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,41 +1,40 @@
|
|||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { LogoOpenAI, MessageIcon, VercelIcon } from "./icons";
|
||||
import { MessageIcon, VercelIcon } from './icons';
|
||||
|
||||
export const Overview = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="overview"
|
||||
className="max-w-[500px] mt-20 mx-4 md:mx-0"
|
||||
className="max-w-3xl mx-auto md:mt-20"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<div className="border rounded-lg p-6 flex flex-col gap-4 text-zinc-500 text-sm dark:text-zinc-400 dark:border-zinc-700">
|
||||
<p className="flex flex-row justify-center gap-4 items-center text-zinc-900 dark:text-zinc-50">
|
||||
<VercelIcon />
|
||||
<div className="rounded-xl p-6 flex flex-col gap-8 leading-relaxed text-center max-w-xl">
|
||||
<p className="flex flex-row justify-center gap-4 items-center">
|
||||
<VercelIcon size={32} />
|
||||
<span>+</span>
|
||||
<MessageIcon />
|
||||
<MessageIcon size={32} />
|
||||
</p>
|
||||
<p>
|
||||
This is an open source Chatbot template built with Next.js and the AI
|
||||
SDK by Vercel. It uses the{" "}
|
||||
<code className="rounded-md bg-muted px-1 py-0.5">streamText</code>{" "}
|
||||
function in the server and the{" "}
|
||||
SDK by Vercel. It uses the{' '}
|
||||
<code className="rounded-md bg-muted px-1 py-0.5">streamText</code>{' '}
|
||||
function in the server and the{' '}
|
||||
<code className="rounded-md bg-muted px-1 py-0.5">useChat</code> hook
|
||||
on the client to create a seamless chat experience.
|
||||
</p>
|
||||
<p>
|
||||
{" "}
|
||||
You can learn more about the AI SDK by visiting the{" "}
|
||||
You can learn more about the AI SDK by visiting the{' '}
|
||||
<Link
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
className="font-medium underline underline-offset-4"
|
||||
href="https://sdk.vercel.ai/docs"
|
||||
target="_blank"
|
||||
>
|
||||
Docs
|
||||
docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Attachment } from "ai";
|
||||
import { Attachment } from 'ai';
|
||||
|
||||
import { LoaderIcon } from "./icons";
|
||||
import { LoaderIcon } from './icons';
|
||||
|
||||
export const PreviewAttachment = ({
|
||||
attachment,
|
||||
|
|
@ -12,18 +12,18 @@ export const PreviewAttachment = ({
|
|||
const { name, url, contentType } = attachment;
|
||||
|
||||
return (
|
||||
(<div className="flex flex-col gap-2 max-w-16">
|
||||
<div className="h-20 w-16 bg-muted rounded-md relative flex flex-col items-center justify-center">
|
||||
<div className="flex flex-col gap-2 max-w-16">
|
||||
<div className="w-20 aspect-video bg-muted rounded-md relative flex flex-col items-center justify-center">
|
||||
{contentType ? (
|
||||
contentType.startsWith("image") ? (
|
||||
contentType.startsWith('image') ? (
|
||||
// NOTE: it is recommended to use next/image for images
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
(<img
|
||||
<img
|
||||
key={url}
|
||||
src={url}
|
||||
alt={name ?? "An image attachment"}
|
||||
alt={name ?? 'An image attachment'}
|
||||
className="rounded-md size-full object-cover"
|
||||
/>)
|
||||
/>
|
||||
) : (
|
||||
<div className=""></div>
|
||||
)
|
||||
|
|
@ -38,6 +38,6 @@ export const PreviewAttachment = ({
|
|||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 max-w-16 truncate">{name}</div>
|
||||
</div>)
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
210
components/custom/sidebar-history.tsx
Normal file
210
components/custom/sidebar-history.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import { type User } from 'next-auth';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import {
|
||||
InfoIcon,
|
||||
MoreHorizontalIcon,
|
||||
TrashIcon,
|
||||
} from '@/components/custom/icons';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Chat } from '@/db/schema';
|
||||
import { fetcher, getTitleFromChat } from '@/lib/utils';
|
||||
|
||||
export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { id } = useParams();
|
||||
const pathname = usePathname();
|
||||
const {
|
||||
data: history,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<Array<Chat>>(user ? '/api/history' : null, fetcher, {
|
||||
fallbackData: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mutate();
|
||||
}, [pathname, mutate]);
|
||||
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const router = useRouter();
|
||||
const handleDelete = async () => {
|
||||
const deletePromise = fetch(`/api/chat?id=${deleteId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
toast.promise(deletePromise, {
|
||||
loading: 'Deleting chat...',
|
||||
success: () => {
|
||||
mutate((history) => {
|
||||
if (history) {
|
||||
return history.filter((h) => h.id !== id);
|
||||
}
|
||||
});
|
||||
return 'Chat deleted successfully';
|
||||
},
|
||||
error: 'Failed to delete chat',
|
||||
});
|
||||
|
||||
setShowDeleteDialog(false);
|
||||
if (deleteId === id) {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>History</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-zinc-500 h-dvh w-full flex flex-row justify-center items-center text-sm gap-2">
|
||||
<InfoIcon />
|
||||
<div>Login to save and revisit previous chats!</div>
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>History</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex flex-col">
|
||||
{[44, 32, 28, 64, 52].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="rounded-md h-8 flex gap-2 px-2 items-center"
|
||||
>
|
||||
<div
|
||||
className="h-4 rounded-md flex-1 max-w-[--skeleton-width] bg-sidebar-accent-foreground/10"
|
||||
style={
|
||||
{
|
||||
'--skeleton-width': `${item}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (history?.length === 0) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>History</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<InfoIcon />
|
||||
<span>No chats found</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>History</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{history &&
|
||||
history.map((chat) => (
|
||||
<SidebarMenuItem key={chat.id}>
|
||||
<SidebarMenuButton asChild isActive={chat.id === id}>
|
||||
<Link
|
||||
href={`/chat/${chat.id}`}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
<span>{getTitleFromChat(chat)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu modal={true}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
showOnHover={chat.id !== id}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:bg-destructive/15 focus:text-destructive"
|
||||
onSelect={() => {
|
||||
setDeleteId(chat.id);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your
|
||||
chat and remove it from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
components/custom/sidebar-toggle.tsx
Normal file
20
components/custom/sidebar-toggle.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { ComponentProps } from 'react';
|
||||
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { BetterTooltip } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function SidebarToggle({
|
||||
className,
|
||||
}: ComponentProps<typeof SidebarTrigger>) {
|
||||
return (
|
||||
<BetterTooltip content="Toggle Sidebar" align="start">
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
'size-10 md:size-8 [&>svg]:!size-5 md:[&>svg]:!size-4',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</BetterTooltip>
|
||||
);
|
||||
}
|
||||
67
components/custom/sidebar-user-nav.tsx
Normal file
67
components/custom/sidebar-user-nav.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
'use client';
|
||||
import { ChevronUp } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { type User } from 'next-auth';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
export function SidebarUserNav({ user }: { user: User }) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10">
|
||||
<Image
|
||||
src={`https://avatar.vercel.sh/${user.email}`}
|
||||
alt={user.email ?? 'User Avatar'}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span>{user?.email}</span>
|
||||
<ChevronUp className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<button
|
||||
className="w-full "
|
||||
onClick={() => {
|
||||
signOut({
|
||||
redirectTo: '/',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
25
components/custom/sign-out-form.tsx
Normal file
25
components/custom/sign-out-form.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Form from 'next/form';
|
||||
|
||||
import { signOut } from '@/app/(auth)/auth';
|
||||
|
||||
export const SignOutForm = () => {
|
||||
return (
|
||||
<Form
|
||||
className="w-full"
|
||||
action={async () => {
|
||||
'use server';
|
||||
|
||||
await signOut({
|
||||
redirectTo: '/',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full text-left px-1 py-0.5 text-red-500"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import * as React from "react";
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className="cursor-pointer" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
}}
|
||||
>
|
||||
{`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"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';
|
||||
|
||||
interface WeatherAtLocation {
|
||||
latitude: number;
|
||||
|
|
@ -47,114 +47,114 @@ const SAMPLE = {
|
|||
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(
|
||||
"flex flex-col gap-4 rounded-2xl p-4 skeleton-bg",
|
||||
'flex flex-col gap-4 rounded-2xl p-4 skeleton-bg max-w-[500px]',
|
||||
{
|
||||
"bg-blue-400": isDay,
|
||||
'bg-blue-400': isDay,
|
||||
},
|
||||
{
|
||||
"bg-indigo-900": !isDay,
|
||||
},
|
||||
'bg-indigo-900': !isDay,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div
|
||||
className={cx(
|
||||
"size-10 rounded-full skeleton-div",
|
||||
'size-10 rounded-full skeleton-div',
|
||||
{
|
||||
"bg-yellow-300": isDay,
|
||||
'bg-yellow-300': isDay,
|
||||
},
|
||||
{
|
||||
"bg-indigo-100": !isDay,
|
||||
},
|
||||
'bg-indigo-100': !isDay,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<div className="text-4xl font-medium text-blue-50">
|
||||
|
|
@ -286,17 +286,17 @@ export function Weather({
|
|||
{displayTimes.map((time, index) => (
|
||||
<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(
|
||||
"size-6 rounded-full skeleton-div",
|
||||
'size-6 rounded-full skeleton-div',
|
||||
{
|
||||
"bg-yellow-300": isDay,
|
||||
'bg-yellow-300': isDay,
|
||||
},
|
||||
{
|
||||
"bg-indigo-200": !isDay,
|
||||
},
|
||||
'bg-indigo-200': !isDay,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<div className="text-blue-50 text-sm">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue