feat: PdfPickerModal — page selection grid with lazy thumbnail loading
This commit is contained in:
@@ -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<Set<number>>(new Set());
|
||||
const [thumbs, setThumbs] = useState<Map<number, string>>(new Map());
|
||||
const [thumbsLoaded, setThumbsLoaded] = useState(0);
|
||||
const [requestedUpTo, setRequestedUpTo] = useState(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(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<number>();
|
||||
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 (
|
||||
<div style={overlayStyle} onClick={onCancel}>
|
||||
<div style={panelStyle} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#e0e0e0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{fileName}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>
|
||||
{pageCount} page{pageCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={smallBtnStyle} onClick={selectAll}>Select all</button>
|
||||
<button style={smallBtnStyle} onClick={deselectAll}>Deselect all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-header: loading counter */}
|
||||
<div style={{ padding: '4px 20px 8px', fontSize: 12, color: '#666' }}>
|
||||
Loading thumbnails... ({thumbsLoaded}/{pageCount})
|
||||
</div>
|
||||
|
||||
{/* Scrollable thumbnail grid */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
style={gridContainerStyle}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${GRID_COLS}, 1fr)`, gap: 8 }}>
|
||||
{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 (
|
||||
<div
|
||||
key={page}
|
||||
onClick={() => togglePage(page)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
border: isSelected ? '2px solid #4a9eff' : '2px solid transparent',
|
||||
background: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
<div style={{ paddingTop: `${aspect * 100}%`, position: 'relative' }}>
|
||||
{thumbKey ? (
|
||||
<img
|
||||
src={thumbUrl(thumbKey)}
|
||||
alt={`Page ${page}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, width: '100%', height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, width: '100%', height: '100%',
|
||||
background: '#2a2a2a',
|
||||
animation: 'pdfPickerPulse 1.5s ease-in-out infinite',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
{/* Page number badge */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 4, right: 4,
|
||||
background: 'rgba(0,0,0,0.7)', color: '#ccc',
|
||||
fontSize: 10, padding: '1px 5px', borderRadius: 3,
|
||||
}}>
|
||||
{page}
|
||||
</div>
|
||||
{/* Selection checkmark */}
|
||||
{isSelected && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 4, right: 4,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: '#4a9eff', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 12, color: '#fff',
|
||||
}}>
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={footerStyle}>
|
||||
<button style={cancelBtnStyle} onClick={onCancel}>Cancel</button>
|
||||
<button
|
||||
style={{
|
||||
...placeBtnStyle,
|
||||
opacity: selected.size === 0 ? 0.4 : 1,
|
||||
cursor: selected.size === 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
onClick={handlePlace}
|
||||
>
|
||||
Place {selected.size} page{selected.size !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyframe animation for pulsing placeholder */}
|
||||
<style>{`
|
||||
@keyframes pdfPickerPulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user