candidate portal
This commit is contained in:
parent
0d4b9dcfe9
commit
d1125bb69e
@ -102,6 +102,10 @@ public function loginCandidate(Request $request)
|
||||
return back()->with('error', 'Invalid candidate credentials or temporary password.');
|
||||
}
|
||||
|
||||
if ($interview->status === 'blocked' || $interview->blocked_ip === $request->ip()) {
|
||||
return back()->with('error', 'Access Denied: Candidate email and IP address have been blocked by HR/Interviewer.');
|
||||
}
|
||||
|
||||
if ($interview->isExpired()) {
|
||||
$interview->update(['status' => 'expired']);
|
||||
return back()->with('error', 'This candidate interview access window has expired. Please contact HR.');
|
||||
@ -124,6 +128,10 @@ public function showCandidateRoom($uniqueId)
|
||||
{
|
||||
$interview = Interview::where('submission_unique_id', $uniqueId)->firstOrFail();
|
||||
|
||||
if ($interview->status === 'blocked') {
|
||||
return response()->view('interview.expired', compact('interview'), 403);
|
||||
}
|
||||
|
||||
if ($interview->isExpired()) {
|
||||
$interview->update(['status' => 'expired']);
|
||||
return response()->view('interview.expired', compact('interview'), 403);
|
||||
@ -301,6 +309,60 @@ public function sendWarning(Request $request, $id)
|
||||
return response()->json(['success' => true, 'warning' => $warning]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Candidate Live Notepad Content.
|
||||
*/
|
||||
public function saveCandidateNotes(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$interview->update(['candidate_notes' => $request->input('notes')]);
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Candidate Live Canvas Drawing Data URL.
|
||||
*/
|
||||
public function saveCandidateDrawing(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$interview->update(['candidate_drawing' => $request->input('drawing')]);
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate Temporary Candidate Password (by Interviewer/HR).
|
||||
*/
|
||||
public function regeneratePassword($id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$newPass = 'Pass-' . rand(100000, 999999);
|
||||
$interview->update(['temp_password' => $newPass]);
|
||||
return response()->json(['success' => true, 'new_password' => $newPass]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block Candidate Session, Email & Client IP (by Interviewer/HR).
|
||||
*/
|
||||
public function blockCandidate(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$clientIp = $request->ip();
|
||||
|
||||
$interview->update([
|
||||
'status' => 'blocked',
|
||||
'blocked_ip' => $clientIp,
|
||||
]);
|
||||
|
||||
// Also block user record if exists
|
||||
User::where('email', strtolower($interview->candidate_email))
|
||||
->update(['is_blocked' => true]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Candidate ' . $interview->candidate_name . ' (' . $interview->candidate_email . ') and IP ' . $clientIp . ' have been blocked successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
|
||||
*/
|
||||
@ -313,6 +375,10 @@ public function getPollData($id)
|
||||
'submitted_code' => $interview->submitted_code,
|
||||
'submitted_language' => $interview->submitted_language,
|
||||
'code_output' => $interview->code_output,
|
||||
'candidate_notes' => $interview->candidate_notes,
|
||||
'candidate_drawing' => $interview->candidate_drawing,
|
||||
'temp_password' => $interview->temp_password,
|
||||
'blocked_ip' => $interview->blocked_ip,
|
||||
'proctor_logs' => $interview->proctor_logs ?? [],
|
||||
'warnings' => $interview->warnings,
|
||||
'is_expired' => $interview->isExpired(),
|
||||
|
||||
@ -25,6 +25,9 @@ class Interview extends Model
|
||||
'code_output',
|
||||
'recording_path',
|
||||
'submission_unique_id',
|
||||
'candidate_notes',
|
||||
'candidate_drawing',
|
||||
'blocked_ip',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->text('candidate_notes')->nullable();
|
||||
$table->longText('candidate_drawing')->nullable();
|
||||
$table->string('blocked_ip')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['candidate_notes', 'candidate_drawing', 'blocked_ip']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -202,6 +202,14 @@
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 14px;">
|
||||
<button onclick="toggleNotepadModal()" class="btn-ide" style="background: rgba(99,102,241,0.2); border-color: #6366f1;">
|
||||
📝 Open Notepad
|
||||
</button>
|
||||
|
||||
<button onclick="toggleDrawingModal()" class="btn-ide" style="background: rgba(6,182,212,0.2); border-color: #06b6d4;">
|
||||
🎨 Open Drawing
|
||||
</button>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<label style="font-size: 0.75rem; color: #94a3b8; font-weight: 500;">Language:</label>
|
||||
<select id="language-select" class="btn-ide" style="background: #151b2c;">
|
||||
@ -224,6 +232,36 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Candidate Notepad Modal -->
|
||||
<div id="notepad-modal" style="position: fixed; top: 70px; right: 360px; width: 420px; height: 350px; background: #0f172a; border: 1.5px solid #6366f1; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); display: none; flex-direction: column; z-index: 1000; overflow: hidden;">
|
||||
<div style="background: #1e293b; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<div style="font-weight: 700; font-size: 0.85rem; color: white;">📝 Candidate Notepad (Synced)</div>
|
||||
<button onclick="toggleNotepadModal()" style="background: none; border: none; color: #94a3b8; font-size: 1.2rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
<textarea id="notepad-textarea" oninput="saveNotepadContent()" placeholder="Type rough notes, pseudo-code, or thoughts here..." style="flex: 1; width: 100%; padding: 14px; background: #0b0f19; color: #e2e8f0; border: none; outline: none; font-family: 'Fira Code', monospace; font-size: 0.85rem; resize: none;">{{ $interview->candidate_notes }}</textarea>
|
||||
<div style="padding: 6px 12px; background: #0d1322; font-size: 0.65rem; color: #34d399; text-align: right;">
|
||||
✓ Auto-saved to interviewer portal
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candidate Canvas Drawing Modal -->
|
||||
<div id="drawing-modal" style="position: fixed; top: 70px; right: 360px; width: 550px; height: 420px; background: #0f172a; border: 1.5px solid #06b6d4; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); display: none; flex-direction: column; z-index: 1000; overflow: hidden;">
|
||||
<div style="background: #1e293b; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<div style="font-weight: 700; font-size: 0.85rem; color: white;">🎨 Candidate Live Drawing Board</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<input type="color" id="draw-color" value="#38bdf8" style="width: 26px; height: 26px; border: none; border-radius: 50%; cursor: pointer;">
|
||||
<button onclick="clearCanvasDraw()" class="btn-ide" style="padding: 2px 8px; font-size: 0.7rem;">Clear</button>
|
||||
<button onclick="toggleDrawingModal()" style="background: none; border: none; color: #94a3b8; font-size: 1.2rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1; background: #050811; position: relative;">
|
||||
<canvas id="drawing-canvas" width="550" height="340" style="cursor: crosshair; display: block; width: 100%; height: 100%;"></canvas>
|
||||
</div>
|
||||
<div style="padding: 6px 12px; background: #0d1322; font-size: 0.65rem; color: #38bdf8; text-align: right;">
|
||||
🎨 Draw architecture / logic diagrams (Auto-synced to Interviewer)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IDE Room Layout -->
|
||||
<div class="ide-layout">
|
||||
<!-- Main Editor & Terminal Area -->
|
||||
@ -239,12 +277,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$words = preg_split('/\s+/', trim($interview->candidate_name));
|
||||
if (count($words) >= 2) {
|
||||
$candInitials = strtoupper(substr($words[0], 0, 1) . substr(end($words), 0, 1));
|
||||
} else {
|
||||
$nameStr = trim($interview->candidate_name);
|
||||
$candInitials = strtoupper(substr($nameStr, 0, 1) . (strlen($nameStr) > 1 ? substr($nameStr, -1) : ''));
|
||||
}
|
||||
@endphp
|
||||
|
||||
<!-- Sidebar: Real-Time 2-Way Interview Call & Screen Share -->
|
||||
<div class="proctor-sidebar">
|
||||
<!-- Candidate Camera Feed -->
|
||||
<div class="webcam-box" style="position: relative;">
|
||||
<video id="webcam" autoplay muted playsinline></video>
|
||||
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600;">
|
||||
<video id="webcam" autoplay muted playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
|
||||
<div id="webcam-avatar-placeholder" style="position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center; background: #0f172a; color: white; z-index: 5;">
|
||||
<div style="width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #6366f1 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 800; font-family: 'Outfit', sans-serif; box-shadow: 0 0 15px rgba(99,102,241,0.4);">
|
||||
{{ $candInitials }}
|
||||
</div>
|
||||
<div style="font-size: 0.7rem; color: #94a3b8; margin-top: 6px;">Camera Disabled</div>
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600; z-index: 10;">
|
||||
📷 Candidate Camera
|
||||
</div>
|
||||
</div>
|
||||
@ -252,12 +306,14 @@
|
||||
<!-- Remote Interviewer Video Feed (Live 2-Way Call) -->
|
||||
<div class="webcam-box" style="position: relative; border-color: #6366f1;">
|
||||
<video id="interviewer-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811;"></video>
|
||||
<div id="interviewer-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.9); color: #94a3b8; font-size: 0.75rem; text-align: center; padding: 10px;">
|
||||
<div style="font-size: 1.5rem; margin-bottom: 4px;">👤</div>
|
||||
<div>Interviewer Video Feed</div>
|
||||
<div id="call-status-sub" style="font-size: 0.65rem; color: #38bdf8; margin-top: 4px;">Waiting for panelist to join call...</div>
|
||||
<div id="interviewer-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; text-align: center; padding: 10px; z-index: 5;">
|
||||
<div id="interviewer-avatar-circle" style="width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 800; font-family: 'Outfit', sans-serif; color: white; margin-bottom: 6px; box-shadow: 0 0 15px rgba(56,189,248,0.4);">
|
||||
IV
|
||||
</div>
|
||||
<div id="call-status-sub" style="font-size: 0.7rem; color: #e2e8f0; font-weight: 600;">Interviewer Video Feed</div>
|
||||
<div style="font-size: 0.65rem; color: #38bdf8; margin-top: 2px;">Waiting for panelist to join call...</div>
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600;">
|
||||
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10;">
|
||||
🎙️ Interviewer / Panelist
|
||||
</div>
|
||||
</div>
|
||||
@ -487,15 +543,21 @@ function toggleMic() {
|
||||
}
|
||||
|
||||
function toggleCam() {
|
||||
if (!localMediaStream) return;
|
||||
const videoTracks = localMediaStream.getVideoTracks();
|
||||
if (videoTracks.length > 0) {
|
||||
const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder');
|
||||
if (localMediaStream) {
|
||||
const videoTracks = localMediaStream.getVideoTracks();
|
||||
if (videoTracks.length > 0) {
|
||||
isCamEnabled = !isCamEnabled;
|
||||
videoTracks[0].enabled = isCamEnabled;
|
||||
const btn = document.getElementById('toggle-cam-btn');
|
||||
btn.innerText = isCamEnabled ? '📷 Cam On' : '🚫 Cam Off';
|
||||
btn.style.background = isCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
|
||||
btn.style.borderColor = isCamEnabled ? '#10b981' : '#ef4444';
|
||||
if (avatarPlaceholder) avatarPlaceholder.style.display = isCamEnabled ? 'none' : 'flex';
|
||||
}
|
||||
} else {
|
||||
isCamEnabled = !isCamEnabled;
|
||||
videoTracks[0].enabled = isCamEnabled;
|
||||
const btn = document.getElementById('toggle-cam-btn');
|
||||
btn.innerText = isCamEnabled ? '📷 Cam On' : '🚫 Cam Off';
|
||||
btn.style.background = isCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
|
||||
btn.style.borderColor = isCamEnabled ? '#10b981' : '#ef4444';
|
||||
if (avatarPlaceholder) avatarPlaceholder.style.display = isCamEnabled ? 'none' : 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
@ -561,6 +623,107 @@ function analyzeCameraFrame() {
|
||||
}
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
// --- NOTEPAD & CANVAS DRAWING HANDLERS ---
|
||||
let notesTimeout = null;
|
||||
|
||||
function toggleNotepadModal() {
|
||||
const modal = document.getElementById('notepad-modal');
|
||||
modal.style.display = (modal.style.display === 'flex') ? 'none' : 'flex';
|
||||
}
|
||||
|
||||
function saveNotepadContent() {
|
||||
clearTimeout(notesTimeout);
|
||||
notesTimeout = setTimeout(() => {
|
||||
const notes = document.getElementById('notepad-textarea').value;
|
||||
fetch(`{{ route('interview.candidate.notes', $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ notes: notes })
|
||||
});
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// HTML5 Canvas Drawing Logic
|
||||
let isDrawing = false;
|
||||
let canvas, ctx;
|
||||
let drawingSaveTimeout = null;
|
||||
|
||||
function toggleDrawingModal() {
|
||||
const modal = document.getElementById('drawing-modal');
|
||||
modal.style.display = (modal.style.display === 'flex') ? 'none' : 'flex';
|
||||
if (modal.style.display === 'flex' && !canvas) {
|
||||
initCanvasDrawing();
|
||||
}
|
||||
}
|
||||
|
||||
function initCanvasDrawing() {
|
||||
canvas = document.getElementById('drawing-canvas');
|
||||
if (!canvas) return;
|
||||
ctx = canvas.getContext('2d');
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = '#38bdf8';
|
||||
|
||||
const savedDrawing = {!! json_encode($interview->candidate_drawing) !!};
|
||||
if (savedDrawing) {
|
||||
const img = new Image();
|
||||
img.onload = () => ctx.drawImage(img, 0, 0);
|
||||
img.src = savedDrawing;
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', startDraw);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stopDraw);
|
||||
canvas.addEventListener('mouseleave', stopDraw);
|
||||
}
|
||||
|
||||
function startDraw(e) {
|
||||
isDrawing = true;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(e.offsetX, e.offsetY);
|
||||
}
|
||||
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
ctx.strokeStyle = document.getElementById('draw-color').value;
|
||||
ctx.lineTo(e.offsetX, e.offsetY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function stopDraw() {
|
||||
if (isDrawing) {
|
||||
isDrawing = false;
|
||||
ctx.closePath();
|
||||
saveCanvasDrawing();
|
||||
}
|
||||
}
|
||||
|
||||
function clearCanvasDraw() {
|
||||
if (ctx && canvas) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
saveCanvasDrawing();
|
||||
}
|
||||
}
|
||||
|
||||
function saveCanvasDrawing() {
|
||||
clearTimeout(drawingSaveTimeout);
|
||||
drawingSaveTimeout = setTimeout(() => {
|
||||
if (!canvas) return;
|
||||
const drawingData = canvas.toDataURL();
|
||||
fetch(`{{ route('interview.candidate.drawing', $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ drawing: drawingData })
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -398,15 +398,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Live Review, 2-Way Video Call, Screen Share & Code Inspection -->
|
||||
<!-- Modal: Live Review, 2-Way Video Call, Screen Share, Large IDE Inspector & SOS Alert -->
|
||||
<div id="review-modal" class="admin-modal">
|
||||
<div class="admin-modal-content" style="max-width: 1100px;">
|
||||
<div class="admin-modal-content" style="max-width: 1150px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px;">
|
||||
<div>
|
||||
<h3 id="modal-cand-name" style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 700; color: white; margin: 0;">Review Candidate Session</h3>
|
||||
<div id="modal-cand-uid" style="font-size: 0.75rem; color: #38bdf8;"></div>
|
||||
</div>
|
||||
<button onclick="closeReviewModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.5rem; cursor:pointer;">×</button>
|
||||
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<!-- SOS CHEATING ALERT BUTTON (Flashing Red) -->
|
||||
<button id="sos-alert-btn" onclick="openSosModal()" class="btn-nav" style="display: none; background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); color: white; border: none; font-weight: 800; padding: 6px 14px; box-shadow: 0 0 15px rgba(239,68,68,0.7); cursor: pointer; animation: sosPulse 1.2s infinite alternate;">
|
||||
🚨 SOS CHEATING DETECTED!
|
||||
</button>
|
||||
|
||||
<!-- REGENERATE CANDIDATE PASSWORD -->
|
||||
<button onclick="regenerateCandidatePassword()" class="btn-nav" style="background: rgba(234,179,8,0.15); color: #fde047; border-color: #eab308; font-size: 0.75rem; font-weight: 600;">
|
||||
🔑 Regenerate Password
|
||||
</button>
|
||||
|
||||
<!-- BLOCK CANDIDATE EMAIL & IP -->
|
||||
<button onclick="blockCandidateAction()" class="btn-nav" style="background: rgba(239,68,68,0.15); color: #fca5a5; border-color: #ef4444; font-size: 0.75rem; font-weight: 600;">
|
||||
⛔ Block Candidate & IP
|
||||
</button>
|
||||
|
||||
<button onclick="closeReviewModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.5rem; cursor:pointer;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIVE 2-WAY VIDEO CALL & SCREEN SHARE PANEL -->
|
||||
@ -431,75 +449,134 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Feeds Grid: Candidate Video, Candidate Screen, Interviewer Video -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 220px; gap: 12px; min-height: 180px;">
|
||||
<!-- 1. Candidate Camera Stream -->
|
||||
<div style="position: relative; background: #050811; border-radius: 12px; border: 1px solid var(--card-border); overflow: hidden; height: 180px;">
|
||||
<!-- Video Feeds Grid: Candidate Video, Candidate Screen (with Zoom controls), Panelist Video -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 12px; min-height: 220px;">
|
||||
<!-- 1. Candidate Camera Stream (with Initials Avatar when Cam is Off) -->
|
||||
<div style="position: relative; background: #050811; border-radius: 12px; border: 1.5px solid var(--card-border); overflow: hidden; height: 220px;">
|
||||
<video id="interviewer-cand-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
|
||||
<div id="cand-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.9); color: #94a3b8; font-size: 0.75rem;">
|
||||
<div style="font-size: 1.8rem;">📷</div>
|
||||
<div>Candidate Camera Stream</div>
|
||||
<div style="font-size: 0.65rem; color: #38bdf8; margin-top: 4px;">Click 'Start 2-Way Call' to connect</div>
|
||||
<div id="cand-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; z-index: 5;">
|
||||
<div id="modal-cand-avatar-circle" style="width: 64px; height: 64px; border-radius: 50%; background: linear-gradient(135deg, #6366f1 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 1.6rem; font-weight: 800; font-family: 'Outfit', sans-serif; color: white; margin-bottom: 6px; box-shadow: 0 0 15px rgba(99,102,241,0.4);">
|
||||
CD
|
||||
</div>
|
||||
<div id="cand-avatar-name" style="font-weight: 700; color: white;">Candidate Camera</div>
|
||||
<div style="font-size: 0.65rem; color: #38bdf8; margin-top: 2px;">Click 'Start 2-Way Call' to connect</div>
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600;">
|
||||
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600; z-index: 10;">
|
||||
👤 Candidate Video
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Candidate Live Shared Screen -->
|
||||
<div style="position: relative; background: #050811; border-radius: 12px; border: 1px solid var(--card-border); overflow: hidden; height: 180px;">
|
||||
<video id="interviewer-screen-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: contain;"></video>
|
||||
<div id="screen-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.9); color: #94a3b8; font-size: 0.75rem;">
|
||||
<div style="font-size: 1.8rem;">🖥️</div>
|
||||
<div>Candidate Live Screen Share</div>
|
||||
<div style="font-size: 0.65rem; color: #94a3b8; margin-top: 4px;">Waiting for candidate to share screen...</div>
|
||||
<!-- 2. Candidate Live Shared Screen (with Zoom In / Zoom Out Controls & Fullscreen) -->
|
||||
<div id="screen-wrapper" style="position: relative; background: #050811; border-radius: 12px; border: 1.5px solid #38bdf8; overflow: hidden; height: 220px;">
|
||||
<div style="position: absolute; top: 6px; right: 6px; z-index: 20; display: flex; gap: 4px; background: rgba(0,0,0,0.7); padding: 3px 6px; border-radius: 6px; backdrop-filter: blur(4px);">
|
||||
<button onclick="zoomScreen(0.25)" class="btn-nav" style="padding: 2px 6px; font-size: 0.7rem; background: rgba(255,255,255,0.1);" title="Zoom In (🔍+)">🔍+</button>
|
||||
<button onclick="zoomScreen(-0.25)" class="btn-nav" style="padding: 2px 6px; font-size: 0.7rem; background: rgba(255,255,255,0.1);" title="Zoom Out (🔎-)">🔎-</button>
|
||||
<button onclick="resetZoomScreen()" class="btn-nav" style="padding: 2px 6px; font-size: 0.7rem; background: rgba(255,255,255,0.1);" title="Reset Zoom (↺)">↺</button>
|
||||
<button onclick="toggleFullscreenScreen()" class="btn-nav" style="padding: 2px 6px; font-size: 0.7rem; background: #38bdf8; color: black; font-weight: bold;" title="Fullscreen (⛶)">⛶</button>
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #38bdf8; font-weight: 600;">
|
||||
🖥️ Candidate Shared Screen
|
||||
|
||||
<div id="screen-zoom-container" style="width: 100%; height: 100%; overflow: auto;">
|
||||
<video id="interviewer-screen-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: contain; transform-origin: 0 0; transition: transform 0.2s ease;"></video>
|
||||
</div>
|
||||
|
||||
<div id="screen-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; z-index: 5;">
|
||||
<div style="font-size: 2rem; margin-bottom: 4px;">🖥️</div>
|
||||
<div style="font-weight: 700; color: white;">Candidate Live Screen</div>
|
||||
<div style="font-size: 0.65rem; color: #94a3b8; margin-top: 2px;">Waiting for candidate to share screen...</div>
|
||||
</div>
|
||||
|
||||
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #38bdf8; font-weight: 600; z-index: 10;">
|
||||
🖥️ Shared Screen <span id="zoom-level-badge" style="color: #a7f3d0; margin-left: 4px;">(100%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Interviewer Self Camera Stream -->
|
||||
<div style="position: relative; background: #050811; border-radius: 12px; border: 1px solid var(--card-border); overflow: hidden; height: 180px;">
|
||||
<!-- 3. Interviewer / Panelist Camera Stream -->
|
||||
<div style="position: relative; background: #050811; border-radius: 12px; border: 1.5px solid var(--card-border); overflow: hidden; height: 220px;">
|
||||
<video id="interviewer-self-video" autoplay muted playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
|
||||
<div id="self-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.9); color: #94a3b8; font-size: 0.75rem;">
|
||||
<div style="font-size: 1.5rem;">🎙️</div>
|
||||
<div>Interviewer Self Stream</div>
|
||||
<div id="self-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; z-index: 5;">
|
||||
@php
|
||||
$usrWords = preg_split('/\s+/', trim(Auth::user()->name));
|
||||
if (count($usrWords) >= 2) {
|
||||
$usrInitials = strtoupper(substr($usrWords[0], 0, 1) . substr(end($usrWords), 0, 1));
|
||||
} else {
|
||||
$uStr = trim(Auth::user()->name);
|
||||
$usrInitials = strtoupper(substr($uStr, 0, 1) . (strlen($uStr) > 1 ? substr($uStr, -1) : ''));
|
||||
}
|
||||
@endphp
|
||||
<div style="width: 64px; height: 64px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #06b6d4 100%); display: flex; align-items: center; justify-content: center; font-size: 1.6rem; font-weight: 800; font-family: 'Outfit', sans-serif; color: white; margin-bottom: 6px; box-shadow: 0 0 15px rgba(16,185,129,0.4);">
|
||||
{{ $usrInitials }}
|
||||
</div>
|
||||
<div style="font-weight: 700; color: white;">{{ Auth::user()->name }}</div>
|
||||
<div style="font-size: 0.65rem; color: #34d399; margin-top: 2px;">Panelist (Self)</div>
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600;">
|
||||
Panelist Self
|
||||
<div style="position: absolute; bottom: 6px; left: 6px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10;">
|
||||
🎙️ Panelist Self
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 20px;">
|
||||
<!-- Left: Code Editor & Execution Output -->
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 0.8rem; font-weight: 600; color: white;">Submitted Code Solution:</span>
|
||||
<span id="modal-code-lang" class="badge" style="background: rgba(99,102,241,0.2); color: #818cf8; font-size: 0.65rem;">PYTHON</span>
|
||||
<!-- LARGE CARD: CANDIDATE IDE & SYNCED NOTEBOOK / DRAWING INSPECTOR (Visible by default even if Screen Share is OFF) -->
|
||||
<div style="background: #090d16; border: 1.5px solid var(--accent-primary); border-radius: 16px; padding: 20px; margin-bottom: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; border-bottom: 1px solid rgba(255,255,255,0.08); padding-bottom: 10px;">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.2rem;">💻</span>
|
||||
<strong style="color: white; font-size: 1.05rem;">Candidate Live IDE & Workspace Inspector</strong>
|
||||
<span id="modal-code-lang" class="badge" style="background: rgba(99,102,241,0.2); color: #818cf8; font-size: 0.7rem;">PYTHON</span>
|
||||
<span style="font-size: 0.7rem; color: #34d399;">● Live Synced</span>
|
||||
</div>
|
||||
<pre id="modal-code-display" style="background: #090d16; border: 1px solid var(--card-border); border-radius: 10px; padding: 14px; font-family: monospace; font-size: 0.8rem; color: #e2e8f0; max-height: 250px; overflow-y: auto; white-space: pre-wrap; margin-bottom: 14px;"></pre>
|
||||
|
||||
<span style="font-size: 0.8rem; font-weight: 600; color: white;">Execution Stdout / Stderr Output:</span>
|
||||
<pre id="modal-output-display" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; font-family: monospace; font-size: 0.75rem; color: #34d399; max-height: 120px; overflow-y: auto; white-space: pre-wrap;"></pre>
|
||||
<!-- Tab Switcher for Code Solution, Notepad, Drawing -->
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button id="tab-btn-code" onclick="switchIdeTab('code')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: #6366f1; color: white; border: none; font-weight: 700;">💻 Live IDE Code</button>
|
||||
<button id="tab-btn-notes" onclick="switchIdeTab('notes')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📝 Candidate Notepad</button>
|
||||
<button id="tab-btn-drawing" onclick="switchIdeTab('drawing')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">🎨 Candidate Drawing</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Proctoring Logs & Silent Warning Sender -->
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; font-weight: 600; color: white;">Proctoring Audit:</span>
|
||||
<div id="modal-logs-display" style="background: rgba(0,0,0,0.3); border: 1px solid var(--card-border); border-radius: 10px; padding: 10px; max-height: 200px; overflow-y: auto; margin-bottom: 14px; font-size: 0.7rem; color: var(--text-muted);">
|
||||
No violations recorded.
|
||||
<div style="display: grid; grid-template-columns: 2.5fr 1fr; gap: 20px;">
|
||||
<!-- Left Column: Tab Content (Code Solution, Notepad, or Canvas Drawing) -->
|
||||
<div>
|
||||
<!-- Tab 1: Live Code Solution & Terminal Output -->
|
||||
<div id="tab-content-code" style="display: block;">
|
||||
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Current Code Solution (Visible even if candidate does not share screen):</div>
|
||||
<pre id="modal-code-display" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 16px; font-family: monospace; font-size: 0.85rem; color: #38bdf8; min-height: 200px; max-height: 280px; overflow-y: auto; white-space: pre-wrap; margin-bottom: 12px;"></pre>
|
||||
|
||||
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Execution Stdout / Stderr Output:</div>
|
||||
<pre id="modal-output-display" style="background: #02040a; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; font-family: monospace; font-size: 0.75rem; color: #34d399; max-height: 90px; overflow-y: auto; white-space: pre-wrap;"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Candidate Notepad Content -->
|
||||
<div id="tab-content-notes" style="display: none;">
|
||||
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Candidate Live Notepad Notes (Auto-Synced):</div>
|
||||
<pre id="modal-notes-display" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 16px; font-family: monospace; font-size: 0.85rem; color: #e2e8f0; min-height: 250px; max-height: 350px; overflow-y: auto; white-space: pre-wrap;"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Candidate Canvas Drawing Image -->
|
||||
<div id="tab-content-drawing" style="display: none;">
|
||||
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Candidate Live Drawing / Architecture Diagram:</div>
|
||||
<div style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 10px; display: flex; justify-content: center; align-items: center; min-height: 250px;">
|
||||
<img id="modal-drawing-img" src="" alt="Candidate Drawing Board" style="max-width: 100%; max-height: 320px; display: none; border-radius: 8px; border: 1px dashed rgba(255,255,255,0.2);" />
|
||||
<div id="no-drawing-placeholder" style="color: #94a3b8; font-size: 0.8rem;">No drawing data submitted by candidate yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Silent Warning Form -->
|
||||
<div style="background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); border-radius: 12px; padding: 12px;">
|
||||
<div style="font-size: 0.75rem; color: #fca5a5; font-weight: 600; margin-bottom: 6px;">Send Message to Candidate:</div>
|
||||
<input type="text" id="warning-input" placeholder="e.g. Please walk me through your solution logic." class="form-input" style="font-size: 0.75rem; margin-bottom: 8px;">
|
||||
<button onclick="sendSilentWarning()" class="btn-nav" style="width: 100%; background: #ef4444; color: white; border: none; font-size: 0.75rem; font-weight: 600; padding: 6px; cursor: pointer;">
|
||||
💬 Send Message
|
||||
</button>
|
||||
<!-- Right Column: Proctoring Audit & Silent Warning Form -->
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; font-weight: 600; color: white;">Proctoring Audit:</span>
|
||||
<div id="modal-logs-display" style="background: rgba(0,0,0,0.4); border: 1px solid var(--card-border); border-radius: 10px; padding: 10px; max-height: 200px; overflow-y: auto; margin-bottom: 14px; font-size: 0.7rem; color: var(--text-muted);">
|
||||
No violations recorded.
|
||||
</div>
|
||||
|
||||
<!-- Silent Warning Form -->
|
||||
<div style="background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); border-radius: 12px; padding: 12px;">
|
||||
<div style="font-size: 0.75rem; color: #fca5a5; font-weight: 600; margin-bottom: 6px;">Send Message to Candidate:</div>
|
||||
<input type="text" id="warning-input" placeholder="e.g. Please walk me through your logic." class="form-input" style="font-size: 0.75rem; margin-bottom: 8px;">
|
||||
<button onclick="sendSilentWarning()" class="btn-nav" style="width: 100%; background: #ef4444; color: white; border: none; font-size: 0.75rem; font-weight: 600; padding: 6px; cursor: pointer;">
|
||||
💬 Send Message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -510,6 +587,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SOS CHEATING ALERT MODAL (FOR INTERVIEWER ONLY) -->
|
||||
<div id="sos-modal" class="admin-modal" style="z-index: 9999;">
|
||||
<div class="admin-modal-content" style="max-width: 520px; border: 2px solid #ef4444; background: #0f172a; box-shadow: 0 0 40px rgba(239,68,68,0.8);">
|
||||
<div style="display: flex; align-items: center; gap: 12px; border-bottom: 1px solid rgba(239,68,68,0.3); padding-bottom: 12px; margin-bottom: 14px;">
|
||||
<div style="font-size: 2.2rem;">🚨</div>
|
||||
<div>
|
||||
<h3 style="color: #fca5a5; font-size: 1.25rem; font-weight: 800; margin: 0;">SOS CHEATING DETECTED</h3>
|
||||
<div style="font-size: 0.72rem; color: #94a3b8;">Proctoring engine flagged candidate cheating activity!</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sos-violation-list" style="background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 12px; padding: 14px; font-size: 0.8rem; color: #fca5a5; margin-bottom: 18px; max-height: 180px; overflow-y: auto;">
|
||||
Violation details loading...
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||
<button onclick="stopSosAlarm()" class="btn-nav" style="background: #ef4444; color: white; border: none; font-weight: 800; padding: 8px 16px; cursor: pointer;">
|
||||
🔕 Acknowledge & Stop SOS Alarm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes sosPulse {
|
||||
0% { transform: scale(1); box-shadow: 0 0 10px rgba(239,68,68,0.5); }
|
||||
100% { transform: scale(1.05); box-shadow: 0 0 25px rgba(239,68,68,1); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let currentInterviewId = null;
|
||||
let interviewerPeer = null;
|
||||
@ -518,29 +625,121 @@
|
||||
let activeCall = null;
|
||||
let isInterviewerMicOn = true;
|
||||
let isInterviewerCamOn = true;
|
||||
let screenZoomLevel = 1.0;
|
||||
|
||||
function getInitials(name) {
|
||||
if (!name) return 'CD';
|
||||
let parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
let str = name.trim();
|
||||
if (str.length >= 2) {
|
||||
return (str[0] + str[str.length - 1]).toUpperCase();
|
||||
}
|
||||
return str.toUpperCase();
|
||||
}
|
||||
|
||||
function zoomScreen(delta) {
|
||||
screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta));
|
||||
const screenVid = document.getElementById('interviewer-screen-video');
|
||||
const badge = document.getElementById('zoom-level-badge');
|
||||
if (screenVid) {
|
||||
screenVid.style.transform = `scale(${screenZoomLevel})`;
|
||||
}
|
||||
if (badge) {
|
||||
badge.innerText = `(${Math.round(screenZoomLevel * 100)}%)`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetZoomScreen() {
|
||||
screenZoomLevel = 1.0;
|
||||
zoomScreen(0);
|
||||
}
|
||||
|
||||
function toggleFullscreenScreen() {
|
||||
const wrapper = document.getElementById('screen-wrapper');
|
||||
if (!wrapper) return;
|
||||
if (!document.fullscreenElement) {
|
||||
wrapper.requestFullscreen().catch(err => console.log(err));
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
let activeViolations = [];
|
||||
let sosPollInterval = null;
|
||||
|
||||
function switchIdeTab(tab) {
|
||||
document.getElementById('tab-content-code').style.display = (tab === 'code') ? 'block' : 'none';
|
||||
document.getElementById('tab-content-notes').style.display = (tab === 'notes') ? 'block' : 'none';
|
||||
document.getElementById('tab-content-drawing').style.display = (tab === 'drawing') ? 'block' : 'none';
|
||||
|
||||
document.getElementById('tab-btn-code').style.background = (tab === 'code') ? '#6366f1' : 'rgba(255,255,255,0.05)';
|
||||
document.getElementById('tab-btn-code').style.color = (tab === 'code') ? 'white' : '#94a3b8';
|
||||
|
||||
document.getElementById('tab-btn-notes').style.background = (tab === 'notes') ? '#6366f1' : 'rgba(255,255,255,0.05)';
|
||||
document.getElementById('tab-btn-notes').style.color = (tab === 'notes') ? 'white' : '#94a3b8';
|
||||
|
||||
document.getElementById('tab-btn-drawing').style.background = (tab === 'drawing') ? '#06b6d4' : 'rgba(255,255,255,0.05)';
|
||||
document.getElementById('tab-btn-drawing').style.color = (tab === 'drawing') ? 'white' : '#94a3b8';
|
||||
}
|
||||
|
||||
function openReviewModal(id, name, uid) {
|
||||
currentInterviewId = id;
|
||||
document.getElementById('modal-cand-name').innerText = name;
|
||||
document.getElementById('modal-cand-uid').innerText = 'Unique Submission ID: ' + uid;
|
||||
|
||||
document.getElementById('review-modal').classList.add('active');
|
||||
initInterviewerPeer();
|
||||
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;
|
||||
|
||||
// Fetch live poll data
|
||||
fetch(`{{ route('interview.poll', ':id') }}`.replace(':id', id))
|
||||
document.getElementById('review-modal').classList.add('active');
|
||||
resetZoomScreen();
|
||||
initInterviewerPeer();
|
||||
switchIdeTab('code');
|
||||
|
||||
pollReviewData();
|
||||
clearInterval(sosPollInterval);
|
||||
sosPollInterval = setInterval(pollReviewData, 4000);
|
||||
}
|
||||
|
||||
function pollReviewData() {
|
||||
if (!currentInterviewId) return;
|
||||
fetch(`{{ route('interview.poll', ':id') }}`.replace(':id', currentInterviewId))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('modal-code-lang').innerText = (data.submitted_language || 'UNKNOWN').toUpperCase();
|
||||
document.getElementById('modal-code-display').innerText = data.submitted_code || '// Candidate has not submitted code yet.';
|
||||
document.getElementById('modal-output-display').innerText = data.code_output || 'No execution output.';
|
||||
document.getElementById('modal-notes-display').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) {
|
||||
drawingImg.src = data.candidate_drawing;
|
||||
drawingImg.style.display = 'block';
|
||||
noDrawing.style.display = 'none';
|
||||
} else {
|
||||
drawingImg.style.display = 'none';
|
||||
noDrawing.style.display = 'block';
|
||||
}
|
||||
|
||||
let logsHtml = '';
|
||||
let cheatingDetected = false;
|
||||
activeViolations = [];
|
||||
|
||||
if (data.proctor_logs && data.proctor_logs.length > 0) {
|
||||
data.proctor_logs.forEach(l => {
|
||||
let icon = '🔴';
|
||||
if (l.type === 'focus_lost') icon = '🟡';
|
||||
if (l.type === 'gaze_anomaly') icon = '👁️';
|
||||
if (l.type === 'paste_event' || l.type === 'tab_switch' || l.type === 'focus_lost') {
|
||||
cheatingDetected = true;
|
||||
activeViolations.push(l);
|
||||
}
|
||||
logsHtml += `<div style="margin-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:4px;">
|
||||
<strong>${icon} ${l.type.toUpperCase()}:</strong> ${l.details || ''}
|
||||
<div style="font-size:0.65rem; color:#94a3b8;">${new Date(l.timestamp).toLocaleTimeString()}</div>
|
||||
@ -550,9 +749,83 @@ function openReviewModal(id, name, uid) {
|
||||
logsHtml = '<div style="color:#34d399;">✅ Zero proctoring violations recorded.</div>';
|
||||
}
|
||||
document.getElementById('modal-logs-display').innerHTML = logsHtml;
|
||||
|
||||
const sosBtn = document.getElementById('sos-alert-btn');
|
||||
if (cheatingDetected) {
|
||||
sosBtn.style.display = 'inline-block';
|
||||
} else {
|
||||
sosBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openSosModal() {
|
||||
let html = '';
|
||||
if (activeViolations.length > 0) {
|
||||
activeViolations.forEach(v => {
|
||||
html += `<div style="margin-bottom: 8px;">
|
||||
<strong>🚨 ${v.type.toUpperCase()}:</strong> ${v.details || 'Suspicious candidate behavior detected.'}
|
||||
<div style="font-size: 0.65rem; color: #94a3b8;">Timestamp: ${new Date(v.timestamp).toLocaleTimeString()}</div>
|
||||
</div>`;
|
||||
});
|
||||
} else {
|
||||
html = '<div>Potential proctoring alert flagged by engine.</div>';
|
||||
}
|
||||
document.getElementById('sos-violation-list').innerHTML = html;
|
||||
document.getElementById('sos-modal').classList.add('active');
|
||||
}
|
||||
|
||||
function stopSosAlarm() {
|
||||
document.getElementById('sos-modal').classList.remove('active');
|
||||
const sosBtn = document.getElementById('sos-alert-btn');
|
||||
if (sosBtn) {
|
||||
sosBtn.style.animation = 'none';
|
||||
sosBtn.innerText = '✅ SOS Acknowledged';
|
||||
sosBtn.style.background = '#475569';
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateCandidatePassword() {
|
||||
if (!currentInterviewId) return;
|
||||
if (!confirm('Are you sure you want to regenerate candidate temporary password?')) return;
|
||||
|
||||
fetch(`{{ route('interview.regenerate-password', ':id') }}`.replace(':id', currentInterviewId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
alert('New Candidate Temporary Password Generated: ' + res.new_password);
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function blockCandidateAction() {
|
||||
if (!currentInterviewId) return;
|
||||
if (!confirm('CRITICAL ACTION: Block this candidate email and client IP address permanently?')) return;
|
||||
|
||||
fetch(`{{ route('interview.block-candidate', ':id') }}`.replace(':id', currentInterviewId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
alert(res.message);
|
||||
closeReviewModal();
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initInterviewerPeer() {
|
||||
if (!currentInterviewId || typeof Peer === 'undefined') return;
|
||||
|
||||
@ -628,14 +901,20 @@ function toggleInterviewerMic() {
|
||||
}
|
||||
|
||||
function toggleInterviewerCam() {
|
||||
if (!interviewerLocalStream) return;
|
||||
const videoTracks = interviewerLocalStream.getVideoTracks();
|
||||
if (videoTracks.length > 0) {
|
||||
const selfPlaceholder = document.getElementById('self-video-placeholder');
|
||||
if (interviewerLocalStream) {
|
||||
const videoTracks = interviewerLocalStream.getVideoTracks();
|
||||
if (videoTracks.length > 0) {
|
||||
isInterviewerCamOn = !isInterviewerCamOn;
|
||||
videoTracks[0].enabled = isInterviewerCamOn;
|
||||
const btn = document.getElementById('btn-toggle-interviewer-cam');
|
||||
btn.innerText = isInterviewerCamOn ? '📷 Cam On' : '🚫 Cam Off';
|
||||
btn.style.background = isInterviewerCamOn ? 'rgba(255,255,255,0.1)' : 'rgba(239,68,68,0.3)';
|
||||
if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex';
|
||||
}
|
||||
} else {
|
||||
isInterviewerCamOn = !isInterviewerCamOn;
|
||||
videoTracks[0].enabled = isInterviewerCamOn;
|
||||
const btn = document.getElementById('btn-toggle-interviewer-cam');
|
||||
btn.innerText = isInterviewerCamOn ? '📷 Cam On' : '🚫 Cam Off';
|
||||
btn.style.background = isInterviewerCamOn ? 'rgba(255,255,255,0.1)' : 'rgba(239,68,68,0.3)';
|
||||
if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
@ -666,6 +945,7 @@ function endInterviewerCall() {
|
||||
}
|
||||
|
||||
function closeReviewModal() {
|
||||
clearInterval(sosPollInterval);
|
||||
endInterviewerCall();
|
||||
document.getElementById('review-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
@ -32,6 +32,8 @@
|
||||
Route::post('/candidate/submit-code/{id}', [InterviewController::class, 'submitCode'])->name('interview.submit');
|
||||
Route::post('/candidate/log-violation/{id}', [InterviewController::class, 'logViolation'])->name('interview.violation');
|
||||
Route::post('/candidate/upload-recording/{id}', [InterviewController::class, 'uploadRecording'])->name('interview.upload-recording');
|
||||
Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes');
|
||||
Route::post('/candidate/drawing/{id}', [InterviewController::class, 'saveCandidateDrawing'])->name('interview.candidate.drawing');
|
||||
|
||||
// User Dashboard Portal (requires authenticated user)
|
||||
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
|
||||
@ -62,6 +64,8 @@
|
||||
Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store');
|
||||
Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning');
|
||||
Route::get('/interviews/{id}/poll', [InterviewController::class, 'getPollData'])->name('interview.poll');
|
||||
Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password');
|
||||
Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate');
|
||||
});
|
||||
|
||||
// Admin Control Panel (requires admin role)
|
||||
|
||||
@ -209,4 +209,60 @@ public function test_interview_zone_tab_visibility_for_assigned_interviewer_duri
|
||||
$interview->update(['expires_at' => now()->subMinute()]);
|
||||
$this->assertFalse($interviewer->fresh()->hasActiveInterviewZone());
|
||||
}
|
||||
|
||||
public function test_candidate_notes_drawing_saving_and_interviewer_password_regen_blocking(): void
|
||||
{
|
||||
$hr = User::create([
|
||||
'name' => 'HR Admin',
|
||||
'email' => 'admin@company.com',
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => 'David Candidate',
|
||||
'candidate_email' => 'david@gmail.com',
|
||||
'candidate_phone' => '9777766666',
|
||||
'temp_password' => 'Pass-777666',
|
||||
'expires_at' => now()->addHours(2),
|
||||
'language' => 'python',
|
||||
'status' => 'in_progress',
|
||||
'submission_unique_id' => 'david-candidate_9777766666_1752000004',
|
||||
]);
|
||||
|
||||
// 1. Candidate saves notepad notes
|
||||
$this->post(route('interview.candidate.notes', $interview->id), [
|
||||
'notes' => 'Scratchpad logic: O(N log N) sorting approach',
|
||||
])->assertStatus(200);
|
||||
|
||||
// 2. Candidate saves canvas drawing data
|
||||
$this->post(route('interview.candidate.drawing', $interview->id), [
|
||||
'drawing' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
])->assertStatus(200);
|
||||
|
||||
$interview->refresh();
|
||||
$this->assertEquals('Scratchpad logic: O(N log N) sorting approach', $interview->candidate_notes);
|
||||
$this->assertNotNull($interview->candidate_drawing);
|
||||
|
||||
// 3. Interviewer regenerates password
|
||||
$regenResp = $this->actingAs($hr)
|
||||
->post(route('interview.regenerate-password', $interview->id));
|
||||
$regenResp->assertStatus(200)->assertJson(['success' => true]);
|
||||
|
||||
$interview->refresh();
|
||||
$this->assertNotEquals('Pass-777666', $interview->temp_password);
|
||||
|
||||
// 4. Interviewer blocks candidate and IP
|
||||
$blockResp = $this->actingAs($hr)
|
||||
->post(route('interview.block-candidate', $interview->id));
|
||||
$blockResp->assertStatus(200)->assertJson(['success' => true]);
|
||||
|
||||
$interview->refresh();
|
||||
$this->assertEquals('blocked', $interview->status);
|
||||
|
||||
// Blocked candidate login should be denied
|
||||
$this->post(route('interview.candidate.login.submit'), [
|
||||
'identifier' => 'david@gmail.com',
|
||||
'temp_password' => $interview->temp_password,
|
||||
])->assertSessionHas('error');
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user