Replace native browser alerts and confirms with customized modern promise-based overlay dialogs

This commit is contained in:
2026-05-28 12:26:32 +02:00
parent 05773ef977
commit d84560f51f
11 changed files with 178 additions and 13 deletions
+53
View File
@@ -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%;
}
+8 -1
View File
@@ -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 (
<DialogProvider>
<App />
</DialogProvider>
)
}
+3 -1
View File
@@ -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()
+3 -1
View File
@@ -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<DecryptedEntryItem[]>([])
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(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()
+9 -7
View File
@@ -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)
}
+3 -1
View File
@@ -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<DecryptedLogbook[]>([])
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 {
+92
View File
@@ -0,0 +1,92 @@
import React, { createContext, useContext, useState, useRef } from 'react'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
}
const DialogContext = createContext<DialogContextType | undefined>(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<void> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
setConfirmLabel(btnText || 'OK')
setIsOpen(true)
return new Promise<void>((resolve) => {
resolveRef.current = resolve
})
}
const showConfirm = (msg: string, headerTitle?: string, btnConfirm?: string, btnCancel?: string): Promise<boolean> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm')
setConfirmLabel(btnConfirm || 'Yes')
setCancelLabel(btnCancel || 'No')
setIsOpen(true)
return new Promise<boolean>((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 (
<DialogContext.Provider value={{ showAlert, showConfirm }}>
{children}
{isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
{title && <h3 className="custom-dialog-title">{title}</h3>}
<p className="custom-dialog-message">{message}</p>
<div className="custom-dialog-actions">
{type === 'confirm' && (
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
{cancelLabel}
</button>
)}
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
{confirmLabel}
</button>
</div>
</div>
</div>
)}
</DialogContext.Provider>
)
}
+3 -1
View File
@@ -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<string | null>(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()
+2
View File
@@ -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",
+2
View File
@@ -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",
-1
View File
@@ -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)