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`] = `"

Attachments

Drag and drop images/files or
shoe-red.png

Aug-Sep 2025_1234-12.pdf

"`; + +exports[`AttachmentCard > snapshots > empty default 1`] = `"

Attachments

Drag and drop images/files or
"`; + +exports[`AttachmentCard > snapshots > populated mixed items 1`] = `"

Attachments

Drag and drop images/files or
shoe-red.png

Aug-Sep 2025_1234-12.pdf

"`; 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 ? ( + {item.fileName} 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 (