Add attachments workflow to notes workspace
This commit is contained in:
+224
-4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user