Add attachments workflow to notes workspace

This commit is contained in:
2026-03-28 19:43:30 +00:00
parent 744adc8645
commit e9d520fd5e
+224 -4
View File
@@ -167,6 +167,15 @@
</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>
<div class="mt-4 flex flex-wrap gap-3">
<button id="createBtn" class="btn btn-accent-emerald">Create Note</button>
@@ -183,6 +192,11 @@
<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>
@@ -198,12 +212,16 @@
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');
@@ -219,6 +237,11 @@
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 saveBtn = document.getElementById('saveBtn');
const hideBtn = document.getElementById('hideBtn');
@@ -283,6 +306,162 @@
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) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
@@ -395,6 +574,12 @@
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);
@@ -408,6 +593,7 @@
<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>
@@ -422,6 +608,7 @@
if (note) {
selectNote(note);
renderList();
renderSelectedAttachments();
}
});
});
@@ -435,6 +622,10 @@
jobNumberInput.value = '';
bodyInput.value = '';
shareInput.value = '';
pendingAttachments = [];
renderPendingAttachments();
selectedAttachmentsStatus.textContent = 'Select a note to view attachments.';
selectedAttachmentsList.innerHTML = '';
saveBtn.disabled = true;
hideBtn.disabled = true;
deleteBtn.disabled = true;
@@ -448,6 +639,8 @@
jobNumberInput.value = String(note.Job_Number || '');
bodyInput.value = String(note.body_plain || '');
shareInput.value = normalizeShares(note.shared_with).join(', ');
pendingAttachments = [];
renderPendingAttachments();
saveBtn.disabled = false;
hideBtn.disabled = false;
deleteBtn.disabled = false;
@@ -547,12 +740,14 @@
...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)` });
}
@@ -561,10 +756,13 @@
ensureLoggedIn();
const payload = buildNotePayload();
const record = await pb.collection(NOTES_COLLECTION).create(payload);
const uploaded = await uploadPendingAttachments(record.id);
await loadNotes();
selectNote(record);
const refreshed = allNotes.find((n) => n.id === record.id) || record;
selectNote(refreshed);
renderList();
writeOutput({ message: 'Note created', id: record.id });
await renderSelectedAttachments();
writeOutput({ message: 'Note created', id: record.id, attachmentsUploaded: uploaded.length });
}
async function saveNote() {
@@ -574,10 +772,13 @@
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();
selectNote(record);
const refreshed = allNotes.find((n) => n.id === selectedNote.id) || record;
selectNote(refreshed);
renderList();
writeOutput({ message: 'Note updated', id: record.id });
await renderSelectedAttachments();
writeOutput({ message: 'Note updated', id: record.id, attachmentsUploaded: uploaded.length });
}
async function toggleHidden() {
@@ -622,6 +823,24 @@
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();
});
document.getElementById('clearBtn').addEventListener('click', () => {
clearForm();
renderList();
@@ -639,6 +858,7 @@
bootstrapWorkspace();
clearForm();
renderPendingAttachments();
renderList();
loadHealth();
</script>