diff --git a/.changeset/beige-islands-breathe.md b/.changeset/beige-islands-breathe.md new file mode 100644 index 000000000..0c386a17e --- /dev/null +++ b/.changeset/beige-islands-breathe.md @@ -0,0 +1,5 @@ +--- +"@assistant-ui/react": patch +--- + +feat: Attachment image thumbnail and previews diff --git a/packages/react/package.json b/packages/react/package.json index 809f1123d..e3b2932d0 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -86,6 +86,7 @@ "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-compose-refs": "^1.1.0", "@radix-ui/react-context": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-primitive": "^2.0.0", "@radix-ui/react-slot": "^1.1.0", diff --git a/packages/react/src/styles/tailwindcss/base-components.css b/packages/react/src/styles/tailwindcss/base-components.css index 5a7413957..f58d8534d 100644 --- a/packages/react/src/styles/tailwindcss/base-components.css +++ b/packages/react/src/styles/tailwindcss/base-components.css @@ -2,7 +2,7 @@ @apply text-aui-foreground border-aui-border; } -/* button */ +/* shadcn-ui/button */ .aui-button { @apply focus-visible:ring-aui-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50; } @@ -39,7 +39,7 @@ } .aui-avatar-image { - @apply aspect-square h-full w-full; + @apply aspect-square h-full w-full object-cover; } .aui-avatar-fallback { @@ -51,3 +51,15 @@ .aui-tooltip-content { @apply bg-aui-popover text-aui-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md; } + +/* shadcn-ui/dialog */ + +.aui-dialog-overlay { + @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80; +} + +.aui-dialog-content { + @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50; + @apply grid translate-x-[-50%] translate-y-[-50%] shadow-lg duration-200; + /* @apply w-full bg-background max-w-lg gap-4 border p-6 sm:rounded-lg; */ +} diff --git a/packages/react/src/styles/tailwindcss/thread.css b/packages/react/src/styles/tailwindcss/thread.css index 2df008692..2476503bd 100644 --- a/packages/react/src/styles/tailwindcss/thread.css +++ b/packages/react/src/styles/tailwindcss/thread.css @@ -50,8 +50,6 @@ @apply line-clamp-2 text-ellipsis text-sm font-semibold; } -/* TODO rename classes to .aui-thread-composer-root ? */ -/* rename composer to thread composer everywhere */ /* thread composer */ .aui-composer-root { @@ -72,10 +70,16 @@ @apply flex w-full flex-row gap-3 px-10; } +/* attachment */ + .aui-attachment-root { @apply relative mt-3 flex h-12 w-40 items-center justify-center gap-2 rounded-lg border p-1; } +.aui-attachment-preview-trigger { + @apply hover:bg-aui-accent/50 cursor-pointer transition-colors; +} + .aui-attachment-thumb { @apply bg-aui-muted flex size-10 items-center justify-center rounded border text-sm; } @@ -92,8 +96,8 @@ @apply text-aui-muted-foreground text-xs; } -.aui-composer-attachment-remove { - @apply text-aui-muted-foreground [&>svg]:bg-aui-background absolute -right-3 -top-3 size-6 [&>svg]:rounded-full [&>svg]:size-4; +.aui-attachment-remove { + @apply text-aui-muted-foreground [&>svg]:bg-aui-background absolute -right-3 -top-3 size-6 [&>svg]:size-4 [&>svg]:rounded-full; } /* user message */ diff --git a/packages/react/src/ui/attachment.tsx b/packages/react/src/ui/attachment.tsx new file mode 100644 index 000000000..3051ca9aa --- /dev/null +++ b/packages/react/src/ui/attachment.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { + forwardRef, + PropsWithChildren, + useEffect, + useState, + type FC, +} from "react"; +import { CircleXIcon, FileIcon } from "lucide-react"; +import { withDefaults } from "./utils/withDefaults"; +import { useThreadConfig } from "./thread-config"; +import { + TooltipIconButton, + TooltipIconButtonProps, +} from "./base/tooltip-icon-button"; +import { AttachmentPrimitive } from "../primitives"; +import { useAttachment } from "../context/react/AttachmentContext"; +import { + AvatarImage, + AvatarRoot, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "./base"; +import { Dialog, DialogTrigger, DialogContent } from "./base/dialog"; +import { AvatarFallback } from "@radix-ui/react-avatar"; + +const AttachmentRoot = withDefaults(AttachmentPrimitive.Root, { + className: "aui-attachment-root", +}); + +AttachmentRoot.displayName = "AttachmentRoot"; + +const useFileSrc = (file: File | undefined) => { + const [src, setSrc] = useState(undefined); + + useEffect(() => { + if (!file) { + setSrc(undefined); + return; + } + + const objectUrl = URL.createObjectURL(file); + setSrc(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [file]); + + return src; +}; + +const useAttachmentSrc = () => { + const { file, src } = useAttachment((a): { file?: File; src?: string } => { + if (a.type !== "image") return {}; + if (a.file) return { file: a.file }; + const src = a.content?.filter((c) => c.type === "image")[0]?.image; + if (!src) return {}; + return { src }; + }); + + return useFileSrc(file) ?? src; +}; + +type AttachmentPreviewProps = { + src: string; +}; + +const AttachmentPreview: FC = ({ src }) => { + const [isLoaded, setIsLoaded] = useState(false); + + return ( + // eslint-disable-next-line @next/next/no-img-element + setIsLoaded(true)} + alt="Image Preview" + /> + ); +}; + +const AttachmentPreviewDialog: FC = ({ children }) => { + const src = useAttachmentSrc(); + + if (!src) return children; + + return ( + + + {children} + + + + + + ); +}; + +const AttachmentThumb: FC = () => { + const isImage = useAttachment((a) => a.type === "image"); + const src = useAttachmentSrc(); + return ( + + + + + + + ); +}; + +const Attachment: FC = () => { + const canRemove = useAttachment((a) => a.source !== "message"); + const typeLabel = useAttachment((a) => { + const type = a.type; + switch (type) { + case "image": + return "Image"; + case "document": + return "Document"; + case "file": + return "File"; + default: + const _exhaustiveCheck: never = type; + throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`); + } + }); + return ( + + + + + +
+

