feat(crew): add E2E encrypted Skipper and Crew photo uploads with directory cards displaying avatars

This commit is contained in:
2026-05-28 15:29:26 +02:00
parent 491c4134f2
commit 53940424c4
2 changed files with 215 additions and 4 deletions
+22
View File
@@ -1727,4 +1727,26 @@ body:has(.theme-cupertino) {
border-color: #ef4444 !important; 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;
}
+193 -4
View File
@@ -5,7 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { useDialog } from './ModalDialog.tsx' 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 { interface CrewFormProps {
logbookId: string logbookId: string
@@ -22,6 +22,7 @@ interface CrewMemberData {
allergies: string allergies: string
diseases: string diseases: string
role: 'skipper' | 'crew' role: 'skipper' | 'crew'
photo?: string | null
} }
interface DecryptedCrew { interface DecryptedCrew {
@@ -44,6 +45,10 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
const [skipAllergies, setSkipAllergies] = useState('') const [skipAllergies, setSkipAllergies] = useState('')
const [skipDiseases, setSkipDiseases] = useState('') const [skipDiseases, setSkipDiseases] = useState('')
const skipFileInputRef = React.useRef<HTMLInputElement>(null)
const [skipPhoto, setSkipPhoto] = useState<string | null>(null)
const [skipPhotoError, setSkipPhotoError] = useState<string | null>(null)
// Crew list state // Crew list state
const [crewList, setCrewList] = useState<DecryptedCrew[]>([]) const [crewList, setCrewList] = useState<DecryptedCrew[]>([])
@@ -60,6 +65,10 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
const [memAllergies, setMemAllergies] = useState('') const [memAllergies, setMemAllergies] = useState('')
const [memDiseases, setMemDiseases] = useState('') const [memDiseases, setMemDiseases] = useState('')
const memFileInputRef = React.useRef<HTMLInputElement>(null)
const [memPhoto, setMemPhoto] = useState<string | null>(null)
const [memPhotoError, setMemPhotoError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [savingSkipper, setSavingSkipper] = useState(false) const [savingSkipper, setSavingSkipper] = useState(false)
const [savingMember, setSavingMember] = useState(false) const [savingMember, setSavingMember] = useState(false)
@@ -70,6 +79,46 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
loadCrewData() loadCrewData()
}, [logbookId]) }, [logbookId])
const resizeImageFile = (file: File): Promise<string> => {
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 () => { const loadCrewData = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
@@ -94,6 +143,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
setSkipBloodType(decrypted.bloodType || '') setSkipBloodType(decrypted.bloodType || '')
setSkipAllergies(decrypted.allergies || '') setSkipAllergies(decrypted.allergies || '')
setSkipDiseases(decrypted.diseases || '') setSkipDiseases(decrypted.diseases || '')
setSkipPhoto(decrypted.photo || null)
} else { } else {
decryptedCrews.push({ decryptedCrews.push({
payloadId: c.payloadId, payloadId: c.payloadId,
@@ -131,7 +181,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
bloodType: skipBloodType.trim(), bloodType: skipBloodType.trim(),
allergies: skipAllergies.trim(), allergies: skipAllergies.trim(),
diseases: skipDiseases.trim(), diseases: skipDiseases.trim(),
role: 'skipper' role: 'skipper',
photo: skipPhoto
} }
const encrypted = await encryptJson(skipperData, masterKey) const encrypted = await encryptJson(skipperData, masterKey)
@@ -178,6 +229,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
setMemBloodType('') setMemBloodType('')
setMemAllergies('') setMemAllergies('')
setMemDiseases('') setMemDiseases('')
setMemPhoto(null)
setMemPhotoError(null)
setShowMemberForm(true) setShowMemberForm(true)
} }
@@ -192,6 +245,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
setMemBloodType(member.data.bloodType) setMemBloodType(member.data.bloodType)
setMemAllergies(member.data.allergies) setMemAllergies(member.data.allergies)
setMemDiseases(member.data.diseases) setMemDiseases(member.data.diseases)
setMemPhoto(member.data.photo || null)
setMemPhotoError(null)
setShowMemberForm(true) setShowMemberForm(true)
} }
@@ -216,7 +271,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
bloodType: memBloodType.trim(), bloodType: memBloodType.trim(),
allergies: memAllergies.trim(), allergies: memAllergies.trim(),
diseases: memDiseases.trim(), diseases: memDiseases.trim(),
role: 'crew' role: 'crew',
photo: memPhoto
} }
const encrypted = await encryptJson(memberData, masterKey) const encrypted = await encryptJson(memberData, masterKey)
@@ -310,6 +366,68 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
<form onSubmit={handleSaveSkipper} className="vessel-form"> <form onSubmit={handleSaveSkipper} className="vessel-form">
<div className="form-grid"> <div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={() => skipFileInputRef.current?.click()}>
{skipPhoto ? (
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<User size={48} className="placeholder-icon" />
</div>
)}
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
</div>
</div>
<div className="vessel-photo-actions">
<button
type="button"
className="btn secondary btn-sm"
onClick={() => skipFileInputRef.current?.click()}
disabled={savingSkipper}
>
<Camera size={16} />
{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}
</button>
{skipPhoto && (
<button
type="button"
className="btn danger btn-sm"
onClick={() => {
setSkipPhoto(null)
if (skipFileInputRef.current) skipFileInputRef.current.value = ''
}}
disabled={savingSkipper}
>
<Trash2 size={16} />
{t('vessel.photo_delete')}
</button>
)}
</div>
<input
type="file"
ref={skipFileInputRef}
onChange={async (e) => {
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 && <div className="auth-error mt-2">{skipPhotoError}</div>}
</div>
<div className="input-group"> <div className="input-group">
<label>{t('crew.name')}</label> <label>{t('crew.name')}</label>
<input <input
@@ -453,6 +571,68 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
</div> </div>
<div className="form-grid"> <div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={() => memFileInputRef.current?.click()}>
{memPhoto ? (
<img src={memPhoto} alt={memName || 'Crew Member'} className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<User size={48} className="placeholder-icon" />
</div>
)}
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{memPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
</div>
</div>
<div className="vessel-photo-actions">
<button
type="button"
className="btn secondary btn-sm"
onClick={() => memFileInputRef.current?.click()}
disabled={savingMember}
>
<Camera size={16} />
{memPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}
</button>
{memPhoto && (
<button
type="button"
className="btn danger btn-sm"
onClick={() => {
setMemPhoto(null)
if (memFileInputRef.current) memFileInputRef.current.value = ''
}}
disabled={savingMember}
>
<Trash2 size={16} />
{t('vessel.photo_delete')}
</button>
)}
</div>
<input
type="file"
ref={memFileInputRef}
onChange={async (e) => {
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 && <div className="auth-error mt-2">{memPhotoError}</div>}
</div>
<div className="input-group"> <div className="input-group">
<label>{t('crew.name')} *</label> <label>{t('crew.name')} *</label>
<input <input
@@ -570,7 +750,16 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
{crewList.map((m) => ( {crewList.map((m) => (
<div key={m.payloadId} className="crew-member-card glass"> <div key={m.payloadId} className="crew-member-card glass">
<div className="crew-card-header"> <div className="crew-card-header">
<h4>{m.data.name}</h4> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{m.data.photo ? (
<img src={m.data.photo} alt={m.data.name} className="crew-card-avatar" />
) : (
<div className="crew-card-avatar-placeholder">
<User size={18} />
</div>
)}
<h4>{m.data.name}</h4>
</div>
<div className="card-actions"> <div className="card-actions">
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit"> <button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
<Edit2 size={14} /> <Edit2 size={14} />