diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index c9c08fa..af1db53 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -31,6 +31,23 @@ public function index() return view('interview.index', compact('interviews', 'interviewers')); } + /** + * Display a specific Candidate Interview Live Review & Call Session. + */ + public function show($id) + { + $user = Auth::user(); + + if (!$user->isAdmin() && strtolower($user->role) !== 'hr' && !$user->hasActiveInterviewZone()) { + return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.'); + } + + $interview = Interview::findOrFail($id); + $interviewers = User::orderBy('name', 'asc')->get(); + + return view('interview.show', compact('interview', 'interviewers')); + } + /** * Store a new Candidate Interview session created by HR. */ diff --git a/resources/js/app.js b/resources/js/app.js index 5c60800..6df38d5 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,5 @@ import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js'; +import './interview-call.js'; window.globalIceServers = globalIceServers; window.getMeteredIceServers = getMeteredIceServers; @@ -8,3 +9,4 @@ window.initMeteredIceServers = initMeteredIceServers; if (typeof window !== 'undefined') { window.getMeteredIceServers(); } + diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js new file mode 100644 index 0000000..898c5b8 --- /dev/null +++ b/resources/js/interview-call.js @@ -0,0 +1,1833 @@ +/** + * Interview Call & Live Assessment Review Engine + * Handles WebRTC 2-Way Calling, Candidate Screen Sharing, Real-Time Sync, + * Proctoring Alerts, SOS Audio Ringtone, Tab Switching, and Silent Call Recording. + */ + +// --- Global Audio Ringtone Synthesizer Engine --- +export class CallRingtoneEngine { + constructor() { + this.ctx = null; + this.interval = null; + this.isPlaying = false; + } + + init() { + try { + if (!this.ctx) { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioCtx(); + } + if (this.ctx && this.ctx.state === 'suspended') { + this.ctx.resume(); + } + } catch (e) { + console.log('Audio init warning:', e); + } + } + + start() { + this.init(); + if (this.isPlaying) return; + this.isPlaying = true; + + this.playTone(); + if (this.interval) clearInterval(this.interval); + this.interval = setInterval(() => { + if (this.isPlaying) this.playTone(); + }, 2400); + } + + playTone() { + this.init(); + if (!this.ctx || !this.isPlaying) return; + try { + const now = this.ctx.currentTime; + const osc1 = this.ctx.createOscillator(); + const osc2 = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc1.type = 'sine'; + osc2.type = 'sine'; + + osc1.frequency.setValueAtTime(523.25, now); + osc2.frequency.setValueAtTime(659.25, now); + + gain.gain.setValueAtTime(0.001, now); + gain.gain.linearRampToValueAtTime(0.35, now + 0.08); + gain.gain.setValueAtTime(0.35, now + 1.1); + gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3); + + osc1.connect(gain); + osc2.connect(gain); + gain.connect(this.ctx.destination); + + osc1.start(now); + osc2.start(now); + + osc1.stop(now + 1.35); + osc2.stop(now + 1.35); + } catch (e) {} + } + + stop() { + this.isPlaying = false; + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +export const callRingtone = new CallRingtoneEngine(); + +// State variables +let currentInterviewId = null; +let interviewerPeer = null; +let screenReceiverPeer = null; +let interviewerLocalStream = null; +let activeCall = null; +let isInterviewerMicOn = true; +let isInterviewerCamOn = true; +let screenZoomLevel = 1.0; +let candVidZoomLevel = 1.0; +let activeViolations = []; +let sosPollInterval = null; +let acknowledgedViolationCount = 0; +let sosDismissed = false; +let sosAutoTriggerDisabled = false; +let currentTabScreenshotEnabled = true; +let interviewerSigChannel = null; +let interviewerPeerConnection = null; +let liveSyncChannel = null; +let panelistMeshTiles = new Map(); + +// Ringing & Call state +let activeIncomingCall = null; +let iAmTheCaller = false; +let ringTimeoutTimer = null; +const dismissedCallIds = new Set(); +const endedCallIds = new Set(); +let globalCallChannel = null; +const adminPageLoadTimestamp = Date.now(); + +let globalIceServers = window.globalIceServers || [ + { urls: 'stun:stun.l.google.com:19302' } +]; + +export function getInitials(name) { + if (!name) return 'CD'; + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + const str = name.trim(); + if (str.length >= 2) { + return (str[0] + str[str.length - 1]).toUpperCase(); + } + return str.toUpperCase(); +} + +export function zoomScreen(delta) { + screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta)); + const screenVid = document.getElementById('interviewer-screen-video'); + if (screenVid) { + screenVid.style.transform = `scale(${screenZoomLevel})`; + } +} + +export function resetZoomScreen() { + screenZoomLevel = 1.0; + zoomScreen(0); +} + +export function toggleFullscreenScreen() { + const wrapper = document.getElementById('screen-wrapper'); + if (!wrapper) return; + if (!document.fullscreenElement) { + wrapper.requestFullscreen().catch(err => console.log(err)); + } else { + document.exitFullscreen(); + } +} + +export function zoomCandVideo(delta) { + if (delta === 0) candVidZoomLevel = 1.0; + else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta)); + const vid = document.getElementById('interviewer-cand-video'); + if (vid) { + vid.style.transform = `scale(${candVidZoomLevel})`; + vid.style.transformOrigin = 'center center'; + vid.style.transition = 'transform 0.2s ease'; + } +} + +export function toggleSosAutoTrigger() { + sosAutoTriggerDisabled = !sosAutoTriggerDisabled; + const toggleSw = document.getElementById('sosToggle'); + const btn = document.getElementById('sos-toggle-btn'); + + if (toggleSw) { + if (sosAutoTriggerDisabled) { + toggleSw.classList.remove('on'); + } else { + toggleSw.classList.add('on'); + } + } + if (btn) { + if (sosAutoTriggerDisabled) { + btn.innerHTML = ' SOS Auto-Trigger: OFF'; + btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300'; + } else { + btn.innerHTML = ' SOS Auto-Trigger: ON'; + btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-indigo-500 bg-indigo-500/15 text-indigo-300'; + } + } +} + +export function openReportPage() { + if (!currentInterviewId) return; + window.open(`/interviews/${currentInterviewId}/report`, '_blank'); +} + +export function toggleTabScreenshotCapture() { + if (!currentInterviewId) return; + const newStatus = !currentTabScreenshotEnabled; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + fetch(`/interviews/${currentInterviewId}/toggle-tab-screenshot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ enable: newStatus }) + }) + .then(r => r.json()) + .then(res => { + if (res.success) { + currentTabScreenshotEnabled = res.enable_tab_switch_screenshot; + updateScreenshotToggleBtnUI(currentTabScreenshotEnabled); + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'info', + title: 'Screenshot Setting Updated', + text: res.message, + timer: 2500, + showConfirmButton: false, + toast: true, + position: 'top-end' + }); + } + } + }) + .catch(() => {}); +} + +export function updateScreenshotToggleBtnUI(enabled) { + const btn = document.getElementById('modal-toggle-screenshot-btn'); + if (btn) { + if (enabled) { + btn.innerHTML = ' ENABLED'; + btn.className = 'px-2.5 py-1 text-xs font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-md cursor-pointer border-none'; + } else { + btn.innerHTML = ' DISABLED'; + btn.className = 'px-2.5 py-1 text-xs font-bold bg-red-600 hover:bg-red-500 text-white rounded-md cursor-pointer border-none'; + } + } +} + +export function switchIdeTab(tab) { + const tabs = ['code', 'notes', 'drawing', 'recordings', 'screenshots']; + tabs.forEach(t => { + const contentEl = document.getElementById(`tab-content-${t}`); + const btnEl = document.getElementById(`tab-btn-${t}`); + if (contentEl) { + contentEl.style.display = (t === tab) ? 'block' : 'none'; + } + if (btnEl) { + if (t === tab) { + btnEl.classList.add('bg-indigo-600', 'text-white', 'border-indigo-500'); + btnEl.classList.remove('bg-white/5', 'text-slate-400', 'border-slate-700/60'); + } else { + btnEl.classList.remove('bg-indigo-600', 'text-white', 'border-indigo-500'); + btnEl.classList.add('bg-white/5', 'text-slate-400', 'border-slate-700/60'); + } + } + }); +} + +export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) { + if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return; + if (iAmTheCaller && currentInterviewId == callData.id) return; + if (interviewerLocalStream && currentInterviewId == callData.id) return; + + if (callData.call_started_at) { + const startedTime = new Date(callData.call_started_at).getTime(); + const elapsedMs = Date.now() - startedTime; + if (elapsedMs > 8000 && !isRealtimeBroadcast) { + dismissedCallIds.add(callData.id); + return; + } + } else if (!isRealtimeBroadcast) { + dismissedCallIds.add(callData.id); + return; + } + + activeIncomingCall = callData; + const nameEl = document.getElementById('incoming-cand-name'); + const infoEl = document.getElementById('incoming-starter-info'); + const modalEl = document.getElementById('incoming-call-modal'); + if (nameEl) nameEl.innerText = callData.candidate_name; + if (infoEl) infoEl.innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist'); + if (modalEl) modalEl.classList.add('active'); + callRingtone.start(); + + if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer); + let remainingMs = 8000; + if (callData.call_started_at) { + const startedTime = new Date(callData.call_started_at).getTime(); + const elapsedMs = Date.now() - startedTime; + remainingMs = Math.max(1000, 8000 - elapsedMs); + } + + ringTimeoutTimer = setTimeout(() => { + declineIncomingCall(true); + }, remainingMs); +} + +export function pollGlobalActiveCalls() { + fetch('/interviews/active-calls', { + headers: { 'Accept': 'application/json' } + }) + .then(r => r.json()) + .then(data => { + if (!data || !data.active_calls || data.active_calls.length === 0) { + if (activeIncomingCall) declineIncomingCall(true); + return; + } + + const incoming = data.active_calls.find(call => { + if (endedCallIds.has(call.id)) return false; + if (iAmTheCaller && currentInterviewId == call.id) return false; + if (interviewerLocalStream && currentInterviewId == call.id) return false; + if (dismissedCallIds.has(call.id)) return false; + return true; + }); + + if (incoming) { + const startedTime = incoming.call_started_at ? new Date(incoming.call_started_at).getTime() : 0; + if (startedTime > 0 && startedTime < (adminPageLoadTimestamp - 4000)) { + dismissedCallIds.add(incoming.id); + return; + } + if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) { + triggerIncomingCallRing(incoming, false); + } + } else if (activeIncomingCall) { + declineIncomingCall(true); + } + }) + .catch(() => {}); +} + +export function acceptIncomingCall() { + callRingtone.stop(); + if (ringTimeoutTimer) { + clearTimeout(ringTimeoutTimer); + ringTimeoutTimer = null; + } + const modal = document.getElementById('incoming-call-modal'); + if (modal) modal.classList.remove('active'); + + if (activeIncomingCall) { + const targetCall = activeIncomingCall; + activeIncomingCall = null; + if (window.location.pathname.includes('/interviews/' + targetCall.id)) { + startInterviewerCall(true); + } else { + window.location.href = `/interviews/${targetCall.id}?join_call=1`; + } + } +} + +export function declineIncomingCall(silent = false) { + callRingtone.stop(); + if (ringTimeoutTimer) { + clearTimeout(ringTimeoutTimer); + ringTimeoutTimer = null; + } + const modal = document.getElementById('incoming-call-modal'); + if (modal) modal.classList.remove('active'); + + if (activeIncomingCall) { + dismissedCallIds.add(activeIncomingCall.id); + activeIncomingCall = null; + } + + if (!silent && typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'info', + title: 'Call Missed / Declined', + text: 'You can click "Join Active Call" from the list whenever you are ready.', + timer: 3000, + showConfirmButton: false, + toast: true, + position: 'top-end' + }); + } +} + +export function subscribeLiveSync(interviewId) { + if (liveSyncChannel) { + try { liveSyncChannel.close(); } catch(e) {} + } + liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId); + liveSyncChannel.onmessage = function(event) { + const data = event.data; + if (!data) return; + + if (data.type === 'code_sync') { + if (data.language) { + const langEl = document.getElementById('modal-code-lang'); + if (langEl) langEl.innerText = data.language.toUpperCase(); + } + if (data.code !== undefined) { + const codeEl = document.getElementById('modal-code-display'); + if (codeEl) codeEl.innerText = data.code || '// Candidate has not typed any code yet.'; + } + } else if (data.type === 'notes_sync') { + if (data.notes !== undefined) { + const notesEl = document.getElementById('modal-notes-display'); + if (notesEl) notesEl.innerText = data.notes || 'No candidate notes written yet.'; + } + } else if (data.type === 'drawing_sync') { + const drawingImg = document.getElementById('modal-drawing-img'); + const noDrawing = document.getElementById('no-drawing-placeholder'); + if (data.drawing) { + if (drawingImg) { + drawingImg.src = data.drawing; + drawingImg.style.display = 'block'; + } + if (noDrawing) noDrawing.style.display = 'none'; + } else { + if (drawingImg) drawingImg.style.display = 'none'; + if (noDrawing) noDrawing.style.display = 'block'; + } + } else if (data.type === 'chat_message' || data.type === 'violation_logged' || data.type === 'violation_occurred') { + pollReviewData(); + } + }; + + if (interviewerSigChannel) { + try { interviewerSigChannel.close(); } catch(e) {} + } + interviewerSigChannel = new BroadcastChannel('interview_call_' + interviewId); + interviewerSigChannel.onmessage = function(event) { + const data = event.data; + if (!data) return; + if (data.type === 'violation_occurred' || data.type === 'violation_logged' || data.type === 'candidate_joined' || data.type === 'tab_switch') { + setTimeout(pollReviewData, 200); + } + }; +} + +export function openReviewModal(id, name, uid, autoJoinCall = false) { + currentInterviewId = id; + acknowledgedViolationCount = 0; + sosDismissed = false; + sosAutoTriggerDisabled = false; + + const toggleSw = document.getElementById('sosToggle'); + if (toggleSw) toggleSw.classList.add('on'); + + const candNameEl = document.getElementById('modal-cand-name'); + const candUidEl = document.getElementById('modal-cand-uid'); + if (candNameEl) candNameEl.innerText = name; + if (candUidEl) candUidEl.innerText = 'Unique Submission ID: ' + uid; + + const candInitials = getInitials(name); + const avatarCircle = document.getElementById('modal-cand-avatar-circle'); + const avatarName = document.getElementById('cand-avatar-name'); + if (avatarCircle) avatarCircle.innerText = candInitials; + if (avatarName) avatarName.innerText = name; + + const reviewModal = document.getElementById('review-modal'); + if (reviewModal) reviewModal.classList.add('active'); + + resetZoomScreen(); + switchIdeTab('code'); + + subscribeLiveSync(id); + pollReviewData(); + if (sosPollInterval) clearInterval(sosPollInterval); + sosPollInterval = setInterval(pollReviewData, 2500); + + if (autoJoinCall) { + setTimeout(() => { + startInterviewerCall(true); + }, 400); + } +} + +export function sendInterviewerPeerHeartbeat(action = 'heartbeat') { + if (!currentInterviewId) return; + const currentUserId = window.currentUserId || 0; + const currentUserName = window.currentUserName || 'Interviewer'; + const currentUserRole = window.currentUserRole || 'interviewer'; + const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + fetch(`/interviews/${currentInterviewId}/peer-heartbeat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + peer_id: myPeerId, + name: currentUserName, + role: currentUserRole, + action: action + }) + }).catch(() => {}); +} + +export function pollReviewData() { + if (!currentInterviewId) return; + if (interviewerLocalStream) { + sendInterviewerPeerHeartbeat('heartbeat'); + } + + fetch(`/interviews/${currentInterviewId}/poll`, { + headers: { 'Accept': 'application/json' } + }) + .then(r => { + if (!r.ok) return null; + const contentType = r.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) return null; + return r.json(); + }) + .then(data => { + if (!data) return; + + const currentUserId = window.currentUserId || 0; + const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; + + if (data.active_peers && Array.isArray(data.active_peers)) { + const activeIds = new Set(data.active_peers.map(p => p.peer_id)); + + data.active_peers.forEach(p => { + if (p.peer_id && p.peer_id !== myPeerId) { + let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || '')); + if (p.peer_id.startsWith('interviewer_')) { + if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { + if (!panelistMeshTiles.has(p.peer_id)) { + try { + const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel); + }); + } + } catch(e) {} + } + } + } + } + }); + + panelistMeshTiles.forEach((_, pid) => { + if (!activeIds.has(pid)) { + removePanelistTile(pid); + } + }); + } + + const codeLang = document.getElementById('modal-code-lang'); + const codeDisplay = document.getElementById('modal-code-display'); + const outputDisplay = document.getElementById('modal-output-display'); + const notesDisplay = document.getElementById('modal-notes-display'); + + if (codeLang) codeLang.innerText = (data.submitted_language || 'UNKNOWN').toUpperCase(); + if (codeDisplay) codeDisplay.innerText = data.submitted_code || '// Candidate has not typed any code yet.'; + if (outputDisplay) outputDisplay.innerText = data.code_output || 'No execution output.'; + if (notesDisplay) notesDisplay.innerText = data.candidate_notes || 'No candidate notes written yet.'; + + const drawingImg = document.getElementById('modal-drawing-img'); + const noDrawing = document.getElementById('no-drawing-placeholder'); + if (data.candidate_drawing) { + if (drawingImg) { + drawingImg.src = data.candidate_drawing; + drawingImg.style.display = 'block'; + } + if (noDrawing) noDrawing.style.display = 'none'; + } else { + if (drawingImg) drawingImg.style.display = 'none'; + if (noDrawing) noDrawing.style.display = 'block'; + } + + // Saved recordings + const recContainer = document.getElementById('modal-recordings-container'); + if (recContainer) { + const recPath = data.recording_path; + let recList = data.recordings || []; + if (recList.length === 0 && recPath) { + recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }]; + } + + if (recList.length > 0) { + recList.sort((a, b) => { + const timeA = a.created_at ? new Date(a.created_at).getTime() : 0; + const timeB = b.created_at ? new Date(b.created_at).getTime() : 0; + return timeB - timeA; + }); + + let recHtml = `
+ 📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''}) + 📜 Scroll to view older recordings +
`; + + recList.forEach((r, displayIdx) => { + let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath); + if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) { + videoUrl = '/' + videoUrl; + } + if (videoUrl && videoUrl.includes('/storage/')) { + videoUrl = videoUrl.replace('/storage/', '/media-stream/'); + } + const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx; + const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date(); + const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString(); + const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`/interviews/${currentInterviewId}/download-recording?index=${origIdx}`); + const mimeType = (typeof r === 'object' && r.mime_type) ? r.mime_type : ((videoUrl && videoUrl.includes('.mp4')) ? 'video/mp4' : 'video/webm'); + + recHtml += `
+
+
+ 📹 Session Recording #${recList.length - displayIdx} + 📅 ${dateStr} +
+ + 📥 Download Video + +
+ +
`; + }); + recContainer.innerHTML = recHtml; + } else { + recContainer.innerHTML = '
No video recordings saved for this candidate yet.
'; + } + } + + // Live Chat history + const chatHistory = document.getElementById('interviewer-chat-history'); + const interviewerColors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f97316']; + + if (chatHistory && data.warnings) { + if (data.warnings.length > 0) { + let chatHtml = ''; + data.warnings.forEach(w => { + const isCand = (w.sent_by === null || w.sent_by === undefined); + const senderName = isCand + ? (data.candidate_name || 'Candidate') + : (w.sender ? w.sender.name : 'Interviewer'); + + const color = isCand + ? '#38bdf8' + : interviewerColors[(w.sent_by || 1) % interviewerColors.length]; + + const badgeLabel = isCand ? 'Candidate' : 'Panelist'; + const badgeClass = isCand ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30'; + + chatHtml += `
+
+
+ + ${senderName} + ${badgeLabel} +
+ ${new Date(w.created_at || Date.now()).toLocaleTimeString()} +
+
${w.message}
+
`; + }); + chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2 mb-2.5'; + chatHistory.innerHTML = chatHtml; + chatHistory.scrollTop = chatHistory.scrollHeight; + } else { + chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 mb-2.5'; + chatHistory.innerHTML = '
No chat history. Send a message below.
'; + } + } + + currentTabScreenshotEnabled = (data.enable_tab_switch_screenshot !== undefined) ? Boolean(data.enable_tab_switch_screenshot) : true; + updateScreenshotToggleBtnUI(currentTabScreenshotEnabled); + + // Screenshots container + const shotContainer = document.getElementById('modal-screenshots-container'); + if (shotContainer) { + const shotList = data.tab_switch_screenshots || []; + if (shotList.length > 0) { + let shotHtml = `
+ 📸 Tab-Switch Screenshots Captured (${shotList.length}) + Click image to enlarge full size +
`; + + shotList.forEach((s) => { + let url = typeof s === 'string' ? s : (s.url || s.full_url); + if (url && !url.startsWith('http') && !url.startsWith('/')) { + url = '/' + url; + } + const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : ''; + shotHtml += `
+ +
📅 ${timeStr}
+ + 📥 View / Download + +
`; + }); + shotHtml += `
`; + shotContainer.innerHTML = shotHtml; + } else { + shotContainer.innerHTML = '
No tab-switch screenshots captured for this candidate yet.
'; + } + } + + // Header live pulse dot & buttons visibility + const livePulseDot = document.getElementById('live-call-pulse'); + const recStartBtn = document.getElementById('btn-start-recording'); + const recStopBtn = document.getElementById('btn-stop-recording'); + const startBtn = document.getElementById('btn-start-call'); + const leaveBtn = document.getElementById('btn-leave-call'); + const endAllBtn = document.getElementById('btn-end-all-call'); + const micBtn = document.getElementById('btn-toggle-interviewer-mic'); + const camBtn = document.getElementById('btn-toggle-interviewer-cam'); + + const timelinePill = document.getElementById('timeline-status-pill'); + + if (data.call_status) { + if (data.call_status === 'active') { + if (!interviewerLocalStream) { + if (livePulseDot) livePulseDot.style.display = 'none'; + if (startBtn) { + const icon = startBtn.querySelector('i'); + const span = startBtn.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-phone'; + if (span) span.innerText = 'Join Call'; + startBtn.style.display = 'inline-flex'; + } + if (leaveBtn) leaveBtn.style.display = 'none'; + if (endAllBtn) endAllBtn.style.display = 'none'; + if (micBtn) micBtn.style.display = 'none'; + if (camBtn) camBtn.style.display = 'none'; + + if (timelinePill) { + const icon = timelinePill.querySelector('i'); + const span = timelinePill.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse'; + if (span) span.innerText = 'Call in progress'; + timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]'; + } + } else { + if (livePulseDot) livePulseDot.style.display = 'inline-block'; + if (startBtn) startBtn.style.display = 'none'; + if (leaveBtn) leaveBtn.style.display = 'inline-flex'; + if (endAllBtn) endAllBtn.style.display = 'inline-flex'; + if (micBtn) micBtn.style.display = 'inline-flex'; + if (camBtn) camBtn.style.display = 'inline-flex'; + + if (timelinePill) { + const icon = timelinePill.querySelector('i'); + const span = timelinePill.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse'; + if (span) span.innerText = 'Connected (Live)'; + timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]'; + } + } + if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) { + recStartBtn.style.display = 'inline-flex'; + } + } else if (data.call_status === 'ended' || data.call_status === 'idle') { + if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall(); + if (livePulseDot) livePulseDot.style.display = 'none'; + if (startBtn) { + const icon = startBtn.querySelector('i'); + const span = startBtn.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-phone'; + if (span) span.innerText = (data.call_status === 'ended') ? 'Restart call' : 'Start / Join Call'; + startBtn.style.display = 'inline-flex'; + } + if (leaveBtn) leaveBtn.style.display = 'none'; + if (endAllBtn) endAllBtn.style.display = 'none'; + if (micBtn) micBtn.style.display = 'none'; + if (camBtn) camBtn.style.display = 'none'; + if (recStartBtn) recStartBtn.style.display = 'none'; + if (recStopBtn) recStopBtn.style.display = 'none'; + + if (timelinePill && data.call_status === 'ended') { + const icon = timelinePill.querySelector('i'); + const span = timelinePill.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-circle text-[8px]'; + if (span) span.innerText = 'Call ended'; + timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]'; + } + } + } + + // Proctoring logs + const logsDisplay = document.getElementById('modal-logs-display'); + let logsHtml = ''; + let currentViolations = []; + + if (data.proctor_logs && data.proctor_logs.length > 0) { + data.proctor_logs.forEach(l => { + let icoClass = 'amber'; + let faIcon = 'fa-solid fa-window-restore'; + let isFlag = false; + + if (l.type === 'focus_lost') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; } + else if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye'; } + else if (l.type === 'gaze_lower_device' || l.type === 'reading_external_device') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen'; isFlag = true; } + else if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') { icoClass = 'red'; faIcon = 'fa-solid fa-comments'; isFlag = true; } + else if (l.type === 'talking_on_phone') { icoClass = 'red'; faIcon = 'fa-solid fa-phone'; isFlag = true; } + else if (l.type === 'external_ai_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-robot'; isFlag = true; } + else if (l.type === 'tab_switch') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; } + + if (['paste_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'].includes(l.type)) { + currentViolations.push(l); + } + + let screenshotLink = l.screenshot_url ? `[ Screenshot]` : ''; + let aiBadge = ''; + + if (l.ai_verdict) { + const confPct = Math.round((l.ai_verdict.confidence || 0.85) * 100); + const engineName = l.ai_verdict.engine || 'AI Engine'; + const toolName = l.ai_verdict.ai_tool_detected ? ` • Detected: ${l.ai_verdict.ai_tool_detected}` : ''; + aiBadge = `
AI inspector · ${confPct}% cheating confidence (${engineName}${toolName})
`; + isFlag = true; + } + + const formattedType = (l.type || 'violation').replace(/_/g, ' '); + const titleCap = formattedType.charAt(0).toUpperCase() + formattedType.slice(1); + + logsHtml += `
+
+ +
+
+
${titleCap} — ${l.details || ''}${screenshotLink}${aiBadge}
+
${new Date(l.timestamp).toLocaleTimeString()}
+
+
`; + }); + } else { + logsHtml = '
Zero proctoring violations recorded.
'; + } + if (logsDisplay) logsDisplay.innerHTML = logsHtml; + + activeViolations = currentViolations; + const totalViolations = currentViolations.length; + + // Trigger SOS Modal Popup and Ringtone Alarm ONLY if NEW unacknowledged violations occurred & SOS Auto-Trigger is active + if (totalViolations > 0 && totalViolations > acknowledgedViolationCount && !sosAutoTriggerDisabled) { + sosDismissed = false; + const newViolations = currentViolations.slice(acknowledgedViolationCount); + openSosModal(newViolations); + } + }) + .catch(() => {}); +} + +export function openSosModal(specificViolations = null) { + let violationsToDisplay = specificViolations; + if (!violationsToDisplay || violationsToDisplay.length === 0) { + if (acknowledgedViolationCount > 0 && activeViolations.length > acknowledgedViolationCount) { + violationsToDisplay = activeViolations.slice(acknowledgedViolationCount); + } else { + violationsToDisplay = activeViolations; + } + } + + let html = ''; + if (violationsToDisplay && violationsToDisplay.length > 0) { + violationsToDisplay.forEach(v => { + let typeTitle = (v.type || '').toUpperCase(); + if (v.type === 'external_ai_detected') { + typeTitle = 'candidate might use external ai'; + } else if (v.type === 'tab_switch') { + typeTitle = 'Candidate Switched Browser Tab'; + } else if (v.type === 'talking_on_phone') { + typeTitle = 'Candidate Talking on Phone'; + } else if (v.type === 'reading_external_device') { + typeTitle = 'Candidate Reading from External Device'; + } else if (v.type === 'question_repetition' || v.type === 'question_repeat_lower') { + typeTitle = 'Candidate Reading Question Out Loud'; + } else if (v.type === 'gaze_anomaly' || v.type === 'gaze_lower_device') { + typeTitle = 'Candidate Lower Eye Gaze Detected'; + } + const borderCol = v.type === 'external_ai_detected' ? 'border-red-500' : 'border-amber-500'; + const textCol = v.type === 'external_ai_detected' ? 'text-red-300' : 'text-amber-300'; + + html += `
+ 🚨 ${typeTitle}: ${v.details || 'Suspicious activity detected.'} +
Timestamp: ${new Date(v.timestamp).toLocaleTimeString()}
+
`; + }); + } else { + html = '
Potential proctoring alert flagged by engine.
'; + } + const listEl = document.getElementById('sos-violation-list'); + const modalEl = document.getElementById('sos-modal'); + if (listEl) listEl.innerHTML = html; + if (modalEl) modalEl.classList.add('active'); + + try { + callRingtone.start(); + } catch(e) {} +} + +export function stopSosAlarm() { + try { + callRingtone.stop(); + } catch(e) {} + + const modalEl = document.getElementById('sos-modal'); + if (modalEl) modalEl.classList.remove('active'); + + acknowledgedViolationCount = activeViolations.length; + sosDismissed = true; +} + +export function interviewerLogoutAction() { + const form = document.getElementById('logout-form'); + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + title: 'Logout of Portal?', + text: 'Are you sure you want to log out of your session?', + icon: 'question', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#475569', + confirmButtonText: 'Yes, Logout' + }).then((result) => { + if (result.isConfirmed && form) { + form.submit(); + } + }); + } else if (form) { + form.submit(); + } +} + +export function regenerateCandidatePassword() { + if (!currentInterviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + title: 'Regenerate Candidate Password?', + text: 'This will invalidate the candidate\'s current password and issue a new system password.', + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#eab308', + cancelButtonColor: '#475569', + confirmButtonText: 'Regenerate Now' + }).then((result) => { + if (result.isConfirmed) { + fetch(`/interviews/${currentInterviewId}/regenerate-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + } + }) + .then(r => r.json()) + .then(res => { + if (res.success) { + window.Swal.fire({ + icon: 'success', + title: 'New Password Generated!', + text: 'New Candidate Passcode: ' + res.new_password, + confirmButtonColor: '#6366f1' + }).then(() => window.location.reload()); + } + }); + } + }); + } +} + +export function blockCandidateAction() { + if (!currentInterviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + title: '⛔ Block Candidate Email & IP?', + text: 'This will immediately terminate the candidate session and block their email and client IP address.', + icon: 'error', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#475569', + confirmButtonText: 'Yes, Block Candidate' + }).then((result) => { + if (result.isConfirmed) { + fetch(`/interviews/${currentInterviewId}/block-candidate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + } + }) + .then(r => r.json()) + .then(res => { + if (res.success) { + window.Swal.fire({ + icon: 'success', + title: 'Candidate Blocked', + text: res.message, + confirmButtonColor: '#6366f1' + }).then(() => { + closeReviewModal(); + window.location.reload(); + }); + } + }); + } + }); + } +} + +export function initMeteredIceServers() { + if (typeof window.getMeteredIceServers === 'function') { + return window.getMeteredIceServers().then(servers => { + if (Array.isArray(servers) && servers.length > 0) { + globalIceServers = servers; + } + return globalIceServers; + }); + } + return Promise.resolve(globalIceServers); +} + +export function safePlayMediaStream(videoElem, stream, isSelf = false) { + if (!videoElem || !stream) return; + if (isSelf) videoElem.muted = true; + if (videoElem.srcObject !== stream) { + videoElem.srcObject = stream; + } + const playPromise = videoElem.play(); + if (playPromise !== undefined) { + playPromise.catch(err => { + if (err.name === 'AbortError' || err.name === 'NotAllowedError') { + videoElem.onloadedmetadata = () => { + videoElem.play().catch(() => {}); + }; + } + }); + } +} + +export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist') { + const currentUserId = window.currentUserId || 0; + if (!peerId || peerId === ('interviewer_' + currentInterviewId + '_' + currentUserId)) return; + const grid = document.getElementById('panelist-mesh-grid'); + if (!grid) return; + + let tile = document.getElementById('panelist-tile-' + peerId); + let videoElem; + + if (!tile) { + tile = document.createElement('div'); + tile.id = 'panelist-tile-' + peerId; + tile.className = 'relative bg-[#050811] rounded-xl border-2 border-indigo-500 overflow-hidden h-[220px]'; + + videoElem = document.createElement('video'); + videoElem.id = 'panelist-video-' + peerId; + videoElem.autoplay = true; + videoElem.playsInline = true; + videoElem.className = 'w-full h-full object-cover'; + + const labelDiv = document.createElement('div'); + labelDiv.className = 'absolute bottom-1.5 left-1.5 bg-indigo-600/85 px-2 py-0.5 rounded text-[0.65rem] text-white font-semibold z-10'; + labelDiv.innerText = '🎙️ ' + name; + + tile.appendChild(videoElem); + tile.appendChild(labelDiv); + grid.appendChild(tile); + } else { + videoElem = document.getElementById('panelist-video-' + peerId); + } + + if (videoElem && stream) { + safePlayMediaStream(videoElem, stream); + } + + panelistMeshTiles.set(peerId, stream); +} + +export function removePanelistTile(peerId) { + const tile = document.getElementById('panelist-tile-' + peerId); + if (tile) tile.remove(); + panelistMeshTiles.delete(peerId); +} + +export async function initInterviewerPeer() { + if (!currentInterviewId || typeof window.Peer === 'undefined') return; + await initMeteredIceServers(); + + const currentUserId = window.currentUserId || 0; + const currentUserName = window.currentUserName || 'Interviewer'; + const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; + + if (interviewerPeer && !interviewerPeer.destroyed) { + try { interviewerPeer.destroy(); } catch(e) {} + } + + interviewerPeer = new window.Peer(myPeerId, { + config: { iceServers: globalIceServers } + }); + + interviewerPeer.on('open', id => { + if (interviewerSigChannel) { + interviewerSigChannel.postMessage({ + type: 'peer_joined', + peer_id: id, + user_id: currentUserId, + user_name: currentUserName, + role: 'interviewer' + }); + } + }); + + interviewerPeer.on('call', call => { + const sendStream = interviewerLocalStream || new MediaStream(); + try { call.answer(sendStream); } catch(e) {} + + call.on('stream', remoteStream => { + if (call.peer === 'cand_' + currentInterviewId) { + const candVid = document.getElementById('interviewer-cand-video'); + const placeholder = document.getElementById('cand-video-placeholder'); + if (candVid) safePlayMediaStream(candVid, remoteStream); + if (placeholder) placeholder.style.display = 'none'; + } else { + addOrUpdatePanelistTile(call.peer, remoteStream, 'Panelist'); + } + + const badge = document.getElementById('interviewer-call-status'); + if (badge) { + badge.innerText = 'CONNECTED (LIVE)'; + badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; + } + }); + }); + + const screenPeerId = 'interviewer_screen_' + currentInterviewId; + if (screenReceiverPeer && !screenReceiverPeer.destroyed) { + try { screenReceiverPeer.destroy(); } catch(e) {} + } + screenReceiverPeer = new window.Peer(screenPeerId, { + config: { iceServers: globalIceServers } + }); + screenReceiverPeer.on('call', call => { + call.answer(); + call.on('stream', screenStream => { + const screenVid = document.getElementById('interviewer-screen-video'); + const placeholder = document.getElementById('screen-video-placeholder'); + if (screenVid) safePlayMediaStream(screenVid, screenStream); + if (placeholder) placeholder.style.display = 'none'; + }); + }); +} + +export function initInterviewerSigChannel() { + if (!currentInterviewId) return; + if (interviewerSigChannel) interviewerSigChannel.close(); + + interviewerSigChannel = new BroadcastChannel('webrtc_call_' + currentInterviewId); + const currentUserId = window.currentUserId || 0; + const currentUserName = window.currentUserName || 'Interviewer'; + const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; + + interviewerSigChannel.onmessage = async (event) => { + const msg = event.data; + if (!msg) return; + + if (msg.type === 'violation_occurred') { + pollReviewData(); + } + + if (msg.type === 'peer_joined' || msg.type === 'candidate_ready' || msg.type === 'peer_presence_ack' || msg.type === 'interviewer_joined') { + if (msg.peer_id && msg.peer_id !== myPeerId) { + if (msg.type === 'peer_joined') { + interviewerSigChannel.postMessage({ + type: 'peer_presence_ack', + peer_id: myPeerId, + user_id: currentUserId, + user_name: currentUserName, + role: 'interviewer' + }); + } + + if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { + try { + const outCall = interviewerPeer.call(msg.peer_id, interviewerLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + if (msg.peer_id === 'cand_' + currentInterviewId) { + const candVid = document.getElementById('interviewer-cand-video'); + const placeholder = document.getElementById('cand-video-placeholder'); + if (candVid) safePlayMediaStream(candVid, remoteStream); + if (placeholder) placeholder.style.display = 'none'; + } else { + addOrUpdatePanelistTile(msg.peer_id, remoteStream, msg.user_name || 'Panelist'); + } + + const badge = document.getElementById('interviewer-call-status'); + if (badge) { + badge.innerText = 'CONNECTED (LIVE)'; + badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; + } + }); + } + } catch(e) {} + } + } + } else if (msg.type === 'peer_left') { + if (msg.peer_id === 'cand_' + currentInterviewId) { + const placeholder = document.getElementById('cand-video-placeholder'); + if (placeholder) placeholder.style.display = 'flex'; + } else if (msg.peer_id) { + removePanelistTile(msg.peer_id); + } + } else if (msg.type === 'answer' && msg.sender === 'candidate' && interviewerPeerConnection) { + await interviewerPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer)); + } else if (msg.type === 'ice-candidate' && msg.sender === 'candidate' && interviewerPeerConnection && msg.candidate) { + try { + await interviewerPeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate)); + } catch(e) {} + } else if (msg.type === 'end_call_all') { + leaveInterviewerCall(false); + } + }; +} + +export function startInterviewerCall(isRejoin = false) { + if (!currentInterviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + const currentUserName = window.currentUserName || 'Interviewer'; + + if (!isRejoin) { + iAmTheCaller = true; + const candNameEl = document.getElementById('modal-cand-name'); + const candName = candNameEl ? candNameEl.innerText : 'Candidate'; + if (!globalCallChannel) { + globalCallChannel = new BroadcastChannel('global_call_alerts'); + } + globalCallChannel.postMessage({ + type: 'incoming_call', + interview_id: currentInterviewId, + candidate_name: candName, + starter_name: currentUserName, + call_started_at: new Date().toISOString() + }); + } + + initInterviewerSigChannel(); + + fetch(`/interviews/${currentInterviewId}/call-status`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ call_status: 'active', is_rejoin: isRejoin }) + }); + + const handleStream = (stream) => { + interviewerLocalStream = stream; + const selfVid = document.getElementById('interviewer-self-video'); + const selfPlace = document.getElementById('self-video-placeholder'); + if (selfVid) { + selfVid.muted = true; + safePlayMediaStream(selfVid, stream, true); + } + if (selfPlace) selfPlace.style.display = 'none'; + + const micBtn = document.getElementById('btn-toggle-interviewer-mic'); + const camBtn = document.getElementById('btn-toggle-interviewer-cam'); + const startBtn = document.getElementById('btn-start-call'); + const leaveBtn = document.getElementById('btn-leave-call'); + const endAllBtn = document.getElementById('btn-end-all-call'); + + if (micBtn) micBtn.style.display = 'inline-flex'; + if (camBtn) camBtn.style.display = 'inline-flex'; + if (startBtn) startBtn.style.display = 'none'; + if (leaveBtn) leaveBtn.style.display = 'inline-flex'; + if (endAllBtn) endAllBtn.style.display = 'inline-flex'; + + if (interviewerSigChannel) { + interviewerSigChannel.postMessage({ type: 'interviewer_joined', sender: 'interviewer' }); + } + + if (!interviewerPeer || interviewerPeer.destroyed) { + initInterviewerPeer(); + } + + const makePeerJSCall = () => { + if (!interviewerPeer || interviewerPeer.destroyed) return; + const candPeerId = 'cand_' + currentInterviewId; + activeCall = interviewerPeer.call(candPeerId, stream); + + if (activeCall) { + activeCall.on('stream', candRemoteStream => { + const candVid = document.getElementById('interviewer-cand-video'); + const placeholder = document.getElementById('cand-video-placeholder'); + if (candVid) safePlayMediaStream(candVid, candRemoteStream); + if (placeholder) placeholder.style.display = 'none'; + + candRemoteStream.onaddtrack = () => { + if (placeholder) placeholder.style.display = 'none'; + if (candVid) safePlayMediaStream(candVid, candRemoteStream); + }; + + const badge = document.getElementById('interviewer-call-status'); + if (badge) { + badge.innerText = 'CONNECTED (LIVE)'; + badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; + } + }); + } + }; + + if (interviewerPeer && interviewerPeer.open) { + makePeerJSCall(); + } else if (interviewerPeer) { + interviewerPeer.on('open', makePeerJSCall); + } + }; + + const audioConstraints = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + }; + + navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints }) + .then(handleStream) + .catch(() => { + navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + .then(handleStream) + .catch(() => { + navigator.mediaDevices.getUserMedia({ video: false, audio: true }) + .then(handleStream) + .catch(() => { + navigator.mediaDevices.getUserMedia({ video: true, audio: false }) + .then(handleStream) + .catch(err => console.log('WebRTC Stream init error:', err)); + }); + }); + }); +} + +export function leaveInterviewerCall(isExplicitEnd = true) { + sendInterviewerPeerHeartbeat('leave'); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + if (currentInterviewId && isExplicitEnd) { + const endingId = currentInterviewId; + endedCallIds.add(endingId); + dismissedCallIds.add(endingId); + + if (globalCallChannel) { + try { + globalCallChannel.postMessage({ + type: 'call_ended', + interview_id: endingId + }); + } catch(e) {} + } + + if (interviewerSigChannel) { + try { + interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId }); + } catch(e) {} + } + + fetch(`/interviews/${endingId}/call-status`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ call_status: 'ended' }) + }).catch(() => {}); + } + + if (interviewerLocalStream) { + try { + interviewerLocalStream.getTracks().forEach(t => t.stop()); + } catch(e) {} + interviewerLocalStream = null; + } + if (activeCall) { + try { activeCall.close(); } catch(e) {} + activeCall = null; + } + if (interviewerPeer) { + try { interviewerPeer.destroy(); } catch(e) {} + interviewerPeer = null; + } + if (screenReceiverPeer) { + try { screenReceiverPeer.destroy(); } catch(e) {} + screenReceiverPeer = null; + } + + const candPlace = document.getElementById('cand-video-placeholder'); + const screenPlace = document.getElementById('screen-video-placeholder'); + const selfPlace = document.getElementById('self-video-placeholder'); + if (candPlace) candPlace.style.display = 'flex'; + if (screenPlace) screenPlace.style.display = 'flex'; + if (selfPlace) selfPlace.style.display = 'flex'; + + const candVid = document.getElementById('interviewer-cand-video'); + if (candVid) candVid.srcObject = null; + + const selfVid = document.getElementById('interviewer-self-video'); + if (selfVid) selfVid.srcObject = null; + + const livePulseDot = document.getElementById('live-call-pulse'); + if (livePulseDot) livePulseDot.style.display = 'none'; + + const startBtn = document.getElementById('btn-start-call'); + if (startBtn) { + const icon = startBtn.querySelector('i'); + const span = startBtn.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-phone'; + if (span) span.innerText = 'Restart Call'; + startBtn.style.display = 'inline-flex'; + } + + const leaveBtn = document.getElementById('btn-leave-call'); + if (leaveBtn) leaveBtn.style.display = 'none'; + + const endAllBtn = document.getElementById('btn-end-all-call'); + if (endAllBtn) endAllBtn.style.display = 'none'; + + const micBtn = document.getElementById('btn-toggle-interviewer-mic'); + if (micBtn) micBtn.style.display = 'none'; + + const camBtn = document.getElementById('btn-toggle-interviewer-cam'); + if (camBtn) camBtn.style.display = 'none'; + + const timelinePill = document.getElementById('timeline-status-pill'); + if (timelinePill) { + const icon = timelinePill.querySelector('i'); + const span = timelinePill.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-circle text-[8px]'; + if (span) span.innerText = 'Call ended'; + timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]'; + } +} + +export function endCallForAll() { + const executeEndCall = () => { + callRingtone.stop(); + const modal = document.getElementById('incoming-call-modal'); + if (modal) modal.classList.remove('active'); + activeIncomingCall = null; + iAmTheCaller = false; + leaveInterviewerCall(true); + }; + + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + title: '🛑 End Call for All Participants?', + text: 'This will terminate the 2-way call session for candidate and all panelists.', + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#475569', + confirmButtonText: 'Yes, End Call for All' + }).then((result) => { + if (result.isConfirmed) { + executeEndCall(); + } + }); + } else { + if (confirm('End Call for All Participants?')) { + executeEndCall(); + } + } +} + +// MediaRecorder recording +let adminMediaRecorder = null; +let adminRecordedChunks = []; +let recAudioContext = null; + +export function startCallRecording() { + if (!currentInterviewId) { + alert('Please open an active candidate interview session first.'); + return; + } + + const candVid = document.getElementById('interviewer-cand-video'); + const candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null; + const streamToRecord = new MediaStream(); + + if (candidateStream) { + candidateStream.getVideoTracks().forEach(track => { + if (track.readyState === 'live') { + try { streamToRecord.addTrack(track); } catch(e) {} + } + }); + } + + if (streamToRecord.getVideoTracks().length === 0 && interviewerLocalStream) { + interviewerLocalStream.getVideoTracks().forEach(track => { + if (track.readyState === 'live') { + try { streamToRecord.addTrack(track); } catch(e) {} + } + }); + } + + const candAudioTracks = candidateStream ? candidateStream.getAudioTracks().filter(t => t.readyState === 'live') : []; + const adminAudioTracks = interviewerLocalStream ? interviewerLocalStream.getAudioTracks().filter(t => t.readyState === 'live') : []; + + if (candAudioTracks.length > 0 || adminAudioTracks.length > 0) { + try { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (recAudioContext) { + try { recAudioContext.close(); } catch(e) {} + } + recAudioContext = new AudioContextClass(); + const audioDestination = recAudioContext.createMediaStreamDestination(); + + if (candAudioTracks.length > 0) { + const candAudioStream = new MediaStream(candAudioTracks); + const candSource = recAudioContext.createMediaStreamSource(candAudioStream); + candSource.connect(audioDestination); + } + + if (adminAudioTracks.length > 0) { + const adminAudioStream = new MediaStream(adminAudioTracks); + const adminSource = recAudioContext.createMediaStreamSource(adminAudioStream); + adminSource.connect(audioDestination); + } + + const mixedAudioTrack = audioDestination.stream.getAudioTracks()[0]; + if (mixedAudioTrack) { + streamToRecord.addTrack(mixedAudioTrack); + } + } catch(e) { + const fallbackAudio = candAudioTracks[0] || adminAudioTracks[0]; + if (fallbackAudio) { + try { streamToRecord.addTrack(fallbackAudio); } catch(err) {} + } + } + } + + const activeTracks = streamToRecord.getTracks().filter(t => t.readyState === 'live'); + if (activeTracks.length === 0) { + alert('No active video/audio stream available to record. Please start or join the call first.'); + return; + } + + adminRecordedChunks = []; + try { + let options = {}; + if (typeof MediaRecorder.isTypeSupported === 'function') { + if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) { + options = { mimeType: 'video/webm;codecs=vp8,opus' }; + } else if (MediaRecorder.isTypeSupported('video/webm')) { + options = { mimeType: 'video/webm' }; + } + } + + try { + adminMediaRecorder = new MediaRecorder(streamToRecord, options); + } catch(errOpt) { + adminMediaRecorder = new MediaRecorder(streamToRecord); + } + + adminMediaRecorder.ondataavailable = function(e) { + if (e.data && e.data.size > 0) { + adminRecordedChunks.push(e.data); + } + }; + + adminMediaRecorder.onstop = function() { + if (recAudioContext) { + try { recAudioContext.close(); } catch(e) {} + recAudioContext = null; + } + if (adminRecordedChunks.length === 0) return; + const recMime = adminMediaRecorder.mimeType || 'video/webm'; + const isMp4 = recMime.includes('mp4'); + const ext = isMp4 ? 'mp4' : 'webm'; + const blobType = isMp4 ? 'video/mp4' : 'video/webm'; + + const blob = new Blob(adminRecordedChunks, { type: blobType }); + uploadRecordedBlob(blob, ext); + }; + + adminMediaRecorder.start(1000); + + const recStartBtn = document.getElementById('btn-start-recording'); + const recStopBtn = document.getElementById('btn-stop-recording'); + if (recStartBtn) recStartBtn.style.display = 'none'; + if (recStopBtn) recStopBtn.style.display = 'inline-flex'; + + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'info', + title: 'Recording Started', + text: 'Interview call recording is active (silent mode - candidate is not notified).', + toast: true, + position: 'top-end', + timer: 3000, + showConfirmButton: false + }); + } + } catch(e) { + alert('Recording failed to initialize: ' + e.message); + } +} + +export function stopCallRecording() { + if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') { + adminMediaRecorder.stop(); + } + const recStartBtn = document.getElementById('btn-start-recording'); + const recStopBtn = document.getElementById('btn-stop-recording'); + if (recStartBtn) recStartBtn.style.display = 'inline-flex'; + if (recStopBtn) recStopBtn.style.display = 'none'; +} + +export function uploadRecordedBlob(blob, ext = 'webm') { + if (!currentInterviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + const formData = new FormData(); + formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.${ext}`); + formData.append('_token', csrfToken); + + fetch(`/candidate/upload-recording/${currentInterviewId}`, { + method: 'POST', + body: formData + }) + .then(r => r.json()) + .then(res => { + if (res.success) { + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'success', + title: 'Recording Saved!', + text: 'Call recording saved successfully under Candidate Profile and Report.', + toast: true, + position: 'top-end', + timer: 3500, + showConfirmButton: false + }); + } + pollReviewData(); + } + }) + .catch(err => console.log('Recording upload error:', err)); +} + +export function toggleInterviewerMic() { + if (!interviewerLocalStream) return; + const audioTracks = interviewerLocalStream.getAudioTracks(); + if (audioTracks.length === 0) return; + + isInterviewerMicOn = !isInterviewerMicOn; + audioTracks[0].enabled = isInterviewerMicOn; + + const btn = document.getElementById('btn-toggle-interviewer-mic'); + if (!btn) return; + + const icon = btn.querySelector('i'); + if (icon) { + icon.className = isInterviewerMicOn ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'; + } + + btn.title = isInterviewerMicOn ? 'Mic On (Click to Mute)' : 'Mic Muted (Click to Unmute)'; + + if (isInterviewerMicOn) { + btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); + btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); + } else { + btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); + btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); + } +} + +export function toggleInterviewerCam() { + const selfPlaceholder = document.getElementById('self-video-placeholder'); + const selfVid = document.getElementById('interviewer-self-video'); + + isInterviewerCamOn = !isInterviewerCamOn; + + const btn = document.getElementById('btn-toggle-interviewer-cam'); + if (btn) { + const icon = btn.querySelector('i'); + if (icon) { + icon.className = isInterviewerCamOn ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'; + } + btn.title = isInterviewerCamOn ? 'Cam On (Click to Turn Off)' : 'Cam Off (Click to Turn On)'; + + if (isInterviewerCamOn) { + btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); + btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); + } else { + btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); + btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); + } + } + + if (interviewerLocalStream) { + const videoTracks = interviewerLocalStream.getVideoTracks(); + if (videoTracks.length > 0) { + videoTracks[0].enabled = isInterviewerCamOn; + } + } else if (isInterviewerCamOn) { + navigator.mediaDevices.getUserMedia({ video: true, audio: false }) + .then(stream => { + interviewerLocalStream = stream; + if (selfVid) { + selfVid.srcObject = stream; + selfVid.play().catch(() => {}); + } + if (selfPlaceholder) selfPlaceholder.style.display = 'none'; + }) + .catch(() => {}); + return; + } + + if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex'; +} + +export function closeReviewModal() { + if (sosPollInterval) clearInterval(sosPollInterval); + if (liveSyncChannel) { + try { liveSyncChannel.close(); } catch(e) {} + liveSyncChannel = null; + } + leaveInterviewerCall(true); + const modal = document.getElementById('review-modal'); + if (modal) modal.classList.remove('active'); +} + +export function sendSilentWarning() { + const input = document.getElementById('warning-input'); + if (!input) return; + const msg = input.value.trim(); + if (!msg || !currentInterviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + input.value = ''; + if (liveSyncChannel) { + liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'interviewer' }); + } + + fetch(`/interviews/${currentInterviewId}/warning`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ message: msg }) + }) + .then(r => r.json()) + .then(() => { + pollReviewData(); + }); +} + +export function setCurrentInterviewId(id) { + currentInterviewId = id; +} + +// Bind functions onto window object for template inline onclick handlers +if (typeof window !== 'undefined') { + window.CallRingtoneEngine = CallRingtoneEngine; + window.callRingtone = callRingtone; + window.getInitials = getInitials; + window.zoomScreen = zoomScreen; + window.resetZoomScreen = resetZoomScreen; + window.toggleFullscreenScreen = toggleFullscreenScreen; + window.zoomCandVideo = zoomCandVideo; + window.toggleSosAutoTrigger = toggleSosAutoTrigger; + window.openReportPage = openReportPage; + window.toggleTabScreenshotCapture = toggleTabScreenshotCapture; + window.updateScreenshotToggleBtnUI = updateScreenshotToggleBtnUI; + window.switchIdeTab = switchIdeTab; + window.triggerIncomingCallRing = triggerIncomingCallRing; + window.pollGlobalActiveCalls = pollGlobalActiveCalls; + window.acceptIncomingCall = acceptIncomingCall; + window.declineIncomingCall = declineIncomingCall; + window.subscribeLiveSync = subscribeLiveSync; + window.openReviewModal = openReviewModal; + window.sendInterviewerPeerHeartbeat = sendInterviewerPeerHeartbeat; + window.pollReviewData = pollReviewData; + window.openSosModal = openSosModal; + window.stopSosAlarm = stopSosAlarm; + window.interviewerLogoutAction = interviewerLogoutAction; + window.regenerateCandidatePassword = regenerateCandidatePassword; + window.blockCandidateAction = blockCandidateAction; + window.startInterviewerCall = startInterviewerCall; + window.leaveInterviewerCall = leaveInterviewerCall; + window.endCallForAll = endCallForAll; + window.startCallRecording = startCallRecording; + window.stopCallRecording = stopCallRecording; + window.toggleInterviewerMic = toggleInterviewerMic; + window.toggleInterviewerCam = toggleInterviewerCam; + window.closeReviewModal = closeReviewModal; + window.sendSilentWarning = sendSilentWarning; + window.setCurrentInterviewId = setCurrentInterviewId; + + // Attach listeners + ['click', 'keydown', 'touchstart'].forEach(evt => { + document.addEventListener(evt, () => callRingtone.init(), { passive: true }); + }); + + globalCallChannel = new BroadcastChannel('global_call_alerts'); + globalCallChannel.onmessage = (event) => { + const data = event.data; + if (!data) return; + + if (data.type === 'incoming_call') { + triggerIncomingCallRing({ + id: data.interview_id, + candidate_name: data.candidate_name, + submission_unique_id: data.submission_unique_id, + starter_name: data.starter_name, + call_started_at: data.call_started_at || new Date().toISOString() + }, true); + } else if (data.type === 'call_ended') { + endedCallIds.add(data.interview_id); + dismissedCallIds.add(data.interview_id); + callRingtone.stop(); + if (ringTimeoutTimer) { + clearTimeout(ringTimeoutTimer); + ringTimeoutTimer = null; + } + const modal = document.getElementById('incoming-call-modal'); + if (modal) modal.classList.remove('active'); + if (activeIncomingCall && activeIncomingCall.id == data.interview_id) { + activeIncomingCall = null; + } + } + }; + + setInterval(pollGlobalActiveCalls, 1500); +} diff --git a/resources/views/components/audit-log-item.blade.php b/resources/views/components/audit-log-item.blade.php new file mode 100644 index 0000000..f86ee70 --- /dev/null +++ b/resources/views/components/audit-log-item.blade.php @@ -0,0 +1,55 @@ +@props([ + 'type' => 'violation', + 'details' => '', + 'timestamp' => null, + 'screenshotUrl' => null, + 'aiVerdict' => null, +]) + +@php + $icoClass = 'amber'; + $faIcon = 'fa-solid fa-window-restore'; + $isFlag = false; + + if ($type === 'focus_lost') { + $icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore'; + } elseif (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) { + $icoClass = 'amber'; $faIcon = 'fa-solid fa-eye'; + } elseif (in_array($type, ['gaze_lower_device', 'reading_external_device'])) { + $icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen'; $isFlag = true; + } elseif (in_array($type, ['question_repeat_lower', 'question_repetition'])) { + $icoClass = 'red'; $faIcon = 'fa-solid fa-comments'; $isFlag = true; + } elseif ($type === 'talking_on_phone') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-phone'; $isFlag = true; + } elseif ($type === 'external_ai_detected') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-robot'; $isFlag = true; + } elseif ($type === 'tab_switch') { + $icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore'; + } + + $titleCap = ucfirst(str_replace('_', ' ', $type)); + $timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('H:i:s') : now()->format('H:i:s'); +@endphp + +
merge(['class' => 'log-item flex gap-2.5 p-3 border-b border-[#1b2233] last:border-b-0 ' . ($isFlag ? 'bg-[rgba(239,74,95,0.1)] flag' : '')]) }}> +
+ +
+
+
+ {{ $titleCap }} — {{ $details }} + @if($screenshotUrl) + [ Screenshot] + @endif + @if($aiVerdict) + @php + $confPct = round(($aiVerdict['confidence'] ?? 0.85) * 100); + $engineName = $aiVerdict['engine'] ?? 'AI Engine'; + $toolName = !empty($aiVerdict['ai_tool_detected']) ? ' • Detected: ' . e($aiVerdict['ai_tool_detected']) . '' : ''; + @endphp +
AI inspector · {{ $confPct }}% cheating confidence ({!! $engineName . $toolName !!})
+ @endif +
+
{{ $timeStr }}
+
+
diff --git a/resources/views/components/audit-log.blade.php b/resources/views/components/audit-log.blade.php new file mode 100644 index 0000000..21000a0 --- /dev/null +++ b/resources/views/components/audit-log.blade.php @@ -0,0 +1,21 @@ +@props([ + 'logs' => [], +]) + +
merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] max-h-[260px] overflow-y-auto']) }}> + @if(is_array($logs) && count($logs) > 0) + @foreach($logs as $log) + + @endforeach + @else +
+ Zero proctoring violations recorded. +
+ @endif +
diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php new file mode 100644 index 0000000..a025502 --- /dev/null +++ b/resources/views/components/button.blade.php @@ -0,0 +1,41 @@ +@props([ + 'variant' => 'primary', // primary, secondary, danger, warning, success, ghost + 'size' => 'md', // sm, md, lg + 'type' => 'button', + 'icon' => null, +]) + +@php + $baseClasses = 'inline-flex items-center gap-1.5 rounded-lg border font-medium text-xs transition-all duration-150 cursor-pointer select-none whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed'; + + $variants = [ + 'default' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]', + 'secondary' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]', + 'primary' => 'bg-[#4b82f7] text-white border-[#4b82f7] hover:bg-[#3d6fe0] shadow-sm', + 'success' => 'bg-[#21c274] text-white border-[#21c274] hover:bg-[#1da865] shadow-sm', + 'danger' => 'bg-[#ef4a5f] text-white border-[#ef4a5f] hover:bg-[#d63d51] shadow-sm', + 'warning' => 'bg-[#f0a939] text-white border-[#f0a939] hover:bg-[#d8952d] shadow-sm', + 'ghost' => 'bg-transparent text-slate-400 border-transparent hover:bg-white/5 hover:text-slate-200', + ]; + + $sizes = [ + 'sm' => 'px-2.5 py-1.5 text-xs', + 'md' => 'px-3 py-1.5 text-[12.5px]', + 'lg' => 'px-4 py-2 text-sm', + ]; + + $classes = implode(' ', [ + $baseClasses, + $variants[$variant] ?? $variants['secondary'], + $sizes[$size] ?? $sizes['md'], + ]); +@endphp + + diff --git a/resources/views/components/card.blade.php b/resources/views/components/card.blade.php new file mode 100644 index 0000000..00f9082 --- /dev/null +++ b/resources/views/components/card.blade.php @@ -0,0 +1,28 @@ +@props([ + 'title' => null, + 'icon' => null, +]) + +
merge(['class' => 'bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden']) }}> + @if($title || $icon || isset($headerExtra)) +
+
+ @if($icon) + + @endif + @if($title) + {{ $title }} + @endif +
+ @if(isset($headerExtra)) +
+ {{ $headerExtra }} +
+ @endif +
+ @endif + +
+ {{ $slot }} +
+
diff --git a/resources/views/components/chat-message.blade.php b/resources/views/components/chat-message.blade.php new file mode 100644 index 0000000..b940239 --- /dev/null +++ b/resources/views/components/chat-message.blade.php @@ -0,0 +1,42 @@ +@props([ + 'senderName' => 'Interviewer', + 'isCandidate' => false, + 'color' => '#6366f1', + 'message' => '', + 'timestamp' => null, +]) + +@php + $resolvedColor = $isCandidate ? '#38bdf8' : ($color ?? '#6366f1'); + $timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('H:i:s') : now()->format('H:i:s'); + + // Hex to rgba helper for background opacity + $hex = ltrim($resolvedColor, '#'); + if (strlen($hex) == 3) { + $r = hexdec(substr($hex,0,1).substr($hex,0,1)); + $g = hexdec(substr($hex,1,1).substr($hex,1,1)); + $b = hexdec(substr($hex,2,1).substr($hex,2,1)); + } else { + $r = hexdec(substr($hex,0,2)); + $g = hexdec(substr($hex,2,2)); + $b = hexdec(substr($hex,4,2)); + } + $bgRgba = "rgba({$r}, {$g}, {$b}, 0.1)"; + $borderRgba = "rgba({$r}, {$g}, {$b}, 0.35)"; +@endphp + +
merge(['class' => 'chat-message mb-2 p-2.5 rounded-lg border-l-2 transition-all']) }} style="background: {{ $bgRgba }}; border-color: {{ $resolvedColor }};"> +
+
+ + {{ $senderName }} + @if($isCandidate) + Candidate + @else + Panelist + @endif +
+ {{ $timeStr }} +
+
{{ $message }}
+
diff --git a/resources/views/components/chat-panel.blade.php b/resources/views/components/chat-panel.blade.php new file mode 100644 index 0000000..c50b5b1 --- /dev/null +++ b/resources/views/components/chat-panel.blade.php @@ -0,0 +1,44 @@ +@props([ + 'warnings' => [], + 'candidateName' => 'Candidate', +]) + +@php + $interviewerColors = [ + '#6366f1', // Indigo + '#10b981', // Emerald + '#f59e0b', // Amber + '#ec4899', // Pink + '#8b5cf6', // Purple + '#06b6d4', // Cyan + '#f97316', // Orange + ]; +@endphp + +
merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2']) }}> + @if(count($warnings) > 0) + @foreach($warnings as $w) + @php + $isCand = is_null($w->sent_by); + $senderName = $isCand + ? ($candidateName ?: 'Candidate') + : ($w->sender ? $w->sender->name : 'Interviewer'); + + $color = $isCand + ? '#38bdf8' + : $interviewerColors[($w->sent_by ?? 1) % count($interviewerColors)]; + @endphp + + @endforeach + @else +
+ No chat history. Send a message below. +
+ @endif +
diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php new file mode 100644 index 0000000..acdabde --- /dev/null +++ b/resources/views/components/modal.blade.php @@ -0,0 +1,43 @@ +@props([ + 'id', + 'title' => null, + 'maxWidth' => '2xl', + 'onClose' => null, +]) + +@php + $maxWidths = [ + 'sm' => 'max-w-sm', + 'md' => 'max-w-md', + 'lg' => 'max-w-lg', + 'xl' => 'max-w-xl', + '2xl' => 'max-w-2xl', + '5xl' => 'max-w-5xl', + '7xl' => 'max-w-7xl', + 'full' => 'max-w-full mx-4', + ]; + + $maxWidthClass = $maxWidths[$maxWidth] ?? $maxWidths['2xl']; +@endphp + +
merge(['class' => 'admin-modal fixed inset-0 w-screen h-screen bg-slate-950/85 backdrop-blur-md flex items-center justify-center z-[1000] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto']) }}> +
+ @if($title || isset($headerActions)) +
+ @if($title) +

