Skip to content

Commit

Permalink
feat: Attachment image thumbnails and previews (#981)
Browse files Browse the repository at this point in the history
  • Loading branch information
Yonom authored Oct 12, 2024
1 parent 4842523 commit 0a3bd06
Show file tree
Hide file tree
Showing 12 changed files with 363 additions and 158 deletions.
5 changes: 5 additions & 0 deletions .changeset/beige-islands-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@assistant-ui/react": patch
---

feat: Attachment image thumbnail and previews
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 14 additions & 2 deletions packages/react/src/styles/tailwindcss/base-components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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; */
}
12 changes: 8 additions & 4 deletions packages/react/src/styles/tailwindcss/thread.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand All @@ -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 */
Expand Down
201 changes: 201 additions & 0 deletions packages/react/src/ui/attachment.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(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<AttachmentPreviewProps> = ({ src }) => {
const [isLoaded, setIsLoaded] = useState(false);

return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
style={{
width: "auto",
height: "auto",
maxWidth: "75dvh",
maxHeight: "75dvh",
display: isLoaded ? "block" : "none",
overflow: "clip",
}}
onLoad={() => setIsLoaded(true)}
alt="Image Preview"
/>
);
};

const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {
const src = useAttachmentSrc();

if (!src) return children;

return (
<Dialog>
<DialogTrigger className="aui-attachment-preview-trigger" asChild>
{children}
</DialogTrigger>
<DialogContent>
<AttachmentPreview src={src} />
</DialogContent>
</Dialog>
);
};

const AttachmentThumb: FC = () => {
const isImage = useAttachment((a) => a.type === "image");
const src = useAttachmentSrc();
return (
<AvatarRoot className="aui-attachment-thumb">
<AvatarFallback delayMs={isImage ? 200 : 0}>
<FileIcon />
</AvatarFallback>
<AvatarImage src={src}></AvatarImage>
</AvatarRoot>
);
};

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 (
<Tooltip>
<AttachmentPreviewDialog>
<TooltipTrigger asChild>
<AttachmentRoot>
<AttachmentThumb />
<div className="aui-attachment-text">
<p className="aui-attachment-name">
<AttachmentPrimitive.Name />
</p>
<p className="aui-attachment-type">{typeLabel}</p>
</div>
{canRemove && <AttachmentRemove />}
</AttachmentRoot>
</TooltipTrigger>
</AttachmentPreviewDialog>
<TooltipContent side="top">
<AttachmentPrimitive.Name />
</TooltipContent>
</Tooltip>
);
};

Attachment.displayName = "Attachment";

namespace AttachmentRemove {
export type Element = HTMLButtonElement;
export type Props = Partial<TooltipIconButtonProps>;
}

const AttachmentRemove = forwardRef<
AttachmentRemove.Element,
AttachmentRemove.Props
>((props, ref) => {
const {
strings: {
composer: { removeAttachment: { tooltip = "Remove file" } = {} } = {},
} = {},
} = useThreadConfig();

return (
<AttachmentPrimitive.Remove asChild>
<TooltipIconButton
tooltip={tooltip}
className="aui-attachment-remove"
side="top"
{...props}
ref={ref}
>
{props.children ?? <CircleXIcon />}
</TooltipIconButton>
</AttachmentPrimitive.Remove>
);
});

AttachmentRemove.displayName = "AttachmentRemove";

const exports = {
Root: AttachmentRoot,
Remove: AttachmentRemove,
};

export default Object.assign(Attachment, exports) as typeof Attachment &
typeof exports;
Loading

0 comments on commit 0a3bd06

Please sign in to comment.