+ +

+

{typeLabel}

+
+ {canRemove && } +
+
+
+ + + +
+ ); +}; + +Attachment.displayName = "Attachment"; + +namespace AttachmentRemove { + export type Element = HTMLButtonElement; + export type Props = Partial; +} + +const AttachmentRemove = forwardRef< + AttachmentRemove.Element, + AttachmentRemove.Props +>((props, ref) => { + const { + strings: { + composer: { removeAttachment: { tooltip = "Remove file" } = {} } = {}, + } = {}, + } = useThreadConfig(); + + return ( + + + {props.children ?? } + + + ); +}); + +AttachmentRemove.displayName = "AttachmentRemove"; + +const exports = { + Root: AttachmentRoot, + Remove: AttachmentRemove, +}; + +export default Object.assign(Attachment, exports) as typeof Attachment & + typeof exports; diff --git a/packages/react/src/ui/base/dialog.tsx b/packages/react/src/ui/base/dialog.tsx new file mode 100644 index 000000000..fbaeebf34 --- /dev/null +++ b/packages/react/src/ui/base/dialog.tsx @@ -0,0 +1,115 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; + +import classNames from "classnames"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + {/* + + Close + */} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +// const DialogHeader = ({ +// className, +// ...props +// }: React.HTMLAttributes) => ( +//
+// ); +// DialogHeader.displayName = "DialogHeader"; + +// const DialogFooter = ({ +// className, +// ...props +// }: React.HTMLAttributes) => ( +//
+// ); +// DialogFooter.displayName = "DialogFooter"; + +// const DialogTitle = React.forwardRef< +// React.ElementRef, +// React.ComponentPropsWithoutRef +// >(({ className, ...props }, ref) => ( +// +// )); +// DialogTitle.displayName = DialogPrimitive.Title.displayName; + +// const DialogDescription = React.forwardRef< +// React.ElementRef, +// React.ComponentPropsWithoutRef +// >(({ className, ...props }, ref) => ( +// +// )); +// DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + // DialogHeader, + // DialogFooter, + // DialogTitle, + // DialogDescription, +}; diff --git a/packages/react/src/ui/composer-attachment.tsx b/packages/react/src/ui/composer-attachment.tsx deleted file mode 100644 index 3b8152c9e..000000000 --- a/packages/react/src/ui/composer-attachment.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { forwardRef, type FC } from "react"; -import { CircleXIcon } from "lucide-react"; -import { withDefaults } from "./utils/withDefaults"; -import { useThreadConfig } from "./thread-config"; -import { - TooltipIconButton, - TooltipIconButtonProps, -} from "./base/tooltip-icon-button"; -import { AttachmentPrimitive } from "../primitives"; -import { useAttachment } from "../context/react/AttachmentContext"; - -const ComposerAttachmentRoot = withDefaults(AttachmentPrimitive.Root, { - className: "aui-attachment-root", -}); - -ComposerAttachmentRoot.displayName = "ComposerAttachmentRoot"; - -const ComposerAttachment: FC = () => { - const typeLabel = useAttachment((a) => { - const type = a.type; - switch (type) { - case "image": - return "Image"; - case "document": - return "Document"; - case "file": - return "File"; - default: - const _exhaustiveCheck: never = type; - throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`); - } - }); - return ( - - -
-

- -

-

{typeLabel}

