Compare commits

...

8 Commits

Author SHA1 Message Date
elpatron 9aabb2729d chore: release v0.1.0.29 2026-05-30 11:12:37 +02:00
elpatron ebe4199b8b fix: Footer-Copyright und Signatur-Fingerprint vereinheitlichen
Footer zeigt KnorrLabs/Markus F.J. Busche mit Mailto nur am Namen. Signatur-Normalisierung ist für Persistenz und isDirty-Check konsistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:12:26 +02:00
elpatron 10f01f1ffc chore: release v0.1.0.28 2026-05-30 11:08:18 +02:00
elpatron 29765d172e feat: Ereignisse sofort speichern und Save-Button bei Änderungen aktivieren
Ereignisprotokoll-Einträge werden direkt persistiert, ohne vorher die Logbuchseite zu speichern. Der Speichern-Button ist nur aktiv, wenn noch ungespeicherte Änderungen vorliegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:07:54 +02:00
elpatron 5f9e83dbdd chore: release v0.1.0.27 2026-05-30 10:56:53 +02:00
elpatron aa2b35ddac feat: Ereignisprotokoll bearbeiten und Skipper-Signatur invalidieren
Bestehende Ereigniszeilen lassen sich nachträglich ändern; beim Speichern
oder Löschen wird nur die Skipper-Unterschrift entfernt, die Crew-Signatur bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:56:45 +02:00
elpatron b5bc80594c chore: release v0.1.0.26 2026-05-30 10:41:35 +02:00
elpatron b88ce17e1d fix: Prevent signature alert loop when adding log events.
Stabilize dialog callbacks and dedupe signature-invalidation alerts so the UI no longer freezes after adding an event to a signed travel day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:40:31 +02:00
8 changed files with 361 additions and 123 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.26
0.1.0.30
+17 -1
View File
@@ -1430,6 +1430,18 @@ html.scheme-dark .themed-select-option.is-selected {
vertical-align: middle;
}
.events-actions-td {
white-space: nowrap;
}
.events-actions-td .btn-icon {
margin-left: 4px;
}
.events-actions-td .btn-icon:first-child {
margin-left: 0;
}
.events-table tbody tr:hover {
background: rgba(255, 255, 255, 0.02);
}
@@ -3034,10 +3046,14 @@ html.theme-cupertino .events-scroll-container {
.app-version-footer__copyright {
color: #94a3b8;
}
.app-version-footer__copyright a {
color: inherit;
text-decoration: none;
}
.app-version-footer__copyright:hover {
.app-version-footer__copyright a:hover {
color: #e2e8f0;
text-decoration: underline;
}
+4 -3
View File
@@ -7,9 +7,10 @@ export default function AppFooter() {
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a className="app-version-footer__copyright" href="mailto:elpatron+kd@mailbox.org">
© 2026 Markus F.J. Busche
</a>
<span className="app-version-footer__copyright">
© 2026 KnorrLabs/
<a href="mailto:elpatron+kd@mailbox.org">Markus F.J. Busche</a>
</span>
</footer>
)
}
+296 -108
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -6,20 +6,21 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
normalizeSignature,
serializeSignature,
fingerprintSignature,
normalizedSerializedSignature,
isPasskeySignature,
isSignatureValidForEntry,
hasAnySignature
} from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
import { buildLogEntryPayload, type LogEventPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -34,6 +35,56 @@ import {
} from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
}
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
const trackDistance = decrypted.trackDistanceNm
const trackSpeedMax = decrypted.trackSpeedMaxKn
const trackSpeedAvg = decrypted.trackSpeedAvgKn
const payload = buildLogEntryPayload({
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
departure: String(decrypted.departure || ''),
destination: String(decrypted.destination || ''),
freshwater: {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
},
fuel: {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
},
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
: undefined,
trackSpeedMaxKn:
trackSpeedMax != null && trackSpeedMax !== ''
? parseFloat(String(trackSpeedMax))
: undefined,
trackSpeedAvgKn:
trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg))
: undefined,
events: (decrypted.events as LogEventPayload[]) || []
})
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(decrypted.signSkipper),
signCrew: fingerprintSignature(decrypted.signCrew)
})
}
interface LogEntryEditorProps {
entryId: string
logbookId: string
@@ -45,24 +96,7 @@ interface LogEntryEditorProps {
preloadedYacht?: any
}
interface LogEvent {
time: string
mgk: string
rwk: string
windPressure: string
windDirection: string
windStrength: string
seaState: string
weatherIcon: string
current: string
heel: string
sailsOrMotor: string
logReading: string
distance: string
gpsLat: string
gpsLng: string
remarks: string
}
interface LogEvent extends LogEventPayload {}
export default function LogEntryEditor({
entryId,
@@ -76,6 +110,8 @@ export default function LogEntryEditor({
}: LogEntryEditorProps) {
const { t, i18n } = useTranslation()
const { showAlert, showConfirm } = useDialog()
const showAlertRef = useRef(showAlert)
showAlertRef.current = showAlert
// General details state
const [date, setDate] = useState('')
@@ -137,6 +173,7 @@ export default function LogEntryEditor({
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
// Track file upload
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
@@ -145,6 +182,9 @@ export default function LogEntryEditor({
const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const lastSignatureAlertHashRef = useRef<string | null>(null)
const skipCrewSignClearRef = useRef(false)
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints)
@@ -167,7 +207,7 @@ export default function LogEntryEditor({
}
}
const buildPayloadForSigning = useCallback(() => {
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
return buildLogEntryPayload({
date,
dayOfTravel,
@@ -188,7 +228,7 @@ export default function LogEntryEditor({
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
events: eventsOverride ?? events
})
}, [
date, dayOfTravel, departure, destination,
@@ -198,6 +238,61 @@ export default function LogEntryEditor({
events
])
const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning()
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
})
}, [buildPayloadForSigning, signSkipper, signCrew])
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
if (readOnly) return
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = {
...buildPayloadForSigning(eventsOverride),
signSkipper: normalizedSerializedSignature(signSkipper),
signCrew: normalizedSerializedSignature(signCrew)
}
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
setSavedFingerprint(JSON.stringify({
...buildPayloadForSigning(eventsOverride),
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
}))
}, [
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
@@ -250,14 +345,21 @@ export default function LogEntryEditor({
if (entryHash !== lockedContentHashRef.current) {
lockedContentHashRef.current = null
setSignSkipper('')
setSignCrew('')
void showAlert(
t('logs.sign_cleared_re_sign'),
t('logs.sign_cleared_re_sign_title')
)
const hadSkipper = !!signSkipper
const hadCrew = !!signCrew
const skipperOnly = skipCrewSignClearRef.current
skipCrewSignClearRef.current = false
if (hadSkipper) setSignSkipper('')
if (hadCrew && !skipperOnly) setSignCrew('')
if (lastSignatureAlertHashRef.current !== entryHash && (hadSkipper || (hadCrew && !skipperOnly))) {
lastSignatureAlertHashRef.current = entryHash
void showAlertRef.current(
skipperOnly ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
skipperOnly ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
)
}
}
}, [entryHash, signSkipper, signCrew, readOnly, showAlert, t])
}, [entryHash, signSkipper, signCrew, readOnly, t])
const confirmSignWarning = useCallback(async (): Promise<boolean> => {
return showConfirm(
@@ -353,8 +455,10 @@ export default function LogEntryEditor({
async function loadEntry() {
setLoading(true)
setError(null)
setSavedFingerprint(null)
lockedContentHashRef.current = null
contentReadyRef.current = false
lastSignatureAlertHashRef.current = null
try {
if (readOnly && preloadedEntry) {
setDate(preloadedEntry.date || '')
@@ -379,6 +483,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return
}
@@ -411,6 +516,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
}
}
} catch (err: any) {
@@ -685,32 +791,26 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase())
}
const handleAddEvent = (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const buildEventFromForm = (): LogEvent => ({
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
})
const newEvent: LogEvent = {
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
}
setEvents((prev) => [...prev, newEvent])
// Clear event form fields
const clearEventForm = () => {
setEvTime('')
setEvMgk('')
setEvRwk('')
@@ -728,11 +828,103 @@ export default function LogEntryEditor({
setEvGpsLng('')
setEvRemarks('')
setEvLocationName('')
setEditingEventIndex(null)
}
const handleDeleteEvent = (index: number) => {
const fillEventForm = (ev: LogEvent) => {
setEvTime(ev.time)
setEvMgk(ev.mgk)
setEvRwk(ev.rwk)
setEvWindPressure(ev.windPressure)
setEvWindDirection(ev.windDirection)
setEvWindStrength(ev.windStrength)
setEvSeaState(ev.seaState)
setEvWeatherIcon(ev.weatherIcon)
setEvCurrent(ev.current)
setEvHeel(ev.heel)
setEvSailsOrMotor(ev.sailsOrMotor)
setEvLogReading(ev.logReading)
setEvDistance(ev.distance)
setEvGpsLat(ev.gpsLat)
setEvGpsLng(ev.gpsLng)
setEvRemarks(ev.remarks)
setEvLocationName('')
}
const markSkipperSignatureClearedForEventChange = () => {
if (!signSkipper) return
skipCrewSignClearRef.current = true
setSignSkipper('')
}
const handleEditEvent = (index: number) => {
if (readOnly) return
setEvents((prev) => prev.filter((_, idx) => idx !== index))
const ev = events[index]
if (!ev) return
fillEventForm(ev)
setEditingEventIndex(index)
}
const handleCancelEventEdit = () => {
clearEventForm()
}
const handleSaveEvent = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const eventData = buildEventFromForm()
let nextEvents: LogEvent[]
if (editingEventIndex !== null) {
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
nextEvents = events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev))
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
} else {
nextEvents = [...events, eventData]
}
setEvents(nextEvents)
clearEventForm()
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save event:', err)
setError(err.message || 'Failed to save event.')
}
}
const handleDeleteEvent = async (index: number) => {
if (readOnly) return
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
const nextEvents = events.filter((_, idx) => idx !== index)
setEvents(nextEvents)
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
if (editingEventIndex === index) {
clearEventForm()
} else if (editingEventIndex !== null && index < editingEventIndex) {
setEditingEventIndex(editingEventIndex - 1)
}
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save after event delete:', err)
setError(err.message || 'Failed to save event deletion.')
}
}
const handleDownloadPdf = async () => {
@@ -751,45 +943,13 @@ export default function LogEntryEditor({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
if (readOnly || !isDirty) return
setSaving(true)
setError(null)
setSuccess(false)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = {
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
// Save locally
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
await persistEntryToDb()
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
@@ -797,8 +957,6 @@ export default function LogEntryEditor({
setSuccess(false)
onBack()
}, 1500)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.')
@@ -1072,8 +1230,22 @@ export default function LogEntryEditor({
</td>
<td className="remarks-td">{ev.remarks}</td>
{!readOnly && (
<td>
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
<td className="events-actions-td">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
</td>
@@ -1088,7 +1260,9 @@ export default function LogEntryEditor({
{/* Add New Event Form Sub-Card */}
{!readOnly && (
<div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
</h4>
<div className="form-grid mb-4">
<div className="input-group">
@@ -1322,16 +1496,30 @@ export default function LogEntryEditor({
</div>
</div>
<button
type="button"
className="btn secondary"
onClick={handleAddEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }}
>
<Plus size={16} />
Add Event Entry
</button>
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto', flexWrap: 'wrap' }}>
{editingEventIndex !== null && (
<button
type="button"
className="btn secondary"
onClick={handleCancelEventEdit}
disabled={saving}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
<X size={16} />
{t('logs.cancel_event_edit')}
</button>
)}
<button
type="button"
className="btn secondary"
onClick={handleSaveEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
</button>
</div>
</div>
)}
</div>
@@ -1488,7 +1676,7 @@ export default function LogEntryEditor({
</div>
)}
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !isDirty}>
<Save size={18} />
{saving ? t('logs.saving') : t('logs.save')}
</button>
+20 -10
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef } from 'react'
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
@@ -25,7 +25,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const resolveRef = useRef<((val: any) => void) | null>(null)
const showAlert = (msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
@@ -35,9 +35,14 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
return new Promise<void>((resolve) => {
resolveRef.current = resolve
})
}
}, [])
const showConfirm = (msg: string, headerTitle?: string, btnConfirm?: string, btnCancel?: string): Promise<boolean> => {
const showConfirm = useCallback((
msg: string,
headerTitle?: string,
btnConfirm?: string,
btnCancel?: string
): Promise<boolean> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm')
@@ -48,26 +53,31 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
})
}
}, [])
const handleConfirm = () => {
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined)
resolveRef.current = null
}
}
}, [type])
const handleCancel = () => {
const handleCancel = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(false)
resolveRef.current = null
}
}
}, [])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
)
return (
<DialogContext.Provider value={{ showAlert, showConfirm }}>
<DialogContext.Provider value={contextValue}>
{children}
{isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
+7
View File
@@ -115,6 +115,13 @@
"new_entry": "Neuer Reisetag",
"travel_details": "Reisedetails",
"add_event": "Neuen Logbucheintrag hinzufügen",
"add_event_btn": "Ereignis hinzufügen",
"edit_event": "Ereignis bearbeiten",
"save_event_btn": "Änderung speichern",
"cancel_event_edit": "Abbrechen",
"delete_event": "Ereignis löschen",
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag",
"departure": "Start-Hafen (Reise von)",
+7
View File
@@ -115,6 +115,13 @@
"new_entry": "New Travel Day",
"travel_details": "Travel Details",
"add_event": "Add Event Log Record",
"add_event_btn": "Add Event Entry",
"edit_event": "Edit event",
"save_event_btn": "Save changes",
"cancel_event_edit": "Cancel",
"delete_event": "Delete event",
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date",
"day_of_travel": "Day of Travel",
"departure": "Departure Port (von)",
+9
View File
@@ -68,3 +68,12 @@ export function serializeSignature(value: SignatureValue | '' | undefined): Sign
const trimmed = value.trim()
return trimmed || undefined
}
/** Normalize then serialize — canonical form for persistence and dirty-check fingerprints. */
export function normalizedSerializedSignature(value: unknown): SignatureValue | undefined {
return serializeSignature(normalizeSignature(value) || '')
}
export function fingerprintSignature(value: unknown): string {
return normalizedSerializedSignature(value) ?? ''
}