Implement E2E-compliant anonymous read-only logbook sharing links
This commit is contained in:
@@ -11,6 +11,7 @@ import SettingsForm from './components/SettingsForm.tsx'
|
||||
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||
import { db } from './services/db.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
|
||||
@@ -27,6 +28,11 @@ function App() {
|
||||
const [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean')
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
|
||||
// Viewer mode for read-only shared links
|
||||
const [isViewerMode, setIsViewerMode] = useState(false)
|
||||
const [shareToken, setShareToken] = useState('')
|
||||
const [shareKey, setShareKey] = useState('')
|
||||
|
||||
const syncQueueCount = useLiveQuery(
|
||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||
[activeLogbookId]
|
||||
@@ -89,6 +95,15 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
||||
|
||||
if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) {
|
||||
setShareToken(params.get('token') || '')
|
||||
setShareKey(hashParams.get('key') || '')
|
||||
setIsViewerMode(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (params.has('token')) {
|
||||
setIsAcceptingInvite(true)
|
||||
}
|
||||
@@ -139,6 +154,14 @@ function App() {
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
if (isViewerMode) {
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<ReadOnlyViewer token={shareToken} hexKey={shareKey} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAcceptingInvite) {
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -209,7 +209,12 @@
|
||||
"theme_auto": "Automatisch (OS-Erkennung)",
|
||||
"theme_ocean": "Ocean (Glassmorphismus)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)"
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||
"share_desc": "Aktivieren Sie diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann Ihre Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
|
||||
"share_enable": "Öffentlichen Link aktivieren",
|
||||
"share_copied": "Link kopiert!",
|
||||
"share_copy_btn": "Link kopieren"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,12 @@
|
||||
"theme_auto": "Auto (OS Detect)",
|
||||
"theme_ocean": "Ocean (Glassmorphism)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)"
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"share_title": "Share Logbook (Read-Only)",
|
||||
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
|
||||
"share_enable": "Enable Public Link",
|
||||
"share_copied": "Link copied!",
|
||||
"share_copy_btn": "Copy Link"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,42 +12,56 @@ function escapeCsvValue(val: string | number | undefined | null): string {
|
||||
return str;
|
||||
}
|
||||
|
||||
export async function exportLogbookToCsv(logbookId: string): Promise<string> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
export async function exportLogbookToCsv(logbookId: string, preloadedData?: { yacht: any; entries: any[] }): Promise<string> {
|
||||
let yachtName = '', homePort = '', owner = '', charter = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
owner = yacht.owner || '';
|
||||
charter = yacht.charter || '';
|
||||
registration = yacht.registration || '';
|
||||
callsign = yacht.callsign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for CSV:', e);
|
||||
}
|
||||
}
|
||||
let decryptedEntries: any[] = [];
|
||||
|
||||
// 2. Fetch logbook entries
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray();
|
||||
const decryptedEntries = [];
|
||||
for (const entry of localEntries) {
|
||||
try {
|
||||
const dec = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey);
|
||||
if (dec) {
|
||||
decryptedEntries.push(dec);
|
||||
if (preloadedData) {
|
||||
const yacht = preloadedData.yacht || {};
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
owner = yacht.owner || '';
|
||||
charter = yacht.charter || '';
|
||||
registration = yacht.registration || '';
|
||||
callsign = yacht.callsign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
decryptedEntries = [...preloadedData.entries];
|
||||
} else {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
owner = yacht.owner || '';
|
||||
charter = yacht.charter || '';
|
||||
registration = yacht.registration || '';
|
||||
callsign = yacht.callsign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for CSV:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch logbook entries
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray();
|
||||
for (const entry of localEntries) {
|
||||
try {
|
||||
const dec = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey);
|
||||
if (dec) {
|
||||
decryptedEntries.push(dec);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt entry for CSV:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt entry for CSV:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +141,8 @@ export async function exportLogbookToCsv(logbookId: string): Promise<string> {
|
||||
return rows.map(r => r.join(',')).join('\n');
|
||||
}
|
||||
|
||||
export async function downloadCsv(logbookId: string, title: string): Promise<void> {
|
||||
const csvContent = await exportLogbookToCsv(logbookId);
|
||||
export async function downloadCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise<void> {
|
||||
const csvContent = await exportLogbookToCsv(logbookId, preloadedData);
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
@@ -142,8 +156,8 @@ export async function downloadCsv(logbookId: string, title: string): Promise<voi
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
export async function shareCsv(logbookId: string, title: string): Promise<void> {
|
||||
const csvContent = await exportLogbookToCsv(logbookId);
|
||||
export async function shareCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise<void> {
|
||||
const csvContent = await exportLogbookToCsv(logbookId, preloadedData);
|
||||
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`;
|
||||
|
||||
const file = new File([csvContent], filename, { type: 'text/csv' });
|
||||
@@ -158,7 +172,7 @@ export async function shareCsv(logbookId: string, title: string): Promise<void>
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error('Sharing failed, falling back to download:', e);
|
||||
await downloadCsv(logbookId, title);
|
||||
await downloadCsv(logbookId, title, preloadedData);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -4,41 +4,55 @@ import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string): Promise<jsPDF> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption key not found. Please log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
// owner not needed in PDF layout
|
||||
registration = yacht.registrationNumber || yacht.registration || '';
|
||||
callsign = yacht.callSign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for PDF:', e);
|
||||
let entry: any = null;
|
||||
|
||||
if (preloadedData) {
|
||||
const yacht = preloadedData.yacht || {};
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
registration = yacht.registrationNumber || yacht.registration || '';
|
||||
callsign = yacht.callSign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
entry = preloadedData.entry;
|
||||
} else {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption key not found. Please log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
registration = yacht.registrationNumber || yacht.registration || '';
|
||||
callsign = yacht.callSign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for PDF:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch active Entry
|
||||
const entryRecord = await db.entries.get(entryId);
|
||||
if (!entryRecord) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
entry = await decryptJson(entryRecord.encryptedData, entryRecord.iv, entryRecord.tag, masterKey);
|
||||
}
|
||||
|
||||
// 2. Fetch active Entry
|
||||
const entryRecord = await db.entries.get(entryId);
|
||||
if (!entryRecord) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
const entry = await decryptJson(entryRecord.encryptedData, entryRecord.iv, entryRecord.tag, masterKey);
|
||||
if (!entry) {
|
||||
throw new Error('Failed to decrypt entry');
|
||||
throw new Error('Failed to load entry');
|
||||
}
|
||||
|
||||
|
||||
// Create PDF landscape A4
|
||||
const doc = new jsPDF({
|
||||
orientation: 'landscape',
|
||||
@@ -217,8 +231,8 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string)
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function downloadLogbookPagePdf(logbookId: string, entryId: string, dateStr: string): Promise<void> {
|
||||
const doc = await generateLogbookPagePdf(logbookId, entryId);
|
||||
export async function downloadLogbookPagePdf(logbookId: string, entryId: string, dateStr: string, preloadedData?: { yacht: any; entry: any }): Promise<void> {
|
||||
const doc = await generateLogbookPagePdf(logbookId, entryId, preloadedData);
|
||||
const filename = `logbook_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
doc.save(filename);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,58 @@ router.get('/invite-details', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 1b. Public read-only share pull endpoint (does not require authentication)
|
||||
router.get('/share-pull', async (req: any, res) => {
|
||||
try {
|
||||
const { token } = req.query
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'Token is required' })
|
||||
}
|
||||
|
||||
const invitation = await prisma.invitation.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
logbook: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!invitation) {
|
||||
return res.status(404).json({ error: 'Share link not found' })
|
||||
}
|
||||
|
||||
if (new Date() > invitation.expiresAt) {
|
||||
return res.status(410).json({ error: 'Share link has expired' })
|
||||
}
|
||||
|
||||
if (invitation.role !== 'READ') {
|
||||
return res.status(403).json({ error: 'Forbidden: Invalid role for public pull' })
|
||||
}
|
||||
|
||||
const logbookId = invitation.logbookId
|
||||
|
||||
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
|
||||
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
|
||||
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
|
||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||
|
||||
return res.json({
|
||||
title: invitation.logbook.encryptedTitle,
|
||||
yacht,
|
||||
deviation,
|
||||
crews,
|
||||
entries,
|
||||
photos,
|
||||
gpsTracks
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error in share-pull:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 2. Accept invitation (requires authenticated invitee)
|
||||
router.post('/accept', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
@@ -240,4 +292,111 @@ router.delete('/collaborators/:id', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 6. Get public share link for a logbook (Owner only)
|
||||
router.get('/share-link', async (req: any, res) => {
|
||||
try {
|
||||
const { logbookId } = req.query
|
||||
if (!logbookId) {
|
||||
return res.status(400).json({ error: 'logbookId is required' })
|
||||
}
|
||||
|
||||
const logbook = await prisma.logbook.findUnique({
|
||||
where: { id: logbookId }
|
||||
})
|
||||
|
||||
if (!logbook) {
|
||||
return res.status(404).json({ error: 'Logbook not found' })
|
||||
}
|
||||
|
||||
if (logbook.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
const invitation = await prisma.invitation.findFirst({
|
||||
where: {
|
||||
logbookId,
|
||||
role: 'READ',
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({
|
||||
token: invitation ? invitation.token : null,
|
||||
expiresAt: invitation ? invitation.expiresAt : null
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching share link:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// 7. Toggle public share link for a logbook (Owner only)
|
||||
router.post('/share-link', async (req: any, res) => {
|
||||
try {
|
||||
const { logbookId, enabled } = req.body
|
||||
if (!logbookId || typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'logbookId and enabled are required' })
|
||||
}
|
||||
|
||||
const logbook = await prisma.logbook.findUnique({
|
||||
where: { id: logbookId }
|
||||
})
|
||||
|
||||
if (!logbook) {
|
||||
return res.status(404).json({ error: 'Logbook not found' })
|
||||
}
|
||||
|
||||
if (logbook.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// Find existing active read-only invitation
|
||||
let invitation = await prisma.invitation.findFirst({
|
||||
where: {
|
||||
logbookId,
|
||||
role: 'READ',
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!invitation) {
|
||||
// Create one that lasts 100 years
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + 100)
|
||||
|
||||
invitation = await prisma.invitation.create({
|
||||
data: {
|
||||
logbookId,
|
||||
role: 'READ',
|
||||
expiresAt
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
token: invitation.token,
|
||||
expiresAt: invitation.expiresAt
|
||||
})
|
||||
} else {
|
||||
// Delete any READ invitations
|
||||
await prisma.invitation.deleteMany({
|
||||
where: {
|
||||
logbookId,
|
||||
role: 'READ'
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error toggling share link:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user