Start on new sidebar (#456)

Co-authored-by: shadcn <m@shadcn.com>
This commit is contained in:
Jared Palmer 2024-10-24 16:35:51 -04:00 committed by GitHub
parent 7faa5f1c9f
commit a68eb2a011
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2350 additions and 800 deletions

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
};

View file

@ -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>
);

View 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>
);
}

View file

@ -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();
}

View file

@ -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>
</>
);
};

View file

@ -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>

View file

@ -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>
);
};

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
};

View file

@ -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>;

View file

@ -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>
);
}

View file

@ -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">