11import { useState } from 'react' ;
2- import { Download , Trash2 , Crown , Eye , EyeOff } from 'lucide-react' ;
2+ import { Download , Trash2 , Crown , Eye , EyeOff , Copy , Check } from 'lucide-react' ;
33import type { UploadedPhoto } from '../types' ;
44
55interface PhotoGridProps {
66 photos : UploadedPhoto [ ] ;
77 maxAspectRatio : number ;
88 onRemove : ( id : string ) => void ;
99 isProcessed : boolean ;
10+ outputFormat : string ;
1011}
1112
1213function downloadDataUrl ( dataUrl : string , filename : string ) {
@@ -16,15 +17,60 @@ function downloadDataUrl(dataUrl: string, filename: string) {
1617 a . click ( ) ;
1718}
1819
19- export function PhotoGrid ( { photos, maxAspectRatio, onRemove, isProcessed } : PhotoGridProps ) {
20+ async function copyToClipboard ( dataUrl : string ) : Promise < boolean > {
21+ try {
22+ const res = await fetch ( dataUrl ) ;
23+ const blob = await res . blob ( ) ;
24+ // Clipboard API requires PNG for images
25+ const pngBlob = blob . type === 'image/png' ? blob : await convertToPngBlob ( dataUrl ) ;
26+ await navigator . clipboard . write ( [
27+ new ClipboardItem ( { 'image/png' : pngBlob } ) ,
28+ ] ) ;
29+ return true ;
30+ } catch {
31+ return false ;
32+ }
33+ }
34+
35+ function convertToPngBlob ( dataUrl : string ) : Promise < Blob > {
36+ return new Promise ( ( resolve , reject ) => {
37+ const img = new Image ( ) ;
38+ img . onload = ( ) => {
39+ const canvas = document . createElement ( 'canvas' ) ;
40+ canvas . width = img . width ;
41+ canvas . height = img . height ;
42+ const ctx = canvas . getContext ( '2d' ) ! ;
43+ ctx . drawImage ( img , 0 , 0 ) ;
44+ canvas . toBlob ( blob => {
45+ if ( blob ) resolve ( blob ) ;
46+ else reject ( new Error ( 'Failed to convert to PNG' ) ) ;
47+ } , 'image/png' ) ;
48+ } ;
49+ img . onerror = reject ;
50+ img . src = dataUrl ;
51+ } ) ;
52+ }
53+
54+ export function PhotoGrid ( { photos, maxAspectRatio, onRemove, isProcessed, outputFormat } : PhotoGridProps ) {
2055 const [ showOriginal , setShowOriginal ] = useState < Record < string , boolean > > ( { } ) ;
56+ const [ copiedId , setCopiedId ] = useState < string | null > ( null ) ;
2157
2258 if ( photos . length === 0 ) return null ;
2359
2460 const togglePreview = ( id : string ) => {
2561 setShowOriginal ( prev => ( { ...prev , [ id ] : ! prev [ id ] } ) ) ;
2662 } ;
2763
64+ const handleCopy = async ( id : string , dataUrl : string ) => {
65+ const ok = await copyToClipboard ( dataUrl ) ;
66+ if ( ok ) {
67+ setCopiedId ( id ) ;
68+ setTimeout ( ( ) => setCopiedId ( null ) , 2000 ) ;
69+ }
70+ } ;
71+
72+ const ext = outputFormat === 'jpeg' ? 'jpg' : outputFormat ;
73+
2874 return (
2975 < div className = "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4" >
3076 { photos . map ( ( photo , idx ) => {
@@ -33,7 +79,7 @@ export function PhotoGrid({ photos, maxAspectRatio, onRemove, isProcessed }: Pho
3379 const displayUrl = isProcessed && photo . paddedDataUrl && ! viewingOriginal
3480 ? photo . paddedDataUrl
3581 : photo . dataUrl ;
36- const filename = `squarify-${ String ( idx + 1 ) . padStart ( 2 , '0' ) } .png ` ;
82+ const filename = `squarify-${ String ( idx + 1 ) . padStart ( 2 , '0' ) } .${ ext } ` ;
3783
3884 return (
3985 < div key = { photo . id } className = "group relative bg-gray-100 dark:bg-gray-800 rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors" >
@@ -59,31 +105,44 @@ export function PhotoGrid({ photos, maxAspectRatio, onRemove, isProcessed }: Pho
59105 </ div >
60106 ) }
61107
62- { /* Info bar */ }
108+ { /* Info bar with action buttons */ }
63109 < div className = "px-3 py-2 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700" >
64- < p className = "text-xs text-gray-500 dark:text-gray-400 truncate" title = { photo . file . name } > { photo . file . name } </ p >
65- < p className = "text-xs text-gray-400 dark:text-gray-500" > { photo . width } × { photo . height } </ p >
110+ < div className = "flex items-center justify-between" >
111+ < div className = "min-w-0 flex-1" >
112+ < p className = "text-xs text-gray-500 dark:text-gray-400 truncate" title = { photo . file . name } > { photo . file . name } </ p >
113+ < p className = "text-xs text-gray-400 dark:text-gray-500" > { photo . width } × { photo . height } </ p >
114+ </ div >
115+ { isProcessed && photo . paddedDataUrl && (
116+ < div className = "flex items-center gap-1 ml-2 shrink-0" >
117+ < button
118+ onClick = { ( ) => handleCopy ( photo . id , photo . paddedDataUrl ! ) }
119+ title = "Copy to clipboard"
120+ className = "p-1.5 rounded-lg text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition-colors"
121+ >
122+ { copiedId === photo . id ? < Check className = "w-3.5 h-3.5 text-emerald-500" /> : < Copy className = "w-3.5 h-3.5" /> }
123+ </ button >
124+ < button
125+ onClick = { ( ) => downloadDataUrl ( photo . paddedDataUrl ! , filename ) }
126+ title = "Download"
127+ className = "p-1.5 rounded-lg text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition-colors"
128+ >
129+ < Download className = "w-3.5 h-3.5" />
130+ </ button >
131+ </ div >
132+ ) }
133+ </ div >
66134 </ div >
67135
68136 { /* Action overlay */ }
69137 < div className = "absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2" >
70138 { isProcessed && photo . paddedDataUrl && (
71- < >
72- < button
73- onClick = { ( ) => togglePreview ( photo . id ) }
74- title = { viewingOriginal ? 'Show padded' : 'Show original' }
75- className = "p-2 bg-white rounded-full text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 transition-colors shadow"
76- >
77- { viewingOriginal ? < Eye className = "w-4 h-4" /> : < EyeOff className = "w-4 h-4" /> }
78- </ button >
79- < button
80- onClick = { ( ) => downloadDataUrl ( photo . paddedDataUrl ! , filename ) }
81- title = "Download"
82- className = "p-2 bg-white rounded-full text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 transition-colors shadow"
83- >
84- < Download className = "w-4 h-4" />
85- </ button >
86- </ >
139+ < button
140+ onClick = { ( ) => togglePreview ( photo . id ) }
141+ title = { viewingOriginal ? 'Show padded' : 'Show original' }
142+ className = "p-2 bg-white rounded-full text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 transition-colors shadow"
143+ >
144+ { viewingOriginal ? < Eye className = "w-4 h-4" /> : < EyeOff className = "w-4 h-4" /> }
145+ </ button >
87146 ) }
88147 < button
89148 onClick = { ( ) => onRemove ( photo . id ) }
0 commit comments