1019 lines
39 KiB
HTML
1019 lines
39 KiB
HTML
<!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="all" selected>Show All</option>
|
|
<option value="visible">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 class="rounded-xl border border-slate-700 bg-slate-950/60 p-3">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button id="pickFilesBtn" class="btn btn-secondary" type="button">Add Attachments</button>
|
|
<button id="clearFilesBtn" class="btn btn-secondary" type="button">Clear Pending</button>
|
|
<span id="pendingAttachmentsSummary" class="text-xs text-slate-400">No attachments selected.</span>
|
|
</div>
|
|
<input id="attachmentsInput" class="hidden" type="file" multiple />
|
|
<div id="pendingAttachmentsList" class="mt-2 space-y-1 text-xs text-slate-300"></div>
|
|
</div>
|
|
<div class="rounded-xl border border-slate-700 bg-slate-950/60 p-3">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button id="startRecordBtn" class="btn btn-secondary" type="button">Start Recording</button>
|
|
<button id="stopRecordBtn" class="btn btn-secondary" type="button" disabled>Stop</button>
|
|
<button id="clearRecordBtn" class="btn btn-secondary" type="button">Clear Last Clip</button>
|
|
<span id="recordStatus" class="text-xs text-slate-400">Ready</span>
|
|
</div>
|
|
<audio id="recordPreview" class="mt-2 hidden w-full" controls preload="metadata"></audio>
|
|
</div>
|
|
</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>
|
|
<div class="mt-4 rounded-xl border border-slate-700 bg-slate-950/60 p-3">
|
|
<div class="text-sm font-medium text-slate-200">Selected Note Attachments</div>
|
|
<div id="selectedAttachmentsStatus" class="mt-1 text-xs text-slate-400">Select a note to view attachments.</div>
|
|
<div id="selectedAttachmentsList" class="mt-2 space-y-2"></div>
|
|
</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';
|
|
const ATTACHMENTS_COLLECTION = 'Attachments';
|
|
const ATTACHMENTS_FILE_FIELD = 'file';
|
|
const ATTACHMENTS_PBID_FIELD = 'pb_id';
|
|
|
|
let pb = null;
|
|
let pbConfigPromise = null;
|
|
let allNotes = [];
|
|
let currentFilter = 'all';
|
|
let selectedNote = null;
|
|
let pendingAttachments = [];
|
|
|
|
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 attachmentsInput = document.getElementById('attachmentsInput');
|
|
const pendingAttachmentsSummary = document.getElementById('pendingAttachmentsSummary');
|
|
const pendingAttachmentsList = document.getElementById('pendingAttachmentsList');
|
|
const selectedAttachmentsStatus = document.getElementById('selectedAttachmentsStatus');
|
|
const selectedAttachmentsList = document.getElementById('selectedAttachmentsList');
|
|
const recordStatus = document.getElementById('recordStatus');
|
|
const recordPreview = document.getElementById('recordPreview');
|
|
const startRecordBtn = document.getElementById('startRecordBtn');
|
|
const stopRecordBtn = document.getElementById('stopRecordBtn');
|
|
const clearRecordBtn = document.getElementById('clearRecordBtn');
|
|
|
|
const saveBtn = document.getElementById('saveBtn');
|
|
const hideBtn = document.getElementById('hideBtn');
|
|
const deleteBtn = document.getElementById('deleteBtn');
|
|
|
|
let recorderStream = null;
|
|
let mediaRecorder = null;
|
|
let recordChunks = [];
|
|
let lastRecordedFile = null;
|
|
|
|
function writeOutput(value) {
|
|
output.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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 attachmentKindFromName(name) {
|
|
const fileName = String(name || '').toLowerCase();
|
|
if (!fileName) return 'file';
|
|
if (/(\.jpg|\.jpeg|\.png|\.gif|\.webp|\.bmp|\.svg)$/.test(fileName)) return 'image';
|
|
if (/(\.mp4|\.mov|\.avi|\.webm|\.mkv|\.m4v)$/.test(fileName)) return 'video';
|
|
if (/(\.mp3|\.wav|\.m4a|\.aac|\.ogg|\.flac|\.webm)$/.test(fileName)) return 'audio';
|
|
return 'file';
|
|
}
|
|
|
|
function getAttachmentFileNames(att) {
|
|
const value = att?.[ATTACHMENTS_FILE_FIELD];
|
|
if (Array.isArray(value)) return value.filter(Boolean);
|
|
if (typeof value === 'string' && value.trim()) return [value.trim()];
|
|
return [];
|
|
}
|
|
|
|
function buildKindSetFromAttachments(atts) {
|
|
const kinds = new Set();
|
|
(atts || []).forEach((att) => {
|
|
getAttachmentFileNames(att).forEach((name) => kinds.add(attachmentKindFromName(name)));
|
|
});
|
|
return kinds;
|
|
}
|
|
|
|
function kindIcon(kind) {
|
|
if (kind === 'image') return '🖼️';
|
|
if (kind === 'video') return '🎬';
|
|
if (kind === 'audio') return '🎵';
|
|
return '📎';
|
|
}
|
|
|
|
function renderPendingAttachments() {
|
|
if (!pendingAttachments.length) {
|
|
pendingAttachmentsSummary.textContent = 'No attachments selected.';
|
|
pendingAttachmentsList.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
pendingAttachmentsSummary.textContent = `${pendingAttachments.length} file(s) selected`;
|
|
pendingAttachmentsList.innerHTML = pendingAttachments.map((file, index) => {
|
|
const kind = attachmentKindFromName(file.name);
|
|
return `<div class="flex items-center justify-between gap-2 rounded border border-slate-700 bg-slate-900/70 px-2 py-1">
|
|
<div class="truncate"><span>${kindIcon(kind)}</span> ${escapeHtml(file.name)}</div>
|
|
<button class="text-xs text-rose-300 hover:text-rose-200" data-pending-remove="${index}" type="button">Remove</button>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
pendingAttachmentsList.querySelectorAll('[data-pending-remove]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const idx = Number.parseInt(btn.getAttribute('data-pending-remove') || '-1', 10);
|
|
if (Number.isInteger(idx) && idx >= 0 && idx < pendingAttachments.length) {
|
|
pendingAttachments.splice(idx, 1);
|
|
renderPendingAttachments();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function uploadPendingAttachments(noteId) {
|
|
if (!pendingAttachments.length) return [];
|
|
const created = [];
|
|
|
|
for (const sourceFile of pendingAttachments) {
|
|
const uploadFile = sourceFile instanceof File
|
|
? sourceFile
|
|
: new File([sourceFile], sourceFile?.name || `attachment-${Date.now()}`, { type: sourceFile?.type || 'application/octet-stream' });
|
|
|
|
const fd = new FormData();
|
|
fd.append(ATTACHMENTS_FILE_FIELD, uploadFile);
|
|
fd.append(ATTACHMENTS_PBID_FIELD, noteId);
|
|
const createdRecord = await pb.collection(ATTACHMENTS_COLLECTION).create(fd);
|
|
created.push(createdRecord);
|
|
}
|
|
|
|
pendingAttachments = [];
|
|
renderPendingAttachments();
|
|
return created;
|
|
}
|
|
|
|
async function ensureRecorderStream() {
|
|
if (recorderStream) return recorderStream;
|
|
recorderStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
},
|
|
});
|
|
return recorderStream;
|
|
}
|
|
|
|
function stopRecorderTracks() {
|
|
if (recorderStream) {
|
|
recorderStream.getTracks().forEach((t) => t.stop());
|
|
}
|
|
recorderStream = null;
|
|
}
|
|
|
|
async function startAudioRecording() {
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') return;
|
|
|
|
const stream = await ensureRecorderStream();
|
|
recordChunks = [];
|
|
mediaRecorder = new MediaRecorder(stream);
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data && event.data.size > 0) {
|
|
recordChunks.push(event.data);
|
|
}
|
|
};
|
|
|
|
mediaRecorder.start();
|
|
startRecordBtn.disabled = true;
|
|
stopRecordBtn.disabled = false;
|
|
recordStatus.textContent = 'Recording…';
|
|
}
|
|
|
|
async function stopAudioRecording() {
|
|
if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
|
|
|
|
await new Promise((resolve) => {
|
|
mediaRecorder.addEventListener('stop', resolve, { once: true });
|
|
mediaRecorder.stop();
|
|
});
|
|
|
|
const mimeType = mediaRecorder?.mimeType || 'audio/webm';
|
|
const blob = new Blob(recordChunks, { type: mimeType });
|
|
stopRecorderTracks();
|
|
mediaRecorder = null;
|
|
startRecordBtn.disabled = false;
|
|
stopRecordBtn.disabled = true;
|
|
|
|
if (!blob.size) {
|
|
recordStatus.textContent = 'No audio captured.';
|
|
return;
|
|
}
|
|
|
|
const ext = mimeType.includes('ogg') ? 'ogg' : mimeType.includes('mp4') ? 'm4a' : 'webm';
|
|
const fileName = `note-audio-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`;
|
|
const file = new File([blob], fileName, { type: mimeType });
|
|
pendingAttachments.push(file);
|
|
lastRecordedFile = file;
|
|
renderPendingAttachments();
|
|
|
|
if (recordPreview.src) URL.revokeObjectURL(recordPreview.src);
|
|
recordPreview.src = URL.createObjectURL(blob);
|
|
recordPreview.classList.remove('hidden');
|
|
recordStatus.textContent = `Recorded: ${fileName}`;
|
|
}
|
|
|
|
async function clearRecordedAudio() {
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
await stopAudioRecording();
|
|
}
|
|
|
|
if (recordPreview.src) {
|
|
URL.revokeObjectURL(recordPreview.src);
|
|
}
|
|
recordPreview.src = '';
|
|
recordPreview.classList.add('hidden');
|
|
|
|
if (lastRecordedFile) {
|
|
pendingAttachments = pendingAttachments.filter((f) => !(f.name === lastRecordedFile.name && f.size === lastRecordedFile.size));
|
|
renderPendingAttachments();
|
|
}
|
|
|
|
lastRecordedFile = null;
|
|
recordStatus.textContent = 'Ready';
|
|
stopRecorderTracks();
|
|
mediaRecorder = null;
|
|
startRecordBtn.disabled = false;
|
|
stopRecordBtn.disabled = true;
|
|
}
|
|
|
|
async function fetchAttachmentsForNote(noteId) {
|
|
try {
|
|
const atts = await pb.collection(ATTACHMENTS_COLLECTION).getFullList({
|
|
sort: '-created',
|
|
filter: `${ATTACHMENTS_PBID_FIELD} = "${String(noteId || '').replace(/"/g, '\\"')}"`,
|
|
batch: 200,
|
|
});
|
|
return Array.isArray(atts) ? atts : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function enrichNotesWithAttachments(notes) {
|
|
const list = Array.isArray(notes) ? notes : [];
|
|
await Promise.all(list.map(async (note) => {
|
|
note._attachments = await fetchAttachmentsForNote(note.id);
|
|
}));
|
|
return list;
|
|
}
|
|
|
|
async function renderSelectedAttachments() {
|
|
if (!selectedNote?.id) {
|
|
selectedAttachmentsStatus.textContent = 'Select a note to view attachments.';
|
|
selectedAttachmentsList.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
selectedAttachmentsStatus.textContent = 'Loading attachments...';
|
|
const noteRef = allNotes.find((n) => n.id === selectedNote.id) || selectedNote;
|
|
const atts = Array.isArray(noteRef._attachments) ? noteRef._attachments : await fetchAttachmentsForNote(selectedNote.id);
|
|
noteRef._attachments = atts;
|
|
selectedNote._attachments = atts;
|
|
|
|
if (!atts.length) {
|
|
selectedAttachmentsStatus.textContent = 'No attachments uploaded.';
|
|
selectedAttachmentsList.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
selectedAttachmentsStatus.textContent = `${atts.length} attachment record(s)`;
|
|
selectedAttachmentsList.innerHTML = atts.map((att) => {
|
|
const names = getAttachmentFileNames(att);
|
|
if (!names.length) return '';
|
|
|
|
const links = names.map((name) => {
|
|
const url = pb.files.getURL(att, name);
|
|
const kind = attachmentKindFromName(name);
|
|
return `<div class="flex items-center justify-between gap-2 rounded border border-slate-700 bg-slate-900/70 px-2 py-1">
|
|
<a class="truncate text-cyan-300 hover:text-cyan-200" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${kindIcon(kind)} ${escapeHtml(name)}</a>
|
|
<button class="text-xs text-rose-300 hover:text-rose-200" data-attachment-delete="${escapeHtml(att.id)}" type="button">Delete</button>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `<div class="space-y-1">${links}</div>`;
|
|
}).join('');
|
|
|
|
selectedAttachmentsList.querySelectorAll('[data-attachment-delete]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
try {
|
|
const attId = btn.getAttribute('data-attachment-delete');
|
|
if (!attId) return;
|
|
await pb.collection(ATTACHMENTS_COLLECTION).delete(attId);
|
|
const refreshedAtts = await fetchAttachmentsForNote(selectedNote.id);
|
|
selectedNote._attachments = refreshedAtts;
|
|
const match = allNotes.find((n) => n.id === selectedNote.id);
|
|
if (match) match._attachments = refreshedAtts;
|
|
renderList();
|
|
await renderSelectedAttachments();
|
|
writeOutput({ message: 'Attachment deleted', attachmentId: attId, noteId: selectedNote.id });
|
|
} catch (error) {
|
|
writeOutput({ error: error?.message || String(error) });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function toBoolFlag(value) {
|
|
if (typeof value === 'boolean') return value;
|
|
if (typeof value === 'number') return value !== 0;
|
|
if (typeof value === 'string') {
|
|
const v = value.trim().toLowerCase();
|
|
if (!v) return false;
|
|
if (['false', '0', 'no', 'off', 'null', 'undefined'].includes(v)) return false;
|
|
if (['true', '1', 'yes', 'on'].includes(v)) return true;
|
|
}
|
|
return Boolean(value);
|
|
}
|
|
|
|
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 || ' '}</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) {
|
|
const isHidden = toBoolFlag(note?.hidden);
|
|
const hiddenView = hiddenMode.value;
|
|
if (hiddenView === 'visible' && isHidden) return false;
|
|
if (hiddenView === 'hidden' && !isHidden) return false;
|
|
|
|
if (currentFilter === 'myshared' && !toBoolFlag(note?.shared)) return false;
|
|
if (currentFilter === 'sharedwithme' && !isSharedWithCurrentUser(note)) return false;
|
|
if (currentFilter === 'job' && !(toBoolFlag(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 sharedByMe = toBoolFlag(note?.shared);
|
|
const atts = note._attachments || [];
|
|
const typeSet = buildKindSetFromAttachments(atts);
|
|
const attachmentSummary = ['image', 'video', 'audio', 'file']
|
|
.filter((kind) => typeSet.has(kind))
|
|
.map((kind) => `${kindIcon(kind)} ${kind}`)
|
|
.join(' · ');
|
|
const badge = sharedWithMe ? 'Shared w/ me' : sharedByMe ? 'Shared by me' : 'Private';
|
|
const tone = sharedWithMe ? 'text-cyan-300' : sharedByMe ? '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>` : ''}
|
|
${atts.length ? `<span>${escapeHtml(attachmentSummary || `${atts.length} attachment(s)`)}</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();
|
|
renderSelectedAttachments();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearForm() {
|
|
selectedNote = null;
|
|
selectedMeta.textContent = 'No selection';
|
|
titleInput.value = '';
|
|
typeInput.value = 'personal';
|
|
jobNumberInput.value = '';
|
|
bodyInput.value = '';
|
|
shareInput.value = '';
|
|
pendingAttachments = [];
|
|
renderPendingAttachments();
|
|
if (recordPreview.src) {
|
|
URL.revokeObjectURL(recordPreview.src);
|
|
}
|
|
recordPreview.src = '';
|
|
recordPreview.classList.add('hidden');
|
|
recordStatus.textContent = 'Ready';
|
|
lastRecordedFile = null;
|
|
selectedAttachmentsStatus.textContent = 'Select a note to view attachments.';
|
|
selectedAttachmentsList.innerHTML = '';
|
|
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(', ');
|
|
pendingAttachments = [];
|
|
renderPendingAttachments();
|
|
if (recordPreview.src) {
|
|
URL.revokeObjectURL(recordPreview.src);
|
|
}
|
|
recordPreview.src = '';
|
|
recordPreview.classList.add('hidden');
|
|
recordStatus.textContent = 'Ready';
|
|
lastRecordedFile = null;
|
|
saveBtn.disabled = false;
|
|
hideBtn.disabled = false;
|
|
deleteBtn.disabled = false;
|
|
hideBtn.textContent = toBoolFlag(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 bootstrapWorkspace() {
|
|
try {
|
|
const client = await ensurePocketBaseClient();
|
|
const config = await getPocketBaseConfig();
|
|
|
|
if (client.authStore?.token) {
|
|
try {
|
|
await client.collection(config.collection).authRefresh();
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
await updatePbStatus();
|
|
if (client.authStore?.isValid) {
|
|
await loadNotes();
|
|
} else {
|
|
writeOutput('Login to load notes.');
|
|
}
|
|
} catch (error) {
|
|
writeOutput({ error: error?.message || String(error) });
|
|
}
|
|
}
|
|
|
|
async function loadNotes() {
|
|
await ensurePocketBaseClient();
|
|
ensureLoggedIn();
|
|
let records = [];
|
|
try {
|
|
const page = await pb.collection(NOTES_COLLECTION).getList(1, 200, {
|
|
sort: '-updated,-created',
|
|
});
|
|
records = Array.isArray(page?.items) ? page.items : [];
|
|
} catch {
|
|
const full = await pb.collection(NOTES_COLLECTION).getFullList({
|
|
sort: '-updated,-created',
|
|
});
|
|
records = Array.isArray(full) ? full : [];
|
|
}
|
|
|
|
allNotes = records.map((note) => ({
|
|
...note,
|
|
_sharedWithMe: isSharedWithCurrentUser(note),
|
|
}));
|
|
await enrichNotesWithAttachments(allNotes);
|
|
if (selectedNote) {
|
|
const refreshed = allNotes.find((n) => n.id === selectedNote.id);
|
|
selectedNote = refreshed || null;
|
|
if (selectedNote) selectNote(selectedNote);
|
|
}
|
|
renderList();
|
|
await renderSelectedAttachments();
|
|
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);
|
|
const uploaded = await uploadPendingAttachments(record.id);
|
|
await loadNotes();
|
|
const refreshed = allNotes.find((n) => n.id === record.id) || record;
|
|
selectNote(refreshed);
|
|
renderList();
|
|
await renderSelectedAttachments();
|
|
writeOutput({ message: 'Note created', id: record.id, attachmentsUploaded: uploaded.length });
|
|
}
|
|
|
|
async function saveNote() {
|
|
if (!selectedNote) throw new Error('Select a note first');
|
|
await ensurePocketBaseClient();
|
|
ensureLoggedIn();
|
|
const payload = buildNotePayload();
|
|
payload.hidden = toBoolFlag(selectedNote.hidden);
|
|
const record = await pb.collection(NOTES_COLLECTION).update(selectedNote.id, payload);
|
|
const uploaded = await uploadPendingAttachments(selectedNote.id);
|
|
await loadNotes();
|
|
const refreshed = allNotes.find((n) => n.id === selectedNote.id) || record;
|
|
selectNote(refreshed);
|
|
renderList();
|
|
await renderSelectedAttachments();
|
|
writeOutput({ message: 'Note updated', id: record.id, attachmentsUploaded: uploaded.length });
|
|
}
|
|
|
|
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: !toBoolFlag(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('pickFilesBtn').addEventListener('click', () => {
|
|
attachmentsInput.click();
|
|
});
|
|
|
|
attachmentsInput.addEventListener('change', (e) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length) {
|
|
pendingAttachments.push(...files);
|
|
renderPendingAttachments();
|
|
}
|
|
attachmentsInput.value = '';
|
|
});
|
|
|
|
document.getElementById('clearFilesBtn').addEventListener('click', () => {
|
|
pendingAttachments = [];
|
|
renderPendingAttachments();
|
|
});
|
|
|
|
startRecordBtn.addEventListener('click', async () => {
|
|
try {
|
|
await startAudioRecording();
|
|
} catch (error) {
|
|
recordStatus.textContent = `Recorder error: ${error?.message || String(error)}`;
|
|
}
|
|
});
|
|
|
|
stopRecordBtn.addEventListener('click', async () => {
|
|
try {
|
|
await stopAudioRecording();
|
|
} catch (error) {
|
|
recordStatus.textContent = `Stop failed: ${error?.message || String(error)}`;
|
|
}
|
|
});
|
|
|
|
clearRecordBtn.addEventListener('click', async () => {
|
|
try {
|
|
await clearRecordedAudio();
|
|
} catch (error) {
|
|
recordStatus.textContent = `Clear failed: ${error?.message || String(error)}`;
|
|
}
|
|
});
|
|
|
|
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());
|
|
|
|
bootstrapWorkspace();
|
|
clearForm();
|
|
renderPendingAttachments();
|
|
renderList();
|
|
loadHealth();
|
|
</script>
|
|
</body>
|
|
</html>
|