Skip to content

Commit cb87884

Browse files
esmcelroyCopilot
andcommitted
feat: add clipboard copy, improved downloads, and PWA support
Phase 3 features: - Copy to clipboard button on each processed photo card - Visible download + copy buttons in info bar (no hover required) - PWA support via vite-plugin-pwa with service worker and manifest - Offline-capable with workbox precaching - Added .npmrc for legacy-peer-deps (vite-plugin-pwa compat) - Fixed tsconfig.app.json to exclude test files from build Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4b91b90 commit cb87884

8 files changed

Lines changed: 6147 additions & 1809 deletions

File tree

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
legacy-peer-deps=true

package-lock.json

Lines changed: 6028 additions & 1783 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@eslint/js": "^9.39.4",
2727
"@playwright/test": "^1.59.1",
2828
"@tailwindcss/vite": "^4.2.2",
29+
"@testing-library/dom": "^10.4.1",
2930
"@testing-library/jest-dom": "^6.9.1",
3031
"@testing-library/react": "^16.3.2",
3132
"@testing-library/user-event": "^14.6.1",
@@ -43,6 +44,7 @@
4344
"typescript": "~6.0.2",
4445
"typescript-eslint": "^8.58.0",
4546
"vite": "^8.0.4",
47+
"vite-plugin-pwa": "^1.2.0",
4648
"vitest": "^4.1.4"
4749
}
4850
}

src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export default function App() {
227227
maxAspectRatio={maxAspectRatio}
228228
onRemove={handleRemove}
229229
isProcessed={isProcessed}
230+
outputFormat={settings.outputFormat}
230231
/>
231232
</main>
232233
</div>

src/__tests__/PhotoGrid.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const defaultProps = {
2020
maxAspectRatio: 800 / 600,
2121
onRemove: vi.fn(),
2222
isProcessed: false,
23+
outputFormat: 'png',
2324
};
2425

2526
describe('PhotoGrid', () => {
@@ -43,7 +44,7 @@ describe('PhotoGrid', () => {
4344
makePhoto({ id: 'p1', aspectRatio: 1.5 }),
4445
makePhoto({ id: 'p2', aspectRatio: 2.0 }),
4546
];
46-
render(<PhotoGrid photos={photos} maxAspectRatio={2.0} onRemove={vi.fn()} isProcessed={false} />);
47+
render(<PhotoGrid photos={photos} maxAspectRatio={2.0} onRemove={vi.fn()} isProcessed={false} outputFormat="png" />);
4748
expect(screen.getByText('Widest')).toBeInTheDocument();
4849
});
4950

@@ -53,12 +54,12 @@ describe('PhotoGrid', () => {
5354
];
5455

5556
const { rerender } = render(
56-
<PhotoGrid photos={photos} maxAspectRatio={800 / 600} onRemove={vi.fn()} isProcessed={false} />
57+
<PhotoGrid photos={photos} maxAspectRatio={800 / 600} onRemove={vi.fn()} isProcessed={false} outputFormat="png" />
5758
);
5859
expect(screen.queryByTitle('Download')).not.toBeInTheDocument();
5960

6061
rerender(
61-
<PhotoGrid photos={photos} maxAspectRatio={800 / 600} onRemove={vi.fn()} isProcessed={true} />
62+
<PhotoGrid photos={photos} maxAspectRatio={800 / 600} onRemove={vi.fn()} isProcessed={true} outputFormat="png" />
6263
);
6364
expect(screen.getByTitle('Download')).toBeInTheDocument();
6465
});

src/components/PhotoGrid.tsx

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { 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';
33
import type { UploadedPhoto } from '../types';
44

55
interface PhotoGridProps {
66
photos: UploadedPhoto[];
77
maxAspectRatio: number;
88
onRemove: (id: string) => void;
99
isProcessed: boolean;
10+
outputFormat: string;
1011
}
1112

1213
function 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)}

tsconfig.app.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@
2121
"erasableSyntaxOnly": true,
2222
"noFallthroughCasesInSwitch": true
2323
},
24-
"include": ["src"]
24+
"include": ["src"],
25+
"exclude": ["src/__tests__"]
2526
}

vite.config.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,39 @@
11
import { defineConfig } from 'vite'
22
import react from '@vitejs/plugin-react'
33
import tailwindcss from '@tailwindcss/vite'
4+
import { VitePWA } from 'vite-plugin-pwa'
45

56
export default defineConfig({
67
base: '/',
78
plugins: [
89
tailwindcss(),
910
react(),
11+
VitePWA({
12+
registerType: 'autoUpdate',
13+
includeAssets: ['favicon.svg'],
14+
manifest: {
15+
name: 'Squarify',
16+
short_name: 'Squarify',
17+
description: 'Pad photos to a uniform aspect ratio',
18+
theme_color: '#4f46e5',
19+
background_color: '#f9fafb',
20+
display: 'standalone',
21+
start_url: '/',
22+
icons: [
23+
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' },
24+
],
25+
},
26+
workbox: {
27+
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
28+
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MB for libheif-js WASM
29+
runtimeCaching: [
30+
{
31+
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
32+
handler: 'CacheFirst',
33+
options: { cacheName: 'google-fonts-cache', expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 } },
34+
},
35+
],
36+
},
37+
}),
1038
],
1139
})

0 commit comments

Comments
 (0)