'use client'; import { createContext, type ReactNode, useCallback, useContext, useMemo, useState, } from 'react'; import { File as FileGlyph, FileText, X } from 'lucide-react'; import { Dialog, DialogContent, DialogTitle } from '@djangocfg/ui-core/components'; import { cn } from '@djangocfg/ui-core/lib'; import { formatFileSize } from '../../forms/Uploader'; import { LazyImageViewer } from '../../media/ImageViewer/lazy'; import { PastedTextDialog } from '../composer/PastedTextDialog'; import { useChatDestructiveStyles } from '../styles'; import type { ChatAttachment } from '../types'; export interface AttachmentRendererArgs { attachment: ChatAttachment; /** True when shown inside the composer's staging tray (denser layout). */ isInComposer: boolean; onClick?: () => void; onRemove?: () => void; } export type AttachmentRenderer = (args: AttachmentRendererArgs) => ReactNode; export interface AttachmentRendererMap { text?: AttachmentRenderer; image?: AttachmentRenderer; audio?: AttachmentRenderer; video?: AttachmentRenderer; file?: AttachmentRenderer; /** Fallback renderer when no per-type entry matched. */ default?: AttachmentRenderer; } interface CommonProps { attachments: ChatAttachment[]; maxVisible?: number; onClick?: (a: ChatAttachment) => void; onRemove?: (a: ChatAttachment) => void; isInComposer?: boolean; className?: string; } // --------------------------------------------------------------------------- // Default image lightbox — image tiles open the shared `ImageViewer` in a // dialog by default. All image attachments of the SAME attachments group feed // the viewer's `images` array, so the lightbox has prev/next across the // message's images and opens at the clicked index. // // Composition with a host `onClick`: the lightbox owns IMAGES, the host // `onClick` owns NON-IMAGE files. A host `onClick` does NOT suppress the image // lightbox — clicking an image opens the gallery, clicking a file fires the // host handler (e.g. reveal-in-file-manager). See `useImageTileClick`. // --------------------------------------------------------------------------- interface ImageLightboxContextValue { /** Opens the default lightbox at the given attachment id. */ openImage: (attachmentId: string) => void; } const ImageLightboxContext = createContext(null); /** * Provides a single shared lightbox dialog for a group of attachments and * renders it once. Image tiles call `openImage(id)` to open at their index. * Active only when `enabled` (i.e. the host did NOT wire `onClick`). */ function ImageLightboxProvider({ attachments, enabled, children, }: { attachments: ChatAttachment[]; enabled: boolean; children: ReactNode; }) { const [open, setOpen] = useState(false); const [index, setIndex] = useState(0); // Only image attachments seed the viewer — non-image files are skipped, so // the gallery index maps onto the image subset (not the full attachment list). const images = useMemo( () => attachments.filter((a) => a.type === 'image'), [attachments], ); const openImage = useCallback( (attachmentId: string) => { const i = images.findIndex((a) => a.id === attachmentId); if (i < 0) return; setIndex(i); setOpen(true); }, [images], ); const ctx = useMemo(() => ({ openImage }), [openImage]); if (!enabled || images.length === 0) return <>{children}; return ( {children} {/* Borderless, full-bleed lightbox: the viewer paints its own dark backdrop + idle-fading chrome, so the dialog provides only the blurred overlay. No card chrome, no built-in close (the viewer's macOS-grade close button + Esc own dismissal). */} Image {index + 1} of {images.length} {open && ( ({ file: { name: a.name ?? 'image', path: a.url }, src: a.url, }))} initialIndex={index} lightbox autoFocus onRequestClose={() => setOpen(false)} /> )} ); } /** * Resolves the click handler for an attachment tile, splitting ownership by * type: * - IMAGE → the default lightbox wins when its provider is mounted (so the * gallery opens with prev/next across the message's images), regardless of * a host `onClick`. Falls back to the host `onClick` only if no provider. * - NON-IMAGE → the host `onClick` (e.g. reveal-in-file-manager). * This lets a host wire `onClick` for files without suppressing the image * lightbox (the two no longer fight over the same group-level flag). */ function useImageTileClick( attachment: ChatAttachment, hostOnClick?: () => void, ): (() => void) | undefined { const lightbox = useContext(ImageLightboxContext); if (attachment.type === 'image' && lightbox) { return () => lightbox.openImage(attachment.id); } return hostOnClick; } // --------------------------------------------------------------------------- // AttachmentsGrid — flex-wrap, ideal for thumbnails / file chips. // --------------------------------------------------------------------------- export interface AttachmentsGridProps extends CommonProps { layout?: 'wrap' | 'grid'; } export function AttachmentsGrid({ attachments, maxVisible, onClick, onRemove, isInComposer = false, layout = 'wrap', className, }: AttachmentsGridProps) { if (!attachments?.length) return null; const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments; return ( // Lightbox owns images even when the host wired `onClick` (that handler is // for non-image files). `enabled` only gates the no-images case inside the // provider, so passing `true` is safe — see `useImageTileClick`.
{visible.map((a) => ( onClick(a) : undefined} onRemove={onRemove ? () => onRemove(a) : undefined} /> ))}
); } // --------------------------------------------------------------------------- // AttachmentsList — vertical stack, designed for rich custom renderers // (LazyAudioPlayer, video players, document previews). // --------------------------------------------------------------------------- export interface AttachmentsListProps extends CommonProps { /** Per-type renderer overrides. Falls back to the default tile. */ renderers?: AttachmentRendererMap; } export function AttachmentsList({ attachments, maxVisible, onClick, onRemove, renderers, isInComposer = false, className, }: AttachmentsListProps) { if (!attachments?.length) return null; const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments; return ( // Lightbox owns images even when the host wired `onClick` (that handler is // for non-image files). See `useImageTileClick` + AttachmentsGrid note.
{visible.map((a) => { const renderer = renderers?.[a.type] ?? renderers?.default; const args: AttachmentRendererArgs = { attachment: a, isInComposer, onClick: onClick ? () => onClick(a) : undefined, onRemove: onRemove ? () => onRemove(a) : undefined, }; if (renderer) { return (
{renderer(args)} {args.onRemove ? : null}
); } return ; })}
); } // --------------------------------------------------------------------------- // Attachments — backwards-compatible facade. Picks the right component based // on whether `renderers` are supplied. Existing call-sites keep working. // --------------------------------------------------------------------------- export interface AttachmentsProps extends CommonProps { layout?: 'grid' | 'row'; renderers?: AttachmentRendererMap; } export function Attachments(props: AttachmentsProps) { const { renderers, layout, ...rest } = props; if (renderers) { return ; } return ; } // --------------------------------------------------------------------------- // Tile + remove btn (default renderers). // --------------------------------------------------------------------------- function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArgs) { // Host `onClick` wins; otherwise image tiles fall back to the default // lightbox (when an `ImageLightboxProvider` is mounted). Called before any // early return to keep hook order stable. Non-image files stay interactive // only when the host wired `onClick`. const effectiveOnClick = useImageTileClick(attachment, onClick); // "Pasted text" chunks are self-contained — they open their own preview // dialog on click rather than going through the generic `onClick`. if (attachment.type === 'text') { return ; } // Rich-preview bag present → render the compact localpreview card // (thumbnail + title + label/value fields). Absent → fall through to the // existing name+thumbnail chip. Per-type `renderers` overrides still win // upstream (AttachmentsList resolves them before reaching this default). if (attachment.preview) { return ( ); } const isImage = attachment.type === 'image'; const isUploading = attachment.status === 'uploading'; const inner = isImage ? ( {attachment.name ) : ( // Two-line file chip: name on top, formatted size on the meta line // (Telegram/Slack convention — "report.pdf" / "1.2 KB"). The size row // is omitted when the byte count is unknown so the chip stays single-line.
{attachment.name ?? 'file'} {attachment.sizeBytes != null && attachment.sizeBytes > 0 ? ( {formatFileSize(attachment.sizeBytes)} ) : null}
); return (
{effectiveOnClick ? ( // Interactive when the host wired `onClick` OR (for images) the // default lightbox is active — the chip reads as clickable (cursor + // hover lift + focus ring) and is a real keyboard-focusable button. ) : ( inner )} {isUploading ? (
{attachment.progress != null ? `${Math.round(attachment.progress * 100)}%` : '…'}
) : null} {onRemove ? : null}
); } /** * Compact rich-preview card driven by `attachment.preview` (the serializable * `AttachmentPreview` bag). Renders a thumbnail (cover / first page), a title, * and an ordered label/value field list (duration, dimensions, pages, EXIF…). * Kind-agnostic: the same layout serves audio/image/pdf/doc — the host fills * `preview.fields` per media kind. Clickable only when the host wired `onClick`. */ function AttachmentPreviewCard({ attachment, onClick, onRemove, }: { attachment: ChatAttachment; onClick?: () => void; onRemove?: () => void; }) { const preview = attachment.preview!; const title = preview.title ?? attachment.name ?? 'attachment'; const fields = preview.fields ?? []; const card = (
{preview.thumbnail ? ( {title} ) : ( )} {title} {fields.slice(0, 3).map((f, i) => ( {f.label}: {f.value} ))}
); return (
{onClick ? ( ) : ( card )} {onRemove ? : null}
); } /** * Tile for a `type:'text'` chunk — a two-line "Pasted text" chip in the * same surface style as file chips (title + "Pasted text" sublabel). * Clicking it opens a read-only `PastedTextDialog` preview of the full * payload. Mirrors ChatGPT's pasted-text card + click-to-expand. */ function TextChunkTile({ attachment, onRemove, }: { attachment: ChatAttachment; onRemove?: () => void; }) { const [open, setOpen] = useState(false); const title = attachment.name ?? 'Pasted text'; return (
{onRemove ? : null}
); } function RemoveBtn({ onRemove }: { onRemove: () => void }) { const styles = useChatDestructiveStyles(); return ( ); }