Replace native browser alerts and confirms with customized modern promise-based overlay dialogs
This commit is contained in:
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user