MingruiZhang commited on
Commit
93dd66e
1 Parent(s): f3a9ef2

mutil image selection

Browse files
components/chat-panel.tsx CHANGED
@@ -1,103 +1,103 @@
1
- import * as React from 'react'
2
- import { type UseChatHelpers } from 'ai/react'
3
 
4
- import { shareChat } from '@/app/actions'
5
- import { Button } from '@/components/ui/button'
6
- import { PromptForm } from '@/components/prompt-form'
7
- import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
8
- import { IconRefresh, IconShare, IconStop } from '@/components/ui/icons'
9
- import { ChatShareDialog } from '@/components/chat-share-dialog'
10
 
11
  export interface ChatPanelProps
12
- extends Pick<
13
- UseChatHelpers,
14
- | 'append'
15
- | 'isLoading'
16
- | 'reload'
17
- | 'messages'
18
- | 'stop'
19
- | 'input'
20
- | 'setInput'
21
- > {
22
- id?: string
23
- title?: string
24
  }
25
 
26
  export function ChatPanel({
27
- id,
28
- title,
29
- isLoading,
30
- stop,
31
- append,
32
- reload,
33
- input,
34
- setInput,
35
- messages
36
  }: ChatPanelProps) {
37
- const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
38
 
39
- return (
40
- <div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% animate-in duration-300 ease-in-out dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
41
- <ButtonScrollToBottom />
42
- <div className="mx-auto sm:max-w-2xl sm:px-4">
43
- <div className="flex items-center justify-center h-12">
44
- {isLoading ? (
45
- <Button
46
- variant="outline"
47
- onClick={() => stop()}
48
- className="bg-background"
49
- >
50
- <IconStop className="mr-2" />
51
- Stop generating
52
- </Button>
53
- ) : (
54
- messages?.length >= 2 && (
55
- <div className="flex space-x-2">
56
- <Button variant="outline" onClick={() => reload()}>
57
- <IconRefresh className="mr-2" />
58
- Regenerate response
59
- </Button>
60
- {id && title ? (
61
- <>
62
- <Button
63
- variant="outline"
64
- onClick={() => setShareDialogOpen(true)}
65
- >
66
- <IconShare className="mr-2" />
67
- Share
68
- </Button>
69
- <ChatShareDialog
70
- open={shareDialogOpen}
71
- onOpenChange={setShareDialogOpen}
72
- onCopy={() => setShareDialogOpen(false)}
73
- shareChat={shareChat}
74
- chat={{
75
- id,
76
- title,
77
- messages
78
- }}
79
- />
80
- </>
81
- ) : null}
82
- </div>
83
- )
84
- )}
85
- </div>
86
- <div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4">
87
- <PromptForm
88
- onSubmit={async value => {
89
- await append({
90
- id,
91
- content: value,
92
- role: 'user'
93
- })
94
- }}
95
- input={input}
96
- setInput={setInput}
97
- isLoading={isLoading}
98
- />
99
- </div>
100
- </div>
101
- </div>
102
- )
103
  }
 
1
+ import * as React from 'react';
2
+ import { type UseChatHelpers } from 'ai/react';
3
 
4
+ import { shareChat } from '@/app/actions';
5
+ import { Button } from '@/components/ui/button';
6
+ import { PromptForm } from '@/components/prompt-form';
7
+ import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom';
8
+ import { IconRefresh, IconShare, IconStop } from '@/components/ui/icons';
9
+ import { ChatShareDialog } from '@/components/chat-share-dialog';
10
 
11
  export interface ChatPanelProps
12
+ extends Pick<
13
+ UseChatHelpers,
14
+ | 'append'
15
+ | 'isLoading'
16
+ | 'reload'
17
+ | 'messages'
18
+ | 'stop'
19
+ | 'input'
20
+ | 'setInput'
21
+ > {
22
+ id?: string;
23
+ title?: string;
24
  }
25
 
