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 <noreply@anthropic.com>
This commit is contained in:
parent
a047f38380
commit
abf25b34ae
6 changed files with 886 additions and 447 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.wrangler/
|
||||
81
functions/api/[[catchall]].js
Normal file
81
functions/api/[[catchall]].js
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
1045
index.html
1045
index.html
File diff suppressed because it is too large
Load diff
21
schema.sql
Normal file
21
schema.sql
Normal file
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
})
|
||||
self.addEventListener('install', e => {
|
||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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()));
|
||||
}
|
||||
return res;
|
||||
}).catch(() => {
|
||||
if (e.request.mode === 'navigate') return caches.match('./index.html');
|
||||
}))
|
||||
);
|
||||
});
|
||||
7
wrangler.toml
Normal file
7
wrangler.toml
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue