Merge pull request 'feat: new interview page, MediaPipe Integration and Stun server discovery' (#20) from experimental/mediapipe-integration into main

Reviewed-on: #20
This commit is contained in:
subhajit.pal 2026-08-14 09:29:03 +00:00
commit 193d14d643
24 changed files with 6627 additions and 4275 deletions

2
.gitignore vendored
View File

@ -17,6 +17,8 @@
/public/build /public/build
/public/fonts-manifest.dev.json /public/fonts-manifest.dev.json
/public/hot /public/hot
/public/uploads/candidate_recordings/*
/public/uploads/candidate_screenshots/*
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail

View File

@ -8,6 +8,7 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -31,6 +32,23 @@ public function index()
return view('interview.index', compact('interviews', 'interviewers')); return view('interview.index', compact('interviews', 'interviewers'));
} }
/**
* Display a specific Candidate Interview Live Review & Call Session.
*/
public function show($id)
{
$user = Auth::user();
if (!$user->isAdmin() && strtolower($user->role) !== 'hr' && !$user->hasActiveInterviewZone()) {
return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.');
}
$interview = Interview::findOrFail($id);
$interviewers = User::orderBy('name', 'asc')->get();
return view('interview.show', compact('interview', 'interviewers'));
}
/** /**
* Store a new Candidate Interview session created by HR. * Store a new Candidate Interview session created by HR.
*/ */
@ -156,41 +174,46 @@ public function executeCode(Request $request)
$code = $request->code; $code = $request->code;
$output = ''; $output = '';
// 1. Ultra-Fast Piston Code Execution API (sub-second for C, C++, Java, JS, Python, PHP, Go, Rust) // 1. Ultra-Fast Public Judge0 CE Code Execution API (no auth needed)
$pistonLangMap = [ $judge0LangMap = [
'python' => 'python', 'python' => 71, // Python 3
'py' => 'python', 'py' => 71,
'javascript' => 'javascript', 'javascript' => 63, // JavaScript (Node.js)
'js' => 'javascript', 'js' => 63,
'c' => 'c', 'c' => 50, // C (GCC)
'cpp' => 'c++', 'cpp' => 54, // C++ (GCC)
'c++' => 'c++', 'c++' => 54,
'java' => 'java', 'java' => 62, // Java (OpenJDK)
'php' => 'php', 'php' => 68, // PHP
'laravel' => 'php', 'laravel' => 68,
'go' => 'go', 'go' => 60, // Go
'rust' => 'rust', 'rust' => 73, // Rust
]; ];
if (isset($judge0LangMap[$langKey])) {
if (isset($pistonLangMap[$langKey])) {
try { try {
$pistonLang = $pistonLangMap[$langKey]; $judge0LangId = $judge0LangMap[$langKey];
$response = Http::timeout(4)->post('https://emkc.org/api/v2/piston/execute', [ $response = Http::timeout(6)->post('https://ce.judge0.com/submissions?base64_encoded=false&wait=true', [
'language' => $pistonLang, 'language_id' => $judge0LangId,
'version' => '*', 'source_code' => $code,
'files' => [
['content' => $code]
]
]); ]);
if ($response->successful()) { if ($response->successful()) {
$resData = $response->json(); $resData = $response->json();
$output = $resData['run']['output'] ?? ($resData['run']['stdout'] ?? ''); $compileOutput = $resData['compile_output'] ?? '';
if (empty($output) && !empty($resData['run']['stderr'])) { $stdout = $resData['stdout'] ?? '';
$output = $resData['run']['stderr']; $stderr = $resData['stderr'] ?? '';
if (!empty($compileOutput)) {
$output = $compileOutput;
} elseif (!empty($stdout)) {
$output = $stdout;
} elseif (!empty($stderr)) {
$output = $stderr;
} }
} }
} catch (\Exception $e) {} } catch (\Exception $e) {
Log::warning("Judge0 Code execute failed.", [$e->getMessage()]);
}
} }
// 2. Instant Local Process Runner Fallback // 2. Instant Local Process Runner Fallback
@ -271,29 +294,41 @@ public function submitCode(Request $request, $id)
} }
/** /**
* Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, Gaze Anomaly). * Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, MediaPipe ML Events, AI Detection).
*/ */
public function logViolation(Request $request, $id) public function logViolation(Request $request, $id)
{ {
$interview = Interview::findOrFail($id); $interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->firstOrFail();
$type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly, question_repetition, external_ai_detected $type = $request->input('type');
$details = $request->input('details', ''); $details = $request->input('details', '');
$confidence = $request->input('confidence', null);
$snapshot = $request->input('snapshot', null);
$detailsStr = is_array($details) ? json_encode($details) : (string)$details;
// Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call // Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call
$aiVerdict = null; $aiVerdict = null;
if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) { if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) {
$aiDetector = new \App\Services\AiCheatingDetectorService(); $aiDetector = new \App\Services\AiCheatingDetectorService();
$aiVerdict = $aiDetector->analyzeSpeechTranscript($details, $interview->candidate_notes ?? ''); $aiVerdict = $aiDetector->analyzeSpeechTranscript($detailsStr, $interview->candidate_notes ?? '');
} }
$logs = $interview->proctor_logs ?? []; $logs = $interview->proctor_logs ?? [];
$logs[] = [ $logEntry = [
'type' => $type, 'type' => $type,
'details' => $details, 'details' => $details,
'confidence' => $confidence,
'snapshot' => $snapshot,
'ai_verdict' => $aiVerdict, 'ai_verdict' => $aiVerdict,
'timestamp' => now()->toIso8601String(), 'timestamp' => $request->input('timestamp', now()->toIso8601String()),
]; ];
if ($request->has('event_id')) {
$logEntry['event_id'] = $request->input('event_id');
}
$logs[] = $logEntry;
$interview->update(['proctor_logs' => $logs]); $interview->update(['proctor_logs' => $logs]);
@ -634,11 +669,11 @@ public function getPollData($id)
} }
} }
// Clean up stale active_peers (> 15 seconds) // Clean up stale active_peers (> 30 seconds)
$activePeers = $interview->active_peers ?? []; $activePeers = $interview->active_peers ?? [];
$now = now()->timestamp; $now = now()->timestamp;
$validPeers = array_values(array_filter($activePeers, function ($p) use ($now) { $validPeers = array_values(array_filter($activePeers, function ($p) use ($now) {
return ($now - ($p['last_seen'] ?? 0)) < 15; return ($now - ($p['last_seen'] ?? 0)) < 30;
})); }));
if (count($validPeers) !== count($activePeers)) { if (count($validPeers) !== count($activePeers)) {
$interview->update(['active_peers' => $validPeers]); $interview->update(['active_peers' => $validPeers]);
@ -697,12 +732,13 @@ public function registerPeerHeartbeat(Request $request, $id)
$role = $request->input('role', $user ? ($user->is_admin ? 'admin' : 'interviewer') : 'candidate'); $role = $request->input('role', $user ? ($user->is_admin ? 'admin' : 'interviewer') : 'candidate');
$userId = $user ? $user->id : 0; $userId = $user ? $user->id : 0;
$interview->refresh();
$activePeers = $interview->active_peers ?? []; $activePeers = $interview->active_peers ?? [];
$now = now()->timestamp; $now = now()->timestamp;
$updatedPeers = []; $updatedPeers = [];
foreach ($activePeers as $p) { foreach ($activePeers as $p) {
if (($now - ($p['last_seen'] ?? 0)) < 15) { if (($now - ($p['last_seen'] ?? 0)) < 30) {
if ($action === 'leave' && ($p['peer_id'] ?? '') === $peerId) { if ($action === 'leave' && ($p['peer_id'] ?? '') === $peerId) {
continue; continue;
} }
@ -712,12 +748,17 @@ public function registerPeerHeartbeat(Request $request, $id)
} }
} }
if ($action !== 'leave') { if ($action === 'heartbeat') {
$micOn = $request->has('mic_on') ? filter_var($request->input('mic_on'), FILTER_VALIDATE_BOOLEAN) : true;
$camOn = $request->has('cam_on') ? filter_var($request->input('cam_on'), FILTER_VALIDATE_BOOLEAN) : true;
$updatedPeers[] = [ $updatedPeers[] = [
'peer_id' => $peerId, 'peer_id' => $peerId,
'user_id' => $userId, 'user_id' => $userId,
'name' => $name, 'name' => $name,
'role' => $role, 'role' => $role,
'mic_on' => $micOn,
'cam_on' => $camOn,
'last_seen' => $now, 'last_seen' => $now,
]; ];
} }
@ -821,9 +862,12 @@ public function uploadTabScreenshot(Request $request, $id)
$relativePath = '/' . $subDir . '/' . $filename; $relativePath = '/' . $subDir . '/' . $filename;
$fullFilePath = $destinationPath . '/' . $filename; $fullFilePath = $destinationPath . '/' . $filename;
// Automatically analyze candidate system screen capture using AI Vision Engine // Automatically analyze candidate system screen capture using AI Vision Engine (disabled for now)
$aiVerdict = null;
/*
$aiDetector = new \App\Services\AiCheatingDetectorService(); $aiDetector = new \App\Services\AiCheatingDetectorService();
$aiVerdict = $aiDetector->analyzeScreenshot($fullFilePath); $aiVerdict = $aiDetector->analyzeScreenshot($fullFilePath);
*/
$screenshotEntry = [ $screenshotEntry = [
'url' => $relativePath, 'url' => $relativePath,

View File

@ -39,3 +39,18 @@ html, body, * {
background: transparent; background: transparent;
} }
/* Mirror camera video feeds for candidate, interviewer, panelist, and admin */
#webcam,
#interviewer-video-default,
#interviewer-cand-video,
#interviewer-self-video,
video[id^="panelist-video-"],
.camera-video {
transform: scaleX(-1);
}
/* Ensure screen sharing is NEVER mirrored */
#interviewer-screen-video {
transform: scaleX(1) !important;
}

View File

@ -1,4 +1,7 @@
import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js'; import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js';
import './interview-call.js';
import './candidate-room.js';
import './candidate-proctor.js';
window.globalIceServers = globalIceServers; window.globalIceServers = globalIceServers;
window.getMeteredIceServers = getMeteredIceServers; window.getMeteredIceServers = getMeteredIceServers;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,8 @@
*/ */
let globalIceServers = [ let globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' } { urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
]; ];
let meteredIceFetchPromise = null; let meteredIceFetchPromise = null;
@ -32,15 +33,20 @@ export function getMeteredIceServers() {
if (Array.isArray(meteredIce) && meteredIce.length > 0) { if (Array.isArray(meteredIce) && meteredIce.length > 0) {
globalIceServers = [ globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.l.google.com:19302' },
...meteredIce.slice(0, 3) { urls: 'stun:stun.cloudflare.com:3478' },
...meteredIce
]; ];
} }
window.globalIceServers = globalIceServers; if (typeof window !== 'undefined') {
window.globalIceServers = globalIceServers;
}
return globalIceServers; return globalIceServers;
}) })
.catch(e => { .catch(e => {
console.warn('Metered TURN fetch warning:', e); console.warn('Metered TURN fetch warning:', e);
window.globalIceServers = globalIceServers; if (typeof window !== 'undefined') {
window.globalIceServers = globalIceServers;
}
return globalIceServers; return globalIceServers;
}); });

