diff --git a/client/src/App.css b/client/src/App.css
index 8219cae..ba929a4 100644
--- a/client/src/App.css
+++ b/client/src/App.css
@@ -1980,6 +1980,98 @@ body:has(.theme-cupertino) {
margin-bottom: 12px;
}
+.signature-grid {
+ align-items: start;
+}
+
+.signature-pad-group {
+ width: 100%;
+}
+
+.signature-pad-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.signature-pad-header label {
+ display: block;
+ font-size: 13.5px;
+ color: #94a3b8;
+ font-weight: 500;
+}
+
+.signature-pad-clear {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: transparent;
+ border: none;
+ color: #94a3b8;
+ font-size: 12px;
+ cursor: pointer;
+ padding: 0;
+}
+
+.signature-pad-clear:hover {
+ color: #e2e8f0;
+ text-decoration: underline;
+}
+
+.signature-pad {
+ position: relative;
+ width: 100%;
+ height: 132px;
+ border-radius: 10px;
+ border: 1px dashed rgba(148, 163, 184, 0.45);
+ background: rgba(255, 255, 255, 0.96);
+ overflow: hidden;
+ touch-action: none;
+}
+
+.signature-pad.disabled {
+ opacity: 0.65;
+}
+
+.signature-pad-canvas,
+.signature-pad-image {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.signature-pad-canvas {
+ cursor: crosshair;
+}
+
+.signature-pad-hint {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #94a3b8;
+ font-size: 13px;
+ pointer-events: none;
+ user-select: none;
+}
+
+.signature-legacy-text {
+ min-height: 132px;
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.04);
+ color: #e2e8f0;
+ font-size: 18px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
/* PWA install prompt */
.pwa-install-banner {
position: fixed;
diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx
index 5e6447a..75f4f63 100644
--- a/client/src/components/LogEntryEditor.tsx
+++ b/client/src/components/LogEntryEditor.tsx
@@ -8,7 +8,9 @@ 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 PhotoCapture from './PhotoCapture.tsx'
+import SignaturePad from './SignaturePad.tsx'
import { useDialog } from './ModalDialog.tsx'
+import { isSignatureImage } from '../utils/signatures.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -566,8 +568,8 @@ export default function LogEntryEditor({
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
},
- signSkipper: signSkipper.trim(),
- signCrew: signCrew.trim(),
+ signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
+ signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(),
events
}
@@ -1212,30 +1214,24 @@ export default function LogEntryEditor({
{t('logs.signatures')}
-
-
-
- setSignSkipper(e.target.value)}
- disabled={saving || readOnly}
- />
-
+
+
-
-
- setSignCrew(e.target.value)}
- disabled={saving || readOnly}
- />
-
+
diff --git a/client/src/components/SignaturePad.tsx b/client/src/components/SignaturePad.tsx
new file mode 100644
index 0000000..331904a
--- /dev/null
+++ b/client/src/components/SignaturePad.tsx
@@ -0,0 +1,236 @@
+import { useEffect, useRef, useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Eraser } from 'lucide-react'
+import { isSignatureImage } from '../utils/signatures.js'
+
+interface SignaturePadProps {
+ id: string
+ label: string
+ value: string
+ onChange: (value: string) => void
+ disabled?: boolean
+ readOnly?: boolean
+}
+
+const STROKE_COLOR = '#0f172a'
+const STROKE_WIDTH = 2.2
+
+export default function SignaturePad({
+ id,
+ label,
+ value,
+ onChange,
+ disabled = false,
+ readOnly = false
+}: SignaturePadProps) {
+ const { t } = useTranslation()
+ const containerRef = useRef(null)
+ const canvasRef = useRef(null)
+ const isDrawing = useRef(false)
+ const lastPoint = useRef<{ x: number; y: number } | null>(null)
+ const skipExternalRedraw = useRef(false)
+ const hasInk = useRef(false)
+ const [showHint, setShowHint] = useState(() => !value)
+
+ const getContext = useCallback(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return null
+ return canvas.getContext('2d')
+ }, [])
+
+ const clearCanvas = useCallback(() => {
+ const canvas = canvasRef.current
+ const ctx = getContext()
+ if (!canvas || !ctx) return
+ ctx.save()
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
+ ctx.restore()
+ hasInk.current = false
+ }, [getContext])
+
+ const drawImageValue = useCallback((dataUrl: string) => {
+ const canvas = canvasRef.current
+ const ctx = getContext()
+ if (!canvas || !ctx) return
+
+ const img = new Image()
+ img.onload = () => {
+ clearCanvas()
+ const width = canvas.clientWidth
+ const height = canvas.clientHeight
+ ctx.drawImage(img, 0, 0, width, height)
+ hasInk.current = true
+ }
+ img.src = dataUrl
+ }, [clearCanvas, getContext])
+
+ const setupCanvas = useCallback(() => {
+ const canvas = canvasRef.current
+ const container = containerRef.current
+ if (!canvas || !container) return
+
+ const rect = container.getBoundingClientRect()
+ const width = Math.max(rect.width, 1)
+ const height = Math.max(rect.height, 1)
+ const dpr = window.devicePixelRatio || 1
+
+ canvas.width = Math.floor(width * dpr)
+ canvas.height = Math.floor(height * dpr)
+ canvas.style.width = `${width}px`
+ canvas.style.height = `${height}px`
+
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return
+
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+ ctx.lineCap = 'round'
+ ctx.lineJoin = 'round'
+ ctx.lineWidth = STROKE_WIDTH
+ ctx.strokeStyle = STROKE_COLOR
+
+ if (value && isSignatureImage(value)) {
+ drawImageValue(value)
+ } else {
+ clearCanvas()
+ }
+ }, [clearCanvas, drawImageValue, value])
+
+ useEffect(() => {
+ setupCanvas()
+ window.addEventListener('resize', setupCanvas)
+ return () => window.removeEventListener('resize', setupCanvas)
+ }, [setupCanvas])
+
+ useEffect(() => {
+ if (skipExternalRedraw.current) {
+ skipExternalRedraw.current = false
+ return
+ }
+ if (value && isSignatureImage(value)) {
+ drawImageValue(value)
+ setShowHint(false)
+ } else if (!value) {
+ clearCanvas()
+ setShowHint(true)
+ }
+ }, [value, clearCanvas, drawImageValue])
+
+ const getPoint = (event: React.PointerEvent) => {
+ const canvas = canvasRef.current
+ if (!canvas) return null
+ const rect = canvas.getBoundingClientRect()
+ return {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top
+ }
+ }
+
+ const commitCanvas = () => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+ if (!hasInk.current) {
+ skipExternalRedraw.current = true
+ onChange('')
+ return
+ }
+ skipExternalRedraw.current = true
+ onChange(canvas.toDataURL('image/png'))
+ }
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ if (readOnly || disabled) return
+ event.preventDefault()
+ const point = getPoint(event)
+ if (!point) return
+
+ isDrawing.current = true
+ lastPoint.current = point
+ setShowHint(false)
+ event.currentTarget.setPointerCapture(event.pointerId)
+ }
+
+ const handlePointerMove = (event: React.PointerEvent) => {
+ if (!isDrawing.current || readOnly || disabled) return
+ event.preventDefault()
+
+ const point = getPoint(event)
+ const ctx = getContext()
+ const prev = lastPoint.current
+ if (!point || !ctx || !prev) return
+
+ ctx.beginPath()
+ ctx.moveTo(prev.x, prev.y)
+ ctx.lineTo(point.x, point.y)
+ ctx.stroke()
+
+ lastPoint.current = point
+ hasInk.current = true
+ }
+
+ const finishStroke = (event: React.PointerEvent) => {
+ if (!isDrawing.current) return
+ isDrawing.current = false
+ lastPoint.current = null
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId)
+ }
+ commitCanvas()
+ }
+
+ const handleClear = () => {
+ if (readOnly || disabled) return
+ clearCanvas()
+ skipExternalRedraw.current = true
+ setShowHint(true)
+ onChange('')
+ }
+
+ const interactive = !readOnly && !disabled
+
+ if (readOnly && value && !isSignatureImage(value)) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ {interactive && (
+
+ )}
+
+
+
+ {readOnly && value && isSignatureImage(value) ? (
+

+ ) : (
+
+ )}
+ {interactive && showHint && (
+
{t('logs.sign_hint')}
+ )}
+
+
+ )
+}
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json
index 3d23597..4d735af 100644
--- a/client/src/i18n/locales/de.json
+++ b/client/src/i18n/locales/de.json
@@ -113,8 +113,10 @@
"evening": "Stand abends",
"consumption": "Tagesverbrauch",
"signatures": "Unterschriften / Freigabe",
- "sign_skipper": "Skipper (Blockschrift)",
- "sign_crew": "Crew-Mitglied (Blockschrift)",
+ "sign_skipper": "Skipper-Unterschrift",
+ "sign_crew": "Crew-Unterschrift",
+ "sign_hint": "Mit Finger, Stift oder Maus unterschreiben",
+ "sign_clear": "Löschen",
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
"back_to_list": "Zurück zur Journal-Liste",
"save": "Logbuchseite speichern",
diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json
index 47c47e9..f0319bb 100644
--- a/client/src/i18n/locales/en.json
+++ b/client/src/i18n/locales/en.json
@@ -113,8 +113,10 @@
"evening": "Evening Level",
"consumption": "Consumption",
"signatures": "Signatures / Sign-Off",
- "sign_skipper": "Skipper Signature",
- "sign_crew": "Crew Signature",
+ "sign_skipper": "Skipper signature",
+ "sign_crew": "Crew signature",
+ "sign_hint": "Sign with finger, stylus, or mouse",
+ "sign_clear": "Clear",
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
"back_to_list": "Back to Journal List",
"save": "Save Logbook Page",
diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts
index 657b7d5..4fd99ae 100644
--- a/client/src/services/csvExport.ts
+++ b/client/src/services/csvExport.ts
@@ -2,6 +2,7 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
+import { formatSignatureForExport } from '../utils/signatures.js'
function escapeCsvValue(val: string | number | undefined | null): string {
if (val === null || val === undefined) return '';
@@ -93,8 +94,8 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const travelDay = entry.dayOfTravel || '';
const dep = entry.departure || '';
const dest = entry.destination || '';
- const signS = entry.signSkipper || '';
- const signC = entry.signCrew || '';
+ const signS = formatSignatureForExport(entry.signSkipper);
+ const signC = formatSignatureForExport(entry.signCrew);
const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? '';
diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts
index 5318043..4ea2e89 100644
--- a/client/src/services/pdfExport.ts
+++ b/client/src/services/pdfExport.ts
@@ -3,6 +3,7 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
+import { isSignatureImage } from '../utils/signatures.js'
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
@@ -219,14 +220,22 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.line(sigX, sigY + rowHeight * 1.5, sigX + 157, sigY + rowHeight * 1.5);
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
- doc.text('Skipper Unterschrift (in Blockschrift):', sigX + 2, sigY + 4.2);
- doc.setFont('Helvetica', 'normal');
- doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
+ doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
+ if (isSignatureImage(entry.signSkipper)) {
+ doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
+ } else {
+ doc.setFont('Helvetica', 'normal');
+ doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
+ }
doc.setFont('Helvetica', 'bold');
- doc.text('Crew Unterschrift (in Blockschrift):', sigX + 80.5, sigY + 4.2);
- doc.setFont('Helvetica', 'normal');
- doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
+ doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
+ if (isSignatureImage(entry.signCrew)) {
+ doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
+ } else {
+ doc.setFont('Helvetica', 'normal');
+ doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
+ }
return doc;
}
diff --git a/client/src/utils/signatures.ts b/client/src/utils/signatures.ts
new file mode 100644
index 0000000..0bb3454
--- /dev/null
+++ b/client/src/utils/signatures.ts
@@ -0,0 +1,9 @@
+export function isSignatureImage(value: string | undefined | null): boolean {
+ return typeof value === 'string' && value.startsWith('data:image/')
+}
+
+export function formatSignatureForExport(value: string | undefined | null): string {
+ if (!value) return ''
+ if (isSignatureImage(value)) return '[Unterschrift]'
+ return value
+}