Skip to content

Commit 61e681b

Browse files
committed
feat: replace browser dialogs with toast notifications and confirm dialog
1 parent d0f6252 commit 61e681b

File tree

7 files changed

+498
-44
lines changed

7 files changed

+498
-44
lines changed

src/App.jsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ import { LoginPage } from './pages/LoginPage'
33
import { SignupPage } from './pages/SignupPage'
44
import { TodoPage } from './pages/TodoPage';
55
import { ProtectedRoute } from './components/ProtectedRoute';
6+
import { ToastProvider } from './components/Toast';
67
import './App.css'
78

89
function App() {
910
return (
10-
<Routes>
11-
<Route index element={<LoginPage />} />
12-
<Route path="/signup" element={<SignupPage />} />
13-
<Route path="/todo" element={<ProtectedRoute><TodoPage /></ProtectedRoute>} />
14-
</Routes>
11+
<ToastProvider>
12+
<Routes>
13+
<Route index element={<LoginPage />} />
14+
<Route path="/signup" element={<SignupPage />} />
15+
<Route path="/todo" element={<ProtectedRoute><TodoPage /></ProtectedRoute>} />
16+
</Routes>
17+
</ToastProvider>
1518
)
1619
}
1720

src/components/ConfirmDialog.css

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
.confirm-overlay {
2+
position: fixed;
3+
top: 0;
4+
left: 0;
5+
right: 0;
6+
bottom: 0;
7+
background-color: rgba(0, 0, 0, 0.6);
8+
display: flex;
9+
justify-content: center;
10+
align-items: center;
11+
z-index: 2000;
12+
animation: confirmFadeIn 0.2s ease-out;
13+
}
14+
15+
.confirm-card {
16+
background: white;
17+
border-radius: 1rem;
18+
padding: 2rem;
19+
width: 90%;
20+
max-width: 400px;
21+
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
22+
animation: confirmSlideUp 0.3s ease-out;
23+
}
24+
25+
.confirm-title {
26+
margin: 0 0 0.75rem 0;
27+
color: #1f2937;
28+
font-size: 1.25rem;
29+
font-weight: 700;
30+
}
31+
32+
.confirm-message {
33+
margin: 0 0 1.5rem 0;
34+
color: #6b7280;
35+
font-size: 0.95rem;
36+
line-height: 1.5;
37+
}
38+
39+
.confirm-actions {
40+
display: flex;
41+
gap: 0.75rem;
42+
}
43+
44+
.confirm-cancel-btn,
45+
.confirm-ok-btn {
46+
flex: 1;
47+
padding: 0.75rem;
48+
border: none;
49+
border-radius: 0.5rem;
50+
font-weight: 600;
51+
cursor: pointer;
52+
transition: all 0.2s;
53+
font-size: 1rem;
54+
}
55+
56+
.confirm-cancel-btn {
57+
background-color: #f3f4f6;
58+
color: #374151;
59+
}
60+
61+
.confirm-cancel-btn:hover {
62+
background-color: #e5e7eb;
63+
}
64+
65+
.confirm-ok-btn {
66+
background-color: #ef4444;
67+
color: white;
68+
}
69+
70+
.confirm-ok-btn:hover {
71+
background-color: #dc2626;
72+
}
73+
74+
@keyframes confirmFadeIn {
75+
from { opacity: 0; }
76+
to { opacity: 1; }
77+
}
78+
79+
@keyframes confirmSlideUp {
80+
from {
81+
transform: translateY(20px);
82+
opacity: 0;
83+
}
84+
to {
85+
transform: translateY(0);
86+
opacity: 1;
87+
}
88+
}

src/components/ConfirmDialog.jsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useState, useCallback, useEffect, useRef } from 'react';
2+
import './ConfirmDialog.css';
3+
4+
export function useConfirmDialog() {
5+
const [state, setState] = useState(null);
6+
const resolveRef = useRef(null);
7+
8+
const showConfirm = useCallback((message, title = 'Confirm') => {
9+
return new Promise((resolve) => {
10+
resolveRef.current = resolve;
11+
setState({ message, title });
12+
});
13+
}, []);
14+
15+
const handleConfirm = useCallback(() => {
16+
resolveRef.current?.(true);
17+
resolveRef.current = null;
18+
setState(null);
19+
}, []);
20+
21+
const handleCancel = useCallback(() => {
22+
resolveRef.current?.(false);
23+
resolveRef.current = null;
24+
setState(null);
25+
}, []);
26+
27+
useEffect(() => {
28+
if (!state) return;
29+
const handleKeyDown = (e) => {
30+
if (e.key === 'Escape') handleCancel();
31+
};
32+
document.addEventListener('keydown', handleKeyDown);
33+
return () => document.removeEventListener('keydown', handleKeyDown);
34+
}, [state, handleCancel]);
35+
36+
const ConfirmDialog = state ? (
37+
<div className="confirm-overlay" onClick={handleCancel}>
38+
<div className="confirm-card" onClick={(e) => e.stopPropagation()}>
39+
<h3 className="confirm-title">{state.title}</h3>
40+
<p className="confirm-message">{state.message}</p>
41+
<div className="confirm-actions">
42+
<button className="confirm-cancel-btn" onClick={handleCancel}>
43+
Cancel
44+
</button>
45+
<button className="confirm-ok-btn" onClick={handleConfirm} autoFocus>
46+
Confirm
47+
</button>
48+
</div>
49+
</div>
50+
</div>
51+
) : null;
52+
53+
return { showConfirm, ConfirmDialog };
54+
}

