From 86d45e8beefb00836d344ec449892b0836f00c7d Mon Sep 17 00:00:00 2001 From: Prakshil Date: Tue, 19 May 2026 15:27:55 +0530 Subject: [PATCH] ui: Replace browser alerts with custom modern toast notifications. --- README.md | 9 +++- css/index.css | 124 +++++++++++++++++++++++++++++++++++++++++++++- js/app.js | 9 ++-- js/store.js | 22 ++++---- js/utils/toast.js | 106 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 js/utils/toast.js diff --git a/README.md b/README.md index 7103879..16377a9 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,12 @@ Planner + Calendar Update - Inline editing (no popups) - Modify extracted data before saving +### 🔔 Smart Notifications +- Modern, glassmorphic toast notifications +- Interactive confirmation modals +- Zero-dependency Vanilla JS implementation +- Automatically adapts to Light/Dark mode + ### 💾 Persistent Storage - SQLite-based local database - Structured task + subject mapping @@ -158,7 +164,8 @@ Open → http://localhost:3000 ├── js │ ├── utils │ │ ├── aiMock.js # The original mock UI extraction hook (deprecated) -│ │ └── api.js # The live fetch logic communicating with our Express API +│ │ ├── api.js # The live fetch logic communicating with our Express API +│ │ └── toast.js # Modern toast & confirmation modal system │ ├── app.js # The main controller (handles DOM UI, event bindings, and Calendar) │ └── store.js # The Custom State Manager handling our frontend Pub/Sub state ├── .env.example # Template file for setting the GEMINI_API_KEY diff --git a/css/index.css b/css/index.css index 24a20f7..52e5ecf 100644 --- a/css/index.css +++ b/css/index.css @@ -2159,4 +2159,126 @@ body { .smart-highlight { color: #ffcc66; font-weight: 600; -} \ No newline at end of file +} + +/* ========================================================================== + Modern Toast Notifications + ========================================================================== */ + +.toast-container { + position: fixed; + top: 24px; + right: 24px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: none; +} + +.toast-notification { + pointer-events: auto; + display: flex; + align-items: center; + gap: 12px; + background: var(--color-background-primary); + color: var(--color-text-primary); + padding: 12px 16px; + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + border: 1px solid var(--color-border-secondary); + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 500; + animation: toastSlideIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; + max-width: 350px; + transform-origin: top right; +} + +.toast-notification.toast-hiding { + animation: toastSlideOut 0.2s ease-in forwards; +} + +.toast-icon { + font-size: 18px; + flex-shrink: 0; +} + +.toast-message { + flex-grow: 1; + line-height: 1.4; +} + +.toast-close { + background: none; + border: none; + color: var(--color-text-tertiary); + font-size: 18px; + cursor: pointer; + padding: 0; + margin-left: 8px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; +} + +.toast-close:hover { + color: var(--color-text-primary); +} + +/* Toast Themes */ +.toast-success { border-left: 4px solid var(--color-text-success); } +.toast-error { border-left: 4px solid var(--color-text-danger); } +.toast-warning { border-left: 4px solid var(--color-text-warning); } +.toast-info { border-left: 4px solid var(--color-text-info); } + +@keyframes toastSlideIn { + from { opacity: 0; transform: translateY(-20px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes toastSlideOut { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.9); } +} + +/* ========================================================================== + Modern Confirm Modal + ========================================================================== */ +.custom-confirm-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 10001; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + opacity: 0; +} + +.custom-confirm-modal { + background: var(--color-background-primary); + border: 1px solid var(--color-border-tertiary); + color: var(--color-text-primary); + opacity: 0; + transform: translateY(10px); +} + +@keyframes fadeIn { + to { opacity: 1; } +} + +@keyframes fadeOut { + to { opacity: 0; } +} + +@keyframes slideUp { + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideDown { + to { opacity: 0; transform: translateY(10px); } +} diff --git a/js/app.js b/js/app.js index 9bfd993..e98f86b 100644 --- a/js/app.js +++ b/js/app.js @@ -2,6 +2,7 @@ import { store } from './store.js'; import { extractTasksFromText } from './utils/api.js'; import { initGlobalErrorBoundary } from './utils/errorBoundary.js'; import { analyzeWorkload } from './utils/scheduler.js'; +import { Toast } from './utils/toast.js'; initGlobalErrorBoundary(); @@ -208,7 +209,7 @@ function startTimer() { if (timeLeft === 0) { clearInterval(timerInterval); timerInterval = null; - alert('Focus session complete!'); + Toast.show('Focus session complete!', 'success'); resetTimer(); } }, 1000); @@ -380,7 +381,7 @@ async function downloadData() { } catch (error) { console.error(error); - alert('Failed to download data'); + Toast.show('Failed to download data', 'error'); } } @@ -976,7 +977,7 @@ document.addEventListener('DOMContentLoaded', () => { newTaskBtn.addEventListener('click', () => { if (!store.subjects || store.subjects.length === 0) { - alert('Subjects are still loading. Please try again in a moment.'); + Toast.show('Subjects are still loading. Please try again in a moment.', 'warning'); return; } @@ -1016,7 +1017,7 @@ newTaskSave.addEventListener('click', async () => { const dateVal = newTaskDate.value; if (!title) { - alert('Please enter a task name'); + Toast.show('Please enter a task name', 'warning'); return; } diff --git a/js/store.js b/js/store.js index 5d83807..cb02f4b 100644 --- a/js/store.js +++ b/js/store.js @@ -1,3 +1,5 @@ +import { Toast } from './utils/toast.js'; + export const store = { subjects: [], tasks: [], @@ -37,7 +39,7 @@ export const store = { async addSubject({ name, color }) { const trimmed = String(name || '').trim(); if (!trimmed) { - alert('Please enter a subject name'); + Toast.show('Please enter a subject name', 'warning'); return false; } try { @@ -48,7 +50,7 @@ export const store = { }); const data = await res.json().catch(() => ({})); if (!res.ok) { - alert(data.error || 'Failed to add subject'); + Toast.show(data.error || 'Failed to add subject', 'error'); return false; } const subsRes = await fetch('/api/subjects'); @@ -57,7 +59,7 @@ export const store = { return true; } catch (e) { console.error('Failed to add subject', e); - alert('Network error. Please try again.'); + Toast.show('Network error. Please try again.', 'error'); return false; } }, @@ -75,7 +77,7 @@ export const store = { if (!res.ok) { // Backend error - alert(`❌ ${data.message || "Failed to add tasks"}`); + Toast.show(`❌ ${data.message || "Failed to add tasks"}`, 'error'); console.error('Add task error:', data); return; } @@ -83,11 +85,11 @@ export const store = { // ================= USER MESSAGES ================= if (data.duplicates?.length > 0) { - alert(`⚠ ${data.duplicates.length} duplicate task(s) skipped`); + Toast.show(`⚠ ${data.duplicates.length} duplicate task(s) skipped`, 'warning'); } if (data.errors?.length > 0) { - alert(`❌ ${data.errors.length} task(s) failed to add`); + Toast.show(`❌ ${data.errors.length} task(s) failed to add`, 'error'); } if ( @@ -95,7 +97,7 @@ export const store = { (data.duplicates?.length || 0) === 0 && (data.errors?.length || 0) === 0 ) { - alert("✅ Tasks added successfully"); + Toast.show("✅ Tasks added successfully", 'success'); } // ================= REFRESH ================= @@ -105,7 +107,7 @@ export const store = { } catch (e) { console.error('Failed to add tasks', e); - alert("❌ Network error. Please try again."); + Toast.show("❌ Network error. Please try again.", 'error'); } }, @@ -140,7 +142,7 @@ export const store = { } } catch (e) { console.error('Failed to update task', e); - alert("❌ Failed to save task changes. Please try again."); + Toast.show("❌ Failed to save task changes. Please try again.", 'error'); // Revert this.tasks[taskIndex] = originalTask; this.notify(); @@ -206,7 +208,7 @@ export const store = { }, async deleteTask(taskId) { - const confirmed = confirm('Are you sure you want to permanently delete this task?'); + const confirmed = await Toast.confirm('Are you sure you want to permanently delete this task?'); if (!confirmed) return; const taskIndex = this.tasks.findIndex(t => String(t.id) === String(taskId)); diff --git a/js/utils/toast.js b/js/utils/toast.js new file mode 100644 index 0000000..b03b4e8 --- /dev/null +++ b/js/utils/toast.js @@ -0,0 +1,106 @@ +/** + * Custom modern toast notifications for StudyPlan + */ + +class ToastManager { + constructor() { + this.container = document.createElement('div'); + this.container.className = 'toast-container'; + document.body.appendChild(this.container); + } + + show(message, type = 'info', duration = 3000) { + const toast = document.createElement('div'); + toast.className = `toast-notification toast-${type}`; + + let icon = ''; + if (type === 'success') icon = '✅'; + else if (type === 'error') icon = '❌'; + else if (type === 'warning') icon = '⚠'; + else icon = 'â„šī¸'; + + // Handle messages that already have emojis at the start to avoid double icons + let cleanMessage = message; + if (/^[✅❌⚠]/.test(message)) { + icon = message.charAt(0); + cleanMessage = message.substring(1).trim(); + } + + toast.innerHTML = ` +
${icon}
+
${cleanMessage}
+ + `; + + this.container.appendChild(toast); + + // Setup close button + const closeBtn = toast.querySelector('.toast-close'); + closeBtn.addEventListener('click', () => { + this.closeToast(toast); + }); + + // Auto dismiss + if (duration > 0) { + setTimeout(() => { + this.closeToast(toast); + }, duration); + } + } + + closeToast(toast) { + if (toast.classList.contains('toast-hiding')) return; + toast.classList.add('toast-hiding'); + toast.addEventListener('animationend', () => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }); + } + + confirm(message) { + return new Promise((resolve) => { + const backdrop = document.createElement('div'); + backdrop.className = 'custom-confirm-backdrop'; + + const modal = document.createElement('div'); + modal.className = 'custom-confirm-modal modal-card'; + + modal.innerHTML = ` +

Confirm Action

+

${message}

+
+ + +
+ `; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + // Animation in + backdrop.style.animation = 'fadeIn 0.2s ease-out forwards'; + modal.style.animation = 'slideUp 0.2s ease-out forwards'; + + const close = (result) => { + backdrop.style.animation = 'fadeOut 0.2s ease-out forwards'; + modal.style.animation = 'slideDown 0.2s ease-out forwards'; + setTimeout(() => { + if (backdrop.parentNode) { + backdrop.parentNode.removeChild(backdrop); + } + resolve(result); + }, 200); // match animation duration + }; + + backdrop.querySelector('.confirm-cancel').addEventListener('click', () => close(false)); + backdrop.querySelector('.confirm-ok').addEventListener('click', () => close(true)); + + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) close(false); + }); + }); + } +} + +export const Toast = new ToastManager();