Implement E2E-compliant anonymous read-only logbook sharing links

This commit is contained in:
2026-05-28 20:47:52 +02:00
parent b3978ed294
commit 20ff2a0baa
14 changed files with 1172 additions and 359 deletions
+102 -65
View File
@@ -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">
+31 -15
View File
@@ -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>
)
+75 -16
View File
@@ -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>
+113 -64
View File
@@ -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>
)
+65 -45
View File
@@ -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">
+245
View File
@@ -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>
)
}
+140
View File
@@ -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' }}>
+111 -84
View File
@@ -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>
)