1+ import { FileIcon , PaperclipIcon } from "lucide-react" ;
12import { useState } from "react" ;
23import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb" ;
34import { getAttachmentType , getAttachmentUrl } from "@/utils/attachment" ;
4- import MemoAttachment from "../../../MemoAttachment " ;
5+ import { formatFileSize , getFileTypeLabel } from "@/utils/format " ;
56import PreviewImageDialog from "../../../PreviewImageDialog" ;
67import AttachmentCard from "./AttachmentCard" ;
8+ import SectionHeader from "./SectionHeader" ;
79
810interface AttachmentListProps {
911 attachments : Attachment [ ] ;
1012}
1113
12- function separateMediaAndDocs ( attachments : Attachment [ ] ) : { media : Attachment [ ] ; docs : Attachment [ ] } {
14+ const separateMediaAndDocs = ( attachments : Attachment [ ] ) : { media : Attachment [ ] ; docs : Attachment [ ] } => {
1315 const media : Attachment [ ] = [ ] ;
1416 const docs : Attachment [ ] = [ ] ;
1517
@@ -23,7 +25,70 @@ function separateMediaAndDocs(attachments: Attachment[]): { media: Attachment[];
2325 }
2426
2527 return { media, docs } ;
26- }
28+ } ;
29+
30+ const DocumentItem = ( { attachment } : { attachment : Attachment } ) => {
31+ const fileTypeLabel = getFileTypeLabel ( attachment . type ) ;
32+ const fileSizeLabel = attachment . size ? formatFileSize ( Number ( attachment . size ) ) : undefined ;
33+
34+ return (
35+ < div className = "flex items-center gap-1 px-1.5 py-1 rounded hover:bg-accent/20 transition-colors whitespace-nowrap" >
36+ < div className = "shrink-0 w-5 h-5 rounded overflow-hidden bg-muted/40 flex items-center justify-center" >
37+ < FileIcon className = "w-3 h-3 text-muted-foreground" />
38+ </ div >
39+ < div className = "flex items-center gap-1 min-w-0" >
40+ < span className = "text-xs font-medium truncate" title = { attachment . filename } >
41+ { attachment . filename }
42+ </ span >
43+ < div className = "flex items-center gap-1 text-xs text-muted-foreground shrink-0" >
44+ < span className = "text-muted-foreground/50" > •</ span >
45+ < span > { fileTypeLabel } </ span >
46+ { fileSizeLabel && (
47+ < >
48+ < span className = "text-muted-foreground/50" > •</ span >
49+ < span > { fileSizeLabel } </ span >
50+ </ >
51+ ) }
52+ </ div >
53+ </ div >
54+ </ div >
55+ ) ;
56+ } ;
57+
58+ const MediaGrid = ( { attachments, onImageClick } : { attachments : Attachment [ ] ; onImageClick : ( url : string ) => void } ) => (
59+ < div className = "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2" >
60+ { attachments . map ( ( attachment ) => (
61+ < div
62+ key = { attachment . name }
63+ className = "aspect-square rounded-lg overflow-hidden bg-muted/40 border border-border hover:border-accent/50 transition-all cursor-pointer group"
64+ onClick = { ( ) => onImageClick ( getAttachmentUrl ( attachment ) ) }
65+ >
66+ < div className = "w-full h-full relative" >
67+ < AttachmentCard attachment = { attachment } className = "rounded-none" />
68+ { getAttachmentType ( attachment ) === "video/*" && (
69+ < div className = "absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/40 transition-colors" >
70+ < div className = "w-8 h-8 rounded-full bg-white/80 flex items-center justify-center" >
71+ < svg className = "w-5 h-5 text-black fill-current ml-0.5" viewBox = "0 0 24 24" >
72+ < path d = "M8 5v14l11-7z" />
73+ </ svg >
74+ </ div >
75+ </ div >
76+ ) }
77+ </ div >
78+ </ div >
79+ ) ) }
80+ </ div >
81+ ) ;
82+
83+ const DocsList = ( { attachments } : { attachments : Attachment [ ] } ) => (
84+ < div className = "flex flex-col gap-0.5" >
85+ { attachments . map ( ( attachment ) => (
86+ < a key = { attachment . name } href = { getAttachmentUrl ( attachment ) } download title = { `Download ${ attachment . filename } ` } >
87+ < DocumentItem attachment = { attachment } />
88+ </ a >
89+ ) ) }
90+ </ div >
91+ ) ;
2792
2893const AttachmentList = ( { attachments } : AttachmentListProps ) => {
2994 const [ previewImage , setPreviewImage ] = useState < { open : boolean ; urls : string [ ] ; index : number ; mimeType ?: string } > ( {
@@ -33,45 +98,33 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
3398 mimeType : undefined ,
3499 } ) ;
35100
36- const handleImageClick = ( imgUrl : string , mediaAttachments : Attachment [ ] ) => {
37- const imageAttachments = mediaAttachments . filter ( ( attachment ) => getAttachmentType ( attachment ) === "image/*" ) ;
38- const imgUrls = imageAttachments . map ( ( attachment ) => getAttachmentUrl ( attachment ) ) ;
39- const index = imgUrls . findIndex ( ( url ) => url === imgUrl ) ;
40- const mimeType = imageAttachments [ index ] ?. type ;
41- setPreviewImage ( { open : true , urls : imgUrls , index, mimeType } ) ;
42- } ;
43-
44101 const { media : mediaItems , docs : docItems } = separateMediaAndDocs ( attachments ) ;
45102
46103 if ( attachments . length === 0 ) {
47104 return null ;
48105 }
49106
107+ const handleImageClick = ( imgUrl : string ) => {
108+ const imageAttachments = mediaItems . filter ( ( a ) => getAttachmentType ( a ) === "image/*" ) ;
109+ const imgUrls = imageAttachments . map ( ( a ) => getAttachmentUrl ( a ) ) ;
110+ const index = imgUrls . findIndex ( ( url ) => url === imgUrl ) ;
111+ const mimeType = imageAttachments [ index ] ?. type ;
112+ setPreviewImage ( { open : true , urls : imgUrls , index, mimeType } ) ;
113+ } ;
114+
50115 return (
51116 < >
52- { mediaItems . length > 0 && (
53- < div className = "w-full flex flex-row justify-start overflow-auto gap-2" >
54- { mediaItems . map ( ( attachment ) => (
55- < div key = { attachment . name } className = "max-w-[60%] w-fit flex flex-col justify-start items-start shrink-0" >
56- < AttachmentCard
57- attachment = { attachment }
58- onClick = { ( ) => {
59- handleImageClick ( getAttachmentUrl ( attachment ) , mediaItems ) ;
60- } }
61- className = "max-h-64 grow"
62- />
63- </ div >
64- ) ) }
65- </ div >
66- ) }
117+ < div className = "w-full rounded-lg border border-border bg-muted/20 overflow-hidden" >
118+ < SectionHeader icon = { PaperclipIcon } title = "Attachments" count = { attachments . length } />
119+
120+ < div className = "p-2 flex flex-col gap-1" >
121+ { mediaItems . length > 0 && < MediaGrid attachments = { mediaItems } onImageClick = { handleImageClick } /> }
122+
123+ { mediaItems . length > 0 && docItems . length > 0 && < div className = "border-t border-border opacity-60" /> }
67124
68- { docItems . length > 0 && (
69- < div className = "w-full flex flex-row justify-start overflow-auto gap-2" >
70- { docItems . map ( ( attachment ) => (
71- < MemoAttachment key = { attachment . name } attachment = { attachment } />
72- ) ) }
125+ { docItems . length > 0 && < DocsList attachments = { docItems } /> }
73126 </ div >
74- ) }
127+ </ div >
75128
76129 < PreviewImageDialog
77130 open = { previewImage . open }
0 commit comments