From abf25b34aec6878e32db0e430a659b4fb578e352 Mon Sep 17 00:00:00 2001 From: King Omar Date: Sun, 19 Jul 2026 22:13:50 +1000 Subject: [PATCH] major feature update: multi-note, dark mode, markdown, draw tools, D1 sync, share - Multiple named notes with sidebar (persisted in localStorage + D1) - Dark mode toggle, persisted - Markdown preview toggle (Ctrl+M) with styled rendering - Drawing: eraser tool, undo/redo (40-step stack) - Auto-save indicator, word/char count status bar - Export note as .txt download - Keyboard shortcuts: Ctrl+S save, Ctrl+D draw, Ctrl+M markdown, Ctrl+Shift+N new note - D1 backend (noted-db) with Pages Functions API for cross-device sync - Share note: generates read-only link at /share/:token - Updated service worker to v2, skip API routes Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + functions/api/[[catchall]].js | 81 +++ index.html | 1129 ++++++++++++++++++++++----------- schema.sql | 21 + service-worker.js | 94 +-- wrangler.toml | 7 + 6 files changed, 886 insertions(+), 447 deletions(-) create mode 100644 .gitignore create mode 100644 functions/api/[[catchall]].js create mode 100644 schema.sql create mode 100644 wrangler.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b75a0fa --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.wrangler/ diff --git a/functions/api/[[catchall]].js b/functions/api/[[catchall]].js new file mode 100644 index 0000000..3cada05 --- /dev/null +++ b/functions/api/[[catchall]].js @@ -0,0 +1,81 @@ +const CORS = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +function json(data, status = 200) { + return new Response(JSON.stringify(data), { status, headers: CORS }); +} + +export async function onRequest({ request, env }) { + if (request.method === 'OPTIONS') return new Response(null, { headers: CORS }); + + const url = new URL(request.url); + const path = url.pathname.replace(/^\/api/, ''); + const method = request.method; + + try { + // GET /notes?user_id=X + if (method === 'GET' && path === '/notes') { + const userId = url.searchParams.get('user_id'); + if (!userId) return json({ error: 'missing user_id' }, 400); + const { results } = await env.DB.prepare( + 'SELECT id, user_id, title, content, drawing, font_size, font_family, text_color, created_at as createdAt, updated_at as updatedAt FROM notes WHERE user_id = ? ORDER BY updated_at DESC' + ).bind(userId).all(); + return json({ notes: results }); + } + + // PUT /notes/:id + if (method === 'PUT' && /^\/notes\/[^/]+$/.test(path)) { + const id = path.split('/')[2]; + const { user_id, title, content, drawing, fontSize, fontFamily, textColor, createdAt, updatedAt } = await request.json(); + await env.DB.prepare(` + INSERT INTO notes (id, user_id, title, content, drawing, font_size, font_family, text_color, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, content = excluded.content, drawing = excluded.drawing, + font_size = excluded.font_size, font_family = excluded.font_family, text_color = excluded.text_color, + updated_at = excluded.updated_at + WHERE notes.user_id = excluded.user_id + `).bind(id, user_id, title || '', content || '', drawing || null, fontSize || null, fontFamily || null, textColor || null, createdAt || Date.now(), updatedAt || Date.now()).run(); + return json({ ok: true }); + } + + // DELETE /notes/:id + if (method === 'DELETE' && /^\/notes\/[^/]+$/.test(path)) { + const id = path.split('/')[2]; + const { user_id } = await request.json(); + await env.DB.prepare('DELETE FROM notes WHERE id = ? AND user_id = ?').bind(id, user_id).run(); + return json({ ok: true }); + } + + // POST /notes/:id/share + if (method === 'POST' && /^\/notes\/[^/]+\/share$/.test(path)) { + const noteId = path.split('/')[2]; + const { user_id } = await request.json(); + const note = await env.DB.prepare('SELECT id FROM notes WHERE id = ? AND user_id = ?').bind(noteId, user_id).first(); + if (!note) return json({ error: 'not found' }, 404); + const existing = await env.DB.prepare('SELECT token FROM shares WHERE note_id = ?').bind(noteId).first(); + if (existing) return json({ token: existing.token }); + const token = crypto.randomUUID().replace(/-/g, ''); + await env.DB.prepare('INSERT INTO shares (token, note_id, user_id, created_at) VALUES (?, ?, ?, ?)').bind(token, noteId, user_id, Date.now()).run(); + return json({ token }); + } + + // GET /share/:token + if (method === 'GET' && /^\/share\/[^/]+$/.test(path)) { + const token = path.split('/')[2]; + const share = await env.DB.prepare('SELECT note_id FROM shares WHERE token = ?').bind(token).first(); + if (!share) return json({ error: 'not found' }, 404); + const note = await env.DB.prepare('SELECT title, content FROM notes WHERE id = ?').bind(share.note_id).first(); + if (!note) return json({ error: 'note not found' }, 404); + return json({ note }); + } + + return json({ error: 'not found' }, 404); + } catch (e) { + return json({ error: e.message }, 500); + } +} diff --git a/index.html b/index.html index 0401e2a..323416a 100644 --- a/index.html +++ b/index.html @@ -10,151 +10,385 @@ + - - -
- -
- -
-
- - - 3px +
+ + noted. +
+ ✓ saved + +
- -
- +
+ +
+ + +
+
+ +
+ +
+
+ +
+
+
+ +
+
+ + + 3px +
+
+
- -
- -
+ + + +
- +
-
- +
+
- -
- - - 36px - +
+ + + 24px +
- -
- +
+
- -
- - +
+ + + +
+
+ 0 words · 0 chars + +
+ - \ No newline at end of file + diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..85cefdd --- /dev/null +++ b/schema.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + drawing TEXT, + font_size TEXT, + font_family TEXT, + text_color TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS shares ( + token TEXT PRIMARY KEY, + note_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL +); diff --git a/service-worker.js b/service-worker.js index bddc85c..8584143 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,78 +1,28 @@ -const CACHE_NAME = 'browser-notepad-v1'; -const urlsToCache = [ - './', - './index.html', - './manifest.json', - 'https://radical.omar-c29.workers.dev/memes/fry.png', - 'https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;700&display=swap', - 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap', - 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap', - 'https://fonts.googleapis.com/css2?family=Courier+Prime&display=swap' -]; +const CACHE = 'noted-v2'; +const ASSETS = ['./', './index.html', './manifest.json', './fry.png']; -// Install event - cache assets -self.addEventListener('install', event => { - event.waitUntil( - caches.open(CACHE_NAME) - .then(cache => { - console.log('Opened cache'); - return cache.addAll(urlsToCache); - }) - ); +self.addEventListener('install', e => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS))); + self.skipWaiting(); }); -// Activate event - clean up old caches -self.addEventListener('activate', event => { - const cacheWhitelist = [CACHE_NAME]; - event.waitUntil( - caches.keys().then(cacheNames => { - return Promise.all( - cacheNames.map(cacheName => { - if (cacheWhitelist.indexOf(cacheName) === -1) { - return caches.delete(cacheName); - } - }) - ); - }) - ); +self.addEventListener('activate', e => { + e.waitUntil(caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) + )); + self.clients.claim(); }); -// Fetch event - serve cached content when offline -self.addEventListener('fetch', event => { - event.respondWith( - caches.match(event.request) - .then(response => { - // Cache hit - return response - if (response) { - return response; - } - - // Clone the request - const fetchRequest = event.request.clone(); - - return fetch(fetchRequest).then( - response => { - // Check if we received a valid response - if(!response || response.status !== 200 || response.type !== 'basic') { - return response; +self.addEventListener('fetch', e => { + if (e.request.url.includes('/api/')) return; + e.respondWith( + caches.match(e.request).then(cached => cached || fetch(e.request).then(res => { + if (res.ok && e.request.method === 'GET') { + caches.open(CACHE).then(c => c.put(e.request, res.clone())); } - - // Clone the response - const responseToCache = response.clone(); - - caches.open(CACHE_NAME) - .then(cache => { - cache.put(event.request, responseToCache); - }); - - return response; - } - ).catch(() => { - // If fetch fails (offline), try to return the cached HTML - if (event.request.mode === 'navigate') { - return caches.match('./index.html'); - } - }); - }) - ); -}); \ No newline at end of file + return res; + }).catch(() => { + if (e.request.mode === 'navigate') return caches.match('./index.html'); + })) + ); +}); diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..7b3d2fe --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,7 @@ +name = "noted" +pages_build_output_dir = "." + +[[d1_databases]] +binding = "DB" +database_name = "noted-db" +database_id = "7ca0e035-25e6-4be0-8c0e-ff445592bb51"