Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
124 changes: 123 additions & 1 deletion css/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2159,4 +2159,126 @@ body {
.smart-highlight {
color: #ffcc66;
font-weight: 600;
}
}

/* ==========================================================================
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); }
}
9 changes: 5 additions & 4 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -380,7 +381,7 @@ async function downloadData() {

} catch (error) {
console.error(error);
alert('Failed to download data');
Toast.show('Failed to download data', 'error');
}
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
22 changes: 12 additions & 10 deletions js/store.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Toast } from './utils/toast.js';

export const store = {
subjects: [],
tasks: [],
Expand Down Expand Up @@ -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 {
Expand All @@ -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');
Expand All @@ -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;
}
},
Expand All @@ -75,27 +77,27 @@ 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;
}

// ================= 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 (
data.inserted > 0 &&
(data.duplicates?.length || 0) === 0 &&
(data.errors?.length || 0) === 0
) {
alert("✅ Tasks added successfully");
Toast.show("✅ Tasks added successfully", 'success');
}

// ================= REFRESH =================
Expand All @@ -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');
}
},

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));
Expand Down
106 changes: 106 additions & 0 deletions js/utils/toast.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="toast-icon">${icon}</div>
<div class="toast-message">${cleanMessage}</div>
<button class="toast-close" aria-label="Close">&times;</button>
`;

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 = `
<h3 style="margin:0 0 12px; font-size:18px; font-weight:600;">Confirm Action</h3>
<p style="font-size:14px; margin-bottom: 24px; color: var(--color-text-secondary); line-height: 1.5;">${message}</p>
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button class="btn confirm-cancel" style="padding:6px 16px;">Cancel</button>
<button class="btn btn-primary task-btn-danger confirm-ok" style="padding:6px 16px;">Confirm</button>
</div>
`;

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();