diff --git a/.changeset/tiny-peas-join.md b/.changeset/tiny-peas-join.md
new file mode 100644
index 00000000..4d3daad4
--- /dev/null
+++ b/.changeset/tiny-peas-join.md
@@ -0,0 +1,17 @@
+---
+"@tailor-platform/app-shell": minor
+---
+
+Add `AttachmentCard` for ERP attachment workflows with drag-and-drop upload, image/file previews, and per-item `Download`/`Delete` actions.
+
+```tsx
+import { AttachmentCard } from "@tailor-platform/app-shell";
+
+;
+```
diff --git a/CLAUDE.md b/CLAUDE.md
index 59e4ec71..c0cb2da5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,6 +27,7 @@ This project has comprehensive documentation organized in the `docs/` directory:
- **[Card](./docs/components/card.md)** - General-purpose container with compound component API
- **[Table](./docs/components/table.md)** - Semantic HTML table sub-components
- **[ActivityCard](./docs/components/activity-card.md)** - Timeline of document activities with avatars and overflow dialog
+- **[AttachmentCard](./docs/components/attachment-card.md)** - Upload, preview, and manage file/image attachments with per-item actions
- **[Dialog](./docs/components/dialog.md)** - Modal dialog with compound component API
- **[Menu](./docs/components/menu.md)** - Dropdown menu with compound component API, checkbox/radio items, groups, and sub-menus
- **[Sheet](./docs/components/sheet.md)** - Slide-in panel with swipe-to-dismiss support
diff --git a/docs/components/attachment-card.md b/docs/components/attachment-card.md
new file mode 100644
index 00000000..3b0954c0
--- /dev/null
+++ b/docs/components/attachment-card.md
@@ -0,0 +1,84 @@
+---
+title: AttachmentCard
+description: Card component for uploading, previewing, and managing attachments
+---
+
+# AttachmentCard
+
+`AttachmentCard` is a reusable file/image attachment surface for ERP detail pages. It provides a header with upload affordance, drag-and-drop upload support, image/file preview tiles, and per-item menu actions for download and delete.
+
+## Import
+
+```tsx
+import { AttachmentCard } from "@tailor-platform/app-shell";
+```
+
+## Basic Usage
+
+```tsx
+import { AttachmentCard, type AttachmentItem } from "@tailor-platform/app-shell";
+
+const items: AttachmentItem[] = [
+ { id: "1", fileName: "shoe-red.png", mimeType: "image/png", previewUrl: "/img/shoe-red.png" },
+ { id: "2", fileName: "Aug-Sep 2025_1234-12.pdf", mimeType: "application/pdf" },
+];
+
+ console.log("upload", files)}
+ onDownload={(item) => console.log("download", item)}
+ onDelete={(item) => console.log("delete", item)}
+/>;
+```
+
+## Props
+
+| Prop | Type | Default | Description |
+| --------------- | --------------------------------------------- | --------------- | ----------------------------------------------------------------- |
+| `title` | `string` | `"Attachments"` | Card heading text |
+| `items` | `AttachmentItem[]` | `[]` | Attachment list rendered as preview tiles |
+| `onUpload` | `(files: File[]) => void` | - | Controlled upload callback for file input + drag/drop |
+| `uploadFile` | `(file: File) => Promise` | - | Optional async upload handler for built-in uploading lifecycle UI |
+| `onUploadError` | `(ctx: { file: File; error: Error }) => void` | - | Called when `uploadFile` fails |
+| `onDelete` | `(item: AttachmentItem) => void` | - | Called when Delete is chosen in a preview menu |
+| `onDownload` | `(item: AttachmentItem) => void` | - | Called when Download is chosen in a preview menu |
+| `uploadLabel` | `string` | `"Upload"` | Upload button text |
+| `accept` | `string` | - | Accepted file types for hidden file input |
+| `disabled` | `boolean` | `false` | Disables upload/drop and hides per-item menu actions |
+| `className` | `string` | - | Additional classes on the root card |
+
+## AttachmentItem
+
+```ts
+interface AttachmentItem {
+ id: string;
+ fileName: string;
+ mimeType: string;
+ previewUrl?: string;
+ status?: "ready" | "uploading";
+}
+```
+
+## Upload Integration Modes
+
+- **Controlled mode (`onUpload`)**: component emits selected files and the parent owns upload + list updates.
+- **Async mode (`uploadFile`)**: component shows temporary uploading tiles with local previews, dark overlay, and spinner while awaiting each upload promise.
+- `onUpload` and `uploadFile` are mutually exclusive integration modes.
+- **Failure behavior**: when `uploadFile` rejects, the component removes the temporary tile, shows a toast, and calls `onUploadError`.
+
+## Behavior
+
+- **Image items** (`mimeType` starts with `image/`) render as 120x120 image thumbnails.
+- **Non-image items** render as 120x120 file tiles with icon and wrapped filename.
+- **Drag and drop** is supported on the entire card container.
+- **Uploading state** renders a dark overlay + centered spinner on the 120x120 tile.
+- **Item actions** are available through the preview menu (`Download`, `Delete`) when not disabled and not uploading.
+
+## Related Components
+
+- [Card](./card.md)
+- [Button](./button.md)
+- [Menu](./menu.md)
diff --git a/examples/app-module/src/custom-module.tsx b/examples/app-module/src/custom-module.tsx
index 20d79f07..00d4afba 100644
--- a/examples/app-module/src/custom-module.tsx
+++ b/examples/app-module/src/custom-module.tsx
@@ -4,6 +4,7 @@ import { ZapIcon } from "./pages/metric-card-demo";
import { actionPanelDemoResource } from "./pages/action-panel-demo";
import { metricCardDemoResource } from "./pages/metric-card-demo";
import { activityCardDemoResource } from "./pages/activity-card-demo";
+import { attachmentCardDemoResource } from "./pages/attachment-card-demo";
import {
purchaseOrderDemoResource,
subPageResource,
@@ -82,6 +83,17 @@ export const customPageModule = defineModule({
View ActivityCard Demo
+
+
+ View AttachmentCard Demo
+
+
{
+ const toast = useToast();
+ const [items, setItems] = useState(initialItems);
+ const [asyncItems, setAsyncItems] = useState(initialItems);
+
+ const nextId = useMemo(() => items.length + 1, [items.length]);
+ const nextAsyncId = useMemo(() => asyncItems.length + 1, [asyncItems.length]);
+
+ return (
+
+
+
+
+ Two integration styles are shown below: controlled `onUpload` and generic async
+ `uploadFile`.
+
+ {
+ const mapped = files.map((file, index) => {
+ const id = `${Date.now()}-${nextId + index}`;
+ const previewUrl = file.type.startsWith("image/")
+ ? URL.createObjectURL(file)
+ : undefined;
+ return {
+ id,
+ fileName: file.name,
+ mimeType: file.type || "application/octet-stream",
+ previewUrl,
+ } satisfies AttachmentItem;
+ });
+ setItems((prev) => [...mapped, ...prev]);
+ }}
+ onDelete={(item) => {
+ setItems((prev) => prev.filter((candidate) => candidate.id !== item.id));
+ }}
+ onDownload={(item) => {
+ toast(`Download clicked for: ${item.fileName}`);
+ }}
+ />
+
+ {
+ await new Promise((resolve) => setTimeout(resolve, 900));
+ if (file.name.toLowerCase().includes("fail")) {
+ throw new Error("Simulated upload failure");
+ }
+
+ const id = `async-${Date.now()}-${nextAsyncId}`;
+ return {
+ id,
+ fileName: file.name,
+ mimeType: file.type || "application/octet-stream",
+ previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
+ status: "ready",
+ } satisfies AttachmentItem;
+ }}
+ onDelete={(item) => {
+ setAsyncItems((prev) => prev.filter((candidate) => candidate.id !== item.id));
+ }}
+ onDownload={(item) => {
+ toast(`Download clicked for: ${item.fileName}`);
+ }}
+ />
+
+
+ );
+};
+
+export const attachmentCardDemoResource = defineResource({
+ path: "attachment-card-demo",
+ meta: { title: "AttachmentCard Demo" },
+ component: AttachmentCardDemoPage,
+});
diff --git a/packages/core/__snapshots__/src__components__attachment-card__AttachmentCard.test.tsx.snap b/packages/core/__snapshots__/src__components__attachment-card__AttachmentCard.test.tsx.snap
new file mode 100644
index 00000000..3f4c4a92
--- /dev/null
+++ b/packages/core/__snapshots__/src__components__attachment-card__AttachmentCard.test.tsx.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`AttachmentCard > snapshots > disabled 1`] = `""`;
+
+exports[`AttachmentCard > snapshots > empty default 1`] = `""`;
+
+exports[`AttachmentCard > snapshots > populated mixed items 1`] = `""`;
diff --git a/packages/core/src/components/attachment-card/AttachmentCard.test.tsx b/packages/core/src/components/attachment-card/AttachmentCard.test.tsx
new file mode 100644
index 00000000..ceefa280
--- /dev/null
+++ b/packages/core/src/components/attachment-card/AttachmentCard.test.tsx
@@ -0,0 +1,299 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { AttachmentCard } from "./AttachmentCard";
+import type { AttachmentItem } from "./types";
+
+const toastError = vi.fn();
+
+vi.mock("@/hooks/use-toast", () => ({
+ useToast: () => ({
+ error: toastError,
+ }),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+const mixedItems: AttachmentItem[] = [
+ {
+ id: "img-1",
+ fileName: "shoe-red.png",
+ mimeType: "image/png",
+ previewUrl: "https://example.com/shoe-red.png",
+ },
+ {
+ id: "file-1",
+ fileName: "Aug-Sep 2025_1234-12.pdf",
+ mimeType: "application/pdf",
+ },
+];
+
+describe("AttachmentCard", () => {
+ describe("snapshots", () => {
+ it("empty default", () => {
+ const { container } = render();
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("populated mixed items", () => {
+ const { container } = render();
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("disabled", () => {
+ const { container } = render();
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+ });
+
+ it("renders title and upload button", () => {
+ render();
+ expect(screen.getByRole("heading", { name: "Product images" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Upload image" })).toBeDefined();
+ });
+
+ it("renders image and file preview branches", () => {
+ render();
+ expect(screen.getByRole("img", { name: "shoe-red.png" })).toBeDefined();
+ expect(screen.getByRole("button", { name: /Aug-Sep 2025_1234-12\.pdf/ })).toBeDefined();
+ expect(screen.getByTestId("attachment-file-icon")).toBeDefined();
+ });
+
+ it("calls onUpload when files are selected through input", () => {
+ const onUpload = vi.fn();
+ render();
+
+ const file = new File(["hello"], "invoice.pdf", { type: "application/pdf" });
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, { target: { files: [file] } });
+
+ expect(onUpload).toHaveBeenCalledTimes(1);
+ expect(onUpload.mock.calls[0]?.[0]).toHaveLength(1);
+ expect(onUpload.mock.calls[0]?.[0][0]?.name).toBe("invoice.pdf");
+ });
+
+ it("calls onUpload when files are dropped on the card", () => {
+ const onUpload = vi.fn();
+ const { container } = render();
+ const cardRoot = container.querySelector('[data-slot="attachment-card"]');
+ expect(cardRoot).toBeTruthy();
+
+ const file = new File(["hello"], "receipt.pdf", { type: "application/pdf" });
+ fireEvent.drop(cardRoot as HTMLElement, {
+ dataTransfer: {
+ files: [file],
+ },
+ });
+
+ expect(onUpload).toHaveBeenCalledTimes(1);
+ expect(onUpload.mock.calls[0]?.[0][0]?.name).toBe("receipt.pdf");
+ });
+
+ it("triggers download and delete actions from menu", async () => {
+ const user = userEvent.setup();
+ const onDownload = vi.fn();
+ const onDelete = vi.fn();
+
+ render();
+
+ const trigger = screen.getByRole("button", {
+ name: /Attachment options for Aug-Sep 2025_1234-12\.pdf/,
+ });
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByText("Download")).toBeDefined();
+ });
+
+ await user.click(screen.getByText("Download"));
+ expect(onDownload).toHaveBeenCalledTimes(1);
+ expect(onDownload).toHaveBeenCalledWith(mixedItems[1]);
+
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(screen.getByText("Delete")).toBeDefined();
+ });
+ await user.click(screen.getByText("Delete"));
+ expect(onDelete).toHaveBeenCalledTimes(1);
+ expect(onDelete).toHaveBeenCalledWith(mixedItems[1]);
+ });
+
+ it("disables upload and hides menu actions when disabled", () => {
+ const onUpload = vi.fn();
+ render();
+
+ const uploadButton = screen.getByRole("button", { name: "Upload" });
+ expect(uploadButton).toHaveProperty("disabled", true);
+
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, {
+ target: {
+ files: [new File(["x"], "blocked.pdf", { type: "application/pdf" })],
+ },
+ });
+ expect(onUpload).not.toHaveBeenCalled();
+
+ const menuTrigger = screen.queryByRole("button", {
+ name: /Attachment options for/i,
+ });
+ expect(menuTrigger).toBeNull();
+ });
+
+ it("renders upload overlay and resolves async uploadFile results", async () => {
+ let resolveUpload: ((value: AttachmentItem) => void) | undefined;
+ const uploadFile = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveUpload = resolve;
+ }),
+ );
+
+ render();
+
+ const pendingFile = new File(["hello"], "pending.pdf", { type: "application/pdf" });
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, { target: { files: [pendingFile] } });
+
+ await waitFor(() => {
+ expect(uploadFile).toHaveBeenCalledTimes(1);
+ expect(screen.getAllByTestId("attachment-upload-overlay")).toHaveLength(1);
+ expect(screen.getAllByTestId("attachment-upload-spinner")).toHaveLength(1);
+ });
+
+ expect(
+ screen.queryByRole("button", { name: /Attachment options for pending\.pdf/ }),
+ ).toBeNull();
+
+ resolveUpload?.({
+ id: "uploaded-1",
+ fileName: "pending.pdf",
+ mimeType: "application/pdf",
+ status: "ready",
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("attachment-upload-overlay")).toBeNull();
+ expect(screen.getByRole("button", { name: /pending\.pdf/ })).toBeDefined();
+ });
+ });
+
+ it("shows toast and removes failed temporary items in async upload mode", async () => {
+ const uploadFile = vi.fn(async () => {
+ throw new Error("Network failed");
+ });
+ const onUploadError = vi.fn();
+
+ render();
+
+ const failedFile = new File(["hello"], "bad-file.pdf", { type: "application/pdf" });
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, { target: { files: [failedFile] } });
+
+ await waitFor(() => {
+ expect(toastError).toHaveBeenCalledWith("Failed to upload bad-file.pdf");
+ expect(onUploadError).toHaveBeenCalledTimes(1);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByRole("button", { name: /bad-file\.pdf/ })).toBeNull();
+ expect(screen.queryByTestId("attachment-upload-overlay")).toBeNull();
+ });
+ });
+
+ it("falls back to image icon tile when image preview fails to load", async () => {
+ render(
+ ,
+ );
+
+ const image = screen.getByRole("img", { name: "IMG_0689_Original.jpg" });
+ fireEvent.error(image);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("attachment-image-fallback-icon")).toBeDefined();
+ expect(screen.getByRole("button", { name: /IMG_0689_Original\.jpg/ })).toBeDefined();
+ });
+ });
+
+ it("removes async-uploaded local item when delete is selected", async () => {
+ const user = userEvent.setup();
+ const onDelete = vi.fn();
+ const uploadFile = vi.fn(async () => ({
+ id: "local-uploaded-1",
+ fileName: "local-delete.pdf",
+ mimeType: "application/pdf",
+ status: "ready" as const,
+ }));
+
+ render();
+
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, {
+ target: { files: [new File(["x"], "local-delete.pdf", { type: "application/pdf" })] },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /local-delete\.pdf/ })).toBeDefined();
+ });
+
+ const trigger = screen.getByRole("button", {
+ name: /Attachment options for local-delete\.pdf/,
+ });
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(screen.getByText("Delete")).toBeDefined();
+ });
+ await user.click(screen.getByText("Delete"));
+
+ await waitFor(() => {
+ expect(screen.queryByRole("button", { name: /local-delete\.pdf/ })).toBeNull();
+ expect(onDelete).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("revokes pending object URLs when unmounted during async upload", async () => {
+ const createObjectUrlSpy = vi
+ .spyOn(URL, "createObjectURL")
+ .mockReturnValue("blob:attachment-card-pending-image");
+ const revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
+
+ const uploadFile = vi.fn(
+ () =>
+ new Promise(() => {
+ // Intentionally unresolved promise to simulate in-flight upload.
+ }),
+ );
+
+ const { unmount } = render();
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+
+ fireEvent.change(input, {
+ target: {
+ files: [new File(["image-bytes"], "pending-image.jpg", { type: "image/jpeg" })],
+ },
+ });
+
+ await waitFor(() => {
+ expect(uploadFile).toHaveBeenCalledTimes(1);
+ });
+
+ unmount();
+
+ expect(revokeObjectUrlSpy).toHaveBeenCalledWith("blob:attachment-card-pending-image");
+
+ createObjectUrlSpy.mockRestore();
+ revokeObjectUrlSpy.mockRestore();
+ });
+});
diff --git a/packages/core/src/components/attachment-card/AttachmentCard.tsx b/packages/core/src/components/attachment-card/AttachmentCard.tsx
new file mode 100644
index 00000000..d7d850fd
--- /dev/null
+++ b/packages/core/src/components/attachment-card/AttachmentCard.tsx
@@ -0,0 +1,380 @@
+import * as React from "react";
+import { Ellipsis, File, Image as ImageIcon, Loader2 } from "lucide-react";
+
+import { useToast } from "@/hooks/use-toast";
+import { cn } from "@/lib/utils";
+
+import { Button } from "../button";
+import { Card } from "../card";
+import { Menu } from "../menu";
+import type { AttachmentCardProps, AttachmentItem } from "./types";
+
+const tileClasses =
+ "astw:relative astw:size-30 astw:shrink-0 astw:overflow-hidden astw:rounded-lg astw:border astw:border-border";
+
+type TemporaryUploadItem = {
+ item: AttachmentItem;
+ file: File;
+ previewUrl?: string;
+};
+
+function isImageItem(item: AttachmentItem): boolean {
+ return item.mimeType.startsWith("image/");
+}
+
+function toFiles(fileList: FileList | null): File[] {
+ return fileList ? Array.from(fileList) : [];
+}
+
+function splitFileName(fileName: string): { baseName: string; extension: string } {
+ const lastDotIndex = fileName.lastIndexOf(".");
+ if (lastDotIndex <= 0 || lastDotIndex === fileName.length - 1) {
+ return { baseName: fileName, extension: "" };
+ }
+
+ return {
+ baseName: fileName.slice(0, lastDotIndex),
+ extension: fileName.slice(lastDotIndex),
+ };
+}
+
+function createTemporaryUploadItem(file: File): TemporaryUploadItem {
+ const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
+ return {
+ file,
+ previewUrl,
+ item: {
+ id: `temp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
+ fileName: file.name,
+ mimeType: file.type || "application/octet-stream",
+ previewUrl,
+ status: "uploading",
+ },
+ };
+}
+
+function mergeAttachmentItems(externalItems: AttachmentItem[], localItems: AttachmentItem[]) {
+ const merged: AttachmentItem[] = [];
+ const seen = new Set();
+
+ for (const item of [...localItems, ...externalItems]) {
+ if (seen.has(item.id)) continue;
+ seen.add(item.id);
+ merged.push(item);
+ }
+
+ return merged;
+}
+
+export function AttachmentCard({
+ title = "Attachments",
+ items = [],
+ onUpload,
+ uploadFile,
+ onUploadError,
+ onDelete,
+ onDownload,
+ uploadLabel = "Upload",
+ accept,
+ disabled = false,
+ className,
+}: AttachmentCardProps) {
+ const inputRef = React.useRef(null);
+ const [isDragOver, setIsDragOver] = React.useState(false);
+ const [localItems, setLocalItems] = React.useState([]);
+ const [failedImagePreviewIds, setFailedImagePreviewIds] = React.useState>(new Set());
+ const dragDepthRef = React.useRef(0);
+ const objectUrlsRef = React.useRef>(new Set());
+ const isMountedRef = React.useRef(true);
+ const toast = useToast();
+
+ React.useEffect(() => {
+ const trackedObjectUrls = objectUrlsRef.current;
+ return () => {
+ isMountedRef.current = false;
+ for (const objectUrl of trackedObjectUrls) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ trackedObjectUrls.clear();
+ };
+ }, []);
+
+ const handleUpload = React.useCallback(
+ async (files: File[]) => {
+ if (disabled || files.length === 0) return;
+ if (!uploadFile) {
+ onUpload?.(files);
+ return;
+ }
+
+ const temporaryItems = files.map(createTemporaryUploadItem);
+ for (const temporaryItem of temporaryItems) {
+ if (temporaryItem.previewUrl) {
+ objectUrlsRef.current.add(temporaryItem.previewUrl);
+ }
+ }
+ setLocalItems((prev) => [...temporaryItems.map((entry) => entry.item), ...prev]);
+
+ await Promise.all(
+ temporaryItems.map(async (entry) => {
+ try {
+ const uploadedItem = await uploadFile(entry.file);
+ if (!isMountedRef.current) return;
+ setLocalItems((prev) =>
+ prev.map((item) =>
+ item.id === entry.item.id
+ ? {
+ ...uploadedItem,
+ status: uploadedItem.status ?? "ready",
+ }
+ : item,
+ ),
+ );
+ } catch (error: unknown) {
+ if (!isMountedRef.current) return;
+ const uploadError =
+ error instanceof Error ? error : new Error("Failed to upload attachment");
+ toast.error(`Failed to upload ${entry.file.name}`);
+ onUploadError?.({ file: entry.file, error: uploadError });
+ setLocalItems((prev) => prev.filter((item) => item.id !== entry.item.id));
+ } finally {
+ if (entry.previewUrl) {
+ URL.revokeObjectURL(entry.previewUrl);
+ objectUrlsRef.current.delete(entry.previewUrl);
+ }
+ }
+ }),
+ );
+ },
+ [disabled, onUpload, onUploadError, toast, uploadFile],
+ );
+
+ const handleInputChange = React.useCallback(
+ (event: React.ChangeEvent) => {
+ void handleUpload(toFiles(event.target.files));
+ event.target.value = "";
+ },
+ [handleUpload],
+ );
+
+ const handleDrop = React.useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ if (disabled) return;
+ dragDepthRef.current = 0;
+ setIsDragOver(false);
+ void handleUpload(toFiles(event.dataTransfer.files));
+ },
+ [disabled, handleUpload],
+ );
+
+ const handleDragEnter = React.useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ if (disabled) return;
+ dragDepthRef.current += 1;
+ setIsDragOver(true);
+ },
+ [disabled],
+ );
+
+ const handleDragLeave = React.useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ if (disabled) return;
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
+ if (dragDepthRef.current === 0) {
+ setIsDragOver(false);
+ }
+ },
+ [disabled],
+ );
+
+ const handleImagePreviewError = React.useCallback((itemId: string) => {
+ setFailedImagePreviewIds((prev) => {
+ const next = new Set(prev);
+ next.add(itemId);
+ return next;
+ });
+ }, []);
+
+ const handleDeleteItem = React.useCallback(
+ (item: AttachmentItem) => {
+ setLocalItems((prev) => prev.filter((candidate) => candidate.id !== item.id));
+ setFailedImagePreviewIds((prev) => {
+ if (!prev.has(item.id)) return prev;
+ const next = new Set(prev);
+ next.delete(item.id);
+ return next;
+ });
+ onDelete?.(item);
+ },
+ [onDelete],
+ );
+
+ const displayItems = React.useMemo(
+ () => mergeAttachmentItems(items, localItems),
+ [items, localItems],
+ );
+ const hasItems = displayItems.length > 0;
+
+ return (
+ event.preventDefault()}
+ onDragEnter={handleDragEnter}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ >
+
+
+
{title}
+
+
+ Drag and drop images/files or
+
+
+
+
+
+
+ {hasItems && (
+
+
+ {displayItems.map((item) => {
+ const { baseName, extension } = splitFileName(item.fileName);
+ const isUploading = item.status === "uploading";
+ const hasFailedImagePreview = failedImagePreviewIds.has(item.id);
+ const shouldShowImagePreview =
+ isImageItem(item) && !!item.previewUrl && !hasFailedImagePreview;
+
+ return (
+
+ {isImageItem(item) ? (
+
+ {shouldShowImagePreview ? (
+

handleImagePreviewError(item.id)}
+ />
+ ) : (
+ <>
+
+
+
+ {baseName}
+
+ {extension ? {extension} : null}
+
+ >
+ )}
+ {isUploading ? (
+
+
+
+ ) : null}
+
+ ) : (
+
+
+
+
+ {baseName}
+
+ {extension ? {extension} : null}
+
+ {isUploading ? (
+
+
+
+ ) : null}
+
+ )}
+ {!disabled && !isUploading && (
+
+
+
+
+
+
+ onDownload?.(item)}>Download
+ handleDeleteItem(item)}>Delete
+
+
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
+export default AttachmentCard;
diff --git a/packages/core/src/components/attachment-card/index.ts b/packages/core/src/components/attachment-card/index.ts
new file mode 100644
index 00000000..c7111067
--- /dev/null
+++ b/packages/core/src/components/attachment-card/index.ts
@@ -0,0 +1,2 @@
+export { AttachmentCard, default } from "./AttachmentCard";
+export type { AttachmentCardProps, AttachmentItem } from "./types";
diff --git a/packages/core/src/components/attachment-card/types.ts b/packages/core/src/components/attachment-card/types.ts
new file mode 100644
index 00000000..3bfd4f51
--- /dev/null
+++ b/packages/core/src/components/attachment-card/types.ts
@@ -0,0 +1,55 @@
+export interface AttachmentItem {
+ /** Unique identifier for the attachment item. */
+ id: string;
+ /** Original filename shown for non-image attachments. */
+ fileName: string;
+ /** MIME type used to switch image/file preview rendering. */
+ mimeType: string;
+ /** Optional preview URL for image attachments. */
+ previewUrl?: string;
+ /** Lifecycle status used for upload rendering. */
+ status?: "ready" | "uploading";
+}
+
+interface AttachmentCardBaseProps {
+ /** Card title text. */
+ title?: string;
+ /** List of attachments to render. */
+ items?: AttachmentItem[];
+ /** Called when delete action is selected for an item. */
+ onDelete?: (item: AttachmentItem) => void;
+ /** Called when download action is selected for an item. */
+ onDownload?: (item: AttachmentItem) => void;
+ /** Upload button label. */
+ uploadLabel?: string;
+ /** Accepted file types passed to the hidden file input. */
+ accept?: string;
+ /** Disable upload and item actions. */
+ disabled?: boolean;
+ /** Additional classes applied on the card root. */
+ className?: string;
+}
+
+type ControlledUploadProps = {
+ /** Called when files are selected or dropped. */
+ onUpload: (files: File[]) => void;
+ uploadFile?: never;
+ onUploadError?: never;
+};
+
+type AsyncUploadProps = {
+ onUpload?: never;
+ /** Optional async upload handler for built-in upload lifecycle UX. */
+ uploadFile: (file: File) => Promise;
+ /** Called when an async upload fails. */
+ onUploadError?: (ctx: { file: File; error: Error }) => void;
+};
+
+type ReadOnlyListProps = {
+ onUpload?: undefined;
+ uploadFile?: undefined;
+ onUploadError?: never;
+};
+
+export type AttachmentCardProps = AttachmentCardBaseProps &
+ (ControlledUploadProps | AsyncUploadProps | ReadOnlyListProps);
diff --git a/packages/core/src/components/content.tsx b/packages/core/src/components/content.tsx
index d0aabd98..511fa769 100644
--- a/packages/core/src/components/content.tsx
+++ b/packages/core/src/components/content.tsx
@@ -1,7 +1,7 @@
import { NavLink, Outlet } from "react-router";
-import { Toaster } from "sonner";
import { useAppShell } from "@/contexts/appshell-context";
import { Button } from "./button";
+import { Toaster } from "./sonner";
import { useT } from "@/i18n-labels";
import { useTitleResolver } from "@/hooks/i18n";
diff --git a/packages/core/src/components/sonner.tsx b/packages/core/src/components/sonner.tsx
index 45992d1e..37319e5d 100644
--- a/packages/core/src/components/sonner.tsx
+++ b/packages/core/src/components/sonner.tsx
@@ -7,6 +7,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
return (