feat: new interview page, MediaPipe Integration and Stun server discovery #20
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
1459
resources/js/candidate-proctor.js
Normal file
1459
resources/js/candidate-proctor.js
Normal file
File diff suppressed because it is too large
Load Diff
1491
resources/js/candidate-room.js
Normal file
1491
resources/js/candidate-room.js
Normal file
File diff suppressed because it is too large
Load Diff
2400
resources/js/interview-call.js
Normal file
2400
resources/js/interview-call.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
window.globalIceServers = globalIceServers;
|
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);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
window.globalIceServers = globalIceServers;
|
window.globalIceServers = globalIceServers;
|
||||||
|
}
|
||||||
return globalIceServers;
|
return globalIceServers;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
115
resources/views/components/audit-log-item.blade.php
Normal file
115
resources/views/components/audit-log-item.blade.php
Normal 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> — {{ $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']) ? ' • Detected: <strong>' . e($aiVerdict['ai_tool_detected']) . '</strong>' : '';
|
||||||
|
@endphp
|
||||||
|
<div class="mt-1 text-[11px] text-red-300"><b>AI inspector · {{ $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>
|
||||||
21
resources/views/components/audit-log.blade.php
Normal file
21
resources/views/components/audit-log.blade.php
Normal 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>
|
||||||
41
resources/views/components/button.blade.php
Normal file
41
resources/views/components/button.blade.php
Normal 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>
|
||||||
28
resources/views/components/card.blade.php
Normal file
28
resources/views/components/card.blade.php
Normal 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>
|
||||||
42
resources/views/components/chat-message.blade.php
Normal file
42
resources/views/components/chat-message.blade.php
Normal 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>
|
||||||
44
resources/views/components/chat-panel.blade.php
Normal file
44
resources/views/components/chat-panel.blade.php
Normal 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>
|
||||||
43
resources/views/components/modal.blade.php
Normal file
43
resources/views/components/modal.blade.php
Normal 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">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
41
resources/views/components/pill.blade.php
Normal file
41
resources/views/components/pill.blade.php
Normal 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>
|
||||||
26
resources/views/components/tab-button.blade.php
Normal file
26
resources/views/components/tab-button.blade.php
Normal 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>
|
||||||
68
resources/views/components/video-tile.blade.php
Normal file
68
resources/views/components/video-tile.blade.php
Normal 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…" }}
|
||||||
|
</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>
|
||||||
@ -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
450
resources/views/interview/show.blade.php
Normal file
450
resources/views/interview/show.blade.php
Normal 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 & 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 }} · {{ $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') }} – {{ $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 & 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…</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…
|
||||||
|
</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 — 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…" 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>
|
||||||
@ -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');
|
||||||
|
|||||||
@ -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)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user