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 InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||||
|
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||||
import { db } from './services/db.js'
|
import { db } from './services/db.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
|
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 [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean')
|
||||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
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(
|
const syncQueueCount = useLiveQuery(
|
||||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||||
[activeLogbookId]
|
[activeLogbookId]
|
||||||
@@ -89,6 +95,15 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
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')) {
|
if (params.has('token')) {
|
||||||
setIsAcceptingInvite(true)
|
setIsAcceptingInvite(true)
|
||||||
}
|
}
|
||||||
@@ -139,6 +154,14 @@ function App() {
|
|||||||
localStorage.removeItem('active_logbook_title')
|
localStorage.removeItem('active_logbook_title')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isViewerMode) {
|
||||||
|
return (
|
||||||
|
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||||
|
<ReadOnlyViewer token={shareToken} hexKey={shareKey} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isAcceptingInvite) {
|
if (isAcceptingInvite) {
|
||||||
return (
|
return (
|
||||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
<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 {
|
interface CrewFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
preloadedData?: any[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CrewMemberData {
|
interface CrewMemberData {
|
||||||
@@ -31,7 +33,7 @@ interface DecryptedCrew {
|
|||||||
data: CrewMemberData
|
data: CrewMemberData
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrewForm({ logbookId }: CrewFormProps) {
|
export default function CrewForm({ logbookId, readOnly = false, preloadedData }: CrewFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCrewData()
|
loadCrewData()
|
||||||
}, [logbookId])
|
}, [logbookId, preloadedData])
|
||||||
|
|
||||||
const resizeImageFile = (file: File): Promise<string> => {
|
const resizeImageFile = (file: File): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -124,6 +126,31 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
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) => {
|
const handleSaveSkipper = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (readOnly) return
|
||||||
setSavingSkipper(true)
|
setSavingSkipper(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSkipperSuccess(false)
|
setSkipperSuccess(false)
|
||||||
@@ -253,7 +281,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
|
|
||||||
const handleSaveMember = async (e: React.FormEvent) => {
|
const handleSaveMember = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!memName.trim()) return
|
if (readOnly || !memName.trim()) return
|
||||||
|
|
||||||
setSavingMember(true)
|
setSavingMember(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -319,6 +347,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteMember = async (memberId: string) => {
|
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'))) {
|
if (await showConfirm(t('crew.delete_confirm'), t('crew.title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -368,7 +397,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
<form onSubmit={handleSaveSkipper} className="vessel-form">
|
<form onSubmit={handleSaveSkipper} className="vessel-form">
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<div className="vessel-photo-wrapper">
|
<div className="vessel-photo-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 ? (
|
{skipPhoto ? (
|
||||||
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
|
<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" />
|
<User size={48} className="placeholder-icon" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="vessel-photo-overlay">
|
{!readOnly && (
|
||||||
<Camera size={24} />
|
<div className="vessel-photo-overlay">
|
||||||
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
<Camera size={24} />
|
||||||
</div>
|
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="vessel-photo-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary btn-sm"
|
|
||||||
onClick={() => skipFileInputRef.current?.click()}
|
|
||||||
disabled={savingSkipper}
|
|
||||||
>
|
|
||||||
<Camera size={16} />
|
|
||||||
{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{skipPhoto && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn danger btn-sm"
|
|
||||||
onClick={() => {
|
|
||||||
setSkipPhoto(null)
|
|
||||||
if (skipFileInputRef.current) skipFileInputRef.current.value = ''
|
|
||||||
}}
|
|
||||||
disabled={savingSkipper}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
{t('vessel.photo_delete')}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={skipFileInputRef}
|
ref={skipFileInputRef}
|
||||||
@@ -436,7 +469,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipName}
|
value={skipName}
|
||||||
onChange={(e) => setSkipName(e.target.value)}
|
onChange={(e) => setSkipName(e.target.value)}
|
||||||
disabled={savingSkipper}
|
disabled={savingSkipper || readOnly}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,7 +481,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipAddress}
|
value={skipAddress}
|
||||||
onChange={(e) => setSkipAddress(e.target.value)}
|
onChange={(e) => setSkipAddress(e.target.value)}
|
||||||
disabled={savingSkipper}
|
disabled={savingSkipper || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -459,7 +492,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipBirthDate}
|
value={skipBirthDate}
|
||||||
onChange={(e) => setSkipBirthDate(e.target.value)}
|
onChange={(e) => setSkipBirthDate(e.target.value)}
|
||||||
disabled={savingSkipper}
|
disabled={savingSkipper || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -470,7 +503,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipPhone}
|
value={skipPhone}
|
||||||
onChange={(e) => setSkipPhone(e.target.value)}
|
onChange={(e) => setSkipPhone(e.target.value)}
|
||||||
disabled={savingSkipper}
|
disabled={savingSkipper || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -481,7 +514,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipNationality}
|
value={skipNationality}
|
||||||
onChange={(e) => setSkipNationality(e.target.value)}
|
onChange={(e) => setSkipNationality(e.target.value)}
|
||||||
disabled={savingSkipper}
|
disabled={savingSkipper || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -492,7 +525,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipPassport}
|
value={skipPassport}
|
||||||
onChange={(e) => setSkipPassport(e.target.value)}
|
onChange={(e) => setSkipPassport(e.target.value)}
|
||||||
disabled={savingSkipper}
|
disabled={savingSkipper || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -503,7 +536,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipBloodType}
|
value={skipBloodType}
|
||||||
onChange={(e) => setSkipBloodType(e.target.value)}
|
onChange={(e) => setSkipBloodType(e.target.value)}
|
||||||
disabled={savingSkipper}
|
disabled={savingSkipper || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -514,7 +547,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipAllergies}
|
value={skipAllergies}
|
||||||
onChange={(e) => setSkipAllergies(e.target.value)}
|
onChange={(e) => setSkipAllergies(e.target.value)}
|
||||||
disabled={savingSkipper}
|
disabled={savingSkipper || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -525,24 +558,26 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={skipDiseases}
|
value={skipDiseases}
|
||||||
onChange={(e) => setSkipDiseases(e.target.value)}
|
onChange={(e) => setSkipDiseases(e.target.value)}
|
||||||
disabled={savingSkipper}
|
disabled={savingSkipper || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
{!readOnly && (
|
||||||
{skipperSuccess && (
|
<div className="form-actions">
|
||||||
<div className="success-toast">
|
{skipperSuccess && (
|
||||||
<Check size={16} />
|
<div className="success-toast">
|
||||||
<span>{t('crew.saved')}</span>
|
<Check size={16} />
|
||||||
</div>
|
<span>{t('crew.saved')}</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<button type="submit" className="btn primary" disabled={savingSkipper || !skipName.trim()}>
|
|
||||||
<Save size={18} />
|
<button type="submit" className="btn primary" disabled={savingSkipper || !skipName.trim()}>
|
||||||
{t('crew.save')}
|
<Save size={18} />
|
||||||
</button>
|
{t('crew.save')}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -553,7 +588,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
<Users size={24} className="form-icon" />
|
<Users size={24} className="form-icon" />
|
||||||
<h2>{t('crew.crew_section')}</h2>
|
<h2>{t('crew.crew_section')}</h2>
|
||||||
</div>
|
</div>
|
||||||
{crewList.length < 5 && !showMemberForm && (
|
{!readOnly && crewList.length < 5 && !showMemberForm && (
|
||||||
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t('crew.add_crew')}
|
{t('crew.add_crew')}
|
||||||
@@ -761,14 +796,16 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
|
|||||||
)}
|
)}
|
||||||
<h4>{m.data.name}</h4>
|
<h4>{m.data.name}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-actions">
|
{!readOnly && (
|
||||||
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
<div className="card-actions">
|
||||||
<Edit2 size={14} />
|
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
||||||
</button>
|
<Edit2 size={14} />
|
||||||
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
</button>
|
||||||
<Trash2 size={14} />
|
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
||||||
</button>
|
<Trash2 size={14} />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="crew-card-body">
|
<div className="crew-card-body">
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { Compass, Save, Check } from 'lucide-react'
|
|||||||
|
|
||||||
interface DeviationFormProps {
|
interface DeviationFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
preloadedData?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
export default function DeviationForm({ logbookId, readOnly = false, preloadedData }: DeviationFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// Generate headings: 0, 10, 20, ..., 360 (37 items)
|
// Generate headings: 0, 10, 20, ..., 360 (37 items)
|
||||||
@@ -29,6 +31,17 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (readOnly) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
@@ -162,26 +176,28 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
|
|||||||
className="input-text cell-input"
|
className="input-text cell-input"
|
||||||
value={deviations[h] || ''}
|
value={deviations[h] || ''}
|
||||||
onChange={(e) => handleInputChange(h, e.target.value)}
|
onChange={(e) => handleInputChange(h, e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions mt-6">
|
{!readOnly && (
|
||||||
{success && (
|
<div className="form-actions mt-6">
|
||||||
<div className="success-toast">
|
{success && (
|
||||||
<Check size={16} />
|
<div className="success-toast">
|
||||||
<span>{t('deviation.saved')}</span>
|
<Check size={16} />
|
||||||
</div>
|
<span>{t('deviation.saved')}</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<button type="submit" className="btn primary" disabled={saving}>
|
|
||||||
<Save size={18} />
|
<button type="submit" className="btn primary" disabled={saving}>
|
||||||
{saving ? t('deviation.saving') : t('deviation.save')}
|
<Save size={18} />
|
||||||
</button>
|
{saving ? t('deviation.saving') : t('deviation.save')}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from
|
|||||||
|
|
||||||
interface LogEntriesListProps {
|
interface LogEntriesListProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
preloadedYacht?: any
|
||||||
|
preloadedEntries?: any[]
|
||||||
|
preloadedPhotos?: any[]
|
||||||
|
preloadedGpsTracks?: any[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DecryptedEntryItem {
|
interface DecryptedEntryItem {
|
||||||
@@ -24,7 +29,14 @@ interface DecryptedEntryItem {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
export default function LogEntriesList({
|
||||||
|
logbookId,
|
||||||
|
readOnly = false,
|
||||||
|
preloadedYacht,
|
||||||
|
preloadedEntries,
|
||||||
|
preloadedPhotos,
|
||||||
|
preloadedGpsTracks
|
||||||
|
}: LogEntriesListProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
|
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
|
||||||
@@ -43,6 +55,26 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
@@ -84,8 +116,12 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
setExporting(true)
|
setExporting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const title = localStorage.getItem('active_logbook_title') || 'Logbook'
|
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||||
await downloadCsv(logbookId, title)
|
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||||
|
await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||||
|
} else {
|
||||||
|
await downloadCsv(logbookId, title)
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download CSV:', err)
|
console.error('Failed to download CSV:', err)
|
||||||
setError(err.message || 'Failed to generate CSV export.')
|
setError(err.message || 'Failed to generate CSV export.')
|
||||||
@@ -98,12 +134,20 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
setExporting(true)
|
setExporting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const title = localStorage.getItem('active_logbook_title') || 'Logbook'
|
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||||
await shareCsv(logbookId, title)
|
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||||
|
await shareCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||||
|
} else {
|
||||||
|
await shareCsv(logbookId, title)
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.message === 'share_unsupported') {
|
if (err.message === 'share_unsupported') {
|
||||||
const title = localStorage.getItem('active_logbook_title') || 'Logbook'
|
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||||
await downloadCsv(logbookId, title)
|
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||||
|
await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||||
|
} else {
|
||||||
|
await downloadCsv(logbookId, title)
|
||||||
|
}
|
||||||
setError(t('logs.share_unsupported'))
|
setError(t('logs.share_unsupported'))
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to share CSV:', err)
|
console.error('Failed to share CSV:', err)
|
||||||
@@ -119,7 +163,12 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
setExporting(true)
|
setExporting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download PDF:', err)
|
console.error('Failed to download PDF:', err)
|
||||||
setError(err.message || 'Failed to generate PDF export.')
|
setError(err.message || 'Failed to generate PDF export.')
|
||||||
@@ -129,6 +178,7 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
|
if (readOnly) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -188,7 +238,8 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (entryId: string, e: React.MouseEvent) => {
|
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'))) {
|
if (await showConfirm(t('logs.delete_confirm'), t('logs.delete_entry'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -221,6 +272,10 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
entryId={selectedEntryId}
|
entryId={selectedEntryId}
|
||||||
logbookId={logbookId}
|
logbookId={logbookId}
|
||||||
onBack={() => setSelectedEntryId(null)}
|
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>
|
<span className="hide-mobile">{t('logs.share_csv')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
|
{!readOnly && (
|
||||||
<Plus size={16} />
|
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||||
{t('logs.new_entry')}
|
<Plus size={16} />
|
||||||
</button>
|
{t('logs.new_entry')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -291,9 +348,11 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
{!readOnly && (
|
||||||
<Trash2 size={18} />
|
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||||
</button>
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ interface LogEntryEditorProps {
|
|||||||
entryId: string
|
entryId: string
|
||||||
logbookId: string
|
logbookId: string
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
readOnly?: boolean
|
||||||
|
preloadedEntry?: any
|
||||||
|
preloadedPhotos?: any[]
|
||||||
|
preloadedGpsTrack?: any
|
||||||
|
preloadedYacht?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LogEvent {
|
interface LogEvent {
|
||||||
@@ -46,7 +51,16 @@ interface LogEvent {
|
|||||||
remarks: string
|
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 { t, i18n } = useTranslation()
|
||||||
const { showAlert } = useDialog()
|
const { showAlert } = useDialog()
|
||||||
|
|
||||||
@@ -132,6 +146,10 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
// Load Yacht Sails
|
// Load Yacht Sails
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadYachtSails() {
|
async function loadYachtSails() {
|
||||||
|
if (readOnly && preloadedYacht?.sails) {
|
||||||
|
setYachtSails(preloadedYacht.sails)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) return
|
if (!masterKey) return
|
||||||
@@ -148,7 +166,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadYachtSails()
|
loadYachtSails()
|
||||||
}, [logbookId])
|
}, [logbookId, preloadedYacht])
|
||||||
|
|
||||||
// Load entry details
|
// Load entry details
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,6 +174,29 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
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()
|
loadEntry()
|
||||||
}, [entryId])
|
}, [entryId, preloadedEntry])
|
||||||
|
|
||||||
// GPS Track Loader
|
// GPS Track Loader
|
||||||
const loadGpsTrack = async () => {
|
const loadGpsTrack = async () => {
|
||||||
|
if (readOnly && preloadedGpsTrack) {
|
||||||
|
setSavedTrack(preloadedGpsTrack)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const track = await getDecryptedGpsTrack(entryId)
|
const track = await getDecryptedGpsTrack(entryId)
|
||||||
setSavedTrack(track)
|
setSavedTrack(track)
|
||||||
@@ -207,7 +252,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGpsTrack()
|
loadGpsTrack()
|
||||||
}, [entryId])
|
}, [entryId, preloadedGpsTrack])
|
||||||
|
|
||||||
// Leaflet Map Initialization and Rendering
|
// Leaflet Map Initialization and Rendering
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -277,6 +322,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
|
|
||||||
// GPX/KML/GeoJSON Upload Handlers
|
// GPX/KML/GeoJSON Upload Handlers
|
||||||
const handleFileUpload = async (file: File) => {
|
const handleFileUpload = async (file: File) => {
|
||||||
|
if (readOnly) return
|
||||||
setUploadError(null)
|
setUploadError(null)
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = async (e) => {
|
reader.onload = async (e) => {
|
||||||
@@ -329,6 +375,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteTrack = async () => {
|
const handleDeleteTrack = async () => {
|
||||||
|
if (readOnly) return
|
||||||
if (!window.confirm(t('logs.gps_track_delete_confirm'))) {
|
if (!window.confirm(t('logs.gps_track_delete_confirm'))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -366,6 +413,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGetGps = () => {
|
const handleGetGps = () => {
|
||||||
|
if (readOnly) return
|
||||||
const lookupFallback = async () => {
|
const lookupFallback = async () => {
|
||||||
const locationQuery = evLocationName.trim() || departure.trim() || destination.trim()
|
const locationQuery = evLocationName.trim() || departure.trim() || destination.trim()
|
||||||
if (!locationQuery) {
|
if (!locationQuery) {
|
||||||
@@ -526,7 +574,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
|
|
||||||
const handleAddEvent = (e: React.FormEvent) => {
|
const handleAddEvent = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!evTime) return
|
if (readOnly || !evTime) return
|
||||||
|
|
||||||
const newEvent: LogEvent = {
|
const newEvent: LogEvent = {
|
||||||
time: evTime,
|
time: evTime,
|
||||||
@@ -570,6 +618,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteEvent = (index: number) => {
|
const handleDeleteEvent = (index: number) => {
|
||||||
|
if (readOnly) return
|
||||||
setEvents((prev) => prev.filter((_, idx) => idx !== index))
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (readOnly) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
@@ -715,20 +765,19 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.day_of_travel')} *</label>
|
<label>{t('logs.day_of_travel')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
placeholder="e.g. 1"
|
|
||||||
value={dayOfTravel}
|
value={dayOfTravel}
|
||||||
onChange={(e) => setDayOfTravel(e.target.value)}
|
onChange={(e) => setDayOfTravel(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -738,10 +787,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
placeholder="Starting port name"
|
|
||||||
value={departure}
|
value={departure}
|
||||||
onChange={(e) => setDeparture(e.target.value)}
|
onChange={(e) => setDeparture(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -750,10 +798,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
placeholder="Destination port name"
|
|
||||||
value={destination}
|
value={destination}
|
||||||
onChange={(e) => setDestination(e.target.value)}
|
onChange={(e) => setDestination(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -769,38 +816,35 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
</div>
|
</div>
|
||||||
<div className="consumption-grid">
|
<div className="consumption-grid">
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.morning')} (L)</label>
|
<label>{t('logs.freshwater')} ({t('logs.morning')})</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={fwMorning}
|
value={fwMorning}
|
||||||
onChange={(e) => setFwMorning(e.target.value)}
|
onChange={(e) => setFwMorning(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.refilled')} (L)</label>
|
<label>{t('logs.freshwater')} ({t('logs.refilled')})</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={fwRefilled}
|
value={fwRefilled}
|
||||||
onChange={(e) => setFwRefilled(e.target.value)}
|
onChange={(e) => setFwRefilled(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.evening')} (L)</label>
|
<label>{t('logs.freshwater')} ({t('logs.evening')})</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={fwEvening}
|
value={fwEvening}
|
||||||
onChange={(e) => setFwEvening(e.target.value)}
|
onChange={(e) => setFwEvening(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -825,38 +869,35 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
</div>
|
</div>
|
||||||
<div className="consumption-grid">
|
<div className="consumption-grid">
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.morning')} (L)</label>
|
<label>{t('logs.fuel')} ({t('logs.morning')})</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={fuelMorning}
|
value={fuelMorning}
|
||||||
onChange={(e) => setFuelMorning(e.target.value)}
|
onChange={(e) => setFuelMorning(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.refilled')} (L)</label>
|
<label>{t('logs.fuel')} ({t('logs.refilled')})</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={fuelRefilled}
|
value={fuelRefilled}
|
||||||
onChange={(e) => setFuelRefilled(e.target.value)}
|
onChange={(e) => setFuelRefilled(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.evening')} (L)</label>
|
<label>{t('logs.fuel')} ({t('logs.evening')})</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={fuelEvening}
|
value={fuelEvening}
|
||||||
onChange={(e) => setFuelEvening(e.target.value)}
|
onChange={(e) => setFuelEvening(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -899,7 +940,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
<th>{t('logs.event_log')}</th>
|
<th>{t('logs.event_log')}</th>
|
||||||
<th>{t('logs.event_gps')}</th>
|
<th>{t('logs.event_gps')}</th>
|
||||||
<th>{t('logs.event_remarks')}</th>
|
<th>{t('logs.event_remarks')}</th>
|
||||||
<th></th>
|
{!readOnly && <th></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -928,11 +969,13 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
|
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="remarks-td">{ev.remarks}</td>
|
<td className="remarks-td">{ev.remarks}</td>
|
||||||
<td>
|
{!readOnly && (
|
||||||
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
|
<td>
|
||||||
<Trash2 size={14} />
|
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
|
||||||
</button>
|
<Trash2 size={14} />
|
||||||
</td>
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -941,8 +984,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add New Event Form Sub-Card */}
|
{/* Add New Event Form Sub-Card */}
|
||||||
<div className="member-editor-card glass">
|
{!readOnly && (
|
||||||
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
|
<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="form-grid mb-4">
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
@@ -1187,6 +1231,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
Add Event Entry
|
Add Event Entry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GPS Track Upload & Map Visualization */}
|
{/* GPS Track Upload & Map Visualization */}
|
||||||
@@ -1245,15 +1290,17 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
{t('logs.gps_tracking_btn_gpx')}
|
{t('logs.gps_tracking_btn_gpx')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<button
|
||||||
className="btn secondary"
|
type="button"
|
||||||
onClick={handleDeleteTrack}
|
className="btn secondary"
|
||||||
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)' }}
|
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')}
|
<Trash2 size={14} />
|
||||||
</button>
|
{t('logs.gps_track_delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1263,7 +1310,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PhotoCapture entryId={entryId} logbookId={logbookId} />
|
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
||||||
|
|
||||||
{/* Section 4: Sign-Off Signatures */}
|
{/* Section 4: Sign-Off Signatures */}
|
||||||
<div className="form-card">
|
<div className="form-card">
|
||||||
@@ -1280,7 +1327,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={signSkipper}
|
value={signSkipper}
|
||||||
onChange={(e) => setSignSkipper(e.target.value)}
|
onChange={(e) => setSignSkipper(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1292,26 +1339,28 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={signCrew}
|
value={signCrew}
|
||||||
onChange={(e) => setSignCrew(e.target.value)}
|
onChange={(e) => setSignCrew(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save Controls */}
|
{/* Save Controls */}
|
||||||
<div className="form-actions mt-4">
|
{!readOnly && (
|
||||||
{success && (
|
<div className="form-actions mt-4">
|
||||||
<div className="success-toast">
|
{success && (
|
||||||
<Check size={16} />
|
<div className="success-toast">
|
||||||
<span>{t('logs.saved')}</span>
|
<Check size={16} />
|
||||||
</div>
|
<span>{t('logs.saved')}</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
|
|
||||||
<Save size={18} />
|
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
|
||||||
{saving ? t('logs.saving') : t('logs.save')}
|
<Save size={18} />
|
||||||
</button>
|
{saving ? t('logs.saving') : t('logs.save')}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Camera, Trash2 } from 'lucide-react'
|
|||||||
interface PhotoCaptureProps {
|
interface PhotoCaptureProps {
|
||||||
entryId: string
|
entryId: string
|
||||||
logbookId: string
|
logbookId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
preloadedPhotos?: any[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DecryptedPhoto {
|
interface DecryptedPhoto {
|
||||||
@@ -21,7 +23,7 @@ interface DecryptedPhoto {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) {
|
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
const [caption, setCaption] = useState('')
|
const [caption, setCaption] = useState('')
|
||||||
@@ -40,6 +42,19 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
|
|||||||
// Decrypt photos on query updates
|
// Decrypt photos on query updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function decryptPhotosList() {
|
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
|
if (!localPhotos) return
|
||||||
|
|
||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
@@ -65,7 +80,7 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
decryptPhotosList()
|
decryptPhotosList()
|
||||||
}, [localPhotos])
|
}, [localPhotos, readOnly, preloadedPhotos, entryId])
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
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>}
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
{/* Upload area */}
|
{/* Upload area */}
|
||||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
{/* Upload Form */}
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
{!readOnly && (
|
||||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||||
<label>{t('logs.photo_caption_label')}</label>
|
<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
|
<input
|
||||||
type="text"
|
type="file"
|
||||||
placeholder={t('logs.photo_caption_placeholder')}
|
accept="image/*"
|
||||||
className="input-text"
|
capture="environment"
|
||||||
value={caption}
|
ref={fileInputRef}
|
||||||
onChange={(e) => setCaption(e.target.value)}
|
onChange={handleFileChange}
|
||||||
disabled={uploading}
|
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>
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Photo Grid */}
|
{/* Photo Grid */}
|
||||||
{decryptedPhotos.length === 0 ? (
|
{decryptedPhotos.length === 0 ? (
|
||||||
@@ -246,14 +264,16 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
|
|||||||
<div key={photo.payloadId} className="photo-card glass">
|
<div key={photo.payloadId} className="photo-card glass">
|
||||||
<div className="photo-container">
|
<div className="photo-container">
|
||||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<button
|
||||||
className="photo-btn-delete"
|
type="button"
|
||||||
onClick={() => handleDelete(photo.payloadId)}
|
className="photo-btn-delete"
|
||||||
title="Remove photo"
|
onClick={() => handleDelete(photo.payloadId)}
|
||||||
>
|
title="Remove photo"
|
||||||
<Trash2 size={16} />
|
>
|
||||||
</button>
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{photo.caption && (
|
{photo.caption && (
|
||||||
<div className="photo-caption-bar">
|
<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 [collabError, setCollabError] = useState<string | null>(null)
|
||||||
const [loadingCollabs, setLoadingCollabs] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (logbookId) {
|
if (logbookId) {
|
||||||
loadCollaborators()
|
loadCollaborators()
|
||||||
|
loadShareLink()
|
||||||
}
|
}
|
||||||
}, [logbookId])
|
}, [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 () => {
|
const loadCollaborators = async () => {
|
||||||
setLoadingCollabs(true)
|
setLoadingCollabs(true)
|
||||||
setCollabError(null)
|
setCollabError(null)
|
||||||
@@ -247,6 +336,57 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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) */}
|
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
|
||||||
{logbookId && isOwner && (
|
{logbookId && isOwner && (
|
||||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
<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 {
|
interface VesselFormProps {
|
||||||
logbookId: string
|
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 { t } = useTranslation()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [homePort, setHomePort] = useState('')
|
const [homePort, setHomePort] = useState('')
|
||||||
@@ -39,6 +41,20 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (readOnly) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
@@ -221,7 +238,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
<form onSubmit={handleSubmit} className="vessel-form">
|
<form onSubmit={handleSubmit} className="vessel-form">
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<div className="vessel-photo-wrapper">
|
<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 ? (
|
{photo ? (
|
||||||
<img src={photo} alt={name || 'Yacht'} className="vessel-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" />
|
<Ship size={48} className="placeholder-icon" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="vessel-photo-overlay">
|
{!readOnly && (
|
||||||
<Camera size={24} />
|
<div className="vessel-photo-overlay">
|
||||||
<span>{photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
<Camera size={24} />
|
||||||
</div>
|
<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>
|
|
||||||
)}
|
)}
|
||||||
</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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -276,7 +297,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,7 +309,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={homePort}
|
value={homePort}
|
||||||
onChange={(e) => setHomePort(e.target.value)}
|
onChange={(e) => setHomePort(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -299,7 +320,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={owner}
|
value={owner}
|
||||||
onChange={(e) => setOwner(e.target.value)}
|
onChange={(e) => setOwner(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -310,7 +331,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={charterCompany}
|
value={charterCompany}
|
||||||
onChange={(e) => setCharterCompany(e.target.value)}
|
onChange={(e) => setCharterCompany(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -321,7 +342,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={registrationNumber}
|
value={registrationNumber}
|
||||||
onChange={(e) => setRegistrationNumber(e.target.value)}
|
onChange={(e) => setRegistrationNumber(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -332,7 +353,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={callSign}
|
value={callSign}
|
||||||
onChange={(e) => setCallSign(e.target.value)}
|
onChange={(e) => setCallSign(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -343,7 +364,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={atis}
|
value={atis}
|
||||||
onChange={(e) => setAtis(e.target.value)}
|
onChange={(e) => setAtis(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,7 +375,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
className="input-text"
|
className="input-text"
|
||||||
value={mmsi}
|
value={mmsi}
|
||||||
onChange={(e) => setMmsi(e.target.value)}
|
onChange={(e) => setMmsi(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -369,61 +390,67 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
sails.map((sail, idx) => (
|
sails.map((sail, idx) => (
|
||||||
<span key={idx} className="sail-badge">
|
<span key={idx} className="sail-badge">
|
||||||
{sail}
|
{sail}
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<button
|
||||||
className="remove-btn"
|
type="button"
|
||||||
onClick={() => handleRemoveSail(idx)}
|
className="remove-btn"
|
||||||
disabled={saving}
|
onClick={() => handleRemoveSail(idx)}
|
||||||
>
|
disabled={saving}
|
||||||
<X size={14} />
|
>
|
||||||
</button>
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="add-sail-form">
|
{!readOnly && (
|
||||||
<input
|
<div className="add-sail-form">
|
||||||
type="text"
|
<input
|
||||||
className="input-text"
|
type="text"
|
||||||
placeholder={t('vessel.sail_name_placeholder')}
|
className="input-text"
|
||||||
value={newSailName}
|
placeholder={t('vessel.sail_name_placeholder')}
|
||||||
onChange={(e) => setNewSailName(e.target.value)}
|
value={newSailName}
|
||||||
disabled={saving}
|
onChange={(e) => setNewSailName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
disabled={saving}
|
||||||
if (e.key === 'Enter') {
|
onKeyDown={(e) => {
|
||||||
e.preventDefault();
|
if (e.key === 'Enter') {
|
||||||
handleAddSail();
|
e.preventDefault();
|
||||||
}
|
handleAddSail();
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
className="btn secondary"
|
type="button"
|
||||||
onClick={handleAddSail}
|
className="btn secondary"
|
||||||
disabled={saving || !newSailName.trim()}
|
onClick={handleAddSail}
|
||||||
style={{ width: 'auto' }}
|
disabled={saving || !newSailName.trim()}
|
||||||
>
|
style={{ width: 'auto' }}
|
||||||
<Plus size={16} />
|
>
|
||||||
{t('vessel.add_sail')}
|
<Plus size={16} />
|
||||||
</button>
|
{t('vessel.add_sail')}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
{!readOnly && (
|
||||||
{success && (
|
<div className="form-actions">
|
||||||
<div className="success-toast">
|
{success && (
|
||||||
<Check size={16} />
|
<div className="success-toast">
|
||||||
<span>{t('vessel.saved')}</span>
|
<Check size={16} />
|
||||||
</div>
|
<span>{t('vessel.saved')}</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<button type="submit" className="btn primary" disabled={saving || !name.trim()}>
|
|
||||||
<Save size={18} />
|
<button type="submit" className="btn primary" disabled={saving || !name.trim()}>
|
||||||
{saving ? t('vessel.saving') : t('vessel.save')}
|
<Save size={18} />
|
||||||
</button>
|
{saving ? t('vessel.saving') : t('vessel.save')}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -209,7 +209,12 @@
|
|||||||
"theme_auto": "Automatisch (OS-Erkennung)",
|
"theme_auto": "Automatisch (OS-Erkennung)",
|
||||||
"theme_ocean": "Ocean (Glassmorphismus)",
|
"theme_ocean": "Ocean (Glassmorphismus)",
|
||||||
"theme_material": "Material (Android)",
|
"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_auto": "Auto (OS Detect)",
|
||||||
"theme_ocean": "Ocean (Glassmorphism)",
|
"theme_ocean": "Ocean (Glassmorphism)",
|
||||||
"theme_material": "Material (Android)",
|
"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;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportLogbookToCsv(logbookId: string): Promise<string> {
|
export async function exportLogbookToCsv(logbookId: string, preloadedData?: { yacht: any; entries: any[] }): 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
|
|
||||||
let yachtName = '', homePort = '', owner = '', charter = '', registration = '', callsign = '', atis = '', mmsi = '';
|
let yachtName = '', homePort = '', owner = '', charter = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||||
const yachtRecord = await db.yachts.get(logbookId);
|
let decryptedEntries: any[] = [];
|
||||||
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
|
if (preloadedData) {
|
||||||
const localEntries = await db.entries.where({ logbookId }).toArray();
|
const yacht = preloadedData.yacht || {};
|
||||||
const decryptedEntries = [];
|
yachtName = yacht.name || '';
|
||||||
for (const entry of localEntries) {
|
homePort = yacht.port || '';
|
||||||
try {
|
owner = yacht.owner || '';
|
||||||
const dec = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey);
|
charter = yacht.charter || '';
|
||||||
if (dec) {
|
registration = yacht.registration || '';
|
||||||
decryptedEntries.push(dec);
|
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');
|
return rows.map(r => r.join(',')).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadCsv(logbookId: string, title: string): Promise<void> {
|
export async function downloadCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise<void> {
|
||||||
const csvContent = await exportLogbookToCsv(logbookId);
|
const csvContent = await exportLogbookToCsv(logbookId, preloadedData);
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@@ -142,8 +156,8 @@ export async function downloadCsv(logbookId: string, title: string): Promise<voi
|
|||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shareCsv(logbookId: string, title: string): Promise<void> {
|
export async function shareCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise<void> {
|
||||||
const csvContent = await exportLogbookToCsv(logbookId);
|
const csvContent = await exportLogbookToCsv(logbookId, preloadedData);
|
||||||
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`;
|
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`;
|
||||||
|
|
||||||
const file = new File([csvContent], filename, { type: 'text/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) {
|
} catch (e: any) {
|
||||||
if (e.name !== 'AbortError') {
|
if (e.name !== 'AbortError') {
|
||||||
console.error('Sharing failed, falling back to download:', e);
|
console.error('Sharing failed, falling back to download:', e);
|
||||||
await downloadCsv(logbookId, title);
|
await downloadCsv(logbookId, title, preloadedData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,41 +4,55 @@ import { getActiveMasterKey } from './auth.js'
|
|||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { decryptJson } from './crypto.js'
|
import { decryptJson } from './crypto.js'
|
||||||
|
|
||||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string): Promise<jsPDF> {
|
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
|
||||||
if (!masterKey) {
|
|
||||||
throw new Error('Encryption key not found. Please log in.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Fetch Yacht details
|
|
||||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||||
const yachtRecord = await db.yachts.get(logbookId);
|
let entry: any = null;
|
||||||
if (yachtRecord) {
|
|
||||||
try {
|
if (preloadedData) {
|
||||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
const yacht = preloadedData.yacht || {};
|
||||||
yachtName = yacht.name || '';
|
yachtName = yacht.name || '';
|
||||||
homePort = yacht.port || '';
|
homePort = yacht.port || '';
|
||||||
// owner not needed in PDF layout
|
registration = yacht.registrationNumber || yacht.registration || '';
|
||||||
registration = yacht.registrationNumber || yacht.registration || '';
|
callsign = yacht.callSign || '';
|
||||||
callsign = yacht.callSign || '';
|
atis = yacht.atis || '';
|
||||||
atis = yacht.atis || '';
|
mmsi = yacht.mmsi || '';
|
||||||
mmsi = yacht.mmsi || '';
|
entry = preloadedData.entry;
|
||||||
} catch (e) {
|
} else {
|
||||||
console.error('Failed to decrypt yacht details for PDF:', e);
|
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) {
|
if (!entry) {
|
||||||
throw new Error('Failed to decrypt entry');
|
throw new Error('Failed to load entry');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Create PDF landscape A4
|
// Create PDF landscape A4
|
||||||
const doc = new jsPDF({
|
const doc = new jsPDF({
|
||||||
orientation: 'landscape',
|
orientation: 'landscape',
|
||||||
@@ -217,8 +231,8 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string)
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadLogbookPagePdf(logbookId: string, entryId: string, dateStr: string): Promise<void> {
|
export async function downloadLogbookPagePdf(logbookId: string, entryId: string, dateStr: string, preloadedData?: { yacht: any; entry: any }): Promise<void> {
|
||||||
const doc = await generateLogbookPagePdf(logbookId, entryId);
|
const doc = await generateLogbookPagePdf(logbookId, entryId, preloadedData);
|
||||||
const filename = `logbook_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
const filename = `logbook_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||||
doc.save(filename);
|
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)
|
// 2. Accept invitation (requires authenticated invitee)
|
||||||
router.post('/accept', requireUser, async (req: any, res) => {
|
router.post('/accept', requireUser, async (req: any, res) => {
|
||||||
try {
|
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
|
export default router
|
||||||
|
|||||||
Reference in New Issue
Block a user