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/docs/components/attachment-card.md b/docs/components/attachment-card.md
new file mode 100644
index 00000000..318a9334
--- /dev/null
+++ b/docs/components/attachment-card.md
@@ -0,0 +1,94 @@
+---
+title: AttachmentCard
+description: Card for uploading, previewing, and managing attachments, with optional title and helper text
+---
+
+# AttachmentCard
+
+`AttachmentCard` is a reusable file/image attachment surface for ERP detail pages. It provides a header with an optional title, optional helper text under the title, 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)}
+/>;
+```
+
+## Title and description
+
+- **`title`** sets the main heading in the card header (default `"Attachments"`).
+- **`description`** is optional. When set, it renders as secondary helper text directly under the title—use it to explain accepted formats, maximum file size, or other constraints. Omit it when no extra guidance is needed.
+- The description is informational only: it does not enforce limits. Pair it with your own validation and with the `accept` attribute (and/or logic inside `onUpload` / `uploadFile`) for real restrictions.
+- You can pass a string or any `React.ReactNode` (for example, inline emphasis or a short link). The rendered wrapper uses `data-slot="attachment-card-description"` for styling or tests.
+
+## Props
+
+| Prop | Type | Default | Description |
+| --------------- | --------------------------------------------- | --------------- | ----------------------------------------------------------------- |
+| `title` | `string` | `"Attachments"` | Card heading text |
+| `description` | `React.ReactNode` | - | Optional helper under the title (formats, size limits, etc.) |
+| `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
+
+- **Header layout**: the title and optional description sit in the left column; the “Drag and drop…” hint (on wide viewports), upload button, and hidden file input sit on the right, top-aligned with the title block.
+- **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 698e1dba..a53fd965 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,
@@ -83,6 +84,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..88c44650
--- /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..ee5b417c
--- /dev/null
+++ b/packages/core/src/components/attachment-card/AttachmentCard.test.tsx
@@ -0,0 +1,311 @@
+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 tile", () => {
+ render( );
+ expect(screen.getByRole("heading", { name: "Product images" })).toBeDefined();
+ expect(screen.getByTestId("attachment-upload-tile")).toBeDefined();
+ expect(screen.getByText("Upload image")).toBeDefined();
+ });
+
+ it("renders upload hint text in the upload tile", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("PDF and images only. Max 5 MB.")).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( );
+
+ expect(screen.queryByTestId("attachment-upload-tile")).toBeNull();
+
+ 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..abc0ce20
--- /dev/null
+++ b/packages/core/src/components/attachment-card/AttachmentCard.tsx
@@ -0,0 +1,388 @@
+import * as React from "react";
+import { CirclePlus, Ellipsis, File, Image as ImageIcon, Loader2 } from "lucide-react";
+
+import { useToast } from "@/hooks/use-toast";
+import { cn } from "@/lib/utils";
+
+import { Card } from "../card";
+import { Menu } from "../menu";
+import type { AttachmentCardProps, AttachmentItem } from "./types";
+
+const tileBaseClasses =
+ "astw:relative astw:size-30 astw:shrink-0 astw:overflow-hidden astw:rounded-lg";
+const tileClasses = `${tileBaseClasses} 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 = "Click to upload",
+ uploadHint,
+ 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(() => {
+ isMountedRef.current = true;
+ 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],
+ );
+
+ return (
+ event.preventDefault()}
+ onDragEnter={handleDragEnter}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ >
+
+
{title}
+
+
+
+
+ {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
+
+
+
+ )}
+
+ );
+ })}
+ {!disabled && (onUpload || uploadFile) && (
+
inputRef.current?.click()}
+ >
+
+
+
+ {uploadLabel}
+
+ {uploadHint ? (
+
+ {uploadHint}
+
+ ) : null}
+
+
+ )}
+
+
+
+ );
+}
+
+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..c2bca994
--- /dev/null
+++ b/packages/core/src/components/attachment-card/types.ts
@@ -0,0 +1,57 @@
+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 label shown in the upload tile. */
+ uploadLabel?: string;
+ /** Supporting text shown below the upload label (e.g. accepted formats, max size). */
+ uploadHint?: 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 (