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 = ` +
+ + + `; + + 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 = ` +${message}
+