MingruiZhang commited on
Commit
8e3dbd3
1 Parent(s): 6b8f69a

feat: Customized Img and scroll to bottom (#18)

Browse files

<img width="1904" alt="image"
src="https://github.com/landing-ai/vision-agent-ui/assets/5669963/2043cf31-34e8-4aae-8ddd-9f068154fb8f">

app/api/sign/route.ts CHANGED
@@ -7,33 +7,33 @@ import { nanoid } from '@/lib/utils';
7
  * @returns
8
  */
9
  export async function POST(req: Request): Promise<Response> {
10
- const session = await auth();
11
- const user = session?.user?.email ?? 'anonymous';
12
- // if (!email) {
13
- // return new Response('Unauthorized', {
14
- // status: 401,
15
- // });
16
- // }
17
 
18
- try {
19
- const { fileName, fileType } = (await req.json()) as {
20
- fileName: string;
21
- fileType: string;
22
- };
23
 
24
- const id = nanoid();
25
 
26
- const signedFileName = `${user}/${id}/${fileName}`;
27
- const res = await getPresignedUrl(signedFileName, fileType);
28
- return Response.json({
29
- id,
30
- signedUrl: res.url,
31
- publicUrl: `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${signedFileName}`,
32
- fields: res.fields,
33
- });
34
- } catch (error) {
35
- return new Response((error as Error).message, {
36
- status: 400,
37
- });
38
- }
39
  }
 
7
  * @returns
8
  */
9
  export async function POST(req: Request): Promise<Response> {
10
+ const session = await auth();
11
+ const user = session?.user?.email ?? 'anonymous';
12
+ // if (!email) {
13
+ // return new Response('Unauthorized', {
14
+ // status: 401,
15
+ // });
16
+ // }
17
 
18
+ try {
19
+ const { fileName, fileType } = (await req.json()) as {
20
+ fileName: string;
21
+ fileType: string;
22
+ };
23
 
24
+ const id = nanoid();
25
 
26
+ const signedFileName = `${user}/${id}/${fileName}`;
27
+ const res = await getPresignedUrl(signedFileName, fileType);
28
+ return Response.json({
29
+ id,
30
+ signedUrl: res.url,
31
+ publicUrl: `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${signedFileName}`,
32
+ fields: res.fields,
33
+ });
34
+ } catch (error) {
35
+ return new Response((error as Error).message, {
36
+ status: 400,
37
+ });
38
+ }
39
  }
app/api/vision-agent/route.ts CHANGED
@@ -5,7 +5,7 @@ import { MessageBase } from '../../../lib/types';
5
 
6
  // export const runtime = 'edge';
7
  export const dynamic = 'force-dynamic';
8
- export const maxDuration = 60; // This function can run for a maximum of 5 seconds
9
 
10
  export async function POST(req: Request) {
11
  const json = await req.json();
 
5
 
6
  // export const runtime = 'edge';
7
  export const dynamic = 'force-dynamic';
8
+ export const maxDuration = 300; // This function can run for a maximum of 5 minutes
9
 
10
  export async function POST(req: Request) {
11
  const json = await req.json();
app/globals.css CHANGED
@@ -144,10 +144,6 @@ tr {
144
  border-top: 1px solid var(--color-border-muted);
145
  }
146
 
147
- tr:nth-child(2n) {
148
- background-color: var(--color-canvas-subtle);
149
- }
150
-
151
  td,
152
  th {
153
  padding: 6px 13px;
 
144
  border-top: 1px solid var(--color-border-muted);
145
  }
146
 
 
 
 
 
147
  td,
148
  th {
149
  padding: 6px 13px;
components/chat-sidebar/ChatCard.tsx CHANGED
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
7
  import { ChatEntity } from '@/lib/types';
8
  import Image from 'next/image';
9
  import clsx from 'clsx';
 
10
 
11
  type ChatCardProps = PropsWithChildren<{
12
  chat: ChatEntity;
@@ -43,13 +44,7 @@ const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
43
  classNames={chatIdFromParam === id && 'border-gray-500'}
44
  >
45
  <div className="overflow-hidden flex items-center size-full">
46
- <Image
47
- src={url}
48
- alt={url}
49
- width={50}
50
- height={50}
51
- className="rounded w-1/4 "
52
- />
53
  <div className="flex items-start h-full">
54
  <p className="text-sm w-3/4 ml-3">{title}</p>
55
  </div>
 
7
  import { ChatEntity } from '@/lib/types';
8
  import Image from 'next/image';
9
  import clsx from 'clsx';
10
+ import Img from '../ui/Img';
11
 
12
  type ChatCardProps = PropsWithChildren<{
13
  chat: ChatEntity;
 
44
  classNames={chatIdFromParam === id && 'border-gray-500'}
45
  >
46
  <div className="overflow-hidden flex items-center size-full">
47
+ <Img src={url} className="w-1/4 " />
 
 
 
 
 
 
48
  <div className="flex items-start h-full">
49
  <p className="text-sm w-3/4 ml-3">{title}</p>
50
  </div>
components/chat/ChatList.tsx CHANGED
@@ -10,7 +10,7 @@ export interface ChatList {
10
 
11
  export function ChatList({ messages }: ChatList) {
12
  return (
13
- <div className="relative mx-auto max-w-5xl px-8 pr-12 overflow-auto">
14
  {messages
15
  // .filter(message => message.role !== 'system')
16
  .map((message, index) => (
 
10
 
11
  export function ChatList({ messages }: ChatList) {
12
  return (
13
+ <div className="relative mx-auto max-w-5xl px-8 pr-12">
14
  {messages
15
  // .filter(message => message.role !== 'system')
16
  .map((message, index) => (
components/chat/ChatMessage.tsx CHANGED
@@ -32,7 +32,7 @@ export function ChatMessage({ message, ...props }: ChatMessageProps) {
32
  </div>
33
  <div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
34
  {logs && message.role !== 'user' && (
35
- <div className="bg-slate-100 dark:bg-slate-900 mb-4 p-4 max-h-[400px] overflow-auto">
36
  <div className="text-xl font-bold">Thinking Process</div>
37
  <MemoizedReactMarkdown
38
  className="break-words text-sm"
 
32
  </div>
33
  <div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
34
  {logs && message.role !== 'user' && (
35
+ <div className="bg-slate-100 dark:bg-slate-900 mb-4 p-4 max-h-[400px] overflow-y-scroll">
36
  <div className="text-xl font-bold">Thinking Process</div>
37
  <MemoizedReactMarkdown
38
  className="break-words text-sm"
components/chat/ChatPanel.tsx CHANGED
@@ -3,7 +3,6 @@ import { type UseChatHelpers } from 'ai/react';
3
 
4
  import { Button } from '@/components/ui/Button';
5
  import { PromptForm } from '@/components/chat/PromptForm';
6
- import { ButtonScrollToBottom } from '@/components/chat/ButtonScrollToBottom';
7
  import { IconRefresh, IconStop } from '@/components/ui/Icons';
8
  import { MessageBase } from '../../lib/types';
9
 
@@ -32,29 +31,7 @@ export function ChatPanel({
32
  }: ChatPanelProps) {
33
  return (
34
  <div className="fixed inset-x-0 bottom-0 w-full animate-in duration-300 ease-in-out peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
35
- <ButtonScrollToBottom />
36
  <div className="mx-auto sm:max-w-3xl sm:px-4">
37
- <div className="flex items-center justify-center h-12">
38
- {isLoading ? (
39
- <Button
40
- variant="outline"
41
- onClick={() => stop()}
42
- className="bg-background"
43
- >
44
- <IconStop className="mr-2" />
45
- Stop generating
46
- </Button>
47
- ) : (
48
- messages?.length >= 2 && (
49
- <div className="flex space-x-2">
50
- <Button variant="outline" onClick={() => reload()}>
51
- <IconRefresh className="mr-2" />
52
- Regenerate response
53
- </Button>
54
- </div>
55
- )
56
- )}
57
- </div>
58
  <div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4">
59
  <PromptForm
60
  url={url}
@@ -68,6 +45,8 @@ export function ChatPanel({
68
  input={input}
69
  setInput={setInput}
70
  isLoading={isLoading}
 
 
71
  />
72
  </div>
73
  </div>
 
3
 
4
  import { Button } from '@/components/ui/Button';
5
  import { PromptForm } from '@/components/chat/PromptForm';
 
6
  import { IconRefresh, IconStop } from '@/components/ui/Icons';
7
  import { MessageBase } from '../../lib/types';
8
 
 
31
  }: ChatPanelProps) {
32
  return (
33
  <div className="fixed inset-x-0 bottom-0 w-full animate-in duration-300 ease-in-out peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
 
34
  <div className="mx-auto sm:max-w-3xl sm:px-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  <div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4">
36
  <PromptForm
37
  url={url}
 
45
  input={input}
46
  setInput={setInput}
47
  isLoading={isLoading}
48
+ messages={messages}
49
+ reload={reload}
50
  />
51
  </div>
52
  </div>
components/chat/ChatScrollAnchor.tsx DELETED
@@ -1,29 +0,0 @@
1
- 'use client';
2
-
3
- import * as React from 'react';
4
- import { useInView } from 'react-intersection-observer';
5
-
6
- import { useAtBottom } from '@/lib/hooks/useAtBottom';
7
-
8
- interface ChatScrollAnchorProps {
9
- trackVisibility?: boolean;
10
- }
11
-
12
- export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
13
- const isAtBottom = useAtBottom();
14
- const { ref, entry, inView } = useInView({
15
- trackVisibility,
16
- delay: 100,
17
- rootMargin: '0px 0px -150px 0px',
18
- });
19
-
20
- React.useEffect(() => {
21
- if (isAtBottom && trackVisibility && !inView) {
22
- entry?.target.scrollIntoView({
23
- block: 'start',
24
- });
25
- }
26
- }, [inView, entry, isAtBottom, trackVisibility]);
27
-
28
- return <div ref={ref} className="h-px w-full" />;
29
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/chat/PromptForm.tsx CHANGED
@@ -2,22 +2,28 @@ import * as React from 'react';
2
  import Textarea from 'react-textarea-autosize';
3
  import { UseChatHelpers } from 'ai/react';
4
  import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit';
5
- import { cn } from '@/lib/utils';
6
  import { Button, buttonVariants } from '@/components/ui/Button';
7
  import {
8
  Tooltip,
9
  TooltipContent,
10
  TooltipTrigger,
11
  } from '@/components/ui/Tooltip';
12
- import { IconArrowElbow, IconPlus } from '@/components/ui/Icons';
 
 
 
 
 
13
  import { useRouter } from 'next/navigation';
14
- import Image from 'next/image';
 
15
 
16
  export interface PromptProps
17
- extends Pick<UseChatHelpers, 'input' | 'setInput'> {
18
  onSubmit: (value: string) => void;
19
  isLoading: boolean;
20
  url?: string;
 
21
  }
22
 
23
  export function PromptForm({
@@ -26,6 +32,8 @@ export function PromptForm({
26
  setInput,
27
  isLoading,
28
  url,
 
 
29
  }: PromptProps) {
30
  const { formRef, onKeyDown } = useEnterSubmit();
31
  const inputRef = React.useRef<HTMLTextAreaElement>(null);
@@ -52,15 +60,11 @@ export function PromptForm({
52
  {url && (
53
  <Tooltip>
54
  <TooltipTrigger asChild>
55
- <Image
56
- src={url}
57
- width={250}
58
- height={250}
59
- alt="chosen image"
60
- className="w-1/5 my-4 mx-2 rounded-md"
61
- />
62
  </TooltipTrigger>
63
- <TooltipContent>New Chat</TooltipContent>
 
 
64
  </Tooltip>
65
  )}
66
  <Textarea
@@ -74,7 +78,28 @@ export function PromptForm({
74
  spellCheck={false}
75
  className="min-h-[60px] w-4/5 resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
76
  />
77
- <div className="absolute right-0 top-4 sm:right-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  <Tooltip>
79
  <TooltipTrigger asChild>
80
  <Button
 
2
  import Textarea from 'react-textarea-autosize';
3
  import { UseChatHelpers } from 'ai/react';
4
  import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit';
 
5
  import { Button, buttonVariants } from '@/components/ui/Button';
6
  import {
7
  Tooltip,
8
  TooltipContent,
9
  TooltipTrigger,
10
  } from '@/components/ui/Tooltip';
11
+ import {
12
+ IconArrowElbow,
13
+ IconPlus,
14
+ IconRefresh,
15
+ IconStop,
16
+ } from '@/components/ui/Icons';
17
  import { useRouter } from 'next/navigation';
18
+ import Img from '../ui/Img';
19
+ import { MessageBase } from '@/lib/types';
20
 
21
  export interface PromptProps
22
+ extends Pick<UseChatHelpers, 'input' | 'setInput' | 'reload'> {
23
  onSubmit: (value: string) => void;
24
  isLoading: boolean;
25
  url?: string;
26
+ messages: MessageBase[];
27
  }
28
 
29
  export function PromptForm({
 
32
  setInput,
33
  isLoading,
34
  url,
35
+ messages,
36
+ reload,
37
  }: PromptProps) {
38
  const { formRef, onKeyDown } = useEnterSubmit();
39
  const inputRef = React.useRef<HTMLTextAreaElement>(null);
 
60
  {url && (
61
  <Tooltip>
62
  <TooltipTrigger asChild>
63
+ <Img src={url} className="w-1/5 my-4 mx-2 cursor-zoom-in" />
 
 
 
 
 
 
64
  </TooltipTrigger>
65
+ <TooltipContent>
66
+ <Img src={url} className="m-2" />
67
+ </TooltipContent>
68
  </Tooltip>
69
  )}
70
  <Textarea
 
78
  spellCheck={false}
79
  className="min-h-[60px] w-4/5 resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
80
  />
81
+ <div className="absolute left-1/2 -translate-x-1/2 bottom-0 h-12 z-40">
82
+ {isLoading ? (
83
+ <Button
84
+ variant="outline"
85
+ onClick={() => stop()}
86
+ className="bg-background"
87
+ >
88
+ <IconStop className="mr-2" />
89
+ Stop generating
90
+ </Button>
91
+ ) : (
92
+ messages?.length >= 2 && (
93
+ <div className="flex space-x-2">
94
+ <Button variant="outline" onClick={() => reload()}>
95
+ <IconRefresh className="mr-2" />
96
+ Regenerate response
97
+ </Button>
98
+ </div>
99
+ )
100
+ )}
101
+ </div>
102
+ <div className="absolute top-1/2 -translate-y-1/2 right-4">
103
  <Tooltip>
104
  <TooltipTrigger asChild>
105
  <Button
components/chat/index.tsx CHANGED
@@ -3,10 +3,11 @@
3
  import { cn } from '@/lib/utils';
4
  import { ChatList } from '@/components/chat/ChatList';
5
  import { ChatPanel } from '@/components/chat/ChatPanel';
6
- import { ChatScrollAnchor } from '@/components/chat/ChatScrollAnchor';
7
  import { ChatEntity } from '@/lib/types';
8
  import Image from 'next/image';
9
  import useVisionAgent from '@/lib/hooks/useVisionAgent';
 
 
10
 
11
  export interface ChatProps extends React.ComponentProps<'div'> {
12
  chat: ChatEntity;
@@ -17,12 +18,16 @@ export function Chat({ chat }: ChatProps) {
17
  const { messages, append, reload, stop, isLoading, input, setInput } =
18
  useVisionAgent(chat);
19
 
 
 
 
 
20
  return (
21
  <>
22
- <div className={cn('pb-[250px] pt-4 md:pt-10 h-full')}>
23
- <div className="size-full overflow-auto">
24
  <ChatList messages={messages} />
25
- <ChatScrollAnchor trackVisibility={isLoading} />
26
  </div>
27
  </div>
28
  <ChatPanel
@@ -36,6 +41,10 @@ export function Chat({ chat }: ChatProps) {
36
  input={input}
37
  setInput={setInput}
38
  />
 
 
 
 
39
  </>
40
  );
41
  }
 
3
  import { cn } from '@/lib/utils';
4
  import { ChatList } from '@/components/chat/ChatList';
5
  import { ChatPanel } from '@/components/chat/ChatPanel';
 
6
  import { ChatEntity } from '@/lib/types';
7
  import Image from 'next/image';
8
  import useVisionAgent from '@/lib/hooks/useVisionAgent';
9
+ import { useScrollAnchor } from '@/lib/hooks/useScrollAnchor';
10
+ import { ButtonScrollToBottom } from '../ui/ButtonScrollToBottom';
11
 
12
  export interface ChatProps extends React.ComponentProps<'div'> {
13
  chat: ChatEntity;
 
18
  const { messages, append, reload, stop, isLoading, input, setInput } =
19
  useVisionAgent(chat);
20
 
21
+ const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
22
+ useScrollAnchor();
23
+ console.log('[Ming] ~ Chat ~ isAtBottom:', isAtBottom);
24
+
25
  return (
26
  <>
27
+ <div className="h-full overflow-auto" ref={scrollRef}>
28
+ <div className="pb-[200px] pt-4 md:pt-10" ref={messagesRef}>
29
  <ChatList messages={messages} />
30
+ <div className="h-px w-full" ref={visibilityRef} />
31
  </div>
32
  </div>
33
  <ChatPanel
 
41
  input={input}
42
  setInput={setInput}
43
  />
44
+ <ButtonScrollToBottom
45
+ isAtBottom={isAtBottom}
46
+ scrollToBottom={scrollToBottom}
47
+ />
48
  </>
49
  );
50
  }
components/project/Chat.tsx CHANGED
@@ -4,7 +4,6 @@ import { MediaDetails } from '@/lib/fetch';
4
  import useChatWithMedia from '@/lib/hooks/useChatWithMedia';
5
  import React from 'react';
6
  import { ChatList } from '../chat/ChatList';
7
- import { ChatScrollAnchor } from '../chat/ChatScrollAnchor';
8
  import { ChatPanel } from '../chat/ChatPanel';
9
 
10
  export interface ChatProps {
@@ -17,7 +16,6 @@ const Chat: React.FC<ChatProps> = ({ mediaList }) => {
17
  return (
18
  <>
19
  <ChatList messages={messages} />
20
- <ChatScrollAnchor trackVisibility={isLoading} />
21
  <ChatPanel
22
  isLoading={isLoading}
23
  stop={stop}
 
4
  import useChatWithMedia from '@/lib/hooks/useChatWithMedia';
5
  import React from 'react';
6
  import { ChatList } from '../chat/ChatList';
 
7
  import { ChatPanel } from '../chat/ChatPanel';
8
 
9
  export interface ChatProps {
 
16
  return (
17
  <>
18
  <ChatList messages={messages} />
 
19
  <ChatPanel
20
  isLoading={isLoading}
21
  stop={stop}
components/{chat → ui}/ButtonScrollToBottom.tsx RENAMED
@@ -1,30 +1,32 @@
1
  'use client';
2
 
3
- import * as React from 'react';
4
 
5
  import { cn } from '@/lib/utils';
6
- import { useAtBottom } from '@/lib/hooks/useAtBottom';
7
  import { Button, type ButtonProps } from '@/components/ui/Button';
8
  import { IconArrowDown } from '@/components/ui/Icons';
9
 
10
- export function ButtonScrollToBottom({ className, ...props }: ButtonProps) {
11
- const isAtBottom = useAtBottom();
 
 
12
 
 
 
 
 
 
 
13
  return (
14
  <Button
15
  variant="outline"
16
  size="icon"
17
  className={cn(
18
- 'absolute right-4 top-1 z-10 bg-background transition-opacity duration-300 sm:right-8 md:top-2',
19
  isAtBottom ? 'opacity-0' : 'opacity-100',
20
  className,
21
  )}
22
- onClick={() =>
23
- window.scrollTo({
24
- top: document.body.offsetHeight,
25
- behavior: 'smooth',
26
- })
27
- }
28
  {...props}
29
  >
30
  <IconArrowDown />
 
1
  'use client';
2
 
3
+ import React from 'react';
4
 
5
  import { cn } from '@/lib/utils';
 
6
  import { Button, type ButtonProps } from '@/components/ui/Button';
7
  import { IconArrowDown } from '@/components/ui/Icons';
8
 
9
+ interface ButtonScrollToBottomProps extends ButtonProps {
10
+ isAtBottom: boolean;
11
+ scrollToBottom: () => void;
12
+ }
13
 
14
+ export function ButtonScrollToBottom({
15
+ className,
16
+ isAtBottom,
17
+ scrollToBottom,
18
+ ...props
19
+ }: ButtonScrollToBottomProps) {
20
  return (
21
  <Button
22
  variant="outline"
23
  size="icon"
24
  className={cn(
25
+ 'fixed bottom-16 right-4 z-10 bg-background transition-opacity duration-300',
26
  isAtBottom ? 'opacity-0' : 'opacity-100',
27
  className,
28
  )}
29
+ onClick={() => scrollToBottom()}
 
 
 
 
 
30
  {...props}
31
  >
32
  <IconArrowDown />
components/ui/ImageLoader.tsx DELETED
@@ -1,11 +0,0 @@
1
- // import Image from 'next/image';
2
- // import React from 'react';
3
-
4
- // type ImageLoaderProps = typeof Image;
5
-
6
- // const ImageLoader: React.FC<ImageLoaderProps> = props => {
7
- // const { alt, width, height } = props;
8
- // return <Image alt={alt} />;
9
- // };
10
-
11
- // export default ImageLoader;
 
 
 
 
 
 
 
 
 
 
 
 
components/ui/Img.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import Image from 'next/image';
5
+ import { cn } from '@/lib/utils';
6
+
7
+ const placeholder =
8
+ 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAgEASABIAAD/4QDWRXhpZgAATU0AKgAAAAgABwEGAAMAAAABAAIAAAESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAcgAAAAAAAABIAAAAAQAAAEgAAAABAAeQAAAHAAAABDAyMjGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCgAQADAAAAAQABAACgAgAEAAAAAQAAADKgAwAEAAAAAQAAADKkBgADAAAAAQAAAAAAAAAAAAD/wAARCAAyADIDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9sAQwABAQEBAQECAQECAwICAgMEAwMDAwQGBAQEBAQGBwYGBgYGBgcHBwcHBwcHCAgICAgICQkJCQkLCwsLCwsLCwsL/9sAQwECAgIDAwMFAwMFCwgGCAsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsL/90ABAAE/9oADAMBAAIRAxEAPwD+nbSfDst/NnB5Ne5+G/htJMqsVOD610fw/wDCS3Lq7L3r6y0Lw7b20SgqMiluB4hpvwti2Asn51fufhVbsmQlfSCQQQjHFOIt24OKLAfEXiL4XtCjMiGvnrxH4SlsJG+UjFfqZf6Va3UZUgV84/ETwTGY3kRaVrbAfAp058//AK6T+zn/AM5/wr1uTw0BIwx3P+elM/4Roen+fyo5gP/Q/tp+G1vGIVJFer6n4lstGizM4XFeH/DzVUW3HPavmL9pX4sXvhmKV0Yqq5qbgfXeq/GjRLJipmH51yMn7Qvh2NtrTL+dfzYfGL9uqTw1PLGZjkE96+M7j/gotqtzf+WkrYz60rsD+1bwt8YtD12ZYYZlJPvXY+KlivdOMy8giv5q/wBjD9prxB8QNcgXezKxHOa/obsdZa48KRPMTuKiqA8dm09PNbjuaj/s9PSp5rtDM5B7nvUf2tfX9f8A69QB/9H+v3wLrW1RHuxmvJv2iPh43jLRZhEuSynpWN4X8Rm0lCs2BnivoKy1iy1W1ENyQcjFQB/J7+0x+x34yvtVmlsY3OScYBr418KfsLfES81xFmgkALDPBr+1rXPhR4d15jJJEj59qwtP+BXhiznE4t0BHsKEugH54fsJfsqS/Dqzt7m/TDgAnIr9j9Q1BNN0kW4OAq1yun2mk+G7fZAFG0dq868Y+LwyMit+FFtAEl1tfNb5x1NR/wBtr/fH+fwrxptbnLE5703+2p6QH//S/pMs+JjivYfDjv5Q5NePWn+uNev+HP8AVCkwPWdPdtvU1fnkk2n5j09aztP+7V6f7p+lLoBxeuu4jOCa8N1xmMjEnNe4a7/qzXh2t/6xqfQDlqKKKgD/2Q==';
9
+
10
+ // const Props = Omit<React.ComponentPropsWithoutRef<typeof Image>, 'alt'>;
11
+ const Img = React.forwardRef<
12
+ React.ElementRef<typeof Image>,
13
+ Omit<React.ComponentPropsWithoutRef<typeof Image>, 'alt'>
14
+ >(({ src, onLoad, width, height, className, ...props }, ref) => {
15
+ const [dimensions, setDimensions] = React.useState({
16
+ width: width ?? 200,
17
+ height: height ?? 200,
18
+ });
19
+ // const [isLoading, setIsLoading] = React.useState(true);
20
+ const [_, startTransition] = React.useTransition();
21
+ return (
22
+ <Image
23
+ src={src}
24
+ placeholder={placeholder}
25
+ width={dimensions.width}
26
+ height={dimensions.height}
27
+ alt="image"
28
+ ref={ref}
29
+ className={cn('rounded-md', className)}
30
+ onLoad={e => {
31
+ const img = e.target as HTMLImageElement;
32
+ startTransition(() => {
33
+ setDimensions({
34
+ width: img.naturalWidth,
35
+ height: img.naturalHeight,
36
+ });
37
+ });
38
+ return onLoad?.(e);
39
+ }}
40
+ {...props}
41
+ />
42
+ );
43
+ });
44
+
45
+ Img.displayName = Image.displayName;
46
+
47
+ export default Img;
lib/hooks/useAtBottom.tsx DELETED
@@ -1,23 +0,0 @@
1
- import * as React from 'react';
2
-
3
- export function useAtBottom(offset = 0) {
4
- const [isAtBottom, setIsAtBottom] = React.useState(false);
5
-
6
- React.useEffect(() => {
7
- const handleScroll = () => {
8
- setIsAtBottom(
9
- window.innerHeight + window.scrollY >=
10
- document.body.offsetHeight - offset,
11
- );
12
- };
13
-
14
- window.addEventListener('scroll', handleScroll, { passive: true });
15
- handleScroll();
16
-
17
- return () => {
18
- window.removeEventListener('scroll', handleScroll);
19
- };
20
- }, [offset]);
21
-
22
- return isAtBottom;
23
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/hooks/useScrollAnchor.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ export const useScrollAnchor = () => {
4
+ const messagesRef = useRef<HTMLDivElement>(null);
5
+ const scrollRef = useRef<HTMLDivElement>(null);
6
+ const visibilityRef = useRef<HTMLDivElement>(null);
7
+
8
+ const [isAtBottom, setIsAtBottom] = useState(true);
9
+ const [isVisible, setIsVisible] = useState(false);
10
+
11
+ const scrollToBottom = useCallback(() => {
12
+ if (messagesRef.current) {
13
+ messagesRef.current.scrollIntoView({
14
+ block: 'end',
15
+ behavior: 'smooth',
16
+ });
17
+ }
18
+ }, []);
19
+
20
+ useEffect(() => {
21
+ if (messagesRef.current) {
22
+ if (isAtBottom && !isVisible) {
23
+ messagesRef.current.scrollIntoView({
24
+ block: 'end',
25
+ });
26
+ }
27
+ }
28
+ }, [isAtBottom, isVisible]);
29
+
30
+ useEffect(() => {
31
+ const { current } = scrollRef;
32
+
33
+ if (current) {
34
+ const handleScroll = (event: Event) => {
35
+ const target = event.target as HTMLDivElement;
36
+ const offset = 25;
37
+ const isAtBottom =
38
+ target.scrollTop + target.clientHeight >=
39
+ target.scrollHeight - offset;
40
+
41
+ setIsAtBottom(isAtBottom);
42
+ };
43
+
44
+ current.addEventListener('scroll', handleScroll, {
45
+ passive: true,
46
+ });
47
+
48
+ return () => {
49
+ current.removeEventListener('scroll', handleScroll);
50
+ };
51
+ }
52
+ }, []);
53
+
54
+ useEffect(() => {
55
+ if (visibilityRef.current) {
56
+ let observer = new IntersectionObserver(
57
+ entries => {
58
+ entries.forEach(entry => {
59
+ if (entry.isIntersecting) {
60
+ setIsVisible(true);
61
+ } else {
62
+ setIsVisible(false);
63
+ }
64
+ });
65
+ },
66
+ {
67
+ rootMargin: '0px 0px -150px 0px',
68
+ },
69
+ );
70
+
71
+ observer.observe(visibilityRef.current);
72
+
73
+ return () => {
74
+ observer.disconnect();
75
+ };
76
+ }
77
+ });
78
+
79
+ return {
80
+ messagesRef,
81
+ scrollRef,
82
+ visibilityRef,
83
+ scrollToBottom,
84
+ isAtBottom,
85
+ isVisible,
86
+ };
87
+ };