Implement E2E-compliant anonymous read-only logbook sharing links
This commit is contained in:
@@ -10,6 +10,8 @@ import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide
|
||||
|
||||
interface CrewFormProps {
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
preloadedData?: any[]
|
||||
}
|
||||
|
||||
interface CrewMemberData {
|
||||
@@ -31,7 +33,7 @@ interface DecryptedCrew {
|
||||
data: CrewMemberData
|
||||
}
|
||||
|
||||
export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
export default function CrewForm({ logbookId, readOnly = false, preloadedData }: CrewFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
|
||||
@@ -78,7 +80,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
|
||||
useEffect(() => {
|
||||
loadCrewData()
|
||||
}, [logbookId])
|
||||
}, [logbookId, preloadedData])
|
||||
|
||||
const resizeImageFile = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -124,6 +126,31 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedData) {
|
||||
const decryptedCrews: DecryptedCrew[] = []
|
||||
for (const c of preloadedData) {
|
||||
if (c.payloadId === 'skipper') {
|
||||
setSkipName(c.data.name || '')
|
||||
setSkipAddress(c.data.address || '')
|
||||
setSkipBirthDate(c.data.birthDate || '')
|
||||
setSkipPhone(c.data.phone || '')
|
||||
setSkipNationality(c.data.nationality || '')
|
||||
setSkipPassport(c.data.passportNumber || '')
|
||||
setSkipBloodType(c.data.bloodType || '')
|
||||
setSkipAllergies(c.data.allergies || '')
|
||||
setSkipDiseases(c.data.diseases || '')
|
||||
setSkipPhoto(c.data.photo || null)
|
||||
} else {
|
||||
decryptedCrews.push({
|
||||
payloadId: c.payloadId,
|
||||
data: c.data
|
||||
})
|
||||
}
|
||||
}
|
||||
setCrewList(decryptedCrews)
|
||||
return
|
||||
}
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
@@ -164,6 +191,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
|
||||
const handleSaveSkipper = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly) return
|
||||
setSavingSkipper(true)
|
||||
setError(null)
|
||||
setSkipperSuccess(false)
|
||||
@@ -253,7 +281,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
|
||||
const handleSaveMember = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!memName.trim()) return
|
||||
if (readOnly || !memName.trim()) return
|
||||
|
||||
setSavingMember(true)
|
||||
setError(null)
|
||||
@@ -319,6 +347,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
}
|
||||
|
||||
const handleDeleteMember = async (memberId: string) => {
|
||||
if (readOnly) return
|
||||
if (await showConfirm(t('crew.delete_confirm'), t('crew.title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
setError(null)
|
||||
try {
|
||||
@@ -368,7 +397,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
<form onSubmit={handleSaveSkipper} className="vessel-form">
|
||||
<div className="form-grid">
|
||||
<div className="vessel-photo-wrapper">
|
||||
<div className="vessel-photo-preview" onClick={() => skipFileInputRef.current?.click()}>
|
||||
<div className="vessel-photo-preview" onClick={readOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: readOnly ? 'default' : 'pointer' }}>
|
||||
{skipPhoto ? (
|
||||
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
|
||||
) : (
|
||||
@@ -376,39 +405,43 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
<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>
|
||||
{!readOnly && (
|
||||
<div className="vessel-photo-overlay">
|
||||
<Camera size={24} />
|
||||
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<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}
|
||||
@@ -436,7 +469,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
className="input-text"
|
||||
value={skipName}
|
||||
onChange={(e) => setSkipName(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
disabled={savingSkipper || readOnly}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -448,7 +481,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
className="input-text"
|
||||
value={skipAddress}
|
||||
onChange={(e) => setSkipAddress(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
disabled={savingSkipper || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -459,7 +492,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
className="input-text"
|
||||
value={skipBirthDate}
|
||||
onChange={(e) => setSkipBirthDate(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
disabled={savingSkipper || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +503,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
className="input-text"
|
||||
value={skipPhone}
|
||||
onChange={(e) => setSkipPhone(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
disabled={savingSkipper || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -481,7 +514,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
className="input-text"
|
||||
value={skipNationality}
|
||||
onChange={(e) => setSkipNationality(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
disabled={savingSkipper || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -492,7 +525,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
className="input-text"
|
||||
value={skipPassport}
|
||||
onChange={(e) => setSkipPassport(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
disabled={savingSkipper || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -503,7 +536,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
className="input-text"
|
||||
value={skipBloodType}
|
||||
onChange={(e) => setSkipBloodType(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
disabled={savingSkipper || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -514,7 +547,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
className="input-text"
|
||||
value={skipAllergies}
|
||||
onChange={(e) => setSkipAllergies(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
disabled={savingSkipper || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -525,24 +558,26 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
className="input-text"
|
||||
value={skipDiseases}
|
||||
onChange={(e) => setSkipDiseases(e.target.value)}
|
||||
disabled={savingSkipper}
|
||||
disabled={savingSkipper || readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
{skipperSuccess && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('crew.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={savingSkipper || !skipName.trim()}>
|
||||
<Save size={18} />
|
||||
{t('crew.save')}
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="form-actions">
|
||||
{skipperSuccess && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('crew.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={savingSkipper || !skipName.trim()}>
|
||||
<Save size={18} />
|
||||
{t('crew.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -553,7 +588,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
<Users size={24} className="form-icon" />
|
||||
<h2>{t('crew.crew_section')}</h2>
|
||||
</div>
|
||||
{crewList.length < 5 && !showMemberForm && (
|
||||
{!readOnly && crewList.length < 5 && !showMemberForm && (
|
||||
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<Plus size={16} />
|
||||
{t('crew.add_crew')}
|
||||
@@ -761,14 +796,16 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
||||
)}
|
||||
<h4>{m.data.name}</h4>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="card-actions">
|
||||
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="crew-card-body">
|
||||
|
||||
@@ -9,9 +9,11 @@ import { Compass, Save, Check } from 'lucide-react'
|
||||
|
||||
interface DeviationFormProps {
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
preloadedData?: any
|
||||
}
|
||||
|
||||
export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
||||
export default function DeviationForm({ logbookId, readOnly = false, preloadedData }: DeviationFormProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Generate headings: 0, 10, 20, ..., 360 (37 items)
|
||||
@@ -29,6 +31,17 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedData) {
|
||||
const map: Record<number, string> = {}
|
||||
if (preloadedData.deviations) {
|
||||
Object.entries(preloadedData.deviations).forEach(([k, v]) => {
|
||||
map[Number(k)] = String(v)
|
||||
})
|
||||
}
|
||||
setDeviations(map)
|
||||
return
|
||||
}
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
@@ -71,6 +84,7 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
@@ -162,26 +176,28 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
||||
className="input-text cell-input"
|
||||
value={deviations[h] || ''}
|
||||
onChange={(e) => handleInputChange(h, e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-6">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('deviation.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving}>
|
||||
<Save size={18} />
|
||||
{saving ? t('deviation.saving') : t('deviation.save')}
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="form-actions mt-6">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('deviation.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving}>
|
||||
<Save size={18} />
|
||||
{saving ? t('deviation.saving') : t('deviation.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,11 @@ import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from
|
||||
|
||||
interface LogEntriesListProps {
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
preloadedYacht?: any
|
||||
preloadedEntries?: any[]
|
||||
preloadedPhotos?: any[]
|
||||
preloadedGpsTracks?: any[]
|
||||
}
|
||||
|
||||
interface DecryptedEntryItem {
|
||||
@@ -24,7 +29,14 @@ interface DecryptedEntryItem {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
export default function LogEntriesList({
|
||||
logbookId,
|
||||
readOnly = false,
|
||||
preloadedYacht,
|
||||
preloadedEntries,
|
||||
preloadedPhotos,
|
||||
preloadedGpsTracks
|
||||
}: LogEntriesListProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
|
||||
@@ -43,6 +55,26 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedEntries) {
|
||||
const list = preloadedEntries.map((entry: any) => ({
|
||||
id: entry.payloadId || entry.id,
|
||||
date: entry.date || '',
|
||||
dayOfTravel: entry.dayOfTravel || '',
|
||||
departure: entry.departure || '',
|
||||
destination: entry.destination || '',
|
||||
updatedAt: entry.updatedAt || new Date().toISOString()
|
||||
}))
|
||||
|
||||
list.sort((a, b) => {
|
||||
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
if (dateCompare !== 0) return dateCompare
|
||||
return Number(b.dayOfTravel) - Number(a.dayOfTravel)
|
||||
})
|
||||
|
||||
setEntries(list)
|
||||
return
|
||||
}
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
@@ -84,8 +116,12 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const title = localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
await downloadCsv(logbookId, title)
|
||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||
} else {
|
||||
await downloadCsv(logbookId, title)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download CSV:', err)
|
||||
setError(err.message || 'Failed to generate CSV export.')
|
||||
@@ -98,12 +134,20 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const title = localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
await shareCsv(logbookId, title)
|
||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
await shareCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||
} else {
|
||||
await shareCsv(logbookId, title)
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.message === 'share_unsupported') {
|
||||
const title = localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
await downloadCsv(logbookId, title)
|
||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||
} else {
|
||||
await downloadCsv(logbookId, title)
|
||||
}
|
||||
setError(t('logs.share_unsupported'))
|
||||
} else {
|
||||
console.error('Failed to share CSV:', err)
|
||||
@@ -119,7 +163,12 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
const fullEntry = preloadedEntries.find(entry => (entry.payloadId || entry.id) === entryId)
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date, { yacht: preloadedYacht, entry: fullEntry })
|
||||
} else {
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(err.message || 'Failed to generate PDF export.')
|
||||
@@ -129,6 +178,7 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (readOnly) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -188,7 +238,8 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
}
|
||||
|
||||
const handleDelete = async (entryId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent selecting the card
|
||||
e.stopPropagation()
|
||||
if (readOnly) return
|
||||
|
||||
if (await showConfirm(t('logs.delete_confirm'), t('logs.delete_entry'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
setError(null)
|
||||
@@ -221,6 +272,10 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
entryId={selectedEntryId}
|
||||
logbookId={logbookId}
|
||||
onBack={() => setSelectedEntryId(null)}
|
||||
readOnly={readOnly}
|
||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||
preloadedPhotos={preloadedPhotos}
|
||||
preloadedGpsTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -252,10 +307,12 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
<span className="hide-mobile">{t('logs.share_csv')}</span>
|
||||
</button>
|
||||
|
||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<Plus size={16} />
|
||||
{t('logs.new_entry')}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<Plus size={16} />
|
||||
{t('logs.new_entry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,9 +348,11 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
<Download size={18} />
|
||||
</button>
|
||||
|
||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,11 @@ interface LogEntryEditorProps {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
onBack: () => void
|
||||
readOnly?: boolean
|
||||
preloadedEntry?: any
|
||||
preloadedPhotos?: any[]
|
||||
preloadedGpsTrack?: any
|
||||
preloadedYacht?: any
|
||||
}
|
||||
|
||||
interface LogEvent {
|
||||
@@ -46,7 +51,16 @@ interface LogEvent {
|
||||
remarks: string
|
||||
}
|
||||
|
||||
export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryEditorProps) {
|
||||
export default function LogEntryEditor({
|
||||
entryId,
|
||||
logbookId,
|
||||
onBack,
|
||||
readOnly = false,
|
||||
preloadedEntry,
|
||||
preloadedPhotos,
|
||||
preloadedGpsTrack,
|
||||
preloadedYacht
|
||||
}: LogEntryEditorProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
|
||||
@@ -132,6 +146,10 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
// Load Yacht Sails
|
||||
useEffect(() => {
|
||||
async function loadYachtSails() {
|
||||
if (readOnly && preloadedYacht?.sails) {
|
||||
setYachtSails(preloadedYacht.sails)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
@@ -148,7 +166,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
}
|
||||
}
|
||||
loadYachtSails()
|
||||
}, [logbookId])
|
||||
}, [logbookId, preloadedYacht])
|
||||
|
||||
// Load entry details
|
||||
useEffect(() => {
|
||||
@@ -156,6 +174,29 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedEntry) {
|
||||
setDate(preloadedEntry.date || '')
|
||||
setDayOfTravel(preloadedEntry.dayOfTravel || '')
|
||||
setDeparture(preloadedEntry.departure || '')
|
||||
setDestination(preloadedEntry.destination || '')
|
||||
|
||||
if (preloadedEntry.freshwater) {
|
||||
setFwMorning(String(preloadedEntry.freshwater.morning || 0))
|
||||
setFwRefilled(String(preloadedEntry.freshwater.refilled || 0))
|
||||
setFwEvening(String(preloadedEntry.freshwater.evening || 0))
|
||||
}
|
||||
if (preloadedEntry.fuel) {
|
||||
setFuelMorning(String(preloadedEntry.fuel.morning || 0))
|
||||
setFuelRefilled(String(preloadedEntry.fuel.refilled || 0))
|
||||
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
||||
}
|
||||
|
||||
setSignSkipper(preloadedEntry.signSkipper || '')
|
||||
setSignCrew(preloadedEntry.signCrew || '')
|
||||
setEvents(preloadedEntry.events || [])
|
||||
return
|
||||
}
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
@@ -193,10 +234,14 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
}
|
||||
|
||||
loadEntry()
|
||||
}, [entryId])
|
||||
}, [entryId, preloadedEntry])
|
||||
|
||||
// GPS Track Loader
|
||||
const loadGpsTrack = async () => {
|
||||
if (readOnly && preloadedGpsTrack) {
|
||||
setSavedTrack(preloadedGpsTrack)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const track = await getDecryptedGpsTrack(entryId)
|
||||
setSavedTrack(track)
|
||||
@@ -207,7 +252,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
|
||||
useEffect(() => {
|
||||
loadGpsTrack()
|
||||
}, [entryId])
|
||||
}, [entryId, preloadedGpsTrack])
|
||||
|
||||
// Leaflet Map Initialization and Rendering
|
||||
useEffect(() => {
|
||||
@@ -277,6 +322,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
|
||||
// GPX/KML/GeoJSON Upload Handlers
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (readOnly) return
|
||||
setUploadError(null)
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (e) => {
|
||||
@@ -329,6 +375,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
}
|
||||
|
||||
const handleDeleteTrack = async () => {
|
||||
if (readOnly) return
|
||||
if (!window.confirm(t('logs.gps_track_delete_confirm'))) {
|
||||
return
|
||||
}
|
||||
@@ -366,6 +413,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
}
|
||||
|
||||
const handleGetGps = () => {
|
||||
if (readOnly) return
|
||||
const lookupFallback = async () => {
|
||||
const locationQuery = evLocationName.trim() || departure.trim() || destination.trim()
|
||||
if (!locationQuery) {
|
||||
@@ -526,7 +574,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
|
||||
const handleAddEvent = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!evTime) return
|
||||
if (readOnly || !evTime) return
|
||||
|
||||
const newEvent: LogEvent = {
|
||||
time: evTime,
|
||||
@@ -570,6 +618,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
}
|
||||
|
||||
const handleDeleteEvent = (index: number) => {
|
||||
if (readOnly) return
|
||||
setEvents((prev) => prev.filter((_, idx) => idx !== index))
|
||||
}
|
||||
|
||||
@@ -588,6 +637,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
@@ -715,20 +765,19 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
className="input-text"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.day_of_travel')} *</label>
|
||||
<label>{t('logs.day_of_travel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="e.g. 1"
|
||||
value={dayOfTravel}
|
||||
onChange={(e) => setDayOfTravel(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -738,10 +787,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="Starting port name"
|
||||
value={departure}
|
||||
onChange={(e) => setDeparture(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -750,10 +798,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder="Destination port name"
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -769,38 +816,35 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.morning')} (L)</label>
|
||||
<label>{t('logs.freshwater')} ({t('logs.morning')})</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
className="input-text"
|
||||
value={fwMorning}
|
||||
onChange={(e) => setFwMorning(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.refilled')} (L)</label>
|
||||
<label>{t('logs.freshwater')} ({t('logs.refilled')})</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
className="input-text"
|
||||
value={fwRefilled}
|
||||
onChange={(e) => setFwRefilled(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.evening')} (L)</label>
|
||||
<label>{t('logs.freshwater')} ({t('logs.evening')})</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
className="input-text"
|
||||
value={fwEvening}
|
||||
onChange={(e) => setFwEvening(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -825,38 +869,35 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.morning')} (L)</label>
|
||||
<label>{t('logs.fuel')} ({t('logs.morning')})</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
className="input-text"
|
||||
value={fuelMorning}
|
||||
onChange={(e) => setFuelMorning(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.refilled')} (L)</label>
|
||||
<label>{t('logs.fuel')} ({t('logs.refilled')})</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
className="input-text"
|
||||
value={fuelRefilled}
|
||||
onChange={(e) => setFuelRefilled(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.evening')} (L)</label>
|
||||
<label>{t('logs.fuel')} ({t('logs.evening')})</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
className="input-text"
|
||||
value={fuelEvening}
|
||||
onChange={(e) => setFuelEvening(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -899,7 +940,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
<th>{t('logs.event_log')}</th>
|
||||
<th>{t('logs.event_gps')}</th>
|
||||
<th>{t('logs.event_remarks')}</th>
|
||||
<th></th>
|
||||
{!readOnly && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -928,11 +969,13 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
|
||||
</td>
|
||||
<td className="remarks-td">{ev.remarks}</td>
|
||||
<td>
|
||||
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
{!readOnly && (
|
||||
<td>
|
||||
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -941,8 +984,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
)}
|
||||
|
||||
{/* Add New Event Form Sub-Card */}
|
||||
<div className="member-editor-card glass">
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
|
||||
{!readOnly && (
|
||||
<div className="member-editor-card glass">
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
<div className="input-group">
|
||||
@@ -1187,6 +1231,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
Add Event Entry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GPS Track Upload & Map Visualization */}
|
||||
@@ -1245,15 +1290,17 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
<Download size={14} />
|
||||
{t('logs.gps_tracking_btn_gpx')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleDeleteTrack}
|
||||
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('logs.gps_track_delete')}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleDeleteTrack}
|
||||
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('logs.gps_track_delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1263,7 +1310,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PhotoCapture entryId={entryId} logbookId={logbookId} />
|
||||
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
||||
|
||||
{/* Section 4: Sign-Off Signatures */}
|
||||
<div className="form-card">
|
||||
@@ -1280,7 +1327,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
className="input-text"
|
||||
value={signSkipper}
|
||||
onChange={(e) => setSignSkipper(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1292,26 +1339,28 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
||||
className="input-text"
|
||||
value={signCrew}
|
||||
onChange={(e) => setSignCrew(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Controls */}
|
||||
<div className="form-actions mt-4">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('logs.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
|
||||
<Save size={18} />
|
||||
{saving ? t('logs.saving') : t('logs.save')}
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="form-actions mt-4">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('logs.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
|
||||
<Save size={18} />
|
||||
{saving ? t('logs.saving') : t('logs.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Camera, Trash2 } from 'lucide-react'
|
||||
interface PhotoCaptureProps {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
preloadedPhotos?: any[]
|
||||
}
|
||||
|
||||
interface DecryptedPhoto {
|
||||
@@ -21,7 +23,7 @@ interface DecryptedPhoto {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) {
|
||||
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [caption, setCaption] = useState('')
|
||||
@@ -40,6 +42,19 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
|
||||
// Decrypt photos on query updates
|
||||
useEffect(() => {
|
||||
async function decryptPhotosList() {
|
||||
if (readOnly && preloadedPhotos) {
|
||||
const filtered = preloadedPhotos
|
||||
.filter((p: any) => p.entryId === entryId)
|
||||
.map((p: any) => ({
|
||||
payloadId: p.payloadId || p.id,
|
||||
image: p.image,
|
||||
caption: p.caption || '',
|
||||
updatedAt: p.updatedAt || new Date().toISOString()
|
||||
}))
|
||||
setDecryptedPhotos(filtered)
|
||||
return
|
||||
}
|
||||
|
||||
if (!localPhotos) return
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
@@ -65,7 +80,7 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
|
||||
}
|
||||
|
||||
decryptPhotosList()
|
||||
}, [localPhotos])
|
||||
}, [localPhotos, readOnly, preloadedPhotos, entryId])
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -197,45 +212,48 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{/* Upload area */}
|
||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
{/* Upload Form */}
|
||||
{!readOnly && (
|
||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
className="input-text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
className="input-text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
disabled={uploading}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerSelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerSelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo Grid */}
|
||||
{decryptedPhotos.length === 0 ? (
|
||||
@@ -246,14 +264,16 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
|
||||
<div key={photo.payloadId} className="photo-card glass">
|
||||
<div className="photo-container">
|
||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||
<button
|
||||
type="button"
|
||||
className="photo-btn-delete"
|
||||
onClick={() => handleDelete(photo.payloadId)}
|
||||
title="Remove photo"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="photo-btn-delete"
|
||||
onClick={() => handleDelete(photo.payloadId)}
|
||||
title="Remove photo"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{photo.caption && (
|
||||
<div className="photo-caption-bar">
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
||||
|
||||
interface ReadOnlyViewerProps {
|
||||
token: string
|
||||
hexKey: string
|
||||
}
|
||||
|
||||
// Convert Hex String back to ArrayBuffer
|
||||
const hexToBuffer = (hex: string): ArrayBuffer => {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'logs'>('logs')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Logbook data states
|
||||
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
||||
const [yacht, setYacht] = useState<any>(null)
|
||||
const [crews, setCrews] = useState<any[]>([])
|
||||
const [entries, setEntries] = useState<any[]>([])
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [token, hexKey])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const keyBuffer = hexToBuffer(hexKey)
|
||||
|
||||
const res = await fetch(`/api/collaboration/share-pull?token=${token}`)
|
||||
if (!res.ok) {
|
||||
if (res.status === 410) {
|
||||
throw new Error(i18n.language.startsWith('de') ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
|
||||
}
|
||||
throw new Error(i18n.language.startsWith('de') ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// Decrypt Title
|
||||
let decryptedTitle = 'Shared Logbook'
|
||||
if (data.title) {
|
||||
const parsed = JSON.parse(data.title)
|
||||
decryptedTitle = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, keyBuffer)
|
||||
}
|
||||
setLogbookTitle(decryptedTitle)
|
||||
|
||||
// Decrypt Yacht
|
||||
let decYacht = null
|
||||
if (data.yacht) {
|
||||
decYacht = await decryptJson(data.yacht.encryptedData, data.yacht.iv, data.yacht.tag, keyBuffer)
|
||||
}
|
||||
setYacht(decYacht)
|
||||
|
||||
// Decrypt Crews
|
||||
const decCrews = []
|
||||
if (data.crews) {
|
||||
for (const c of data.crews) {
|
||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
||||
decCrews.push({
|
||||
payloadId: c.payloadId,
|
||||
data: dec
|
||||
})
|
||||
}
|
||||
}
|
||||
setCrews(decCrews)
|
||||
|
||||
// Decrypt Entries
|
||||
const decEntries = []
|
||||
if (data.entries) {
|
||||
for (const e of data.entries) {
|
||||
const dec = await decryptJson(e.encryptedData, e.iv, e.tag, keyBuffer)
|
||||
decEntries.push({
|
||||
payloadId: e.payloadId,
|
||||
...dec
|
||||
})
|
||||
}
|
||||
}
|
||||
setEntries(decEntries)
|
||||
|
||||
// Decrypt Photos
|
||||
const decPhotos = []
|
||||
if (data.photos) {
|
||||
for (const p of data.photos) {
|
||||
const dec = await decryptJson(p.encryptedData, p.iv, p.tag, keyBuffer)
|
||||
decPhotos.push({
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
image: dec.image,
|
||||
caption: dec.caption || '',
|
||||
updatedAt: p.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
setPhotos(decPhotos)
|
||||
|
||||
// Decrypt GPS Tracks
|
||||
const decGpsTracks = []
|
||||
if (data.gpsTracks) {
|
||||
for (const tr of data.gpsTracks) {
|
||||
const dec = await decryptJson(tr.encryptedData, tr.iv, tr.tag, keyBuffer)
|
||||
decGpsTracks.push({
|
||||
entryId: tr.entryId,
|
||||
waypoints: dec.waypoints || dec || [],
|
||||
filename: dec.filename || 'track.gpx'
|
||||
})
|
||||
}
|
||||
}
|
||||
setGpsTracks(decGpsTracks)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
setError(err.message || 'Failed to decrypt logbook details.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Ship className="header-logo spin" size={48} />
|
||||
<p>{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '20px', textAlign: 'center' }}>
|
||||
<AlertCircle size={48} style={{ color: '#ef4444', marginBottom: '16px' }} />
|
||||
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}</h2>
|
||||
<p style={{ color: '#94a3b8', maxWidth: '400px', marginBottom: '24px' }}>{error}</p>
|
||||
<button className="btn primary" onClick={loadData} style={{ width: 'auto' }}>
|
||||
{i18n.language.startsWith('de') ? 'Erneut versuchen' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{/* Top Banner indicating read-only shared view */}
|
||||
<div className="sync-progress-bar" style={{ height: '4px', background: 'linear-gradient(90deg, #10b981, #3b82f6)' }} />
|
||||
|
||||
<header className="app-header" style={{ borderBottom: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
<div className="app-header-left">
|
||||
<div className="app-title-area" style={{ marginLeft: 0 }}>
|
||||
<h2>{logbookTitle}</h2>
|
||||
<p className="app-subtitle" style={{ color: '#10b981', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Lock size={12} />
|
||||
<span>{i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-body">
|
||||
<aside className="app-sidebar">
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
>
|
||||
<FileText size={18} />
|
||||
{t('nav.logs')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vessel')}
|
||||
>
|
||||
<Ship size={18} />
|
||||
{t('nav.vessel')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="app-content">
|
||||
{activeTab === 'logs' && (
|
||||
<LogEntriesList
|
||||
logbookId="shared"
|
||||
readOnly={true}
|
||||
preloadedYacht={yacht}
|
||||
preloadedEntries={entries}
|
||||
preloadedPhotos={photos}
|
||||
preloadedGpsTracks={gpsTracks}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm
|
||||
logbookId="shared"
|
||||
readOnly={true}
|
||||
preloadedData={yacht}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm
|
||||
logbookId="shared"
|
||||
readOnly={true}
|
||||
preloadedData={crews}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,12 +40,101 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const [collabError, setCollabError] = useState<string | null>(null)
|
||||
const [loadingCollabs, setLoadingCollabs] = useState(false)
|
||||
|
||||
// Public Share Link States
|
||||
const [shareEnabled, setShareEnabled] = useState(false)
|
||||
const [shareLink, setShareLink] = useState('')
|
||||
const [shareCopied, setShareCopied] = useState(false)
|
||||
const [loadingShareLink, setLoadingShareLink] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (logbookId) {
|
||||
loadCollaborators()
|
||||
loadShareLink()
|
||||
}
|
||||
}, [logbookId])
|
||||
|
||||
const loadShareLink = async () => {
|
||||
if (!logbookId) return
|
||||
setLoadingShareLink(true)
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/collaboration/share-link?logbookId=${logbookId}`, {
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.token) {
|
||||
setShareEnabled(true)
|
||||
const logbookKey = await ensureLogbookKey(logbookId)
|
||||
const hexKey = bufferToHex(logbookKey)
|
||||
setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`)
|
||||
} else {
|
||||
setShareEnabled(false)
|
||||
setShareLink('')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load share link:', err)
|
||||
} finally {
|
||||
setLoadingShareLink(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleShare = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!logbookId) return
|
||||
const checked = e.target.checked
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
|
||||
setLoadingShareLink(true)
|
||||
try {
|
||||
const res = await fetch('/api/collaboration/share-link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({ logbookId, enabled: checked })
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (checked && data.token) {
|
||||
setShareEnabled(true)
|
||||
const logbookKey = await ensureLogbookKey(logbookId)
|
||||
const hexKey = bufferToHex(logbookKey)
|
||||
setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`)
|
||||
showAlert('Public share link enabled!')
|
||||
} else {
|
||||
setShareEnabled(false)
|
||||
setShareLink('')
|
||||
showAlert('Public share link disabled.')
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to toggle public share link.')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Toggle share link failed:', err)
|
||||
showAlert(err.message || 'Failed to update public share link.')
|
||||
} finally {
|
||||
setLoadingShareLink(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyShareLink = () => {
|
||||
if (shareLink) {
|
||||
navigator.clipboard.writeText(shareLink)
|
||||
setShareCopied(true)
|
||||
setTimeout(() => setShareCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const loadCollaborators = async () => {
|
||||
setLoadingCollabs(true)
|
||||
setCollabError(null)
|
||||
@@ -247,6 +336,57 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Public Share Link Card (Only visible to Logbook Owner) */}
|
||||
{logbookId && isOwner && (
|
||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<LinkIcon size={20} style={{ color: '#fbbf24' }} />
|
||||
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
|
||||
{t('settings.share_title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.share_desc')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
|
||||
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareEnabled}
|
||||
onChange={handleToggleShare}
|
||||
disabled={loadingShareLink}
|
||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||
/>
|
||||
<span>{t('settings.share_enable')}</span>
|
||||
</label>
|
||||
{loadingShareLink && <span style={{ fontSize: '12px', color: '#64748b' }}>Updating...</span>}
|
||||
</div>
|
||||
|
||||
{shareEnabled && shareLink && (
|
||||
<div className="input-group mb-4" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyShareLink}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
>
|
||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
|
||||
{logbookId && isOwner && (
|
||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||
|
||||
@@ -9,9 +9,11 @@ import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||
|
||||
interface VesselFormProps {
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
preloadedData?: any
|
||||
}
|
||||
|
||||
export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
export default function VesselForm({ logbookId, readOnly = false, preloadedData }: VesselFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [homePort, setHomePort] = useState('')
|
||||
@@ -39,6 +41,20 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedData) {
|
||||
setName(preloadedData.name || '')
|
||||
setHomePort(preloadedData.homePort || '')
|
||||
setCharterCompany(preloadedData.charterCompany || '')
|
||||
setOwner(preloadedData.owner || '')
|
||||
setRegistrationNumber(preloadedData.registrationNumber || '')
|
||||
setCallSign(preloadedData.callSign || '')
|
||||
setAtis(preloadedData.atis || '')
|
||||
setMmsi(preloadedData.mmsi || '')
|
||||
setSails(preloadedData.sails || [])
|
||||
setPhoto(preloadedData.photo || null)
|
||||
return
|
||||
}
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
@@ -143,6 +159,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
@@ -221,7 +238,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
<form onSubmit={handleSubmit} className="vessel-form">
|
||||
<div className="form-grid">
|
||||
<div className="vessel-photo-wrapper">
|
||||
<div className="vessel-photo-preview" onClick={triggerFileInput}>
|
||||
<div className="vessel-photo-preview" onClick={readOnly ? undefined : triggerFileInput} style={{ cursor: readOnly ? 'default' : 'pointer' }}>
|
||||
{photo ? (
|
||||
<img src={photo} alt={name || 'Yacht'} className="vessel-photo" />
|
||||
) : (
|
||||
@@ -229,36 +246,40 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
<Ship size={48} className="placeholder-icon" />
|
||||
</div>
|
||||
)}
|
||||
<div className="vessel-photo-overlay">
|
||||
<Camera size={24} />
|
||||
<span>{photo ? 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={triggerFileInput}
|
||||
disabled={saving}
|
||||
>
|
||||
<Camera size={16} />
|
||||
{photo ? t('vessel.photo_change') : t('vessel.photo_add')}
|
||||
</button>
|
||||
|
||||
{photo && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger btn-sm"
|
||||
onClick={handleRemovePhoto}
|
||||
disabled={saving}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('vessel.photo_delete')}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<div className="vessel-photo-overlay">
|
||||
<Camera size={24} />
|
||||
<span>{photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="vessel-photo-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary btn-sm"
|
||||
onClick={triggerFileInput}
|
||||
disabled={saving}
|
||||
>
|
||||
<Camera size={16} />
|
||||
{photo ? t('vessel.photo_change') : t('vessel.photo_add')}
|
||||
</button>
|
||||
|
||||
{photo && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger btn-sm"
|
||||
onClick={handleRemovePhoto}
|
||||
disabled={saving}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('vessel.photo_delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
@@ -276,7 +297,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
className="input-text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -288,7 +309,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
className="input-text"
|
||||
value={homePort}
|
||||
onChange={(e) => setHomePort(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -299,7 +320,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
className="input-text"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -310,7 +331,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
className="input-text"
|
||||
value={charterCompany}
|
||||
onChange={(e) => setCharterCompany(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -321,7 +342,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
className="input-text"
|
||||
value={registrationNumber}
|
||||
onChange={(e) => setRegistrationNumber(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -332,7 +353,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
className="input-text"
|
||||
value={callSign}
|
||||
onChange={(e) => setCallSign(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -343,7 +364,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
className="input-text"
|
||||
value={atis}
|
||||
onChange={(e) => setAtis(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -354,7 +375,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
className="input-text"
|
||||
value={mmsi}
|
||||
onChange={(e) => setMmsi(e.target.value)}
|
||||
disabled={saving}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -369,61 +390,67 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
||||
sails.map((sail, idx) => (
|
||||
<span key={idx} className="sail-badge">
|
||||
{sail}
|
||||
<button
|
||||
type="button"
|
||||
className="remove-btn"
|
||||
onClick={() => handleRemoveSail(idx)}
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="remove-btn"
|
||||
onClick={() => handleRemoveSail(idx)}
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="add-sail-form">
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder={t('vessel.sail_name_placeholder')}
|
||||
value={newSailName}
|
||||
onChange={(e) => setNewSailName(e.target.value)}
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddSail();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleAddSail}
|
||||
disabled={saving || !newSailName.trim()}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('vessel.add_sail')}
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="add-sail-form">
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder={t('vessel.sail_name_placeholder')}
|
||||
value={newSailName}
|
||||
onChange={(e) => setNewSailName(e.target.value)}
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddSail();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleAddSail}
|
||||
disabled={saving || !newSailName.trim()}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('vessel.add_sail')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('vessel.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving || !name.trim()}>
|
||||
<Save size={18} />
|
||||
{saving ? t('vessel.saving') : t('vessel.save')}
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="form-actions">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('vessel.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving || !name.trim()}>
|
||||
<Save size={18} />
|
||||
{saving ? t('vessel.saving') : t('vessel.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user