View File

@ -0,0 +1,115 @@
@props([
'type' => 'violation',
'details' => '',
'timestamp' => null,
'screenshotUrl' => null,
'aiVerdict' => null,
])
@php
$icoClass = 'amber';
$faIcon = 'fa-solid fa-window-restore';
$isFlag = false;
if (in_array($type, ['focus_lost', 'tab_switch'])) {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore';
} elseif (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-eye';
} elseif ($type === 'looking_away') {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-eye-slash';
} elseif ($type === 'face_missing') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-user-slash'; $isFlag = true;
} elseif ($type === 'multiple_faces') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-users-viewfinder'; $isFlag = true;
} elseif ($type === 'prolonged_looking_away') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-clock-rotate-left'; $isFlag = true;
} elseif ($type === 'phone_detected') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen-button'; $isFlag = true;
} elseif ($type === 'prolonged_corner_gaze') {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-arrows-to-eye'; $isFlag = true;
} elseif ($type === 'talking_secondary_person') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-user-group'; $isFlag = true;
} elseif (in_array($type, ['gaze_lower_device', 'reading_external_device'])) {
$icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen'; $isFlag = true;
} elseif (in_array($type, ['question_repeat_lower', 'question_repetition'])) {
$icoClass = 'red'; $faIcon = 'fa-solid fa-comments'; $isFlag = true;
} elseif ($type === 'talking_on_phone') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-phone'; $isFlag = true;
} elseif ($type === 'external_ai_detected') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-robot'; $isFlag = true;
} elseif ($type === 'paste_event') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-paste'; $isFlag = true;
} elseif ($type === 'copy_event') {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-copy';
}
$titleCap = ucfirst(str_replace('_', ' ', $type));
$timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('h:i:s a') : now()->format('h:i:s a');
// Human readable details parsing
$detailsObj = is_array($details) ? $details : (is_string($details) && str_starts_with(trim($details), '{') ? json_decode($details, true) : null);
$humanDetails = '';
if ($type === 'multiple_faces') {
$count = (is_array($detailsObj) && isset($detailsObj['face_count'])) ? $detailsObj['face_count'] : 2;
$humanDetails = "Multiple faces detected in camera frame ({$count} faces)";
} elseif ($type === 'face_missing') {
$humanDetails = 'Candidate face is not visible in camera frame';
} elseif (in_array($type, ['looking_away', 'prolonged_looking_away'])) {
if (is_array($detailsObj) && (isset($detailsObj['reasons']) || isset($detailsObj['yaw']))) {
$reasonLabels = [
'head_yaw' => 'Head Yaw',
'head_up' => 'Head Pitch Up',
'head_down' => 'Head Pitch Down',
'eye_gaze_left' => 'Eye Gaze Left',
'eye_gaze_right' => 'Eye Gaze Right',
'eye_gaze_down' => 'Eye Gaze Down'
];
$reasonsArr = isset($detailsObj['reasons']) && is_array($detailsObj['reasons']) ? array_map(fn($r) => $reasonLabels[$r] ?? str_replace('_', ' ', $r), $detailsObj['reasons']) : [];
$reasonsStr = implode(', ', $reasonsArr);
$prefix = ($type === 'prolonged_looking_away') ? 'Looking away continuously (>10s)' : 'Looking away from camera';
$humanDetails = $reasonsStr ? "{$prefix} ({$reasonsStr})" : $prefix;
} else {
$humanDetails = is_string($details) && !str_starts_with(trim($details), '{') ? $details : ($type === 'prolonged_looking_away' ? 'Candidate prolonged looking away' : 'Candidate looking away from camera');
}
} elseif ($type === 'phone_detected') {
$label = (is_array($detailsObj) && isset($detailsObj['label'])) ? $detailsObj['label'] : 'cell phone';
$humanDetails = "Possible mobile phone detected in camera frame ({$label})";
} elseif ($type === 'prolonged_corner_gaze') {
if (is_array($detailsObj) && (isset($detailsObj['corner_label']) || isset($detailsObj['corner']))) {
$cornerLabel = $detailsObj['corner_label'] ?? str_replace('_', ' ', $detailsObj['corner'] ?? 'screen corner');
$dur = $detailsObj['duration_s'] ?? round(($detailsObj['duration_ms'] ?? 18000) / 1000);
$humanDetails = "Fixated on {$cornerLabel} for {$dur}s";
} else {
$humanDetails = 'Candidate fixated on screen corner for prolonged period';
}
} elseif ($type === 'talking_secondary_person') {
$humanDetails = is_string($details) && !str_starts_with(trim($details), '{') ? $details : 'Candidate detected speaking out loud to a secondary person';
} else {
$humanDetails = is_string($details) ? $details : (is_array($details) ? json_encode($details) : (string)$details);
}
@endphp
<div {{ $attributes->merge(['class' => 'log-item flex gap-2.5 p-3 border-b border-[#1b2233] last:border-b-0 ' . ($isFlag ? 'bg-[rgba(239,74,95,0.1)] flag' : '')]) }}>
<div class="log-ico w-6 h-6 rounded-md flex-shrink-0 flex items-center justify-center text-[11px] mt-0.5 {{ $icoClass === 'amber' ? 'bg-[rgba(240,169,57,0.12)] text-[#f0a939]' : 'bg-[rgba(239,74,95,0.1)] text-[#ef4a5f]' }}">
<i class="{{ $faIcon }}"></i>
</div>
<div>
<div class="log-text text-xs leading-relaxed text-slate-300">
<b class="text-white {{ $isFlag ? '!text-[#ef4a5f]' : '' }}">{{ $titleCap }}</b> &mdash; {{ $humanDetails }}
@if($screenshotUrl)
<a href="{{ $screenshotUrl }}" target="_blank" class="text-sky-400 underline ml-1 font-medium">[<i class="fa-solid fa-camera mr-0.5"></i> Screenshot]</a>
@endif
@if($aiVerdict)
@php
$confPct = round(($aiVerdict['confidence'] ?? 0.85) * 100);
$engineName = $aiVerdict['engine'] ?? 'AI Engine';
$toolName = !empty($aiVerdict['ai_tool_detected']) ? ' &bull; Detected: <strong>' . e($aiVerdict['ai_tool_detected']) . '</strong>' : '';
@endphp
<div class="mt-1 text-[11px] text-red-300"><b>AI inspector &middot; {{ $confPct }}% cheating confidence</b> ({!! $engineName . $toolName !!})</div>
@endif
</div>
<div class="log-time text-[10.5px] text-slate-500 mt-0.5">{{ $timeStr }}</div>
</div>
</div>

View File

@ -0,0 +1,21 @@
@props([
'logs' => [],
])
<div {{ $attributes->merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] max-h-[260px] overflow-y-auto']) }}>
@if(is_array($logs) && count($logs) > 0)
@foreach($logs as $log)
<x-audit-log-item
:type="$log['type'] ?? 'violation'"
:details="$log['details'] ?? ''"
:timestamp="$log['timestamp'] ?? null"
:screenshotUrl="$log['screenshot_url'] ?? null"
:aiVerdict="$log['ai_verdict'] ?? null"
/>
@endforeach
@else
<div class="p-4 text-center text-[#21c274] text-xs font-medium">
<i class="fa-solid fa-circle-check mr-1"></i> Zero proctoring violations recorded.
</div>
@endif
</div>

View File

@ -0,0 +1,41 @@
@props([
'variant' => 'primary', // primary, secondary, danger, warning, success, ghost
'size' => 'md', // sm, md, lg
'type' => 'button',
'icon' => null,
])
@php
$baseClasses = 'inline-flex items-center gap-1.5 rounded-lg border font-medium text-xs transition-all duration-150 cursor-pointer select-none whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed';
$variants = [
'default' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]',
'secondary' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]',
'primary' => 'bg-[#4b82f7] text-white border-[#4b82f7] hover:bg-[#3d6fe0] shadow-sm',
'success' => 'bg-[#21c274] text-white border-[#21c274] hover:bg-[#1da865] shadow-sm',
'danger' => 'bg-[#ef4a5f] text-white border-[#ef4a5f] hover:bg-[#d63d51] shadow-sm',
'warning' => 'bg-[#f0a939] text-white border-[#f0a939] hover:bg-[#d8952d] shadow-sm',
'ghost' => 'bg-transparent text-slate-400 border-transparent hover:bg-white/5 hover:text-slate-200',
];
$sizes = [
'sm' => 'px-2.5 py-1.5 text-xs',
'md' => 'px-3 py-1.5 text-[12.5px]',
'lg' => 'px-4 py-2 text-sm',
];
$classes = implode(' ', [
$baseClasses,
$variants[$variant] ?? $variants['secondary'],
$sizes[$size] ?? $sizes['md'],
]);
@endphp
<button type="{{ $type }}" {{ $attributes->merge(['class' => $classes]) }}>
@if($icon)
<i class="{{ $icon }}"></i>
@endif
@if(trim($slot))
<span>{{ $slot }}</span>
@endif
</button>

View File

@ -0,0 +1,28 @@
@props([
'title' => null,
'icon' => null,
])
<div {{ $attributes->merge(['class' => 'bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden']) }}>
@if($title || $icon || isset($headerExtra))
<div class="flex items-center justify-between px-5 py-3.5 border-b border-[#1b2233]">
<div class="flex items-center gap-2.5 font-semibold text-[14.5px] tracking-tight text-white">
@if($icon)
<i class="{{ $icon }} text-[#4b82f7] text-[13.5px]"></i>
@endif
@if($title)
<span>{{ $title }}</span>
@endif
</div>
@if(isset($headerExtra))
<div class="flex items-center gap-2">
{{ $headerExtra }}
</div>
@endif
</div>
@endif
<div>
{{ $slot }}
</div>
</div>

View File

@ -0,0 +1,42 @@
@props([
'senderName' => 'Interviewer',
'isCandidate' => false,
'color' => '#6366f1',
'message' => '',
'timestamp' => null,
])
@php
$resolvedColor = $isCandidate ? '#38bdf8' : ($color ?? '#6366f1');
$timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('H:i:s') : now()->format('H:i:s');
// Hex to rgba helper for background opacity
$hex = ltrim($resolvedColor, '#');
if (strlen($hex) == 3) {
$r = hexdec(substr($hex,0,1).substr($hex,0,1));
$g = hexdec(substr($hex,1,1).substr($hex,1,1));
$b = hexdec(substr($hex,2,1).substr($hex,2,1));
} else {
$r = hexdec(substr($hex,0,2));
$g = hexdec(substr($hex,2,2));
$b = hexdec(substr($hex,4,2));
}
$bgRgba = "rgba({$r}, {$g}, {$b}, 0.1)";
$borderRgba = "rgba({$r}, {$g}, {$b}, 0.35)";
@endphp
<div {{ $attributes->merge(['class' => 'chat-message mb-2 p-2.5 rounded-lg border-l-2 transition-all']) }} style="background: {{ $bgRgba }}; border-color: {{ $resolvedColor }};">
<div class="flex justify-between items-center mb-1 gap-2">
<div class="flex items-center gap-1.5 min-w-0">
<span class="w-1.5 h-1.5 rounded-full shrink-0" style="background: {{ $resolvedColor }};"></span>
<strong class="text-xs truncate" style="color: {{ $resolvedColor }};">{{ $senderName }}</strong>
@if($isCandidate)
<span class="text-[9.5px] uppercase font-bold px-1.5 py-0.2 rounded bg-sky-500/20 text-sky-300 border border-sky-500/30 shrink-0">Candidate</span>
@else
<span class="text-[9.5px] uppercase font-bold px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 shrink-0">Panelist</span>
@endif
</div>
<span class="text-[10.5px] text-slate-500 shrink-0 font-mono">{{ $timeStr }}</span>
</div>
<div class="text-slate-200 text-xs leading-relaxed font-medium pl-3 break-words">{{ $message }}</div>
</div>

View File

@ -0,0 +1,44 @@
@props([
'warnings' => [],
'candidateName' => 'Candidate',
])
@php
$interviewerColors = [
'#6366f1', // Indigo
'#10b981', // Emerald
'#f59e0b', // Amber
'#ec4899', // Pink
'#8b5cf6', // Purple
'#06b6d4', // Cyan
'#f97316', // Orange
];
@endphp
<div {{ $attributes->merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2']) }}>
@if(count($warnings) > 0)
@foreach($warnings as $w)
@php
$isCand = is_null($w->sent_by);
$senderName = $isCand
? ($candidateName ?: 'Candidate')
: ($w->sender ? $w->sender->name : 'Interviewer');
$color = $isCand
? '#38bdf8'
: $interviewerColors[($w->sent_by ?? 1) % count($interviewerColors)];
@endphp
<x-chat-message
:senderName="$senderName"
:isCandidate="$isCand"
:color="$color"
:message="$w->message"
:timestamp="$w->created_at"
/>
@endforeach
@else
<div class="h-full w-full flex items-center justify-center text-xs text-[#5b637a] italic">
No chat history. Send a message below.
</div>
@endif
</div>

View File

@ -0,0 +1,43 @@
@props([
'id',
'title' => null,
'maxWidth' => '2xl',
'onClose' => null,
])
@php
$maxWidths = [
'sm' => 'max-w-sm',
'md' => 'max-w-md',
'lg' => 'max-w-lg',
'xl' => 'max-w-xl',
'2xl' => 'max-w-2xl',
'5xl' => 'max-w-5xl',
'7xl' => 'max-w-7xl',
'full' => 'max-w-full mx-4',
];
$maxWidthClass = $maxWidths[$maxWidth] ?? $maxWidths['2xl'];
@endphp
<div id="{{ $id }}" {{ $attributes->merge(['class' => 'admin-modal fixed inset-0 w-screen h-screen bg-slate-950/85 backdrop-blur-md flex items-center justify-center z-[1000] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto']) }}>
<div class="admin-modal-content bg-slate-900 border border-slate-700/60 rounded-2xl w-[90%] {{ $maxWidthClass }} p-7 shadow-2xl text-white max-h-[90vh] overflow-y-auto">
@if($title || isset($headerActions))
<div class="flex items-center justify-between pb-4 mb-4 border-b border-white/10">
@if($title)
<h3 class="font-outfit text-xl font-bold text-white m-0">{{ $title }}</h3>
@endif
<div class="flex items-center gap-2">
@if(isset($headerActions))
{{ $headerActions }}
@endif
<button type="button" onclick="document.getElementById('{{ $id }}').classList.remove('active'); {{ $onClose ? $onClose . '();' : '' }}" class="bg-transparent border-none text-slate-400 hover:text-white text-2xl cursor-pointer leading-none px-1">&times;</button>
</div>
</div>
@endif
<div>
{{ $slot }}
</div>
</div>
</div>

View File

@ -0,0 +1,41 @@
@props([
'variant' => 'info', // primary, secondary, success, danger, warning, info, ghost
'size' => 'md', // sm, md
'icon' => null,
])
@php
$baseClasses = 'inline-flex items-center gap-1.5 font-semibold text-[11px] tracking-wide px-2.5 py-1 rounded-full border transition-colors duration-150 whitespace-nowrap';
$variants = [
'blue' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]',
'primary' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]',
'info' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]',
'red' => 'text-[#ef4a5f] bg-[rgba(239,74,95,0.1)] border-[rgba(239,74,95,0.32)]',
'danger' => 'text-[#ef4a5f] bg-[rgba(239,74,95,0.1)] border-[rgba(239,74,95,0.32)]',
'success' => 'text-[#21c274] bg-[rgba(33,194,116,0.12)] border-[rgba(33,194,116,0.35)]',
'warning' => 'text-[#f0a939] bg-[rgba(240,169,57,0.12)] border-[rgba(240,169,57,0.35)]',
'secondary' => 'text-slate-400 bg-[#0d1220] border-[#232b3d]',
'ghost' => 'text-slate-400 bg-[#0d1220] border-[#232b3d]',
];
$sizes = [
'sm' => 'px-2 py-0.5 text-[10.5px]',
'md' => 'px-2.5 py-1 text-[11px]',
];
$classes = implode(' ', [
$baseClasses,
$variants[$variant] ?? $variants['ghost'],
$sizes[$size] ?? $sizes['md'],
]);
@endphp
<span {{ $attributes->merge(['class' => $classes]) }}>
@if($icon)
<i class="{{ $icon }}"></i>
@endif
@if(trim($slot))
<span>{{ $slot }}</span>
@endif
</span>

View File

@ -0,0 +1,26 @@
@props([
'id' => null,
'active' => false,
'icon' => null,
'tabKey' => null,
])
@php
$baseClasses = 'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md transition-colors duration-150 cursor-pointer select-none border-none';
$activeClasses = $active
? 'bg-[#4b82f7] text-white shadow-sm active'
: 'bg-transparent text-slate-400 hover:text-slate-200';
@endphp
<button
@if($id) id="{{ $id }}" @endif
type="button"
@if($tabKey) data-tab="{{ $tabKey }}" @endif
{{ $attributes->merge(['class' => implode(' ', ['tab', $baseClasses, $activeClasses])]) }}>
@if($icon)
<i class="{{ $icon }} text-[11px]"></i>
@endif
@if(trim($slot))
<span>{{ $slot }}</span>
@endif
</button>

View File

