Merge pull request 'subhajit_work_22_07' (#12) from subhajit_work_22_07 into main
Reviewed-on: #12
This commit is contained in:
commit
ce4fa85c69
@ -155,17 +155,55 @@ public function executeCode(Request $request)
|
||||
$code = $request->code;
|
||||
$output = '';
|
||||
|
||||
// 1. Instant Local CLI Process Execution (0.02s response for Node.js, Python, PHP)
|
||||
// 1. Ultra-Fast Piston Code Execution API (sub-second for C, C++, Java, JS, Python, PHP, Go, Rust)
|
||||
$pistonLangMap = [
|
||||
'python' => 'python',
|
||||
'py' => 'python',
|
||||
'javascript' => 'javascript',
|
||||
'js' => 'javascript',
|
||||
'c' => 'c',
|
||||
'cpp' => 'c++',
|
||||
'c++' => 'c++',
|
||||
'java' => 'java',
|
||||
'php' => 'php',
|
||||
'laravel' => 'php',
|
||||
'go' => 'go',
|
||||
'rust' => 'rust',
|
||||
];
|
||||
|
||||
if (isset($pistonLangMap[$langKey])) {
|
||||
try {
|
||||
$pistonLang = $pistonLangMap[$langKey];
|
||||
$response = Http::timeout(4)->post('https://emkc.org/api/v2/piston/execute', [
|
||||
'language' => $pistonLang,
|
||||
'version' => '*',
|
||||
'files' => [
|
||||
['content' => $code]
|
||||
]
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$resData = $response->json();
|
||||
$output = $resData['run']['output'] ?? ($resData['run']['stdout'] ?? '');
|
||||
if (empty($output) && !empty($resData['run']['stderr'])) {
|
||||
$output = $resData['run']['stderr'];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {}
|
||||
}
|
||||
|
||||
// 2. Instant Local Process Runner Fallback
|
||||
if (empty($output)) {
|
||||
try {
|
||||
if (in_array($langKey, ['javascript', 'js'])) {
|
||||
$process = new \Symfony\Component\Process\Process(['node', '-e', $code]);
|
||||
$process->setTimeout(4);
|
||||
$process->setTimeout(3);
|
||||
$process->run();
|
||||
$out = $process->getOutput() ?: $process->getErrorOutput();
|
||||
$output = trim($out) ?: 'JavaScript executed successfully (no stdout).';
|
||||
} elseif ($langKey === 'python') {
|
||||
$process = new \Symfony\Component\Process\Process(['python', '-c', $code]);
|
||||
$process->setTimeout(4);
|
||||
$process->setTimeout(3);
|
||||
$process->run();
|
||||
$out = $process->getOutput() ?: $process->getErrorOutput();
|
||||
$output = trim($out) ?: 'Python executed successfully (no stdout).';
|
||||
@ -181,44 +219,8 @@ public function executeCode(Request $request)
|
||||
$output = 'PHP Evaluation Error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Local execution exception; fall through to Judge0 API
|
||||
}
|
||||
|
||||
// 2. Remote Compiler API (Judge0 CE) for C, C++, Java or non-local runtimes
|
||||
if (empty($output)) {
|
||||
$judge0Map = [
|
||||
'python' => 71,
|
||||
'c' => 50,
|
||||
'cpp' => 54,
|
||||
'c++' => 54,
|
||||
'java' => 62,
|
||||
'php' => 68,
|
||||
'laravel' => 68,
|
||||
'javascript' => 63,
|
||||
'js' => 63,
|
||||
];
|
||||
|
||||
if (isset($judge0Map[$langKey])) {
|
||||
try {
|
||||
$response = Http::timeout(5)->post('https://ce.judge0.com/submissions?wait=true', [
|
||||
'source_code' => $code,
|
||||
'language_id' => $judge0Map[$langKey],
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$stdout = $data['stdout'] ?? '';
|
||||
$stderr = $data['stderr'] ?? '';
|
||||
$compile = $data['compile_output'] ?? '';
|
||||
$msg = $data['message'] ?? '';
|
||||
|
||||
$outputParts = array_filter([$stdout, $stderr, $compile, $msg]);
|
||||
$output = trim(implode("\n", $outputParts));
|
||||
}
|
||||
} catch (\Exception $e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($output)) {
|
||||
$output = "=== Code Execution Status ===\nSolution executed.\nLanguage: " . strtoupper($langKey);
|
||||
@ -533,6 +535,14 @@ public function getActiveCalls(Request $request)
|
||||
$activeCalls = [];
|
||||
|
||||
foreach ($accessible as $interview) {
|
||||
// Auto-terminate call session if interview timeline has expired
|
||||
if ($interview->isExpired()) {
|
||||
if ($interview->call_status === 'active') {
|
||||
$interview->update(['call_status' => 'ended']);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($interview->call_status === 'active') {
|
||||
$starterName = 'Panelist';
|
||||
if ($interview->call_started_by) {
|
||||
@ -570,6 +580,14 @@ public function getPollData($id)
|
||||
->where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
// Enforce expiration: ended call status if timeline is expired
|
||||
if ($interview->isExpired()) {
|
||||
if ($interview->call_status === 'active') {
|
||||
$interview->update(['call_status' => 'ended']);
|
||||
}
|
||||
}
|
||||
|
||||
$formattedRecordings = $this->getFormattedRecordings($interview);
|
||||
|
||||
$starterName = $interview->callStarter ? $interview->callStarter->name : null;
|
||||
@ -580,7 +598,7 @@ public function getPollData($id)
|
||||
|
||||
return response()->json([
|
||||
'status' => $interview->status,
|
||||
'call_status' => $interview->call_status ?? 'idle',
|
||||
'call_status' => $interview->isExpired() ? 'ended' : ($interview->call_status ?? 'idle'),
|
||||
'call_started_by' => $interview->call_started_by,
|
||||
'starter_name' => $starterName,
|
||||
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
|
||||
|
||||
@ -577,12 +577,15 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
@ -910,76 +913,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Global Admin Settings & Proctoring Control Panel -->
|
||||
<section class="admin-card" style="margin-bottom: 35px; background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%); border: 1.5px solid rgba(99, 102, 241, 0.35); box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5); border-radius: 16px;">
|
||||
<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 style="font-family: 'Outfit', sans-serif; font-size: 1.3rem; font-weight: 800; color: #ffffff; margin: 0; display: flex; align-items: center; gap: 8px;">
|
||||
⚙️ Global Admin Proctoring & System Settings
|
||||
</h3>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;">Master control panel for candidate assessment rules, system screen capturing, and module toggles</div>
|
||||
</div>
|
||||
<span class="badge" style="background: rgba(99, 102, 241, 0.2); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.4); font-weight: 700; padding: 6px 12px; font-size: 0.75rem;">
|
||||
🛡️ ADMIN CONTROL PANEL
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-top: 16px;">
|
||||
|
||||
<!-- 1. Candidate System Screenshot Capture Master Toggle -->
|
||||
<div style="padding: 16px; background: rgba(16, 185, 129, 0.08); border: 1.5px solid rgba(16, 185, 129, 0.3); border-radius: 12px;">
|
||||
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST">
|
||||
@csrf
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div style="font-size: 0.95rem; color: #ffffff; font-weight: 700; display: flex; align-items: center; gap: 6px;">
|
||||
📸 Candidate System Screenshot Capture
|
||||
</div>
|
||||
<div style="font-size: 0.73rem; color: var(--text-muted); margin-top: 4px; line-height: 1.4;">
|
||||
Captures candidate system desktop screen automatically on call start & tab switches. Admin can stop taking screenshots anytime across all candidates.
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.08);">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🟢 ENABLED (Active)' : '🔴 STOPPED / DISABLED' }}
|
||||
</span>
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 20px; height: 20px; cursor: pointer;">
|
||||
<span style="font-size: 0.8rem; font-weight: 800; color: white; background: rgba(255,255,255,0.12); padding: 5px 12px; border-radius: 6px;">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'Stop Screenshot Capture' : 'Enable Screenshot Capture' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 2. Onboarding & Offboarding Module Master Toggle -->
|
||||
<div style="padding: 16px; background: rgba(99, 102, 241, 0.08); border: 1.5px solid rgba(99, 102, 241, 0.3); border-radius: 12px;">
|
||||
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST">
|
||||
@csrf
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div style="font-size: 0.95rem; color: #ffffff; font-weight: 700; display: flex; align-items: center; gap: 6px;">
|
||||
🚀 Candidate Provisioning & Onboarding Module
|
||||
</div>
|
||||
<div style="font-size: 0.73rem; color: var(--text-muted); margin-top: 4px; line-height: 1.4;">
|
||||
Master toggle to enable/disable automated candidate provisioning, onboarding checklists, and 1-click revocation.
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.08);">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '🟢 ENABLED' : '🔴 DISABLED' }}
|
||||
</span>
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" name="onboarding_enabled" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 20px; height: 20px; cursor: pointer;">
|
||||
<span style="font-size: 0.8rem; font-weight: 800; color: white; background: rgba(255,255,255,0.12); padding: 5px 12px; border-radius: 6px;">
|
||||
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'Disable Onboarding' : 'Enable Onboarding' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Users Table Card -->
|
||||
<section class="users-card" style="margin-bottom: 50px;">
|
||||
@ -1244,40 +1178,6 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Onboarding & Offboarding Module Master Toggle -->
|
||||
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST" style="margin-bottom: 12px; padding: 12px 14px; background: rgba(99, 102, 241, 0.08); border: 1px solid rgba(99, 102, 241, 0.25); border-radius: 12px;">
|
||||
@csrf
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<div style="font-size: 0.8rem; color: #ffffff; font-weight: 600;">🚀 Onboarding & Offboarding Module</div>
|
||||
<div style="font-size: 0.7rem; color: var(--text-muted);">Enable/disable candidate provisioning & 1-click revocation</div>
|
||||
</div>
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" name="onboarding_enabled" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 18px; height: 18px;">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'ENABLED' : 'DISABLED' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Candidate System Screenshot Master Toggle (Admin Control) -->
|
||||
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST" style="margin-bottom: 20px; padding: 12px 14px; background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.25); border-radius: 12px;">
|
||||
@csrf
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<div style="font-size: 0.8rem; color: #ffffff; font-weight: 600;">📸 Candidate System Screenshot Capture</div>
|
||||
<div style="font-size: 0.7rem; color: var(--text-muted);">Master Admin control to stop/start candidate system screen capturing (call start & tab switch)</div>
|
||||
</div>
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 18px; height: 18px;">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'ENABLED' : 'STOPPED / DISABLED' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Registered Roles List -->
|
||||
<div class="table-responsive" style="max-height: 380px; overflow-y: auto;">
|
||||
<table class="users-table" style="font-size:0.8rem;">
|
||||
|
||||
@ -368,7 +368,7 @@
|
||||
<div style="color: #94a3b8; font-style: italic; text-align: center; padding-top: 40px;">No messages yet. Send a message below.</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<input type="text" id="candidate-chat-input" placeholder="Type message to panelist..." onkeydown="if(event.key==='Enter') sendCandidateMessage()" class="form-input" style="font-size: 0.75rem; background: #0b0f19; border: 1px solid var(--card-border); padding: 6px 10px; flex: 1;">
|
||||
<input type="text" id="candidate-chat-input" placeholder="Type message to panelist..." onkeydown="if(event.key==='Enter') sendCandidateMessage()" class="form-input" style="font-size: 0.75rem; background: #0b0f19; color: #ffffff !important; border: 1px solid var(--card-border); padding: 6px 10px; flex: 1;">
|
||||
<button onclick="sendCandidateMessage()" class="btn-ide" style="padding: 6px 10px; font-size: 0.75rem; background: #6366f1; border: none; font-weight: 600;">
|
||||
Send
|
||||
</button>
|
||||
@ -458,13 +458,25 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Real-Time Code Sync to Interviewer (Before Submission)
|
||||
// Real-Time Instant BroadcastChannel + Backend Code Sync
|
||||
const liveSyncChannel = new BroadcastChannel('live_sync_' + '{{ $interview->id }}');
|
||||
liveSyncChannel.onmessage = function(event) {
|
||||
if (event.data && event.data.type === 'chat_message' && typeof pollCandidateChat === 'function') {
|
||||
pollCandidateChat();
|
||||
}
|
||||
};
|
||||
let codeSyncTimeout = null;
|
||||
editor.onDidChangeModelContent(function () {
|
||||
clearTimeout(codeSyncTimeout);
|
||||
codeSyncTimeout = setTimeout(function () {
|
||||
const code = editor.getValue();
|
||||
const lang = document.getElementById('language-select').value;
|
||||
|
||||
// Broadcast instant local event (0ms latency)
|
||||
if (typeof liveSyncChannel !== 'undefined' && liveSyncChannel) {
|
||||
liveSyncChannel.postMessage({ type: 'code_sync', code: code, language: lang });
|
||||
}
|
||||
|
||||
clearTimeout(codeSyncTimeout);
|
||||
codeSyncTimeout = setTimeout(function () {
|
||||
fetch(`{{ route("interview.candidate.sync-code", $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -473,7 +485,7 @@
|
||||
},
|
||||
body: JSON.stringify({ code: code, language: lang })
|
||||
});
|
||||
}, 600);
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1176,6 +1188,10 @@ function showCandidateJoinOption() {
|
||||
|
||||
if (joinBtn) joinBtn.style.display = 'none';
|
||||
|
||||
if (!localMediaStream) {
|
||||
await startCandidateCameraStream();
|
||||
}
|
||||
|
||||
isCandidateCallConnected = true;
|
||||
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
@ -1380,6 +1396,12 @@ function initCandidatePeer() {
|
||||
|
||||
peerInstance.on('call', call => {
|
||||
activeCallInstance = call;
|
||||
latestCallStatus = 'active';
|
||||
|
||||
if (!isCandidateCallConnected && !candidateDeclinedOrLeftCall) {
|
||||
triggerCandidateRing();
|
||||
}
|
||||
|
||||
const sendStream = localMediaStream || new MediaStream();
|
||||
call.answer(sendStream);
|
||||
|
||||
@ -1388,16 +1410,18 @@ function initCandidatePeer() {
|
||||
const placeholder = document.getElementById('interviewer-video-placeholder');
|
||||
if (remoteVideo) {
|
||||
remoteVideo.srcObject = remoteStream;
|
||||
remoteVideo.play().catch(e => console.log(e));
|
||||
remoteVideo.play().catch(e => console.log('Candidate remote video play error:', e));
|
||||
}
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
|
||||
if (isCandidateCallConnected) {
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
|
||||
badge.style.background = 'rgba(16,185,129,0.3)';
|
||||
badge.style.color = '#34d399';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
call.on('close', () => {
|
||||
@ -1805,6 +1829,10 @@ function sendCandidateMessage() {
|
||||
if (!msg) return;
|
||||
|
||||
input.value = '';
|
||||
if (typeof liveSyncChannel !== 'undefined' && liveSyncChannel) {
|
||||
liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'candidate' });
|
||||
}
|
||||
|
||||
fetch(`{{ route('interview.candidate.send-message', $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -1815,9 +1843,7 @@ function sendCandidateMessage() {
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
pollCandidateChat();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1830,9 +1856,13 @@ function toggleNotepadModal() {
|
||||
}
|
||||
|
||||
function saveNotepadContent() {
|
||||
const notes = document.getElementById('notepad-textarea').value;
|
||||
if (typeof liveSyncChannel !== 'undefined' && liveSyncChannel) {
|
||||
liveSyncChannel.postMessage({ type: 'notes_sync', notes: notes });
|
||||
}
|
||||
|
||||
clearTimeout(notesTimeout);
|
||||
notesTimeout = setTimeout(() => {
|
||||
const notes = document.getElementById('notepad-textarea').value;
|
||||
fetch(`{{ route('interview.candidate.notes', $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -1841,13 +1871,14 @@ function saveNotepadContent() {
|
||||
},
|
||||
body: JSON.stringify({ notes: notes })
|
||||
});
|
||||
}, 300);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// HTML5 Canvas Drawing Logic
|
||||
let isDrawing = false;
|
||||
let canvas, ctx;
|
||||
let drawingSaveTimeout = null;
|
||||
let lastDrawingSaveTime = 0;
|
||||
|
||||
function toggleDrawingModal() {
|
||||
const modal = document.getElementById('drawing-modal');
|
||||
@ -1889,29 +1920,38 @@ function draw(e) {
|
||||
ctx.strokeStyle = document.getElementById('draw-color').value;
|
||||
ctx.lineTo(e.offsetX, e.offsetY);
|
||||
ctx.stroke();
|
||||
saveCanvasDrawing();
|
||||
saveCanvasDrawing(false);
|
||||
}
|
||||
|
||||
function stopDraw() {
|
||||
if (isDrawing) {
|
||||
isDrawing = false;
|
||||
ctx.closePath();
|
||||
saveCanvasDrawing();
|
||||
saveCanvasDrawing(true);
|
||||
}
|
||||
}
|
||||
|
||||
function clearCanvasDraw() {
|
||||
if (ctx && canvas) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
saveCanvasDrawing();
|
||||
saveCanvasDrawing(true);
|
||||
}
|
||||
}
|
||||
|
||||
function saveCanvasDrawing() {
|
||||
clearTimeout(drawingSaveTimeout);
|
||||
drawingSaveTimeout = setTimeout(() => {
|
||||
function saveCanvasDrawing(force = false) {
|
||||
if (!canvas) return;
|
||||
const now = Date.now();
|
||||
const drawingData = canvas.toDataURL();
|
||||
|
||||
// Broadcast instant local event (0ms latency)
|
||||
if (typeof liveSyncChannel !== 'undefined' && liveSyncChannel) {
|
||||
liveSyncChannel.postMessage({ type: 'drawing_sync', drawing: drawingData });
|
||||
}
|
||||
|
||||
// Throttled HTTP POST save to backend (fires every 250ms while drawing & on mouseup)
|
||||
if (force || (now - lastDrawingSaveTime >= 250)) {
|
||||
clearTimeout(drawingSaveTimeout);
|
||||
lastDrawingSaveTime = now;
|
||||
fetch(`{{ route('interview.candidate.drawing', $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -1920,7 +1960,12 @@ function saveCanvasDrawing() {
|
||||
},
|
||||
body: JSON.stringify({ drawing: drawingData })
|
||||
});
|
||||
}, 300);
|
||||
} else {
|
||||
clearTimeout(drawingSaveTimeout);
|
||||
drawingSaveTimeout = setTimeout(() => {
|
||||
saveCanvasDrawing(true);
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
@ -1944,18 +1989,13 @@ function saveCanvasDrawing() {
|
||||
}, 1200);
|
||||
}
|
||||
if (!isCandidateCallConnected && !candidateDeclinedOrLeftCall) {
|
||||
const elapsed = data.call_started_at ? (Date.now() - new Date(data.call_started_at).getTime()) : 999999;
|
||||
if (elapsed <= 12000 && lastRungCallStartedAt !== data.call_started_at) {
|
||||
lastRungCallStartedAt = data.call_started_at;
|
||||
triggerCandidateRing(null, data.call_started_at);
|
||||
} else if (elapsed > 12000) {
|
||||
showCandidateJoinOption();
|
||||
}
|
||||
}
|
||||
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
|
||||
latestCallStatus = data.call_status;
|
||||
hasCapturedCallStartScreenshot = false;
|
||||
lastRungCallStartedAt = null;
|
||||
candidateDeclinedOrLeftCall = false;
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) clearTimeout(candRingTimer);
|
||||
isCandidateCallConnected = false;
|
||||
@ -1978,7 +2018,7 @@ function saveCanvasDrawing() {
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 2200);
|
||||
}, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -128,10 +128,14 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
@ -215,33 +219,81 @@
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
<!-- Admin Proctoring & System Screenshot Control Banner -->
|
||||
<div style="margin-bottom: 20px; padding: 14px 20px; background: linear-gradient(135deg, rgba(15, 23, 42, 0.9) 0%, rgba(30, 41, 59, 0.9) 100%); border: 1.5px solid rgba(16, 185, 129, 0.35); border-radius: 14px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<div style="font-size: 1.5rem;">📸</div>
|
||||
<!-- Global Admin Settings & Proctoring Control Panel -->
|
||||
<section class="admin-card" style="margin-bottom: 25px; padding: 20px; background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%); border: 1.5px solid rgba(99, 102, 241, 0.35); box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5); border-radius: 16px;">
|
||||
<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>
|
||||
<div style="font-size: 0.9rem; font-weight: 700; color: white;">Global Candidate System Screenshot Control</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted);">
|
||||
Captures candidate desktop screen on call start & tab switches.
|
||||
Admin can stop/start screen capturing globally anytime.
|
||||
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 800; color: #ffffff; margin: 0; display: flex; align-items: center; gap: 8px;">
|
||||
⚙️ Global Admin Proctoring & System Settings
|
||||
</h3>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;">Master control panel for candidate assessment rules, system screen capturing, and module toggles</div>
|
||||
</div>
|
||||
<span class="badge" style="background: rgba(99, 102, 241, 0.2); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.4); font-weight: 700; padding: 6px 12px; font-size: 0.75rem;">
|
||||
🛡️ ADMIN CONTROL PANEL
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-top: 16px;">
|
||||
|
||||
<!-- 1. Candidate System Screenshot Capture Master Toggle -->
|
||||
<div style="padding: 16px; background: rgba(16, 185, 129, 0.08); border: 1.5px solid rgba(16, 185, 129, 0.3); border-radius: 12px;">
|
||||
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST">
|
||||
@csrf
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div style="font-size: 0.95rem; color: #ffffff; font-weight: 700; display: flex; align-items: center; gap: 6px;">
|
||||
📸 Candidate System Screenshot Capture
|
||||
</div>
|
||||
<div style="font-size: 0.73rem; color: var(--text-muted); margin-top: 4px; line-height: 1.4;">
|
||||
Captures candidate system desktop screen automatically on call start & tab switches. Admin can stop taking screenshots anytime across all candidates.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<span id="global-screenshot-status-text" style="font-size: 0.8rem; font-weight: 700; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🟢 ENABLED (Screen Capturing Active)' : '🔴 STOPPED / DISABLED' }}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.08);">
|
||||
<span id="global-screenshot-status-text" style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🟢 ENABLED (Active)' : '🔴 STOPPED / DISABLED' }}
|
||||
</span>
|
||||
@if(Auth::user() && Auth::user()->isAdmin())
|
||||
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST" style="margin: 0;">
|
||||
@csrf
|
||||
<button type="submit" class="btn-nav" style="padding: 6px 14px; font-size: 0.75rem; font-weight: 700; background: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)' }}; color: {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#fca5a5' : '#34d399' }}; border: 1px solid {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '#ef4444' : '#10b981' }}; border-radius: 8px; cursor: pointer;">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🛑 Stop Screenshot Capture' : '🟢 Start Screenshot Capture' }}
|
||||
</button>
|
||||
</form>
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 20px; height: 20px; cursor: pointer;">
|
||||
<span style="font-size: 0.8rem; font-weight: 800; color: white; background: rgba(255,255,255,0.12); padding: 5px 12px; border-radius: 6px;">
|
||||
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'Stop Screenshot Capture' : 'Enable Screenshot Capture' }}
|
||||
</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 2. Onboarding & Offboarding Module Master Toggle -->
|
||||
<div style="padding: 16px; background: rgba(99, 102, 241, 0.08); border: 1.5px solid rgba(99, 102, 241, 0.3); border-radius: 12px;">
|
||||
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST">
|
||||
@csrf
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div style="font-size: 0.95rem; color: #ffffff; font-weight: 700; display: flex; align-items: center; gap: 6px;">
|
||||
🚀 Candidate Provisioning & Onboarding Module
|
||||
</div>
|
||||
<div style="font-size: 0.73rem; color: var(--text-muted); margin-top: 4px; line-height: 1.4;">
|
||||
Master toggle to enable/disable automated candidate provisioning, onboarding checklists, and 1-click revocation.
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.08);">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '#34d399' : '#fca5a5' }};">
|
||||
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '🟢 ENABLED' : '🔴 DISABLED' }}
|
||||
</span>
|
||||
@if(Auth::user() && Auth::user()->isAdmin())
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" name="onboarding_enabled" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'checked' : '' }} style="accent-color: #10b981; width: 20px; height: 20px; cursor: pointer;">
|
||||
<span style="font-size: 0.8rem; font-weight: 800; color: white; background: rgba(255,255,255,0.12); padding: 5px 12px; border-radius: 6px;">
|
||||
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'Disable Onboarding' : 'Enable Onboarding' }}
|
||||
</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
||||
<div>
|
||||
<h2 style="font-family: 'Outfit', sans-serif; font-size: 1.4rem; font-weight: 700; color: white; margin: 0;">Live Interview Assessments</h2>
|
||||
@ -697,7 +749,7 @@
|
||||
<div style="color: #94a3b8; font-style: italic; text-align: center; padding-top: 40px;">No chat history. Send a message below.</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<input type="text" id="warning-input" placeholder="Type message to candidate..." onkeydown="if(event.key==='Enter') sendSilentWarning()" class="form-input" style="font-size: 0.75rem; background: #0b0f19; padding: 6px 10px; flex: 1;">
|
||||
<input type="text" id="warning-input" placeholder="Type message to candidate..." onkeydown="if(event.key==='Enter') sendSilentWarning()" class="form-input" style="font-size: 0.75rem; background: #0b0f19; color: #ffffff !important; border: 1px solid var(--card-border); padding: 6px 10px; flex: 1;">
|
||||
<button onclick="sendSilentWarning()" class="btn-nav" style="background: #6366f1; color: white; border: none; font-size: 0.75rem; font-weight: 600; padding: 6px 12px; cursor: pointer;">
|
||||
Send
|
||||
</button>
|
||||
@ -1029,17 +1081,24 @@ class CallRingtoneEngine {
|
||||
const endedCallIds = new Set();
|
||||
const currentUserId = {{ Auth::id() }};
|
||||
const globalCallChannel = new BroadcastChannel('global_call_alerts');
|
||||
const adminPageLoadTimestamp = Date.now();
|
||||
|
||||
function triggerIncomingCallRing(callData) {
|
||||
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;
|
||||
|
||||
// Check ring 10-second threshold based on call_started_at
|
||||
// Only play ringtone for real-time fresh calls initiated within the last 8 seconds
|
||||
if (callData.call_started_at) {
|
||||
const startedTime = new Date(callData.call_started_at).getTime();
|
||||
const elapsedMs = Date.now() - startedTime;
|
||||
if (elapsedMs > 10000) return;
|
||||
if (elapsedMs > 8000 && !isRealtimeBroadcast) {
|
||||
dismissedCallIds.add(callData.id);
|
||||
return;
|
||||
}
|
||||
} else if (!isRealtimeBroadcast) {
|
||||
dismissedCallIds.add(callData.id);
|
||||
return;
|
||||
}
|
||||
|
||||
activeIncomingCall = callData;
|
||||
@ -1048,13 +1107,13 @@ function triggerIncomingCallRing(callData) {
|
||||
document.getElementById('incoming-call-modal').classList.add('active');
|
||||
callRingtone.start();
|
||||
|
||||
// Set 10-second auto-stop ring timeout
|
||||
// Set 8-second auto-stop ring timeout
|
||||
if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer);
|
||||
let remainingMs = 10000;
|
||||
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, 10000 - elapsedMs);
|
||||
remainingMs = Math.max(1000, 8000 - elapsedMs);
|
||||
}
|
||||
|
||||
ringTimeoutTimer = setTimeout(() => {
|
||||
@ -1073,7 +1132,7 @@ function triggerIncomingCallRing(callData) {
|
||||
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);
|
||||
@ -1111,8 +1170,16 @@ function pollGlobalActiveCalls() {
|
||||
});
|
||||
|
||||
if (incoming) {
|
||||
// Check if call was started before page refresh
|
||||
const startedTime = incoming.call_started_at ? new Date(incoming.call_started_at).getTime() : 0;
|
||||
if (startedTime > 0 && startedTime < (adminPageLoadTimestamp - 4000)) {
|
||||
// Ongoing call from before page reload: silent status update without ringtone
|
||||
dismissedCallIds.add(incoming.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) {
|
||||
triggerIncomingCallRing(incoming);
|
||||
triggerIncomingCallRing(incoming, false);
|
||||
}
|
||||
} else if (activeIncomingCall) {
|
||||
declineIncomingCall(true);
|
||||
@ -1121,8 +1188,8 @@ function pollGlobalActiveCalls() {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Poll for active calls every 1 second for instant ring notification
|
||||
setInterval(pollGlobalActiveCalls, 1000);
|
||||
// Poll for active calls every 1.5 seconds
|
||||
setInterval(pollGlobalActiveCalls, 1500);
|
||||
|
||||
function acceptIncomingCall() {
|
||||
callRingtone.stop();
|
||||
@ -1165,6 +1232,50 @@ function declineIncomingCall(silent = false) {
|
||||
}
|
||||
}
|
||||
|
||||
let liveSyncChannel = null;
|
||||
|
||||
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') {
|
||||
pollReviewData();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function openReviewModal(id, name, uid, autoJoinCall = false) {
|
||||
currentInterviewId = id;
|
||||
acknowledgedViolationCount = 0;
|
||||
@ -1184,9 +1295,10 @@ function openReviewModal(id, name, uid, autoJoinCall = false) {
|
||||
initInterviewerPeer();
|
||||
switchIdeTab('code');
|
||||
|
||||
subscribeLiveSync(id);
|
||||
pollReviewData();
|
||||
clearInterval(sosPollInterval);
|
||||
sosPollInterval = setInterval(pollReviewData, 1200);
|
||||
sosPollInterval = setInterval(pollReviewData, 500);
|
||||
|
||||
if (autoJoinCall) {
|
||||
setTimeout(() => {
|
||||
@ -2120,6 +2232,10 @@ function endInterviewerCall() {
|
||||
|
||||
function closeReviewModal() {
|
||||
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');
|
||||
@ -2131,6 +2247,10 @@ function sendSilentWarning() {
|
||||
if (!msg || !currentInterviewId) return;
|
||||
|
||||
input.value = '';
|
||||
if (typeof liveSyncChannel !== 'undefined' && liveSyncChannel) {
|
||||
liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'interviewer' });
|
||||
}
|
||||
|
||||
fetch(`{{ route('interview.warning', ':id') }}`.replace(':id', currentInterviewId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -2141,9 +2261,7 @@ function sendSilentWarning() {
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
pollReviewData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user