Add note sharing feature: user selection, shared status, green card styling, and type badges
This commit is contained in:
+274
-11
@@ -51,9 +51,16 @@
|
||||
window.captureSnip = async function() {
|
||||
console.log('captureSnip called');
|
||||
return new Promise((resolve) => {
|
||||
// Hide main app elements before showing dialog
|
||||
const appElements = document.querySelectorAll('body > div:not([style*="z-index"])');
|
||||
appElements.forEach(el => el.style.visibility = 'hidden');
|
||||
|
||||
navigator.mediaDevices.getDisplayMedia({
|
||||
video: { mediaSource: 'screen' }
|
||||
}).then(mediaStream => {
|
||||
// Restore visibility
|
||||
appElements.forEach(el => el.style.visibility = '');
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.srcObject = mediaStream;
|
||||
|
||||
@@ -225,6 +232,8 @@
|
||||
};
|
||||
video.play();
|
||||
}).catch(error => {
|
||||
// Restore visibility on error
|
||||
appElements.forEach(el => el.style.visibility = '');
|
||||
console.error('Capture failed:', error);
|
||||
resolve(null);
|
||||
});
|
||||
@@ -242,10 +251,89 @@
|
||||
});
|
||||
};
|
||||
|
||||
window.recordScreen = async function(options = {}) {
|
||||
const {
|
||||
maxDuration = 5 * 60 * 1000,
|
||||
mimeType = 'video/webm',
|
||||
onStart = null,
|
||||
onStop = null
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { mediaSource: 'screen' },
|
||||
audio: false
|
||||
});
|
||||
|
||||
const mediaRecorder = new MediaRecorder(mediaStream, { mimeType });
|
||||
const chunks = [];
|
||||
let isRecording = true;
|
||||
let timeoutId = null;
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
isRecording = false;
|
||||
clearTimeout(timeoutId);
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
if (onStop) onStop();
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
if (onStart) onStart();
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (isRecording) {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}, maxDuration);
|
||||
|
||||
mediaStream.getVideoTracks()[0].onended = () => {
|
||||
if (isRecording) {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
return new Promise((resolve) => {
|
||||
if (!isRecording) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const stopHandler = () => {
|
||||
mediaRecorder.removeEventListener('stop', stopHandler);
|
||||
const blob = new Blob(chunks, { type: mimeType });
|
||||
const file = new File(
|
||||
[blob],
|
||||
`screen-recording-${Date.now()}.webm`,
|
||||
{ type: mimeType }
|
||||
);
|
||||
resolve(file);
|
||||
};
|
||||
|
||||
mediaRecorder.addEventListener('stop', stopHandler);
|
||||
mediaRecorder.stop();
|
||||
});
|
||||
},
|
||||
isRecording: () => isRecording,
|
||||
getDuration: () => mediaRecorder.state === 'recording' ? mediaRecorder.state : 0,
|
||||
mediaStream
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Screen recording failed:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('✓ Capture tools initialized successfully');
|
||||
console.log('Available: window.captureScreenshot, window.captureSnip, window.canvasToFile');
|
||||
console.log('Available: window.captureScreenshot, window.captureSnip, window.canvasToFile, window.recordScreen');
|
||||
</script>
|
||||
<script src="./tools/screen-record/index.js"></script>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-primary to-secondary p-4 sm:p-8 flex flex-col items-center justify-center font-sans relative h-screen overflow-hidden">
|
||||
<!-- Top-right display name -->
|
||||
@@ -389,6 +477,16 @@
|
||||
<label for="jobNumber" class="block text-sm font-medium text-gray-700 mb-1">Job Number <span class="text-red-600">*</span></label>
|
||||
<input type="text" id="jobNumber" class="w-full rounded-lg border border-gray-300 p-3 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Enter job number (required)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Share Note</label>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<input type="text" id="userSearchInput" class="flex-1 rounded-lg border border-gray-300 p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Search users by name...">
|
||||
<div id="userSuggestions" class="hidden absolute top-full left-0 right-0 mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-48 overflow-y-auto z-20"></div>
|
||||
</div>
|
||||
<div id="selectedUsersContainer" class="flex flex-wrap gap-2 mb-3">
|
||||
<!-- Selected user capsules will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="noteText" class="block text-sm font-medium text-gray-700 mb-1">Note Text</label>
|
||||
<div class="relative">
|
||||
@@ -1713,6 +1811,9 @@
|
||||
document.getElementById('noteType').value = 'personal';
|
||||
document.getElementById('jobNumber').value = '';
|
||||
document.getElementById('jobNumberContainer').classList.add('hidden');
|
||||
document.getElementById('userSearchInput').value = '';
|
||||
selectedUsers = [];
|
||||
renderSelectedUsers();
|
||||
clearAudio();
|
||||
pendingNoteAttachments = [];
|
||||
const noteAttachmentsInput = document.getElementById('noteAttachmentsInput');
|
||||
@@ -1751,11 +1852,19 @@
|
||||
card.type = 'button';
|
||||
card.dataset.noteId = note.id;
|
||||
const type = (note.type || 'personal').toLowerCase();
|
||||
const typeClasses = {
|
||||
|
||||
// Use green background for shared notes, otherwise use type-based colors
|
||||
let typeClasses;
|
||||
if (note.shared) {
|
||||
typeClasses = 'bg-green-100 border border-green-300 hover:border-green-400';
|
||||
} else {
|
||||
typeClasses = {
|
||||
personal: 'bg-amber-100 border border-amber-300 hover:border-amber-400',
|
||||
manager: 'bg-orange-200 border border-orange-400 hover:border-orange-500',
|
||||
job: 'bg-pink-200 border border-pink-400 hover:border-pink-500',
|
||||
}[type] || 'bg-gray-100 border border-gray-300 hover:border-primary/60';
|
||||
}
|
||||
|
||||
card.className = `text-left ${typeClasses} hover:shadow-md transition rounded-xl px-2 py-0.5 space-y-0 h-full flex flex-col`;
|
||||
const title = note.title || 'Untitled';
|
||||
const plain = note[NOTE_BODY_PLAIN_FIELD] || (note[NOTE_BODY_HTML_FIELD] ? htmlToPlainText(note[NOTE_BODY_HTML_FIELD]) : '');
|
||||
@@ -1776,15 +1885,25 @@
|
||||
.filter((k) => typeSet.has(k))
|
||||
.map(iconBtnHtml)
|
||||
.join('');
|
||||
|
||||
// Create type indicator badge with matching card background colors
|
||||
const typeColorMap = {
|
||||
personal: { bg: 'bg-amber-200', border: 'border-amber-400', text: 'text-amber-900' },
|
||||
manager: { bg: 'bg-orange-300', border: 'border-orange-500', text: 'text-orange-900' },
|
||||
job: { bg: 'bg-pink-300', border: 'border-pink-500', text: 'text-pink-900' },
|
||||
};
|
||||
const typeColor = typeColorMap[type] || { bg: 'bg-gray-200', border: 'border-gray-400', text: 'text-gray-900' };
|
||||
const typeBadge = `<span class="inline-block px-2 py-1 rounded-full text-xs font-semibold ${typeColor.bg} ${typeColor.border} border ${typeColor.text}">${escapeHtml(type)}</span>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="font-semibold text-gray-900 truncate">${escapeHtml(title)}</div>
|
||||
<div class="text-xs text-gray-500">${formatDateShort(note.created)}</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 line-clamp-3 flex-1">${escapeHtml(snippet || '(empty)')}</div>
|
||||
<div class="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>${escapeHtml(meta)}</span>
|
||||
<div class="text-sm text-gray-700 line-clamp-2 flex-1">${escapeHtml(snippet || '(empty)')}</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">${attachmentIcons}</div>
|
||||
${typeBadge}
|
||||
</div>
|
||||
`;
|
||||
frag.appendChild(card);
|
||||
@@ -2148,11 +2267,36 @@
|
||||
|
||||
const recorder = await window.recordScreen();
|
||||
if (recorder) {
|
||||
// Auto-stop after 5 minutes
|
||||
setTimeout(async () => {
|
||||
// Create stop button overlay
|
||||
const stopOverlay = document.createElement('div');
|
||||
stopOverlay.style.cssText = `position:fixed;top:20px;right:20px;z-index:50000;background:white;padding:15px 20px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.15);`;
|
||||
|
||||
const stopBtn = document.createElement('button');
|
||||
stopBtn.textContent = '⏹ Stop Recording';
|
||||
stopBtn.style.cssText = `padding:10px 20px;background:#EF4444;color:white;border:none;border-radius:6px;font-size:14px;font-weight:bold;cursor:pointer;`;
|
||||
|
||||
stopBtn.addEventListener('click', async () => {
|
||||
const file = await recorder.stop();
|
||||
if (file) {
|
||||
pendingNoteAttachments.push(file);
|
||||
renderNoteAttachmentsList();
|
||||
}
|
||||
stopOverlay.remove();
|
||||
});
|
||||
|
||||
stopOverlay.appendChild(stopBtn);
|
||||
document.body.appendChild(stopOverlay);
|
||||
|
||||
// Auto-stop after 5 minutes as fallback
|
||||
setTimeout(async () => {
|
||||
if (recorder.isRecording()) {
|
||||
const file = await recorder.stop();
|
||||
if (file) {
|
||||
pendingNoteAttachments.push(file);
|
||||
renderNoteAttachmentsList();
|
||||
}
|
||||
stopOverlay.remove();
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2270,11 +2414,36 @@
|
||||
|
||||
const recorder = await window.recordScreen();
|
||||
if (recorder) {
|
||||
// Auto-stop after 5 minutes
|
||||
setTimeout(async () => {
|
||||
// Create stop button overlay
|
||||
const stopOverlay = document.createElement('div');
|
||||
stopOverlay.style.cssText = `position:fixed;top:20px;right:20px;z-index:50000;background:white;padding:15px 20px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.15);`;
|
||||
|
||||
const stopBtn = document.createElement('button');
|
||||
stopBtn.textContent = '⏹ Stop Recording';
|
||||
stopBtn.style.cssText = `padding:10px 20px;background:#EF4444;color:white;border:none;border-radius:6px;font-size:14px;font-weight:bold;cursor:pointer;`;
|
||||
|
||||
stopBtn.addEventListener('click', async () => {
|
||||
const file = await recorder.stop();
|
||||
if (file) {
|
||||
pendingConversationAttachments.push(file);
|
||||
renderConversationAttachmentsList();
|
||||
}
|
||||
stopOverlay.remove();
|
||||
});
|
||||
|
||||
stopOverlay.appendChild(stopBtn);
|
||||
document.body.appendChild(stopOverlay);
|
||||
|
||||
// Auto-stop after 5 minutes as fallback
|
||||
setTimeout(async () => {
|
||||
if (recorder.isRecording()) {
|
||||
const file = await recorder.stop();
|
||||
if (file) {
|
||||
pendingConversationAttachments.push(file);
|
||||
renderConversationAttachmentsList();
|
||||
}
|
||||
stopOverlay.remove();
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2371,6 +2540,98 @@
|
||||
}
|
||||
});
|
||||
|
||||
// --- Share Note Logic ---
|
||||
let selectedUsers = []; // { id, displayName, firstName }
|
||||
const userSearchInput = document.getElementById('userSearchInput');
|
||||
const userSuggestions = document.getElementById('userSuggestions');
|
||||
const selectedUsersContainer = document.getElementById('selectedUsersContainer');
|
||||
|
||||
userSearchInput.addEventListener('input', async (e) => {
|
||||
const query = e.target.value.trim().toLowerCase();
|
||||
userSuggestions.innerHTML = '';
|
||||
|
||||
if (!query) {
|
||||
userSuggestions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allUsers = await pb.collection('users').getFullList({
|
||||
sort: 'displayName'
|
||||
});
|
||||
|
||||
const matching = allUsers.filter(u =>
|
||||
u.displayName?.toLowerCase().includes(query) ||
|
||||
u.firstName?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query)
|
||||
).filter(u => !selectedUsers.find(su => su.id === u.id));
|
||||
|
||||
if (matching.length === 0) {
|
||||
userSuggestions.innerHTML = '<div class="px-4 py-2 text-sm text-gray-500">No users found</div>';
|
||||
} else {
|
||||
matching.forEach(user => {
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition';
|
||||
option.innerHTML = `<div class="font-medium">${escapeHtml(user.displayName || user.email)}</div><div class="text-xs text-gray-500">${escapeHtml(user.email)}</div>`;
|
||||
option.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
selectUser(user);
|
||||
userSearchInput.value = '';
|
||||
userSuggestions.classList.add('hidden');
|
||||
});
|
||||
userSuggestions.appendChild(option);
|
||||
});
|
||||
}
|
||||
userSuggestions.classList.remove('hidden');
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err);
|
||||
userSuggestions.innerHTML = '<div class="px-4 py-2 text-sm text-red-500">Failed to load users</div>';
|
||||
}
|
||||
});
|
||||
|
||||
function selectUser(user) {
|
||||
if (selectedUsers.find(u => u.id === user.id)) return;
|
||||
|
||||
selectedUsers.push({
|
||||
id: user.id,
|
||||
displayName: user.displayName || user.email,
|
||||
firstName: user.firstName || (user.displayName?.split(' ')[0] || 'User')
|
||||
});
|
||||
|
||||
renderSelectedUsers();
|
||||
}
|
||||
|
||||
function removeUser(userId) {
|
||||
selectedUsers = selectedUsers.filter(u => u.id !== userId);
|
||||
renderSelectedUsers();
|
||||
}
|
||||
|
||||
function renderSelectedUsers() {
|
||||
selectedUsersContainer.innerHTML = '';
|
||||
selectedUsers.forEach(user => {
|
||||
const capsule = document.createElement('div');
|
||||
capsule.className = 'inline-flex items-center gap-2 px-3 py-1 rounded-full bg-green-100 border border-green-300 text-sm text-gray-800';
|
||||
capsule.innerHTML = `
|
||||
<span>${escapeHtml(user.firstName)}</span>
|
||||
<button type="button" class="text-gray-600 hover:text-gray-800 font-bold" data-remove-user="${user.id}">×</button>
|
||||
`;
|
||||
const removeBtn = capsule.querySelector('[data-remove-user]');
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
removeUser(user.id);
|
||||
});
|
||||
selectedUsersContainer.appendChild(capsule);
|
||||
});
|
||||
}
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#userSearchInput') && !e.target.closest('#userSuggestions')) {
|
||||
userSuggestions.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Submit note ---
|
||||
async function submitNote() {
|
||||
const statusEl = document.getElementById('submitStatus');
|
||||
@@ -2420,6 +2681,8 @@
|
||||
job_note: jobNoteValue === 'yes',
|
||||
type: noteType,
|
||||
userId: pb.authStore.model?.id || pb.authStore.record?.id,
|
||||
shared: selectedUsers.length > 0,
|
||||
shared_with: selectedUsers.map(u => u.id),
|
||||
};
|
||||
|
||||
// Only include job_number if it has a value
|
||||
@@ -2466,7 +2729,7 @@
|
||||
if (audioAttachmentCount) attachmentParts.push(`${audioAttachmentCount} audio`);
|
||||
if (fileAttachmentCount) attachmentParts.push(`${fileAttachmentCount} file`);
|
||||
const attachmentMsg = attachmentParts.length ? ` with ${attachmentParts.join(' and ')} attachment(s)` : '';
|
||||
statusEl.textContent = `Saved note ${noteRecord.id}${attachmentMsg}`;
|
||||
statusEl.textContent = '';
|
||||
await clearAudio();
|
||||
pendingNoteAttachments = [];
|
||||
document.getElementById('imageInput').value = '';
|
||||
|
||||
@@ -3,6 +3,21 @@ PRISM NOTES - SESSION LOG
|
||||
Project history, decisions, and architectural milestones
|
||||
================================================================================
|
||||
|
||||
[2026-01-11 TODO] Screen Capture Window Visibility Issue - INVESTIGATE LATER
|
||||
- Issue: Prism Notes window still appears in getDisplayMedia() dialog options
|
||||
even though visibility is hidden before dialog opens
|
||||
- Attempted Fix: Used visibility:hidden on app elements before getDisplayMedia()
|
||||
- Result: Not working - window still shows in screen selector
|
||||
- Root Cause: Unclear - may be timing issue or browser caching of window list
|
||||
- Workaround: User can manually select other screen/window instead
|
||||
- Investigation Needed:
|
||||
* Try alternative hiding methods (display:none, opacity:0, positioning off-screen)
|
||||
* Check if timing delay needed before getDisplayMedia()
|
||||
* Verify with different browsers (Chrome vs Edge vs Firefox)
|
||||
* Consider if OS-level window management interferes
|
||||
- Impact: Minor UX issue - doesn't prevent functionality, just less elegant
|
||||
- Priority: LOW - defer until screen capture feature stabilized otherwise
|
||||
|
||||
[2026-01-10 20:10] MONICA SESSION: Fixed Container Injection Bug
|
||||
- Issue: Refactor commit (cee9157) had CONTAINERS + initializeContainers() code
|
||||
but ALSO kept all old HTML in DOM, creating duplicates and preventing injection
|
||||
|
||||
Reference in New Issue
Block a user