src/components/Toast.css

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
.toast-container {
2+
position: fixed;
3+
top: 1rem;
4+
right: 1rem;
5+
z-index: 9999;
6+
display: flex;
7+
flex-direction: column;
8+
gap: 0.5rem;
9+
pointer-events: none;
10+
}
11+
12+
.toast {
13+
display: flex;
14+
align-items: center;
15+
gap: 0.75rem;
16+
padding: 0.875rem 1.25rem;
17+
border-radius: 0.75rem;
18+
color: white;
19+
font-size: 0.925rem;
20+
font-weight: 500;
21+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
22+
pointer-events: auto;
23+
cursor: pointer;
24+
animation: toastSlideIn 0.3s ease-out;
25+
max-width: 380px;
26+
word-break: break-word;
27+
}
28+
29+
.toast.dismissing {
30+
animation: toastSlideOut 0.25s ease-in forwards;
31+
}
32+
33+
.toast-success {
34+
background-color: #b45309;
35+
border-left: 4px solid #fbbf24;
36+
}
37+
38+
.toast-error {
39+
background-color: #dc2626;
40+
border-left: 4px solid #fca5a5;
41+
}
42+
43+
.toast-warning {
44+
background-color: #d97706;
45+
border-left: 4px solid #fde68a;
46+
}
47+
48+
.toast-info {
49+
background-color: #2563eb;
50+
border-left: 4px solid #93c5fd;
51+
}
52+
53+
.toast-icon {
54+
font-size: 1.15rem;
55+
flex-shrink: 0;
56+
}
57+
58+
.toast-message {
59+
flex: 1;
60+
line-height: 1.4;
61+
}
62+
63+
@keyframes toastSlideIn {
64+
from {
65+
transform: translateX(100%);
66+
opacity: 0;
67+
}
68+
to {
69+
transform: translateX(0);
70+
opacity: 1;
71+
}
72+
}
73+
74+
@keyframes toastSlideOut {
75+
from {
76+
transform: translateX(0);
77+
opacity: 1;
78+
}
79+
to {
80+
transform: translateX(100%);
81+
opacity: 0;
82+
}
83+
}
84+
85+
/* Mobile: stack at bottom */
86+
@media (max-width: 768px) {
87+
.toast-container {
88+
top: auto;
89+
bottom: 1rem;
90+
right: 1rem;
91+
left: 1rem;
92+
}
93+
94+
.toast {
95+
max-width: 100%;
96+
}
97+
}

src/components/Toast.jsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { createContext, useContext, useState, useCallback, useRef } from 'react';
2+
import './Toast.css';
3+
4+
const ToastContext = createContext(null);
5+
6+
const ICONS = {
7+
success: '\u2714',
8+
error: '\u2716',
9+
warning: '\u26A0',
10+
info: '\u2139',
11+
};
12+
13+
const MAX_TOASTS = 5;
14+
15+
function ToastItem({ toast, onDismiss }) {
16+
const [dismissing, setDismissing] = useState(false);
17+
18+
const handleDismiss = () => {
19+
setDismissing(true);
20+
setTimeout(() => onDismiss(toast.id), 250);
21+
};
22+
23+
return (
24+
<div
25+
className={`toast toast-${toast.type} ${dismissing ? 'dismissing' : ''}`}
26+
onClick={handleDismiss}
27+
role="alert"
28+
>
29+
<span className="toast-icon">{ICONS[toast.type] || ICONS.info}</span>
30+
<span className="toast-message">{toast.message}</span>
31+
</div>
32+
);
33+
}
34+
35+
export function ToastProvider({ children }) {
36+
const [toasts, setToasts] = useState([]);
37+
const idRef = useRef(0);
38+
39+
const removeToast = useCallback((id) => {
40+
setToasts((prev) => prev.filter((t) => t.id !== id));
41+
}, []);
42+
43+
const showToast = useCallback((message, type = 'info', duration = 3000) => {
44+
const id = ++idRef.current;
45+
setToasts((prev) => {
46+
const next = [...prev, { id, message, type }];
47+
return next.length > MAX_TOASTS ? next.slice(-MAX_TOASTS) : next;
48+
});
49+
setTimeout(() => removeToast(id), duration);
50+
return id;
51+
}, [removeToast]);
52+
53+
return (
54+
<ToastContext.Provider value={{ showToast }}>
55+
{children}
56+
<div className="toast-container">
57+
{toasts.map((toast) => (
58+
<ToastItem key={toast.id} toast={toast} onDismiss={removeToast} />
59+
))}
60+
</div>
61+
</ToastContext.Provider>
62+
);
63+
}
64+
65+
export function useToast() {
66+
const context = useContext(ToastContext);
67+
if (!context) {
68+
throw new Error('useToast must be used within a ToastProvider');
69+
}
70+
return context;
71+
}

0 commit comments

Comments
 (0)