From 530b9196c2bf230af832c4833dee10ff2c7bf7cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 25 Aug 2025 16:40:28 +0000 Subject: [PATCH] Create simple offline notes app with localStorage, search, and theme toggle Co-authored-by: clondinski1234 --- README.md | 16 +++ app.js | 302 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 41 ++++++++ styles.css | 131 +++++++++++++++++++++++ 4 files changed, 490 insertions(+) create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css diff --git a/README.md b/README.md index fab11f6..01eb24f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ +## Simple Notetaking App + +A minimal, offline-first notes app. Notes are stored in localStorage. + +### Quick start +- Open `index.html` in a browser. + +### Features (initial) +- Create, edit, delete notes +- Persist notes locally + +### Files +- `index.html` — App shell +- `styles.css` — Basic styling +- `app.js` — App logic + # web www diff --git a/app.js b/app.js new file mode 100644 index 0000000..e8a1220 --- /dev/null +++ b/app.js @@ -0,0 +1,302 @@ +(() => { + /** @type {HTMLButtonElement} */ + const newNoteButton = document.getElementById('new-note'); + /** @type {HTMLButtonElement} */ + const exportButton = document.getElementById('export-notes'); + /** @type {HTMLInputElement} */ + const importFileInput = document.getElementById('import-file'); + /** @type {HTMLButtonElement} */ + const themeToggleButton = document.getElementById('toggle-theme'); + /** @type {HTMLInputElement} */ + const searchInput = document.getElementById('search'); + /** @type {HTMLUListElement} */ + const notesListElement = document.getElementById('notes-list'); + /** @type {HTMLInputElement} */ + const noteTitleInput = document.getElementById('note-title'); + /** @type {HTMLTextAreaElement} */ + const noteBodyTextarea = document.getElementById('note-body'); + /** @type {HTMLButtonElement} */ + const saveButton = document.getElementById('save-note'); + /** @type {HTMLButtonElement} */ + const deleteButton = document.getElementById('delete-note'); + /** @type {HTMLButtonElement} */ + const pinButton = document.getElementById('pin-note'); + + /** + * @typedef {Object} Note + * @property {string} id + * @property {string} title + * @property {string} body + * @property {number} updatedAt + * @property {boolean} pinned + */ + + /** @type {Note[]} */ + let notes = []; + /** @type {string|null} */ + let activeNoteId = null; + + const STORAGE_KEY = 'simple-notes.v1'; + + function loadNotes() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.map(n => ({ + id: n.id || crypto.randomUUID(), + title: typeof n.title === 'string' ? n.title : '', + body: typeof n.body === 'string' ? n.body : '', + updatedAt: typeof n.updatedAt === 'number' ? n.updatedAt : Date.now(), + pinned: !!n.pinned, + })); + } catch { + return []; + } + } + + function saveNotes() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)); + } + + function createEmptyNote() { + return { + id: crypto.randomUUID(), + title: '', + body: '', + updatedAt: Date.now(), + pinned: false, + }; + } + + function setActiveNote(noteId) { + activeNoteId = noteId; + renderNotesList(); + const note = notes.find(n => n.id === noteId); + if (note) { + noteTitleInput.value = note.title; + noteBodyTextarea.value = note.body; + noteTitleInput.focus(); + } + } + + function upsertActiveNoteFromInputs() { + if (!activeNoteId) return; + const idx = notes.findIndex(n => n.id === activeNoteId); + if (idx === -1) return; + notes[idx] = { + ...notes[idx], + title: noteTitleInput.value.trim(), + body: noteBodyTextarea.value, + updatedAt: Date.now(), + }; + saveNotes(); + renderNotesList(); + } + + function deleteActiveNote() { + if (!activeNoteId) return; + notes = notes.filter(n => n.id !== activeNoteId); + saveNotes(); + activeNoteId = notes[0]?.id ?? null; + renderNotesList(); + renderEditor(); + } + + function renderEditor() { + const note = notes.find(n => n.id === activeNoteId); + if (!note) { + noteTitleInput.value = ''; + noteBodyTextarea.value = ''; + pinButton.disabled = true; + pinButton.setAttribute('aria-pressed', 'false'); + pinButton.textContent = 'Pin'; + return; + } + noteTitleInput.value = note.title; + noteBodyTextarea.value = note.body; + pinButton.disabled = false; + pinButton.setAttribute('aria-pressed', String(!!note.pinned)); + pinButton.textContent = note.pinned ? 'Unpin' : 'Pin'; + } + + function matchesQuery(note, query) { + if (!query) return true; + const q = query.toLowerCase(); + return ( + note.title.toLowerCase().includes(q) || + note.body.toLowerCase().includes(q) + ); + } + + function renderNotesList() { + const query = searchInput.value.trim(); + const sorted = [...notes] + .filter(n => matchesQuery(n, query)) + .sort((a, b) => (Number(b.pinned) - Number(a.pinned)) || (b.updatedAt - a.updatedAt)); + + notesListElement.innerHTML = ''; + for (const note of sorted) { + const li = document.createElement('li'); + li.dataset.id = note.id; + li.className = `${note.id === activeNoteId ? 'active ' : ''}${note.pinned ? 'pinned' : ''}`.trim(); + li.setAttribute('role', 'button'); + li.tabIndex = 0; + const title = note.title || 'Untitled'; + const preview = note.body.replace(/\n/g, ' ').slice(0, 80); + li.innerHTML = `${escapeHtml(title)}${escapeHtml(preview)}`; + li.addEventListener('click', () => setActiveNote(note.id)); + li.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setActiveNote(note.id); + } + }); + if (note.id === activeNoteId) { + li.setAttribute('aria-current', 'true'); + } else { + li.removeAttribute('aria-current'); + } + notesListElement.appendChild(li); + } + } + + function escapeHtml(str) { + return str + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); + } + + function debounce(fn, wait) { + let t = 0; + return (...args) => { + clearTimeout(t); + t = setTimeout(() => fn.apply(null, args), wait); + }; + } + + // Event bindings + newNoteButton.addEventListener('click', () => { + const note = createEmptyNote(); + notes.unshift(note); + saveNotes(); + setActiveNote(note.id); + renderEditor(); + }); + + saveButton.addEventListener('click', () => { + upsertActiveNoteFromInputs(); + }); + + deleteButton.addEventListener('click', () => { + if (!activeNoteId) return; + if (confirm('Delete this note?')) { + deleteActiveNote(); + } + }); + + pinButton.addEventListener('click', () => { + if (!activeNoteId) return; + const idx = notes.findIndex(n => n.id === activeNoteId); + if (idx === -1) return; + notes[idx].pinned = !notes[idx].pinned; + notes[idx].updatedAt = Date.now(); + saveNotes(); + renderNotesList(); + renderEditor(); + }); + + noteTitleInput.addEventListener('input', debounce(() => upsertActiveNoteFromInputs(), 300)); + noteBodyTextarea.addEventListener('input', debounce(() => upsertActiveNoteFromInputs(), 300)); + searchInput.addEventListener('input', () => renderNotesList()); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { + e.preventDefault(); + upsertActiveNoteFromInputs(); + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'n') { + e.preventDefault(); + newNoteButton.click(); + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'e') { + e.preventDefault(); + exportButton.click(); + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'p') { + e.preventDefault(); + pinButton.click(); + } + }); + + // Export / Import + exportButton.addEventListener('click', () => { + const data = JSON.stringify(notes, null, 2); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `notes-${new Date().toISOString().slice(0,10)}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }); + + importFileInput.addEventListener('change', async () => { + const file = importFileInput.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const imported = JSON.parse(text); + if (!Array.isArray(imported)) throw new Error('Invalid format'); + const normalized = imported.map(n => ({ + id: n.id || crypto.randomUUID(), + title: typeof n.title === 'string' ? n.title : '', + body: typeof n.body === 'string' ? n.body : '', + updatedAt: typeof n.updatedAt === 'number' ? n.updatedAt : Date.now(), + pinned: !!n.pinned, + })); + if (!confirm('Replace existing notes with imported notes?')) return; + notes = normalized; + saveNotes(); + activeNoteId = notes[0]?.id || null; + renderNotesList(); + renderEditor(); + } catch (err) { + alert('Failed to import notes. Ensure the file is a valid JSON export.'); + } finally { + importFileInput.value = ''; + } + }); + + // Theme + function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('simple-notes.theme', theme); + themeToggleButton.textContent = theme === 'light' ? 'Dark' : 'Light'; + themeToggleButton.setAttribute('aria-label', `Switch to ${theme === 'light' ? 'dark' : 'light'} mode`); + } + const savedTheme = localStorage.getItem('simple-notes.theme') || 'dark'; + applyTheme(savedTheme); + + themeToggleButton.addEventListener('click', () => { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + applyTheme(current === 'dark' ? 'light' : 'dark'); + }); + + // Init + notes = loadNotes(); + if (notes.length === 0) { + const first = createEmptyNote(); + first.title = 'Welcome'; + first.body = 'Start typing your notes here.'; + notes.push(first); + } + activeNoteId = notes[0].id; + renderNotesList(); + renderEditor(); +})(); \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..b1a4f40 --- /dev/null +++ b/index.html @@ -0,0 +1,41 @@ + + + + + + Notes + + + +
+ +
+ + +
+ + + +
+
+
+ + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..5d4efff --- /dev/null +++ b/styles.css @@ -0,0 +1,131 @@ +:root { + --bg: #0b0f14; + --panel: #121821; + --panel-2: #0f141b; + --text: #e6edf3; + --muted: #9fb0c0; + --primary: #4ea1ff; + --danger: #ff6b6b; + --border: #1f2a37; +} + +* { box-sizing: border-box; } +html, body, .app { height: 100%; } +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + color: var(--text); + background: linear-gradient(180deg, var(--bg), #0d131b 60%); +} + +.app { + display: grid; + grid-template-columns: 320px 1fr; + height: 100vh; +} + +.sidebar { + border-right: 1px solid var(--border); + background: var(--panel); + display: flex; + flex-direction: column; +} +.sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 12px; + border-bottom: 1px solid var(--border); +} +.sidebar__header .actions { display:flex; gap:8px; align-items:center; } +.sidebar__header h1 { font-size: 18px; margin: 0; } +.sidebar__search { padding: 8px 12px; } +.sidebar__search input { + width: 100%; + background: var(--panel-2); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 10px; + border-radius: 8px; +} + +.notes-list { + list-style: none; + margin: 0; + padding: 0; + overflow: auto; + flex: 1; +} +.notes-list li { + border-bottom: 1px solid var(--border); + padding: 10px 12px; + cursor: pointer; +} +.notes-list li.active { background: #18202b; } +.notes-list li .title { display: block; font-weight: 600; } +.notes-list li .preview { color: var(--muted); font-size: 12px; margin-top: 4px; } +.notes-list li.pinned .title::before { content: "★ "; color: #ffd36b; } + +.editor { + display: flex; + flex-direction: column; + background: radial-gradient(1200px 1200px at 80% -200px, rgba(78,161,255,0.08), transparent 50%), var(--bg); +} +.editor__title { + font-size: 20px; + padding: 14px 16px; + border: none; + outline: none; + background: transparent; + color: var(--text); + border-bottom: 1px solid var(--border); +} +.editor__body { + flex: 1; + padding: 16px; + border: none; + outline: none; + background: transparent; + color: var(--text); + resize: none; + line-height: 1.6; +} +.editor__actions { + border-top: 1px solid var(--border); + padding: 10px 16px; + display: flex; + gap: 8px; + background: var(--panel); +} + +.btn { + appearance: none; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + padding: 8px 12px; + border-radius: 8px; + cursor: pointer; +} +.btn.primary { border-color: #2b67ff3d; background: #2b67ff24; color: #a9c9ff; } +.btn.danger { border-color: #ff6b6b3d; background: #ff6b6b24; color: #ffb3b3; } + +:root[data-theme='light'] { + --bg: #f6f8fa; + --panel: #ffffff; + --panel-2: #f3f4f6; + --text: #0b0f14; + --muted: #4b5563; + --primary: #1f6feb; + --danger: #b91c1c; + --border: #e5e7eb; +} + +:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } + +@media (max-width: 900px) { + .app { grid-template-columns: 1fr; } + .sidebar { height: 40vh; } + .editor { height: 60vh; } +} +