diff --git a/client/src/App.css b/client/src/App.css index 080a9f0..8a3ad96 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1727,4 +1727,26 @@ body:has(.theme-cupertino) { border-color: #ef4444 !important; } +/* Crew Avatar Card Styling */ +.crew-card-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 1px solid #334155; +} + +.crew-card-avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(30, 41, 59, 0.6); + border: 1px solid #334155; + display: flex; + align-items: center; + justify-content: center; + color: #475569; +} + + diff --git a/client/src/components/CrewForm.tsx b/client/src/components/CrewForm.tsx index 6659cab..34f2010 100644 --- a/client/src/components/CrewForm.tsx +++ b/client/src/components/CrewForm.tsx @@ -5,7 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' import { useDialog } from './ModalDialog.tsx' -import { Users, User, Plus, Trash2, Edit2, Save, X, Check } from 'lucide-react' +import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react' interface CrewFormProps { logbookId: string @@ -22,6 +22,7 @@ interface CrewMemberData { allergies: string diseases: string role: 'skipper' | 'crew' + photo?: string | null } interface DecryptedCrew { @@ -44,6 +45,10 @@ export default function CrewForm({ logbookId }: CrewFormProps) { const [skipAllergies, setSkipAllergies] = useState('') const [skipDiseases, setSkipDiseases] = useState('') + const skipFileInputRef = React.useRef(null) + const [skipPhoto, setSkipPhoto] = useState(null) + const [skipPhotoError, setSkipPhotoError] = useState(null) + // Crew list state const [crewList, setCrewList] = useState([]) @@ -60,6 +65,10 @@ export default function CrewForm({ logbookId }: CrewFormProps) { const [memAllergies, setMemAllergies] = useState('') const [memDiseases, setMemDiseases] = useState('') + const memFileInputRef = React.useRef(null) + const [memPhoto, setMemPhoto] = useState(null) + const [memPhotoError, setMemPhotoError] = useState(null) + const [loading, setLoading] = useState(false) const [savingSkipper, setSavingSkipper] = useState(false) const [savingMember, setSavingMember] = useState(false) @@ -70,6 +79,46 @@ export default function CrewForm({ logbookId }: CrewFormProps) { loadCrewData() }, [logbookId]) + const resizeImageFile = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (event) => { + const img = new Image() + img.onload = () => { + try { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Could not get canvas context') + + let width = img.width + let height = img.height + const MAX_WIDTH = 800 + const MAX_HEIGHT = 600 + + if (width > MAX_WIDTH || height > MAX_HEIGHT) { + const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height) + width = Math.round(width * ratio) + height = Math.round(height * ratio) + } + + canvas.width = width + canvas.height = height + ctx.drawImage(img, 0, 0, width, height) + + const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7) + resolve(compressedBase64) + } catch (err) { + reject(err) + } + } + img.onerror = () => reject(new Error('Invalid image file')) + img.src = event.target?.result as string + } + reader.onerror = () => reject(new Error('Failed to read file')) + reader.readAsDataURL(file) + }) + } + const loadCrewData = async () => { setLoading(true) setError(null) @@ -94,6 +143,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { setSkipBloodType(decrypted.bloodType || '') setSkipAllergies(decrypted.allergies || '') setSkipDiseases(decrypted.diseases || '') + setSkipPhoto(decrypted.photo || null) } else { decryptedCrews.push({ payloadId: c.payloadId, @@ -131,7 +181,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) { bloodType: skipBloodType.trim(), allergies: skipAllergies.trim(), diseases: skipDiseases.trim(), - role: 'skipper' + role: 'skipper', + photo: skipPhoto } const encrypted = await encryptJson(skipperData, masterKey) @@ -178,6 +229,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) { setMemBloodType('') setMemAllergies('') setMemDiseases('') + setMemPhoto(null) + setMemPhotoError(null) setShowMemberForm(true) } @@ -192,6 +245,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) { setMemBloodType(member.data.bloodType) setMemAllergies(member.data.allergies) setMemDiseases(member.data.diseases) + setMemPhoto(member.data.photo || null) + setMemPhotoError(null) setShowMemberForm(true) } @@ -216,7 +271,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) { bloodType: memBloodType.trim(), allergies: memAllergies.trim(), diseases: memDiseases.trim(), - role: 'crew' + role: 'crew', + photo: memPhoto } const encrypted = await encryptJson(memberData, masterKey) @@ -310,6 +366,68 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
+
+
skipFileInputRef.current?.click()}> + {skipPhoto ? ( + {skipName + ) : ( +
+ +
+ )} +
+ + {skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')} +
+
+ +
+ + + {skipPhoto && ( + + )} +
+ + { + const file = e.target.files?.[0] + if (!file) return + setSkipPhotoError(null) + try { + const resized = await resizeImageFile(file) + setSkipPhoto(resized) + } catch (err: any) { + setSkipPhotoError(err.message || 'Failed to process image') + } + }} + accept="image/*" + style={{ display: 'none' }} + /> + {skipPhotoError &&
{skipPhotoError}
} +
+
+
+
memFileInputRef.current?.click()}> + {memPhoto ? ( + {memName + ) : ( +
+ +
+ )} +
+ + {memPhoto ? t('vessel.photo_change') : t('vessel.photo_add')} +
+
+ +
+ + + {memPhoto && ( + + )} +
+ + { + const file = e.target.files?.[0] + if (!file) return + setMemPhotoError(null) + try { + const resized = await resizeImageFile(file) + setMemPhoto(resized) + } catch (err: any) { + setMemPhotoError(err.message || 'Failed to process image') + } + }} + accept="image/*" + style={{ display: 'none' }} + /> + {memPhotoError &&
{memPhotoError}
} +
+
(
-

{m.data.name}

+
+ {m.data.photo ? ( + {m.data.name} + ) : ( +
+ +
+ )} +

{m.data.name}

+