@ -0,0 +1,68 @@
@props([
'id',
'videoId',
'placeholderId',
'badgeText' => '',
'badgeId' => null,
'micStatusId' => null,
'camStatusId' => null,
'borderColor' => 'border-[#1b2233]',
'showZoom' => false,
'showFullscreen' => false,
'initials' => 'CD',
'avatarCircleId' => null,
'avatarNameId' => null,
'avatarTitle' => 'Video Stream',
'zoomInFn' => null,
'zoomOutFn' => null,
'zoomResetFn' => null,
'fullscreenFn' => null,
'containerId' => null,
'focused' => false,
])
<div id="{{ $id }}" {{ $attributes->merge(['class' => 'video-tile relative aspect-[16/11] bg-[#0d1220] border rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all ' . ($focused ? 'border-[rgba(75,130,247,0.35)] shadow-[0_0_0_1px_rgba(75,130,247,0.35)]' : 'border-[#1b2233]')]) }}>
@if($showZoom || $showFullscreen)
<div class="tile-tools absolute top-2 right-2 z-20 flex gap-1">
@if($showZoom)
<button type="button" onclick="{{ $zoomInFn }}" class="w-6 h-6 rounded-md border border-[#232b3d] bg-[#0a0e17]/65 text-slate-400 hover:text-slate-200 text-[10.5px] cursor-pointer flex items-center justify-center" title="Zoom In"><i class="fa-solid fa-magnifying-glass-plus"></i></button>
<button type="button" onclick="{{ $zoomOutFn }}" class="w-6 h-6 rounded-md border border-[#232b3d] bg-[#0a0e17]/65 text-slate-400 hover:text-slate-200 text-[10.5px] cursor-pointer flex items-center justify-center" title="Zoom Out"><i class="fa-solid fa-magnifying-glass-minus"></i></button>
<button type="button" onclick="{{ $zoomResetFn }}" class="w-6 h-6 rounded-md border border-[#232b3d] bg-[#0a0e17]/65 text-slate-400 hover:text-slate-200 text-[10.5px] cursor-pointer flex items-center justify-center" title="Reset Zoom"><i class="fa-solid fa-rotate-left"></i></button>
@endif
@if($showFullscreen)
<button type="button" onclick="{{ $fullscreenFn }}" class="w-6 h-6 rounded-md border border-[#232b3d] bg-[#0a0e17]/65 text-slate-400 hover:text-slate-200 text-[10.5px] cursor-pointer flex items-center justify-center" title="Fullscreen"><i class="fa-solid fa-expand"></i></button>
@endif
</div>
@endif
@if($containerId)
<div id="{{ $containerId }}" class="w-full h-full overflow-auto">
<video id="{{ $videoId }}" autoplay playsinline {{ in_array($videoId, ['interviewer-screen-video', 'interviewer-self-video']) ? 'muted' : '' }} class="w-full h-full object-contain origin-top-left transition-transform duration-200" style="{{ $videoId === 'interviewer-screen-video' ? 'transform: none;' : 'transform: scaleX(-1);' }}"></video>
</div>
@else
<video id="{{ $videoId }}" autoplay playsinline {{ in_array($videoId, ['interviewer-screen-video', 'interviewer-self-video']) ? 'muted' : '' }} class="w-full h-full object-cover origin-center transition-transform duration-200" style="{{ $videoId === 'interviewer-screen-video' ? 'transform: none;' : 'transform: scaleX(-1);' }}"></video>
@endif
<div id="{{ $placeholderId }}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3">
<div id="{{ $avatarCircleId }}" class="av w-[58px] h-[58px] rounded-full bg-gradient-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-white font-semibold text-[19px] mb-2.5 shadow-md shadow-[#4b82f7]/20">
{{ $initials }}
</div>
<div id="{{ $avatarNameId }}" class="name text-[13.5px] font-semibold text-white">{{ $avatarTitle }}</div>
<div class="status text-[11.5px] text-[#5b637a] mt-1 text-center px-3">
{{ $slot->isNotEmpty() ? $slot : "Waiting for candidate&hellip;" }}
</div>
</div>
<span class="tile-tag absolute left-2.5 bottom-2.5 text-[10px] font-semibold tracking-wide px-2.5 py-1 rounded-md bg-black/50 text-slate-300 border border-[#1b2233] z-20 flex items-center gap-1.5 backdrop-blur-sm">
<span>{{ $badgeText }}</span>
@if($micStatusId || $camStatusId)
<span class="w-px h-2.5 bg-white/20 mx-0.5"></span>
@if($micStatusId)
<i id="{{ $micStatusId }}" class="fa-solid fa-microphone text-[#21c274] text-[10px]" title="Mic On"></i>
@endif
@if($camStatusId)
<i id="{{ $camStatusId }}" class="fa-solid fa-video text-[#21c274] text-[10px]" title="Camera On"></i>
@endif
@endif
</span>
</div>

View File

