From 28c56b39facbdc9ed52f2b05e1c7fd046c109048 Mon Sep 17 00:00:00 2001 From: NikitaZavartsev Date: Thu, 19 Feb 2026 14:58:20 +0100 Subject: [PATCH 1/6] feat: downscale images on gallery import --- src/components/CameraScreen.tsx | 2 +- src/components/DetailsScreen.tsx | 2 +- src/utils/camera.ts | 82 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/components/CameraScreen.tsx b/src/components/CameraScreen.tsx index 93b5609..40d12be 100644 --- a/src/components/CameraScreen.tsx +++ b/src/components/CameraScreen.tsx @@ -274,7 +274,7 @@ export const CameraScreen: React.FC = ({ return; } - const imageData = await fileUtils.fileToBase64(file); + const imageData = await fileUtils.scaleImageFile(file); if (!imageData || imageData.length < 100) { throw new Error('Invalid image data generated'); diff --git a/src/components/DetailsScreen.tsx b/src/components/DetailsScreen.tsx index 7378ebe..2ca6606 100644 --- a/src/components/DetailsScreen.tsx +++ b/src/components/DetailsScreen.tsx @@ -87,7 +87,7 @@ export const DetailsScreen: React.FC = ({ const handleImageChange = async (file: File) => { try { - const imageData = await fileUtils.fileToBase64(file); + const imageData = await fileUtils.scaleImageFile(file); setEditedExposure(prev => ({ ...prev, imageData })); } catch (error) { console.error('Error processing image:', error); diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 46f6aa8..96e4192 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -182,6 +182,88 @@ export const geolocation = { }; export const fileUtils = { + // Scale and compress image file (for gallery uploads) + scaleImageFile: (file: File): Promise => { + return new Promise((resolve, reject) => { + // Validate file first + if (!file || !(file instanceof File)) { + reject(new Error('Invalid file provided')); + return; + } + + // Check file size (limit to 10MB for mobile compatibility) + const maxSize = 10 * 1024 * 1024; // 10MB + if (file.size > maxSize) { + reject(new Error('File too large. Please select a smaller image.')); + return; + } + + // Check file type + if (!file.type.startsWith('image/')) { + reject(new Error('Please select an image file.')); + return; + } + + // Create image element to load file + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + try { + URL.revokeObjectURL(url); // Clean up + + // Same scaling logic as camera.captureImage() + const maxSize = 1280; + let canvasWidth = img.width; + let canvasHeight = img.height; + + if (img.width > maxSize || img.height > maxSize) { + const aspectRatio = img.width / img.height; + if (img.width > img.height) { + canvasWidth = maxSize; + canvasHeight = maxSize / aspectRatio; + } else { + canvasHeight = maxSize; + canvasWidth = maxSize * aspectRatio; + } + } + + const canvas = document.createElement('canvas'); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Unable to create canvas context')); + return; + } + + ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight); + + // Same quality calculation as camera.captureImage() + const quality = canvasWidth * canvasHeight > 640 * 480 ? 0.7 : 0.8; + const dataURL = canvas.toDataURL('image/jpeg', quality); + + if (!dataURL || dataURL.length < 100) { + reject(new Error('Failed to generate image data')); + return; + } + + resolve(dataURL); + } catch (error) { + reject(error); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load image file')); + }; + + img.src = url; + }); + }, + // Convert file to base64 with mobile-specific handling fileToBase64: (file: File): Promise => { return new Promise((resolve, reject) => { From 344f6e81eef2602421b85d8a7701510c33ab250d Mon Sep 17 00:00:00 2001 From: NikitaZavartsev Date: Thu, 19 Feb 2026 14:58:32 +0100 Subject: [PATCH 2/6] test: downscale images on gallery import --- e2e/image-upload.spec.ts | 165 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 e2e/image-upload.spec.ts diff --git a/e2e/image-upload.spec.ts b/e2e/image-upload.spec.ts new file mode 100644 index 0000000..8f3fdaa --- /dev/null +++ b/e2e/image-upload.spec.ts @@ -0,0 +1,165 @@ +import { test, expect } from './fixtures/test-fixtures'; +import { TEST_DATA } from './utils/test-data'; + +/** + * Image Upload Tests + * Tests gallery image upload functionality and image scaling + */ +test.describe('Image Upload and Scaling', () => { + + test('should upload image from gallery and create exposure', async ({ filmTrackerPage, cleanApp, page }) => { + // Create film roll + await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic); + + // Verify we're in camera screen + await expect(filmTrackerPage.cameraButton).toBeVisible(); + await expect(filmTrackerPage.page.getByText(/1\/36/)).toBeVisible(); + + // Create a test image file (small 10x10 red square) + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC', + 'base64' + ); + + // Set up file chooser listener before clicking gallery button + const fileChooserPromise = page.waitForEvent('filechooser'); + + // Click gallery button to trigger file picker + await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + + const fileChooser = await fileChooserPromise; + + // Upload the test image + await fileChooser.setFiles({ + name: 'test-image.png', + mimeType: 'image/png', + buffer: testImageBuffer + }); + + // Wait for exposure to be created and verify exposure count increased + await expect(filmTrackerPage.page.getByText(/2\/36/)).toBeVisible({ timeout: 5000 }); + + // Navigate to gallery to verify the exposure was created + await filmTrackerPage.galleryButton.click(); + await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible(); + }); + + test('should upload multiple images from gallery', async ({ filmTrackerPage, cleanApp, page }) => { + // Create film roll + await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic); + + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC', + 'base64' + ); + + // Upload first image + const fileChooserPromise1 = page.waitForEvent('filechooser'); + await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + const fileChooser1 = await fileChooserPromise1; + await fileChooser1.setFiles({ + name: 'test-image-1.png', + mimeType: 'image/png', + buffer: testImageBuffer + }); + await expect(filmTrackerPage.page.getByText(/2\/36/)).toBeVisible({ timeout: 5000 }); + + // Upload second image + const fileChooserPromise2 = page.waitForEvent('filechooser'); + await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + const fileChooser2 = await fileChooserPromise2; + await fileChooser2.setFiles({ + name: 'test-image-2.png', + mimeType: 'image/png', + buffer: testImageBuffer + }); + await expect(filmTrackerPage.page.getByText(/3\/36/)).toBeVisible({ timeout: 5000 }); + + // Verify both exposures in gallery + await filmTrackerPage.galleryButton.click(); + await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible(); + await expect(filmTrackerPage.page.getByText(/#2/)).toBeVisible(); + }); + + test('should replace image in exposure details', async ({ filmTrackerPage, cleanApp, page }) => { + // Create film roll and upload initial image + await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic); + + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC', + 'base64' + ); + + // Upload first image + const fileChooserPromise1 = page.waitForEvent('filechooser'); + await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + const fileChooser1 = await fileChooserPromise1; + await fileChooser1.setFiles({ + name: 'original.png', + mimeType: 'image/png', + buffer: testImageBuffer + }); + await expect(filmTrackerPage.page.getByText(/2\/36/)).toBeVisible({ timeout: 5000 }); + + // Navigate to gallery + await filmTrackerPage.galleryButton.click(); + await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible(); + + // Click on exposure to open details + await filmTrackerPage.page.getByText(/#1/).click(); + await expect(filmTrackerPage.page.getByText(/Exposure #1/)).toBeVisible(); + + // Click edit button + await filmTrackerPage.page.getByRole('button', { name: /edit/i }).click(); + + // Click gallery button in the image overlay + const fileChooserPromise2 = page.waitForEvent('filechooser'); + await filmTrackerPage.page.getByRole('button', { name: /photo library/i }).click(); + const fileChooser2 = await fileChooserPromise2; + + // Upload replacement image + await fileChooser2.setFiles({ + name: 'replacement.png', + mimeType: 'image/png', + buffer: testImageBuffer + }); + + // Wait a moment for the image to be processed + await page.waitForTimeout(500); + + // Save changes + await filmTrackerPage.page.getByRole('button', { name: /save/i }).click(); + + // Verify we're still on details screen (image was replaced successfully) + await expect(filmTrackerPage.page.getByText(/Exposure #1/)).toBeVisible(); + }); + + test('should handle large image upload gracefully', async ({ filmTrackerPage, cleanApp, page }) => { + // Create film roll + await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic); + + // Create a larger test image (100x100 PNG) + // This is still small for testing, but larger than the 10x10 used in other tests + const largeImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAXklEQVR42u3BAQ0AAADCoPdPbQ8HFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwZMgAAAenKbBIAAAAASUVORK5CYII=', + 'base64' + ); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + const fileChooser = await fileChooserPromise; + + await fileChooser.setFiles({ + name: 'large-test-image.png', + mimeType: 'image/png', + buffer: largeImageBuffer + }); + + // Should successfully create exposure even with larger image + await expect(filmTrackerPage.page.getByText(/2\/36/)).toBeVisible({ timeout: 5000 }); + + // Verify in gallery + await filmTrackerPage.galleryButton.click(); + await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible(); + }); +}); From 534ab77b89305e4cd4c65a74755c4b27935b7210 Mon Sep 17 00:00:00 2001 From: NikitaZavartsev Date: Thu, 19 Feb 2026 14:58:56 +0100 Subject: [PATCH 3/6] docs: add test instructions to claude.md --- CLAUDE.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dc62020..77a954e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,21 @@ See `.llm/procedure/` for workflows (e.g., `import-tasks-github.md`) ## Coding Best Practices +### Testing Requirements +**CRITICAL:** Before implementing any feature: +1. **Run existing tests:** `npm run test:e2e` - All tests must pass +2. **Add tests for new features:** Create focused Playwright tests in `e2e/` +3. **Regression testing:** New implementations shouldn't break old tests unless it's a complete change of functionality +4. **Test file naming:** Match functionality (e.g., `image-upload.spec.ts` for image upload features) +5. **Keep tests focused:** Each test should verify one specific behavior + +**When to add tests:** +- New user-facing features (camera, gallery, import/export) +- Critical workflows (photo capture, data persistence) +- Bug fixes (prevent regression) + +**Test structure:** Use page objects (`e2e/utils/page-objects.ts`), test data generators (`e2e/utils/test-data.ts`), and fixtures. + ### Component Composition **DO:** Use shared from `src/components/common/` (DialogHeader, EmptyStateDisplay, ConfirmationDialog, EntityContextMenu, LensSelector, ApertureSelector, ShutterSpeedSelector). Extract when pattern appears 2+ times. @@ -98,14 +113,14 @@ npm run version:{patch|minor|major} ### Testing ```bash -npm run test:e2e # 80 tests across 5 browsers +npm run test:e2e # All E2E tests across 5 browsers npm run test:e2e:ui # Interactive mode npm run test:e2e:headed # See browser npm run test:e2e:debug # Inspector npm run test:e2e:report # HTML report ``` -**Test files:** app-navigation, camera-management, film-roll-management, photography-workflow +**Test files:** app-navigation, camera-management, film-roll-management, photography-workflow, lens-management, import-export, image-upload **Browsers:** Chromium, Firefox, WebKit, Mobile Chrome, Mobile Safari **Page Objects:** `e2e/utils/page-objects.ts` From 3ec1d584ed8a5bbbd54aa7ae3ca0f93fd1f0d86b Mon Sep 17 00:00:00 2001 From: NikitaZavartsev Date: Thu, 19 Feb 2026 16:09:19 +0100 Subject: [PATCH 4/6] feat: remove gallery button from camera screen, add Add from gallery to gallery screen --- e2e/image-upload.spec.ts | 56 ++--- e2e/utils/page-objects.ts | 4 + src/App.tsx | 3 + src/components/CameraScreen.tsx | 88 -------- src/components/GalleryScreen.tsx | 342 ++++++++++--------------------- 5 files changed, 139 insertions(+), 354 deletions(-) diff --git a/e2e/image-upload.spec.ts b/e2e/image-upload.spec.ts index 8f3fdaa..2ece065 100644 --- a/e2e/image-upload.spec.ts +++ b/e2e/image-upload.spec.ts @@ -15,17 +15,21 @@ test.describe('Image Upload and Scaling', () => { await expect(filmTrackerPage.cameraButton).toBeVisible(); await expect(filmTrackerPage.page.getByText(/1\/36/)).toBeVisible(); + // Navigate to gallery screen + await filmTrackerPage.galleryButton.click(); + await expect(filmTrackerPage.page.getByText(/No exposures yet/)).toBeVisible(); + // Create a test image file (small 10x10 red square) const testImageBuffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC', 'base64' ); - // Set up file chooser listener before clicking gallery button + // Set up file chooser listener before clicking Add From Gallery button const fileChooserPromise = page.waitForEvent('filechooser'); - // Click gallery button to trigger file picker - await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + // Click Add From Gallery button to trigger file picker + await filmTrackerPage.addFromGalleryButton.click(); const fileChooser = await fileChooserPromise; @@ -36,18 +40,17 @@ test.describe('Image Upload and Scaling', () => { buffer: testImageBuffer }); - // Wait for exposure to be created and verify exposure count increased - await expect(filmTrackerPage.page.getByText(/2\/36/)).toBeVisible({ timeout: 5000 }); - - // Navigate to gallery to verify the exposure was created - await filmTrackerPage.galleryButton.click(); - await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible(); + // Wait for exposure to be created and verify it appears in the gallery + await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible({ timeout: 5000 }); }); test('should upload multiple images from gallery', async ({ filmTrackerPage, cleanApp, page }) => { // Create film roll await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic); + // Navigate to gallery screen + await filmTrackerPage.galleryButton.click(); + const testImageBuffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC', 'base64' @@ -55,36 +58,38 @@ test.describe('Image Upload and Scaling', () => { // Upload first image const fileChooserPromise1 = page.waitForEvent('filechooser'); - await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + await filmTrackerPage.addFromGalleryButton.click(); const fileChooser1 = await fileChooserPromise1; await fileChooser1.setFiles({ name: 'test-image-1.png', mimeType: 'image/png', buffer: testImageBuffer }); - await expect(filmTrackerPage.page.getByText(/2\/36/)).toBeVisible({ timeout: 5000 }); + await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible({ timeout: 5000 }); // Upload second image const fileChooserPromise2 = page.waitForEvent('filechooser'); - await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + await filmTrackerPage.addFromGalleryButton.click(); const fileChooser2 = await fileChooserPromise2; await fileChooser2.setFiles({ name: 'test-image-2.png', mimeType: 'image/png', buffer: testImageBuffer }); - await expect(filmTrackerPage.page.getByText(/3\/36/)).toBeVisible({ timeout: 5000 }); + await expect(filmTrackerPage.page.getByText(/#2/)).toBeVisible({ timeout: 5000 }); - // Verify both exposures in gallery - await filmTrackerPage.galleryButton.click(); + // Verify both exposures are visible in gallery await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible(); await expect(filmTrackerPage.page.getByText(/#2/)).toBeVisible(); }); test('should replace image in exposure details', async ({ filmTrackerPage, cleanApp, page }) => { - // Create film roll and upload initial image + // Create film roll await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic); + // Navigate to gallery screen + await filmTrackerPage.galleryButton.click(); + const testImageBuffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC', 'base64' @@ -92,18 +97,14 @@ test.describe('Image Upload and Scaling', () => { // Upload first image const fileChooserPromise1 = page.waitForEvent('filechooser'); - await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + await filmTrackerPage.addFromGalleryButton.click(); const fileChooser1 = await fileChooserPromise1; await fileChooser1.setFiles({ name: 'original.png', mimeType: 'image/png', buffer: testImageBuffer }); - await expect(filmTrackerPage.page.getByText(/2\/36/)).toBeVisible({ timeout: 5000 }); - - // Navigate to gallery - await filmTrackerPage.galleryButton.click(); - await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible(); + await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible({ timeout: 5000 }); // Click on exposure to open details await filmTrackerPage.page.getByText(/#1/).click(); @@ -138,6 +139,9 @@ test.describe('Image Upload and Scaling', () => { // Create film roll await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic); + // Navigate to gallery screen + await filmTrackerPage.galleryButton.click(); + // Create a larger test image (100x100 PNG) // This is still small for testing, but larger than the 10x10 used in other tests const largeImageBuffer = Buffer.from( @@ -146,7 +150,7 @@ test.describe('Image Upload and Scaling', () => { ); const fileChooserPromise = page.waitForEvent('filechooser'); - await filmTrackerPage.page.getByRole('button', { name: /gallery/i }).first().click(); + await filmTrackerPage.addFromGalleryButton.click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles({ @@ -156,10 +160,6 @@ test.describe('Image Upload and Scaling', () => { }); // Should successfully create exposure even with larger image - await expect(filmTrackerPage.page.getByText(/2\/36/)).toBeVisible({ timeout: 5000 }); - - // Verify in gallery - await filmTrackerPage.galleryButton.click(); - await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible(); + await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible({ timeout: 5000 }); }); }); diff --git a/e2e/utils/page-objects.ts b/e2e/utils/page-objects.ts index 3b74b21..413fe7f 100644 --- a/e2e/utils/page-objects.ts +++ b/e2e/utils/page-objects.ts @@ -127,6 +127,10 @@ export class FilmTrackerPage { return this.page.getByRole('button', { name: /view gallery/i }); } + get addFromGalleryButton() { + return this.page.getByRole('button', { name: /add from gallery/i }); + } + get apertureChip() { return this.page.locator('.MuiChip-root').filter({ hasText: /f\// }).first(); } diff --git a/src/App.tsx b/src/App.tsx index 9965d3a..153c646 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -477,6 +477,9 @@ function App() { onBack={() => navigateToScreen('camera')} onHome={() => navigateToScreen('filmrolls')} onDataImported={handleDataImported} + onExposureTaken={handleExposureTaken} + currentSettings={exposureSettings} + setCurrentSettings={setExposureSettings} /> ); diff --git a/src/components/CameraScreen.tsx b/src/components/CameraScreen.tsx index 40d12be..287d27f 100644 --- a/src/components/CameraScreen.tsx +++ b/src/components/CameraScreen.tsx @@ -73,7 +73,6 @@ export const CameraScreen: React.FC = ({ setCurrentSettings }) => { const videoRef = useRef(null); - const fileInputRef = useRef(null); const streamRef = useRef(null); const [isCameraActive, setIsCameraActive] = useState(false); @@ -246,76 +245,6 @@ export const CameraScreen: React.FC = ({ } }; - const handleFileSelect = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - - // Clear the input immediately to allow reselection of the same file - const target = event.target; - setTimeout(() => { - target.value = ''; - }, 100); - - if (!file) { - console.log('No file selected'); - return; - } - - console.log('Processing file:', file.name, 'Size:', file.size, 'Type:', file.type); - - try { - // Validate file before processing - if (!file.type.startsWith('image/')) { - alert('Please select an image file'); - return; - } - - if (file.size > 10 * 1024 * 1024) { // 10MB limit - alert('Image too large. Please select a smaller image.'); - return; - } - - const imageData = await fileUtils.scaleImageFile(file); - - if (!imageData || imageData.length < 100) { - throw new Error('Invalid image data generated'); - } - - console.log('Image processed successfully, size:', imageData.length); - - const location = geolocation.isSupported() - ? await geolocation.getCurrentPosition().catch((err) => { - console.warn('Location not available:', err); - return undefined; - }) - : undefined; - - const exposure: Exposure = { - id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // More unique ID - filmRollId: filmRoll.id, - exposureNumber: currentExposureNumber, - aperture: currentSettings.aperture, - shutterSpeed: currentSettings.shutterSpeed, - additionalInfo: currentSettings.additionalInfo, - imageData, - location, - capturedAt: new Date(), - ei: currentSettings.ei, - lensId: currentSettings.lensId, - focalLength: currentSettings.focalLength - }; - - console.log('Creating exposure:', exposure.id); - onExposureTaken(exposure); - - // Reset additional info for next shot - setCurrentSettings(prev => ({ ...prev, additionalInfo: '' })); - - } catch (error) { - console.error('Error processing file:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - alert(`Error processing selected image: ${errorMessage}`); - } - }; const openSettingsDialog = () => { setShowSettingsDialog(true); @@ -632,14 +561,6 @@ getUserMedia: ${!!navigator.mediaDevices?.getUserMedia} {/* Controls */} - - - {/* Hidden file input */} - - {/* Settings Dialog */} setShowSettingsDialog(false)} maxWidth="sm" fullWidth> diff --git a/src/components/GalleryScreen.tsx b/src/components/GalleryScreen.tsx index 8aca90f..4cf32b7 100644 --- a/src/components/GalleryScreen.tsx +++ b/src/components/GalleryScreen.tsx @@ -28,8 +28,7 @@ import { LocationOn, AccessTime, CloudUpload, - CloudDownload, - FolderOpen, + PhotoLibrary, Save, Share, Close, @@ -39,7 +38,7 @@ import { } from '@mui/icons-material'; import type { Exposure, FilmRoll, Lens } from '../types'; import { exportUtils, googleDriveUtils } from '../utils/exportImport'; -import { storage } from '../utils/storage'; +import { geolocation, fileUtils } from '../utils/camera'; import { colors } from '../theme'; interface GalleryScreenProps { @@ -52,6 +51,9 @@ interface GalleryScreenProps { onBack: () => void; onHome?: () => void; onDataImported?: (filmRoll: FilmRoll, exposures: Exposure[]) => void; + onExposureTaken: (exposure: Exposure) => void; + currentSettings: import('../types').ExposureSettings; + setCurrentSettings: React.Dispatch>; } export const GalleryScreen: React.FC = ({ @@ -63,21 +65,93 @@ export const GalleryScreen: React.FC = ({ onExposureUpdate, onBack, onHome, - onDataImported + onDataImported, + onExposureTaken, + currentSettings, + setCurrentSettings }) => { const [showExportDialog, setShowExportDialog] = useState(false); - const [showImportDialog, setShowImportDialog] = useState(false); const [exportFolderName, setExportFolderName] = useState(''); - const [importFolderName, setImportFolderName] = useState(''); const [exportMethod, setExportMethod] = useState<'local' | 'googledrive' | 'jsononly' | 'jsonwithimages'>('local'); - const [importMethod, setImportMethod] = useState<'local' | 'googledrive' | 'jsonwithimages'>('local'); const [isProcessing, setIsProcessing] = useState(false); - const fileInputRef = useRef(null); - const jsonWithImagesInputRef = useRef(null); + const galleryInputRef = useRef(null); const filmExposures = exposures.filter(exposure => exposure.filmRollId === filmRoll.id) .sort((a, b) => a.exposureNumber - b.exposureNumber); + const currentExposureNumber = filmExposures.length + 1; + + const handleGallerySelect = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + // Clear the input immediately to allow reselection of the same file + const target = event.target; + setTimeout(() => { + target.value = ''; + }, 100); + + if (!file) { + console.log('No file selected'); + return; + } + + console.log('Processing file:', file.name, 'Size:', file.size, 'Type:', file.type); + + try { + // Validate file before processing + if (!file.type.startsWith('image/')) { + alert('Please select an image file'); + return; + } + + if (file.size > 10 * 1024 * 1024) { // 10MB limit + alert('Image too large. Please select a smaller image.'); + return; + } + + const imageData = await fileUtils.scaleImageFile(file); + + if (!imageData || imageData.length < 100) { + throw new Error('Invalid image data generated'); + } + + console.log('Image processed successfully, size:', imageData.length); + + const location = geolocation.isSupported() + ? await geolocation.getCurrentPosition().catch((err) => { + console.warn('Location not available:', err); + return undefined; + }) + : undefined; + + const exposure: Exposure = { + id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + filmRollId: filmRoll.id, + exposureNumber: currentExposureNumber, + aperture: currentSettings.aperture, + shutterSpeed: currentSettings.shutterSpeed, + additionalInfo: currentSettings.additionalInfo, + imageData, + location, + capturedAt: new Date(), + ei: currentSettings.ei, + lensId: currentSettings.lensId, + focalLength: currentSettings.focalLength + }; + + console.log('Creating exposure:', exposure.id); + onExposureTaken(exposure); + + // Reset additional info for next shot + setCurrentSettings(prev => ({ ...prev, additionalInfo: '' })); + + } catch (error) { + console.error('Error processing file:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + alert(`Error processing selected image: ${errorMessage}`); + } + }; + const handleCopyFromPrevious = async (currentExposure: Exposure, previousExposure: Exposure) => { if (!onExposureUpdate) return; @@ -126,104 +200,6 @@ export const GalleryScreen: React.FC = ({ } }; - const handleImport = async () => { - setIsProcessing(true); - try { - let result: { filmRoll: FilmRoll; exposures: Exposure[] } | null = null; - - if (importMethod === 'googledrive') { - if (!importFolderName.trim()) { - alert('Please enter a folder name'); - setIsProcessing(false); - return; - } - result = await googleDriveUtils.importFromGoogleDrive(importFolderName); - } else if (importMethod === 'jsonwithimages') { - // Trigger file input for JSON with images - jsonWithImagesInputRef.current?.click(); - setIsProcessing(false); - return; - } else { - // Trigger file input for local multi-file import - fileInputRef.current?.click(); - setIsProcessing(false); - return; - } - - if (result && onDataImported) { - // Save imported data - await storage.saveFilmRoll(result.filmRoll); - for (const exposure of result.exposures) { - await storage.saveExposure(exposure); - } - - onDataImported(result.filmRoll, result.exposures); - setShowImportDialog(false); - setImportFolderName(''); - } - } catch (error) { - console.error('Import failed:', error); - alert('Import failed. Please try again.'); - } finally { - setIsProcessing(false); - } - }; - - const handleFileImport = async (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; - - setIsProcessing(true); - try { - const result = await exportUtils.importFromLocal(files); - if (result && onDataImported) { - // Save imported data - await storage.saveFilmRoll(result.filmRoll); - for (const exposure of result.exposures) { - await storage.saveExposure(exposure); - } - - onDataImported(result.filmRoll, result.exposures); - setShowImportDialog(false); - } - } catch (error) { - console.error('Import failed:', error); - alert('Import failed. Please try again.'); - } finally { - setIsProcessing(false); - } - - // Clear the input - event.target.value = ''; - }; - - const handleJsonWithImagesFileSelect = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - try { - const result = await exportUtils.importJsonWithImages(file); - - if (result && onDataImported) { - // Save imported data - await storage.saveFilmRoll(result.filmRoll); - for (const exposure of result.exposures) { - await storage.saveExposure(exposure); - } - - onDataImported(result.filmRoll, result.exposures); - setShowImportDialog(false); - alert(`Successfully imported film roll: ${result.filmRoll.name}\nExposures: ${result.exposures.length}`); - } - } catch (error) { - console.error('Import failed:', error); - alert('Import failed. Please check the file and try again.'); - } finally { - // Reset file input - event.target.value = ''; - } - }; - const handleDeleteExposure = (exposureId: string, exposureNumber: number) => { const confirmed = window.confirm( `Are you sure you want to delete exposure #${exposureNumber}?\n\n` + @@ -280,18 +256,26 @@ export const GalleryScreen: React.FC = ({ - {/* Import Button for Empty State */} + {/* Add From Gallery Button for Empty State */} + + {/* Hidden file input for gallery picker */} + ); } @@ -352,16 +336,15 @@ export const GalleryScreen: React.FC = ({ )} - {/* Import/Export Buttons */} + {/* Add From Gallery/Export Buttons */} - - {/* Import Dialog */} - setShowImportDialog(false)} maxWidth="sm" fullWidth> - - - Import Film Data - setShowImportDialog(false)}> - - - - - - - - Import Method - setImportMethod(e.target.value as 'local' | 'googledrive' | 'jsonwithimages')} - > - } - label={ - - Local Files - - Select files from your device - - - } - /> - } - label={ - - Import JSON with Images - - Select single JSON file with embedded images - - - } - /> - } - label={ - - Google Drive - - Import from Google Drive folder (requires setup) - - - } - /> - - - Local Files: Select metadata.json + image files
- JSON with Images: Select single JSON file with embedded images
- Google Drive: Requires API setup -
-
- - {importMethod !== 'jsonwithimages' && ( - <> - {importMethod === 'googledrive' && ( - setImportFolderName(e.target.value)} - placeholder="Enter Google Drive folder name" - helperText="Name of the folder in Google Drive containing the exported data" - /> - )} - - {importMethod === 'local' && ( - - - Click "Import" to select the metadata.json file and all image files from your exported folder. - - - )} - - )} - - {importMethod === 'jsonwithimages' && ( - - - Click "Import" to select a JSON file exported with the "JSON with Images" option. - - - )} -
-
- - - - -
); }; \ No newline at end of file From a769872afa4c6ec53d28b743225ec805553a43a9 Mon Sep 17 00:00:00 2001 From: NikitaZavartsev Date: Thu, 19 Feb 2026 17:59:09 +0100 Subject: [PATCH 5/6] fix: scroll position preservation --- src/App.tsx | 1 - src/components/CameraScreen.tsx | 2 +- src/components/GalleryScreen.tsx | 33 +++++++++++++++++++++++++++----- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 153c646..289046b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -476,7 +476,6 @@ function App() { onExposureUpdate={handleExposureUpdate} onBack={() => navigateToScreen('camera')} onHome={() => navigateToScreen('filmrolls')} - onDataImported={handleDataImported} onExposureTaken={handleExposureTaken} currentSettings={exposureSettings} setCurrentSettings={setExposureSettings} diff --git a/src/components/CameraScreen.tsx b/src/components/CameraScreen.tsx index 287d27f..bbe2ece 100644 --- a/src/components/CameraScreen.tsx +++ b/src/components/CameraScreen.tsx @@ -26,7 +26,7 @@ import { Close, ArrowBack } from '@mui/icons-material'; -import { camera, geolocation, fileUtils } from '../utils/camera'; +import { camera, geolocation } from '../utils/camera'; import type { FilmRoll, Exposure, ExposureSettings, Lens } from '../types'; import { APERTURE, APERTURE_VALUES, SHUTTER_SPEED, SHUTTER_SPEED_VALUES, EI_VALUES } from '../types'; import { FocalLengthSlider } from './FocalLengthSlider'; diff --git a/src/components/GalleryScreen.tsx b/src/components/GalleryScreen.tsx index 4cf32b7..8c3fc2c 100644 --- a/src/components/GalleryScreen.tsx +++ b/src/components/GalleryScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { Box, Container, @@ -50,7 +50,6 @@ interface GalleryScreenProps { onExposureUpdate?: (exposure: Exposure) => void; onBack: () => void; onHome?: () => void; - onDataImported?: (filmRoll: FilmRoll, exposures: Exposure[]) => void; onExposureTaken: (exposure: Exposure) => void; currentSettings: import('../types').ExposureSettings; setCurrentSettings: React.Dispatch>; @@ -65,7 +64,6 @@ export const GalleryScreen: React.FC = ({ onExposureUpdate, onBack, onHome, - onDataImported, onExposureTaken, currentSettings, setCurrentSettings @@ -75,10 +73,35 @@ export const GalleryScreen: React.FC = ({ const [exportMethod, setExportMethod] = useState<'local' | 'googledrive' | 'jsononly' | 'jsonwithimages'>('local'); const [isProcessing, setIsProcessing] = useState(false); const galleryInputRef = useRef(null); + const scrollContainerRef = useRef(null); const filmExposures = exposures.filter(exposure => exposure.filmRollId === filmRoll.id) .sort((a, b) => a.exposureNumber - b.exposureNumber); + // Restore scroll position on mount + useEffect(() => { + const scrollKey = `gallery-scroll-${filmRoll.id}`; + const savedScrollPos = sessionStorage.getItem(scrollKey); + + if (savedScrollPos && scrollContainerRef.current) { + // Use setTimeout to ensure DOM is fully rendered + setTimeout(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = parseInt(savedScrollPos, 10); + } + }, 0); + } + }, [filmRoll.id]); + + const handleExposureSelect = (exposure: Exposure) => { + // Save scroll position before navigating + if (scrollContainerRef.current) { + const scrollKey = `gallery-scroll-${filmRoll.id}`; + sessionStorage.setItem(scrollKey, scrollContainerRef.current.scrollTop.toString()); + } + onExposureSelect(exposure); + }; + const currentExposureNumber = filmExposures.length + 1; const handleGallerySelect = async (event: React.ChangeEvent) => { @@ -358,7 +381,7 @@ export const GalleryScreen: React.FC = ({ {/* Exposures Grid */} - + {filmExposures.map((exposure, index) => { const previousExposure = index > 0 ? filmExposures[index - 1] : null; @@ -380,7 +403,7 @@ export const GalleryScreen: React.FC = ({ )} onExposureSelect(exposure)} + onClick={() => handleExposureSelect(exposure)} sx={{ cursor: 'pointer', position: 'relative', From 833ea86bbd2d7e1f77023ad48641ebe437b6c07a Mon Sep 17 00:00:00 2001 From: NikitaZavartsev Date: Thu, 19 Feb 2026 18:17:15 +0100 Subject: [PATCH 6/6] test: fix tests --- e2e/image-upload.spec.ts | 8 +++++--- e2e/utils/page-objects.ts | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/e2e/image-upload.spec.ts b/e2e/image-upload.spec.ts index 2ece065..00f4af7 100644 --- a/e2e/image-upload.spec.ts +++ b/e2e/image-upload.spec.ts @@ -110,12 +110,14 @@ test.describe('Image Upload and Scaling', () => { await filmTrackerPage.page.getByText(/#1/).click(); await expect(filmTrackerPage.page.getByText(/Exposure #1/)).toBeVisible(); - // Click edit button - await filmTrackerPage.page.getByRole('button', { name: /edit/i }).click(); + // Click edit button (first icon button in the header actions area) + const editButton = filmTrackerPage.page.locator('button:has(svg[data-testid="EditIcon"])'); + await editButton.click(); // Click gallery button in the image overlay const fileChooserPromise2 = page.waitForEvent('filechooser'); - await filmTrackerPage.page.getByRole('button', { name: /photo library/i }).click(); + const galleryButton = filmTrackerPage.page.locator('button:has(svg[data-testid="PhotoLibraryIcon"])'); + await galleryButton.click(); const fileChooser2 = await fileChooserPromise2; // Upload replacement image diff --git a/e2e/utils/page-objects.ts b/e2e/utils/page-objects.ts index 413fe7f..c6229ee 100644 --- a/e2e/utils/page-objects.ts +++ b/e2e/utils/page-objects.ts @@ -180,6 +180,10 @@ export class FilmTrackerPage { camera?: string; }) { await this.createFilmRollButton.click(); + + // Wait for the dialog/form to be visible + await this.filmNameInput.waitFor({ state: 'visible', timeout: 5000 }); + await this.filmNameInput.fill(data.name); if (data.iso) {