From d84560f51fea187326e1e7ad1dfb62ca488eb8c8 Mon Sep 17 00:00:00 2001 From: elpatron Date: Thu, 28 May 2026 12:26:32 +0200 Subject: [PATCH] Replace native browser alerts and confirms with customized modern promise-based overlay dialogs --- client/src/App.css | 53 +++++++++++++ client/src/App.tsx | 9 ++- client/src/components/CrewForm.tsx | 4 +- client/src/components/LogEntriesList.tsx | 4 +- client/src/components/LogEntryEditor.tsx | 16 ++-- client/src/components/LogbookDashboard.tsx | 4 +- client/src/components/ModalDialog.tsx | 92 ++++++++++++++++++++++ client/src/components/PhotoCapture.tsx | 4 +- client/src/i18n/locales/de.json | 2 + client/src/i18n/locales/en.json | 2 + client/src/services/gpsTracker.ts | 1 - 11 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 client/src/components/ModalDialog.tsx diff --git a/client/src/App.css b/client/src/App.css index 126bb94..7306929 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1463,3 +1463,56 @@ body:has(.theme-cupertino) { overflow: hidden; white-space: nowrap; } + +/* Custom Dialog Modals Styling */ +.custom-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(11, 12, 16, 0.75); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.custom-dialog-card { + background: rgba(15, 23, 42, 0.85); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 28px; + width: 90%; + max-width: 420px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.custom-dialog-title { + font-size: 19px; + font-weight: 700; + color: #fbbf24; + margin: 0 0 14px 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.custom-dialog-message { + font-size: 15px; + color: #e2e8f0; + line-height: 1.5; + margin: 0 0 24px 0; +} + +.custom-dialog-actions { + display: flex; + justify-content: center; + gap: 16px; + width: 100%; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index f5bdfd4..d23ac16 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import './App.css' +import { DialogProvider } from './components/ModalDialog.tsx' import AuthOnboarding from './components/AuthOnboarding.tsx' import LogbookDashboard from './components/LogbookDashboard.tsx' import VesselForm from './components/VesselForm.tsx' @@ -259,4 +260,10 @@ function App() { ) } -export default App +export default function AppWrapper() { + return ( + + + + ) +} diff --git a/client/src/components/CrewForm.tsx b/client/src/components/CrewForm.tsx index 388c49e..6659cab 100644 --- a/client/src/components/CrewForm.tsx +++ b/client/src/components/CrewForm.tsx @@ -4,6 +4,7 @@ import { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' +import { useDialog } from './ModalDialog.tsx' import { Users, User, Plus, Trash2, Edit2, Save, X, Check } from 'lucide-react' interface CrewFormProps { @@ -30,6 +31,7 @@ interface DecryptedCrew { export default function CrewForm({ logbookId }: CrewFormProps) { const { t } = useTranslation() + const { showConfirm } = useDialog() // Skipper profile state const [skipName, setSkipName] = useState('') @@ -260,7 +262,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { } const handleDeleteMember = async (memberId: string) => { - if (window.confirm(t('crew.delete_confirm'))) { + if (await showConfirm(t('crew.delete_confirm'), t('crew.title'), t('logs.confirm_yes'), t('logs.confirm_no'))) { setError(null) try { const now = new Date().toISOString() diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index 7ab0490..075fb65 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -7,6 +7,7 @@ import { syncLogbook } from '../services/sync.js' import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import LogEntryEditor from './LogEntryEditor.tsx' +import { useDialog } from './ModalDialog.tsx' import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react' interface LogEntriesListProps { @@ -24,6 +25,7 @@ interface DecryptedEntryItem { export default function LogEntriesList({ logbookId }: LogEntriesListProps) { const { t } = useTranslation() + const { showConfirm } = useDialog() const [entries, setEntries] = useState([]) const [selectedEntryId, setSelectedEntryId] = useState(null) const [loading, setLoading] = useState(false) @@ -187,7 +189,7 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { const handleDelete = async (entryId: string, e: React.MouseEvent) => { e.stopPropagation() // Prevent selecting the card - if (window.confirm(t('logs.delete_confirm'))) { + if (await showConfirm(t('logs.delete_confirm'), t('logs.delete_entry'), t('logs.confirm_yes'), t('logs.confirm_no'))) { setError(null) try { const now = new Date().toISOString() diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 39d77cc..39122ef 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -7,6 +7,7 @@ import { syncLogbook } from '../services/sync.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Play, Square, Navigation } from 'lucide-react' import PhotoCapture from './PhotoCapture.tsx' +import { useDialog } from './ModalDialog.tsx' import { startGpsTracking, stopGpsTracking, @@ -44,6 +45,7 @@ interface LogEvent { export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryEditorProps) { const { t } = useTranslation() + const { showAlert } = useDialog() // General details state const [date, setDate] = useState('') @@ -202,7 +204,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE }) setTrackingActive(true) } catch (err: any) { - alert(err.message || 'Failed to start GPS tracking') + showAlert(err.message || 'Failed to start GPS tracking') } } @@ -243,7 +245,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE const handleGetGps = () => { if (!navigator.geolocation) { - alert('Geolocation is not supported by your browser') + showAlert('Geolocation is not supported by your browser') return } @@ -254,20 +256,20 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE }, (err) => { console.error('GPS capturing failed:', err) - alert(`Failed to retrieve coordinates: ${err.message}`) + showAlert(`Failed to retrieve coordinates: ${err.message}`) } ) } const handleFetchWeather = async () => { if (!evGpsLat || !evGpsLng) { - alert(t('settings.gps_error')) + showAlert(t('settings.gps_error')) return } const apiKey = localStorage.getItem('owm_api_key') if (!apiKey) { - alert(t('settings.no_key')) + showAlert(t('settings.no_key')) return } @@ -313,10 +315,10 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE setEvWeatherIcon(data.weather[0].icon) } - alert(t('settings.weather_success')) + showAlert(t('settings.weather_success')) } catch (err) { console.error('Weather prefilling failed:', err) - alert(t('settings.weather_error')) + showAlert(t('settings.weather_error')) } finally { setWeatherLoading(false) } diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index 2a277a2..044c57a 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -4,6 +4,7 @@ import { useLiveQuery } from 'dexie-react-hooks' import { db } from '../services/db.js' import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js' import { logoutUser } from '../services/auth.js' +import { useDialog } from './ModalDialog.tsx' import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react' interface LogbookDashboardProps { @@ -13,6 +14,7 @@ interface LogbookDashboardProps { export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) { const { t, i18n } = useTranslation() + const { showConfirm } = useDialog() const [logbooks, setLogbooks] = useState([]) const [newTitle, setNewTitle] = useState('') const [loading, setLoading] = useState(false) @@ -76,7 +78,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD const handleDelete = async (id: string, e: React.MouseEvent) => { e.stopPropagation() // Prevent selecting the logbook when clicking delete - if (window.confirm(t('dashboard.delete_confirm'))) { + if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.title'), t('logs.confirm_yes'), t('logs.confirm_no'))) { setLoading(true) setError(null) try { diff --git a/client/src/components/ModalDialog.tsx b/client/src/components/ModalDialog.tsx new file mode 100644 index 0000000..f9ae596 --- /dev/null +++ b/client/src/components/ModalDialog.tsx @@ -0,0 +1,92 @@ +import React, { createContext, useContext, useState, useRef } from 'react' + +interface DialogContextType { + showAlert: (message: string, title?: string, confirmText?: string) => Promise + showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise +} + +const DialogContext = createContext(undefined) + +export function useDialog() { + const context = useContext(DialogContext) + if (!context) { + throw new Error('useDialog must be used within a DialogProvider') + } + return context +} + +export function DialogProvider({ children }: { children: React.ReactNode }) { + const [isOpen, setIsOpen] = useState(false) + const [title, setTitle] = useState('') + const [message, setMessage] = useState('') + const [type, setType] = useState<'alert' | 'confirm'>('alert') + const [confirmLabel, setConfirmLabel] = useState('OK') + const [cancelLabel, setCancelLabel] = useState('Cancel') + + const resolveRef = useRef<((val: any) => void) | null>(null) + + const showAlert = (msg: string, headerTitle?: string, btnText?: string): Promise => { + setMessage(msg) + setTitle(headerTitle || '') + setType('alert') + setConfirmLabel(btnText || 'OK') + setIsOpen(true) + + return new Promise((resolve) => { + resolveRef.current = resolve + }) + } + + const showConfirm = (msg: string, headerTitle?: string, btnConfirm?: string, btnCancel?: string): Promise => { + setMessage(msg) + setTitle(headerTitle || '') + setType('confirm') + setConfirmLabel(btnConfirm || 'Yes') + setCancelLabel(btnCancel || 'No') + setIsOpen(true) + + return new Promise((resolve) => { + resolveRef.current = resolve + }) + } + + const handleConfirm = () => { + setIsOpen(false) + if (resolveRef.current) { + resolveRef.current(type === 'confirm' ? true : undefined) + resolveRef.current = null + } + } + + const handleCancel = () => { + setIsOpen(false) + if (resolveRef.current) { + resolveRef.current(false) + resolveRef.current = null + } + } + + return ( + + {children} + {isOpen && ( +
+
e.stopPropagation()}> + {title &&

{title}

} +

{message}

+
+ {type === 'confirm' && ( + + )} + +
+
+
+ )} +
+ ) +} diff --git a/client/src/components/PhotoCapture.tsx b/client/src/components/PhotoCapture.tsx index 03b5626..df2ae90 100644 --- a/client/src/components/PhotoCapture.tsx +++ b/client/src/components/PhotoCapture.tsx @@ -5,6 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' import { useLiveQuery } from 'dexie-react-hooks' +import { useDialog } from './ModalDialog.tsx' import { Camera, Trash2 } from 'lucide-react' interface PhotoCaptureProps { @@ -21,6 +22,7 @@ interface DecryptedPhoto { export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) { const { t } = useTranslation() + const { showConfirm } = useDialog() const [caption, setCaption] = useState('') const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) @@ -156,7 +158,7 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) } const handleDelete = async (photoId: string) => { - if (window.confirm(t('logs.photo_delete_confirm'))) { + if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) { try { const now = new Date().toISOString() diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index f7de214..9c42e4e 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -97,6 +97,8 @@ "photo_processing": "Wird verarbeitet...", "no_photos": "Noch keine Fotos an diesen Reisetag angehängt.", "photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?", + "confirm_yes": "Ja", + "confirm_no": "Nein", "gps_tracking_title": "GPS-Routenaufzeichnung (E2E-verschlüsselt)", "gps_tracking_status_active": "Aufzeichnung läuft", "gps_tracking_status_inactive": "Aufzeichnung inaktiv", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index d9975ac..651f2f7 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -97,6 +97,8 @@ "photo_processing": "Processing...", "no_photos": "No photos attached to this journal entry yet.", "photo_delete_confirm": "Are you sure you want to permanently delete this photo?", + "confirm_yes": "Yes", + "confirm_no": "No", "gps_tracking_title": "GPS Route Tracking (E2E Encrypted)", "gps_tracking_status_active": "Tracking Active", "gps_tracking_status_inactive": "Tracking Inactive", diff --git a/client/src/services/gpsTracker.ts b/client/src/services/gpsTracker.ts index 228b115..25b2eec 100644 --- a/client/src/services/gpsTracker.ts +++ b/client/src/services/gpsTracker.ts @@ -254,7 +254,6 @@ ${trkpts} // Download GPX file client-side export function downloadGpxFile(waypoints: GpsWaypoint[], dateStr: string): void { if (waypoints.length === 0) { - alert('No waypoints recorded to export.') return } const gpxContent = generateGpxString(waypoints, dateStr)