@ -1567,7 +1567,7 @@ function acceptIncomingCall() {
if (activeIncomingCall) { if (activeIncomingCall) {
const targetId = activeIncomingCall.id; const targetId = activeIncomingCall.id;
activeIncomingCall = null; activeIncomingCall = null;
window.location.href = "{{ route('interview.index') }}?open_interview=" + targetId + "&join_call=1"; window.location.href = "/interviews/" + targetId + "?join_call=1";
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,450 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $interview->candidate_name }} - Assessment Review | SingleLogin</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
<script src="//cdn.metered.ca/sdk/video/1.4.6/sdk.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<style>
:root {
--bg: #0a0e17;
--panel: #121826;
--panel-2: #0d1220;
--border: #232b3d;
--border-soft: #1b2233;
--text: #e8ebf3;
--text-dim: #9199ad;
--text-faint: #5b637a;
--accent: #4b82f7;
--accent-soft: rgba(75,130,247,0.12);
--accent-border: rgba(75,130,247,0.35);
--success: #21c274;
--danger: #ef4a5f;
--danger-soft: rgba(239,74,95,0.1);
--warning: #f0a939;
--warning-soft: rgba(240,169,57,0.12);
--radius: 12px;
--mono: 'JetBrains Mono', monospace;
--sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
body {
background: radial-gradient(1200px 600px at 15% -10%, rgba(75,130,247,0.06), transparent 60%), var(--bg);
color: var(--text);
font-family: var(--sans);
}
/* Toggle switch */
.toggle {
width: 32px; height: 18px; border-radius: 20px; background: var(--panel-2);
border: 1px solid var(--border); position: relative; cursor: pointer; flex-shrink: 0;
display: inline-block;
}
.toggle::after {
content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
border-radius: 50%; background: var(--text-faint); transition: left .15s ease;
}
.toggle.on { background: var(--accent-soft); border-color: var(--accent-border); }
.toggle.on::after { left: 16px; background: var(--accent); }
/* Accordion disclosure */
details.meta summary {
list-style: none; cursor: pointer; user-select: none;
}
details.meta summary::-webkit-details-marker { display: none; }
details.meta summary i.chev { transition: transform .15s ease; }
details.meta[open] summary i.chev { transform: rotate(180deg); }
</style>
</head>
<body class="min-h-screen flex flex-col antialiased" data-interview-id="{{ $interview->id }}" data-submission-id="{{ $interview->submission_unique_id }}">
<div class="w-full mx-auto px-7 py-5 pb-16 flex-1">
<!-- Top bar -->
<div class="flex items-center justify-between pb-4 mb-4 border-b border-[#1b2233]">
<div class="flex items-center gap-3">
<a href="{{ route('interview.index') }}" class="w-9 h-9 rounded-xl bg-gradient-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-sm text-white shadow-[0_4px_14px_rgba(75,130,247,0.35)] no-underline">
<i class="fa-solid fa-bolt"></i>
</a>
<div>
<h1 class="text-base font-semibold text-white m-0 tracking-tight">Candidate Assessment Review</h1>
<p class="text-xs text-[#5b637a] m-0 mt-0.5">Live proctored session &amp; review room</p>
</div>
</div>
<div class="flex items-center gap-1.5">
<a href="{{ route('interview.index') }}">
<x-button variant="ghost" icon="fa-solid fa-arrow-left">Back to assessments</x-button>
</a>
@if(Auth::user()->isAdmin())
<a href="{{ route('admin.dashboard') }}">
<x-button variant="ghost" icon="fa-solid fa-sliders">Control panel</x-button>
</a>
@endif
<form action="{{ route('logout') }}" method="POST" id="logout-form" class="inline">
@csrf
<x-button type="button" onclick="interviewerLogoutAction()" variant="ghost" icon="fa-solid fa-arrow-right-from-bracket">Logout</x-button>
</form>
</div>
</div>
@if(session('success'))
<div class="mb-4 bg-[#21c274]/15 border border-[#21c274]/35 text-[#21c274] px-4 py-2.5 rounded-lg text-xs font-medium">
{{ session('success') }}
</div>
@endif
<!-- Session Panel -->
<div class="bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden">
<div class="flex items-center justify-between p-5 gap-4 flex-wrap">
<div class="flex items-center gap-3.5">
@php
$parts = preg_split('/\s+/', trim($interview->candidate_name));
$candInitials = count($parts) >= 2 ? strtoupper(substr($parts[0],0,1).substr(end($parts),0,1)) : strtoupper(substr($interview->candidate_name,0,2));
@endphp
<div class="w-[42px] h-[42px] rounded-full bg-gradient-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-white font-semibold text-[15px] shrink-0">
{{ $candInitials }}
</div>
<div>
<div class="flex items-center gap-2 mb-0.5">
<h2 id="modal-cand-name" class="text-[15px] font-semibold text-white m-0 leading-none">{{ $interview->candidate_name }}</h2>
<span id="candidate-joined-pill" class="pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60">
<i class="fa-solid fa-circle text-[8px] text-slate-500"></i> Not Joined
</span>
</div>
<div class="text-xs text-[#5b637a] font-mono">{{ $interview->candidate_email }} &middot; {{ $interview->candidate_phone }}</div>
</div>
</div>
<div class="flex items-center gap-1.5 flex-wrap">
@if($interview->isExpired())
<x-pill id="timeline-status-pill" variant="red" icon="fa-solid fa-circle">Expired</x-pill>
@elseif($interview->status === 'completed')
<x-pill id="timeline-status-pill" variant="success" icon="fa-solid fa-circle">Completed</x-pill>
@elseif($interview->call_status === 'active')
<x-pill id="timeline-status-pill" variant="blue" class="live" icon="fa-solid fa-circle">Call in progress</x-pill>
@elseif($interview->isActiveTimeline())
<x-pill id="timeline-status-pill" variant="blue" class="live" icon="fa-solid fa-circle">Timeline live</x-pill>
@else
<x-pill id="timeline-status-pill" variant="secondary" icon="fa-solid fa-clock">Scheduled</x-pill>
@endif
<div class="w-px h-[18px] bg-[#232b3d] mx-1"></div>
<div class="flex items-center gap-2 text-xs text-[#9199ad] cursor-pointer" onclick="toggleSosAutoTrigger()">
<span class="toggle" id="sosToggle"></span> SOS auto-trigger
</div>
<div class="w-px h-[18px] bg-[#232b3d] mx-1"></div>
<x-button onclick="regenerateCandidatePassword()" variant="secondary" icon="fa-solid fa-key">Password</x-button>
<x-button onclick="openReportPage()" variant="secondary" icon="fa-solid fa-file-pdf">PDF report</x-button>
<x-button onclick="blockCandidateAction()" variant="secondary" icon="fa-solid fa-ban">Block</x-button>
</div>
</div>
<!-- Submission Details Disclosure Accordion -->
<details class="meta border-t border-[#1b2233]">
<summary class="flex items-center gap-2 px-5 py-2.5 text-xs text-[#5b637a] font-medium hover:text-[#9199ad] transition-colors">
<i class="fa-solid fa-chevron-down chev text-[10px]"></i> Show submission details
</summary>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2.5 px-5 pb-4">
<div class="bg-[#0d1220] border border-[#1b2233] rounded-lg p-2.5">
<div class="text-[10.5px] text-[#5b637a] uppercase tracking-wider mb-1">Submission ID</div>
<div id="modal-cand-uid" class="text-xs font-mono text-[#9199ad] break-all">{{ $interview->submission_unique_id }}</div>
</div>
<div class="bg-[#0d1220] border border-[#1b2233] rounded-lg p-2.5">
<div class="text-[10.5px] text-[#5b637a] uppercase tracking-wider mb-1">Temp Pass</div>
<div class="text-xs font-mono text-[#9199ad]">{{ $interview->temp_password }}</div>
</div>
<div class="bg-[#0d1220] border border-[#1b2233] rounded-lg p-2.5">
<div class="text-[10.5px] text-[#5b637a] uppercase tracking-wider mb-1">Language</div>
<div class="text-xs font-mono text-[#9199ad] uppercase">{{ $interview->language }}</div>
</div>
<div class="bg-[#0d1220] border border-[#1b2233] rounded-lg p-2.5">
<div class="text-[10.5px] text-[#5b637a] uppercase tracking-wider mb-1">Timeline</div>
<div class="text-xs font-mono text-[#9199ad]">{{ ($interview->scheduled_at ?? $interview->created_at)->format('M d, H:i') }} &ndash; {{ $interview->expires_at->format('H:i') }}</div>
</div>
</div>
</details>
</div>
<!-- Call Panel -->
<x-card>
<x-slot:headerExtra>
<div class="flex items-center gap-2">
<!-- 1. Mic Toggle (Icon Only) -->
<x-button id="btn-toggle-interviewer-mic" onclick="toggleInterviewerMic()" variant="ghost" icon="fa-solid fa-microphone" style="display: none;" title="Mic On / Mute"></x-button>
<!-- 2. Cam Toggle (Icon Only) -->
<x-button id="btn-toggle-interviewer-cam" onclick="toggleInterviewerCam()" variant="ghost" icon="fa-solid fa-video" style="display: none;" title="Cam On / Off"></x-button>
<!-- 3. Start / Join / Restart Call -->
@if($interview->call_status === 'ended')
<x-button id="btn-start-call" disabled variant="primary" icon="fa-solid fa-phone-slash" class="opacity-50 cursor-not-allowed pointer-events-none">
Call Ended
</x-button>
@elseif($interview->call_status === 'active')
<x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone">
Join Call
</x-button>
@else
<x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone">
Start Call
</x-button>
@endif
<!-- 4. Record Call (Silent) -->
<x-button id="btn-start-recording" onclick="startCallRecording()" variant="secondary" icon="fa-solid fa-circle-dot" style="display: none;">
Record Call (Silent)
</x-button>
<x-button id="btn-stop-recording" onclick="stopCallRecording()" variant="danger" icon="fa-solid fa-square" style="display: none;" class="animate-pulse" title="Stop &amp; Save Recording"></x-button>
<!-- 5. Leave Call -->
<x-button id="btn-leave-call" onclick="leaveInterviewerCall()" variant="secondary" icon="fa-solid fa-arrow-right-from-bracket" style="display: none;">
Leave Call
</x-button>
<!-- 6. End Call for All (Icon Only, Far Right) -->
<x-button id="btn-end-all-call" onclick="endCallForAll()" variant="danger" icon="fa-solid fa-phone-slash" style="display: none;" title="End Call for All"></x-button>
</div>
</x-slot:headerExtra>
<!-- Video Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3.5 p-5">
<!-- Candidate Video -->
<x-video-tile
id="cand-video-wrapper"
videoId="interviewer-cand-video"
placeholderId="cand-video-placeholder"
badgeText="Candidate video"
micStatusId="cand-mic-status"
camStatusId="cand-cam-status"
showZoom="true"
zoomInFn="zoomCandVideo(0.25)"
zoomOutFn="zoomCandVideo(-0.25)"
zoomResetFn="zoomCandVideo(0)"
avatarCircleId="modal-cand-avatar-circle"
avatarNameId="cand-avatar-name"
avatarTitle="{{ $interview->candidate_name }}"
initials="{{ $candInitials }}">
<span id="cand-status-text">Waiting for candidate&hellip;</span>
</x-video-tile>
<!-- Candidate Shared Screen -->
<x-video-tile
id="screen-wrapper"
videoId="interviewer-screen-video"
placeholderId="screen-video-placeholder"
badgeText="Shared screen"
focused="true"
showZoom="true"
showFullscreen="true"
zoomInFn="zoomScreen(0.25)"
zoomOutFn="zoomScreen(-0.25)"
zoomResetFn="resetZoomScreen()"
fullscreenFn="toggleFullscreenScreen()"
containerId="screen-zoom-container"
avatarTitle="Candidate live screen"
initials="CD">
Waiting for candidate to share screen&hellip;
</x-video-tile>
<!-- Panelist Self -->
@php
$usrWords = preg_split('/\s+/', trim(Auth::user()->name));
$usrInitials = count($usrWords) >= 2 ? strtoupper(substr($usrWords[0],0,1).substr(end($usrWords),0,1)) : strtoupper(substr(trim(Auth::user()->name),0,2));
@endphp
<x-video-tile
id="self-video-wrapper"
videoId="interviewer-self-video"
placeholderId="self-video-placeholder"
badgeText="Panelist self"
micStatusId="self-mic-status"
camStatusId="self-cam-status"
avatarTitle="{{ Auth::user()->name }}"
initials="{{ $usrInitials }}">
Panelist (self)
</x-video-tile>
</div>
<!-- Multi-Party Panelist Video Grid -->
<div id="panelist-mesh-grid" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3.5 px-5 pb-5"></div>
</x-card>
<!-- Inspector Panel -->
<x-card>
<div class="inline-flex gap-1 p-1 bg-[#0d1220] border border-[#1b2233] rounded-[10px] mx-5 mt-4">
<x-tab-button id="tab-btn-code" tabKey="code" active="true" icon="fa-solid fa-code" onclick="switchIdeTab('code')">Live IDE code</x-tab-button>
<x-tab-button id="tab-btn-notes" tabKey="notes" active="false" icon="fa-solid fa-note-sticky" onclick="switchIdeTab('notes')">Candidate notepad</x-tab-button>
<x-tab-button id="tab-btn-drawing" tabKey="drawing" active="false" icon="fa-solid fa-pen" onclick="switchIdeTab('drawing')">Candidate drawing</x-tab-button>
<x-tab-button id="tab-btn-recordings" tabKey="recordings" active="false" icon="fa-solid fa-film" onclick="switchIdeTab('recordings')">Saved recordings</x-tab-button>
<x-tab-button id="tab-btn-screenshots" tabKey="screenshots" active="false" icon="fa-solid fa-image" onclick="switchIdeTab('screenshots')">Tab switch screenshots</x-tab-button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-5 gap-4 p-5">
<!-- Left 3 Cols: Tab Content -->
<div class="lg:col-span-3">
<!-- Tab 1: Live IDE Code -->
<div id="tab-content-code" class="block">
<div class="text-[11.5px] text-[#5b637a] mb-2 font-medium">Current code solution &mdash; visible even if candidate does not share screen</div>
<pre id="modal-code-display" class="bg-[#0d1220] border border-[#1b2233] rounded-[9px] p-3.5 font-mono text-[13px] text-[#6ee7c9] min-h-[220px] max-h-[300px] overflow-y-auto whitespace-pre-wrap"></pre>
<div class="text-[11.5px] text-[#5b637a] mt-4 mb-2 font-medium">Execution stdout / stderr</div>
<pre id="modal-output-display" class="bg-[#0d1220] border border-[#1b2233] rounded-[9px] p-3 font-mono text-xs text-[#5b637a] max-h-[100px] overflow-y-auto whitespace-pre-wrap"></pre>
</div>
<!-- Tab 2: Candidate Notepad -->
<div id="tab-content-notes" class="hidden">
<div class="text-[11.5px] text-[#5b637a] mb-2 font-medium">Candidate live notepad notes (auto-synced)</div>
<pre id="modal-notes-display" class="bg-[#0d1220] border border-[#1b2233] rounded-[9px] p-3.5 font-mono text-xs text-slate-200 min-h-[250px] max-h-[350px] overflow-y-auto whitespace-pre-wrap"></pre>
</div>
<!-- Tab 3: Candidate Canvas Drawing -->
<div id="tab-content-drawing" class="hidden">
<div class="text-[11.5px] text-[#5b637a] mb-2 font-medium">Candidate live drawing / architecture diagram</div>
<div class="bg-[#0d1220] border border-[#1b2233] rounded-[9px] p-3 flex justify-center items-center min-h-[250px]">
<img id="modal-drawing-img" src="" alt="Candidate Drawing Board" class="max-w-full max-h-[320px] hidden rounded-lg border border-dashed border-[#232b3d]" />
<div id="no-drawing-placeholder" class="text-[#5b637a] text-xs">No drawing data submitted by candidate yet.</div>
</div>
</div>
<!-- Tab 4: Saved Video Recordings -->
<div id="tab-content-recordings" class="hidden">
<div class="text-[11.5px] text-[#5b637a] mb-2 font-medium">Saved candidate video sessions (sorted by newest first)</div>
<div id="modal-recordings-container" class="bg-[#0d1220] border border-[#1b2233] rounded-[9px] p-3 min-h-[250px] max-h-[420px] overflow-y-auto">
<div class="text-[#5b637a] text-xs text-center pt-10">No video recordings saved for this candidate yet.</div>
</div>
</div>
<!-- Tab 5: Tab-Switch Screenshots -->
<div id="tab-content-screenshots" class="hidden">
<div class="bg-[#0d1220] border border-[#1b2233] rounded-[9px] p-8 flex flex-col items-center justify-center min-h-[250px] text-center">
<div class="w-10 h-10 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400 mb-3 text-base">
<i class="fa-solid fa-clock"></i>
</div>
<div class="text-sm font-semibold text-white mb-1">Coming Soon</div>
<div class="text-xs text-[#5b637a] max-w-xs">Tab switch screenshot capturing and inspection is currently under maintenance.</div>
</div>
{{--
<div class="text-[11.5px] text-[#5b637a] mb-2 font-medium flex justify-between items-center">
<span>Candidate tab-switch captured screenshots</span>
<div class="flex items-center gap-2">
<span class="text-xs text-[#9199ad]">Admin Screenshot Capture:</span>
<x-button id="modal-toggle-screenshot-btn" onclick="toggleTabScreenshotCapture()" variant="success" size="sm" icon="fa-solid fa-camera">
ENABLED
</x-button>
</div>
</div>
<div id="modal-screenshots-container" class="bg-[#0d1220] border border-[#1b2233] rounded-[9px] p-3 min-h-[250px] max-h-[420px] overflow-y-auto">
<div class="text-[#5b637a] text-xs text-center pt-10">No tab-switch screenshots captured for this candidate yet.</div>
</div>
--}}
</div>
</div>
<!-- Right 2 Cols: Audit Log & Chat -->
<div class="lg:col-span-2">
<h3 class="text-[12.5px] font-semibold text-white mb-2.5 flex items-center gap-2 m-0">
Proctoring logs
</h3>
<x-audit-log id="modal-logs-display" :logs="$interview->proctor_logs ?? []" />
<div class="mt-4">
<h3 class="text-[12.5px] font-semibold text-white mb-2.5 flex items-center gap-2 m-0">
Live Chat
</h3>
<x-chat-panel id="interviewer-chat-history" class="mb-2.5" :warnings="$interview->warnings ?? []" :candidateName="$interview->candidate_name" />
<div class="flex gap-2">
<input type="text" id="warning-input" placeholder="Type message to candidate&hellip;" onkeydown="if(event.key==='Enter') sendSilentWarning()" class="flex-1 bg-[#0d1220] border border-[#232b3d] rounded-lg px-3 py-2 text-white text-xs outline-none focus:border-[rgba(75,130,247,0.35)] placeholder-[#5b637a]">
<x-button onclick="sendSilentWarning()" variant="primary" icon="fa-solid fa-paper-plane">Send</x-button>
</div>
</div>
</div>
</div>
</x-card>
</div>
<!-- SOS CHEATING ALERT MODAL -->
<x-modal id="sos-modal" title="candidate might cheating" maxWidth="lg" onClose="stopSosAlarm">
<div id="sos-violation-list" class="bg-[rgba(239,74,95,0.1)] border border-[rgba(239,74,95,0.32)] rounded-lg p-3.5 text-xs text-[#ef4a5f] mb-4 max-h-[180px] overflow-y-auto">
Violation details loading...
</div>
<div class="flex justify-end gap-2">
<x-button onclick="stopSosAlarm()" variant="secondary" size="md" icon="fa-solid fa-check">
Acknowledge Alert
</x-button>
<x-button onclick="disableSosAndStopAlarm()" variant="danger" size="md" icon="fa-solid fa-bell-slash">
Acknowledge & Disable SOS
</x-button>
</div>
</x-modal>
<!-- INCOMING CALL RING MODAL -->
<x-modal id="incoming-call-modal" maxWidth="md">
<div class="text-center p-2">
<div class="relative w-[90px] h-[90px] mx-auto mb-5 flex items-center justify-center">
<div class="absolute -inset-4 rounded-full border-2 border-[#21c274]/60 animate-ping"></div>
<div class="w-20 h-20 rounded-full bg-gradient-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-3xl font-extrabold text-white shadow-xl shadow-[#4b82f7]/50 z-10">
<i class="fa-solid fa-phone"></i>
</div>
</div>
<div class="text-xs uppercase tracking-wider text-[#21c274] font-extrabold mb-1">
INCOMING INTERVIEW CALL
</div>
<h3 id="incoming-cand-name" class="font-outfit text-2xl font-extrabold text-white mb-1">
{{ $interview->candidate_name }}
</h3>
<div id="incoming-starter-info" class="text-xs text-[#4b82f7] mb-4">
Initiated by Panelist
</div>
<div class="bg-white/5 border border-white/10 rounded-xl p-3 text-xs text-slate-300 mb-6 leading-relaxed">
Another interviewer has started calling the candidate. Click <strong>Accept Call</strong> to join live, or <strong>Decline</strong> if busy.
</div>
<div class="flex gap-3 justify-center">
<x-button id="incoming-decline-btn" onclick="declineIncomingCall()" variant="danger" size="md" icon="fa-solid fa-xmark" class="flex-1">
Decline / Miss
</x-button>
<x-button id="incoming-accept-btn" onclick="acceptIncomingCall()" variant="success" size="md" icon="fa-solid fa-phone" class="flex-[1.3] font-extrabold animate-pulse">
Accept Call
</x-button>
</div>
</div>
</x-modal>
<!-- Page Initialization Script -->
<script>
window.currentUserId = {{ Auth::id() }};
window.currentUserName = "{{ addslashes(Auth::user()->name) }}";
window.currentUserRole = "{{ Auth::user()->is_admin ? 'admin' : 'interviewer' }}";
window.csrfToken = "{{ csrf_token() }}";
document.addEventListener('DOMContentLoaded', function() {
openReviewModal(
{{ $interview->id }},
"{{ addslashes($interview->candidate_name) }}",
"{{ $interview->submission_unique_id }}",
{{ request('join_call') == '1' ? 'true' : 'false' }}
);
});
</script>
</body>
</html>

View File

@ -74,6 +74,7 @@
// Candidate Assessment Management for HR & Panelists // Candidate Assessment Management for HR & Panelists
Route::get('/interviews', [InterviewController::class, 'index'])->name('interview.index'); Route::get('/interviews', [InterviewController::class, 'index'])->name('interview.index');
Route::get('/interviews/active-calls', [InterviewController::class, 'getActiveCalls'])->name('interview.active-calls'); Route::get('/interviews/active-calls', [InterviewController::class, 'getActiveCalls'])->name('interview.active-calls');
Route::get('/interviews/{id}', [InterviewController::class, 'show'])->name('interview.show');
Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store'); Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store');
Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning'); Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning');
Route::get('/interviews/{id}/poll', [InterviewController::class, 'getPollData'])->name('interview.poll'); Route::get('/interviews/{id}/poll', [InterviewController::class, 'getPollData'])->name('interview.poll');

View File

@ -71,13 +71,17 @@ public function test_candidate_can_login_with_temp_credentials_and_access_ide_ro
->assertSee('sarah-candidate_9123456789_1752000000'); ->assertSee('sarah-candidate_9123456789_1752000000');
} }
public function test_live_code_execution_via_piston_api_proxy(): void public function test_live_code_execution_via_judge0_api_proxy(): void
{ {
Http::fake([ Http::fake([
'https://emkc.org/api/v2/piston/execute' => Http::response([ 'https://ce.judge0.com/submissions*' => Http::response([
'run' => [ 'stdout' => "Hello from SingleLogin Assessment!\nComputed Result: 84\n",
'output' => "Hello from SingleLogin Assessment!\nComputed Result: 84\n" 'stderr' => null,
] 'compile_output' => null,
'status' => [
'id' => 3,
'description' => 'Accepted',
],
], 200) ], 200)
]); ]);