From 656b0a987505655edd425f292c0feb015d4593e1 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:29:31 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20PdfPickerModal=20=E2=80=94=20page=20sel?= =?UTF-8?q?ection=20grid=20with=20lazy=20thumbnail=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/PdfPickerModal.tsx | 292 +++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 frontend/src/components/PdfPickerModal.tsx diff --git a/frontend/src/components/PdfPickerModal.tsx b/frontend/src/components/PdfPickerModal.tsx new file mode 100644 index 0000000..437fde6 --- /dev/null +++ b/frontend/src/components/PdfPickerModal.tsx @@ -0,0 +1,292 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import api from '../api'; +import { getSocket } from '../socket'; + +interface PdfPickerModalProps { + imageId: string; + fileName: string; + pageCount: number; + dimensions: Array<{ w: number; h: number }>; + boardId: string; + onPlace: (selectedPages: number[]) => void; + onCancel: () => void; +} + +const MAX_SELECTIONS = 50; +const THUMB_BATCH = 20; +const GRID_COLS = 5; + +export default function PdfPickerModal({ + imageId, fileName, pageCount, dimensions, boardId, onPlace, onCancel, +}: PdfPickerModalProps) { + const [selected, setSelected] = useState>(new Set()); + const [thumbs, setThumbs] = useState>(new Map()); + const [thumbsLoaded, setThumbsLoaded] = useState(0); + const [requestedUpTo, setRequestedUpTo] = useState(0); + const scrollRef = useRef(null); + const requestingRef = useRef(false); + + // Request first batch of thumbnails on mount + useEffect(() => { + requestThumbnails(1, Math.min(THUMB_BATCH, pageCount)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Listen for socket thumbnail events + useEffect(() => { + const socket = getSocket(); + if (!socket) return; + + const handler = (data: any) => { + if (!data || data.imageId !== imageId) return; + if (data.type === 'pdf-thumbnail' && data.status === 'done') { + const page: number = data.pageNumber; + const key: string = data.thumbAssetKey; + if (page && key) { + setThumbs(prev => { + const next = new Map(prev); + next.set(page, key); + return next; + }); + setThumbsLoaded(prev => prev + 1); + } + } + }; + + socket.on('media:job:update', handler); + return () => { socket.off('media:job:update', handler); }; + }, [imageId]); + + const requestThumbnails = useCallback(async (from: number, to: number) => { + if (requestingRef.current) return; + if (from > pageCount) return; + const clampedTo = Math.min(to, pageCount); + if (clampedTo <= requestedUpTo) return; + + requestingRef.current = true; + const pages: number[] = []; + for (let p = Math.max(from, requestedUpTo + 1); p <= clampedTo; p++) pages.push(p); + + if (pages.length > 0) { + try { + await api.post(`/api/boards/${boardId}/pdf-thumbnails`, { + imageId, + pages, + }); + } catch (err) { + console.error('[PdfPickerModal] thumbnail request failed:', err); + } + } + setRequestedUpTo(clampedTo); + requestingRef.current = false; + }, [boardId, imageId, pageCount, requestedUpTo]); + + // Lazy scroll loading — request next batch when near bottom + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200; + if (nearBottom && requestedUpTo < pageCount) { + requestThumbnails(requestedUpTo + 1, requestedUpTo + THUMB_BATCH); + } + }, [requestThumbnails, requestedUpTo, pageCount]); + + const togglePage = (page: number) => { + setSelected(prev => { + const next = new Set(prev); + if (next.has(page)) { + next.delete(page); + } else if (next.size < MAX_SELECTIONS) { + next.add(page); + } + return next; + }); + }; + + const selectAll = () => { + const all = new Set(); + const max = Math.min(pageCount, MAX_SELECTIONS); + for (let i = 1; i <= max; i++) all.add(i); + setSelected(all); + }; + + const deselectAll = () => { + setSelected(new Set()); + }; + + const handlePlace = () => { + if (selected.size === 0) return; + const sorted = Array.from(selected).sort((a, b) => a - b); + onPlace(sorted); + }; + + // Build asset URL from key + const thumbUrl = (key: string) => `/api/assets/${key}`; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+
+ {fileName} +
+
+ {pageCount} page{pageCount !== 1 ? 's' : ''} +
+
+
+ + +
+
+ + {/* Sub-header: loading counter */} +
+ Loading thumbnails... ({thumbsLoaded}/{pageCount}) +
+ + {/* Scrollable thumbnail grid */} +
+
+ {Array.from({ length: pageCount }, (_, i) => { + const page = i + 1; + const dim = dimensions[i] || { w: 612, h: 792 }; + const aspect = dim.h / dim.w; + const isSelected = selected.has(page); + const thumbKey = thumbs.get(page); + + return ( +
togglePage(page)} + style={{ + position: 'relative', + cursor: 'pointer', + borderRadius: 4, + overflow: 'hidden', + border: isSelected ? '2px solid #4a9eff' : '2px solid transparent', + background: '#1a1a1a', + }} + > +
+ {thumbKey ? ( + {`Page + ) : ( +
+ )} +
+ {/* Page number badge */} +
+ {page} +
+ {/* Selection checkmark */} + {isSelected && ( +
+ ✓ +
+ )} +
+ ); + })} +
+
+ + {/* Footer */} +
+ + +
+
+ + {/* Keyframe animation for pulsing placeholder */} + +
+ ); +} + +// Styles +const overlayStyle: React.CSSProperties = { + position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, + background: 'rgba(0,0,0,0.6)', zIndex: 10000, + display: 'flex', alignItems: 'center', justifyContent: 'center', +}; + +const panelStyle: React.CSSProperties = { + width: 700, maxHeight: '80vh', background: '#242424', + borderRadius: 8, display: 'flex', flexDirection: 'column', + boxShadow: '0 8px 32px rgba(0,0,0,0.5)', +}; + +const headerStyle: React.CSSProperties = { + padding: '16px 20px 8px', display: 'flex', alignItems: 'center', gap: 12, + borderBottom: '1px solid #333', +}; + +const smallBtnStyle: React.CSSProperties = { + background: '#333', border: 'none', color: '#ccc', + padding: '4px 10px', borderRadius: 4, fontSize: 12, cursor: 'pointer', +}; + +const gridContainerStyle: React.CSSProperties = { + flex: 1, overflowY: 'auto', padding: '8px 20px', + minHeight: 0, +}; + +const footerStyle: React.CSSProperties = { + padding: '12px 20px', borderTop: '1px solid #333', + display: 'flex', justifyContent: 'flex-end', gap: 10, +}; + +const cancelBtnStyle: React.CSSProperties = { + background: 'transparent', border: '1px solid #555', color: '#aaa', + padding: '8px 16px', borderRadius: 4, cursor: 'pointer', fontSize: 13, +}; + +const placeBtnStyle: React.CSSProperties = { + background: '#4a9eff', border: 'none', color: '#fff', + padding: '8px 20px', borderRadius: 4, fontSize: 13, fontWeight: 600, +};