Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/container-scan-trivy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
cache-to: type=gha,mode=max

- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.28.0
uses: aquasecurity/trivy-action@v0.35.0
with:
image-ref: 'security-scan:latest'
format: 'sarif'
Expand Down
51 changes: 24 additions & 27 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -1061,11 +1061,35 @@ ul li:hover {

.view-mode-buttons {
display: flex;
align-items: center;
border: 1px solid var(--border-medium);
border-radius: var(--radius-md);
overflow: hidden;
}

.thumbnail-size-control {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border-right: 1px solid var(--border-light);
background: var(--bg-primary);
}

.thumbnail-size-slider {
width: 110px;
cursor: pointer;
accent-color: var(--primary-color);
}

.thumbnail-size-label {
font-size: 0.75rem;
color: var(--gray-600);
white-space: nowrap;
min-width: 38px;
text-align: right;
}

.view-mode-btn {
padding: var(--space-2) var(--space-3);
border: none;
Expand Down Expand Up @@ -1154,21 +1178,6 @@ ul li:hover {
margin-bottom: var(--space-6);
}

.gallery-grid.view-small {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-3);
}

.gallery-grid.view-medium {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-4);
}

.gallery-grid.view-large {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-6);
}

.gallery-item {
position: relative;
background: var(--bg-primary);
Expand Down Expand Up @@ -1974,18 +1983,6 @@ ul li:hover {
align-self: center;
}

.gallery-grid.view-small {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}

.gallery-grid.view-medium {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}

.gallery-grid.view-large {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}

.gallery-pagination-info {
flex-direction: column;
gap: var(--space-2);
Expand Down
31 changes: 20 additions & 11 deletions frontend/src/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,32 @@ function Project() {
const delOnly = opts.deletedOnly ?? deletedOnly;
const searchField = opts.searchField;
const searchValue = opts.searchValue;
let url = `/api/projects/${projId}/images`;
const params = [];
const baseParams = new URLSearchParams();
if (delOnly) {
params.push('deleted_only=true');
baseParams.set('deleted_only', 'true');
} else if (inc) {
params.push('include_deleted=true');
baseParams.set('include_deleted', 'true');
}
if (searchField && searchValue) {
params.push(`search_field=${encodeURIComponent(searchField)}`);
params.push(`search_value=${encodeURIComponent(searchValue)}`);
baseParams.set('search_field', searchField);
baseParams.set('search_value', searchValue);
}
if (params.length) url += `?${params.join('&')}`;
const imagesResponse = await fetch(url);
if (imagesResponse.ok) {
const imagesData = await imagesResponse.json();
setImages(imagesData);
const PAGE_SIZE = 200;
let skip = 0;
let allImages = [];
let hasMore = true;
while (hasMore) {
const params = new URLSearchParams(baseParams);
params.set('skip', String(skip));
params.set('limit', String(PAGE_SIZE));
const resp = await fetch(`/api/projects/${projId}/images?${params}`);
if (!resp.ok) break;
const batch = await resp.json();
allImages = allImages.concat(batch);
hasMore = batch.length === PAGE_SIZE;
skip += PAGE_SIZE;
}
Comment thread
travisdock marked this conversation as resolved.
setImages(allImages);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Expand Down
21 changes: 19 additions & 2 deletions frontend/src/components/GalleryGridView.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,35 @@ function formatFileSize(bytes) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

// Snap to the nearest fixed tier to keep backend thumbnail cache bounded.
// At most 5 distinct cache entries per image instead of one per slider step.
const THUMBNAIL_TIERS = [200, 400, 600, 800, 1000];
function snapToTier(size) {
const target = size * 2; // 2x for retina
let best = THUMBNAIL_TIERS[0];
for (const tier of THUMBNAIL_TIERS) {
if (Math.abs(tier - target) < Math.abs(best - target)) best = tier;
}
return best;
}

function GalleryGridView({
images,
viewMode,
thumbnailSize,
selectedImages,
reviewStatuses,
onImageClick,
onToggleSelection,
onRestore,
onImageLoadStatusChange,
}) {
const thumbFetchSize = snapToTier(thumbnailSize);
const gridStyle = viewMode !== 'list'
? { gridTemplateColumns: `repeat(auto-fill, minmax(${thumbnailSize}px, 1fr))` }
Comment thread
travisdock marked this conversation as resolved.
: undefined;
return (
<div className={`gallery-grid view-${viewMode}`}>
<div className="gallery-grid" style={gridStyle}>
{images.map(image => (
<div
key={image.id}
Expand Down Expand Up @@ -60,7 +77,7 @@ function GalleryGridView({
}}
>
<img
src={image.deleted_at ? DELETED_IMAGE_SVG : `/api/images/${image.id}/thumbnail?width=400&height=400`}
src={image.deleted_at ? DELETED_IMAGE_SVG : `/api/images/${image.id}/thumbnail?width=${thumbFetchSize}&height=${thumbFetchSize}`}
alt={image.filename || 'Image'}
loading="lazy"
onLoad={() => {
Expand Down
51 changes: 32 additions & 19 deletions frontend/src/components/ImageGallery.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function ImageGallery({ projectId, galleryKey, images, loading, onImageUpdated,
// Filter/sort state is loaded once from localStorage and persisted back on change
const [savedState] = useState(() => loadGalleryStateWithDefaults(stateKey));
const [viewMode, setViewMode] = useState(savedState.viewMode);
const [thumbnailSize, setThumbnailSize] = useState(savedState.thumbnailSize);
const [sortBy, setSortBy] = useState(savedState.sortBy);
const [searchField, setSearchField] = useState(savedState.searchField);
const [searchValue, setSearchValue] = useState(savedState.searchValue);
Expand All @@ -36,6 +37,7 @@ function ImageGallery({ projectId, galleryKey, images, loading, onImageUpdated,
prevKeyRef.current = stateKey;
const saved = loadGalleryStateWithDefaults(stateKey);
setViewMode(saved.viewMode);
setThumbnailSize(saved.thumbnailSize);
setSortBy(saved.sortBy);
setSearchField(saved.searchField);
setSearchValue(saved.searchValue);
Expand All @@ -44,12 +46,15 @@ function ImageGallery({ projectId, galleryKey, images, loading, onImageUpdated,
}
}, [stateKey]);

// Persist filter/sort state to localStorage whenever it changes
// Persist filter/sort state to localStorage whenever it changes.
// Debounce to avoid excessive writes while dragging the thumbnail size slider.
useEffect(() => {
saveGalleryState(stateKey, { viewMode, sortBy, searchField, searchValue, reviewFilter });
}, [stateKey, viewMode, sortBy, searchField, searchValue, reviewFilter]);
const state = { viewMode, thumbnailSize, sortBy, searchField, searchValue, reviewFilter };
const timer = setTimeout(() => saveGalleryState(stateKey, state), 300);
return () => clearTimeout(timer);
}, [stateKey, viewMode, thumbnailSize, sortBy, searchField, searchValue, reviewFilter]);

const imagesPerPage = viewMode === 'small' ? 100 : viewMode === 'medium' ? 50 : viewMode === 'large' ? 25 : 200;
const imagesPerPage = viewMode === 'list' ? 200 : 60;
Comment thread
travisdock marked this conversation as resolved.

const filteredImages = sortImages(
filterByReviewStatus(
Expand Down Expand Up @@ -229,23 +234,30 @@ function ImageGallery({ projectId, galleryKey, images, loading, onImageUpdated,
</select>

<div className="view-mode-buttons">
{[
{ mode: 'small', label: 'Small thumbnails', content: 'S' },
{ mode: 'medium', label: 'Medium thumbnails', content: 'M' },
{ mode: 'large', label: 'Large thumbnails', content: 'L' },
].map(({ mode, label, content }) => (
<button
key={mode}
className={`view-mode-btn ${viewMode === mode ? 'active' : ''}`}
onClick={() => setViewMode(mode)}
title={label}
aria-label={label}
aria-pressed={viewMode === mode}
>{content}</button>
))}
<div className="thumbnail-size-control">
<input
id="thumbnail-size-slider"
type="range"
min="100"
max="500"
step="10"
value={thumbnailSize}
onChange={(e) => {
setThumbnailSize(Number(e.target.value));
if (viewMode === 'list') setViewMode('grid');
}}
className="thumbnail-size-slider"
title="Adjust thumbnail size"
aria-label="Adjust thumbnail size"
aria-valuemin={100}
aria-valuemax={500}
aria-valuenow={thumbnailSize}
/>
<span className="thumbnail-size-label">{thumbnailSize}px</span>
</div>
<button
className={`view-mode-btn ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => setViewMode('list')}
onClick={() => setViewMode(viewMode === 'list' ? 'grid' : 'list')}
title="List view"
aria-label="List view"
aria-pressed={viewMode === 'list'}
Expand Down Expand Up @@ -340,6 +352,7 @@ function ImageGallery({ projectId, galleryKey, images, loading, onImageUpdated,
<GalleryGridView
images={currentImages}
viewMode={viewMode}
thumbnailSize={thumbnailSize}
selectedImages={selectedImages}
reviewStatuses={reviewStatuses}
onImageClick={(imageId) => navigate(`/view/${imageId}?project=${projectId}&galleryKey=${encodeURIComponent(stateKey)}`)}
Expand Down
20 changes: 12 additions & 8 deletions frontend/src/components/__tests__/ImageGallery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,8 @@ describe('ImageGallery', () => {
await waitFor(() => {
const stored = JSON.parse(localStorage.getItem('gallery_state_test-project-id'));
expect(stored).toMatchObject({
viewMode: 'medium',
viewMode: 'grid',
thumbnailSize: 220,
sortBy: 'date',
searchField: 'filename',
searchValue: '',
Expand Down Expand Up @@ -758,14 +759,16 @@ describe('ImageGallery', () => {
});
});

test('persists view mode change to localStorage', async () => {
test('persists thumbnail size change to localStorage', async () => {
renderImageGallery();

fireEvent.click(screen.getByTitle('Small thumbnails'));
const slider = screen.getByTitle('Adjust thumbnail size');
fireEvent.change(slider, { target: { value: '350' } });

await waitFor(() => {
const stored = JSON.parse(localStorage.getItem('gallery_state_test-project-id'));
expect(stored.viewMode).toBe('small');
expect(stored.thumbnailSize).toBe(350);
expect(stored.viewMode).toBe('grid');
});
});

Expand All @@ -783,7 +786,8 @@ describe('ImageGallery', () => {

test('restores saved state from localStorage on mount', () => {
localStorage.setItem('gallery_state_test-project-id', JSON.stringify({
viewMode: 'large',
viewMode: 'grid',
thumbnailSize: 300,
sortBy: 'name',
searchField: 'content_type',
searchValue: 'png',
Expand All @@ -809,9 +813,9 @@ describe('ImageGallery', () => {
const reviewSelect = screen.getByTitle('Filter by review status');
expect(reviewSelect.value).toBe('pass');

// Large view mode button should be active
const largeBtn = screen.getByTitle('Large thumbnails');
expect(largeBtn).toHaveAttribute('aria-pressed', 'true');
// Thumbnail size slider should reflect stored size
const slider = screen.getByTitle('Adjust thumbnail size');
expect(slider.value).toBe('300');
});

test('uses galleryKey for localStorage when provided', async () => {
Expand Down
Loading
Loading