26
  export function ChatPanel({
27
+ id,
28
+ title,
29
+ isLoading,
30
+ stop,
31
+ append,
32
+ reload,
33
+ input,
34
+ setInput,
35
+ messages,
36
  }: ChatPanelProps) {
37
+ const [shareDialogOpen, setShareDialogOpen] = React.useState(false);
38
 
39
+ return (
40
+ <div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% animate-in duration-300 ease-in-out dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
41
+ <ButtonScrollToBottom />
42
+ <div className="mx-auto sm:max-w-3xl sm:px-4">
43
+ <div className="flex items-center justify-center h-12">
44
+ {isLoading ? (
45
+ <Button
46
+ variant="outline"
47
+ onClick={() => stop()}
48
+ className="bg-background"
49
+ >
50
+ <IconStop className="mr-2" />
51
+ Stop generating
52
+ </Button>
53
+ ) : (
54
+ messages?.length >= 2 && (
55
+ <div className="flex space-x-2">
56
+ <Button variant="outline" onClick={() => reload()}>
57
+ <IconRefresh className="mr-2" />
58
+ Regenerate response
59
+ </Button>
60
+ {id && title ? (
61
+ <>
62
+ <Button
63
+ variant="outline"
64
+ onClick={() => setShareDialogOpen(true)}
65
+ >
66
+ <IconShare className="mr-2" />
67
+ Share
68
+ </Button>
69
+ <ChatShareDialog
70
+ open={shareDialogOpen}
71
+ onOpenChange={setShareDialogOpen}
72
+ onCopy={() => setShareDialogOpen(false)}
73
+ shareChat={shareChat}
74
+ chat={{
75
+ id,
76
+ title,
77
+ messages,
78
+ }}
79
+ />
80
+ </>
81
+ ) : null}
82
+ </div>
83
+ )
84
+ )}
85
+ </div>
86
+ <div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4">
87
+ <PromptForm
88
+ onSubmit={async value => {
89
+ await append({
90
+ id,
91
+ content: value,
92
+ role: 'user',
93
+ });
94
+ }}
95
+ input={input}
96
+ setInput={setInput}
97
+ isLoading={isLoading}
98
+ />
99
+ </div>
100
+ </div>
101
+ </div>
102
+ );
103
  }
