From 2155c022fec75105647abe24c4b0d5f6b3637a07 Mon Sep 17 00:00:00 2001 From: NikitaZavartsev Date: Mon, 16 Feb 2026 14:28:11 +0100 Subject: [PATCH 1/4] feat: add lenses, add EI, add focal length --- CLAUDE.md | 273 ++++++++++++++++ apply_filmroll_metadata.py | 21 +- src/App.tsx | 76 ++++- src/components/CameraManagementScreen.tsx | 4 +- src/components/CameraScreen.tsx | 304 ++++++++++++++++-- src/components/DetailsScreen.tsx | 137 ++++++-- src/components/FilmRollListScreen.tsx | 6 +- src/components/GalleryScreen.tsx | 86 ++++- src/components/LensManagementScreen.tsx | 373 ++++++++++++++++++++++ src/components/MainScreen.tsx | 30 +- src/components/SetupScreen.tsx | 79 ++++- src/types.ts | 32 +- src/utils/exportImport.ts | 63 ++-- src/utils/indexedDBStorage.ts | 36 ++- src/utils/storage.ts | 34 +- 15 files changed, 1455 insertions(+), 99 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/components/LensManagementScreen.tsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f468430 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,273 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Film Photography Tracker is a Progressive Web App (PWA) for tracking film photography metadata. Built with React 18, TypeScript, Vite, and Material-UI, it helps photographers record and organize shots with exposure details, location data, and camera information. + +## Key Commands + +### Development +```bash +npm run dev # Start development server (http://localhost:5173 or https if certs exist) +npm run build # Build for production (outputs to dist/) +npm run preview # Preview production build +npm run lint # Run ESLint +``` + +### Version Management +```bash +npm run version:patch # Bump patch version +npm run version:minor # Bump minor version +npm run version:major # Bump major version +``` + +### Testing +```bash +npm run test:e2e # Run Playwright E2E tests +npm run test:e2e:ui # Run E2E tests with Playwright UI +npm run test:e2e:headed # Run E2E tests in headed mode +npm run test:e2e:debug # Debug E2E tests +npm run test:e2e:report # Show test report +``` + +### Python Metadata Script +```bash +python apply_filmroll_metadata.py # Apply film roll metadata to TIF files using exiftool +# Or on Windows: +apply.cmd # Windows wrapper for Python script +``` + +The Python script reads exported JSON metadata and applies it to scanned TIF files using exiftool. It matches exposures to TIF files in order and writes EXIF tags including: +- Aperture, shutter speed, ISO/EI +- GPS location (lat/long) +- Camera make/model +- **Lens info** (uses `lensName` from JSON if available) +- **Focal length** (uses exposure-specific value if set) +- Capture date/time +- User notes + +**v2.0.0 Support**: Script now uses EI if set (overrides film ISO), lens name per exposure, and focal length per exposure. + +## Project Structure + +``` +src/ +├── components/ # React components +│ ├── MainScreen.tsx # Tabbed interface (Film Rolls & Cameras tabs) +│ ├── FilmRollListScreen.tsx # Film roll list and management +│ ├── CameraManagementScreen.tsx # Camera equipment management +│ ├── SetupScreen.tsx # Film roll configuration +│ ├── CameraScreen.tsx # Photo capture interface +│ ├── GalleryScreen.tsx # Exposure list view with import/export +│ ├── DetailsScreen.tsx # Individual exposure details/editing +│ ├── SettingsModal.tsx # App settings (Google Drive, etc.) +│ └── ItemCard.tsx # Reusable card component +├── utils/ # Utility functions +│ ├── storage.ts # Storage facade (async API wrapper) +│ ├── indexedDBStorage.ts # IndexedDB implementation with migration +│ ├── exportImport.ts # Export/Import functionality +│ ├── googleDriveService.ts # Google Drive integration (placeholder) +│ └── camera.ts # Camera, geolocation, and file utilities +├── types.ts # TypeScript type definitions +├── App.tsx # Main application component (state management) +└── main.tsx # React entry point +``` + +## Architecture + +### Storage System + +The app uses a **two-layer storage architecture**: + +1. **IndexedDB Layer** (`src/utils/indexedDBStorage.ts`) + - Primary storage with high capacity (50MB+) + - Object stores: filmRolls, exposures, cameras, settings, currentFilmRoll + - Handles automatic migration from localStorage on first run + - All date fields are serialized/deserialized properly + +2. **Storage Facade** (`src/utils/storage.ts`) + - Simple async API wrapper around IndexedDB + - No caching or fallbacks - pure IndexedDB operations + - All methods are async and return Promises + - Must call `storage.initialize()` before use (done in App.tsx on mount) + +**Important**: The app previously used localStorage but migrated to IndexedDB. The migration happens automatically on first load via `indexedDBStorage.migrateFromLocalStorage()`. + +### Application State + +State is managed in `App.tsx` using React hooks (no Redux or external state management): + +- **AppState** contains: currentFilmRoll, filmRolls, cameras, exposures, currentScreen, selectedExposure, settings +- Screen navigation via `currentScreen` enum: 'filmrolls' | 'setup' | 'camera' | 'gallery' | 'details' +- All storage operations go through async `storage` API and update local state on success +- Settings include Google Drive integration (currently disabled during storage migration) + +### Data Model (src/types.ts) + +Core entities: +- **Camera**: Equipment definition (make, model, lens, auto-generated name) +- **FilmRoll**: Film configuration (name, ISO, totalExposures, cameraId link) +- **Exposure**: Individual shots (filmRollId link, exposure number, aperture, shutter speed, location, imageData as base64, capturedAt timestamp) +- **AppSettings**: Google Drive settings, version + +### Screen Flow + +1. **MainScreen** - Tabbed interface with Film Rolls and Cameras management tabs +2. **SetupScreen** - Create new film roll with camera selection +3. **CameraScreen** - Photo capture with camera API or file picker, exposure settings chips (aperture, shutter, notes) +4. **GalleryScreen** - Grid view of all exposures for current film roll, import/export functionality +5. **DetailsScreen** - Full-screen photo view with editable metadata + +### PWA Configuration + +- Uses `vite-plugin-pwa` with **injectManifest** strategy (custom service worker at `public/sw.js`) +- **Manual update prompt** - registerType: 'prompt' prevents automatic updates to preserve user data +- Service worker uses NetworkFirst caching strategy for offline support +- Update notification shown in App.tsx via Snackbar with user confirmation +- HTTPS support via local certificates (localhost+4.pem) for camera API access + +### Import/Export System + +Located in `src/utils/exportImport.ts`: + +**Export Format (v2.0.0):** +- `metadata.json`: Contains film roll info, exposures array, exportedAt timestamp, version + - FilmRoll includes: `ei` (Exposure Index), `currentLensId` + - Each exposure includes: + - Core: aperture, shutterSpeed, additionalInfo, location, capturedAt + - **New in v2.0.0**: `ei`, `lensId`, `lensName` (denormalized), `focalLength` +- `exposure_X_ID.jpg`: Individual photos with numbered naming (X = exposure number) +- All files organized in a single folder for easy management + +**Export Methods:** +- **Local Download**: Downloads all files to device (staggered by 500ms to avoid browser limits) +- **JSON Only**: Exports metadata.json with Web Share API on mobile, regular download on desktop +- **Google Drive**: Placeholder implementation (requires API setup) + +**Import Methods:** +- **Local Files**: Select folder contents via file input, reads metadata.json and reconstructs exposures +- **Google Drive**: Placeholder implementation (requires API setup) + +**Important Details:** +- Images stored as base64 data URLs in IndexedDB +- Export creates separate files; import reads them back +- FileReader used for file-to-base64 conversion with 10MB size limit +- Dates converted between Date objects and ISO strings during export/import +- **Lens name denormalized** in export for easier external tool usage (Python script) + +## Key Features + +### Photo Capture & Settings +- Live camera view with MediaDevices API +- Fallback camera constraints (tries rear camera first, then front, then basic) +- Gallery file picker for existing photos +- Exposure settings: aperture (f/1.4 to f/22), shutter speed (1/4000 to BULB), notes +- Automatic location capture with Geolocation API (10min cache, high accuracy) +- Exposure counter with remaining shots display + +### Camera Management +- Define equipment: make, model, lens +- Auto-generated camera names (e.g., "Zenit ET, Helios 44-2 58mm f/2") +- Link cameras to film rolls +- CRUD operations for camera library + +### Data Persistence & Privacy +- **100% Local Storage**: All data stays on device via IndexedDB +- **No Server Communication**: App works completely offline +- **No Tracking**: No analytics or data collection +- **Permission-Based**: Camera and location require user consent +- **Offline-First PWA**: Installation, offline support, service worker caching + +## Development Notes + +### HTTPS for Camera Access +Modern browsers require HTTPS for camera API access. The project includes localhost SSL certificates (localhost+4.pem, localhost+4-key.pem). Vite detects these and enables HTTPS automatically. + +### Camera Utilities (src/utils/camera.ts) +- **camera.isSupported()**: Checks MediaDevices API and secure context +- **camera.getMediaStream()**: Tries multiple constraint sets for maximum compatibility +- **camera.captureImage()**: Captures from video element, limits to 1280px, JPEG quality 0.7-0.8 +- **geolocation.getCurrentPosition()**: High accuracy, 10s timeout, 10min cache +- **fileUtils.fileToBase64()**: Converts File to base64 with 10MB limit and type validation + +### Date Handling +All Date fields (createdAt, capturedAt, lastSyncTime) are stored as Date objects in memory but serialized as ISO strings in IndexedDB. The storage layer handles conversion automatically. + +### Exposure Settings State +Current aperture, shutter speed, and additionalInfo are maintained separately in App.tsx (`exposureSettings` state) and passed to CameraScreen. This allows settings to persist across multiple shots within the same session. + +### Material-UI Theme +Global theme defined in App.tsx with primary color #1976d2 (blue) and secondary #dc004e (pink). + +### TypeScript Configuration +- Strict mode enabled +- Separate configs: tsconfig.app.json (app code), tsconfig.node.json (Vite config) +- React 19 type definitions included + +## External Dependencies + +- **Aperture/Shutter Constants**: Pre-defined values in types.ts (APERTURE, SHUTTER_SPEED enums) +- **Geolocation API**: Browser API for GPS coordinates +- **MediaDevices API**: Browser API for camera access +- **Web Share API**: For mobile sharing (in GalleryScreen) +- **exiftool**: External tool (v13.38) required for Python metadata script + +## Common Patterns + +### Creating New Entities +1. Generate unique ID (Date.now().toString()) +2. Call storage method (e.g., `await storage.saveFilmRoll(filmRoll)`) +3. Update local state immutably +4. Handle errors with try/catch and user alerts + +### Updating Exposures +Exposures are identified by ID. When updating, always: +1. Save to storage first: `await storage.saveExposure(exposure)` +2. Update state by mapping over array: `exposures.map(e => e.id === exposure.id ? exposure : e)` + +### Screen Navigation +Use `navigateToScreen(screen, exposure?)` helper in App.tsx which updates both currentScreen and selectedExposure. + +## Browser Compatibility + +- **Camera Access**: Chrome 53+, Firefox 36+, Safari 11+ +- **PWA Features**: Chrome 40+, Firefox 44+, Safari 11.1+ +- **IndexedDB**: All modern browsers +- **Geolocation**: All modern browsers +- **Web Share API**: Mobile browsers (Chrome Android 61+, Safari iOS 12.2+) + +## Google Drive Integration (Optional) + +The app has placeholder code for Google Drive integration but it requires setup: + +1. **Create Google Cloud Project** at console.cloud.google.com +2. **Enable Google Drive API** in APIs & Services > Library +3. **Create Credentials** (API Key restricted to Google Drive API) +4. **Add Google APIs JavaScript Client** to index.html: + ```html + + ``` +5. **Implement authentication flow** in `src/utils/googleDriveService.ts` +6. **Handle OAuth redirect** in App.tsx (code present but disabled) + +**Note**: Without Google Drive setup, all import/export uses local file downloads. + +## Deployment + +The app is configured for static hosting (Vercel, Netlify, GitHub Pages). Build output goes to `dist/`: + +```bash +npm run build # Build for production +npx vercel --prod # Deploy to Vercel +npx netlify deploy --prod --dir=dist # Deploy to Netlify +``` + +PWA manifest and service worker are automatically generated during build. + +## Planned Features (Currently Disabled) + +- Google Drive sync (SyncManager commented out in App.tsx) +- Automatic cloud backup (awaiting storage migration completion) +- OAuth authentication flow (placeholder exists in App.tsx) diff --git a/apply_filmroll_metadata.py b/apply_filmroll_metadata.py index 2b1dae9..863339c 100644 --- a/apply_filmroll_metadata.py +++ b/apply_filmroll_metadata.py @@ -53,6 +53,11 @@ def apply_metadata(folder_path): latitude = exp.get("location", {}).get("latitude") longitude = exp.get("location", {}).get("longitude") + # Get new fields (v2.0.0) + ei = exp.get("ei") # Exposure Index (may differ from ISO) + lens_name = exp.get("lensName", "") # Denormalized lens name + focal_length = exp.get("focalLength") # Focal length in mm + # Parse datetime into Exif format try: dt_obj = datetime.fromisoformat(captured_at.replace("Z", "+00:00")) @@ -60,17 +65,27 @@ def apply_metadata(folder_path): except Exception: capture_date = "" + # Use EI if available, otherwise fall back to film ISO + iso_value = ei if ei else data['filmRoll'].get('iso', 200) + + # Use lens name from exposure if available (for lenses that changed mid-roll) + # Otherwise fall back to hardcoded default + lens_model = lens_name if lens_name else "-" + + # Use focal length from exposure if available, otherwise default + focal_length_str = f"{focal_length}mm" if focal_length else "-" + # Build exiftool args args = [ EXIFTOOL_PATH, "-overwrite_original", "-Make=Zenit", "-Model=Zenit ET", - "-LensModel=Helios 44-2 58mm f/2", + f"-LensModel={lens_model}", f"-FNumber={aperture}" if aperture else "", f"-ExposureTime={shutter}" if shutter else "", - f"-ISO={data['filmRoll'].get('iso', 200)}", - "-FocalLength=58mm", + f"-ISO={iso_value}", + f"-FocalLength={focal_length_str}", f"-UserComment={add_info}" if add_info else "", f"-DateTimeOriginal={capture_date}" if capture_date else "", f"-GPSLatitude={latitude}" if latitude else "", diff --git a/src/App.tsx b/src/App.tsx index d55eee6..50182f7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,7 @@ import { DetailsScreen } from './components/DetailsScreen'; import { SettingsModal } from './components/SettingsModal'; import { storage } from './utils/storage'; // import { SyncManager } from './utils/syncManager'; // Temporarily disabled -import type { FilmRoll, Exposure, AppState, Camera, AppSettings, ExposureSettings } from './types'; +import type { FilmRoll, Exposure, AppState, Camera, Lens, AppSettings, ExposureSettings } from './types'; import { SHUTTER_SPEED, APERTURE } from './types'; const theme = createTheme({ @@ -43,6 +43,7 @@ function App() { currentFilmRoll: null, filmRolls: [], cameras: [], + lenses: [], exposures: [], currentScreen: 'filmrolls', selectedExposure: null, @@ -69,10 +70,11 @@ function App() { console.log('✅ Storage initialized successfully'); // Load all data from IndexedDB - const [currentFilmRoll, filmRolls, cameras, exposures, settings] = await Promise.all([ + const [currentFilmRoll, filmRolls, cameras, lenses, exposures, settings] = await Promise.all([ storage.getCurrentFilmRoll(), storage.getFilmRolls(), storage.getCameras(), + storage.getLenses(), storage.getExposures(), storage.getSettings() ]); @@ -82,6 +84,7 @@ function App() { currentFilmRoll, filmRolls, cameras, + lenses, exposures, settings, currentScreen: 'filmrolls' // Always show main screen with film rolls tab @@ -99,6 +102,7 @@ function App() { ...prev, filmRolls: [], cameras: [], + lenses: [], exposures: [], currentFilmRoll: null, settings: { @@ -213,6 +217,24 @@ function App() { })); }; + const handleFilmRollUpdated = async (filmRoll: FilmRoll) => { + try { + await storage.saveFilmRoll(filmRoll); + if (appState.currentFilmRoll?.id === filmRoll.id) { + await storage.setCurrentFilmRoll(filmRoll); + } + + setAppState(prev => ({ + ...prev, + currentFilmRoll: prev.currentFilmRoll?.id === filmRoll.id ? filmRoll : prev.currentFilmRoll, + filmRolls: prev.filmRolls.map(r => r.id === filmRoll.id ? filmRoll : r) + })); + } catch (error) { + console.error('Failed to update film roll:', error); + alert('Failed to update film roll. Please try again.'); + } + }; + // Camera handlers const handleCameraCreated = async (camera: Camera) => { try { @@ -253,6 +275,46 @@ function App() { } }; + // Lens handlers + const handleLensCreated = async (lens: Lens) => { + try { + await storage.saveLens(lens); + setAppState(prev => ({ + ...prev, + lenses: [...prev.lenses.filter(l => l.id !== lens.id), lens] + })); + } catch (error) { + console.error('Failed to create lens:', error); + alert('Failed to save lens. Please try again.'); + } + }; + + const handleLensUpdated = async (lens: Lens) => { + try { + await storage.saveLens(lens); + setAppState(prev => ({ + ...prev, + lenses: prev.lenses.map(l => l.id === lens.id ? lens : l) + })); + } catch (error) { + console.error('Failed to update lens:', error); + alert('Failed to update lens. Please try again.'); + } + }; + + const handleLensDeleted = async (lensId: string) => { + try { + await storage.deleteLens(lensId); + setAppState(prev => ({ + ...prev, + lenses: prev.lenses.filter(l => l.id !== lensId) + })); + } catch (error) { + console.error('Failed to delete lens:', error); + alert('Failed to delete lens. Please try again.'); + } + }; + const handleExposureTaken = async (exposure: Exposure) => { try { await storage.saveExposure(exposure); @@ -374,12 +436,16 @@ function App() { filmRolls={appState.filmRolls} exposures={appState.exposures} cameras={appState.cameras} + lenses={appState.lenses} onFilmRollSelected={handleFilmRollSelected} onFilmRollCreated={handleFilmRollCreated} onFilmRollDeleted={handleFilmRollDeleted} onCameraCreated={handleCameraCreated} onCameraUpdated={handleCameraUpdated} onCameraDeleted={handleCameraDeleted} + onLensCreated={handleLensCreated} + onLensUpdated={handleLensUpdated} + onLensDeleted={handleLensDeleted} onSettingsClick={() => setShowSettings(true)} /> ); @@ -388,6 +454,7 @@ function App() { return ( ); @@ -397,8 +464,10 @@ function App() { return ( navigateToScreen('gallery')} onBack={() => navigateToScreen('filmrolls')} currentSettings={exposureSettings} @@ -411,9 +480,11 @@ function App() { return ( navigateToScreen('details', exposure)} onExposureDelete={handleExposureDelete} + onExposureUpdate={handleExposureUpdate} onBack={() => navigateToScreen('camera')} onDataImported={handleDataImported} /> @@ -424,6 +495,7 @@ function App() { return ( navigateToScreen('gallery')} diff --git a/src/components/CameraManagementScreen.tsx b/src/components/CameraManagementScreen.tsx index fb15e53..9d0cefa 100644 --- a/src/components/CameraManagementScreen.tsx +++ b/src/components/CameraManagementScreen.tsx @@ -144,7 +144,7 @@ export const CameraManagementScreen: React.FC = ({ - Camera Equipment + Camera Management {cameras.length} camera{cameras.length !== 1 ? 's' : ''} @@ -231,7 +231,7 @@ export const CameraManagementScreen: React.FC = ({ )} - {/* Add Camera FAB */} + {/* Add FAB */} void; + onFilmRollUpdated: (filmRoll: FilmRoll) => void; onOpenGallery: () => void; onBack?: () => void; currentSettings: ExposureSettings; @@ -57,8 +62,10 @@ interface CameraScreenProps { export const CameraScreen: React.FC = ({ filmRoll, + lenses, exposures, onExposureTaken, + onFilmRollUpdated, onOpenGallery, onBack, currentSettings, @@ -71,6 +78,7 @@ export const CameraScreen: React.FC = ({ const [isCameraActive, setIsCameraActive] = useState(false); const [showSettingsDialog, setShowSettingsDialog] = useState(false); const [showShutterEffect, setShowShutterEffect] = useState(false); + const [showLensChangeDialog, setShowLensChangeDialog] = useState(false); const currentExposureNumber = exposures.filter(e => e.filmRollId === filmRoll.id).length + 1; const exposuresLeft = filmRoll.totalExposures - (currentExposureNumber - 1); @@ -218,7 +226,10 @@ export const CameraScreen: React.FC = ({ additionalInfo: currentSettings.additionalInfo, imageData, location, - capturedAt: new Date() + capturedAt: new Date(), + ei: currentSettings.ei, + lensId: currentSettings.lensId, + focalLength: currentSettings.focalLength }; onExposureTaken(exposure); @@ -286,7 +297,10 @@ export const CameraScreen: React.FC = ({ additionalInfo: currentSettings.additionalInfo, imageData, location, - capturedAt: new Date() + capturedAt: new Date(), + ei: currentSettings.ei, + lensId: currentSettings.lensId, + focalLength: currentSettings.focalLength }; console.log('Creating exposure:', exposure.id); @@ -306,6 +320,22 @@ export const CameraScreen: React.FC = ({ setShowSettingsDialog(true); }; + const handleLensChange = (newLensId: string) => { + const updatedFilmRoll = { + ...filmRoll, + currentLensId: newLensId || undefined + }; + onFilmRollUpdated(updatedFilmRoll); + + // Also update current settings + setCurrentSettings(prev => ({ + ...prev, + lensId: newLensId || undefined + })); + + setShowLensChangeDialog(false); + }; + return ( {/* Header */} @@ -407,8 +437,37 @@ getUserMedia: ${!!navigator.mediaDevices?.getUserMedia} )} + {/* Current Lens - Shows film roll's current lens */} + {filmRoll.currentLensId && (() => { + const currentLens = lenses.find(l => l.id === filmRoll.currentLensId); + return currentLens ? ( + + setShowLensChangeDialog(true)} + variant="filled" + color="primary" + size="medium" + sx={{ fontWeight: 'bold' }} + /> + + Click to change lens + + + ) : null; + })()} + {/* Settings Chips */} + {!filmRoll.currentLensId && ( + setShowLensChangeDialog(true)} + variant="outlined" + size="small" + color="default" + /> + )} + {currentSettings.ei && ( + + )} + {currentSettings.focalLength && ( + + )} - - { + const newLensId = e.target.value; + setCurrentSettings(prev => { + const lens = lenses.find(l => l.id === newLensId); + return { + ...prev, + lensId: newLensId || undefined, + // Set default focal length for prime lenses + focalLength: lens?.focalLength || prev.focalLength + }; + }); + }} + label="Lens" + > + + None selected - ))} - + {lenses.map((lens) => ( + + {lens.name} ({lens.maxAperture}) + + ))} + + + + {/* Aperture - limited by lens */} + + Aperture + + + + {/* Shutter Speed */} + + Shutter Speed + + + + {/* EI (Exposure Index) */} + + + EI (Exposure Index) + + + {currentSettings.ei !== undefined && !EI_VALUES.includes(currentSettings.ei as any) && ( + setCurrentSettings(prev => ({ ...prev, ei: parseInt(e.target.value) || undefined }))} + inputProps={{ min: 1, max: 10000 }} + sx={{ mt: 2 }} + /> + )} + + + {/* Focal Length Slider */} + + + Focal Length: {currentSettings.focalLength || 'Not set'}mm + + setCurrentSettings(prev => ({ ...prev, focalLength: value as number }))} + min={1} + max={200} + step={1} + marks={[ + { value: 1, label: '1mm' }, + { value: 50, label: '50mm' }, + { value: 100, label: '100mm' }, + { value: 200, label: '200mm' } + ]} + valueLabelDisplay="auto" + /> + setCurrentSettings(prev => ({ ...prev, focalLength: parseInt(e.target.value) || undefined }))} + inputProps={{ min: 1, max: 10000 }} + size="small" + sx={{ mt: 1 }} + /> + + + {/* Additional Info */} + + {/* Lens Change Dialog */} + setShowLensChangeDialog(false)} maxWidth="sm" fullWidth> + + + Change Lens + setShowLensChangeDialog(false)}> + + + + + + + + Select a lens to use for upcoming shots. This will update the film roll and all new exposures will use this lens. + + + Lens + + + {lenses.length === 0 && ( + + No lenses available. Add lenses in the Cameras tab. + + )} + + + + + + ); }; \ No newline at end of file diff --git a/src/components/DetailsScreen.tsx b/src/components/DetailsScreen.tsx index 3f91f55..d38f419 100644 --- a/src/components/DetailsScreen.tsx +++ b/src/components/DetailsScreen.tsx @@ -13,7 +13,10 @@ import { DialogTitle, DialogContent, Select, - MenuItem + MenuItem, + FormControl, + InputLabel, + Slider } from '@mui/material'; import { ArrowBack, @@ -27,10 +30,11 @@ import { Delete } from '@mui/icons-material'; import { camera, fileUtils } from '../utils/camera'; -import { APERTURE, APERTURE_VALUES, SHUTTER_SPEED, SHUTTER_SPEED_VALUES, type Exposure } from '../types'; +import { APERTURE, APERTURE_VALUES, SHUTTER_SPEED, SHUTTER_SPEED_VALUES, EI_VALUES, type Exposure, type Lens } from '../types'; interface DetailsScreenProps { exposure: Exposure; + lenses: Lens[]; onExposureUpdate: (exposure: Exposure) => void; onExposureDelete?: (exposureId: string) => void; onBack: () => void; @@ -38,6 +42,7 @@ interface DetailsScreenProps { export const DetailsScreen: React.FC = ({ exposure, + lenses, onExposureUpdate, onExposureDelete, onBack @@ -197,33 +202,119 @@ export const DetailsScreen: React.FC = ({ {isEditing ? ( <> - setEditedExposure(prev => ({ ...prev, lensId: e.target.value || undefined }))} + label="Lens" + > + + None selected - ))} - - + + + {/* Aperture */} + + Aperture + + + + {/* Shutter Speed */} + + Shutter Speed + + + + {/* EI */} + + EI (Exposure Index) + + {EI_VALUES.map((value) => ( + + {value} + + ))} + + + + {/* Focal Length */} + + + Focal Length: {editedExposure.focalLength || 'Not set'}mm + + setEditedExposure(prev => ({ ...prev, focalLength: value as number }))} + min={1} + max={200} + step={1} + marks={[ + { value: 1, label: '1mm' }, + { value: 50, label: '50mm' }, + { value: 100, label: '100mm' }, + { value: 200, label: '200mm' } + ]} + valueLabelDisplay="auto" + /> + setEditedExposure(prev => ({ ...prev, focalLength: parseInt(e.target.value) || undefined }))} + inputProps={{ min: 1, max: 10000 }} + size="small" + sx={{ mt: 1 }} + /> + ) : ( - + + {(() => { + const lens = lenses.find(l => l.id === exposure.lensId); + return lens && ( + + ); + })()} + {exposure.ei && } + {exposure.focalLength && } )} diff --git a/src/components/FilmRollListScreen.tsx b/src/components/FilmRollListScreen.tsx index a71dbf8..d50aee9 100644 --- a/src/components/FilmRollListScreen.tsx +++ b/src/components/FilmRollListScreen.tsx @@ -30,13 +30,14 @@ import { Delete, Close } from '@mui/icons-material'; -import type { FilmRoll, Exposure, Camera } from '../types'; +import type { FilmRoll, Exposure, Camera, Lens } from '../types'; import { SetupScreen } from './SetupScreen'; import { storage } from '../utils/storage'; interface FilmRollListScreenProps { filmRolls: FilmRoll[]; cameras: Camera[]; + lenses: Lens[]; exposures: Exposure[]; onFilmRollSelected: (filmRoll: FilmRoll) => void; onFilmRollCreated: (filmRoll: FilmRoll) => void; @@ -46,6 +47,7 @@ interface FilmRollListScreenProps { export const FilmRollListScreen: React.FC = ({ filmRolls, cameras, + lenses, exposures, onFilmRollSelected, onFilmRollCreated, @@ -366,6 +368,7 @@ export const FilmRollListScreen: React.FC = ({ @@ -434,6 +437,7 @@ export const FilmRollListScreen: React.FC = ({ diff --git a/src/components/GalleryScreen.tsx b/src/components/GalleryScreen.tsx index 07c51dc..e11f5cd 100644 --- a/src/components/GalleryScreen.tsx +++ b/src/components/GalleryScreen.tsx @@ -33,26 +33,31 @@ import { Save, Share, Close, - Delete + Delete, + ContentCopy } from '@mui/icons-material'; -import type { Exposure, FilmRoll } from '../types'; +import type { Exposure, FilmRoll, Lens } from '../types'; import { exportUtils, googleDriveUtils } from '../utils/exportImport'; import { storage } from '../utils/storage'; interface GalleryScreenProps { filmRoll: FilmRoll; + lenses: Lens[]; exposures: Exposure[]; onExposureSelect: (exposure: Exposure) => void; onExposureDelete?: (exposureId: string) => void; + onExposureUpdate?: (exposure: Exposure) => void; onBack: () => void; onDataImported?: (filmRoll: FilmRoll, exposures: Exposure[]) => void; } export const GalleryScreen: React.FC = ({ filmRoll, + lenses, exposures, onExposureSelect, onExposureDelete, + onExposureUpdate, onBack, onDataImported }) => { @@ -65,7 +70,29 @@ export const GalleryScreen: React.FC = ({ const [isProcessing, setIsProcessing] = useState(false); const fileInputRef = useRef(null); - const filmExposures = exposures.filter(exposure => exposure.filmRollId === filmRoll.id); + const filmExposures = exposures.filter(exposure => exposure.filmRollId === filmRoll.id) + .sort((a, b) => a.exposureNumber - b.exposureNumber); + + const handleCopyFromPrevious = async (currentExposure: Exposure, previousExposure: Exposure) => { + if (!onExposureUpdate) return; + + const updatedExposure: Exposure = { + ...currentExposure, + aperture: previousExposure.aperture, + shutterSpeed: previousExposure.shutterSpeed, + additionalInfo: previousExposure.additionalInfo, + ei: previousExposure.ei, + lensId: previousExposure.lensId, + focalLength: previousExposure.focalLength + }; + + try { + onExposureUpdate(updatedExposure); + } catch (error) { + console.error('Failed to copy settings:', error); + alert('Failed to copy settings. Please try again.'); + } + }; const handleExport = async () => { if (exportMethod !== 'jsononly' && !exportFolderName.trim()) { @@ -76,11 +103,11 @@ export const GalleryScreen: React.FC = ({ setIsProcessing(true); try { if (exportMethod === 'googledrive') { - await googleDriveUtils.exportToGoogleDrive(filmRoll, filmExposures, exportFolderName); + await googleDriveUtils.exportToGoogleDrive(filmRoll, filmExposures, lenses, exportFolderName); } else if (exportMethod === 'jsononly') { - await exportUtils.exportJsonOnly(filmRoll, filmExposures); + await exportUtils.exportJsonOnly(filmRoll, filmExposures, lenses); } else { - await exportUtils.exportToLocal(filmRoll, filmExposures, exportFolderName); + await exportUtils.exportToLocal(filmRoll, filmExposures, lenses, exportFolderName); } setShowExportDialog(false); setExportFolderName(''); @@ -268,8 +295,25 @@ export const GalleryScreen: React.FC = ({ {/* Exposures Grid */} - {filmExposures.map((exposure) => ( + {filmExposures.map((exposure, index) => { + const previousExposure = index > 0 ? filmExposures[index - 1] : null; + const lens = lenses.find(l => l.id === exposure.lensId); + + return ( + {/* Copy from previous button */} + {previousExposure && onExposureUpdate && ( + + + + )} onExposureSelect(exposure)} sx={{ @@ -318,7 +362,15 @@ export const GalleryScreen: React.FC = ({ - + + {lens && ( + + )} = ({ size="small" variant="outlined" /> + {exposure.ei && ( + + )} + {exposure.focalLength && ( + + )} {exposure.location && ( @@ -347,7 +414,8 @@ export const GalleryScreen: React.FC = ({ - ))} + ); + })} diff --git a/src/components/LensManagementScreen.tsx b/src/components/LensManagementScreen.tsx new file mode 100644 index 0000000..8eb5d57 --- /dev/null +++ b/src/components/LensManagementScreen.tsx @@ -0,0 +1,373 @@ +import React, { useState } from 'react'; +import { + Box, + Container, + Typography, + Fab, + AppBar, + Toolbar, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Stack, + IconButton, + Menu, + MenuItem +} from '@mui/material'; +import { + Add, + Edit, + Delete, + MoreVert, + CameraEnhance +} from '@mui/icons-material'; +import type { Lens } from '../types'; +import { APERTURE_VALUES } from '../types'; +import { ItemCard } from './ItemCard'; + +interface LensManagementScreenProps { + lenses: Lens[]; + onLensCreated: (lens: Lens) => void; + onLensUpdated: (lens: Lens) => void; + onLensDeleted: (lensId: string) => void; +} + +export const LensManagementScreen: React.FC = ({ + lenses, + onLensCreated, + onLensUpdated, + onLensDeleted +}) => { + const [showDialog, setShowDialog] = useState(false); + const [editingLens, setEditingLens] = useState(null); + const [menuAnchor, setMenuAnchor] = useState(null); + const [selectedLens, setSelectedLens] = useState(null); + + const [formData, setFormData] = useState({ + name: '', + maxAperture: 'f/1.4', + focalLength: '', + focalLengthMin: '', + focalLengthMax: '' + }); + + const resetForm = () => { + setFormData({ + name: '', + maxAperture: 'f/1.4', + focalLength: '', + focalLengthMin: '', + focalLengthMax: '' + }); + setEditingLens(null); + }; + + const handleCreate = () => { + if (!formData.name.trim()) { + alert('Please enter lens name'); + return; + } + + const lens: Lens = { + id: Date.now().toString(), + name: formData.name.trim(), + maxAperture: formData.maxAperture, + focalLength: formData.focalLength ? parseInt(formData.focalLength) : undefined, + focalLengthMin: formData.focalLengthMin ? parseInt(formData.focalLengthMin) : undefined, + focalLengthMax: formData.focalLengthMax ? parseInt(formData.focalLengthMax) : undefined, + createdAt: new Date() + }; + + onLensCreated(lens); + setShowDialog(false); + resetForm(); + }; + + const handleUpdate = () => { + if (!editingLens || !formData.name.trim()) { + alert('Please enter lens name'); + return; + } + + const updatedLens: Lens = { + ...editingLens, + name: formData.name.trim(), + maxAperture: formData.maxAperture, + focalLength: formData.focalLength ? parseInt(formData.focalLength) : undefined, + focalLengthMin: formData.focalLengthMin ? parseInt(formData.focalLengthMin) : undefined, + focalLengthMax: formData.focalLengthMax ? parseInt(formData.focalLengthMax) : undefined + }; + + onLensUpdated(updatedLens); + setEditingLens(null); + resetForm(); + }; + + const handleEditClick = (lens: Lens) => { + setEditingLens(lens); + setFormData({ + name: lens.name, + maxAperture: lens.maxAperture, + focalLength: lens.focalLength?.toString() || '', + focalLengthMin: lens.focalLengthMin?.toString() || '', + focalLengthMax: lens.focalLengthMax?.toString() || '' + }); + setMenuAnchor(null); + }; + + const handleDeleteClick = (lens: Lens) => { + const confirmed = window.confirm( + `Delete lens "${lens.name}"?\n\nThis will not affect existing exposures using this lens.` + ); + if (confirmed) { + onLensDeleted(lens.id); + } + setMenuAnchor(null); + }; + + const handleMenuClick = (event: React.MouseEvent, lens: Lens) => { + event.stopPropagation(); + setSelectedLens(lens); + setMenuAnchor(event.currentTarget); + }; + + const handleMenuClose = () => { + setMenuAnchor(null); + setSelectedLens(null); + }; + + const sortedLenses = [...lenses].sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + return ( + + + + + + Lens Management + + + {lenses.length} lens{lenses.length !== 1 ? 'es' : ''} + + + + + + {lenses.length === 0 ? ( + + + + No Lenses Added Yet + + + Add your lenses to track focal lengths and apertures for each shot. + + + + ) : ( + <> + + + Your Lenses + + + Manage your lens collection for accurate exposure tracking. + + + + + {sortedLenses.map((lens) => { + const focalLengthStr = lens.focalLength + ? `${lens.focalLength}mm` + : lens.focalLengthMin && lens.focalLengthMax + ? `${lens.focalLengthMin}-${lens.focalLengthMax}mm` + : 'N/A'; + + return ( + + + handleMenuClick(e, lens)} + > + + + + ); + })} + + + )} + + + {/* Add FAB */} + setShowDialog(true)} + > + + + + {/* Create/Edit Dialog */} + { + setShowDialog(false); + setEditingLens(null); + resetForm(); + }} + maxWidth="sm" + fullWidth + > + + {editingLens ? 'Edit Lens' : 'Add New Lens'} + + + + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="e.g., Helios 44-2, Canon EF 50mm" + required + /> + + setFormData(prev => ({ ...prev, maxAperture: e.target.value }))} + required + > + {APERTURE_VALUES.map((value) => ( + + {value} + + ))} + + + + Focal Length (for prime lenses) + + setFormData(prev => ({ ...prev, focalLength: e.target.value }))} + placeholder="e.g., 50" + inputProps={{ min: 1, max: 10000 }} + /> + + + Or for zoom lenses + + + setFormData(prev => ({ ...prev, focalLengthMin: e.target.value }))} + placeholder="e.g., 24" + inputProps={{ min: 1, max: 10000 }} + /> + setFormData(prev => ({ ...prev, focalLengthMax: e.target.value }))} + placeholder="e.g., 70" + inputProps={{ min: 1, max: 10000 }} + /> + + + + + + + + + + {/* Actions Menu */} + + selectedLens && handleEditClick(selectedLens)}> + + Edit + + selectedLens && handleDeleteClick(selectedLens)}> + + Delete + + + + ); +}; diff --git a/src/components/MainScreen.tsx b/src/components/MainScreen.tsx index 176a95b..5d923b2 100644 --- a/src/components/MainScreen.tsx +++ b/src/components/MainScreen.tsx @@ -11,11 +11,13 @@ import { import { PhotoLibrary, CameraAlt, - Settings + Settings, + CameraEnhance } from '@mui/icons-material'; -import type { FilmRoll, Exposure, Camera } from '../types'; +import type { FilmRoll, Exposure, Camera, Lens } from '../types'; import { FilmRollListScreen } from './FilmRollListScreen'; import { CameraManagementScreen } from './CameraManagementScreen'; +import { LensManagementScreen } from './LensManagementScreen'; interface TabPanelProps { children?: React.ReactNode; @@ -41,6 +43,7 @@ function TabPanel(props: TabPanelProps) { interface MainScreenProps { filmRolls: FilmRoll[]; cameras: Camera[]; + lenses: Lens[]; exposures: Exposure[]; onFilmRollSelected: (filmRoll: FilmRoll) => void; onFilmRollCreated: (filmRoll: FilmRoll) => void; @@ -48,12 +51,16 @@ interface MainScreenProps { onCameraCreated: (camera: Camera) => void; onCameraUpdated: (camera: Camera) => void; onCameraDeleted: (cameraId: string) => void; + onLensCreated: (lens: Lens) => void; + onLensUpdated: (lens: Lens) => void; + onLensDeleted: (lensId: string) => void; onSettingsClick?: () => void; } export const MainScreen: React.FC = ({ filmRolls, cameras, + lenses, exposures, onFilmRollSelected, onFilmRollCreated, @@ -61,6 +68,9 @@ export const MainScreen: React.FC = ({ onCameraCreated, onCameraUpdated, onCameraDeleted, + onLensCreated, + onLensUpdated, + onLensDeleted, onSettingsClick }) => { const [currentTab, setCurrentTab] = useState(0); @@ -112,6 +122,12 @@ export const MainScreen: React.FC = ({ iconPosition="start" sx={{ minHeight: 48 }} /> + } + label="Lenses" + iconPosition="start" + sx={{ minHeight: 48 }} + /> @@ -119,6 +135,7 @@ export const MainScreen: React.FC = ({ = ({ onCameraDeleted={onCameraDeleted} /> + + + + ); }; \ No newline at end of file diff --git a/src/components/SetupScreen.tsx b/src/components/SetupScreen.tsx index a8c1f41..2361261 100644 --- a/src/components/SetupScreen.tsx +++ b/src/components/SetupScreen.tsx @@ -12,10 +12,12 @@ import { MenuItem } from '@mui/material'; import { PhotoCamera, Settings } from '@mui/icons-material'; -import type { FilmRoll, Camera } from '../types'; +import type { FilmRoll, Camera, Lens } from '../types'; +import { EI_VALUES } from '../types'; interface SetupScreenProps { cameras?: Camera[]; + lenses?: Lens[]; editingFilmRoll?: FilmRoll | null; onFilmRollCreated?: (filmRoll: FilmRoll) => void; onFilmRollUpdated?: (filmRoll: FilmRoll) => void; @@ -23,14 +25,18 @@ interface SetupScreenProps { export const SetupScreen: React.FC = ({ cameras = [], + lenses = [], editingFilmRoll, onFilmRollCreated, onFilmRollUpdated }) => { const [filmName, setFilmName] = useState(editingFilmRoll?.name || ''); const [iso, setIso] = useState(editingFilmRoll?.iso?.toString() || '400'); + const [ei, setEi] = useState(editingFilmRoll?.ei?.toString() || ''); + const [useCustomEi, setUseCustomEi] = useState(false); const [totalExposures, setTotalExposures] = useState(editingFilmRoll?.totalExposures?.toString() || '36'); const [selectedCameraId, setSelectedCameraId] = useState(editingFilmRoll?.cameraId || ''); + const [selectedLensId, setSelectedLensId] = useState(editingFilmRoll?.currentLensId || ''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -40,14 +46,18 @@ export const SetupScreen: React.FC = ({ return; } + const parsedEi = ei ? parseInt(ei) : undefined; + if (editingFilmRoll) { // Update existing film roll const updatedFilmRoll: FilmRoll = { ...editingFilmRoll, name: filmName.trim(), iso: parseInt(iso) || 400, + ei: parsedEi, totalExposures: parseInt(totalExposures) || 36, - cameraId: selectedCameraId || undefined + cameraId: selectedCameraId || undefined, + currentLensId: selectedLensId || undefined }; onFilmRollUpdated?.(updatedFilmRoll); } else { @@ -56,8 +66,10 @@ export const SetupScreen: React.FC = ({ id: Date.now().toString(), name: filmName.trim(), iso: parseInt(iso) || 400, + ei: parsedEi, totalExposures: parseInt(totalExposures) || 36, cameraId: selectedCameraId || undefined, + currentLensId: selectedLensId || undefined, createdAt: new Date() }; onFilmRollCreated?.(filmRoll); @@ -102,6 +114,51 @@ export const SetupScreen: React.FC = ({ required /> + + + EI (Exposure Index) - Optional + + + {useCustomEi && ( + setEi(e.target.value)} + inputProps={{ min: 1, max: 10000 }} + sx={{ mt: 2 }} + /> + )} + + EI can differ from ISO when push/pull processing + + + = ({ + + Lens (Optional) + + +