Add attachments workflow to notes workspace
This commit is contained in:
+224
-4
@@ -167,6 +167,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<textarea id="bodyInput" rows="8" class="field" placeholder="Write your note..."></textarea>
|
<textarea id="bodyInput" rows="8" class="field" placeholder="Write your note..."></textarea>
|
||||||
<input id="shareInput" class="field" placeholder="Share with (comma separated names)" />
|
<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>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<button id="createBtn" class="btn btn-accent-emerald">Create Note</button>
|
<button id="createBtn" class="btn btn-accent-emerald">Create Note</button>
|
||||||
@@ -183,6 +192,11 @@
|
|||||||
<span id="selectedMeta" class="chip">No selection</span>
|
<span id="selectedMeta" class="chip">No selection</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="notesList" class="mt-4 space-y-2 max-h-[560px] overflow-auto"></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>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -198,12 +212,16 @@
|
|||||||
const PB_DEFAULT_AUTH_COLLECTION = 'Users';
|
const PB_DEFAULT_AUTH_COLLECTION = 'Users';
|
||||||
const PB_DEFAULT_OAUTH_PROVIDER = 'microsoft';
|
const PB_DEFAULT_OAUTH_PROVIDER = 'microsoft';
|
||||||
const NOTES_COLLECTION = 'Notes';
|
const NOTES_COLLECTION = 'Notes';
|
||||||
|
const ATTACHMENTS_COLLECTION = 'Attachments';
|
||||||
|
const ATTACHMENTS_FILE_FIELD = 'file';
|
||||||
|
const ATTACHMENTS_PBID_FIELD = 'pb_id';
|
||||||
|
|
||||||
let pb = null;
|
let pb = null;
|
||||||
let pbConfigPromise = null;
|
let pbConfigPromise = null;
|
||||||
let allNotes = [];
|
let allNotes = [];
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let selectedNote = null;
|
let selectedNote = null;
|
||||||
|
let pendingAttachments = [];
|
||||||
|
|
||||||
const output = document.getElementById('output');
|
const output = document.getElementById('output');
|
||||||
const healthStatus = document.getElementById('healthStatus');
|
const healthStatus = document.getElementById('healthStatus');
|
||||||
@@ -219,6 +237,11 @@
|
|||||||
const shareInput = document.getElementById('shareInput');
|
const shareInput = document.getElementById('shareInput');
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
const hiddenMode = document.getElementById('hiddenMode');
|
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 saveBtn = document.getElementById('saveBtn');
|
const saveBtn = document.getElementById('saveBtn');
|
||||||
const hideBtn = document.getElementById('hideBtn');
|
const hideBtn = document.getElementById('hideBtn');
|
||||||
@@ -283,6 +306,162 @@
|
|||||||
return [];
|
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 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) {
|
function toBoolFlag(value) {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (typeof value === 'number') return value !== 0;
|
if (typeof value === 'number') return value !== 0;
|
||||||
@@ -395,6 +574,12 @@
|
|||||||
const mine = isMine(note);
|
const mine = isMine(note);
|
||||||
const sharedWithMe = isSharedWithCurrentUser(note);
|
const sharedWithMe = isSharedWithCurrentUser(note);
|
||||||
const sharedByMe = toBoolFlag(note?.shared);
|
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 badge = sharedWithMe ? 'Shared w/ me' : sharedByMe ? 'Shared by me' : 'Private';
|
||||||
const tone = sharedWithMe ? 'text-cyan-300' : sharedByMe ? 'text-emerald-300' : 'text-slate-300';
|
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 preview = String(note.body_plain || '').replace(/\s+/g, ' ').trim().slice(0, 160);
|
||||||
@@ -408,6 +593,7 @@
|
|||||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-slate-400">
|
<div class="mt-1 flex flex-wrap gap-2 text-xs text-slate-400">
|
||||||
<span>${escapeHtml(note.type || 'personal')}</span>
|
<span>${escapeHtml(note.type || 'personal')}</span>
|
||||||
${note.Job_Number ? `<span>Job ${escapeHtml(note.Job_Number)}</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>
|
<span>${escapeHtml(new Date(note.updated || note.created).toLocaleString())}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-slate-300">${escapeHtml(preview || '(empty)')}</p>
|
<p class="mt-2 text-xs text-slate-300">${escapeHtml(preview || '(empty)')}</p>
|
||||||
@@ -422,6 +608,7 @@
|
|||||||
if (note) {
|
if (note) {
|
||||||
selectNote(note);
|
selectNote(note);
|
||||||
renderList();
|
renderList();
|
||||||
|
renderSelectedAttachments();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -435,6 +622,10 @@
|
|||||||
jobNumberInput.value = '';
|
jobNumberInput.value = '';
|
||||||
bodyInput.value = '';
|
bodyInput.value = '';
|
||||||
shareInput.value = '';
|
shareInput.value = '';
|
||||||
|
pendingAttachments = [];
|
||||||
|
renderPendingAttachments();
|
||||||
|
selectedAttachmentsStatus.textContent = 'Select a note to view attachments.';
|
||||||
|
selectedAttachmentsList.innerHTML = '';
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
hideBtn.disabled = true;
|
hideBtn.disabled = true;
|
||||||
deleteBtn.disabled = true;
|
deleteBtn.disabled = true;
|
||||||
@@ -448,6 +639,8 @@
|
|||||||
jobNumberInput.value = String(note.Job_Number || '');
|
jobNumberInput.value = String(note.Job_Number || '');
|
||||||
bodyInput.value = String(note.body_plain || '');
|
bodyInput.value = String(note.body_plain || '');
|
||||||
shareInput.value = normalizeShares(note.shared_with).join(', ');
|
shareInput.value = normalizeShares(note.shared_with).join(', ');
|
||||||
|
pendingAttachments = [];
|
||||||
|
renderPendingAttachments();
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
hideBtn.disabled = false;
|
hideBtn.disabled = false;
|
||||||
deleteBtn.disabled = false;
|
deleteBtn.disabled = false;
|
||||||
@@ -547,12 +740,14 @@
|
|||||||
...note,
|
...note,
|
||||||
_sharedWithMe: isSharedWithCurrentUser(note),
|
_sharedWithMe: isSharedWithCurrentUser(note),
|
||||||
}));
|
}));
|
||||||
|
await enrichNotesWithAttachments(allNotes);
|
||||||
if (selectedNote) {
|
if (selectedNote) {
|
||||||
const refreshed = allNotes.find((n) => n.id === selectedNote.id);
|
const refreshed = allNotes.find((n) => n.id === selectedNote.id);
|
||||||
selectedNote = refreshed || null;
|
selectedNote = refreshed || null;
|
||||||
if (selectedNote) selectNote(selectedNote);
|
if (selectedNote) selectNote(selectedNote);
|
||||||
}
|
}
|
||||||
renderList();
|
renderList();
|
||||||
|
await renderSelectedAttachments();
|
||||||
writeOutput({ message: `Loaded ${allNotes.length} note(s)` });
|
writeOutput({ message: `Loaded ${allNotes.length} note(s)` });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,10 +756,13 @@
|
|||||||
ensureLoggedIn();
|
ensureLoggedIn();
|
||||||
const payload = buildNotePayload();
|
const payload = buildNotePayload();
|
||||||
const record = await pb.collection(NOTES_COLLECTION).create(payload);
|
const record = await pb.collection(NOTES_COLLECTION).create(payload);
|
||||||
|
const uploaded = await uploadPendingAttachments(record.id);
|
||||||
await loadNotes();
|
await loadNotes();
|
||||||
selectNote(record);
|
const refreshed = allNotes.find((n) => n.id === record.id) || record;
|
||||||
|
selectNote(refreshed);
|
||||||
renderList();
|
renderList();
|
||||||
writeOutput({ message: 'Note created', id: record.id });
|
await renderSelectedAttachments();
|
||||||
|
writeOutput({ message: 'Note created', id: record.id, attachmentsUploaded: uploaded.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveNote() {
|
async function saveNote() {
|
||||||
@@ -574,10 +772,13 @@
|
|||||||
const payload = buildNotePayload();
|
const payload = buildNotePayload();
|
||||||
payload.hidden = toBoolFlag(selectedNote.hidden);
|
payload.hidden = toBoolFlag(selectedNote.hidden);
|
||||||
const record = await pb.collection(NOTES_COLLECTION).update(selectedNote.id, payload);
|
const record = await pb.collection(NOTES_COLLECTION).update(selectedNote.id, payload);
|
||||||
|
const uploaded = await uploadPendingAttachments(selectedNote.id);
|
||||||
await loadNotes();
|
await loadNotes();
|
||||||
selectNote(record);
|
const refreshed = allNotes.find((n) => n.id === selectedNote.id) || record;
|
||||||
|
selectNote(refreshed);
|
||||||
renderList();
|
renderList();
|
||||||
writeOutput({ message: 'Note updated', id: record.id });
|
await renderSelectedAttachments();
|
||||||
|
writeOutput({ message: 'Note updated', id: record.id, attachmentsUploaded: uploaded.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleHidden() {
|
async function toggleHidden() {
|
||||||
@@ -622,6 +823,24 @@
|
|||||||
bind('hideBtn', toggleHidden);
|
bind('hideBtn', toggleHidden);
|
||||||
bind('deleteBtn', softDelete);
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('clearBtn').addEventListener('click', () => {
|
document.getElementById('clearBtn').addEventListener('click', () => {
|
||||||
clearForm();
|
clearForm();
|
||||||
renderList();
|
renderList();
|
||||||
@@ -639,6 +858,7 @@
|
|||||||
|
|
||||||
bootstrapWorkspace();
|
bootstrapWorkspace();
|
||||||
clearForm();
|
clearForm();
|
||||||
|
renderPendingAttachments();
|
||||||
renderList();
|
renderList();
|
||||||
loadHealth();
|
loadHealth();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user