"use client"; /** * Attachment — single unified renderer for every attachment surface. * * Takes one attachment-shaped input (a full record, a URL-only reference, or * an in-flight upload) and dispatches by PreviewKind: * * - image → ImageAttachmentView (figure + hover toolbar + lightbox via * the shared AttachmentPreviewModal) * - html → HtmlAttachmentPreview (inline iframe + hover toolbar) * - others → AttachmentCard (icon + filename + Eye/Download row) * * Call sites: * - extensions/file-card.tsx FileCardView (Tiptap NodeView) * - extensions/image-view.tsx ImageView (Tiptap NodeView) * - rich-content/rich-content.tsx (markdown img + fileCard div renderers, * serving Chat, Issue descriptions and Comments through one renderer) * - issues/components/comment-card.tsx AttachmentList (standalone fallback) * * The component owns its own preview modal and download dispatcher — callers * just pass `attachment` and (for editor surfaces) optional editor chrome * hints (selected, editable, onDelete). */ import { Download, Link as LinkIcon, Maximize2, Trash2, } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@multica/ui/lib/utils"; import { copyText } from "@multica/ui/lib/clipboard"; import { useQuery } from "@tanstack/react-query"; import { api } from "@multica/core/api"; import { useConfigStore } from "@multica/core/config"; import type { Attachment as AttachmentRecord } from "@multica/core/types"; import { attachmentIdFromDownloadURL } from "@multica/core/types/attachment-url"; import { useT } from "../i18n"; import { useAttachmentDownloadResolver } from "./attachment-download-context"; import { useAttachmentPreview } from "./attachment-preview-modal"; import { useDownloadAttachment } from "./use-download-attachment"; import { AttachmentCard } from "./attachment-card"; import { HtmlAttachmentPreview } from "./html-attachment-preview"; import { getPreviewKind, type PreviewKind } from "./utils/preview"; import "./styles/attachment.css"; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export type AttachmentInput = // Server response in hand — full record. Used by AttachmentList and any // caller iterating a server-returned attachments[] array. | { kind: "record"; attachment: AttachmentRecord } // Markdown / Tiptap inline: only a URL + filename. Resolves to a full // record via the surrounding AttachmentDownloadProvider when available; // otherwise renders in URL-only mode (media types still preview from URL, // text types fall back to a download CTA). | { kind: "url"; url: string; filename: string; contentType?: string; /** Editor in-flight state. Renders a loader placeholder. */ uploading?: boolean; /** * Intrinsic pixel dimensions. Rendered as `` so the * browser reserves the box before the image decodes — prevents the * layout shift that would otherwise push the caret out of view on paste. */ width?: number; height?: number; /** * Structural hint from the call site: "this slot is definitionally an * image / file / ...". Bypasses `getPreviewKind` autodetect, which * needs a filename or content-type and falls back to the file-card * chrome when neither is available. Required for callers that KNOW * the kind from context (markdown `![]()` is always an image; Tiptap * image NodeView is always an image) but receive only a URL with an * empty `alt`/`filename`. */ forceKind?: PreviewKind; }; export interface AttachmentProps { attachment: AttachmentInput; /** Editor hint — when true, the image toolbar exposes Trash. */ editable?: boolean; /** Editor hint — applies the "selected" visual to the image figure. */ selected?: boolean; /** Editor hint — wired to Tiptap deleteNode(). */ onDelete?: () => void; className?: string; } interface Normalized { filename: string; contentType: string; url: string; attachmentId?: string; record?: AttachmentRecord; uploading: boolean; width?: number; height?: number; } function normalize( input: AttachmentInput, resolve: (url: string) => AttachmentRecord | undefined, cdnDomain: string, cdnSigned: boolean, ): Normalized { if (input.kind === "record") { return { filename: input.attachment.filename, contentType: input.attachment.content_type, url: absolutizeMediaURL( pickInlineMediaURL(input.attachment, input.attachment.url, cdnDomain, cdnSigned), ), attachmentId: input.attachment.id, record: input.attachment, uploading: false, }; } const record = input.url ? resolve(input.url) : undefined; return { filename: input.filename || record?.filename || "", contentType: input.contentType || record?.content_type || "", // When the markdown URL resolved to an attachment record, swap to // the record's freshly-loadable URL. The persisted markdown URL // (`/api/attachments//download` for new content; raw stored URL // for legacy) is correct as a stable reference but doesn't // necessarily load as a native /