components/chat/ChatList.tsx CHANGED
@@ -57,7 +57,7 @@ export function ChatList({ messages, isLoading }: ChatList) {
57
  : messages;
58
 
59
  return (
60
- <div className="relative mx-auto max-w-2xl px-8 pr-12">
61
  {messageWithLoading.map((message, index) => (
62
  <div key={index}>
63
  <ChatMessage message={message} />
 
57
  : messages;
58
 
59
  return (
60
+ <div className="relative mx-auto max-w-3xl px-8 pr-12">
61
  {messageWithLoading.map((message, index) => (
62
  <div key={index}>
63
  <ChatMessage message={message} />
components/chat/ImageList.tsx CHANGED
@@ -1,19 +1,23 @@
1
- import React from 'react';
2
  import useImageUpload from '../../lib/hooks/useImageUpload';
3
- import { useAtomValue } from 'jotai';
4
  import { datasetAtom } from '../../state';
5
  import Image from 'next/image';
 
6
 
7
  export interface ImageListProps {}
8
 
9
  const ImageList: React.FC<ImageListProps> = () => {
10
- const { getRootProps, getInputProps, isDragActive } = useImageUpload();
11
- const dataset = useAtomValue(datasetAtom);
 
 
 
12
  return (
13
- <div className="relative aspect-[1/1] size-full px-12">
14
  {dataset.length < 10 ? (
15
  <div className="col-span-full px-8 py-4 rounded-xl bg-blue-100 text-blue-400 mb-8">
16
- You can upload up to 10 images max.
17
  </div>
18
  ) : (
19
  <div className="col-span-full px-8 py-4 rounded-xl bg-red-100 text-red-400 mb-8">
@@ -24,17 +28,34 @@ const ImageList: React.FC<ImageListProps> = () => {
24
  {...getRootProps()}
25
  className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-4"
26
  >
27
- {dataset.map((imageSrc, index) => {
 
28
  return (
29
- <Image
30
- src={imageSrc}
31
- key={index}
32
- alt="dataset images"
33
- width={500}
34
- height={500}
35
- objectFit="cover"
36
- className="relative rounded-xl overflow-hidden shadow-md cursor-pointer transition-transform hover:scale-105"
37
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  );
39
  })}
40
  </div>
 
1
+ import React, { useCallback } from 'react';
2
  import useImageUpload from '../../lib/hooks/useImageUpload';
3
+ import { useAtom, useAtomValue } from 'jotai';
4
  import { datasetAtom } from '../../state';
5
  import Image from 'next/image';
6
+ import { produce } from 'immer';
7
 
8
  export interface ImageListProps {}
9
 
10
  const ImageList: React.FC<ImageListProps> = () => {
11
+ const { getRootProps, getInputProps, isDragActive } = useImageUpload({
12
+ noClick: true,
13
+ });
14
+
15
+ const [dataset, setDataset] = useAtom(datasetAtom);
16
  return (
17
+ <div className="relative size-full px-12 max-w-3xl mx-auto">
18
  {dataset.length < 10 ? (
19
  <div className="col-span-full px-8 py-4 rounded-xl bg-blue-100 text-blue-400 mb-8">
20
+ You can upload up to 10 images max by dragging image.
21
  </div>
22
  ) : (
23
  <div className="col-span-full px-8 py-4 rounded-xl bg-red-100 text-red-400 mb-8">
 
28
  {...getRootProps()}
29
  className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-4"
30
  >
31
+ {dataset.map(entity => {
32
+ const { url: imageSrc, name, selected } = entity;
33
  return (
34
+ <div
35
+ key={name}
36
+ onClick={() =>
37
+ setDataset(prev =>
38
+ produce(prev, draft => {
39
+ const index = draft.findIndex(d => d.name === name);
40
+ draft[index].selected = !selected;
41
+ }),
42
+ )
43
+ }
44
+ className={`relative rounded-xl overflow-hidden shadow-md cursor-pointer transition-transform hover:scale-105 box-content ${selected ? 'border-4 border-blue-500' : ''}`}
45
+ >
46
+ <Image
47
+ src={imageSrc}
48
+ draggable={false}
49
+ alt="dataset images"
50
+ width={500}
51
+ height={500}
52
+ objectFit="cover"
53
+ className="rounded-xl"
54
+ />
55
+ <div className="absolute bottom-0 left-0 bg-gray-800/50 text-white px-3 py-1 rounded-tr-lg">
56
+ <p className="text-xs font-bold">{name}</p>
57
+ </div>
58
+ </div>
59
  );
60
  })}
61
  </div>
components/chat/index.tsx CHANGED
@@ -24,7 +24,7 @@ export function Chat({ id, initialMessages, className }: ChatProps) {
24
  id,
25
  body: {
26
  id,
27
- dataset: dataset,
28
  },
29
  onResponse(response) {
30
  if (response.status === 401) {
 
24
  id,
25
  body: {
26
  id,
27
+ dataset: dataset.filter(entity => entity.selected),
28
  },
29
  onResponse(response) {
30
  if (response.status === 401) {
components/empty-screen.tsx CHANGED
@@ -40,7 +40,9 @@ export function EmptyScreen() {
40
  height={120}
41
  alt="example images"
42
  className="object-cover rounded mr-3 shadow-md hover:scale-105 cursor-pointer transition-transform"
43
- onClick={() => setTarget([example])}
 
 
44
  />
45
  ))}
46
  </div>
 
40
  height={120}
41
  alt="example images"
42
  className="object-cover rounded mr-3 shadow-md hover:scale-105 cursor-pointer transition-transform"
43
+ onClick={() =>
44
+ setTarget([{ url: example, name: 'i-1', selected: false }])
45
+ }
46
  />
47
  ))}
48
  </div>
lib/hooks/useImageUpload.ts CHANGED
@@ -1,29 +1,44 @@
1
  import { useAtom } from 'jotai';
2
- import { useDropzone } from 'react-dropzone';
3
- import { datasetAtom } from '../../state';
 
4
 
5
- const useImageUpload = () => {
6
  const [, setTarget] = useAtom(datasetAtom);
7
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
8
  accept: {
9
  'image/*': ['.jpeg', '.png'],
10
  },
11
- maxFiles: 10,
12
  multiple: true,
13
  onDrop: acceptedFiles => {
14
- acceptedFiles.forEach(file => {
 
 
 
 
 
15
  try {
16
  const reader = new FileReader();
17
  reader.onloadend = () => {
18
  const newImage = reader.result as string;
19
  setTarget(prev => {
20
  // Check if the image already exists in the state
21
- if (prev.length >= 10 || prev.includes(newImage)) {
 
 
 
22
  // If it does, return the state unchanged
23
  return prev;
24
  } else {
25
  // If it doesn't, add the new image to the state
26
- return [...prev, newImage];
 
 
 
 
 
 
 
27
  }
28
  });
29
  };
@@ -33,6 +48,7 @@ const useImageUpload = () => {
33
  }
34
  });
35
  },
 
36
  });
37
 
38
  return { getRootProps, getInputProps, isDragActive };
 
1
  import { useAtom } from 'jotai';
2
+ import { DropzoneOptions, useDropzone } from 'react-dropzone';
3
+ import { DatasetImageEntity, datasetAtom } from '../../state';
4
+ import { toast } from 'react-hot-toast';
5
 
6
+ const useImageUpload = (options?: Partial<DropzoneOptions>) => {
7
  const [, setTarget] = useAtom(datasetAtom);
8
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
9
  accept: {
10
  'image/*': ['.jpeg', '.png'],
11
  },
 
12
  multiple: true,
13
  onDrop: acceptedFiles => {
14
+ if (acceptedFiles.length > 10) {
15
+ toast('You can only upload 10 images max.', {
16
+ icon: '⚠️',
17
+ });
18
+ }
19
+ acceptedFiles.slice(0, 10).forEach(file => {
20
  try {
21
  const reader = new FileReader();
22
  reader.onloadend = () => {
23
  const newImage = reader.result as string;
24
  setTarget(prev => {
25
  // Check if the image already exists in the state
26
+ if (
27
+ prev.length >= 10 ||
28
+ prev.find(entity => entity.url === newImage)
29
+ ) {
30
  // If it does, return the state unchanged
31
  return prev;
32
  } else {
33
  // If it doesn't, add the new image to the state
34
+ return [
35
+ ...prev,
36
+ {
37
+ url: newImage,
38
+ selected: false,
39
+ name: `i-${prev.length + 1}`,
40
+ } satisfies DatasetImageEntity,
41
+ ];
42
  }
43
  });
44
  };
 
48
  }
49
  });
50
  },
51
+ ...options,
52
  });
53
 
54
  return { getRootProps, getInputProps, isDragActive };
package.json CHANGED
@@ -30,6 +30,7 @@
30
  "focus-trap-react": "^10.2.3",
31
  "framer-motion": "^10.18.0",
32
  "geist": "^1.2.1",
 
33
  "jotai": "^2.7.0",
34
  "nanoid": "^5.0.4",
35
  "next": "14.1.0",
 
30
  "focus-trap-react": "^10.2.3",
31
  "framer-motion": "^10.18.0",
32
  "geist": "^1.2.1",
33
+ "immer": "^10.0.3",
34
  "jotai": "^2.7.0",
35
  "nanoid": "^5.0.4",
36
  "next": "14.1.0",
state/index.ts CHANGED
@@ -1,4 +1,10 @@
1
  import { atom } from 'jotai';
2
 
 
 
 
 
 
3
  // list of image urls or base64 strings
4
- export const datasetAtom = atom<string[]>([]);
 
 
1
  import { atom } from 'jotai';
2
 
3
+ export type DatasetImageEntity = {
4
+ url: string;
5
+ selected: boolean;
6
+ name: string;
7
+ };
8
  // list of image urls or base64 strings
9
+ export const datasetAtom = atom<DatasetImageEntity[]>([]);
10
+ // export const selectedImagesAtom = atom<number[]>([]);
yarn.lock CHANGED
@@ -2262,6 +2262,11 @@ ignore@^5.2.0:
2262
  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
2263
  integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
2264
 
 
 
 
 
 
2265
  import-fresh@^3.2.1:
2266
  version "3.3.0"
2267
  resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
 
2262
  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
2263
  integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
2264
 
2265
+ immer@^10.0.3:
2266
+ version "10.0.3"
2267
+ resolved "https://registry.yarnpkg.com/immer/-/immer-10.0.3.tgz#a8de42065e964aa3edf6afc282dfc7f7f34ae3c9"
2268
+ integrity sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==
2269
+
2270
  import-fresh@^3.2.1:
2271
  version "3.3.0"
2272
  resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"