-
- -
- ); -}; - -ComposerAttachment.displayName = "ComposerAttachment"; - -namespace ComposerAttachmentRemove { - export type Element = HTMLButtonElement; - export type Props = Partial; -} - -const ComposerAttachmentRemove = forwardRef< - ComposerAttachmentRemove.Element, - ComposerAttachmentRemove.Props ->((props, ref) => { - const { - strings: { - composer: { removeAttachment: { tooltip = "Remove file" } = {} } = {}, - } = {}, - } = useThreadConfig(); - - return ( - - - {props.children ?? } - - - ); -}); - -ComposerAttachmentRemove.displayName = "ComposerAttachmentRemove"; - -const exports = { - Root: ComposerAttachmentRoot, - Remove: ComposerAttachmentRemove, -}; - -export default Object.assign( - ComposerAttachment, - exports, -) as typeof ComposerAttachment & typeof exports; diff --git a/packages/react/src/ui/composer.tsx b/packages/react/src/ui/composer.tsx index 40e48abaf..d53db4efc 100644 --- a/packages/react/src/ui/composer.tsx +++ b/packages/react/src/ui/composer.tsx @@ -12,7 +12,7 @@ import { import { CircleStopIcon } from "./base/CircleStopIcon"; import { ComposerPrimitive, ThreadPrimitive } from "../primitives"; import { useThread } from "../context/react/ThreadContext"; -import ComposerAttachment from "./composer-attachment"; +import Attachment from "./attachment"; const useAllowAttachments = (ensureCapability = false) => { const { composer: { allowAttachments = true } = {} } = useThreadConfig(); @@ -85,7 +85,7 @@ const ComposerAttachments: FC = ({ components }) => { diff --git a/packages/react/src/ui/index.ts b/packages/react/src/ui/index.ts index 94acc8384..b1913e69b 100644 --- a/packages/react/src/ui/index.ts +++ b/packages/react/src/ui/index.ts @@ -23,7 +23,17 @@ export { default as BranchPicker } from "./branch-picker"; export { default as Composer, type ComposerInputProps } from "./composer"; -export { default as ComposerAttachment } from "./composer-attachment"; +export { + default as AttachmentUI, // TODO name collision with Attachment + /** + * @deprecated Use `AttachmentUI` instead. This will be removed in 0.6.0. + */ + default as UserMessageAttachment, + /** + * @deprecated Use `AttachmentUI` instead. This will be removed in 0.6.0. + */ + default as ComposerAttachment, +} from "./attachment"; export { default as EditComposer } from "./edit-composer"; @@ -36,8 +46,6 @@ export { export { default as UserActionBar } from "./user-action-bar"; -export { default as UserMessageAttachment } from "./user-message-attachment"; - export { default as ThreadWelcome, type ThreadWelcomeMessageProps, diff --git a/packages/react/src/ui/user-message-attachment.tsx b/packages/react/src/ui/user-message-attachment.tsx deleted file mode 100644 index 1d1c876f7..000000000 --- a/packages/react/src/ui/user-message-attachment.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { type FC } from "react"; - -import { withDefaults } from "./utils/withDefaults"; -import { AttachmentPrimitive } from "../primitives"; -import { useAttachment } from "../context/react/AttachmentContext"; - -const UserMessageAttachmentRoot = withDefaults(AttachmentPrimitive.Root, { - className: "aui-attachment-root", -}); - -UserMessageAttachmentRoot.displayName = "UserMessageAttachmentRoot"; - -const UserMessageAttachment: FC = () => { - const typeLabel = useAttachment((a) => { - const type = a.type; - switch (type) { - case "image": - return "Image"; - case "document": - return "Document"; - case "file": - return "File"; - default: - const _exhaustiveCheck: never = type; - throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`); - } - }); - - return ( - - -
-

- -

-

{typeLabel}

-
-
- ); -}; - -UserMessageAttachment.displayName = "UserMessageAttachment"; - -const exports = { - Root: UserMessageAttachmentRoot, -}; - -export default Object.assign( - UserMessageAttachment, - exports, -) as typeof UserMessageAttachment & typeof exports; diff --git a/packages/react/src/ui/user-message.tsx b/packages/react/src/ui/user-message.tsx index 2207b20ca..ff658c80a 100644 --- a/packages/react/src/ui/user-message.tsx +++ b/packages/react/src/ui/user-message.tsx @@ -7,7 +7,7 @@ import { withDefaults } from "./utils/withDefaults"; import UserActionBar from "./user-action-bar"; import ContentPart from "./content-part"; import { MessagePrimitive } from "../primitives"; -import UserMessageAttachment from "./user-message-attachment"; +import Attachment from "./attachment"; const UserMessage: FC = () => { return ( @@ -83,10 +83,10 @@ const UserMessageAttachments: FC = ({ return ( - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ae5d7294..63610be9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1078,6 +1078,9 @@ importers: '@radix-ui/react-context': specifier: ^1.1.1 version: 1.1.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)