implement candidate feature
This commit is contained in:
parent
9e9e38e8c8
commit
3d7d3042ed
@ -306,6 +306,18 @@ public function updateOnboardingToggle(Request $request)
|
||||
return redirect()->route('admin.dashboard')->with('success', $msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle candidate system screenshot capture globally.
|
||||
*/
|
||||
public function updateScreenshotToggle(Request $request)
|
||||
{
|
||||
$enabled = $request->boolean('enable_candidate_screenshots') ? '1' : '0';
|
||||
\App\Models\Setting::set('enable_candidate_screenshots', $enabled);
|
||||
|
||||
$msg = $enabled === '1' ? 'Candidate System Screenshot Capture ENABLED globally.' : 'Candidate System Screenshot Capture DISABLED globally by Admin.';
|
||||
return redirect()->route('admin.dashboard')->with('success', $msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the admin privilege of a user.
|
||||
*/
|
||||
|
||||
@ -148,96 +148,96 @@ public function executeCode(Request $request)
|
||||
$request->validate([
|
||||
'language' => 'required|string',
|
||||
'code' => 'required|string',
|
||||
'interview_id' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$langMap = [
|
||||
'python' => ['language' => 'python', 'version' => '*'],
|
||||
'c' => ['language' => 'c', 'version' => '*'],
|
||||
'cpp' => ['language' => 'c++', 'version' => '*'],
|
||||
'java' => ['language' => 'java', 'version' => '*'],
|
||||
'php' => ['language' => 'php', 'version' => '*'],
|
||||
'laravel' => ['language' => 'php', 'version' => '*'],
|
||||
'javascript' => ['language' => 'javascript', 'version' => '*'],
|
||||
];
|
||||
|
||||
$langKey = strtolower($request->language);
|
||||
$targetLang = $langMap[$langKey] ?? ['language' => 'python', 'version' => '*'];
|
||||
$langKey = strtolower(trim($request->language));
|
||||
$code = $request->code;
|
||||
$output = '';
|
||||
|
||||
// 1. Instant Local CLI Process Execution (0.02s response for Node.js, Python, PHP)
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [
|
||||
'language' => $targetLang['language'],
|
||||
'version' => $targetLang['version'],
|
||||
'files' => [
|
||||
[
|
||||
'name' => 'solution',
|
||||
'content' => $request->code,
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully. No stdout output.');
|
||||
return response()->json(['success' => true, 'output' => $output]);
|
||||
}
|
||||
|
||||
// Retry with explicit fallback version if wildcard returned non-200
|
||||
$fallbackVersions = [
|
||||
'python' => '3.10.0',
|
||||
'c' => '10.2.0',
|
||||
'c++' => '10.2.0',
|
||||
'java' => '15.0.2',
|
||||
'php' => '8.2.3',
|
||||
'javascript' => '18.15.0',
|
||||
];
|
||||
|
||||
$retryResponse = Http::withHeaders([
|
||||
'User-Agent' => 'SingleLogin-ProctoredIDE/1.0',
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [
|
||||
'language' => $targetLang['language'],
|
||||
'version' => $fallbackVersions[$targetLang['language']] ?? '3.10.0',
|
||||
'files' => [
|
||||
[
|
||||
'name' => 'solution',
|
||||
'content' => $request->code,
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
if ($retryResponse->successful()) {
|
||||
$data = $retryResponse->json();
|
||||
$output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully.');
|
||||
return response()->json(['success' => true, 'output' => $output]);
|
||||
}
|
||||
|
||||
// Fallback for local PHP execution if PHP/Laravel language requested
|
||||
if (in_array($langKey, ['php', 'laravel'])) {
|
||||
if (in_array($langKey, ['javascript', 'js'])) {
|
||||
$process = new \Symfony\Component\Process\Process(['node', '-e', $code]);
|
||||
$process->setTimeout(4);
|
||||
$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->run();
|
||||
$out = $process->getOutput() ?: $process->getErrorOutput();
|
||||
$output = trim($out) ?: 'Python executed successfully (no stdout).';
|
||||
} elseif (in_array($langKey, ['php', 'laravel'])) {
|
||||
ob_start();
|
||||
try {
|
||||
$cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $request->code);
|
||||
$cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $code);
|
||||
eval($cleanCode);
|
||||
$localOutput = ob_get_clean();
|
||||
return response()->json(['success' => true, 'output' => $localOutput ?: '[Local PHP Execution Succeeded]']);
|
||||
$out = ob_get_clean();
|
||||
$output = trim($out) ?: 'PHP code executed successfully.';
|
||||
} catch (\Throwable $e) {
|
||||
ob_end_clean();
|
||||
return response()->json(['success' => true, 'output' => 'PHP Evaluation Error: ' . $e->getMessage()]);
|
||||
$output = 'PHP Evaluation Error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'output' => "=== Code Output ===\n" . "Code submitted successfully for evaluation.\n(Remote execution engine status: " . $response->status() . ")\nSolution saved to proctored record."
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'output' => "=== Code Output ===\nCode session active.\nSolution saved to proctored record."
|
||||
]);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Auto-persist candidate code + execution output directly to interview model
|
||||
$interviewId = $request->input('interview_id');
|
||||
if ($interviewId) {
|
||||
$interview = Interview::where('id', $interviewId)->orWhere('submission_unique_id', $interviewId)->first();
|
||||
if ($interview) {
|
||||
$interview->update([
|
||||
'submitted_code' => $code,
|
||||
'submitted_language' => $langKey,
|
||||
'code_output' => $output,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'output' => $output]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -340,10 +340,14 @@ public function candidateSendMessage(Request $request, $id)
|
||||
public function syncCandidateCode(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$interview->update([
|
||||
$updateData = [
|
||||
'submitted_code' => $request->input('code'),
|
||||
'submitted_language' => $request->input('language', $interview->language),
|
||||
]);
|
||||
];
|
||||
if ($request->has('output')) {
|
||||
$updateData['code_output'] = $request->input('output');
|
||||
}
|
||||
$interview->update($updateData);
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
@ -570,13 +574,18 @@ public function getPollData($id)
|
||||
|
||||
$starterName = $interview->callStarter ? $interview->callStarter->name : null;
|
||||
|
||||
$globalScreenshotsEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1';
|
||||
$interviewScreenshotsEnabled = (bool) ($interview->enable_tab_switch_screenshot ?? true);
|
||||
$effectiveScreenshotEnabled = $globalScreenshotsEnabled && $interviewScreenshotsEnabled;
|
||||
|
||||
return response()->json([
|
||||
'status' => $interview->status,
|
||||
'call_status' => $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,
|
||||
'enable_tab_switch_screenshot' => (bool) ($interview->enable_tab_switch_screenshot ?? true),
|
||||
'enable_tab_switch_screenshot' => $effectiveScreenshotEnabled,
|
||||
'global_screenshot_enabled' => $globalScreenshotsEnabled,
|
||||
'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [],
|
||||
'submitted_code' => $interview->submitted_code,
|
||||
'submitted_language' => $interview->submitted_language,
|
||||
@ -633,7 +642,7 @@ public function updateCallStatus(Request $request, $id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload & Save Candidate Tab-Switch Screenshot under public/uploads/candidate_screenshots/{unique_id}/.
|
||||
* Upload & Save Candidate System Screen Screenshot under public/uploads/candidate_screenshots/{unique_id}/.
|
||||
*/
|
||||
public function uploadTabScreenshot(Request $request, $id)
|
||||
{
|
||||
@ -641,15 +650,18 @@ public function uploadTabScreenshot(Request $request, $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
if (isset($interview->enable_tab_switch_screenshot) && !$interview->enable_tab_switch_screenshot) {
|
||||
$globalEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1';
|
||||
$interviewEnabled = !isset($interview->enable_tab_switch_screenshot) || $interview->enable_tab_switch_screenshot;
|
||||
|
||||
if (!$globalEnabled || !$interviewEnabled) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Tab-switch screenshot capture is disabled by admin setting.'
|
||||
'message' => 'Candidate system screenshot capture is disabled by admin setting.'
|
||||
]);
|
||||
}
|
||||
|
||||
$url = null;
|
||||
$filename = 'screenshot_tab_switch_' . time() . '_' . rand(100, 999) . '.jpg';
|
||||
$reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch'
|
||||
$filename = 'screenshot_' . $reason . '_' . time() . '_' . rand(100, 999) . '.jpg';
|
||||
$subDir = 'uploads/candidate_screenshots/' . $interview->submission_unique_id;
|
||||
$destinationPath = public_path($subDir);
|
||||
|
||||
@ -657,6 +669,7 @@ public function uploadTabScreenshot(Request $request, $id)
|
||||
mkdir($destinationPath, 0777, true);
|
||||
}
|
||||
|
||||
$url = null;
|
||||
if ($request->hasFile('screenshot')) {
|
||||
$file = $request->file('screenshot');
|
||||
$file->move($destinationPath, $filename);
|
||||
@ -685,7 +698,7 @@ public function uploadTabScreenshot(Request $request, $id)
|
||||
'filename' => $filename,
|
||||
'ai_verdict' => $aiVerdict,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'reason' => 'tab_switch',
|
||||
'reason' => $reason,
|
||||
];
|
||||
|
||||
$screenshots = $interview->tab_switch_screenshots ?? [];
|
||||
@ -693,10 +706,13 @@ public function uploadTabScreenshot(Request $request, $id)
|
||||
|
||||
$aiDetails = $aiVerdict['summary'] . ' (' . round(($aiVerdict['confidence'] ?? 0.85) * 100) . '% Confidence - ' . ($aiVerdict['engine'] ?? 'AI Engine') . ')';
|
||||
|
||||
$logType = ($reason === 'call_start') ? 'call_start_screenshot' : (($aiVerdict['is_cheating'] && str_contains(strtolower($aiVerdict['ai_tool_detected'] ?? ''), 'parakeet')) ? 'external_ai_detected' : 'tab_switch');
|
||||
$logMsg = ($reason === 'call_start') ? '📸 CALL STARTED SYSTEM SNAPSHOT: System screen snapshot captured automatically when call started.' : ('🤖 AI INSPECTOR VERDICT: ' . $aiDetails);
|
||||
|
||||
$logs = $interview->proctor_logs ?? [];
|
||||
$logs[] = [
|
||||
'type' => ($aiVerdict['is_cheating'] && str_contains(strtolower($aiVerdict['ai_tool_detected'] ?? ''), 'parakeet')) ? 'external_ai_detected' : 'tab_switch',
|
||||
'details' => '🤖 AI INSPECTOR VERDICT: ' . $aiDetails,
|
||||
'type' => $logType,
|
||||
'details' => $logMsg,
|
||||
'ai_verdict' => $aiVerdict,
|
||||
'screenshot_url' => $relativePath,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@ -910,6 +910,77 @@
|
||||
</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;">
|
||||
<div class="card-header" style="margin-bottom: 20px;">
|
||||
@ -1174,7 +1245,7 @@
|
||||
</form>
|
||||
|
||||
<!-- Onboarding & Offboarding Module Master Toggle -->
|
||||
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST" style="margin-bottom: 20px; padding: 12px 14px; background: rgba(99, 102, 241, 0.08); border: 1px solid rgba(99, 102, 241, 0.25); border-radius: 12px;">
|
||||
<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>
|
||||
@ -1190,6 +1261,23 @@
|
||||
</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;">
|
||||
|
||||
@ -487,7 +487,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Execute Code live via Piston API
|
||||
// Execute Code live via Piston/Judge0 Engine
|
||||
function runCode() {
|
||||
document.getElementById('run-status').innerText = 'Running...';
|
||||
document.getElementById('terminal-output').innerText = 'Executing solution on code engine...';
|
||||
@ -501,16 +501,23 @@ function runCode() {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ language: lang, code: code })
|
||||
body: JSON.stringify({ language: lang, code: code, interview_id: '{{ $interview->id }}' })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('run-status').innerText = 'Ready';
|
||||
if (data.success) {
|
||||
document.getElementById('terminal-output').innerText = data.output;
|
||||
} else {
|
||||
document.getElementById('terminal-output').innerText = 'Error: ' + data.output;
|
||||
}
|
||||
const outStr = data.success ? data.output : ('Error: ' + data.output);
|
||||
document.getElementById('terminal-output').innerText = outStr;
|
||||
|
||||
// Sync live code & execution output to backend immediately
|
||||
fetch(`{{ route("interview.candidate.sync-code", $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ code: code, language: lang, output: outStr })
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('run-status').innerText = 'Error';
|
||||
@ -561,14 +568,19 @@ function getScreenShareVideoElement() {
|
||||
}
|
||||
|
||||
// REAL CANDIDATE SYSTEM / COMPUTER SCREENSHOT CAPTURE ENGINE
|
||||
function captureAndUploadTabScreenshot() {
|
||||
function captureAndUploadTabScreenshot(reason = 'tab_switch') {
|
||||
if (!isTabSwitchScreenshotEnabled) return;
|
||||
|
||||
try {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
let drewScreenStream = false;
|
||||
|
||||
// PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Captures switched tab, external desktop apps like Parakeet AI, etc.)
|
||||
const isCallStart = (reason === 'call_start');
|
||||
const bannerLabel = isCallStart
|
||||
? '📸 CALL STARTED • CANDIDATE SYSTEM SCREEN SNAPSHOT • ' + new Date().toLocaleString()
|
||||
: '🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT • ' + new Date().toLocaleString();
|
||||
|
||||
// PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Screen share stream)
|
||||
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
|
||||
const screenTrack = screenShareStream.getVideoTracks()[0];
|
||||
if (screenTrack.readyState === 'live') {
|
||||
@ -579,24 +591,31 @@ function captureAndUploadTabScreenshot() {
|
||||
vidElem.play().catch(e => {});
|
||||
}
|
||||
|
||||
const settings = screenTrack.getSettings ? screenTrack.getSettings() : {};
|
||||
const w = settings.width || vidElem.videoWidth || 1280;
|
||||
const h = settings.height || vidElem.videoHeight || 720;
|
||||
let rawW = vidElem.videoWidth || 1280;
|
||||
let rawH = vidElem.videoHeight || 720;
|
||||
|
||||
if (w > 0 && h > 0) {
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
if (rawW > 0 && rawH > 0) {
|
||||
const maxW = 1280;
|
||||
let targetW = rawW;
|
||||
let targetH = rawH;
|
||||
if (targetW > maxW) {
|
||||
targetH = Math.round(targetH * (maxW / targetW));
|
||||
targetW = maxW;
|
||||
}
|
||||
|
||||
tempCanvas.width = targetW;
|
||||
tempCanvas.height = targetH;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
tempCtx.drawImage(vidElem, 0, 0, w, h);
|
||||
tempCtx.drawImage(vidElem, 0, 0, targetW, targetH);
|
||||
|
||||
// Add red violation banner
|
||||
// Add system screenshot banner
|
||||
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
||||
tempCtx.fillStyle = '#ef4444';
|
||||
tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
|
||||
tempCtx.font = 'bold 14px sans-serif';
|
||||
tempCtx.fillText('🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT (TAB / APP SWITCH DETECTED) • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15);
|
||||
tempCtx.fillText(bannerLabel, 16, tempCanvas.height - 15);
|
||||
|
||||
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85));
|
||||
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.75), reason);
|
||||
drewScreenStream = true;
|
||||
}
|
||||
} catch(e) {
|
||||
@ -607,32 +626,31 @@ function captureAndUploadTabScreenshot() {
|
||||
|
||||
if (drewScreenStream) return;
|
||||
|
||||
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas (Captures exact active tab DOM, code editor, layout)
|
||||
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas
|
||||
if (typeof html2canvas !== 'undefined') {
|
||||
html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true }).then(canvas => {
|
||||
html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||
ctx.fillRect(0, canvas.height - 42, canvas.width, 42);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText('🚨 CANDIDATE WORKSPACE TAB SNAPSHOT • ' + new Date().toLocaleString(), 16, canvas.height - 15);
|
||||
uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.85));
|
||||
ctx.fillText(bannerLabel, 16, canvas.height - 15);
|
||||
uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.75), reason);
|
||||
}).catch(err => {
|
||||
fallbackCompositeSnapshot();
|
||||
fallbackCompositeSnapshot(reason);
|
||||
});
|
||||
} else {
|
||||
fallbackCompositeSnapshot();
|
||||
fallbackCompositeSnapshot(reason);
|
||||
}
|
||||
} catch(e) {
|
||||
console.log('Tab switch screenshot capture exception:', e);
|
||||
console.log('Candidate system screenshot capture exception:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCompositeSnapshot() {
|
||||
function fallbackCompositeSnapshot(reason = 'tab_switch') {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const video = document.getElementById('webcam');
|
||||
const w = (video && video.videoWidth > 0) ? video.videoWidth : 1280;
|
||||
const h = (video && video.videoHeight > 0) ? video.videoHeight : 720;
|
||||
const w = 1280;
|
||||
const h = 720;
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
@ -643,51 +661,47 @@ function fallbackCompositeSnapshot() {
|
||||
tempCtx.fillRect(0, 0, w, 50);
|
||||
tempCtx.fillStyle = '#38bdf8';
|
||||
tempCtx.font = 'bold 16px sans-serif';
|
||||
tempCtx.fillText('💻 Candidate Assessment Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32);
|
||||
tempCtx.fillText('💻 Candidate System Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32);
|
||||
|
||||
const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor');
|
||||
tempCtx.fillStyle = '#020617';
|
||||
tempCtx.fillRect(20, 70, w - 240, h - 130);
|
||||
tempCtx.fillRect(20, 70, w - 40, h - 130);
|
||||
tempCtx.fillStyle = '#94a3b8';
|
||||
tempCtx.font = '13px monospace';
|
||||
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : '';
|
||||
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : (typeof editor !== 'undefined' && editor ? editor.getValue() : '');
|
||||
const lines = codeText.split('\n').slice(0, 30);
|
||||
lines.forEach((line, i) => {
|
||||
tempCtx.fillText(line.substring(0, 100), 35, 95 + (i * 18));
|
||||
tempCtx.fillText(line.substring(0, 120), 35, 95 + (i * 18));
|
||||
});
|
||||
|
||||
if (video && video.readyState >= 2) {
|
||||
try {
|
||||
tempCtx.drawImage(video, w - 210, 70, 190, 140);
|
||||
tempCtx.strokeStyle = '#6366f1';
|
||||
tempCtx.lineWidth = 2;
|
||||
tempCtx.strokeRect(w - 210, 70, 190, 140);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Candidate webcam feed is explicitly omitted to ensure screenshot is candidate system screen ONLY
|
||||
const isCallStart = (reason === 'call_start');
|
||||
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
||||
tempCtx.fillStyle = '#ef4444';
|
||||
tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
|
||||
tempCtx.font = 'bold 14px sans-serif';
|
||||
tempCtx.fillText('🚨 CANDIDATE TAB SWITCH DETECTED • SYSTEM SNAPSHOT • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15);
|
||||
const text = isCallStart
|
||||
? '📸 CALL STARTED • CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString()
|
||||
: '🚨 CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString();
|
||||
tempCtx.fillText(text, 16, tempCanvas.height - 15);
|
||||
|
||||
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85));
|
||||
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85), reason);
|
||||
}
|
||||
|
||||
function uploadTabScreenshotBlob(imageData) {
|
||||
function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
|
||||
fetch(`{{ route('interview.upload-tab-screenshot', $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ image: imageData })
|
||||
body: JSON.stringify({ image: imageData, reason: reason })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
console.log('Computer screen tab-switch screenshot saved:', data);
|
||||
console.log('Candidate system screenshot saved:', data);
|
||||
})
|
||||
.catch(err => console.log('Tab screenshot fetch error:', err));
|
||||
.catch(err => console.log('System screenshot fetch error:', err));
|
||||
}
|
||||
|
||||
// 1. Tab Switch Detection (Captures Candidate's SYSTEM DESKTOP MONITOR screenshot)
|
||||
@ -1046,6 +1060,7 @@ class CandRingtoneEngine {
|
||||
|
||||
function triggerCandidateRing(offer = null, callStartedAt = null) {
|
||||
if (isCandidateCallConnected) return;
|
||||
if (latestCallStatus === 'ended' || latestCallStatus === 'idle') return;
|
||||
if (candidateDeclinedOrLeftCall) {
|
||||
showCandidateJoinOption();
|
||||
return;
|
||||
@ -1187,6 +1202,14 @@ function showCandidateJoinOption() {
|
||||
await handleInterviewerOffer(pendingOffer);
|
||||
pendingOffer = null;
|
||||
}
|
||||
|
||||
// Automatically capture candidate system screenshot on call start (if screenshot feature enabled)
|
||||
if (!hasCapturedCallStartScreenshot && isTabSwitchScreenshotEnabled) {
|
||||
hasCapturedCallStartScreenshot = true;
|
||||
setTimeout(() => {
|
||||
captureAndUploadTabScreenshot('call_start');
|
||||
}, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. WebRTC Real-Time 2-Way Interview Call Setup (Dual Signaling: BroadcastChannel + PeerJS)
|
||||
@ -1197,6 +1220,8 @@ function showCandidateJoinOption() {
|
||||
let isMicEnabled = true;
|
||||
let isCamEnabled = true;
|
||||
let activeCallInstance = null;
|
||||
let hasCapturedCallStartScreenshot = false;
|
||||
let lastRungCallStartedAt = null;
|
||||
|
||||
const globalCallChannel = new BroadcastChannel('global_call_alerts');
|
||||
globalCallChannel.onmessage = (event) => {
|
||||
@ -1472,6 +1497,20 @@ function candidateLogoutAction() {
|
||||
|
||||
function candidateLeaveCall(isEndedByHost = false) {
|
||||
isCandidateCallConnected = false;
|
||||
candidateDeclinedOrLeftCall = true;
|
||||
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) {
|
||||
clearTimeout(candRingTimer);
|
||||
candRingTimer = null;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
if (activeCallInstance) {
|
||||
try { activeCallInstance.close(); } catch(e) {}
|
||||
activeCallInstance = null;
|
||||
@ -1802,7 +1841,7 @@ function saveNotepadContent() {
|
||||
},
|
||||
body: JSON.stringify({ notes: notes })
|
||||
});
|
||||
}, 800);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// HTML5 Canvas Drawing Logic
|
||||
@ -1850,6 +1889,7 @@ function draw(e) {
|
||||
ctx.strokeStyle = document.getElementById('draw-color').value;
|
||||
ctx.lineTo(e.offsetX, e.offsetY);
|
||||
ctx.stroke();
|
||||
saveCanvasDrawing();
|
||||
}
|
||||
|
||||
function stopDraw() {
|
||||
@ -1880,7 +1920,7 @@ function saveCanvasDrawing() {
|
||||
},
|
||||
body: JSON.stringify({ drawing: drawingData })
|
||||
});
|
||||
}, 1000);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
@ -1897,16 +1937,25 @@ function saveCanvasDrawing() {
|
||||
|
||||
if (data.call_status === 'active') {
|
||||
latestCallStatus = 'active';
|
||||
if (!isCandidateCallConnected) {
|
||||
if (!hasCapturedCallStartScreenshot && isTabSwitchScreenshotEnabled) {
|
||||
hasCapturedCallStartScreenshot = true;
|
||||
setTimeout(() => {
|
||||
captureAndUploadTabScreenshot('call_start');
|
||||
}, 1200);
|
||||
}
|
||||
if (!isCandidateCallConnected && !candidateDeclinedOrLeftCall) {
|
||||
const elapsed = data.call_started_at ? (Date.now() - new Date(data.call_started_at).getTime()) : 999999;
|
||||
if (elapsed <= 10000) {
|
||||
if (elapsed <= 12000 && lastRungCallStartedAt !== data.call_started_at) {
|
||||
lastRungCallStartedAt = data.call_started_at;
|
||||
triggerCandidateRing(null, data.call_started_at);
|
||||
} else {
|
||||
} else if (elapsed > 12000) {
|
||||
showCandidateJoinOption();
|
||||
}
|
||||
}
|
||||
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
|
||||
latestCallStatus = data.call_status;
|
||||
hasCapturedCallStartScreenshot = false;
|
||||
lastRungCallStartedAt = null;
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) clearTimeout(candRingTimer);
|
||||
isCandidateCallConnected = false;
|
||||
|
||||
@ -215,6 +215,32 @@
|
||||
{{ 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>
|
||||
<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.
|
||||
</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' }}
|
||||
</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>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
||||
<div>
|
||||
@ -1160,7 +1186,7 @@ function openReviewModal(id, name, uid, autoJoinCall = false) {
|
||||
|
||||
pollReviewData();
|
||||
clearInterval(sosPollInterval);
|
||||
sosPollInterval = setInterval(pollReviewData, 2000);
|
||||
sosPollInterval = setInterval(pollReviewData, 1200);
|
||||
|
||||
if (autoJoinCall) {
|
||||
setTimeout(() => {
|
||||
@ -1781,38 +1807,112 @@ function startInterviewerCall(isRejoin = false) {
|
||||
});
|
||||
}
|
||||
|
||||
function leaveInterviewerCall() {
|
||||
function leaveInterviewerCall(isExplicitEnd = true) {
|
||||
if (currentInterviewId && isExplicitEnd) {
|
||||
const endingId = currentInterviewId;
|
||||
endedCallIds.add(endingId);
|
||||
dismissedCallIds.add(endingId);
|
||||
|
||||
if (typeof globalCallChannel !== 'undefined' && globalCallChannel) {
|
||||
try {
|
||||
globalCallChannel.postMessage({
|
||||
type: 'call_ended',
|
||||
interview_id: endingId
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
if (typeof callSigChannel !== 'undefined' && callSigChannel) {
|
||||
try {
|
||||
callSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId });
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
if (typeof interviewerSigChannel !== 'undefined' && interviewerSigChannel) {
|
||||
try {
|
||||
interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId });
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', endingId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ call_status: 'ended' })
|
||||
}).catch(e => {});
|
||||
}
|
||||
|
||||
if (interviewerLocalStream) {
|
||||
interviewerLocalStream.getTracks().forEach(t => t.stop());
|
||||
try {
|
||||
interviewerLocalStream.getTracks().forEach(t => t.stop());
|
||||
} catch(e) {}
|
||||
interviewerLocalStream = null;
|
||||
}
|
||||
if (activeCall) {
|
||||
activeCall.close();
|
||||
try { activeCall.close(); } catch(e) {}
|
||||
activeCall = null;
|
||||
}
|
||||
|
||||
document.getElementById('cand-video-placeholder').style.display = 'flex';
|
||||
document.getElementById('self-video-placeholder').style.display = 'flex';
|
||||
const candPlace = document.getElementById('cand-video-placeholder');
|
||||
const screenPlace = document.getElementById('screen-video-placeholder');
|
||||
const selfPlace = document.getElementById('self-video-placeholder');
|
||||
if (candPlace) candPlace.style.display = 'flex';
|
||||
if (screenPlace) screenPlace.style.display = 'flex';
|
||||
if (selfPlace) selfPlace.style.display = 'flex';
|
||||
|
||||
document.getElementById('btn-start-call').innerText = '📞 Join Call';
|
||||
document.getElementById('btn-start-call').style.display = 'inline-block';
|
||||
document.getElementById('btn-start-call').style.background = '#6366f1';
|
||||
document.getElementById('btn-leave-call').style.display = 'none';
|
||||
document.getElementById('btn-end-all-call').style.display = 'none';
|
||||
const candVid = document.getElementById('interviewer-cand-video');
|
||||
if (candVid) candVid.srcObject = null;
|
||||
|
||||
document.getElementById('btn-toggle-interviewer-mic').style.display = 'none';
|
||||
document.getElementById('btn-toggle-interviewer-cam').style.display = 'none';
|
||||
const selfVid = document.getElementById('interviewer-self-video');
|
||||
if (selfVid) selfVid.srcObject = null;
|
||||
|
||||
document.getElementById('interviewer-call-status').innerText = 'LEFT CALL';
|
||||
document.getElementById('interviewer-call-status').style.background = 'rgba(234,179,8,0.2)';
|
||||
document.getElementById('interviewer-call-status').style.color = '#fde047';
|
||||
const startBtn = document.getElementById('btn-start-call');
|
||||
if (startBtn) {
|
||||
startBtn.innerText = '📞 Call Candidate';
|
||||
startBtn.style.display = 'inline-block';
|
||||
startBtn.style.background = 'linear-gradient(135deg, #10b981 0%, #059669 100%)';
|
||||
startBtn.onclick = startInterviewerCall;
|
||||
}
|
||||
|
||||
const leaveBtn = document.getElementById('btn-leave-call');
|
||||
if (leaveBtn) leaveBtn.style.display = 'none';
|
||||
|
||||
const endAllBtn = document.getElementById('btn-end-all-call');
|
||||
if (endAllBtn) endAllBtn.style.display = 'none';
|
||||
|
||||
const micBtn = document.getElementById('btn-toggle-interviewer-mic');
|
||||
if (micBtn) micBtn.style.display = 'none';
|
||||
|
||||
const camBtn = document.getElementById('btn-toggle-interviewer-cam');
|
||||
if (camBtn) camBtn.style.display = 'none';
|
||||
|
||||
const statusBadge = document.getElementById('interviewer-call-status');
|
||||
if (statusBadge) {
|
||||
statusBadge.innerText = 'CALL ENDED';
|
||||
statusBadge.style.background = 'rgba(239,68,68,0.2)';
|
||||
statusBadge.style.color = '#fca5a5';
|
||||
}
|
||||
}
|
||||
|
||||
function endCallForAll() {
|
||||
const executeEndCall = () => {
|
||||
if (typeof callRingtone !== 'undefined' && callRingtone) {
|
||||
try { callRingtone.stop(); } catch(e) {}
|
||||
}
|
||||
const modal = document.getElementById('incoming-call-modal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
activeIncomingCall = null;
|
||||
|
||||
iAmTheCaller = false;
|
||||
leaveInterviewerCall(true);
|
||||
};
|
||||
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
title: '🛑 End Call for All Participants?',
|
||||
text: 'This will terminate the 2-way call session for the candidate and all panelists.',
|
||||
text: 'This will terminate the 2-way call session for candidate and all panelists.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#ef4444',
|
||||
@ -1820,36 +1920,13 @@ function endCallForAll() {
|
||||
confirmButtonText: 'Yes, End Call for All'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
if (currentInterviewId) {
|
||||
const endingId = currentInterviewId;
|
||||
endedCallIds.add(endingId);
|
||||
dismissedCallIds.add(endingId);
|
||||
callRingtone.stop();
|
||||
document.getElementById('incoming-call-modal').classList.remove('active');
|
||||
activeIncomingCall = null;
|
||||
|
||||
globalCallChannel.postMessage({
|
||||
type: 'call_ended',
|
||||
interview_id: endingId
|
||||
});
|
||||
|
||||
fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', endingId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ call_status: 'ended' })
|
||||
});
|
||||
}
|
||||
|
||||
iAmTheCaller = false;
|
||||
leaveInterviewerCall();
|
||||
document.getElementById('interviewer-call-status').innerText = 'CALL ENDED FOR ALL';
|
||||
document.getElementById('interviewer-call-status').style.background = 'rgba(239,68,68,0.2)';
|
||||
document.getElementById('interviewer-call-status').style.color = '#fca5a5';
|
||||
executeEndCall();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (confirm('End Call for All Participants?')) {
|
||||
executeEndCall();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1867,42 +1944,53 @@ function startCallRecording() {
|
||||
let candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
|
||||
let streamToRecord = new MediaStream();
|
||||
|
||||
// Add Candidate Video and Audio tracks
|
||||
// Add live Candidate Video and Audio tracks
|
||||
if (candidateStream) {
|
||||
candidateStream.getTracks().forEach(track => streamToRecord.addTrack(track));
|
||||
candidateStream.getTracks().forEach(track => {
|
||||
if (track.readyState === 'live') {
|
||||
try { streamToRecord.addTrack(track); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add Interviewer Local Microphone Audio track for 2-way call recording
|
||||
if (interviewerLocalStream) {
|
||||
interviewerLocalStream.getAudioTracks().forEach(track => {
|
||||
// Only add if not already in streamToRecord
|
||||
if (!streamToRecord.getAudioTracks().includes(track)) {
|
||||
streamToRecord.addTrack(track);
|
||||
if (track.readyState === 'live' && !streamToRecord.getAudioTracks().includes(track)) {
|
||||
try { streamToRecord.addTrack(track); } catch(e) {}
|
||||
}
|
||||
});
|
||||
if (!candidateStream) {
|
||||
interviewerLocalStream.getVideoTracks().forEach(track => streamToRecord.addTrack(track));
|
||||
if (streamToRecord.getVideoTracks().length === 0) {
|
||||
interviewerLocalStream.getVideoTracks().forEach(track => {
|
||||
if (track.readyState === 'live') {
|
||||
try { streamToRecord.addTrack(track); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (streamToRecord.getTracks().length === 0) {
|
||||
alert('No active video/audio stream available to record. Please click "Start / Join Call" first.');
|
||||
const activeTracks = streamToRecord.getTracks().filter(t => t.readyState === 'live');
|
||||
if (activeTracks.length === 0) {
|
||||
alert('No active video/audio stream available to record. Please start or join the call first.');
|
||||
return;
|
||||
}
|
||||
|
||||
adminRecordedChunks = [];
|
||||
try {
|
||||
let mime = 'video/webm';
|
||||
if (MediaRecorder.isTypeSupported('video/mp4')) {
|
||||
mime = 'video/mp4';
|
||||
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
|
||||
mime = 'video/webm;codecs=vp9';
|
||||
} else if (MediaRecorder.isTypeSupported('video/webm')) {
|
||||
mime = 'video/webm';
|
||||
let options = {};
|
||||
if (typeof MediaRecorder.isTypeSupported === 'function') {
|
||||
if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) {
|
||||
options = { mimeType: 'video/webm;codecs=vp8,opus' };
|
||||
} else if (MediaRecorder.isTypeSupported('video/webm')) {
|
||||
options = { mimeType: 'video/webm' };
|
||||
}
|
||||
}
|
||||
|
||||
const options = { mimeType: mime };
|
||||
adminMediaRecorder = new MediaRecorder(streamToRecord, options);
|
||||
try {
|
||||
adminMediaRecorder = new MediaRecorder(streamToRecord, options);
|
||||
} catch(errOpt) {
|
||||
adminMediaRecorder = new MediaRecorder(streamToRecord);
|
||||
}
|
||||
|
||||
adminMediaRecorder.ondataavailable = function(e) {
|
||||
if (e.data && e.data.size > 0) {
|
||||
@ -1912,7 +2000,7 @@ function startCallRecording() {
|
||||
|
||||
adminMediaRecorder.onstop = function() {
|
||||
if (adminRecordedChunks.length === 0) return;
|
||||
const recMime = adminMediaRecorder.mimeType || mime;
|
||||
const recMime = adminMediaRecorder.mimeType || 'video/webm';
|
||||
const isMp4 = recMime.includes('mp4');
|
||||
const ext = isMp4 ? 'mp4' : 'webm';
|
||||
const blobType = isMp4 ? 'video/mp4' : 'video/webm';
|
||||
@ -1938,6 +2026,7 @@ function startCallRecording() {
|
||||
});
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('MediaRecorder error:', e);
|
||||
alert('Recording failed to initialize: ' + e.message);
|
||||
}
|
||||
}
|
||||
@ -2026,35 +2115,14 @@ function toggleInterviewerCam() {
|
||||
}
|
||||
|
||||
function endInterviewerCall() {
|
||||
if (interviewerLocalStream) {
|
||||
interviewerLocalStream.getTracks().forEach(t => t.stop());
|
||||
interviewerLocalStream = null;
|
||||
}
|
||||
if (activeCall) {
|
||||
activeCall.close();
|
||||
activeCall = null;
|
||||
}
|
||||
|
||||
document.getElementById('cand-video-placeholder').style.display = 'flex';
|
||||
document.getElementById('screen-video-placeholder').style.display = 'flex';
|
||||
document.getElementById('self-video-placeholder').style.display = 'flex';
|
||||
|
||||
document.getElementById('btn-start-call').innerText = '📞 Start 2-Way Call';
|
||||
document.getElementById('btn-start-call').style.background = '#10b981';
|
||||
document.getElementById('btn-start-call').onclick = startInterviewerCall;
|
||||
|
||||
document.getElementById('btn-toggle-interviewer-mic').style.display = 'none';
|
||||
document.getElementById('btn-toggle-interviewer-cam').style.display = 'none';
|
||||
|
||||
document.getElementById('interviewer-call-status').innerText = 'DISCONNECTED';
|
||||
document.getElementById('interviewer-call-status').style.background = 'rgba(99,102,241,0.2)';
|
||||
document.getElementById('interviewer-call-status').style.color = '#818cf8';
|
||||
leaveInterviewerCall(true);
|
||||
}
|
||||
|
||||
function closeReviewModal() {
|
||||
clearInterval(sosPollInterval);
|
||||
endInterviewerCall();
|
||||
document.getElementById('review-modal').classList.remove('active');
|
||||
leaveInterviewerCall(true);
|
||||
const modal = document.getElementById('review-modal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
}
|
||||
|
||||
function sendSilentWarning() {
|
||||
|
||||
@ -97,6 +97,7 @@
|
||||
Route::delete('/roles/{id}', [AdminController::class, 'destroyRole'])->name('admin.roles.destroy');
|
||||
Route::post('/settings/default-role', [AdminController::class, 'updateDefaultRole'])->name('admin.settings.default-role');
|
||||
Route::post('/settings/onboarding-toggle', [AdminController::class, 'updateOnboardingToggle'])->name('admin.settings.onboarding-toggle');
|
||||
Route::post('/settings/screenshot-toggle', [AdminController::class, 'updateScreenshotToggle'])->name('admin.settings.screenshot-toggle');
|
||||
|
||||
// User Roles & Overrides
|
||||
Route::post('/users/{id}/roles', [AdminController::class, 'updateUserRoles'])->name('admin.users.roles.update');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user