/**
* Sticky Notes App - Offline Web Application
* A complete sticky notes application with local storage persistence
*/
// Global namespace for the app
const StickyApp = (function() {
'use strict';
// Utility functions
const Utils = {
/**
* Generate a UUID v4
* @returns {string} UUID
*/
generateUUID: function() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
},
/**
* Debounce function to limit how often a function can be called
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in milliseconds
* @returns {Function} Debounced function
*/
debounce: function(func, wait) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
},
/**
* Sanitize HTML to prevent XSS
* @param {string} str - String to sanitize
* @returns {string} Sanitized string
*/
sanitizeHTML: function(str) {
const temp = document.createElement('div');
temp.textContent = str;
return temp.innerHTML;
},
/**
* Extract tags from text content
* @param {string} content - Text content
* @returns {Array} Array of tags
*/
extractTags: function(content) {
const tagRegex = /#(\w+)/g;
const tags = [];
let match;
while ((match = tagRegex.exec(content)) !== null) {
tags.push(match[1]);
}
return [...new Set(tags)]; // Remove duplicates
},
/**
* Format date for display
* @param {Date|string} date - Date to format
* @returns {string} Formatted date
*/
formatDate: function(date) {
const d = new Date(date);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
},
/**
* Show notification
* @param {string} message - Message to display
* @param {string} type - Type of notification (success, error, info)
*/
showNotification: function(message, type = 'info') {
const container = document.getElementById('notification-container');
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
container.appendChild(notification);
// Trigger animation
setTimeout(() => {
notification.classList.add('show');
}, 10);
// Remove after 3 seconds
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
container.removeChild(notification);
}, 300);
}, 3000);
}
};
/**
* Note model class
*/
class Note {
constructor(data = {}) {
this.id = data.id || Utils.generateUUID();
this.content = data.content || '';
this.x = data.x || 20 + Math.random() * 200;
this.y = data.y || 20 + Math.random() * 200;
this.width = data.width || 250;
this.height = data.height || 250;
this.color = data.color || '#fff9a8';
this.zIndex = data.zIndex || 10;
this.createdAt = data.createdAt || new Date().toISOString();
this.updatedAt = data.updatedAt || new Date().toISOString();
this.pinned = data.pinned || false;
this.tags = data.tags || [];
this.reminder = data.reminder || null;
this.category = data.category || 'Uncategorized';
this.isMarkdown = data.isMarkdown !== undefined ? data.isMarkdown : false;
this.kanbanStatus = data.kanbanStatus || 'todo';
}
/**
* Update note content and extract tags
* @param {string} content - New content
*/
updateContent(content) {
this.content = content;
this.tags = Utils.extractTags(content);
this.updatedAt = new Date().toISOString();
}
/**
* Convert note to JSON
* @returns {Object} Note data
*/
toJSON() {
return {
id: this.id,
content: this.content,
x: this.x,
y: this.y,
width: this.width,
height: this.height,
color: this.color,
zIndex: this.zIndex,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
pinned: this.pinned,
tags: this.tags,
reminder: this.reminder,
category: this.category,
isMarkdown: this.isMarkdown,
kanbanStatus: this.kanbanStatus
};
}
/**
* Create note from JSON
* @param {Object} data - Note data
* @returns {Note} Note instance
*/
static fromJSON(data) {
return new Note(data);
}
}
/**
* Storage manager class for handling localStorage
*/
class StorageManager {
constructor(key = 'stickyNotesApp') {
this.key = key;
this.maxRecycleBinSize = 10;
}
/**
* Save all data to localStorage
* @param {Object} data - Data to save
*/
save(data) {
try {
localStorage.setItem(this.key, JSON.stringify(data));
return true;
} catch (error) {
if (error.name === 'QuotaExceededError') {
Utils.showNotification('Storage quota exceeded. Please export and delete some notes.', 'error');
} else {
Utils.showNotification('Failed to save data.', 'error');
}
return false;
}
}
/**
* Load all data from localStorage
* @returns {Object} Loaded data
*/
load() {
try {
const data = localStorage.getItem(this.key);
return data ? JSON.parse(data) : this.getEmptyData();
} catch (error) {
Utils.showNotification('Failed to load data.', 'error');
return this.getEmptyData();
}
}
/**
* Get empty data structure
* @returns {Object} Empty data structure
*/
getEmptyData() {
return {
notes: [],
settings: {
theme: 'light',
lastZIndex: 10,
nextId: 1
},
recycleBin: []
};
}
/**
* Export data as JSON string
* @returns {string} JSON string
*/
export() {
const data = this.load();
return JSON.stringify(data, null, 2);
}
/**
* Import data from JSON string
* @param {string} jsonString - JSON string to import
* @param {string} mode - Import mode ('replace' or 'merge')
* @returns {boolean} Success status
*/
import(jsonString, mode = 'merge') {
try {
const importedData = JSON.parse(jsonString);
// Validate data structure
if (!importedData.notes || !Array.isArray(importedData.notes)) {
throw new Error('Invalid data format');
}
const currentData = this.load();
if (mode === 'replace') {
currentData.notes = importedData.notes;
} else {
// Merge notes, avoiding duplicates by ID
const existingIds = new Set(currentData.notes.map(note => note.id));
const newNotes = importedData.notes.filter(note => !existingIds.has(note.id));
currentData.notes.push(...newNotes);
}
// Update settings if available
if (importedData.settings) {
currentData.settings = {...currentData.settings, ...importedData.settings};
}
return this.save(currentData);
} catch (error) {
Utils.showNotification('Failed to import data: ' + error.message, 'error');
return false;
}
}
/**
* Add note to recycle bin
* @param {Note} note - Note to add to recycle bin
*/
addToRecycleBin(note) {
const data = this.load();
data.recycleBin.unshift(note.toJSON());
// Limit recycle bin size
if (data.recycleBin.length > this.maxRecycleBinSize) {
data.recycleBin = data.recycleBin.slice(0, this.maxRecycleBinSize);
}
this.save(data);
}
/**
* Restore note from recycle bin
* @param {string} noteId - ID of note to restore
* @returns {Note|null} Restored note or null
*/
restoreFromRecycleBin(noteId) {
const data = this.load();
const noteIndex = data.recycleBin.findIndex(note => note.id === noteId);
if (noteIndex !== -1) {
const note = data.recycleBin.splice(noteIndex, 1)[0];
data.notes.push(note);
this.save(data);
return Note.fromJSON(note);
}
return null;
}
/**
* Empty recycle bin
*/
emptyRecycleBin() {
const data = this.load();
data.recycleBin = [];
this.save(data);
}
}
/**
* UI Manager class for handling DOM operations
*/
class UIManager {
constructor(boardElement) {
this.board = boardElement;
this.notes = new Map(); // Map of note ID to DOM element
this.selectedNoteId = null;
this.isDragging = false;
this.isResizing = false;
this.dragOffset = {x: 0, y: 0};
this.activeColorPicker = null;
this.activeMenu = null;
this.setupEventListeners();
}
/**
* Setup global event listeners
*/
setupEventListeners() {
// Close color pickers and menus when clicking outside
document.addEventListener('click', (e) => {
if (this.activeColorPicker && !this.activeColorPicker.contains(e.target)) {
this.activeColorPicker.remove();
this.activeColorPicker = null;
}
if (this.activeMenu && !this.activeMenu.contains(e.target)) {
this.activeMenu.remove();
this.activeMenu = null;
}
});
// Handle window resize
window.addEventListener('resize', () => {
this.adjustNotesPosition();
});
}
/**
* Render a note
* @param {Note} note - Note to render
* @returns {HTMLElement} Note DOM element
*/
renderNote(note) {
// Check if note already exists
if (this.notes.has(note.id)) {
return this.updateNote(note);
}
// Create note element
const noteEl = document.createElement('div');
noteEl.className = 'note';
noteEl.id = `note-${note.id}`;
noteEl.style.left = `${note.x}px`;
noteEl.style.top = `${note.y}px`;
noteEl.style.width = `${note.width}px`;
noteEl.style.height = `${note.height}px`;
noteEl.style.backgroundColor = note.color;
noteEl.style.zIndex = note.zIndex;
if (note.pinned) {
noteEl.classList.add('pinned');
}
// Reminder indicator
if (note.reminder && new Date(note.reminder) > new Date()) {
const reminderIndicator = document.createElement('div');
reminderIndicator.className = 'reminder-indicator';
reminderIndicator.innerHTML = '<span class="material-symbols-outlined">alarm</span>' + new Date(note.reminder).toLocaleDateString();
noteEl.appendChild(reminderIndicator);
}
// Create note header
const header = document.createElement('div');
header.className = 'note-header';
// Drag handle
const dragHandle = document.createElement('div');
dragHandle.className = 'drag-handle';
dragHandle.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">drag_handle</span>';
dragHandle.setAttribute('aria-label', 'Drag to move');
// Note actions
const actions = document.createElement('div');
actions.className = 'note-actions';
// Pin button
const pinBtn = document.createElement('button');
pinBtn.className = 'note-action pin-btn';
pinBtn.innerHTML = note.pinned ? '<span class="material-symbols-outlined">push_pin</span>' : '<span class="material-symbols-outlined">push_pin</span>';
if (note.pinned) pinBtn.classList.add('active');
pinBtn.setAttribute('aria-label', note.pinned ? 'Unpin note' : 'Pin note');
// Markdown toggle button
const markdownBtn = document.createElement('button');
markdownBtn.className = 'note-action markdown-btn';
markdownBtn.innerHTML = '<span class="material-symbols-outlined">code</span>';
if (note.isMarkdown) markdownBtn.classList.add('active');
markdownBtn.setAttribute('aria-label', 'Toggle markdown');
// Color picker button
const colorBtn = document.createElement('button');
colorBtn.className = 'note-action color-btn';
colorBtn.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">palette</span>';
colorBtn.setAttribute('aria-label', 'Change color');
// Menu button
const menuBtn = document.createElement('button');
menuBtn.className = 'note-action menu-btn';
menuBtn.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">more_vert</span>';
menuBtn.setAttribute('aria-label', 'Menu');
actions.appendChild(pinBtn);
actions.appendChild(markdownBtn);
actions.appendChild(colorBtn);
actions.appendChild(menuBtn);
header.appendChild(dragHandle);
header.appendChild(actions);
// Rich text toolbar
const toolbar = document.createElement('div');
toolbar.className = 'rich-text-toolbar';
toolbar.style.display = 'none';
const toolbarButtons = [
{cmd: 'bold', icon: 'B', title: 'Bold'},
{cmd: 'italic', icon: 'I', title: 'Italic'},
{cmd: 'underline', icon: 'U', title: 'Underline'},
{cmd: 'strikeThrough', icon: 'S', title: 'Strikethrough'},
{cmd: 'insertUnorderedList', icon: '•', title: 'Bullet List'},
{cmd: 'insertOrderedList', icon: '1.', title: 'Numbered List'}
];
toolbarButtons.forEach(btn => {
const button = document.createElement('button');
button.className = 'toolbar-btn';
button.textContent = btn.icon;
button.title = btn.title;
button.setAttribute('data-command', btn.cmd);
toolbar.appendChild(button);
});
// Create note content
const content = document.createElement('div');
content.className = 'note-content';
content.contentEditable = true;
if (note.isMarkdown && note.content) {
content.innerHTML = marked.parse(note.content);
content.classList.add('preview-mode');
content.contentEditable = false;
} else {
content.textContent = note.content;
}
content.setAttribute('role', 'textbox');
content.setAttribute('aria-label', 'Note content');
// Create note footer
const footer = document.createElement('div');
footer.className = 'note-footer';
const timestamp = document.createElement('div');
timestamp.className = 'note-timestamp';
timestamp.textContent = Utils.formatDate(note.updatedAt);
const tags = document.createElement('div');
tags.className = 'note-tags';
// Category badge
if (note.category && note.category !== 'Uncategorized') {
const categoryBadge = document.createElement('span');
categoryBadge.className = 'category-badge';
categoryBadge.textContent = note.category;
tags.appendChild(categoryBadge);
}
note.tags.forEach(tag => {
const tagEl = document.createElement('span');
tagEl.className = 'tag';
tagEl.textContent = `#${tag}`;
tags.appendChild(tagEl);
});
footer.appendChild(timestamp);
footer.appendChild(tags);
// Create resize handle
const resizeHandle = document.createElement('div');
resizeHandle.className = 'resize-handle';
// Assemble note
noteEl.appendChild(header);
noteEl.appendChild(toolbar);
noteEl.appendChild(content);
noteEl.appendChild(footer);
noteEl.appendChild(resizeHandle);
// Add event listeners
this.setupNoteEventListeners(noteEl, note);
// Add to board and track
this.board.appendChild(noteEl);
this.notes.set(note.id, noteEl);
return noteEl;
}
/**
* Update an existing note
* @param {Note} note - Note to update
* @returns {HTMLElement} Updated note DOM element
*/
updateNote(note) {
const noteEl = this.notes.get(note.id);
if (!noteEl) return null;
// Update position and size
noteEl.style.left = `${note.x}px`;
noteEl.style.top = `${note.y}px`;
noteEl.style.width = `${note.width}px`;
noteEl.style.height = `${note.height}px`;
noteEl.style.backgroundColor = note.color;
noteEl.style.zIndex = note.zIndex;
// Update pinned state
if (note.pinned) {
noteEl.classList.add('pinned');
} else {
noteEl.classList.remove('pinned');
}
// Update content
const contentEl = noteEl.querySelector('.note-content');
contentEl.textContent = note.content;
// Update tags
const tagsEl = noteEl.querySelector('.note-tags');
tagsEl.innerHTML = '';
note.tags.forEach(tag => {
const tagEl = document.createElement('span');
tagEl.className = 'tag';
tagEl.textContent = `#${tag}`;
tagsEl.appendChild(tagEl);
});
// Update timestamp
const timestampEl = noteEl.querySelector('.note-timestamp');
timestampEl.textContent = Utils.formatDate(note.updatedAt);
// Update pin button
const pinBtn = noteEl.querySelector('.pin-btn');
pinBtn.innerHTML = '<span class="material-symbols-outlined">push_pin</span>';
if (note.pinned) pinBtn.classList.add('active'); else pinBtn.classList.remove('active');
pinBtn.setAttribute('aria-label', note.pinned ? 'Unpin note' : 'Pin note');
return noteEl;
}
/**
* Setup event listeners for a note
* @param {HTMLElement} noteEl - Note DOM element
* @param {Note} note - Note model
*/
setupNoteEventListeners(noteEl, note) {
const header = noteEl.querySelector('.note-header');
const content = noteEl.querySelector('.note-content');
const toolbar = noteEl.querySelector('.rich-text-toolbar');
const pinBtn = noteEl.querySelector('.pin-btn');
const markdownBtn = noteEl.querySelector('.markdown-btn');
const colorBtn = noteEl.querySelector('.color-btn');
const menuBtn = noteEl.querySelector('.menu-btn');
const resizeHandle = noteEl.querySelector('.resize-handle');
// Note selection
noteEl.addEventListener('mousedown', (e) => {
if (e.target === noteEl || e.target === header || e.target === header.querySelector('.drag-handle')) {
this.selectNote(note.id);
}
});
// Drag functionality
header.addEventListener('pointerdown', (e) => {
// If the pointerdown originated from any action button (pin, color, menu, etc.)
// don't start dragging. Use closest() to handle clicks on inner elements
// (icons/text nodes) inside the button rather than relying on strict
// reference equality which can fail for nested targets.
if (e.target.closest && e.target.closest('.note-action')) return;
this.isDragging = true;
this.dragOffset.x = e.clientX - note.x;
this.dragOffset.y = e.clientY - note.y;
const handlePointerMove = (e) => {
if (!this.isDragging) return;
const newX = e.clientX - this.dragOffset.x;
const newY = e.clientY - this.dragOffset.y;
// Keep note within viewport
const maxX = window.innerWidth - note.width;
const maxY = window.innerHeight - note.height - 80; // Account for header
note.x = Math.max(0, Math.min(newX, maxX));
note.y = Math.max(0, Math.min(newY, maxY));
noteEl.style.left = `${note.x}px`;
noteEl.style.top = `${note.y}px`;
};
const handlePointerUp = () => {
this.isDragging = false;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
// Trigger save
this.onNoteChange(note);
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
});
// Resize functionality
resizeHandle.addEventListener('pointerdown', (e) => {
this.isResizing = true;
const startX = e.clientX;
const startY = e.clientY;
const startWidth = note.width;
const startHeight = note.height;
const handlePointerMove = (e) => {
if (!this.isResizing) return;
const newWidth = startWidth + (e.clientX - startX);
const newHeight = startHeight + (e.clientY - startY);
// Minimum size
note.width = Math.max(150, newWidth);
note.height = Math.max(150, newHeight);
noteEl.style.width = `${note.width}px`;
noteEl.style.height = `${note.height}px`;
};
const handlePointerUp = () => {
this.isResizing = false;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
// Trigger save
this.onNoteChange(note);
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
});
// Content change
content.addEventListener('input', Utils.debounce(() => {
if (note.isMarkdown) {
note.updateContent(content.textContent);
} else {
note.updateContent(content.textContent);
}
this.updateNote(note);
this.onNoteChange(note);
}, 500));
// Show/hide toolbar on focus
content.addEventListener('focus', () => {
if (!note.isMarkdown) {
toolbar.style.display = 'flex';
}
});
content.addEventListener('blur', () => {
setTimeout(() => {
if (!toolbar.contains(document.activeElement)) {
toolbar.style.display = 'none';
}
}, 200);
});
// Toolbar buttons
toolbar.querySelectorAll('.toolbar-btn').forEach(btn => {
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
const command = btn.getAttribute('data-command');
document.execCommand(command, false, null);
content.focus();
});
});
// Pin toggle
pinBtn.addEventListener('click', () => {
note.pinned = !note.pinned;
this.updateNote(note);
this.onNoteChange(note);
});
// Markdown toggle
markdownBtn.addEventListener('click', () => {
note.isMarkdown = !note.isMarkdown;
if (note.isMarkdown) {
// Switch to markdown preview mode
const plainText = content.textContent;
note.content = plainText;
content.innerHTML = marked.parse(plainText);
content.classList.add('preview-mode');
content.contentEditable = false;
toolbar.style.display = 'none';
markdownBtn.classList.add('active');
} else {
// Switch back to edit mode
const plainText = note.content;
content.textContent = plainText;
content.classList.remove('preview-mode');
content.contentEditable = true;
markdownBtn.classList.remove('active');
}
this.onNoteChange(note);
});
// Color picker (render as floating element attached to body to avoid clipping)
colorBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Close existing color picker
if (this.activeColorPicker) {
this.activeColorPicker.remove();
this.activeColorPicker = null;
}
// Create color picker and append to body
const colorPicker = this.createColorPicker(note);
document.body.appendChild(colorPicker);
this.positionFloatingElement(colorPicker, colorBtn);
colorPicker.style.display = 'flex';
this.activeColorPicker = colorPicker;
// Prevent the header drag from starting when clicking inside the picker
colorPicker.addEventListener('pointerdown', (ev) => ev.stopPropagation());
});
// Menu (render as floating element attached to body to avoid clipping)
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Close existing menu
if (this.activeMenu) {
this.activeMenu.remove();
this.activeMenu = null;
}
// Create menu and append to body
const menu = this.createNoteMenu(note);
document.body.appendChild(menu);
this.positionFloatingElement(menu, menuBtn);
menu.style.display = 'block';
this.activeMenu = menu;
// Prevent the header drag from starting when clicking inside the menu
menu.addEventListener('pointerdown', (ev) => ev.stopPropagation());
});
}
/**
* Create color picker for a note
* @param {Note} note - Note to create color picker for
* @returns {HTMLElement} Color picker element
*/
createColorPicker(note) {
const colorPicker = document.createElement('div');
colorPicker.className = 'color-picker';
const colors = ['#fff9a8', '#ffebcc', '#d4f0f0', '#ffd6d6', '#e6ccff', '#ccffcc'];
colors.forEach(color => {
const colorOption = document.createElement('div');
colorOption.className = 'color-option';
colorOption.style.backgroundColor = color;
colorOption.setAttribute('aria-label', `Select color ${color}`);
colorOption.addEventListener('click', () => {
note.color = color;
this.updateNote(note);
this.onNoteChange(note);
colorPicker.style.display = 'none';
this.activeColorPicker = null;
});
colorPicker.appendChild(colorOption);
});
return colorPicker;
}
/**
* Position a floating element (appended to body) next to a reference element
* and keep it inside the viewport.
* @param {HTMLElement} floatingEl - Element appended to body
* @param {HTMLElement} referenceEl - Element to position next to
*/
positionFloatingElement(floatingEl, referenceEl) {
const refRect = referenceEl.getBoundingClientRect();
const floatRect = floatingEl.getBoundingClientRect();
// Default position: below and aligned to right of reference
let top = refRect.bottom + window.scrollY + 4; // small gap
let left = refRect.right + window.scrollX - floatRect.width;
// If it goes off the right edge, align to the left of the reference
if (left + floatRect.width > window.innerWidth + window.scrollX) {
left = refRect.left + window.scrollX;
}
// If it goes off the bottom edge, position above reference
if (top + floatRect.height > window.innerHeight + window.scrollY) {
top = refRect.top + window.scrollY - floatRect.height - 4;
}
// Clamp to viewport
top = Math.max(window.scrollY + 4, Math.min(top, window.scrollY + window.innerHeight - floatRect.height - 4));
left = Math.max(window.scrollX + 4, Math.min(left, window.scrollX + window.innerWidth - floatRect.width - 4));
floatingEl.style.position = 'absolute';
floatingEl.style.top = `${top}px`;
floatingEl.style.left = `${left}px`;
floatingEl.style.zIndex = 2000;
}
/**
* Create menu for a note
* @param {Note} note - Note to create menu for
* @returns {HTMLElement} Menu element
*/
createNoteMenu(note) {
const menu = document.createElement('div');
menu.className = 'note-menu';
// Set reminder option
const reminderOption = document.createElement('div');
reminderOption.className = 'menu-item';
reminderOption.textContent = 'Set Reminder';
reminderOption.addEventListener('click', () => {
this.onSetReminder(note);
menu.style.display = 'none';
this.activeMenu = null;
});
// Set category option
const categoryOption = document.createElement('div');
categoryOption.className = 'menu-item';
categoryOption.textContent = 'Set Category';
categoryOption.addEventListener('click', () => {
this.onSetCategory(note);
menu.style.display = 'none';
this.activeMenu = null;
});
// Duplicate option
const duplicateOption = document.createElement('div');
duplicateOption.className = 'menu-item';
duplicateOption.textContent = 'Duplicate';
duplicateOption.addEventListener('click', () => {
this.onNoteDuplicate(note);
menu.style.display = 'none';
this.activeMenu = null;
});
// Delete option
const deleteOption = document.createElement('div');
deleteOption.className = 'menu-item';
deleteOption.textContent = 'Delete';
deleteOption.addEventListener('click', () => {
this.onNoteDelete(note);
menu.style.display = 'none';
this.activeMenu = null;
});
// Export option
const exportOption = document.createElement('div');
exportOption.className = 'menu-item';
exportOption.textContent = 'Export';
exportOption.addEventListener('click', () => {
this.onNoteExport(note);
menu.style.display = 'none';
this.activeMenu = null;
});
menu.appendChild(reminderOption);
menu.appendChild(categoryOption);
menu.appendChild(duplicateOption);
menu.appendChild(deleteOption);
menu.appendChild(exportOption);
return menu;
}
/**
* Select a note
* @param {string} noteId - ID of note to select
*/
selectNote(noteId) {
// Deselect previous note
if (this.selectedNoteId) {
const prevNoteEl = this.notes.get(this.selectedNoteId);
if (prevNoteEl) {
prevNoteEl.classList.remove('selected');
}
}
// Select new note
this.selectedNoteId = noteId;
const noteEl = this.notes.get(noteId);
if (noteEl) {
noteEl.classList.add('selected');
this.onNoteSelect(noteId);
}
}
/**
* Remove a note from the DOM
* @param {string} noteId - ID of note to remove
*/
removeNote(noteId) {
const noteEl = this.notes.get(noteId);
if (noteEl) {
this.board.removeChild(noteEl);
this.notes.delete(noteId);
if (this.selectedNoteId === noteId) {
this.selectedNoteId = null;
}
}
}
/**
* Clear all notes from the DOM
*/
clearAllNotes() {
this.notes.forEach(noteEl => {
this.board.removeChild(noteEl);
});
this.notes.clear();
this.selectedNoteId = null;
}
/**
* Filter notes based on search query
* @param {string} query - Search query
*/
filterNotes(query) {
const lowerQuery = query.toLowerCase();
this.notes.forEach((noteEl, noteId) => {
const note = this.onGetNote(noteId);
if (!note) return;
const matchesContent = note.content.toLowerCase().includes(lowerQuery);
const matchesTags = note.tags.some(tag => tag.toLowerCase().includes(lowerQuery));
if (matchesContent || matchesTags) {
noteEl.style.display = 'flex';
} else {
noteEl.style.display = 'none';
}
});
}
/**
* Show all notes
*/
showAllNotes() {
this.notes.forEach(noteEl => {
noteEl.style.display = 'flex';
});
}
/**
* Adjust notes position to fit within viewport
*/
adjustNotesPosition() {
this.notes.forEach((noteEl, noteId) => {
const note = this.onGetNote(noteId);
if (!note) return;
const maxX = window.innerWidth - note.width;
const maxY = window.innerHeight - note.height - 80; // Account for header
if (note.x > maxX) {
note.x = maxX;
noteEl.style.left = `${note.x}px`;
}
if (note.y > maxY) {
note.y = maxY;
noteEl.style.top = `${note.y}px`;
}
});
}
/**
* Set theme
* @param {string} theme - Theme name ('light' or 'dark')
*/
setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
/**
* Show recycle bin modal
* @param {Array} deletedNotes - Array of deleted notes
*/
showRecycleBin(deletedNotes) {
const modal = document.getElementById('recycle-bin-modal');
const content = document.getElementById('recycle-bin-content');
content.innerHTML = '';
if (deletedNotes.length === 0) {
content.innerHTML = '<div class="empty-state show"><div class="empty-state-icon">🗑️</div><p>Recycle bin is empty</p></div>';
} else {
deletedNotes.forEach(noteData => {
const note = Note.fromJSON(noteData);
const noteEl = document.createElement('div');
noteEl.className = 'deleted-note';
const noteContent = document.createElement('div');
noteContent.className = 'deleted-note-content';
noteContent.textContent = note.content.substring(0, 100) + (note.content.length > 100 ? '...' : '');
const noteActions = document.createElement('div');
noteActions.className = 'deleted-note-actions';
const restoreBtn = document.createElement('button');
restoreBtn.className = 'btn btn-primary';
restoreBtn.textContent = 'Restore';
restoreBtn.addEventListener('click', () => {
this.onRestoreNote(note.id);
});
noteActions.appendChild(restoreBtn);
noteEl.appendChild(noteContent);
noteEl.appendChild(noteActions);
content.appendChild(noteEl);
});
}
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
/**
* Hide recycle bin modal
*/
hideRecycleBin() {
const modal = document.getElementById('recycle-bin-modal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
}
/**
* Show help modal
*/
showHelp() {
const modal = document.getElementById('help-modal');
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
/**
* Hide help modal
*/
hideHelp() {
const modal = document.getElementById('help-modal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
}
/**
* Callback for when a note changes
* @param {Note} note - Changed note
*/
onNoteChange(note) {
// This will be overridden by the AppController
}
/**
* Callback for when a note is selected
* @param {string} noteId - Selected note ID
*/
onNoteSelect(noteId) {
// This will be overridden by the AppController
}
/**
* Callback for when a note is deleted
* @param {Note} note - Deleted note
*/
onNoteDelete(note) {
// This will be overridden by the AppController
}
/**
* Callback for when a note is duplicated
* @param {Note} note - Note to duplicate
*/
onNoteDuplicate(note) {
// This will be overridden by the AppController
}
/**
* Callback for when a note is exported
* @param {Note} note - Note to export
*/
onNoteExport(note) {
// This will be overridden by the AppController
}
/**
* Callback for when a note is restored
* @param {string} noteId - ID of note to restore
*/
onRestoreNote(noteId) {
// This will be overridden by the AppController
}
/**
* Callback for getting a note by ID
* @param {string} noteId - ID of note to get
* @returns {Note|null} Note or null
*/
onGetNote(noteId) {
// This will be overridden by the AppController
return null;
}
/**
* Callback for setting reminder
* @param {Note} note - Note to set reminder for
*/
onSetReminder(note) {
// This will be overridden by the AppController
}
/**
* Callback for setting category
* @param {Note} note - Note to set category for
*/
onSetCategory(note) {
// This will be overridden by the AppController
}
/**
* Render Kanban board
* @param {Array} notes - Array of notes
*/
renderKanban(notes) {
const todoColumn = document.getElementById('kanban-todo');
const inProgressColumn = document.getElementById('kanban-in-progress');
const doneColumn = document.getElementById('kanban-done');
// Clear columns
todoColumn.innerHTML = '';
inProgressColumn.innerHTML = '';
doneColumn.innerHTML = '';
// Count notes in each column
let todoCount = 0;
let inProgressCount = 0;
let doneCount = 0;
notes.forEach(note => {
const card = this.createKanbanCard(note);
if (note.kanbanStatus === 'todo') {
todoColumn.appendChild(card);
todoCount++;
} else if (note.kanbanStatus === 'in-progress') {
inProgressColumn.appendChild(card);
inProgressCount++;
} else if (note.kanbanStatus === 'done') {
doneColumn.appendChild(card);
doneCount++;
}
});
// Update counts
document.querySelector('[data-status="todo"] .kanban-count').textContent = todoCount;
document.querySelector('[data-status="in-progress"] .kanban-count').textContent = inProgressCount;
document.querySelector('[data-status="done"] .kanban-count').textContent = doneCount;
}
/**
* Create Kanban card
* @param {Note} note - Note to create card for
* @returns {HTMLElement} Kanban card element
*/
createKanbanCard(note) {
const card = document.createElement('div');
card.className = 'kanban-card';
card.setAttribute('data-note-id', note.id);
card.style.borderLeftColor = note.color;
card.draggable = true;
const header = document.createElement('div');
header.className = 'kanban-card-header';
if (note.category && note.category !== 'Uncategorized') {
const categoryBadge = document.createElement('span');
categoryBadge.className = 'category-badge';
categoryBadge.textContent = note.category;
header.appendChild(categoryBadge);
}
const content = document.createElement('div');
content.className = 'kanban-card-content';
if (note.isMarkdown) {
content.innerHTML = marked.parse(note.content.substring(0, 200));
} else {
content.textContent = note.content.substring(0, 200) + (note.content.length > 200 ? '...' : '');
}
const footer = document.createElement('div');
footer.className = 'kanban-card-footer';
const timestamp = document.createElement('span');
timestamp.textContent = Utils.formatDate(note.updatedAt);
const tags = document.createElement('div');
tags.className = 'note-tags';
note.tags.slice(0, 2).forEach(tag => {
const tagEl = document.createElement('span');
tagEl.className = 'tag';
tagEl.textContent = `#${tag}`;
tags.appendChild(tagEl);
});
footer.appendChild(timestamp);
footer.appendChild(tags);
card.appendChild(header);
card.appendChild(content);
card.appendChild(footer);
// Drag events
card.addEventListener('dragstart', (e) => {
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', note.id);
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
});
// Click to edit
card.addEventListener('click', () => {
this.onKanbanCardClick(note.id);
});
return card;
}
/**
* Callback for Kanban card click
* @param {string} noteId - ID of note clicked
*/
onKanbanCardClick(noteId) {
// This will be overridden by the AppController
}
}
/**
* App Controller class to tie everything together
*/
class AppController {
constructor() {
this.storage = new StorageManager();
this.ui = new UIManager(document.getElementById('board'));
this.notes = new Map(); // Map of note ID to Note model
this.settings = {};
this.recycleBin = [];
this.lastZIndex = 10;
this.currentView = 'sticky'; // 'sticky' or 'kanban'
this.categories = new Set(['Uncategorized']);
this.templates = this.getTemplates();
this.setupUICallbacks();
this.setupGlobalEventListeners();
this.loadData();
this.updateCategoryFilter();
this.checkReminders();
}
/**
* Setup UI callbacks
*/
setupUICallbacks() {
// Note change callback
this.ui.onNoteChange = (note) => {
this.saveNote(note);
};
// Note select callback
this.ui.onNoteSelect = (noteId) => {
this.bringNoteToFront(noteId);
};
// Note delete callback
this.ui.onNoteDelete = (note) => {
this.deleteNote(note.id);
};
// Note duplicate callback
this.ui.onNoteDuplicate = (note) => {
this.duplicateNote(note.id);
};
// Note export callback
this.ui.onNoteExport = (note) => {
this.exportSingleNote(note);
};
// Note restore callback
this.ui.onRestoreNote = (noteId) => {
this.restoreNote(noteId);
};
// Get note callback
this.ui.onGetNote = (noteId) => {
return this.notes.get(noteId);
};
// Set reminder callback
this.ui.onSetReminder = (note) => {
this.setReminder(note);
};
// Set category callback
this.ui.onSetCategory = (note) => {
this.setCategory(note);
};
// Kanban card click callback
this.ui.onKanbanCardClick = (noteId) => {
this.switchToStickyView();
setTimeout(() => {
this.ui.selectNote(noteId);
const noteEl = this.ui.notes.get(noteId);
if (noteEl) {
noteEl.scrollIntoView({behavior: 'smooth', block: 'center'});
}
}, 100);
};
}
/**
* Setup global event listeners
*/
setupGlobalEventListeners() {
// New note button
document.getElementById('new-note-btn').addEventListener('click', () => {
this.createNote();
});
// Search input
document.getElementById('search-input').addEventListener('input', (e) => {
const query = e.target.value.trim();
if (query) {
this.ui.filterNotes(query);
} else {
this.ui.showAllNotes();
}
});
// Export button
document.getElementById('export-btn').addEventListener('click', () => {
this.exportNotes();
});
// Import button
document.getElementById('import-btn').addEventListener('click', () => {
document.getElementById('import-file').click();
});
// Import file input
document.getElementById('import-file').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const json = event.target.result;
this.importNotes(json);
};
reader.readAsText(file);
}
// Reset input
e.target.value = '';
});
// Theme toggle
document.getElementById('theme-toggle').addEventListener('click', () => {
this.toggleTheme();
});
// Recycle bin button
document.getElementById('recycle-bin-btn').addEventListener('click', () => {
this.ui.showRecycleBin(this.recycleBin);
});
// Close recycle bin
document.getElementById('close-recycle-bin').addEventListener('click', () => {
this.ui.hideRecycleBin();
});
// Empty recycle bin
document.getElementById('empty-recycle-bin').addEventListener('click', () => {
this.emptyRecycleBin();
});
// Help button
document.getElementById('help-btn').addEventListener('click', () => {
this.ui.showHelp();
});
// Close help
document.getElementById('close-help').addEventListener('click', () => {
this.ui.hideHelp();
});
// Template button
document.getElementById('template-btn').addEventListener('click', () => {
this.showTemplateModal();
});
// Close template modal
document.getElementById('close-template').addEventListener('click', () => {
this.hideTemplateModal();
});
// Template selection
document.querySelectorAll('.template-card').forEach(card => {
card.addEventListener('click', () => {
const templateType = card.getAttribute('data-template');
this.createNoteFromTemplate(templateType);
this.hideTemplateModal();
});
});
// View toggle
document.getElementById('view-toggle').addEventListener('click', () => {
this.toggleView();
});
// Category filter
document.getElementById('category-filter').addEventListener('change', (e) => {
const category = e.target.value;
this.filterByCategory(category);
});
// Kanban drag and drop
document.querySelectorAll('.kanban-content').forEach(column => {
column.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
column.addEventListener('drop', (e) => {
e.preventDefault();
const noteId = e.dataTransfer.getData('text/plain');
const newStatus = column.parentElement.getAttribute('data-status');
this.updateKanbanStatus(noteId, newStatus);
});
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// N - new note
if (e.key === 'n' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
// Only create new note if not typing in a note
const activeElement = document.activeElement;
if (!activeElement || !activeElement.classList.contains('note-content')) {
e.preventDefault();
this.createNote();
}
}
// Ctrl/Cmd + S - export
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
this.exportNotes();
}
// Delete - delete selected note
if (e.key === 'Delete' && this.ui.selectedNoteId) {
this.deleteNote(this.ui.selectedNoteId);
}
// Ctrl/Cmd + Z - undo last delete
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
this.undoLastDelete();
}
// Esc - close modals
if (e.key === 'Escape') {
this.ui.hideRecycleBin();
this.ui.hideHelp();
}
});
// Storage events for cross-tab sync
window.addEventListener('storage', (e) => {
if (e.key === this.storage.key) {
this.loadData();
}
});
}
/**
* Load data from storage
*/
loadData() {
const data = this.storage.load();
// Load settings
this.settings = data.settings || {
theme: 'light',
lastZIndex: 10,
nextId: 1
};
this.lastZIndex = this.settings.lastZIndex || 10;
// Apply theme
this.ui.setTheme(this.settings.theme || 'light');
// Update theme button
const themeBtn = document.getElementById('theme-toggle');
themeBtn.innerHTML = this.settings.theme === 'dark' ? '<span class="material-symbols-outlined">light_mode</span>' : '<span class="material-symbols-outlined">dark_mode</span>';
const emptyState = document.getElementById('empty-state');
// Load notes
this.notes.clear();
this.ui.clearAllNotes();
this.categories.clear();
this.categories.add('Uncategorized');
if (data.notes && data.notes.length > 0) {
data.notes.forEach(noteData => {
const note = Note.fromJSON(noteData);
this.notes.set(note.id, note);
// Collect categories
if (note.category) {
this.categories.add(note.category);
}
if (this.currentView === 'sticky') {
this.ui.renderNote(note);
}
});
emptyState.style.display = 'none';
} else {
emptyState.style.display = 'flex';
}
// Render Kanban if in Kanban view
if (this.currentView === 'kanban') {
this.ui.renderKanban(Array.from(this.notes.values()));
}
// Load recycle bin
this.recycleBin = data.recycleBin || [];
// Update category filter
this.updateCategoryFilter();
}
/**
* Save data to storage
*/
saveData() {
const notes = Array.from(this.notes.values()).map(note => note.toJSON());
const data = {
notes: notes,
settings: {
...this.settings,
lastZIndex: this.lastZIndex
},
recycleBin: this.recycleBin
};
this.storage.save(data);
}
/**
* Save a note
* @param {Note} note - Note to save
*/
saveNote(note) {
this.notes.set(note.id, note);
this.saveData();
}
/**
* Create a new note
* @param {number} x - X position
* @param {number} y - Y position
* @returns {Note} Created note
*/
createNote(x = null, y = null) {
const note = new Note({
x: x || 20 + Math.random() * 200,
y: y || 20 + Math.random() * 200,
zIndex: ++this.lastZIndex
});
this.notes.set(note.id, note);
this.ui.renderNote(note);
this.saveData();
// Focus on the new note
setTimeout(() => {
const noteEl = this.ui.notes.get(note.id);
if (noteEl) {
const contentEl = noteEl.querySelector('.note-content');
if (contentEl) {
contentEl.focus();
}
}
}, 100);
return note;
}
/**
* Delete a note
* @param {string} noteId - ID of note to delete
*/
deleteNote(noteId) {
const note = this.notes.get(noteId);
if (!note) return;
// Add to recycle bin
this.storage.addToRecycleBin(note);
this.recycleBin = this.storage.load().recycleBin;
// Remove from UI and model
this.ui.removeNote(noteId);
this.notes.delete(noteId);
this.saveData();
Utils.showNotification('Note deleted. You can restore it from the recycle bin.', 'success');
}
/**
* Duplicate a note
* @param {string} noteId - ID of note to duplicate
* @returns {Note|null} Duplicated note or null
*/
duplicateNote(noteId) {
const originalNote = this.notes.get(noteId);
if (!originalNote) return null;
const duplicatedNote = new Note({
content: originalNote.content,
x: originalNote.x + 20,
y: originalNote.y + 20,
width: originalNote.width,
height: originalNote.height,
color: originalNote.color,
zIndex: ++this.lastZIndex,
pinned: originalNote.pinned,
reminder: originalNote.reminder
});
this.notes.set(duplicatedNote.id, duplicatedNote);
this.ui.renderNote(duplicatedNote);
this.saveData();
Utils.showNotification('Note duplicated.', 'success');
return duplicatedNote;
}
/**
* Export a single note
* @param {Note} note - Note to export
*/
exportSingleNote(note) {
const data = {
notes: [note.toJSON()],
settings: this.settings,
recycleBin: []
};
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sticky-note-${note.id}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
Utils.showNotification('Note exported.', 'success');
}
/**
* Export all notes
*/
exportNotes() {
const json = this.storage.export();
const blob = new Blob([json], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sticky-notes-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
Utils.showNotification('Notes exported.', 'success');
}
/**
* Import notes
* @param {string} json - JSON string to import
*/
importNotes(json) {
// Ask user for import mode
const mode = confirm('Replace all notes or merge with existing notes?\n\nOK = Replace, Cancel = Merge') ? 'replace' : 'merge';
if (this.storage.import(json, mode)) {
this.loadData();
Utils.showNotification(`Notes imported (${mode} mode).`, 'success');
}
}
/**
* Restore a note from recycle bin
* @param {string} noteId - ID of note to restore
*/
restoreNote(noteId) {
const note = this.storage.restoreFromRecycleBin(noteId);
if (note) {
this.notes.set(note.id, note);
this.ui.renderNote(note);
this.saveData();
this.loadData(); // Refresh recycle bin
Utils.showNotification('Note restored.', 'success');
}
}
/**
* Empty recycle bin
*/
emptyRecycleBin() {
if (confirm('Are you sure you want to empty the recycle bin? This action cannot be undone.')) {
this.storage.emptyRecycleBin();
this.recycleBin = [];
this.ui.showRecycleBin([]);
Utils.showNotification('Recycle bin emptied.', 'success');
}
}
/**
* Undo last delete
*/
undoLastDelete() {
if (this.recycleBin.length > 0) {
const lastDeleted = this.recycleBin[0];
this.restoreNote(lastDeleted.id);
} else {
Utils.showNotification('Nothing to undo.', 'info');
}
}
/**
* Bring a note to front
* @param {string} noteId - ID of note to bring to front
*/
bringNoteToFront(noteId) {
const note = this.notes.get(noteId);
if (!note) return;
note.zIndex = ++this.lastZIndex;
this.ui.updateNote(note);
this.saveData();
}
/**
* Toggle theme
*/
toggleTheme() {
const currentTheme = this.settings.theme || 'light';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
this.settings.theme = newTheme;
this.ui.setTheme(newTheme);
this.saveData();
// Update theme button
const themeBtn = document.getElementById('theme-toggle');
themeBtn.innerHTML = newTheme === 'dark' ? '<span class="material-symbols-outlined">light_mode</span>' : '<span class="material-symbols-outlined">dark_mode</span>';
}
/**
* Get templates
* @returns {Object} Templates object
*/
getTemplates() {
return {
todo: {
content: '# To-Do List\n\n- [ ] Task 1\n- [ ] Task 2\n- [ ] Task 3\n\n## Priority\n- [ ] High priority task\n\n## Notes\nAdd any additional notes here...',
category: 'Tasks',
color: '#d4f0f0',
isMarkdown: true
},
meeting: {
content: '# Meeting Notes\n\n**Date:** ' + new Date().toLocaleDateString() + '\n**Attendees:** \n\n## Agenda\n1. \n2. \n3. \n\n## Discussion\n\n## Action Items\n- [ ] \n- [ ] ',
category: 'Meetings',
color: '#ffebcc',
isMarkdown: true
},
idea: {
content: '# 💡 Idea\n\n## Concept\n\n## Why?\n\n## Next Steps\n- \n- ',
category: 'Ideas',
color: '#fff9a8',
isMarkdown: true
},
goals: {
content: '# 🎯 Goals\n\n## Short-term\n- [ ] \n- [ ] \n\n## Long-term\n- [ ] \n- [ ] \n\n## Progress\n',
category: 'Goals',
color: '#e6ccff',
isMarkdown: true
},
shopping: {
content: '# 🛒 Shopping List\n\n## Groceries\n- [ ] \n- [ ] \n\n## Other\n- [ ] \n- [ ] ',
category: 'Shopping',
color: '#ccffcc',
isMarkdown: true
},
blank: {
content: '',
category: 'Uncategorized',
color: '#fff9a8',
isMarkdown: false
}
};
}
/**
* Create note from template
* @param {string} templateType - Type of template
*/
createNoteFromTemplate(templateType) {
const template = this.templates[templateType];
if (!template) return;
const note = new Note({
content: template.content,
color: template.color,
category: template.category,
isMarkdown: template.isMarkdown,
zIndex: ++this.lastZIndex
});
this.notes.set(note.id, note);
this.categories.add(template.category);
if (this.currentView === 'sticky') {
this.ui.renderNote(note);
}
this.saveData();
this.updateCategoryFilter();
Utils.showNotification('Note created from template!', 'success');
}
/**
* Show template modal
*/
showTemplateModal() {
const modal = document.getElementById('template-modal');
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
/**
* Hide template modal
*/
hideTemplateModal() {
const modal = document.getElementById('template-modal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
}
/**
* Set reminder for a note
* @param {Note} note - Note to set reminder for
*/
setReminder(note) {
const dateStr = prompt('Set reminder date and time (YYYY-MM-DD HH:MM):',
new Date(Date.now() + 86400000).toISOString().slice(0, 16).replace('T', ' '));
if (dateStr) {
try {
const reminderDate = new Date(dateStr);
if (reminderDate > new Date()) {
note.reminder = reminderDate.toISOString();
this.ui.updateNote(note);
this.saveData();
Utils.showNotification('Reminder set!', 'success');
} else {
Utils.showNotification('Please set a future date.', 'error');
}
} catch (e) {
Utils.showNotification('Invalid date format.', 'error');
}
}
}
/**
* Set category for a note
* @param {Note} note - Note to set category for
*/
setCategory(note) {
const categoriesList = Array.from(this.categories).join(', ');
const category = prompt(`Set category for this note:\n\nExisting categories: ${categoriesList}`, note.category || 'Uncategorized');
if (category !== null && category.trim()) {
note.category = category.trim();
this.categories.add(category.trim());
this.ui.updateNote(note);
this.saveData();
this.updateCategoryFilter();
Utils.showNotification('Category updated!', 'success');
}
}
/**
* Update category filter dropdown
*/
updateCategoryFilter() {
const select = document.getElementById('category-filter');
const currentValue = select.value;
select.innerHTML = '<option value="">All Categories</option>';
Array.from(this.categories).sort().forEach(category => {
const option = document.createElement('option');
option.value = category;
option.textContent = category;
select.appendChild(option);
});
select.value = currentValue;
}
/**
* Filter notes by category
* @param {string} category - Category to filter by
*/
filterByCategory(category) {
if (!category) {
this.ui.showAllNotes();
return;
}
this.ui.notes.forEach((noteEl, noteId) => {
const note = this.notes.get(noteId);
if (note && note.category === category) {
noteEl.style.display = 'flex';
} else {
noteEl.style.display = 'none';
}
});
}
/**
* Toggle between sticky and kanban view
*/
toggleView() {
const stickyBoard = document.getElementById('board');
const kanbanBoard = document.getElementById('kanban-board');
const viewToggleBtn = document.getElementById('view-toggle');
if (this.currentView === 'sticky') {
// Switch to Kanban
this.currentView = 'kanban';
stickyBoard.style.display = 'none';
kanbanBoard.style.display = 'flex';
viewToggleBtn.innerHTML = '<span class="material-symbols-outlined">dashboard</span> Sticky';
this.ui.renderKanban(Array.from(this.notes.values()));
} else {
// Switch to Sticky
this.switchToStickyView();
}
}
/**
* Switch to sticky view
*/
switchToStickyView() {
const stickyBoard = document.getElementById('board');
const kanbanBoard = document.getElementById('kanban-board');
const viewToggleBtn = document.getElementById('view-toggle');
this.currentView = 'sticky';
stickyBoard.style.display = 'block';
kanbanBoard.style.display = 'none';
viewToggleBtn.innerHTML = '<span class="material-symbols-outlined">view_kanban</span> Kanban';
// Re-render all notes
this.ui.clearAllNotes();
this.notes.forEach(note => {
this.ui.renderNote(note);
});
}
/**
* Update Kanban status for a note
* @param {string} noteId - ID of note
* @param {string} newStatus - New status
*/
updateKanbanStatus(noteId, newStatus) {
const note = this.notes.get(noteId);
if (note) {
note.kanbanStatus = newStatus;
this.saveData();
this.ui.renderKanban(Array.from(this.notes.values()));
Utils.showNotification('Status updated!', 'success');
}
}
/**
* Check reminders and show notifications
*/
checkReminders() {
setInterval(() => {
const now = new Date();
this.notes.forEach(note => {
if (note.reminder) {
const reminderDate = new Date(note.reminder);
const diff = reminderDate - now;
// Show notification 5 minutes before
if (diff > 0 && diff < 300000 && !note._reminderShown) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Reminder: Sticky Note', {
body: note.content.substring(0, 100),
icon: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📝</text></svg>'
});
}
Utils.showNotification('Reminder: ' + note.content.substring(0, 50), 'info');
note._reminderShown = true;
}
}
});
}, 60000); // Check every minute
// Request notification permission
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
}
}
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const app = new AppController();
// Register service worker if available
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(registration => {
console.log('Service Worker registered with scope:', registration.scope);
})
.catch(error => {
console.log('Service Worker registration failed:', error);
});
}
});
// Return public API
return {
Note,
StorageManager,
UIManager,
AppController,
Utils
};
})();