Add modern notes workspace with core legacy note functions

This commit is contained in:
2026-03-28 19:26:35 +00:00
parent 17904cff3a
commit 94d6840ef7
5 changed files with 607 additions and 0 deletions
+1
View File
@@ -102,6 +102,7 @@
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<a href="/notes-workspace" class="btn btn-secondary">Open Notes Workspace</a>
<a href="/notes" class="btn btn-secondary">Open Legacy Notes</a> <a href="/notes" class="btn btn-secondary">Open Legacy Notes</a>
<a href="/tasks" class="btn btn-secondary">Open Tasks View</a> <a href="/tasks" class="btn btn-secondary">Open Tasks View</a>
</div> </div>
+599
View File
@@ -0,0 +1,599 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes Workspace</title>
<link rel="icon" type="image/png" href="/images/prism.png" />
<script src="https://cdn.tailwindcss.com"></script>
<style>
.panel {
border-radius: 1rem;
border: 1px solid rgb(30 41 59 / 1);
background: rgb(15 23 42 / 0.78);
backdrop-filter: blur(8px);
padding: 1.25rem;
}
.btn {
border-radius: 0.75rem;
padding: 0.55rem 1rem;
font-weight: 600;
transition: all 160ms ease;
position: relative;
overflow: hidden;
}
.btn:focus-visible,
.field:focus-visible {
outline: 2px solid rgb(34 211 238 / 0.8);
outline-offset: 1px;
}
.btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(120deg, transparent 0%, rgb(255 255 255 / 0.2) 48%, transparent 100%);
transform: translateX(-130%);
transition: transform 260ms ease;
pointer-events: none;
}
.btn:hover {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 10px 22px rgb(15 23 42 / 0.28);
}
.btn:hover::before { transform: translateX(130%); }
.btn-accent-cyan { color: rgb(8 47 73 / 1); background: rgb(6 182 212 / 1); }
.btn-accent-emerald { color: rgb(6 44 32 / 1); background: rgb(34 197 94 / 1); }
.btn-accent-indigo { color: rgb(255 255 255 / 0.98); background: rgb(99 102 241 / 1); }
.btn-danger { color: rgb(255 255 255 / 0.98); background: rgb(220 38 38 / 1); }
.btn-secondary {
border: 1px solid rgb(51 65 85 / 1);
background: rgb(15 23 42 / 1);
color: rgb(226 232 240 / 1);
}
.btn-secondary:hover { border-color: rgb(100 116 139 / 1); }
.field {
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgb(51 65 85 / 1);
background: rgb(2 6 23 / 1);
padding: 0.55rem 0.75rem;
font-size: 0.875rem;
color: rgb(248 250 252 / 1);
}
.field::placeholder { color: rgb(100 116 139 / 1); }
.chip {
border-radius: 999px;
border: 1px solid rgb(51 65 85 / 1);
background: rgb(2 6 23 / 1);
padding: 0.2rem 0.6rem;
font-size: 0.72rem;
color: rgb(191 219 254 / 1);
white-space: nowrap;
}
.note-row {
border: 1px solid rgb(51 65 85 / 1);
border-radius: 0.8rem;
background: rgb(2 6 23 / 0.9);
padding: 0.7rem;
cursor: pointer;
}
.note-row:hover { border-color: rgb(100 116 139 / 1); }
.note-row.active { border-color: rgb(6 182 212 / 1); box-shadow: 0 0 0 1px rgb(6 182 212 / 0.35); }
#output {
border: 1px solid rgb(30 41 59 / 1);
background: rgb(2 6 23 / 1);
}
</style>
</head>
<body class="min-h-screen bg-gradient-to-b from-slate-950 via-slate-950 to-slate-900 text-slate-100">
<main class="mx-auto max-w-7xl px-6 py-10">
<header class="panel mb-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<img src="/images/prism.png" alt="Prism Notes" class="h-14 w-14 rounded-xl object-cover ring-1 ring-slate-600/60" />
<div>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-400">Prism Notes</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Notes Workspace</h1>
</div>
</div>
<div class="flex flex-wrap gap-3">
<a href="/" class="btn btn-secondary">Testing Harness</a>
<a href="/tasks" class="btn btn-secondary">Tasks</a>
<a href="/notes" class="btn btn-secondary">Legacy Notes</a>
</div>
</div>
<p class="mt-3 text-sm text-slate-300">Modern workspace for core old app note functions: create, list, filter, edit, hide/unhide, share, and soft delete.</p>
</header>
<section class="mb-6 grid gap-4 md:grid-cols-3">
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">Server</p>
<p id="healthStatus" class="mt-2 text-sm font-medium">Checking...</p>
</article>
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">PocketBase User</p>
<p id="pbStatus" class="mt-2 text-sm font-medium">Not logged in</p>
</article>
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">Visible Notes</p>
<p id="noteCount" class="mt-2 text-sm font-medium">0</p>
</article>
</section>
<section class="mb-6 grid gap-6 lg:grid-cols-2">
<article class="panel p-6">
<h2 class="text-xl font-medium">1) Prism Notes Login</h2>
<div class="mt-4 flex flex-wrap gap-3">
<button id="loginBtn" class="btn btn-accent-cyan">Login with Microsoft</button>
<button id="logoutBtn" class="btn btn-secondary">Logout</button>
<button id="refreshBtn" class="btn btn-secondary">Refresh Notes</button>
</div>
</article>
<article class="panel p-6">
<h2 class="text-xl font-medium">2) Filters</h2>
<div class="mt-4 flex flex-wrap gap-2">
<button class="btn btn-secondary" data-filter="all">All</button>
<button class="btn btn-secondary" data-filter="myshared">My Shared</button>
<button class="btn btn-secondary" data-filter="sharedwithme">Shared With Me</button>
<button class="btn btn-secondary" data-filter="job">Job</button>
<button class="btn btn-secondary" data-filter="manager">Manager</button>
<button class="btn btn-secondary" data-filter="personal">Personal</button>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<input id="searchInput" class="field" placeholder="Search notes..." />
<select id="hiddenMode" class="field">
<option value="visible" selected>Show Visible</option>
<option value="hidden">Show Hidden</option>
</select>
</div>
</article>
</section>
<section class="grid gap-6 lg:grid-cols-2">
<article class="panel p-6">
<h2 class="text-xl font-medium">3) New / Edit Note</h2>
<div class="mt-4 space-y-3">
<input id="titleInput" class="field" placeholder="Title" />
<div class="grid gap-3 md:grid-cols-2">
<select id="typeInput" class="field">
<option value="personal">personal</option>
<option value="job">job</option>
<option value="manager">manager</option>
<option value="billing">billing</option>
</select>
<input id="jobNumberInput" class="field" placeholder="Job Number (optional)" />
</div>
<textarea id="bodyInput" rows="8" class="field" placeholder="Write your note..."></textarea>
<input id="shareInput" class="field" placeholder="Share with (comma separated names)" />
</div>
<div class="mt-4 flex flex-wrap gap-3">
<button id="createBtn" class="btn btn-accent-emerald">Create Note</button>
<button id="saveBtn" class="btn btn-accent-indigo" disabled>Save Changes</button>
<button id="hideBtn" class="btn btn-secondary" disabled>Hide</button>
<button id="deleteBtn" class="btn btn-danger" disabled>Soft Delete</button>
<button id="clearBtn" class="btn btn-secondary">Clear</button>
</div>
</article>
<article class="panel p-6">
<div class="flex items-center justify-between gap-4">
<h2 class="text-xl font-medium">4) Notes</h2>
<span id="selectedMeta" class="chip">No selection</span>
</div>
<div id="notesList" class="mt-4 space-y-2 max-h-[560px] overflow-auto"></div>
</article>
</section>
<section class="panel p-6 mt-6">
<h2 class="text-xl font-medium">Output</h2>
<pre id="output" class="mt-4 min-h-[180px] overflow-auto rounded-xl p-4 text-xs leading-5 text-slate-200"></pre>
</section>
</main>
<script type="module">
import PocketBase from 'https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/+esm';
const PB_DEFAULT_AUTH_COLLECTION = 'Users';
const PB_DEFAULT_OAUTH_PROVIDER = 'microsoft';
const NOTES_COLLECTION = 'Notes';
let pb = null;
let pbConfigPromise = null;
let allNotes = [];
let currentFilter = 'all';
let selectedNote = null;
const output = document.getElementById('output');
const healthStatus = document.getElementById('healthStatus');
const pbStatus = document.getElementById('pbStatus');
const noteCount = document.getElementById('noteCount');
const selectedMeta = document.getElementById('selectedMeta');
const notesList = document.getElementById('notesList');
const titleInput = document.getElementById('titleInput');
const typeInput = document.getElementById('typeInput');
const jobNumberInput = document.getElementById('jobNumberInput');
const bodyInput = document.getElementById('bodyInput');
const shareInput = document.getElementById('shareInput');
const searchInput = document.getElementById('searchInput');
const hiddenMode = document.getElementById('hiddenMode');
const saveBtn = document.getElementById('saveBtn');
const hideBtn = document.getElementById('hideBtn');
const deleteBtn = document.getElementById('deleteBtn');
function writeOutput(value) {
output.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function getPocketBaseConfig() {
if (!pbConfigPromise) {
pbConfigPromise = fetch('/api/auth/pocketbase-config')
.then(async (resp) => {
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success || !data?.pbUrl) {
throw new Error(data?.message || 'PocketBase config unavailable');
}
return {
pbUrl: data.pbUrl,
collection: data.collection || PB_DEFAULT_AUTH_COLLECTION,
provider: data.provider || PB_DEFAULT_OAUTH_PROVIDER,
};
})
.catch((error) => {
pbConfigPromise = null;
throw error;
});
}
return pbConfigPromise;
}
async function ensurePocketBaseClient() {
if (pb) return pb;
const config = await getPocketBaseConfig();
pb = new PocketBase(config.pbUrl);
pb.autoCancellation(false);
return pb;
}
function currentUser() {
return pb?.authStore?.model || null;
}
function ensureLoggedIn() {
if (!pb?.authStore?.isValid) {
throw new Error('PocketBase login required');
}
}
function normalizeShares(value) {
if (Array.isArray(value)) return value.map((v) => String(v || '').trim()).filter(Boolean);
if (typeof value === 'string') return value.split(',').map((v) => v.trim()).filter(Boolean);
return [];
}
function buildNotePayload() {
const title = titleInput.value.trim() || 'Untitled';
const bodyPlain = bodyInput.value.trim();
if (!bodyPlain) throw new Error('Note body is required');
const noteType = typeInput.value.trim() || 'personal';
const jobNumber = jobNumberInput.value.trim();
const sharedWith = normalizeShares(shareInput.value);
const html = bodyPlain
.split(/\r\n|\r|\n/)
.map((line) => `<p>${line || '&nbsp;'}</p>`)
.join('');
const user = currentUser();
const payload = {
title,
type: noteType,
body_plain: bodyPlain,
body_html: html,
Username: user?.name || user?.username || user?.email || '',
email: user?.email || '',
userId: user?.id || '',
shared: sharedWith.length > 0,
shared_with: sharedWith,
job_note: noteType === 'job' || !!jobNumber,
hidden: false,
};
if (jobNumber) payload.Job_Number = jobNumber;
return payload;
}
function isSharedWithCurrentUser(note) {
const me = currentUser();
if (!me) return false;
const list = normalizeShares(note?.shared_with);
if (!list.length) return false;
const name = String(me.name || me.username || '').trim().toLowerCase();
const email = String(me.email || '').trim().toLowerCase();
const first = name.split(' ')[0] || '';
return list.some((entry) => {
const v = String(entry || '').trim().toLowerCase();
return !!v && (v === name || v === first || v === email || v.includes(first));
});
}
function isMine(note) {
const me = currentUser();
return !!me && String(note?.userId || '') === String(me.id || '');
}
function noteType(note) {
return String(note?.type || 'personal').toLowerCase();
}
function passFilter(note) {
if (Boolean(note?.delete)) return false;
const wantHidden = hiddenMode.value === 'hidden';
const isHidden = Boolean(note?.hidden);
if (wantHidden !== isHidden) return false;
if (currentFilter === 'myshared' && !(isMine(note) && Boolean(note?.shared))) return false;
if (currentFilter === 'sharedwithme' && !isSharedWithCurrentUser(note)) return false;
if (currentFilter === 'job' && !(Boolean(note?.job_note) || noteType(note) === 'job')) return false;
if (currentFilter === 'manager' && noteType(note) !== 'manager') return false;
if (currentFilter === 'personal' && noteType(note) !== 'personal') return false;
const q = searchInput.value.trim().toLowerCase();
if (q) {
const hay = [
note?.title,
note?.body_plain,
note?.Username,
note?.email,
note?.Job_Number,
...(normalizeShares(note?.shared_with)),
]
.map((v) => String(v || '').toLowerCase())
.join('\n');
if (!hay.includes(q)) return false;
}
return true;
}
function renderList() {
const filtered = allNotes.filter(passFilter);
noteCount.textContent = String(filtered.length);
if (!filtered.length) {
notesList.innerHTML = '<div class="text-sm text-slate-400">No notes match current filters.</div>';
return;
}
notesList.innerHTML = filtered.map((note) => {
const mine = isMine(note);
const sharedWithMe = isSharedWithCurrentUser(note);
const badge = sharedWithMe ? 'Shared w/ me' : note.shared ? 'Shared by me' : 'Private';
const tone = sharedWithMe ? 'text-cyan-300' : note.shared ? 'text-emerald-300' : 'text-slate-300';
const preview = String(note.body_plain || '').replace(/\s+/g, ' ').trim().slice(0, 160);
const selected = selectedNote?.id === note.id ? 'active' : '';
return `
<article class="note-row ${selected}" data-note-id="${escapeHtml(note.id)}">
<div class="flex items-center justify-between gap-2">
<h3 class="font-semibold text-sm text-slate-100">${escapeHtml(note.title || 'Untitled')}</h3>
<span class="chip ${tone}">${escapeHtml(badge)}</span>
</div>
<div class="mt-1 flex flex-wrap gap-2 text-xs text-slate-400">
<span>${escapeHtml(note.type || 'personal')}</span>
${note.Job_Number ? `<span>Job ${escapeHtml(note.Job_Number)}</span>` : ''}
<span>${escapeHtml(new Date(note.updated || note.created).toLocaleString())}</span>
</div>
<p class="mt-2 text-xs text-slate-300">${escapeHtml(preview || '(empty)')}</p>
</article>
`;
}).join('');
notesList.querySelectorAll('[data-note-id]').forEach((el) => {
el.addEventListener('click', () => {
const id = el.getAttribute('data-note-id');
const note = allNotes.find((n) => n.id === id);
if (note) {
selectNote(note);
renderList();
}
});
});
}
function clearForm() {
selectedNote = null;
selectedMeta.textContent = 'No selection';
titleInput.value = '';
typeInput.value = 'personal';
jobNumberInput.value = '';
bodyInput.value = '';
shareInput.value = '';
saveBtn.disabled = true;
hideBtn.disabled = true;
deleteBtn.disabled = true;
}
function selectNote(note) {
selectedNote = note;
selectedMeta.textContent = `Selected: ${note.id}`;
titleInput.value = String(note.title || '');
typeInput.value = String(note.type || 'personal');
jobNumberInput.value = String(note.Job_Number || '');
bodyInput.value = String(note.body_plain || '');
shareInput.value = normalizeShares(note.shared_with).join(', ');
saveBtn.disabled = false;
hideBtn.disabled = false;
deleteBtn.disabled = false;
hideBtn.textContent = Boolean(note.hidden) ? 'Unhide' : 'Hide';
}
async function updatePbStatus() {
const client = await ensurePocketBaseClient();
if (client.authStore?.isValid) {
const email = client.authStore.model?.email || '(unknown user)';
pbStatus.textContent = `Logged in: ${email}`;
} else {
pbStatus.textContent = 'Not logged in';
}
}
async function loadHealth() {
try {
const resp = await fetch('/health');
const data = await resp.json().catch(() => ({}));
healthStatus.textContent = resp.ok ? `OK (${data.pbDB || 'configured'})` : 'Unavailable';
} catch {
healthStatus.textContent = 'Unavailable';
}
}
async function loginPocketBase() {
writeOutput('Opening Microsoft login...');
const client = await ensurePocketBaseClient();
const config = await getPocketBaseConfig();
const authData = await client.collection(config.collection).authWithOAuth2({
provider: config.provider,
urlCallback(url) {
const w = Math.min(900, window.screen.availWidth || 900);
const h = Math.min(680, window.screen.availHeight || 680);
const left = Math.floor(((window.screen.availWidth || 1280) - w) / 2);
const top = Math.floor(((window.screen.availHeight || 800) - h) / 2);
window.open(url, 'pb_oauth', `width=${w},height=${h},top=${top},left=${left},resizable=yes,menubar=no`);
},
});
await updatePbStatus();
await loadNotes();
writeOutput({ message: 'PocketBase login completed.', user: authData?.record?.email || client.authStore.model?.email || null });
}
async function logoutPocketBase() {
const client = await ensurePocketBaseClient();
client.authStore.clear();
allNotes = [];
clearForm();
renderList();
await updatePbStatus();
writeOutput({ message: 'PocketBase session cleared.' });
}
async function loadNotes() {
await ensurePocketBaseClient();
ensureLoggedIn();
const records = await pb.collection(NOTES_COLLECTION).getFullList({
sort: '-updated,-created',
});
allNotes = Array.isArray(records) ? records : [];
if (selectedNote) {
const refreshed = allNotes.find((n) => n.id === selectedNote.id);
selectedNote = refreshed || null;
if (selectedNote) selectNote(selectedNote);
}
renderList();
writeOutput({ message: `Loaded ${allNotes.length} note(s)` });
}
async function createNote() {
await ensurePocketBaseClient();
ensureLoggedIn();
const payload = buildNotePayload();
const record = await pb.collection(NOTES_COLLECTION).create(payload);
await loadNotes();
selectNote(record);
renderList();
writeOutput({ message: 'Note created', id: record.id });
}
async function saveNote() {
if (!selectedNote) throw new Error('Select a note first');
await ensurePocketBaseClient();
ensureLoggedIn();
const payload = buildNotePayload();
payload.hidden = Boolean(selectedNote.hidden);
const record = await pb.collection(NOTES_COLLECTION).update(selectedNote.id, payload);
await loadNotes();
selectNote(record);
renderList();
writeOutput({ message: 'Note updated', id: record.id });
}
async function toggleHidden() {
if (!selectedNote) throw new Error('Select a note first');
await ensurePocketBaseClient();
ensureLoggedIn();
const updated = await pb.collection(NOTES_COLLECTION).update(selectedNote.id, {
hidden: !Boolean(selectedNote.hidden),
});
await loadNotes();
selectNote(updated);
renderList();
writeOutput({ message: updated.hidden ? 'Note hidden' : 'Note unhidden', id: updated.id });
}
async function softDelete() {
if (!selectedNote) throw new Error('Select a note first');
await ensurePocketBaseClient();
ensureLoggedIn();
await pb.collection(NOTES_COLLECTION).update(selectedNote.id, { delete: true });
await loadNotes();
clearForm();
renderList();
writeOutput({ message: 'Note soft deleted', id: selectedNote.id });
}
function bind(id, handler) {
document.getElementById(id).addEventListener('click', async () => {
try {
await handler();
} catch (error) {
writeOutput({ error: error?.message || String(error) });
}
});
}
bind('loginBtn', loginPocketBase);
bind('logoutBtn', logoutPocketBase);
bind('refreshBtn', loadNotes);
bind('createBtn', createNote);
bind('saveBtn', saveNote);
bind('hideBtn', toggleHidden);
bind('deleteBtn', softDelete);
document.getElementById('clearBtn').addEventListener('click', () => {
clearForm();
renderList();
});
document.querySelectorAll('[data-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
currentFilter = btn.getAttribute('data-filter') || 'all';
renderList();
});
});
searchInput.addEventListener('input', () => renderList());
hiddenMode.addEventListener('change', () => renderList());
ensurePocketBaseClient()
.then(() => updatePbStatus())
.catch((error) => writeOutput({ error: error?.message || String(error) }));
clearForm();
renderList();
loadHealth();
</script>
</body>
</html>
+1
View File
@@ -369,6 +369,7 @@
<div class="fixed top-3 left-3 z-50 flex flex-wrap items-center gap-2"> <div class="fixed top-3 left-3 z-50 flex flex-wrap items-center gap-2">
<a href="/" class="px-3 py-1.5 rounded-full border border-white/40 bg-black/45 text-white text-xs font-semibold hover:bg-black/60 transition">Testing Harness</a> <a href="/" class="px-3 py-1.5 rounded-full border border-white/40 bg-black/45 text-white text-xs font-semibold hover:bg-black/60 transition">Testing Harness</a>
<a href="/tasks" class="px-3 py-1.5 rounded-full border border-white/40 bg-black/45 text-white text-xs font-semibold hover:bg-black/60 transition">Tasks</a> <a href="/tasks" class="px-3 py-1.5 rounded-full border border-white/40 bg-black/45 text-white text-xs font-semibold hover:bg-black/60 transition">Tasks</a>
<a href="/notes-workspace" class="px-3 py-1.5 rounded-full border border-white/40 bg-black/45 text-white text-xs font-semibold hover:bg-black/60 transition">Notes Workspace</a>
<span class="px-3 py-1.5 rounded-full border border-cyan-300/70 bg-slate-900/85 text-cyan-200 text-xs font-semibold">Legacy Notes Workspace</span> <span class="px-3 py-1.5 rounded-full border border-cyan-300/70 bg-slate-900/85 text-cyan-200 text-xs font-semibold">Legacy Notes Workspace</span>
</div> </div>
<!-- Top-right display name --> <!-- Top-right display name -->
+5
View File
@@ -1090,6 +1090,11 @@ app.get('/notes', async (c) => {
return c.html(html); return c.html(html);
}); });
app.get('/notes-workspace', async (c) => {
const html = await Bun.file(new URL('./notes-workspace.html', import.meta.url)).text();
return c.html(html);
});
app.get('/tasks', async (c) => { app.get('/tasks', async (c) => {
const html = await Bun.file(new URL('./tasks.html', import.meta.url)).text(); const html = await Bun.file(new URL('./tasks.html', import.meta.url)).text();
return c.html(html); return c.html(html);
+1
View File
@@ -99,6 +99,7 @@
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<a href="/notes-workspace" class="btn btn-secondary">Open Notes Workspace</a>
<a href="/notes" class="btn btn-secondary">Open Legacy Notes</a> <a href="/notes" class="btn btn-secondary">Open Legacy Notes</a>
<a href="/" class="btn btn-secondary">Back to Testing View</a> <a href="/" class="btn btn-secondary">Back to Testing View</a>
</div> </div>