{{ $title }}

+ @endif +
+ @if(isset($headerActions)) + {{ $headerActions }} + @endif + +
+
+ @endif + +
+ {{ $slot }} +
+
+
diff --git a/resources/views/components/pill.blade.php b/resources/views/components/pill.blade.php new file mode 100644 index 0000000..865b722 --- /dev/null +++ b/resources/views/components/pill.blade.php @@ -0,0 +1,41 @@ +@props([ + 'variant' => 'info', // primary, secondary, success, danger, warning, info, ghost + 'size' => 'md', // sm, md + 'icon' => null, +]) + +@php + $baseClasses = 'inline-flex items-center gap-1.5 font-semibold text-[11px] tracking-wide px-2.5 py-1 rounded-full border transition-colors duration-150 whitespace-nowrap'; + + $variants = [ + 'blue' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]', + 'primary' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]', + 'info' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]', + 'red' => 'text-[#ef4a5f] bg-[rgba(239,74,95,0.1)] border-[rgba(239,74,95,0.32)]', + 'danger' => 'text-[#ef4a5f] bg-[rgba(239,74,95,0.1)] border-[rgba(239,74,95,0.32)]', + 'success' => 'text-[#21c274] bg-[rgba(33,194,116,0.12)] border-[rgba(33,194,116,0.35)]', + 'warning' => 'text-[#f0a939] bg-[rgba(240,169,57,0.12)] border-[rgba(240,169,57,0.35)]', + 'secondary' => 'text-slate-400 bg-[#0d1220] border-[#232b3d]', + 'ghost' => 'text-slate-400 bg-[#0d1220] border-[#232b3d]', + ]; + + $sizes = [ + 'sm' => 'px-2 py-0.5 text-[10.5px]', + 'md' => 'px-2.5 py-1 text-[11px]', + ]; + + $classes = implode(' ', [ + $baseClasses, + $variants[$variant] ?? $variants['ghost'], + $sizes[$size] ?? $sizes['md'], + ]); +@endphp + +merge(['class' => $classes]) }}> + @if($icon) + + @endif + @if(trim($slot)) + {{ $slot }} + @endif + diff --git a/resources/views/components/tab-button.blade.php b/resources/views/components/tab-button.blade.php new file mode 100644 index 0000000..f9a6a31 --- /dev/null +++ b/resources/views/components/tab-button.blade.php @@ -0,0 +1,26 @@ +@props([ + 'id' => null, + 'active' => false, + 'icon' => null, + 'tabKey' => null, +]) + +@php + $baseClasses = 'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md transition-colors duration-150 cursor-pointer select-none border-none'; + $activeClasses = $active + ? 'bg-[#4b82f7] text-white shadow-sm active' + : 'bg-transparent text-slate-400 hover:text-slate-200'; +@endphp + + diff --git a/resources/views/components/video-tile.blade.php b/resources/views/components/video-tile.blade.php new file mode 100644 index 0000000..5b1177a --- /dev/null +++ b/resources/views/components/video-tile.blade.php @@ -0,0 +1,57 @@ +@props([ + 'id', + 'videoId', + 'placeholderId', + 'badgeText' => '', + 'badgeId' => null, + 'borderColor' => 'border-[#1b2233]', + 'showZoom' => false, + 'showFullscreen' => false, + 'initials' => 'CD', + 'avatarCircleId' => null, + 'avatarNameId' => null, + 'avatarTitle' => 'Video Stream', + 'zoomInFn' => null, + 'zoomOutFn' => null, + 'zoomResetFn' => null, + 'fullscreenFn' => null, + 'containerId' => null, + 'focused' => false, +]) + +
merge(['class' => 'video-tile relative aspect-[16/11] bg-[#0d1220] border rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all ' . ($focused ? 'border-[rgba(75,130,247,0.35)] shadow-[0_0_0_1px_rgba(75,130,247,0.35)]' : 'border-[#1b2233]')]) }}> + @if($showZoom || $showFullscreen) +
+ @if($showZoom) + + + + @endif + @if($showFullscreen) + + @endif +
+ @endif + + @if($containerId) +
+ +
+ @else + + @endif + +
+
+ {{ $initials }} +
+
{{ $avatarTitle }}
+
+ {{ $slot->isNotEmpty() ? $slot : "Waiting for candidate…" }} +
+
+ + + {{ $badgeText }} + +
diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index cd2ef7d..e92db91 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -1022,12 +1022,19 @@ function logViolation(type, details) { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }, body: JSON.stringify({ type: type, details: details }) + }) + .then(r => r.json()) + .then(res => { + if (typeof callSigChannel !== 'undefined' && callSigChannel) { + callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details }); + } + if (typeof liveSyncChannel !== 'undefined' && liveSyncChannel) { + liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: type, details: details }); + } + if (typeof pollReviewData === 'function') { + pollReviewData(); + } }); - - // Post message over BroadcastChannel to alert interviewer dashboard real-time - if (typeof callSigChannel !== 'undefined' && callSigChannel) { - callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details }); - } } // --- CANDIDATE REAL-TIME CALL RINGTONE SYNTHESIZER --- diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index 4497b69..73e21d5 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -8,254 +8,87 @@ + - - - - - + + -