Compare commits
No commits in common. "03a36c00e6833b8c57d2e2dcbb94066c7515d869" and "193d14d643e4976f607071ecb5ac1a5361566246" have entirely different histories.
03a36c00e6
...
193d14d643
@ -1,104 +0,0 @@
|
|||||||
---
|
|
||||||
name: infer-conventions
|
|
||||||
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
|
|
||||||
# Infer Conventions
|
|
||||||
|
|
||||||
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
|
|
||||||
|
|
||||||
## Ground Rules (read before you start)
|
|
||||||
|
|
||||||
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
|
|
||||||
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
|
|
||||||
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
|
|
||||||
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
|
|
||||||
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
|
|
||||||
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
|
|
||||||
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
Each step ends on a checkable completion criterion. Do not advance until it holds.
|
|
||||||
|
|
||||||
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
|
|
||||||
|
|
||||||
### Step 0: Orient
|
|
||||||
|
|
||||||
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
|
||||||
|
|
||||||
This app has no Livewire/Inertia/Flux packages installed. Treat the frontend group as likely API-only: confirm from `resources/views` before spending time there, and skip the Livewire/Inertia/Flux dimensions.
|
|
||||||
|
|
||||||
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
|
||||||
|
|
||||||
### Step 1: Predefined sweep
|
|
||||||
|
|
||||||
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
|
|
||||||
|
|
||||||
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
|
|
||||||
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
|
|
||||||
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
|
|
||||||
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
|
|
||||||
- Tooling-owned or Already-recorded. Skip per the ground rules.
|
|
||||||
|
|
||||||
Done when: every applicable dimension carries exactly one of those verdicts.
|
|
||||||
|
|
||||||
### Step 2: Open-ended pass
|
|
||||||
|
|
||||||
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
|
|
||||||
|
|
||||||
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
|
|
||||||
|
|
||||||
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
|
|
||||||
|
|
||||||
### Step 3: Confirm
|
|
||||||
|
|
||||||
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
|
|
||||||
|
|
||||||
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
|
|
||||||
|
|
||||||
Done when: every candidate is approved, rejected, or (conflicts) decided.
|
|
||||||
|
|
||||||
### Step 4: Record
|
|
||||||
|
|
||||||
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
|
|
||||||
|
|
||||||
Record this:
|
|
||||||
|
|
||||||
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
|
|
||||||
|
|
||||||
Not this:
|
|
||||||
|
|
||||||
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
|
|
||||||
|
|
||||||
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
|
|
||||||
|
|
||||||
### Step 5: Summarize
|
|
||||||
|
|
||||||
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
|
|
||||||
|
|
||||||
## Glob mapping
|
|
||||||
|
|
||||||
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
|
|
||||||
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
|
|
||||||
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
|
|
||||||
- Tests: `tests/**`.
|
|
||||||
- Migrations and database: `database/migrations/**`.
|
|
||||||
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
|
|
||||||
|
|
||||||
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
|
|
||||||
|
|
||||||
## Edge cases
|
|
||||||
|
|
||||||
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
|
|
||||||
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
|
|
||||||
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
|
|
||||||
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
|
|
||||||
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
# Detection Checklist
|
|
||||||
|
|
||||||
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
|
|
||||||
|
|
||||||
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Validation & HTTP input
|
|
||||||
|
|
||||||
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
|
||||||
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
|
||||||
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
|
||||||
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
|
||||||
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
|
||||||
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
|
||||||
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
|
||||||
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
|
||||||
|
|
||||||
## B. Controllers & routing
|
|
||||||
|
|
||||||
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
|
|
||||||
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
|
|
||||||
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
|
|
||||||
- Hint: read a few controller methods; `ls app/Actions app/Services`.
|
|
||||||
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
|
||||||
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
|
||||||
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
|
||||||
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
|
||||||
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
|
||||||
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
|
||||||
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
|
||||||
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
|
|
||||||
|
|
||||||
## C. Authorization
|
|
||||||
|
|
||||||
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
|
||||||
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
|
||||||
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
|
||||||
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
|
||||||
|
|
||||||
## D. Eloquent & models
|
|
||||||
|
|
||||||
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
|
|
||||||
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
|
|
||||||
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
|
|
||||||
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
|
|
||||||
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
|
|
||||||
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
|
|
||||||
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
|
|
||||||
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
|
|
||||||
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
|
|
||||||
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
|
|
||||||
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
|
|
||||||
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
|
|
||||||
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
|
||||||
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
|
||||||
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
|
||||||
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
|
||||||
|
|
||||||
## E. Architecture & organization
|
|
||||||
|
|
||||||
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
|
|
||||||
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
|
|
||||||
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
|
|
||||||
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
|
|
||||||
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
|
|
||||||
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
|
|
||||||
24. Decoupling: events + listeners vs direct service calls.
|
|
||||||
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
|
|
||||||
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
|
|
||||||
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
|
|
||||||
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
|
|
||||||
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
|
|
||||||
27. Enums: backed vs pure; case naming; where they live.
|
|
||||||
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
|
|
||||||
|
|
||||||
## F. Frontend & views
|
|
||||||
|
|
||||||
No Livewire/Inertia/Flux package is installed. This app may be API-only. Confirm from `resources/views` before sweeping, and treat the Livewire/Flux dimensions as not applicable.
|
|
||||||
|
|
||||||
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
|
||||||
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
|
||||||
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
|
||||||
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
|
||||||
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
|
||||||
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
|
||||||
|
|
||||||
## G. Database & migrations
|
|
||||||
|
|
||||||
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
|
||||||
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
|
||||||
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
|
||||||
- Hint: grep `function down` vs the migration count.
|
|
||||||
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
|
||||||
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
|
||||||
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
|
||||||
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
|
||||||
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
|
||||||
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
|
|
||||||
|
|
||||||
## H. Testing
|
|
||||||
|
|
||||||
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
|
|
||||||
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
|
|
||||||
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
|
|
||||||
- Hint: grep those trait names in `tests/`.
|
|
||||||
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
|
||||||
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
|
||||||
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
|
||||||
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
|
||||||
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
|
||||||
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
|
||||||
|
|
||||||
## I. Responses & API resources
|
|
||||||
|
|
||||||
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
|
||||||
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
|
||||||
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
|
||||||
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
|
||||||
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
|
||||||
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
|
|
||||||
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
|
|
||||||
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
|
|
||||||
|
|
||||||
## J. Strings, collections & dates
|
|
||||||
|
|
||||||
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
|
||||||
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
|
||||||
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
|
||||||
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
|
||||||
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
|
||||||
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
---
|
|
||||||
name: laravel-best-practices
|
|
||||||
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
|
|
||||||
# Laravel Best Practices
|
|
||||||
|
|
||||||
Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
|
||||||
|
|
||||||
## Consistency First
|
|
||||||
|
|
||||||
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
|
||||||
|
|
||||||
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
|
||||||
|
|
||||||
## How to Apply
|
|
||||||
|
|
||||||
1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
|
|
||||||
2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
|
|
||||||
3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
|
|
||||||
4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
|
|
||||||
5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
|
|
||||||
6. Re-read the diff against every mapped rule before finishing.
|
|
||||||
|
|
||||||
## Rule Index
|
|
||||||
|
|
||||||
Cross-cutting changes often need more than one rule file.
|
|
||||||
|
|
||||||
| Concern | Read |
|
|
||||||
| --- | --- |
|
|
||||||
| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
|
|
||||||
| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
|
|
||||||
| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
|
|
||||||
| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
|
|
||||||
| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
|
|
||||||
| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
|
|
||||||
| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
|
|
||||||
| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
|
|
||||||
| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
|
|
||||||
| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
|
|
||||||
| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
|
|
||||||
| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
|
|
||||||
| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
|
|
||||||
| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
|
|
||||||
| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
|
|
||||||
| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
|
|
||||||
| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
|
|
||||||
| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
|
|
||||||
| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
|
|
||||||
| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
|
|
||||||
|
|
||||||
## Decision Rules
|
|
||||||
|
|
||||||
- Prefer framework features and existing application abstractions over new helpers or dependencies.
|
|
||||||
- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
|
|
||||||
- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
# Advanced Query Patterns
|
|
||||||
|
|
||||||
## Use `addSelect()` Subqueries for Single Values from Has-Many
|
|
||||||
|
|
||||||
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function scopeWithLastLoginAt($query): void
|
|
||||||
{
|
|
||||||
$query->addSelect([
|
|
||||||
'last_login_at' => Login::select('created_at')
|
|
||||||
->whereColumn('user_id', 'users.id')
|
|
||||||
->latest()
|
|
||||||
->take(1),
|
|
||||||
])->withCasts(['last_login_at' => 'datetime']);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Create Dynamic Relationships via Subquery FK
|
|
||||||
|
|
||||||
Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function lastLogin(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Login::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeWithLastLogin($query): void
|
|
||||||
{
|
|
||||||
$query->addSelect([
|
|
||||||
'last_login_id' => Login::select('id')
|
|
||||||
->whereColumn('user_id', 'users.id')
|
|
||||||
->latest()
|
|
||||||
->take(1),
|
|
||||||
])->with('lastLogin');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Conditional Aggregates Instead of Multiple Count Queries
|
|
||||||
|
|
||||||
Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
|
|
||||||
|
|
||||||
```php
|
|
||||||
$statuses = Feature::toBase()
|
|
||||||
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
|
|
||||||
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
|
|
||||||
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
|
|
||||||
->first();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `setRelation()` to Prevent Circular N+1
|
|
||||||
|
|
||||||
When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
|
|
||||||
|
|
||||||
```php
|
|
||||||
$feature->load('comments.user');
|
|
||||||
$feature->comments->each->setRelation('feature', $feature);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prefer `whereIn` + Subquery Over `whereHas`
|
|
||||||
|
|
||||||
`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
|
|
||||||
|
|
||||||
Incorrect (correlated EXISTS re-executes per row):
|
|
||||||
|
|
||||||
```php
|
|
||||||
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (index-friendly subquery, no PHP memory overhead):
|
|
||||||
|
|
||||||
```php
|
|
||||||
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sometimes Two Simple Queries Beat One Complex Query
|
|
||||||
|
|
||||||
Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
|
|
||||||
|
|
||||||
## Use Compound Indexes Matching `orderBy` Column Order
|
|
||||||
|
|
||||||
When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Migration
|
|
||||||
$table->index(['last_name', 'first_name']);
|
|
||||||
|
|
||||||
// Query — column order must match the index
|
|
||||||
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Correlated Subqueries for Has-Many Ordering
|
|
||||||
|
|
||||||
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function scopeOrderByLastLogin($query): void
|
|
||||||
{
|
|
||||||
$query->orderByDesc(Login::select('created_at')
|
|
||||||
->whereColumn('user_id', 'users.id')
|
|
||||||
->latest()
|
|
||||||
->take(1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
# Architecture Best Practices
|
|
||||||
|
|
||||||
## Single-Purpose Action Classes
|
|
||||||
|
|
||||||
Extract discrete business operations into invokable Action classes.
|
|
||||||
|
|
||||||
```php
|
|
||||||
class CreateOrderAction
|
|
||||||
{
|
|
||||||
public function __construct(private InventoryService $inventory) {}
|
|
||||||
|
|
||||||
public function handle(array $data): Order
|
|
||||||
{
|
|
||||||
$order = Order::create($data);
|
|
||||||
$this->inventory->reserve($order);
|
|
||||||
|
|
||||||
return $order;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Dependency Injection
|
|
||||||
|
|
||||||
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
class OrderController extends Controller
|
|
||||||
{
|
|
||||||
public function store(StoreOrderRequest $request)
|
|
||||||
{
|
|
||||||
$service = app(OrderService::class);
|
|
||||||
|
|
||||||
return $service->create($request->validated());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
class OrderController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(private OrderService $service) {}
|
|
||||||
|
|
||||||
public function store(StoreOrderRequest $request)
|
|
||||||
{
|
|
||||||
return $this->service->create($request->validated());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code to Interfaces
|
|
||||||
|
|
||||||
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
|
|
||||||
|
|
||||||
Incorrect (concrete dependency):
|
|
||||||
```php
|
|
||||||
class OrderService
|
|
||||||
{
|
|
||||||
public function __construct(private StripeGateway $gateway) {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (interface dependency):
|
|
||||||
```php
|
|
||||||
interface PaymentGateway
|
|
||||||
{
|
|
||||||
public function charge(int $amount, string $customerId): PaymentResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
class OrderService
|
|
||||||
{
|
|
||||||
public function __construct(private PaymentGateway $gateway) {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Bind in a service provider:
|
|
||||||
|
|
||||||
```php
|
|
||||||
$this->app->bind(PaymentGateway::class, StripeGateway::class);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Default Sort by Descending
|
|
||||||
|
|
||||||
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$posts = Post::paginate();
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
$posts = Post::latest()->paginate();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Atomic Locks for Race Conditions
|
|
||||||
|
|
||||||
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
|
|
||||||
|
|
||||||
```php
|
|
||||||
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
|
|
||||||
$order->process();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Or at query level
|
|
||||||
$product = Product::where('id', $id)->lockForUpdate()->first();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `mb_*` String Functions
|
|
||||||
|
|
||||||
When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
strlen('José'); // 5 (bytes, not characters)
|
|
||||||
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
mb_strlen('José'); // 4 (characters)
|
|
||||||
mb_strtolower('MÜNCHEN'); // 'münchen'
|
|
||||||
|
|
||||||
// Prefer Laravel's Str helpers when available
|
|
||||||
Str::length('José'); // 4
|
|
||||||
Str::lower('MÜNCHEN'); // 'münchen'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `defer()` for Post-Response Work
|
|
||||||
|
|
||||||
For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
|
|
||||||
|
|
||||||
Incorrect (job overhead for trivial work):
|
|
||||||
```php
|
|
||||||
dispatch(new LogPageView($page));
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (runs after response, same process):
|
|
||||||
```php
|
|
||||||
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
|
|
||||||
```
|
|
||||||
|
|
||||||
Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
|
|
||||||
|
|
||||||
## Use `Context` for Request-Scoped Data
|
|
||||||
|
|
||||||
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
|
|
||||||
|
|
||||||
```php
|
|
||||||
// In middleware
|
|
||||||
Context::add('tenant_id', $request->header('X-Tenant-ID'));
|
|
||||||
|
|
||||||
// Anywhere later — controllers, jobs, log context
|
|
||||||
$tenantId = Context::get('tenant_id');
|
|
||||||
```
|
|
||||||
|
|
||||||
Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
|
|
||||||
|
|
||||||
## Use `Concurrency::run()` for Parallel Execution
|
|
||||||
|
|
||||||
Run independent operations in parallel using child processes — no async libraries needed.
|
|
||||||
|
|
||||||
```php
|
|
||||||
use Illuminate\Support\Facades\Concurrency;
|
|
||||||
|
|
||||||
[$users, $orders] = Concurrency::run([
|
|
||||||
fn () => User::count(),
|
|
||||||
fn () => Order::where('status', 'pending')->count(),
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
|
|
||||||
|
|
||||||
## Convention Over Configuration
|
|
||||||
|
|
||||||
Follow Laravel conventions. Don't override defaults unnecessarily.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
class Customer extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'Customer';
|
|
||||||
protected $primaryKey = 'customer_id';
|
|
||||||
|
|
||||||
public function roles(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
class Customer extends Model
|
|
||||||
{
|
|
||||||
public function roles(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Role::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
# Blade & Views Best Practices
|
|
||||||
|
|
||||||
## Use `$attributes->merge()` in Component Templates
|
|
||||||
|
|
||||||
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
|
|
||||||
{{ $message }}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `@pushOnce` for Per-Component Scripts
|
|
||||||
|
|
||||||
If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
|
|
||||||
|
|
||||||
## Prefer Blade Components Over `@include`
|
|
||||||
|
|
||||||
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
|
|
||||||
|
|
||||||
## Use View Composers for Shared View Data
|
|
||||||
|
|
||||||
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
|
|
||||||
|
|
||||||
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
|
||||||
|
|
||||||
A single view can return either the full page or just a fragment, keeping routing clean.
|
|
||||||
|
|
||||||
```php
|
|
||||||
return view('dashboard', compact('users'))
|
|
||||||
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `@aware` for Deeply Nested Component Props
|
|
||||||
|
|
||||||
Avoids re-passing parent props through every level of nested components.
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
# Caching Best Practices
|
|
||||||
|
|
||||||
## Use `Cache::remember()` Instead of Manual Get/Put
|
|
||||||
|
|
||||||
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$val = Cache::get('stats');
|
|
||||||
if (! $val) {
|
|
||||||
$val = $this->computeStats();
|
|
||||||
Cache::put('stats', $val, 60);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `Cache::flexible()` for Stale-While-Revalidate
|
|
||||||
|
|
||||||
On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
|
|
||||||
|
|
||||||
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
|
|
||||||
|
|
||||||
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
|
|
||||||
|
|
||||||
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
|
|
||||||
|
|
||||||
If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
|
|
||||||
|
|
||||||
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
|
|
||||||
|
|
||||||
## Use Cache Tags to Invalidate Related Groups
|
|
||||||
|
|
||||||
Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
|
|
||||||
|
|
||||||
```php
|
|
||||||
Cache::tags(['user-1'])->flush();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `Cache::add()` for Atomic Conditional Writes
|
|
||||||
|
|
||||||
`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
|
|
||||||
|
|
||||||
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
|
|
||||||
|
|
||||||
Correct: `Cache::add('lock', true, 10);`
|
|
||||||
|
|
||||||
## Use `once()` for Per-Request Memoization
|
|
||||||
|
|
||||||
`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function roles(): Collection
|
|
||||||
{
|
|
||||||
return once(fn () => $this->loadRoles());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
|
|
||||||
|
|
||||||
## Configure Failover Cache Stores in Production
|
|
||||||
|
|
||||||
If Redis goes down, the app falls back to a secondary store automatically.
|
|
||||||
|
|
||||||
```php
|
|
||||||
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
|
||||||
```
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
# Collection Best Practices
|
|
||||||
|
|
||||||
## Use Higher-Order Messages for Simple Operations
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$users->each(function (User $user) {
|
|
||||||
$user->markAsVip();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct: `$users->each->markAsVip();`
|
|
||||||
|
|
||||||
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
|
|
||||||
|
|
||||||
## Choose `cursor()` vs. `lazy()` Correctly
|
|
||||||
|
|
||||||
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
|
|
||||||
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
|
|
||||||
|
|
||||||
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
|
|
||||||
|
|
||||||
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
|
|
||||||
|
|
||||||
## Use `lazyById()` When Updating Records While Iterating
|
|
||||||
|
|
||||||
`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
|
|
||||||
|
|
||||||
## Use `toQuery()` for Bulk Operations on Collections
|
|
||||||
|
|
||||||
Avoids manual `whereIn` construction.
|
|
||||||
|
|
||||||
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
|
|
||||||
|
|
||||||
Correct: `$users->toQuery()->update([...]);`
|
|
||||||
|
|
||||||
## Use `#[CollectedBy]` for Custom Collection Classes
|
|
||||||
|
|
||||||
More declarative than overriding `newCollection()`.
|
|
||||||
|
|
||||||
```php
|
|
||||||
#[CollectedBy(UserCollection::class)]
|
|
||||||
class User extends Model {}
|
|
||||||
```
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
# Configuration Best Practices
|
|
||||||
|
|
||||||
## `env()` Only in Config Files
|
|
||||||
|
|
||||||
Direct `env()` calls may return `null` when config is cached.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$key = env('API_KEY');
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
// config/services.php
|
|
||||||
'key' => env('API_KEY'),
|
|
||||||
|
|
||||||
// Application code
|
|
||||||
$key = config('services.key');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Encrypted Env or External Secrets
|
|
||||||
|
|
||||||
Never store production secrets in plain `.env` files in version control.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```bash
|
|
||||||
|
|
||||||
# .env committed to repo or shared in Slack
|
|
||||||
|
|
||||||
STRIPE_SECRET=sk_live_abc123
|
|
||||||
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```bash
|
|
||||||
php artisan env:encrypt --env=production --readable
|
|
||||||
php artisan env:decrypt --env=production
|
|
||||||
```
|
|
||||||
|
|
||||||
For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
|
|
||||||
|
|
||||||
## Use `App::environment()` for Environment Checks
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
if (env('APP_ENV') === 'production') {
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
if (app()->isProduction()) {
|
|
||||||
// or
|
|
||||||
if (App::environment('production')) {
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Constants and Language Files
|
|
||||||
|
|
||||||
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Incorrect
|
|
||||||
return $this->type === 'normal';
|
|
||||||
|
|
||||||
// Correct
|
|
||||||
return $this->type === self::TYPE_NORMAL;
|
|
||||||
```
|
|
||||||
|
|
||||||
If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Only when lang files already exist in the project
|
|
||||||
return back()->with('message', __('app.article_added'));
|
|
||||||
```
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
# Database Performance Best Practices
|
|
||||||
|
|
||||||
## Always Eager Load Relationships
|
|
||||||
|
|
||||||
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
|
|
||||||
|
|
||||||
Incorrect (N+1 — executes 1 + N queries):
|
|
||||||
```php
|
|
||||||
$posts = Post::all();
|
|
||||||
foreach ($posts as $post) {
|
|
||||||
echo $post->author->name;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (2 queries total):
|
|
||||||
```php
|
|
||||||
$posts = Post::with('author')->get();
|
|
||||||
foreach ($posts as $post) {
|
|
||||||
echo $post->author->name;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Constrain eager loads to select only needed columns (always include the foreign key):
|
|
||||||
|
|
||||||
```php
|
|
||||||
$users = User::with(['posts' => function ($query) {
|
|
||||||
$query->select('id', 'user_id', 'title')
|
|
||||||
->where('published', true)
|
|
||||||
->latest()
|
|
||||||
->limit(10);
|
|
||||||
}])->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prevent Lazy Loading in Development
|
|
||||||
|
|
||||||
Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
Model::preventLazyLoading(! app()->isProduction());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
|
|
||||||
|
|
||||||
## Select Only Needed Columns
|
|
||||||
|
|
||||||
Avoid `SELECT *` — especially when tables have large text or JSON columns.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$posts = Post::with('author')->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
$posts = Post::select('id', 'title', 'user_id', 'created_at')
|
|
||||||
->with(['author:id,name,avatar'])
|
|
||||||
->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
|
|
||||||
|
|
||||||
## Chunk Large Datasets
|
|
||||||
|
|
||||||
Never load thousands of records at once. Use chunking for batch processing.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$users = User::all();
|
|
||||||
foreach ($users as $user) {
|
|
||||||
$user->notify(new WeeklyDigest);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
User::where('subscribed', true)->chunk(200, function ($users) {
|
|
||||||
foreach ($users as $user) {
|
|
||||||
$user->notify(new WeeklyDigest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
|
|
||||||
|
|
||||||
```php
|
|
||||||
User::where('active', false)->chunkById(200, function ($users) {
|
|
||||||
$users->each->delete();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Add Database Indexes
|
|
||||||
|
|
||||||
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
Schema::create('orders', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->constrained();
|
|
||||||
$table->string('status');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
Schema::create('orders', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->index()->constrained();
|
|
||||||
$table->string('status')->index();
|
|
||||||
$table->timestamps();
|
|
||||||
$table->index(['status', 'created_at']);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
|
|
||||||
|
|
||||||
## Use `withCount()` for Counting Relations
|
|
||||||
|
|
||||||
Never load entire collections just to count them.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$posts = Post::all();
|
|
||||||
foreach ($posts as $post) {
|
|
||||||
echo $post->comments->count();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
$posts = Post::withCount('comments')->get();
|
|
||||||
foreach ($posts as $post) {
|
|
||||||
echo $post->comments_count;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Conditional counting:
|
|
||||||
|
|
||||||
```php
|
|
||||||
$posts = Post::withCount([
|
|
||||||
'comments',
|
|
||||||
'comments as approved_comments_count' => function ($query) {
|
|
||||||
$query->where('approved', true);
|
|
||||||
},
|
|
||||||
])->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `cursor()` for Memory-Efficient Iteration
|
|
||||||
|
|
||||||
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$users = User::where('active', true)->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
foreach (User::where('active', true)->cursor() as $user) {
|
|
||||||
ProcessUser::dispatch($user->id);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
|
|
||||||
|
|
||||||
## No Queries in Blade Templates
|
|
||||||
|
|
||||||
Never execute queries in Blade templates. Pass data from controllers.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```blade
|
|
||||||
@foreach (User::all() as $user)
|
|
||||||
{{ $user->profile->name }}
|
|
||||||
@endforeach
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
// Controller
|
|
||||||
$users = User::with('profile')->get();
|
|
||||||
return view('users.index', compact('users'));
|
|
||||||
```
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@foreach ($users as $user)
|
|
||||||
{{ $user->profile->name }}
|
|
||||||
@endforeach
|
|
||||||
```
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
# Eloquent Best Practices
|
|
||||||
|
|
||||||
## Use Correct Relationship Types
|
|
||||||
|
|
||||||
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function comments(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Comment::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function author(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'user_id');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Local Scopes for Reusable Queries
|
|
||||||
|
|
||||||
Extract reusable query constraints into local scopes to avoid duplication.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
|
|
||||||
$articles = Article::whereHas('user', function ($q) {
|
|
||||||
$q->where('verified', true)->whereNotNull('activated_at');
|
|
||||||
})->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
#[Scope]
|
|
||||||
protected function active(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
$active = User::active()->get();
|
|
||||||
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Apply Global Scopes Sparingly
|
|
||||||
|
|
||||||
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
|
|
||||||
|
|
||||||
Incorrect (global scope for a conditional filter):
|
|
||||||
```php
|
|
||||||
class PublishedScope implements Scope
|
|
||||||
{
|
|
||||||
public function apply(Builder $builder, Model $model): void
|
|
||||||
{
|
|
||||||
$builder->where('published', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Now admin panels, reports, and background jobs all silently skip drafts
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (local scope you opt into):
|
|
||||||
```php
|
|
||||||
#[Scope]
|
|
||||||
protected function published(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('published', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Post::published()->paginate(); // Explicit
|
|
||||||
Post::paginate(); // Admin sees all
|
|
||||||
```
|
|
||||||
|
|
||||||
## Define Attribute Casts
|
|
||||||
|
|
||||||
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
|
|
||||||
|
|
||||||
```php
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'is_active' => 'boolean',
|
|
||||||
'metadata' => 'array',
|
|
||||||
'total' => 'decimal:2',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cast Date Columns Properly
|
|
||||||
|
|
||||||
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```blade
|
|
||||||
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'ordered_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```blade
|
|
||||||
{{ $order->ordered_at->toDateString() }}
|
|
||||||
{{ $order->ordered_at->format('m-d') }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `whereBelongsTo()` for Relationship Queries
|
|
||||||
|
|
||||||
Cleaner than manually specifying foreign keys.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
Post::where('user_id', $user->id)->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
Post::whereBelongsTo($user)->get();
|
|
||||||
Post::whereBelongsTo($user, 'author')->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Avoid Hardcoded Table Names in Queries
|
|
||||||
|
|
||||||
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
DB::table('users')->where('active', true)->get();
|
|
||||||
|
|
||||||
$query->join('companies', 'companies.id', '=', 'users.company_id');
|
|
||||||
|
|
||||||
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct — reference the model's table:
|
|
||||||
```php
|
|
||||||
DB::table((new User)->getTable())->where('active', true)->get();
|
|
||||||
|
|
||||||
// Even better — use Eloquent or the query builder instead of raw SQL
|
|
||||||
User::where('active', true)->get();
|
|
||||||
Order::where('status', 'pending')->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
|
||||||
|
|
||||||
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
# Error Handling Best Practices
|
|
||||||
|
|
||||||
## Exception Reporting and Rendering
|
|
||||||
|
|
||||||
There are two valid approaches — choose one and apply it consistently across the project.
|
|
||||||
|
|
||||||
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
|
|
||||||
|
|
||||||
```php
|
|
||||||
class InvalidOrderException extends Exception
|
|
||||||
{
|
|
||||||
public function report(): void { /* custom reporting */ }
|
|
||||||
|
|
||||||
public function render(Request $request): Response
|
|
||||||
{
|
|
||||||
return response()->view('errors.invalid-order', status: 422);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
|
|
||||||
|
|
||||||
```php
|
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
|
||||||
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
|
|
||||||
$exceptions->render(function (InvalidOrderException $e, Request $request) {
|
|
||||||
return response()->view('errors.invalid-order', status: 422);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Check the existing codebase and follow whichever pattern is already established.
|
|
||||||
|
|
||||||
## Use `ShouldntReport` for Exceptions That Should Never Log
|
|
||||||
|
|
||||||
More discoverable than listing classes in `dontReport()`.
|
|
||||||
|
|
||||||
```php
|
|
||||||
class PodcastProcessingException extends Exception implements ShouldntReport {}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Throttle High-Volume Exceptions
|
|
||||||
|
|
||||||
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
|
|
||||||
|
|
||||||
## Enable `dontReportDuplicates()`
|
|
||||||
|
|
||||||
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
|
|
||||||
|
|
||||||
## Force JSON Error Rendering for API Routes
|
|
||||||
|
|
||||||
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
|
|
||||||
|
|
||||||
```php
|
|
||||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
|
||||||
return $request->is('api/*') || $request->expectsJson();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Add Context to Exception Classes
|
|
||||||
|
|
||||||
Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
|
|
||||||
|
|
||||||
```php
|
|
||||||
class InvalidOrderException extends Exception
|
|
||||||
{
|
|
||||||
public function context(): array
|
|
||||||
{
|
|
||||||
return ['order_id' => $this->orderId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
# Events & Notifications Best Practices
|
|
||||||
|
|
||||||
## Rely on Event Discovery
|
|
||||||
|
|
||||||
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
|
|
||||||
|
|
||||||
## Run `event:cache` in Production Deploy
|
|
||||||
|
|
||||||
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
|
|
||||||
|
|
||||||
## Use `ShouldDispatchAfterCommit` Inside Transactions
|
|
||||||
|
|
||||||
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
|
|
||||||
|
|
||||||
```php
|
|
||||||
class OrderShipped implements ShouldDispatchAfterCommit {}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Always Queue Notifications
|
|
||||||
|
|
||||||
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
|
|
||||||
|
|
||||||
```php
|
|
||||||
class InvoicePaid extends Notification implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `afterCommit()` on Notifications in Transactions
|
|
||||||
|
|
||||||
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
|
|
||||||
|
|
||||||
```php
|
|
||||||
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
|
||||||
```
|
|
||||||
|
|
||||||
## Route Notification Channels to Dedicated Queues
|
|
||||||
|
|
||||||
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
|
|
||||||
|
|
||||||
## Use On-Demand Notifications for Non-User Recipients
|
|
||||||
|
|
||||||
Avoid creating dummy models to send notifications to arbitrary addresses.
|
|
||||||
|
|
||||||
```php
|
|
||||||
Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implement `HasLocalePreference` on Notifiable Models
|
|
||||||
|
|
||||||
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
# HTTP Client Best Practices
|
|
||||||
|
|
||||||
## Always Set Explicit Timeouts
|
|
||||||
|
|
||||||
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$response = Http::get('https://api.example.com/users');
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
$response = Http::timeout(5)
|
|
||||||
->connectTimeout(3)
|
|
||||||
->get('https://api.example.com/users');
|
|
||||||
```
|
|
||||||
|
|
||||||
For service-specific clients, define timeouts in a macro:
|
|
||||||
|
|
||||||
```php
|
|
||||||
Http::macro('github', function () {
|
|
||||||
return Http::baseUrl('https://api.github.com')
|
|
||||||
->timeout(10)
|
|
||||||
->connectTimeout(3)
|
|
||||||
->withToken(config('services.github.token'));
|
|
||||||
});
|
|
||||||
|
|
||||||
$response = Http::github()->get('/repos/laravel/framework');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Retry with Backoff for External APIs
|
|
||||||
|
|
||||||
External APIs have transient failures. Use `retry()` with increasing delays.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$response = Http::post('https://api.stripe.com/v1/charges', $data);
|
|
||||||
|
|
||||||
if ($response->failed()) {
|
|
||||||
throw new PaymentFailedException('Charge failed');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
$response = Http::retry([100, 500, 1000])
|
|
||||||
->timeout(10)
|
|
||||||
->post('https://api.stripe.com/v1/charges', $data);
|
|
||||||
```
|
|
||||||
|
|
||||||
Only retry on specific errors:
|
|
||||||
|
|
||||||
```php
|
|
||||||
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
|
|
||||||
return $exception instanceof ConnectionException
|
|
||||||
|| ($exception instanceof RequestException && $exception->response->serverError());
|
|
||||||
})->post('https://api.example.com/data');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Handle Errors Explicitly
|
|
||||||
|
|
||||||
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$response = Http::get('https://api.example.com/users/1');
|
|
||||||
$user = $response->json(); // Could be an error body
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
$response = Http::timeout(5)
|
|
||||||
->get('https://api.example.com/users/1')
|
|
||||||
->throw();
|
|
||||||
|
|
||||||
$user = $response->json();
|
|
||||||
```
|
|
||||||
|
|
||||||
For graceful degradation:
|
|
||||||
|
|
||||||
```php
|
|
||||||
$response = Http::get('https://api.example.com/users/1');
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
|
||||||
return $response->json();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($response->notFound()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->throw();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Request Pooling for Concurrent Requests
|
|
||||||
|
|
||||||
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$users = Http::get('https://api.example.com/users')->json();
|
|
||||||
$posts = Http::get('https://api.example.com/posts')->json();
|
|
||||||
$comments = Http::get('https://api.example.com/comments')->json();
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
use Illuminate\Http\Client\Pool;
|
|
||||||
|
|
||||||
$responses = Http::pool(fn (Pool $pool) => [
|
|
||||||
$pool->as('users')->get('https://api.example.com/users'),
|
|
||||||
$pool->as('posts')->get('https://api.example.com/posts'),
|
|
||||||
$pool->as('comments')->get('https://api.example.com/comments'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$users = $responses['users']->json();
|
|
||||||
$posts = $responses['posts']->json();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fake HTTP Calls in Tests
|
|
||||||
|
|
||||||
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
it('syncs user from API', function () {
|
|
||||||
$service = new UserSyncService;
|
|
||||||
$service->sync(1); // Hits the real API
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
it('syncs user from API', function () {
|
|
||||||
Http::preventStrayRequests();
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'api.example.com/users/1' => Http::response([
|
|
||||||
'name' => 'John Doe',
|
|
||||||
'email' => 'john@example.com',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$service = new UserSyncService;
|
|
||||||
$service->sync(1);
|
|
||||||
|
|
||||||
Http::assertSent(function (Request $request) {
|
|
||||||
return $request->url() === 'https://api.example.com/users/1';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Test failure scenarios too:
|
|
||||||
|
|
||||||
```php
|
|
||||||
Http::fake([
|
|
||||||
'api.example.com/*' => Http::failedConnection(),
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
# Mail Best Practices
|
|
||||||
|
|
||||||
## Implement `ShouldQueue` on the Mailable Class
|
|
||||||
|
|
||||||
Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
|
|
||||||
|
|
||||||
## Use `afterCommit()` on Mailables Inside Transactions
|
|
||||||
|
|
||||||
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
|
|
||||||
|
|
||||||
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
|
||||||
|
|
||||||
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
|
|
||||||
|
|
||||||
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
|
||||||
|
|
||||||
Correct: `Mail::assertQueued(OrderShipped::class);`
|
|
||||||
|
|
||||||
## Use Markdown Mailables for Transactional Emails
|
|
||||||
|
|
||||||
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
|
|
||||||
|
|
||||||
## Separate Content Tests from Sending Tests
|
|
||||||
|
|
||||||
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
|
||||||
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
|
||||||
Don't mix them — it conflates concerns and makes tests brittle.
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
# Migration Best Practices
|
|
||||||
|
|
||||||
## Generate Migrations with Artisan
|
|
||||||
|
|
||||||
Always use `php artisan make:migration` for consistent naming and timestamps.
|
|
||||||
|
|
||||||
Incorrect (manually created file):
|
|
||||||
```php
|
|
||||||
// database/migrations/posts_migration.php ← wrong naming, no timestamp
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (Artisan-generated):
|
|
||||||
```bash
|
|
||||||
php artisan make:migration create_posts_table
|
|
||||||
php artisan make:migration add_slug_to_posts_table
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `constrained()` for Foreign Keys
|
|
||||||
|
|
||||||
Automatic naming and referential integrity.
|
|
||||||
|
|
||||||
```php
|
|
||||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
|
||||||
|
|
||||||
// Non-standard names
|
|
||||||
$table->foreignId('author_id')->constrained('users');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Never Modify Deployed Migrations
|
|
||||||
|
|
||||||
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
|
|
||||||
|
|
||||||
Incorrect (editing a deployed migration):
|
|
||||||
```php
|
|
||||||
// 2024_01_01_create_posts_table.php — already in production
|
|
||||||
$table->string('slug')->unique(); // ← added after deployment
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (new migration to alter):
|
|
||||||
```php
|
|
||||||
// 2024_03_15_add_slug_to_posts_table.php
|
|
||||||
Schema::table('posts', function (Blueprint $table) {
|
|
||||||
$table->string('slug')->unique()->after('title');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Add Indexes in the Migration
|
|
||||||
|
|
||||||
Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
Schema::create('orders', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->constrained();
|
|
||||||
$table->string('status');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
Schema::create('orders', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->constrained()->index();
|
|
||||||
$table->string('status')->index();
|
|
||||||
$table->timestamp('shipped_at')->nullable()->index();
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mirror Defaults in Model `$attributes`
|
|
||||||
|
|
||||||
When a column has a database default, mirror it in the model so new instances have correct values before saving.
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Migration
|
|
||||||
$table->string('status')->default('pending');
|
|
||||||
|
|
||||||
// Model
|
|
||||||
protected $attributes = [
|
|
||||||
'status' => 'pending',
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
## Write Reversible `down()` Methods by Default
|
|
||||||
|
|
||||||
Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('posts', function (Blueprint $table) {
|
|
||||||
$table->dropColumn('slug');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
|
|
||||||
|
|
||||||
## Keep Migrations Focused
|
|
||||||
|
|
||||||
One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
|
|
||||||
|
|
||||||
Incorrect (partial failure creates unrecoverable state):
|
|
||||||
```php
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('settings', function (Blueprint $table) { ... });
|
|
||||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (separate migrations):
|
|
||||||
```php
|
|
||||||
// Migration 1: create_settings_table
|
|
||||||
Schema::create('settings', function (Blueprint $table) { ... });
|
|
||||||
|
|
||||||
// Migration 2: seed_default_settings
|
|
||||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
|
||||||
```
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
# Queue & Job Best Practices
|
|
||||||
|
|
||||||
## Set `retry_after` Greater Than `timeout`
|
|
||||||
|
|
||||||
If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
|
|
||||||
|
|
||||||
Incorrect (`retry_after` ≤ `timeout`):
|
|
||||||
```php
|
|
||||||
class ProcessReport implements ShouldQueue
|
|
||||||
{
|
|
||||||
public $timeout = 120;
|
|
||||||
}
|
|
||||||
|
|
||||||
// config/queue.php — retry_after: 90 ← job retried while still running!
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (`retry_after` > `timeout`):
|
|
||||||
```php
|
|
||||||
class ProcessReport implements ShouldQueue
|
|
||||||
{
|
|
||||||
public $timeout = 120;
|
|
||||||
}
|
|
||||||
|
|
||||||
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Exponential Backoff
|
|
||||||
|
|
||||||
Use progressively longer delays between retries to avoid hammering failing services.
|
|
||||||
|
|
||||||
Incorrect (fixed retry interval):
|
|
||||||
```php
|
|
||||||
class SyncWithStripe implements ShouldQueue
|
|
||||||
{
|
|
||||||
public $tries = 3;
|
|
||||||
// Default: retries immediately, overwhelming the API
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct (exponential backoff):
|
|
||||||
```php
|
|
||||||
class SyncWithStripe implements ShouldQueue
|
|
||||||
{
|
|
||||||
public $tries = 3;
|
|
||||||
public $backoff = [1, 5, 10];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implement `ShouldBeUnique`
|
|
||||||
|
|
||||||
Prevent duplicate job processing.
|
|
||||||
|
|
||||||
```php
|
|
||||||
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
|
|
||||||
{
|
|
||||||
public function uniqueId(): string
|
|
||||||
{
|
|
||||||
return $this->order->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public $uniqueFor = 3600;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Always Implement `failed()`
|
|
||||||
|
|
||||||
Handle errors explicitly — don't rely on silent failure.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function failed(?Throwable $exception): void
|
|
||||||
{
|
|
||||||
$this->podcast->update(['status' => 'failed']);
|
|
||||||
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rate Limit External API Calls in Jobs
|
|
||||||
|
|
||||||
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new RateLimited('external-api')];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Batch Related Jobs
|
|
||||||
|
|
||||||
Use `Bus::batch()` when jobs should succeed or fail together.
|
|
||||||
|
|
||||||
```php
|
|
||||||
Bus::batch([
|
|
||||||
new ImportCsvChunk($chunk1),
|
|
||||||
new ImportCsvChunk($chunk2),
|
|
||||||
])
|
|
||||||
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
|
|
||||||
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
|
|
||||||
->dispatch();
|
|
||||||
```
|
|
||||||
|
|
||||||
## `retryUntil()` Needs `$tries = 0`
|
|
||||||
|
|
||||||
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public $tries = 0;
|
|
||||||
|
|
||||||
public function retryUntil(): \DateTimeInterface
|
|
||||||
{
|
|
||||||
return now()->addHours(4);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
|
|
||||||
|
|
||||||
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
|
|
||||||
|
|
||||||
```php
|
|
||||||
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
|
||||||
{
|
|
||||||
// Lock releases when processing begins, not when it finishes
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Horizon for Complex Queue Scenarios
|
|
||||||
|
|
||||||
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
|
||||||
|
|
||||||
```php
|
|
||||||
// config/horizon.php
|
|
||||||
'environments' => [
|
|
||||||
'production' => [
|
|
||||||
'supervisor-1' => [
|
|
||||||
'connection' => 'redis',
|
|
||||||
'queue' => ['high', 'default', 'low'],
|
|
||||||
'balance' => 'auto',
|
|
||||||
'minProcesses' => 1,
|
|
||||||
'maxProcesses' => 10,
|
|
||||||
'tries' => 3,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
```
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
# Routing & Controllers Best Practices
|
|
||||||
|
|
||||||
## Use Implicit Route Model Binding
|
|
||||||
|
|
||||||
Let Laravel resolve models automatically from route parameters.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
public function show(int $id)
|
|
||||||
{
|
|
||||||
$post = Post::findOrFail($id);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
public function show(Post $post)
|
|
||||||
{
|
|
||||||
return view('posts.show', ['post' => $post]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Scoped Bindings for Nested Resources
|
|
||||||
|
|
||||||
Enforce parent-child relationships automatically.
|
|
||||||
|
|
||||||
```php
|
|
||||||
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
|
|
||||||
// $post is automatically scoped to $user
|
|
||||||
})->scopeBindings();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Resource Controllers
|
|
||||||
|
|
||||||
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
|
|
||||||
|
|
||||||
```php
|
|
||||||
Route::resource('posts', PostController::class);
|
|
||||||
// In routes/api.php — the /api prefix is applied automatically
|
|
||||||
Route::apiResource('posts', Api\PostController::class);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Keep Controllers Thin
|
|
||||||
|
|
||||||
Aim for under 10 lines per method. Extract business logic to action or service classes.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([...]);
|
|
||||||
if ($request->hasFile('image')) {
|
|
||||||
$request->file('image')->move(public_path('images'));
|
|
||||||
}
|
|
||||||
$post = Post::create($validated);
|
|
||||||
$post->tags()->sync($validated['tags']);
|
|
||||||
event(new PostCreated($post));
|
|
||||||
return redirect()->route('posts.show', $post);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
public function store(StorePostRequest $request, CreatePostAction $create)
|
|
||||||
{
|
|
||||||
$post = $create->execute($request->validated());
|
|
||||||
|
|
||||||
return redirect()->route('posts.show', $post);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type-Hint Form Requests
|
|
||||||
|
|
||||||
Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'title' => ['required', 'max:255'],
|
|
||||||
'body' => ['required'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Post::create($validated);
|
|
||||||
|
|
||||||
return redirect()->route('posts.index');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
public function store(StorePostRequest $request): RedirectResponse
|
|
||||||
{
|
|
||||||
Post::create($request->validated());
|
|
||||||
|
|
||||||
return redirect()->route('posts.index');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
# Task Scheduling Best Practices
|
|
||||||
|
|
||||||
## Use `withoutOverlapping()` on Variable-Duration Tasks
|
|
||||||
|
|
||||||
Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
|
|
||||||
|
|
||||||
## Use `onOneServer()` on Multi-Server Deployments
|
|
||||||
|
|
||||||
Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
|
|
||||||
|
|
||||||
## Use `runInBackground()` for Concurrent Long Tasks
|
|
||||||
|
|
||||||
By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
|
|
||||||
|
|
||||||
## Use `environments()` to Restrict Tasks
|
|
||||||
|
|
||||||
Prevent accidental execution of production-only tasks (billing, reporting) on staging.
|
|
||||||
|
|
||||||
```php
|
|
||||||
Schedule::command('billing:charge')->monthly()->environments(['production']);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `takeUntilTimeout()` for Time-Bounded Processing
|
|
||||||
|
|
||||||
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
|
|
||||||
|
|
||||||
## Use Schedule Groups for Shared Configuration
|
|
||||||
|
|
||||||
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
|
|
||||||
|
|
||||||
```php
|
|
||||||
Schedule::daily()
|
|
||||||
->onOneServer()
|
|
||||||
->timezone('America/New_York')
|
|
||||||
->group(function () {
|
|
||||||
Schedule::command('emails:send --force');
|
|
||||||
Schedule::command('emails:prune');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
# Security Best Practices
|
|
||||||
|
|
||||||
## Mass Assignment Protection
|
|
||||||
|
|
||||||
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
class User extends Model
|
|
||||||
{
|
|
||||||
protected $guarded = []; // All fields are mass assignable
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
class User extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'name',
|
|
||||||
'email',
|
|
||||||
'password',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Never use `$guarded = []` on models that accept user input.
|
|
||||||
|
|
||||||
## Authorize Every Action
|
|
||||||
|
|
||||||
Use policies or gates in controllers. Never skip authorization.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
public function update(UpdatePostRequest $request, Post $post)
|
|
||||||
{
|
|
||||||
$post->update($request->validated());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
public function update(UpdatePostRequest $request, Post $post)
|
|
||||||
{
|
|
||||||
Gate::authorize('update', $post);
|
|
||||||
|
|
||||||
$post->update($request->validated());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Or via Form Request:
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return $this->user()->can('update', $this->route('post'));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prevent SQL Injection
|
|
||||||
|
|
||||||
Always use parameter binding. Never interpolate user input into queries.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
User::where('name', $request->name)->get();
|
|
||||||
|
|
||||||
// Raw expressions with bindings
|
|
||||||
User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Escape Output to Prevent XSS
|
|
||||||
|
|
||||||
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```blade
|
|
||||||
{!! $user->bio !!}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```blade
|
|
||||||
{{ $user->bio }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CSRF Protection
|
|
||||||
|
|
||||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```blade
|
|
||||||
<form method="POST" action="/posts">
|
|
||||||
<input type="text" name="title">
|
|
||||||
</form>
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```blade
|
|
||||||
<form method="POST" action="/posts">
|
|
||||||
@csrf
|
|
||||||
<input type="text" name="title">
|
|
||||||
</form>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rate Limit Auth and API Routes
|
|
||||||
|
|
||||||
Apply `throttle` middleware to authentication and API routes.
|
|
||||||
|
|
||||||
```php
|
|
||||||
RateLimiter::for('login', function (Request $request) {
|
|
||||||
return Limit::perMinute(5)->by($request->ip());
|
|
||||||
});
|
|
||||||
|
|
||||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validate File Uploads
|
|
||||||
|
|
||||||
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Store with generated filenames:
|
|
||||||
|
|
||||||
```php
|
|
||||||
$path = $request->file('avatar')->store('avatars', 'public');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Keep Secrets Out of Code
|
|
||||||
|
|
||||||
Never commit `.env`. Access secrets via `config()` only.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
$key = env('API_KEY');
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
// config/services.php
|
|
||||||
'api_key' => env('API_KEY'),
|
|
||||||
|
|
||||||
// In application code
|
|
||||||
$key = config('services.api_key');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Audit Dependencies
|
|
||||||
|
|
||||||
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
composer audit
|
|
||||||
```
|
|
||||||
|
|
||||||
## Encrypt Sensitive Database Fields
|
|
||||||
|
|
||||||
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
class Integration extends Model
|
|
||||||
{
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'api_key' => 'string',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
class Integration extends Model
|
|
||||||
{
|
|
||||||
protected $hidden = ['api_key', 'api_secret'];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'api_key' => 'encrypted',
|
|
||||||
'api_secret' => 'encrypted',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
# Conventions & Style
|
|
||||||
|
|
||||||
## Follow Laravel Naming Conventions
|
|
||||||
|
|
||||||
| What | Convention | Good | Bad |
|
|
||||||
|------|-----------|------|-----|
|
|
||||||
| Controller | singular | `ArticleController` | `ArticlesController` |
|
|
||||||
| Model | singular | `User` | `Users` |
|
|
||||||
| Table | plural, snake_case | `article_comments` | `articleComments` |
|
|
||||||
| Pivot table | singular alphabetical | `article_user` | `user_article` |
|
|
||||||
| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
|
|
||||||
| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
|
|
||||||
| Route | plural | `articles/1` | `article/1` |
|
|
||||||
| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
|
|
||||||
| Method | camelCase | `getAll` | `get_all` |
|
|
||||||
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
|
|
||||||
| Collection | descriptive, plural | `$activeUsers` | `$data` |
|
|
||||||
| Object | descriptive, singular | `$activeUser` | `$users` |
|
|
||||||
| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
|
|
||||||
| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
|
|
||||||
| Enum | singular | `UserType` | `UserTypes` |
|
|
||||||
|
|
||||||
## Prefer Shorter Readable Syntax
|
|
||||||
|
|
||||||
| Verbose | Shorter |
|
|
||||||
|---------|---------|
|
|
||||||
| `Session::get('cart')` | `session('cart')` |
|
|
||||||
| `$request->session()->get('cart')` | `session('cart')` |
|
|
||||||
| `$request->input('name')` | `$request->name` |
|
|
||||||
| `return Redirect::back()` | `return back()` |
|
|
||||||
| `Carbon::now()` | `now()` |
|
|
||||||
| `App::make('Class')` | `app('Class')` |
|
|
||||||
| `->where('column', '=', 1)` | `->where('column', 1)` |
|
|
||||||
| `->orderBy('created_at', 'desc')` | `->latest()` |
|
|
||||||
| `->orderBy('created_at', 'asc')` | `->oldest()` |
|
|
||||||
| `->first()->name` | `->value('name')` |
|
|
||||||
|
|
||||||
## Use Laravel String & Array Helpers
|
|
||||||
|
|
||||||
Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
|
|
||||||
|
|
||||||
Strings — use `Str` and fluent `Str::of()` over raw PHP:
|
|
||||||
```php
|
|
||||||
// Incorrect
|
|
||||||
$slug = strtolower(str_replace(' ', '-', $title));
|
|
||||||
$short = substr($text, 0, 100) . '...';
|
|
||||||
$class = substr(strrchr('App\Models\User', '\\'), 1);
|
|
||||||
|
|
||||||
// Correct
|
|
||||||
$slug = Str::slug($title);
|
|
||||||
$short = Str::limit($text, 100);
|
|
||||||
$class = class_basename('App\Models\User');
|
|
||||||
```
|
|
||||||
|
|
||||||
Fluent strings — chain operations for complex transformations:
|
|
||||||
```php
|
|
||||||
// Incorrect
|
|
||||||
$result = strtolower(trim(str_replace('_', '-', $input)));
|
|
||||||
|
|
||||||
// Correct
|
|
||||||
$result = Str::of($input)->trim()->replace('_', '-')->lower();
|
|
||||||
```
|
|
||||||
|
|
||||||
Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
|
|
||||||
|
|
||||||
Arrays — use `Arr` over raw PHP:
|
|
||||||
```php
|
|
||||||
// Incorrect
|
|
||||||
$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
|
|
||||||
|
|
||||||
// Correct
|
|
||||||
$name = Arr::get($array, 'user.name', 'default');
|
|
||||||
```
|
|
||||||
|
|
||||||
Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
|
|
||||||
|
|
||||||
Numbers — use `Number` for display formatting:
|
|
||||||
```php
|
|
||||||
Number::format(1000000); // "1,000,000"
|
|
||||||
Number::currency(1500, 'USD'); // "$1,500.00"
|
|
||||||
Number::abbreviate(1000000); // "1M"
|
|
||||||
Number::fileSize(1024 * 1024); // "1 MB"
|
|
||||||
Number::percentage(75.5); // "75.5%"
|
|
||||||
```
|
|
||||||
|
|
||||||
URIs — use `Uri` for URL manipulation:
|
|
||||||
```php
|
|
||||||
$uri = Uri::of('https://example.com/search')
|
|
||||||
->withQuery(['q' => 'laravel', 'page' => 1]);
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
|
|
||||||
|
|
||||||
Use `search-docs` for the full list of available methods — these helpers are extensive.
|
|
||||||
|
|
||||||
## No Inline JS/CSS in Blade
|
|
||||||
|
|
||||||
Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```blade
|
|
||||||
let article = `{{ json_encode($article) }}`;
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```blade
|
|
||||||
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
|
|
||||||
|
|
||||||
## No Unnecessary Comments
|
|
||||||
|
|
||||||
Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
// Check if there are any joins
|
|
||||||
if (count((array) $builder->getQuery()->joins) > 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
if ($this->hasJoins())
|
|
||||||
```
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
# Testing Best Practices
|
|
||||||
|
|
||||||
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
|
||||||
|
|
||||||
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
|
|
||||||
|
|
||||||
## Use Model Assertions Over Raw Database Assertions
|
|
||||||
|
|
||||||
Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
|
|
||||||
|
|
||||||
Correct: `$this->assertModelExists($user);`
|
|
||||||
|
|
||||||
More expressive, type-safe, and fails with clearer messages.
|
|
||||||
|
|
||||||
## Use Factory States and Sequences
|
|
||||||
|
|
||||||
Named states make tests self-documenting. Sequences eliminate repetitive setup.
|
|
||||||
|
|
||||||
Incorrect: `User::factory()->create(['email_verified_at' => null]);`
|
|
||||||
|
|
||||||
Correct: `User::factory()->unverified()->create();`
|
|
||||||
|
|
||||||
## Use `Exceptions::fake()` to Assert Exception Reporting
|
|
||||||
|
|
||||||
Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
|
|
||||||
|
|
||||||
## Call `Event::fake()` After Factory Setup
|
|
||||||
|
|
||||||
Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
|
|
||||||
|
|
||||||
Incorrect: `Event::fake(); $user = User::factory()->create();`
|
|
||||||
|
|
||||||
Correct: `$user = User::factory()->create(); Event::fake();`
|
|
||||||
|
|
||||||
## Use `recycle()` to Share Relationship Instances Across Factories
|
|
||||||
|
|
||||||
Without `recycle()`, nested factories create separate instances of the same conceptual entity.
|
|
||||||
|
|
||||||
```php
|
|
||||||
Ticket::factory()
|
|
||||||
->recycle(Airline::factory()->create())
|
|
||||||
->create();
|
|
||||||
```
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
# Validation & Forms Best Practices
|
|
||||||
|
|
||||||
## Use Form Request Classes
|
|
||||||
|
|
||||||
Extract validation from controllers into dedicated Form Request classes.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'title' => 'required|max:255',
|
|
||||||
'body' => 'required',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
public function store(StorePostRequest $request)
|
|
||||||
{
|
|
||||||
Post::create($request->validated());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Array vs. String Notation for Rules
|
|
||||||
|
|
||||||
Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Preferred for new code
|
|
||||||
'email' => ['required', 'email', Rule::unique('users')],
|
|
||||||
|
|
||||||
// Follow existing convention if the project uses string notation
|
|
||||||
'email' => 'required|email|unique:users',
|
|
||||||
```
|
|
||||||
|
|
||||||
## Always Use `validated()`
|
|
||||||
|
|
||||||
Get only validated data. Never use `$request->all()` for mass operations.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
```php
|
|
||||||
Post::create($request->all());
|
|
||||||
```
|
|
||||||
|
|
||||||
Correct:
|
|
||||||
```php
|
|
||||||
Post::create($request->validated());
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use `Rule::when()` for Conditional Validation
|
|
||||||
|
|
||||||
```php
|
|
||||||
'company_name' => [
|
|
||||||
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use the `after()` Method for Custom Validation
|
|
||||||
|
|
||||||
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function after(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
function (Validator $validator) {
|
|
||||||
if ($this->quantity > Product::find($this->product_id)?->stock) {
|
|
||||||
$validator->errors()->add('quantity', 'Not enough stock.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
---
|
|
||||||
name: socialite-development
|
|
||||||
description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
|
|
||||||
# Socialite Authentication
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth).
|
|
||||||
|
|
||||||
## Available Providers
|
|
||||||
|
|
||||||
Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch`
|
|
||||||
|
|
||||||
Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`.
|
|
||||||
|
|
||||||
Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`.
|
|
||||||
|
|
||||||
Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand.
|
|
||||||
|
|
||||||
Community providers differ from built-in providers in the following ways:
|
|
||||||
- Installed via `composer require socialiteproviders/{name}`
|
|
||||||
- Must register via event listener — NOT auto-discovered like built-in providers
|
|
||||||
- Use `search-docs` for the registration pattern
|
|
||||||
|
|
||||||
## Adding a Provider
|
|
||||||
|
|
||||||
### 1. Configure the provider
|
|
||||||
|
|
||||||
Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly.
|
|
||||||
|
|
||||||
### 2. Create redirect and callback routes
|
|
||||||
|
|
||||||
Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details.
|
|
||||||
|
|
||||||
### 3. Authenticate and store the user
|
|
||||||
|
|
||||||
In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`.
|
|
||||||
|
|
||||||
### 4. Customize the redirect (optional)
|
|
||||||
|
|
||||||
- `scopes()` — merge additional scopes with the provider's defaults
|
|
||||||
- `setScopes()` — replace all scopes entirely
|
|
||||||
- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google)
|
|
||||||
- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object.
|
|
||||||
- `stateless()` — for API/SPA contexts where session state is not maintained
|
|
||||||
|
|
||||||
### 5. Verify
|
|
||||||
|
|
||||||
1. Config key matches driver name exactly (check the list above for hyphenated names)
|
|
||||||
2. `client_id`, `client_secret`, and `redirect` are all present
|
|
||||||
3. Redirect URL matches what is registered in the provider's OAuth dashboard
|
|
||||||
4. Callback route handles denied grants (when user declines authorization)
|
|
||||||
|
|
||||||
Use `search-docs` for complete code examples of each step.
|
|
||||||
|
|
||||||
## Additional Features
|
|
||||||
|
|
||||||
Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details.
|
|
||||||
|
|
||||||
User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes`
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails.
|
|
||||||
- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors.
|
|
||||||
- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely.
|
|
||||||
- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`.
|
|
||||||
- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol).
|
|
||||||
- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved.
|
|
||||||
- Community providers require event listener registration via `SocialiteWasCalled`.
|
|
||||||
- `user()` throws when the user declines authorization. Always handle denied grants.
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
---
|
|
||||||
name: tailwindcss-development
|
|
||||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tailwind CSS Development
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
|
||||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
|
||||||
|
|
||||||
## Tailwind CSS v4 Specifics
|
|
||||||
|
|
||||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
|
||||||
|
|
||||||
### CSS-First Configuration
|
|
||||||
|
|
||||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
|
||||||
|
|
||||||
<!-- CSS-First Config -->
|
|
||||||
```css
|
|
||||||
@theme {
|
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Import Syntax
|
|
||||||
|
|
||||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
|
||||||
|
|
||||||
<!-- v4 Import Syntax -->
|
|
||||||
```diff
|
|
||||||
- @tailwind base;
|
|
||||||
- @tailwind components;
|
|
||||||
- @tailwind utilities;
|
|
||||||
+ @import "tailwindcss";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Replaced Utilities
|
|
||||||
|
|
||||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
|
||||||
|
|
||||||
| Deprecated | Replacement |
|
|
||||||
|------------|-------------|
|
|
||||||
| bg-opacity-* | bg-black/* |
|
|
||||||
| text-opacity-* | text-black/* |
|
|
||||||
| border-opacity-* | border-black/* |
|
|
||||||
| divide-opacity-* | divide-black/* |
|
|
||||||
| ring-opacity-* | ring-black/* |
|
|
||||||
| placeholder-opacity-* | placeholder-black/* |
|
|
||||||
| flex-shrink-* | shrink-* |
|
|
||||||
| flex-grow-* | grow-* |
|
|
||||||
| overflow-ellipsis | text-ellipsis |
|
|
||||||
| decoration-slice | box-decoration-slice |
|
|
||||||
| decoration-clone | box-decoration-clone |
|
|
||||||
|
|
||||||
## Spacing
|
|
||||||
|
|
||||||
Use `gap` utilities instead of margins for spacing between siblings:
|
|
||||||
|
|
||||||
<!-- Gap Utilities -->
|
|
||||||
```html
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div>Item 1</div>
|
|
||||||
<div>Item 2</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dark Mode
|
|
||||||
|
|
||||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
|
||||||
|
|
||||||
<!-- Dark Mode -->
|
|
||||||
```html
|
|
||||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
|
||||||
Content adapts to color scheme
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Flexbox Layout
|
|
||||||
|
|
||||||
<!-- Flexbox Layout -->
|
|
||||||
```html
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>Left content</div>
|
|
||||||
<div>Right content</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Grid Layout
|
|
||||||
|
|
||||||
<!-- Grid Layout -->
|
|
||||||
```html
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<div>Card 1</div>
|
|
||||||
<div>Card 2</div>
|
|
||||||
<div>Card 3</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
|
||||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
|
||||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
|
||||||
- Using margins for spacing between siblings instead of gap utilities
|
|
||||||
- Forgetting to add dark mode variants when the project uses dark mode
|
|
||||||
@ -303,7 +303,7 @@ public function updateOnboardingToggle(Request $request)
|
|||||||
\App\Models\Setting::set('onboarding_enabled', $enabled);
|
\App\Models\Setting::set('onboarding_enabled', $enabled);
|
||||||
|
|
||||||
$msg = $enabled === '1' ? 'Onboarding & Offboarding module enabled.' : 'Onboarding & Offboarding module disabled.';
|
$msg = $enabled === '1' ? 'Onboarding & Offboarding module enabled.' : 'Onboarding & Offboarding module disabled.';
|
||||||
return redirect()->back(fallback: route('admin.dashboard'))->with('success', $msg);
|
return redirect()->route('admin.dashboard')->with('success', $msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -315,7 +315,7 @@ public function updateScreenshotToggle(Request $request)
|
|||||||
\App\Models\Setting::set('enable_candidate_screenshots', $enabled);
|
\App\Models\Setting::set('enable_candidate_screenshots', $enabled);
|
||||||
|
|
||||||
$msg = $enabled === '1' ? 'Candidate System Screenshot Capture ENABLED globally.' : 'Candidate System Screenshot Capture DISABLED globally by Admin.';
|
$msg = $enabled === '1' ? 'Candidate System Screenshot Capture ENABLED globally.' : 'Candidate System Screenshot Capture DISABLED globally by Admin.';
|
||||||
return redirect()->back(fallback: route('admin.dashboard'))->with('success', $msg);
|
return redirect()->route('admin.dashboard')->with('success', $msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -101,44 +101,4 @@ public function isAssignedInterviewer($userId): bool
|
|||||||
$interviewers = array_map('strval', (array) $this->assigned_interviewers);
|
$interviewers = array_map('strval', (array) $this->assigned_interviewers);
|
||||||
return in_array((string) $userId, $interviewers, true);
|
return in_array((string) $userId, $interviewers, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get computed initials for candidate (e.g. "John Doe" -> "JD").
|
|
||||||
*/
|
|
||||||
public function getCandidateInitialsAttribute(): string
|
|
||||||
{
|
|
||||||
$parts = preg_split('/\s+/', trim($this->candidate_name ?? ''));
|
|
||||||
if (count($parts) >= 2) {
|
|
||||||
return strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
|
|
||||||
}
|
|
||||||
$nameStr = trim($this->candidate_name ?? '');
|
|
||||||
return strtoupper(substr($nameStr, 0, 1) . (strlen($nameStr) > 1 ? substr($nameStr, -1) : ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get proctoring alert metrics summary.
|
|
||||||
*/
|
|
||||||
public function getProctorMetricsAttribute(): array
|
|
||||||
{
|
|
||||||
$logs = $this->proctor_logs ?? [];
|
|
||||||
return [
|
|
||||||
'tab_switches' => count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'tab_switch')),
|
|
||||||
'focus_lost' => count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'focus_lost')),
|
|
||||||
'gaze_alerts' => count(array_filter($logs, fn($l) => in_array($l['type'] ?? '', ['gaze_anomaly', 'gaze_fixed_staring', 'looking_away']))),
|
|
||||||
'lower_gaze' => count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'gaze_lower_device')),
|
|
||||||
'question_repeat' => count(array_filter($logs, fn($l) => in_array($l['type'] ?? '', ['question_repeat_lower', 'question_repetition']))),
|
|
||||||
'external_ai' => count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'external_ai_detected')),
|
|
||||||
'paste_events' => count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'paste_event')),
|
|
||||||
'total_incidents' => count($logs),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format timeline string: "M d, H:i – H:i".
|
|
||||||
*/
|
|
||||||
public function getFormattedTimelineAttribute(): string
|
|
||||||
{
|
|
||||||
$start = $this->scheduled_at ?? $this->created_at;
|
|
||||||
return $start->format('M d, H:i') . ' – ' . $this->expires_at->format('H:i');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
17
boost.json
17
boost.json
@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"agents": [
|
|
||||||
"antigravity",
|
|
||||||
"zed"
|
|
||||||
],
|
|
||||||
"cloud": false,
|
|
||||||
"guidelines": true,
|
|
||||||
"mcp": true,
|
|
||||||
"nightwatch": false,
|
|
||||||
"sail": false,
|
|
||||||
"skills": [
|
|
||||||
"infer-conventions",
|
|
||||||
"laravel-best-practices",
|
|
||||||
"socialite-development",
|
|
||||||
"tailwindcss-development"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -10,7 +10,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"gehrisandro/tailwind-merge-laravel": "^1.4",
|
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
"laravel/socialite": "^5.28",
|
"laravel/socialite": "^5.28",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
@ -18,7 +17,6 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"laravel/boost": "^2.5",
|
|
||||||
"laravel/pail": "^1.2.5",
|
"laravel/pail": "^1.2.5",
|
||||||
"laravel/pao": "^1.0.6",
|
"laravel/pao": "^1.0.6",
|
||||||
"laravel/pint": "^1.27",
|
"laravel/pint": "^1.27",
|
||||||
|
|||||||
500
composer.lock
generated
500
composer.lock
generated
@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d8109d7765ee6f6846cd672d742ad3e6",
|
"content-hash": "c4355def664b0181bc293f22335a84f1",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@ -644,147 +644,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-03T09:33:47+00:00"
|
"time": "2025-12-03T09:33:47+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "gehrisandro/tailwind-merge-laravel",
|
|
||||||
"version": "v1.4.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/gehrisandro/tailwind-merge-laravel.git",
|
|
||||||
"reference": "4dfc54d2f1c148b87a7f9803b3bae74c3437e7d9"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/gehrisandro/tailwind-merge-laravel/zipball/4dfc54d2f1c148b87a7f9803b3bae74c3437e7d9",
|
|
||||||
"reference": "4dfc54d2f1c148b87a7f9803b3bae74c3437e7d9",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"gehrisandro/tailwind-merge-php": "^v1.1.0",
|
|
||||||
"guzzlehttp/guzzle": "^7.5.1",
|
|
||||||
"laravel/framework": "^12.0|^13.0",
|
|
||||||
"php": "^8.2.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"laravel/pint": "^1.13.8",
|
|
||||||
"orchestra/testbench": "^10.0|^11.0",
|
|
||||||
"pestphp/pest": "^3.7|^4.4",
|
|
||||||
"pestphp/pest-plugin-arch": "^3.0|^4.0",
|
|
||||||
"pestphp/pest-plugin-type-coverage": "^3.3|^4.0",
|
|
||||||
"phpstan/phpstan": "^1.10.55|^2.1",
|
|
||||||
"rector/rector": "^0.19|^2.0",
|
|
||||||
"symfony/var-dumper": "^6.4.2|^7.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"providers": [
|
|
||||||
"TailwindMerge\\Laravel\\TailwindMergeServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"src/helpers.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"TailwindMerge\\Laravel\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Sandro Gehri",
|
|
||||||
"email": "sandrogehri@gmail.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "TailwindMerge for Laravel merges multiple Tailwind CSS classes by automatically resolving conflicts between them",
|
|
||||||
"keywords": [
|
|
||||||
"classes",
|
|
||||||
"laravel",
|
|
||||||
"merge",
|
|
||||||
"php",
|
|
||||||
"tailwindcss"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/gehrisandro/tailwind-merge-laravel/issues",
|
|
||||||
"source": "https://github.com/gehrisandro/tailwind-merge-laravel/tree/v1.4.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/gehrisandro",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-03-21T06:54:49+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "gehrisandro/tailwind-merge-php",
|
|
||||||
"version": "v1.2.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/gehrisandro/tailwind-merge-php.git",
|
|
||||||
"reference": "400040c89bc958ed130699ee4db0213f689cd498"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/gehrisandro/tailwind-merge-php/zipball/400040c89bc958ed130699ee4db0213f689cd498",
|
|
||||||
"reference": "400040c89bc958ed130699ee4db0213f689cd498",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^8.2.0",
|
|
||||||
"psr/simple-cache": "^3.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"laravel/pint": "^1.13.8",
|
|
||||||
"nunomaduro/collision": "^v8.9.1",
|
|
||||||
"pestphp/pest": "^v4.4.2",
|
|
||||||
"pestphp/pest-plugin-type-coverage": "^v4.0.3",
|
|
||||||
"phpstan/phpstan": "^2.1.42",
|
|
||||||
"rector/rector": "^2.3.9",
|
|
||||||
"symfony/var-dumper": "^v7.4.6"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"src/TailwindMerge.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"TailwindMerge\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Sandro Gehri",
|
|
||||||
"email": "sandrogehri@gmail.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "TailwindMerge for PHP merges multiple Tailwind CSS classes by automatically resolving conflicts between them",
|
|
||||||
"keywords": [
|
|
||||||
"classes",
|
|
||||||
"merge",
|
|
||||||
"php",
|
|
||||||
"tailwindcss"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/gehrisandro/tailwind-merge-php/issues",
|
|
||||||
"source": "https://github.com/gehrisandro/tailwind-merge-php/tree/v1.2.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/gehrisandro",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-03-21T06:39:15+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "graham-campbell/result-type",
|
"name": "graham-campbell/result-type",
|
||||||
"version": "v1.1.4",
|
"version": "v1.1.4",
|
||||||
@ -6706,83 +6565,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
{
|
|
||||||
"name": "composer/semver",
|
|
||||||
"version": "3.4.4",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/composer/semver.git",
|
|
||||||
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
|
||||||
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^5.3.2 || ^7.0 || ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpstan/phpstan": "^1.11",
|
|
||||||
"symfony/phpunit-bridge": "^3 || ^7"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-main": "3.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Composer\\Semver\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Nils Adermann",
|
|
||||||
"email": "naderman@naderman.de",
|
|
||||||
"homepage": "http://www.naderman.de"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jordi Boggiano",
|
|
||||||
"email": "j.boggiano@seld.be",
|
|
||||||
"homepage": "http://seld.be"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Rob Bast",
|
|
||||||
"email": "rob.bast@gmail.com",
|
|
||||||
"homepage": "http://robbast.nl"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Semver library that offers utilities, version constraint parsing and validation.",
|
|
||||||
"keywords": [
|
|
||||||
"semantic",
|
|
||||||
"semver",
|
|
||||||
"validation",
|
|
||||||
"versioning"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"irc": "ircs://irc.libera.chat:6697/composer",
|
|
||||||
"issues": "https://github.com/composer/semver/issues",
|
|
||||||
"source": "https://github.com/composer/semver/tree/3.4.4"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://packagist.com",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/composer",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2025-08-20T19:15:30+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "fakerphp/faker",
|
"name": "fakerphp/faker",
|
||||||
"version": "v1.24.1",
|
"version": "v1.24.1",
|
||||||
@ -7030,146 +6812,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-04-29T18:32:34+00:00"
|
"time": "2026-04-29T18:32:34+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "laravel/boost",
|
|
||||||
"version": "v2.5.3",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/laravel/boost.git",
|
|
||||||
"reference": "f5f9297225aba9857d014b3140f55a7c50cb1a92"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/laravel/boost/zipball/f5f9297225aba9857d014b3140f55a7c50cb1a92",
|
|
||||||
"reference": "f5f9297225aba9857d014b3140f55a7c50cb1a92",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"guzzlehttp/guzzle": "^7.9",
|
|
||||||
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"laravel/mcp": "^0.7.1|^0.8.0|^0.9.0",
|
|
||||||
"laravel/prompts": "^0.3.10",
|
|
||||||
"laravel/roster": "^1.0.0",
|
|
||||||
"php": "^8.2"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"laravel/pint": "^1.27.0",
|
|
||||||
"mockery/mockery": "^1.6.12",
|
|
||||||
"orchestra/testbench": "^9.15.0|^10.6|^11.0",
|
|
||||||
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
|
|
||||||
"phpstan/phpstan": "^2.1.27",
|
|
||||||
"rector/rector": "^2.1"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"providers": [
|
|
||||||
"Laravel\\Boost\\BoostServiceProvider"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "1.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Laravel\\Boost\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
|
|
||||||
"homepage": "https://github.com/laravel/boost",
|
|
||||||
"keywords": [
|
|
||||||
"ai",
|
|
||||||
"dev",
|
|
||||||
"laravel"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/laravel/boost/issues",
|
|
||||||
"source": "https://github.com/laravel/boost"
|
|
||||||
},
|
|
||||||
"time": "2026-08-07T05:46:02+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "laravel/mcp",
|
|
||||||
"version": "v0.9.4",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/laravel/mcp.git",
|
|
||||||
"reference": "7ca5b923630118696602d14348cd0466a5e853ec"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/laravel/mcp/zipball/7ca5b923630118696602d14348cd0466a5e853ec",
|
|
||||||
"reference": "7ca5b923630118696602d14348cd0466a5e853ec",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-json": "*",
|
|
||||||
"ext-mbstring": "*",
|
|
||||||
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"illuminate/json-schema": "^12.41.1|^13.0",
|
|
||||||
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
|
|
||||||
"php": "^8.2",
|
|
||||||
"symfony/process": "^7.4.5|^8.0.5"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"laravel/pint": "^1.20",
|
|
||||||
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
|
||||||
"pestphp/pest": "^3.8.5|^4.3.2",
|
|
||||||
"phpstan/phpstan": "^2.1.27",
|
|
||||||
"rector/rector": "^2.2.4"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"aliases": {
|
|
||||||
"Mcp": "Laravel\\Mcp\\Facades\\Mcp"
|
|
||||||
},
|
|
||||||
"providers": [
|
|
||||||
"Laravel\\Mcp\\Server\\McpServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Laravel\\Mcp\\": "src/",
|
|
||||||
"Laravel\\Mcp\\Server\\": "src/Server/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Taylor Otwell",
|
|
||||||
"email": "taylor@laravel.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Rapidly build MCP servers for your Laravel applications.",
|
|
||||||
"homepage": "https://github.com/laravel/mcp",
|
|
||||||
"keywords": [
|
|
||||||
"laravel",
|
|
||||||
"mcp"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/laravel/mcp/issues",
|
|
||||||
"source": "https://github.com/laravel/mcp"
|
|
||||||
},
|
|
||||||
"time": "2026-08-13T15:01:07+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "laravel/pail",
|
"name": "laravel/pail",
|
||||||
"version": "v1.2.7",
|
"version": "v1.2.7",
|
||||||
@ -7403,68 +7045,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-06-16T15:34:04+00:00"
|
"time": "2026-06-16T15:34:04+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "laravel/roster",
|
|
||||||
"version": "v1.0.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/laravel/roster.git",
|
|
||||||
"reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa",
|
|
||||||
"reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"composer/semver": "^3.0",
|
|
||||||
"illuminate/console": "^11.0|^12.0|^13.0",
|
|
||||||
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
|
||||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
|
||||||
"php": "^8.2",
|
|
||||||
"symfony/yaml": "^7.2|^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"laravel/pint": "^1.29",
|
|
||||||
"mockery/mockery": "^1.6",
|
|
||||||
"orchestra/testbench": "^9.0|^10.0|^11.0",
|
|
||||||
"pestphp/pest": "^3.0|^4.1",
|
|
||||||
"phpstan/phpstan": "^2.0",
|
|
||||||
"rector/rector": "^2.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"providers": [
|
|
||||||
"Laravel\\Roster\\RosterServiceProvider"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "1.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Laravel\\Roster\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"description": "Detect packages & approaches in use within a Laravel project",
|
|
||||||
"homepage": "https://github.com/laravel/roster",
|
|
||||||
"keywords": [
|
|
||||||
"dev",
|
|
||||||
"laravel"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/laravel/roster/issues",
|
|
||||||
"source": "https://github.com/laravel/roster"
|
|
||||||
},
|
|
||||||
"time": "2026-07-18T17:53:15+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "mockery/mockery",
|
"name": "mockery/mockery",
|
||||||
"version": "1.6.12",
|
"version": "1.6.12",
|
||||||
@ -9218,82 +8798,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-10-20T05:08:20+00:00"
|
"time": "2024-10-20T05:08:20+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/yaml",
|
|
||||||
"version": "v8.1.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/yaml.git",
|
|
||||||
"reference": "faabdbe998e8c5c599dceffa27aa265b185c0736"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/faabdbe998e8c5c599dceffa27aa265b185c0736",
|
|
||||||
"reference": "faabdbe998e8c5c599dceffa27aa265b185c0736",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.4.1",
|
|
||||||
"symfony/polyfill-ctype": "^1.8"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"symfony/console": "<7.4"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"symfony/console": "^7.4|^8.0",
|
|
||||||
"yaml/yaml-test-suite": "*"
|
|
||||||
},
|
|
||||||
"bin": [
|
|
||||||
"Resources/bin/yaml-lint"
|
|
||||||
],
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Component\\Yaml\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Loads and dumps YAML files",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/yaml/tree/v8.1.2"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-07-22T15:42:13+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "theseer/tokenizer",
|
"name": "theseer/tokenizer",
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@ -9354,5 +8858,5 @@
|
|||||||
"php": "^8.3"
|
"php": "^8.3"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* Real-Time Voice Activity Detector (VAD) & Active Speaking Border Engine
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Easily configurable Tailwind CSS classes applied when a participant is actively speaking
|
|
||||||
export const SPEAKING_ACTIVE_CLASSES = [
|
|
||||||
'border-emerald-500/80',
|
|
||||||
];
|
|
||||||
|
|
||||||
export class AudioActivityMonitor {
|
|
||||||
constructor(speakingClasses = SPEAKING_ACTIVE_CLASSES) {
|
|
||||||
this.audioCtx = null;
|
|
||||||
this.monitors = new Map(); // key -> { source, analyser, animId, targetElemId }
|
|
||||||
this.speakingClasses = speakingClasses;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAudioContext() {
|
|
||||||
if (!this.audioCtx) {
|
|
||||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
|
||||||
if (AudioCtx) {
|
|
||||||
this.audioCtx = new AudioCtx();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.audioCtx && this.audioCtx.state === 'suspended') {
|
|
||||||
this.audioCtx.resume().catch(() => {});
|
|
||||||
}
|
|
||||||
return this.audioCtx;
|
|
||||||
}
|
|
||||||
|
|
||||||
attach(key, mediaStream, targetElemId, threshold = 12) {
|
|
||||||
this.detach(key);
|
|
||||||
if (!mediaStream || !mediaStream.getAudioTracks || mediaStream.getAudioTracks().length === 0) return;
|
|
||||||
|
|
||||||
const ctx = this.getAudioContext();
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const source = ctx.createMediaStreamSource(mediaStream);
|
|
||||||
const analyser = ctx.createAnalyser();
|
|
||||||
analyser.fftSize = 256;
|
|
||||||
analyser.smoothingTimeConstant = 0.3;
|
|
||||||
source.connect(analyser);
|
|
||||||
|
|
||||||
const bufferLength = analyser.frequencyBinCount;
|
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
|
||||||
|
|
||||||
let isSpeaking = false;
|
|
||||||
let silenceTimer = null;
|
|
||||||
|
|
||||||
const checkAudio = () => {
|
|
||||||
const entry = this.monitors.get(key);
|
|
||||||
if (!entry) return;
|
|
||||||
|
|
||||||
analyser.getByteFrequencyData(dataArray);
|
|
||||||
let sum = 0;
|
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
|
||||||
sum += dataArray[i];
|
|
||||||
}
|
|
||||||
const average = sum / bufferLength;
|
|
||||||
|
|
||||||
const elem = document.getElementById(targetElemId);
|
|
||||||
if (average > threshold) {
|
|
||||||
if (!isSpeaking) {
|
|
||||||
isSpeaking = true;
|
|
||||||
if (silenceTimer) clearTimeout(silenceTimer);
|
|
||||||
if (elem) {
|
|
||||||
elem.classList.add(...this.speakingClasses);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (silenceTimer) clearTimeout(silenceTimer);
|
|
||||||
}
|
|
||||||
silenceTimer = setTimeout(() => {
|
|
||||||
isSpeaking = false;
|
|
||||||
if (elem) {
|
|
||||||
elem.classList.remove(...this.speakingClasses);
|
|
||||||
}
|
|
||||||
}, 350);
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.animId = requestAnimationFrame(checkAudio);
|
|
||||||
};
|
|
||||||
|
|
||||||
const animId = requestAnimationFrame(checkAudio);
|
|
||||||
this.monitors.set(key, { source, analyser, animId, targetElemId });
|
|
||||||
} catch (e) {
|
|
||||||
console.log('VAD attach notice:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
detach(key) {
|
|
||||||
if (this.monitors.has(key)) {
|
|
||||||
const m = this.monitors.get(key);
|
|
||||||
if (m.animId) cancelAnimationFrame(m.animId);
|
|
||||||
try { if (m.source) m.source.disconnect(); } catch(e) {}
|
|
||||||
try { if (m.analyser) m.analyser.disconnect(); } catch(e) {}
|
|
||||||
const elem = document.getElementById(m.targetElemId);
|
|
||||||
if (elem) {
|
|
||||||
elem.classList.remove(...this.speakingClasses);
|
|
||||||
}
|
|
||||||
this.monitors.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const globalAudioMonitor = new AudioActivityMonitor();
|
|
||||||
@ -626,12 +626,12 @@ export function updateScreenShareButton(active) {
|
|||||||
const btn = document.getElementById('share-screen-btn');
|
const btn = document.getElementById('share-screen-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (active) {
|
if (active) {
|
||||||
btn.innerHTML = '<i class="fa-solid fa-stop mr-1.5"></i> <span>Stop Screen Sharing</span>';
|
btn.innerHTML = '🛑 Stop Screen Sharing';
|
||||||
btn.style.background = '#ef4444';
|
btn.style.background = '#ef4444';
|
||||||
btn.style.borderColor = '#dc2626';
|
btn.style.borderColor = '#dc2626';
|
||||||
btn.onclick = stopScreenShare;
|
btn.onclick = stopScreenShare;
|
||||||
} else {
|
} else {
|
||||||
btn.innerHTML = '<i class="fa-solid fa-desktop mr-1.5"></i> <span>Share Screen with Interviewer</span>';
|
btn.innerHTML = '🖥️ Share Screen with Interviewer';
|
||||||
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
|
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
|
||||||
btn.style.borderColor = '#6366f1';
|
btn.style.borderColor = '#6366f1';
|
||||||
btn.onclick = startScreenShare;
|
btn.onclick = startScreenShare;
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { safePlayMediaStream, getInitials } from './interview-call.js';
|
import { safePlayMediaStream, getInitials } from './interview-call.js';
|
||||||
import { globalAudioMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js';
|
|
||||||
import { initMeteredIceServers } from './metered.js';
|
import { initMeteredIceServers } from './metered.js';
|
||||||
|
|
||||||
let editor = null;
|
let editor = null;
|
||||||
@ -438,20 +437,22 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
|
|||||||
if (!tile) {
|
if (!tile) {
|
||||||
tile = document.createElement('div');
|
tile = document.createElement('div');
|
||||||
tile.id = 'cand-iv-tile-' + peerId;
|
tile.id = 'cand-iv-tile-' + peerId;
|
||||||
tile.className = 'w-full aspect-[16/10] bg-black rounded-xl border-2 border-indigo-500/40 overflow-hidden relative shrink-0';
|
tile.className = 'webcam-box';
|
||||||
|
tile.style.position = 'relative';
|
||||||
|
tile.style.borderColor = '#6366f1';
|
||||||
|
|
||||||
tile.innerHTML = `
|
tile.innerHTML = `
|
||||||
<video id="cand-iv-video-${peerId}" autoplay playsinline class="w-full h-full object-cover bg-slate-950 -scale-x-100"></video>
|
<video id="cand-iv-video-${peerId}" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811;"></video>
|
||||||
<div id="cand-iv-placeholder-${peerId}" class="absolute inset-0 ${camOnVal ? 'hidden' : 'flex'} flex-col items-center justify-center bg-slate-900/95 text-slate-400 text-xs text-center p-2.5 z-10">
|
<div id="cand-iv-placeholder-${peerId}" style="position: absolute; inset: 0; display: ${camOnVal ? 'none' : 'flex'}; flex-direction: column; align-items: center; justify-content: center; background: #0f172a; color: white; z-index: 5;">
|
||||||
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-sky-400 to-indigo-500 flex items-center justify-center text-xl font-bold font-outfit text-white mb-1.5 shadow-lg shadow-sky-500/30">
|
<div style="width: 58px; height: 58px; border-radius: 50%; background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 800; color: white; box-shadow: 0 0 15px rgba(56,189,248,0.4);">
|
||||||
${initials}
|
${initials}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-200 font-semibold">${labelName}</div>
|
<div style="font-size: 0.8rem; font-weight: 700; color: white; margin-top: 6px;">${labelName}</div>
|
||||||
<div class="text-[10.5px] text-slate-400 mt-0.5 font-medium">Camera Turned Off</div>
|
<div style="font-size: 0.65rem; color: #94a3b8; margin-top: 2px;">Camera Turned Off</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-2 left-2 bg-indigo-600/90 px-2 py-0.5 rounded text-[10.5px] text-white font-semibold z-10 backdrop-blur-xs flex items-center gap-1.5">
|
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.85); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10; display: flex; align-items: center; gap: 6px;">
|
||||||
<span><i class="fa-solid fa-microphone-lines mr-1 text-[9px]"></i>${labelName}</span>
|
<span>🎙️ ${labelName}</span>
|
||||||
<span class="opacity-40">|</span>
|
<span style="opacity: 0.4;">|</span>
|
||||||
<i id="cand-iv-mic-${peerId}" class="${micOnVal ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'}" style="color: ${micOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${micOnVal ? 'Mic On' : 'Mic Muted'}"></i>
|
<i id="cand-iv-mic-${peerId}" class="${micOnVal ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'}" style="color: ${micOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${micOnVal ? 'Mic On' : 'Mic Muted'}"></i>
|
||||||
<i id="cand-iv-cam-${peerId}" class="${camOnVal ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'}" style="color: ${camOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${camOnVal ? 'Camera On' : 'Camera Off'}"></i>
|
<i id="cand-iv-cam-${peerId}" class="${camOnVal ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'}" style="color: ${camOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${camOnVal ? 'Camera On' : 'Camera Off'}"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -476,13 +477,7 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
|
|||||||
camIcon.title = camOnVal ? 'Camera On' : 'Camera Off';
|
camIcon.title = camOnVal ? 'Camera On' : 'Camera Off';
|
||||||
}
|
}
|
||||||
if (placeholderEl) {
|
if (placeholderEl) {
|
||||||
if (camOnVal) {
|
placeholderEl.style.display = camOnVal ? 'none' : 'flex';
|
||||||
placeholderEl.classList.add('hidden');
|
|
||||||
placeholderEl.classList.remove('flex');
|
|
||||||
} else {
|
|
||||||
placeholderEl.classList.remove('hidden');
|
|
||||||
placeholderEl.classList.add('flex');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -497,7 +492,7 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
|
|||||||
|
|
||||||
const badge = document.getElementById('call-status-badge');
|
const badge = document.getElementById('call-status-badge');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.innerHTML = '<i class="fa-solid fa-circle text-[8px] mr-1 text-emerald-400"></i> CALL LIVE';
|
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
|
||||||
badge.style.background = 'rgba(16,185,129,0.3)';
|
badge.style.background = 'rgba(16,185,129,0.3)';
|
||||||
badge.style.color = '#34d399';
|
badge.style.color = '#34d399';
|
||||||
}
|
}
|
||||||
@ -505,7 +500,6 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function removeCandidateInterviewerTile(peerId) {
|
export function removeCandidateInterviewerTile(peerId) {
|
||||||
globalAudioMonitor.detach('cand-iv-video-' + peerId);
|
|
||||||
const stream = candidateInterviewerTiles.get(peerId);
|
const stream = candidateInterviewerTiles.get(peerId);
|
||||||
if (stream && stream.active && latestCallStatus !== 'ended') {
|
if (stream && stream.active && latestCallStatus !== 'ended') {
|
||||||
return;
|
return;
|
||||||
@ -522,11 +516,11 @@ export function removeCandidateInterviewerTile(peerId) {
|
|||||||
const badge = document.getElementById('call-status-badge');
|
const badge = document.getElementById('call-status-badge');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
if (latestCallStatus === 'ended') {
|
if (latestCallStatus === 'ended') {
|
||||||
badge.innerText = 'CALL ENDED';
|
badge.innerText = 'CALL ENDED BY HOST';
|
||||||
badge.style.background = 'rgba(239,68,68,0.2)';
|
badge.style.background = 'rgba(239,68,68,0.2)';
|
||||||
badge.style.color = '#fca5a5';
|
badge.style.color = '#fca5a5';
|
||||||
} else {
|
} else {
|
||||||
badge.innerText = 'READY';
|
badge.innerText = 'READY (Waiting for Interviewer)';
|
||||||
badge.style.background = 'rgba(16,185,129,0.2)';
|
badge.style.background = 'rgba(16,185,129,0.2)';
|
||||||
badge.style.color = '#34d399';
|
badge.style.color = '#34d399';
|
||||||
}
|
}
|
||||||
@ -556,7 +550,7 @@ export function triggerCandidateRing(offer = null, callStartedAt = null) {
|
|||||||
|
|
||||||
const badge = document.getElementById('call-status-badge');
|
const badge = document.getElementById('call-status-badge');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.innerHTML = '<i class="fa-solid fa-bell text-[9px] mr-1 text-amber-300 animate-bounce"></i> INCOMING CALL... RINGING';
|
badge.innerText = '🔔 INCOMING CALL... RINGING';
|
||||||
badge.style.background = 'rgba(234,179,8,0.3)';
|
badge.style.background = 'rgba(234,179,8,0.3)';
|
||||||
badge.style.color = '#fde047';
|
badge.style.color = '#fde047';
|
||||||
}
|
}
|
||||||
@ -624,7 +618,7 @@ export function showCandidateJoinOption() {
|
|||||||
}
|
}
|
||||||
const badge = document.getElementById('call-status-badge');
|
const badge = document.getElementById('call-status-badge');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.innerText = 'CALL IN PROGRESS';
|
badge.innerText = '📞 CALL IN PROGRESS';
|
||||||
badge.style.background = 'rgba(59,130,246,0.3)';
|
badge.style.background = 'rgba(59,130,246,0.3)';
|
||||||
badge.style.color = '#93c5fd';
|
badge.style.color = '#93c5fd';
|
||||||
}
|
}
|
||||||
@ -632,7 +626,7 @@ export function showCandidateJoinOption() {
|
|||||||
if (joinBtn) {
|
if (joinBtn) {
|
||||||
joinBtn.style.display = 'block';
|
joinBtn.style.display = 'block';
|
||||||
joinBtn.disabled = false;
|
joinBtn.disabled = false;
|
||||||
joinBtn.innerHTML = '<i class="fa-solid fa-phone-volume mr-1.5"></i> <span>Join Call Now</span>';
|
joinBtn.innerText = '🟢 📞 Call in Progress • Join Call Now';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -685,7 +679,7 @@ export async function acceptCandidateCall() {
|
|||||||
|
|
||||||
const badge = document.getElementById('call-status-badge');
|
const badge = document.getElementById('call-status-badge');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.innerText = 'CALL CONNECTED';
|
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
|
||||||
badge.style.background = 'rgba(16,185,129,0.3)';
|
badge.style.background = 'rgba(16,185,129,0.3)';
|
||||||
badge.style.color = '#34d399';
|
badge.style.color = '#34d399';
|
||||||
}
|
}
|
||||||
@ -797,7 +791,7 @@ export function candidateLeaveCall(isEndedByHost = false) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (joinBtn) {
|
if (joinBtn) {
|
||||||
joinBtn.innerHTML = '<i class="fa-solid fa-phone-volume mr-1.5"></i> <span>Rejoin Call Now</span>';
|
joinBtn.innerText = '🟢 📞 Rejoin Call Now';
|
||||||
joinBtn.style.display = 'block';
|
joinBtn.style.display = 'block';
|
||||||
joinBtn.disabled = false;
|
joinBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@ -1094,10 +1088,9 @@ export function toggleMic() {
|
|||||||
audioTracks[0].enabled = isCandidateMicEnabled;
|
audioTracks[0].enabled = isCandidateMicEnabled;
|
||||||
const btn = document.getElementById('toggle-mic-btn');
|
const btn = document.getElementById('toggle-mic-btn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = `<i class="${isCandidateMicEnabled ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'}"></i> <span>${isCandidateMicEnabled ? 'Mic On' : 'Mic Off'}</span>`;
|
btn.innerText = isCandidateMicEnabled ? '🎤 Mic On' : '🔇 Mic Muted';
|
||||||
btn.style.background = isCandidateMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
|
btn.style.background = isCandidateMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
|
||||||
btn.style.borderColor = isCandidateMicEnabled ? '#10b981' : '#ef4444';
|
btn.style.borderColor = isCandidateMicEnabled ? '#10b981' : '#ef4444';
|
||||||
btn.className = `flex-1 py-2 px-2 text-xs font-semibold rounded-lg ${isCandidateMicEnabled ? 'bg-emerald-500/20 border-emerald-500 text-emerald-300 hover:bg-emerald-500/30' : 'bg-red-500/20 border-red-500 text-red-300 hover:bg-red-500/30'} border transition-all cursor-pointer flex items-center justify-center gap-1.5`;
|
|
||||||
}
|
}
|
||||||
const icon = document.getElementById('cand-self-mic-icon');
|
const icon = document.getElementById('cand-self-mic-icon');
|
||||||
if (icon) {
|
if (icon) {
|
||||||
@ -1118,10 +1111,9 @@ export function toggleCam() {
|
|||||||
videoTracks[0].enabled = isCandidateCamEnabled;
|
videoTracks[0].enabled = isCandidateCamEnabled;
|
||||||
const btn = document.getElementById('toggle-cam-btn');
|
const btn = document.getElementById('toggle-cam-btn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = `<i class="${isCandidateCamEnabled ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'}"></i> <span>${isCandidateCamEnabled ? 'Cam On' : 'Cam Off'}</span>`;
|
btn.innerText = isCandidateCamEnabled ? '📷 Cam On' : '🚫 Cam Off';
|
||||||
btn.style.background = isCandidateCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
|
btn.style.background = isCandidateCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
|
||||||
btn.style.borderColor = isCandidateCamEnabled ? '#10b981' : '#ef4444';
|
btn.style.borderColor = isCandidateCamEnabled ? '#10b981' : '#ef4444';
|
||||||
btn.className = `flex-1 py-2 px-2 text-xs font-semibold rounded-lg ${isCandidateCamEnabled ? 'bg-emerald-500/20 border-emerald-500 text-emerald-300 hover:bg-emerald-500/30' : 'bg-red-500/20 border-red-500 text-red-300 hover:bg-red-500/30'} border transition-all cursor-pointer flex items-center justify-center gap-1.5`;
|
|
||||||
}
|
}
|
||||||
if (avatarPlaceholder) avatarPlaceholder.style.display = isCandidateCamEnabled ? 'none' : 'flex';
|
if (avatarPlaceholder) avatarPlaceholder.style.display = isCandidateCamEnabled ? 'none' : 'flex';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { initMeteredIceServers } from './metered.js';
|
import { initMeteredIceServers } from './metered.js';
|
||||||
import { globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js';
|
|
||||||
|
|
||||||
// --- Global Audio Ringtone Synthesizer Engine ---
|
// --- Global Audio Ringtone Synthesizer Engine ---
|
||||||
export class CallRingtoneEngine {
|
export class CallRingtoneEngine {
|
||||||
@ -363,71 +362,8 @@ export function updateScreenshotToggleBtnUI(enabled) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentInspectorTab = null;
|
export function switchIdeTab(tab) {
|
||||||
|
const tabs = ['code', 'notes', 'drawing', 'recordings', 'screenshots'];
|
||||||
export function closeInspectorPanel() {
|
|
||||||
currentInspectorTab = null;
|
|
||||||
try {
|
|
||||||
if (currentInterviewId) {
|
|
||||||
sessionStorage.removeItem('interview_inspector_tab_' + currentInterviewId);
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
|
|
||||||
const details = document.getElementById('inspector-details');
|
|
||||||
if (details) details.open = false;
|
|
||||||
|
|
||||||
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots'];
|
|
||||||
tabs.forEach(t => {
|
|
||||||
const contentEl = document.getElementById(`tab-content-${t}`);
|
|
||||||
const btnEl = document.getElementById(`tab-btn-${t}`);
|
|
||||||
if (contentEl) contentEl.style.display = 'none';
|
|
||||||
if (btnEl) {
|
|
||||||
btnEl.classList.remove('bg-indigo-600', 'text-white');
|
|
||||||
btnEl.classList.add('text-slate-400', 'hover:bg-white/5');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function switchIdeTab(tab, event = null) {
|
|
||||||
if (event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = document.getElementById('inspector-details');
|
|
||||||
|
|
||||||
// If user clicks the currently active open tab button, toggle it collapsed
|
|
||||||
if (currentInspectorTab === tab && details && details.open) {
|
|
||||||
closeInspectorPanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentInspectorTab = tab;
|
|
||||||
try {
|
|
||||||
if (currentInterviewId) {
|
|
||||||
sessionStorage.setItem('interview_inspector_tab_' + currentInterviewId, tab);
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
|
|
||||||
if (details) details.open = true;
|
|
||||||
|
|
||||||
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots'];
|
|
||||||
const tabMeta = {
|
|
||||||
logs: { title: 'Proctoring Logs', icon: 'fa-solid fa-shield-halved' },
|
|
||||||
notes: { title: 'Candidate Notepad', icon: 'fa-solid fa-note-sticky' },
|
|
||||||
drawing: { title: 'Candidate Drawing Board', icon: 'fa-solid fa-pen' },
|
|
||||||
recordings: { title: 'Saved Video Recordings', icon: 'fa-solid fa-film' },
|
|
||||||
screenshots: { title: 'Tab Switch Screenshots', icon: 'fa-solid fa-image' },
|
|
||||||
code: { title: 'Candidate Code', icon: 'fa-solid fa-code' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const floatingTitle = document.getElementById('inspector-floating-text');
|
|
||||||
const floatingIcon = document.getElementById('inspector-floating-icon');
|
|
||||||
|
|
||||||
if (tabMeta[tab]) {
|
|
||||||
if (floatingTitle) floatingTitle.innerText = tabMeta[tab].title;
|
|
||||||
if (floatingIcon) floatingIcon.className = `${tabMeta[tab].icon} text-indigo-400`;
|
|
||||||
}
|
|
||||||
|
|
||||||
tabs.forEach(t => {
|
tabs.forEach(t => {
|
||||||
const contentEl = document.getElementById(`tab-content-${t}`);
|
const contentEl = document.getElementById(`tab-content-${t}`);
|
||||||
const btnEl = document.getElementById(`tab-btn-${t}`);
|
const btnEl = document.getElementById(`tab-btn-${t}`);
|
||||||
@ -436,26 +372,16 @@ export function switchIdeTab(tab, event = null) {
|
|||||||
}
|
}
|
||||||
if (btnEl) {
|
if (btnEl) {
|
||||||
if (t === tab) {
|
if (t === tab) {
|
||||||
btnEl.classList.add('bg-indigo-600', 'text-white');
|
btnEl.classList.add('bg-indigo-600', 'text-white', 'border-indigo-500');
|
||||||
btnEl.classList.remove('text-slate-400', 'hover:bg-white/5');
|
btnEl.classList.remove('bg-white/5', 'text-slate-400', 'border-slate-700/60');
|
||||||
} else {
|
} else {
|
||||||
btnEl.classList.remove('bg-indigo-600', 'text-white');
|
btnEl.classList.remove('bg-indigo-600', 'text-white', 'border-indigo-500');
|
||||||
btnEl.classList.add('text-slate-400', 'hover:bg-white/5');
|
btnEl.classList.add('bg-white/5', 'text-slate-400', 'border-slate-700/60');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click outside to dismiss floating inspector details
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
const details = document.getElementById('inspector-details');
|
|
||||||
if (details && details.open && !details.contains(e.target)) {
|
|
||||||
closeInspectorPanel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) {
|
export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) {
|
||||||
if (!callData || !callData.id) return;
|
if (!callData || !callData.id) return;
|
||||||
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
|
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
|
||||||
@ -683,24 +609,7 @@ export function openReviewModal(id, name, uid, autoJoinCall = false) {
|
|||||||
if (reviewModal) reviewModal.classList.add('active');
|
if (reviewModal) reviewModal.classList.add('active');
|
||||||
|
|
||||||
resetZoomScreen();
|
resetZoomScreen();
|
||||||
if (document.getElementById('inspector-content-container')) {
|
switchIdeTab('code');
|
||||||
let savedInspectorTab = null;
|
|
||||||
try {
|
|
||||||
savedInspectorTab = sessionStorage.getItem('interview_inspector_tab_' + id);
|
|
||||||
} catch(e) {}
|
|
||||||
|
|
||||||
if (savedInspectorTab) {
|
|
||||||
switchIdeTab(savedInspectorTab);
|
|
||||||
} else {
|
|
||||||
closeInspectorPanel();
|
|
||||||
}
|
|
||||||
} else if (document.getElementById('tab-btn-logs')) {
|
|
||||||
switchIdeTab('logs');
|
|
||||||
} else if (document.getElementById('tab-btn-notes')) {
|
|
||||||
switchIdeTab('notes');
|
|
||||||
} else {
|
|
||||||
switchIdeTab('code');
|
|
||||||
}
|
|
||||||
updateCandidateStatusIcons(false, false, false);
|
updateCandidateStatusIcons(false, false, false);
|
||||||
|
|
||||||
initInterviewerSigChannel();
|
initInterviewerSigChannel();
|
||||||
@ -874,9 +783,6 @@ export function pollReviewData() {
|
|||||||
const screenPlace = document.getElementById('screen-video-placeholder');
|
const screenPlace = document.getElementById('screen-video-placeholder');
|
||||||
if (screenVid) screenVid.srcObject = null;
|
if (screenVid) screenVid.srcObject = null;
|
||||||
if (screenPlace) screenPlace.style.display = 'flex';
|
if (screenPlace) screenPlace.style.display = 'flex';
|
||||||
if (typeof window.updateScreenShareLayout === 'function') {
|
|
||||||
window.updateScreenShareLayout(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
panelistMeshTiles.forEach((_, pid) => {
|
panelistMeshTiles.forEach((_, pid) => {
|
||||||
removePanelistTile(pid);
|
removePanelistTile(pid);
|
||||||
@ -1051,7 +957,7 @@ export function pollReviewData() {
|
|||||||
const badgeLabel = isCand ? 'Candidate' : 'Panelist';
|
const badgeLabel = isCand ? 'Candidate' : 'Panelist';
|
||||||
const badgeClass = isCand ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30';
|
const badgeClass = isCand ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30';
|
||||||
|
|
||||||
chatHtml += `<div class="chat-message mb-2 p-2.5 rounded-lg transition-all w-full shrink-0" style="background: ${color}1a; border-color: ${color};">
|
chatHtml += `<div class="chat-message mb-2 p-2.5 rounded-lg border-l-2 transition-all w-full shrink-0" style="background: ${color}1a; border-color: ${color};">
|
||||||
<div class="flex justify-between items-center mb-1 gap-2">
|
<div class="flex justify-between items-center mb-1 gap-2">
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<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: ${color};"></span>
|
<span class="w-1.5 h-1.5 rounded-full shrink-0" style="background: ${color};"></span>
|
||||||
@ -1063,11 +969,11 @@ export function pollReviewData() {
|
|||||||
<div class="text-slate-200 text-xs leading-relaxed font-medium pl-3 break-words">${w.message}</div>
|
<div class="text-slate-200 text-xs leading-relaxed font-medium pl-3 break-words">${w.message}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-full overflow-y-auto p-3 flex flex-col gap-2 mb-2.5';
|
chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2 mb-2.5';
|
||||||
chatHistory.innerHTML = chatHtml;
|
chatHistory.innerHTML = chatHtml;
|
||||||
chatHistory.scrollTop = chatHistory.scrollHeight;
|
chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||||
} else {
|
} else {
|
||||||
chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-full overflow-y-auto p-3 mb-2.5';
|
chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 mb-2.5';
|
||||||
chatHistory.innerHTML = '<div class="h-full w-full flex items-center justify-center text-xs text-[#5b637a] italic">No chat history. Send a message below.</div>';
|
chatHistory.innerHTML = '<div class="h-full w-full flex items-center justify-center text-xs text-[#5b637a] italic">No chat history. Send a message below.</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1465,7 +1371,7 @@ export function blockCandidateAction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { initMeteredIceServers, globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES };
|
export { initMeteredIceServers };
|
||||||
|
|
||||||
export function safePlayMediaStream(videoElem, stream, isSelf = false) {
|
export function safePlayMediaStream(videoElem, stream, isSelf = false) {
|
||||||
if (!videoElem || !stream) return;
|
if (!videoElem || !stream) return;
|
||||||
@ -1476,33 +1382,6 @@ export function safePlayMediaStream(videoElem, stream, isSelf = false) {
|
|||||||
if (videoElem.srcObject !== stream) {
|
if (videoElem.srcObject !== stream) {
|
||||||
videoElem.srcObject = stream;
|
videoElem.srcObject = stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach dynamic emerald speaking border VAD monitor
|
|
||||||
if (!isScreenShare && stream.getAudioTracks && stream.getAudioTracks().length > 0) {
|
|
||||||
let targetId = null;
|
|
||||||
let monitorKey = videoElem.id;
|
|
||||||
|
|
||||||
if (videoElem.id === 'interviewer-cand-video') {
|
|
||||||
targetId = 'cand-video-wrapper';
|
|
||||||
} else if (videoElem.id === 'interviewer-self-video') {
|
|
||||||
targetId = 'self-video-wrapper';
|
|
||||||
} else if (videoElem.id === 'webcam') {
|
|
||||||
targetId = 'cand-self-video-box';
|
|
||||||
} else if (videoElem.id === 'interviewer-video-default') {
|
|
||||||
targetId = 'interviewer-placeholder-box';
|
|
||||||
} else if (videoElem.id.startsWith('panelist-video-')) {
|
|
||||||
const pid = videoElem.id.replace('panelist-video-', '');
|
|
||||||
targetId = 'panelist-tile-' + pid;
|
|
||||||
} else if (videoElem.id.startsWith('cand-iv-video-')) {
|
|
||||||
const pid = videoElem.id.replace('cand-iv-video-', '');
|
|
||||||
targetId = 'cand-iv-tile-' + pid;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetId) {
|
|
||||||
globalAudioMonitor.attach(monitorKey, stream, targetId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const playPromise = videoElem.play();
|
const playPromise = videoElem.play();
|
||||||
if (playPromise !== undefined) {
|
if (playPromise !== undefined) {
|
||||||
playPromise.catch(err => {
|
playPromise.catch(err => {
|
||||||
@ -1540,7 +1419,7 @@ export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn
|
|||||||
if (!tile) {
|
if (!tile) {
|
||||||
tile = document.createElement('div');
|
tile = document.createElement('div');
|
||||||
tile.id = 'panelist-tile-' + peerId;
|
tile.id = 'panelist-tile-' + peerId;
|
||||||
tile.className = 'video-tile relative w-[220px] aspect-video shrink-0 bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all duration-200';
|
tile.className = 'video-tile relative aspect-[16/11] bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all';
|
||||||
|
|
||||||
tile.innerHTML = `
|
tile.innerHTML = `
|
||||||
<video id="panelist-video-${peerId}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200" style="transform: scaleX(-1);"></video>
|
<video id="panelist-video-${peerId}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200" style="transform: scaleX(-1);"></video>
|
||||||
@ -1595,7 +1474,6 @@ export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function removePanelistTile(peerId) {
|
export function removePanelistTile(peerId) {
|
||||||
globalAudioMonitor.detach('panelist-video-' + peerId);
|
|
||||||
const tile = document.getElementById('panelist-tile-' + peerId);
|
const tile = document.getElementById('panelist-tile-' + peerId);
|
||||||
if (tile) tile.remove();
|
if (tile) tile.remove();
|
||||||
panelistMeshTiles.delete(peerId);
|
panelistMeshTiles.delete(peerId);
|
||||||
@ -1635,18 +1513,6 @@ export async function initInterviewerPeer() {
|
|||||||
console.log('[WebRTC Screen] Attaching incoming screen stream to video element');
|
console.log('[WebRTC Screen] Attaching incoming screen stream to video element');
|
||||||
const screenVid = document.getElementById('interviewer-screen-video');
|
const screenVid = document.getElementById('interviewer-screen-video');
|
||||||
const placeholder = document.getElementById('screen-video-placeholder');
|
const placeholder = document.getElementById('screen-video-placeholder');
|
||||||
if (typeof window.updateScreenShareLayout === 'function') {
|
|
||||||
window.updateScreenShareLayout(true);
|
|
||||||
}
|
|
||||||
if (screenStream && screenStream.getVideoTracks) {
|
|
||||||
screenStream.getVideoTracks().forEach(track => {
|
|
||||||
track.onended = () => {
|
|
||||||
if (typeof window.updateScreenShareLayout === 'function') {
|
|
||||||
window.updateScreenShareLayout(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (screenVid && screenStream) {
|
if (screenVid && screenStream) {
|
||||||
screenVid.muted = true;
|
screenVid.muted = true;
|
||||||
if (screenVid.srcObject !== screenStream) {
|
if (screenVid.srcObject !== screenStream) {
|
||||||
@ -2489,7 +2355,6 @@ if (typeof window !== 'undefined') {
|
|||||||
window.closeReviewModal = closeReviewModal;
|
window.closeReviewModal = closeReviewModal;
|
||||||
window.sendSilentWarning = sendSilentWarning;
|
window.sendSilentWarning = sendSilentWarning;
|
||||||
window.setCurrentInterviewId = setCurrentInterviewId;
|
window.setCurrentInterviewId = setCurrentInterviewId;
|
||||||
window.closeInspectorPanel = closeInspectorPanel;
|
|
||||||
|
|
||||||
// Attach listeners
|
// Attach listeners
|
||||||
['click', 'keydown', 'touchstart'].forEach(evt => {
|
['click', 'keydown', 'touchstart'].forEach(evt => {
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
@props([
|
|
||||||
'variant' => 'info', // success, danger, warning, info
|
|
||||||
'icon' => null,
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$variants = [
|
|
||||||
'success' => 'bg-emerald-500/15 border-emerald-500/30 text-emerald-300',
|
|
||||||
'danger' => 'bg-red-500/15 border-red-500/30 text-red-300',
|
|
||||||
'warning' => 'bg-amber-500/15 border-amber-500/30 text-amber-300',
|
|
||||||
'info' => 'bg-sky-500/15 border-sky-500/30 text-sky-300',
|
|
||||||
];
|
|
||||||
|
|
||||||
$defaultIcons = [
|
|
||||||
'success' => 'fa-solid fa-circle-check',
|
|
||||||
'danger' => 'fa-solid fa-triangle-exclamation',
|
|
||||||
'warning' => 'fa-solid fa-circle-exclamation',
|
|
||||||
'info' => 'fa-solid fa-circle-info',
|
|
||||||
];
|
|
||||||
|
|
||||||
$selectedIcon = $icon ?? ($defaultIcons[$variant] ?? $defaultIcons['info']);
|
|
||||||
$variantClass = $variants[$variant] ?? $variants['info'];
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div {{ $attributes->merge(['class' => "flex items-start gap-2.5 px-4 py-3 border rounded-xl text-xs font-medium {$variantClass}"]) }}>
|
|
||||||
@if($selectedIcon)
|
|
||||||
<i class="{{ $selectedIcon }} text-sm mt-0.5 shrink-0"></i>
|
|
||||||
@endif
|
|
||||||
<div class="flex-1 leading-relaxed">
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -2,7 +2,7 @@
|
|||||||
'logs' => [],
|
'logs' => [],
|
||||||
])
|
])
|
||||||
|
|
||||||
<div {{ $attributes->twMerge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] overflow-y-auto']) }}>
|
<div {{ $attributes->merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] max-h-[260px] overflow-y-auto']) }}>
|
||||||
@if(is_array($logs) && count($logs) > 0)
|
@if(is_array($logs) && count($logs) > 0)
|
||||||
@foreach($logs as $log)
|
@foreach($logs as $log)
|
||||||
<x-audit-log-item
|
<x-audit-log-item
|
||||||
|
|||||||
@ -31,7 +31,7 @@
|
|||||||
]);
|
]);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<button type="{{ $type }}" {{ $attributes->twMerge(['class' => $classes]) }}>
|
<button type="{{ $type }}" {{ $attributes->merge(['class' => $classes]) }}>
|
||||||
@if($icon)
|
@if($icon)
|
||||||
<i class="{{ $icon }}"></i>
|
<i class="{{ $icon }}"></i>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -4,22 +4,18 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<div {{ $attributes->merge(['class' => 'bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden']) }}>
|
<div {{ $attributes->merge(['class' => 'bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden']) }}>
|
||||||
@if($title || $icon || isset($header) || isset($headerExtra))
|
@if($title || $icon || isset($headerExtra))
|
||||||
<div class="flex items-center justify-between px-5 py-3 border-b border-[#1b2233] gap-4 flex-wrap">
|
<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 min-w-0">
|
<div class="flex items-center gap-2.5 font-semibold text-[14.5px] tracking-tight text-white">
|
||||||
@if(isset($header))
|
@if($icon)
|
||||||
{{ $header }}
|
<i class="{{ $icon }} text-[#4b82f7] text-[13.5px]"></i>
|
||||||
@else
|
@endif
|
||||||
@if($icon)
|
@if($title)
|
||||||
<i class="{{ $icon }} text-[#4b82f7] text-[13.5px]"></i>
|
<span>{{ $title }}</span>
|
||||||
@endif
|
|
||||||
@if($title)
|
|
||||||
<span>{{ $title }}</span>
|
|
||||||
@endif
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@if(isset($headerExtra))
|
@if(isset($headerExtra))
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2">
|
||||||
{{ $headerExtra }}
|
{{ $headerExtra }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
@php
|
@php
|
||||||
$resolvedColor = $isCandidate ? '#38bdf8' : ($color ?? '#6366f1');
|
$resolvedColor = $isCandidate ? '#38bdf8' : ($color ?? '#6366f1');
|
||||||
$timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('H:i:s') : now()->format('H:i:s');
|
$timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('H:i:s') : now()->format('H:i:s');
|
||||||
|
|
||||||
// Hex to rgba helper for background opacity
|
// Hex to rgba helper for background opacity
|
||||||
$hex = ltrim($resolvedColor, '#');
|
$hex = ltrim($resolvedColor, '#');
|
||||||
if (strlen($hex) == 3) {
|
if (strlen($hex) == 3) {
|
||||||
@ -25,7 +25,7 @@
|
|||||||
$borderRgba = "rgba({$r}, {$g}, {$b}, 0.35)";
|
$borderRgba = "rgba({$r}, {$g}, {$b}, 0.35)";
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div {{ $attributes->merge(['class' => 'chat-message mb-2 p-2.5 rounded-lg transition-all']) }} style="background: {{ $bgRgba }}; border-color: {{ $resolvedColor }};">
|
<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 justify-between items-center mb-1 gap-2">
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<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>
|
<span class="w-1.5 h-1.5 rounded-full shrink-0" style="background: {{ $resolvedColor }};"></span>
|
||||||
|
|||||||
@ -15,22 +15,22 @@
|
|||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div {{ $attributes->twMerge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] overflow-y-auto p-3 flex flex-col gap-2']) }}>
|
<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)
|
@if(count($warnings) > 0)
|
||||||
@foreach($warnings as $w)
|
@foreach($warnings as $w)
|
||||||
@php
|
@php
|
||||||
$isCand = is_null($w->sent_by);
|
$isCand = is_null($w->sent_by);
|
||||||
$senderName = $isCand
|
$senderName = $isCand
|
||||||
? ($candidateName ?: 'Candidate')
|
? ($candidateName ?: 'Candidate')
|
||||||
: ($w->sender ? $w->sender->name : 'Interviewer');
|
: ($w->sender ? $w->sender->name : 'Interviewer');
|
||||||
|
|
||||||
$color = $isCand
|
$color = $isCand
|
||||||
? '#38bdf8'
|
? '#38bdf8'
|
||||||
: $interviewerColors[($w->sent_by ?? 1) % count($interviewerColors)];
|
: $interviewerColors[($w->sent_by ?? 1) % count($interviewerColors)];
|
||||||
@endphp
|
@endphp
|
||||||
<x-chat-message
|
<x-chat-message
|
||||||
:senderName="$senderName"
|
:senderName="$senderName"
|
||||||
:isCandidate="
|
:isCandidate="$isCand"
|
||||||
:color="$color"
|
:color="$color"
|
||||||
:message="$w->message"
|
:message="$w->message"
|
||||||
:timestamp="$w->created_at"
|
:timestamp="$w->created_at"
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
@props([
|
|
||||||
'id',
|
|
||||||
'title' => null,
|
|
||||||
'description' => null,
|
|
||||||
'width' => 'lg',
|
|
||||||
'side' => 'right',
|
|
||||||
'onClose' => null,
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$widths = [
|
|
||||||
'sm' => 'max-w-sm',
|
|
||||||
'md' => 'max-w-md',
|
|
||||||
'lg' => 'max-w-lg',
|
|
||||||
'xl' => 'max-w-xl',
|
|
||||||
'2xl' => 'max-w-2xl',
|
|
||||||
'3xl' => 'max-w-3xl',
|
|
||||||
'full' => 'max-w-full',
|
|
||||||
];
|
|
||||||
|
|
||||||
$widthClass = $widths[$width] ?? $widths['lg'];
|
|
||||||
$isLeft = $side === 'left';
|
|
||||||
$justifyClass = $isLeft ? 'justify-start' : 'justify-end';
|
|
||||||
$translateClosed = $isLeft ? '-translate-x-full' : 'translate-x-full';
|
|
||||||
$borderClass = $isLeft ? 'border-r' : 'border-l';
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div id="{{ $id }}" {{ $attributes->merge(['class' => "admin-drawer fixed inset-0 w-screen h-screen z-[1000] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto flex $justifyClass"]) }}>
|
|
||||||
<!-- Backdrop Overlay Target -->
|
|
||||||
<div class="fixed inset-0" onclick="document.getElementById('{{ $id }}').classList.remove('active'); {{ $onClose ? $onClose . '();' : '' }}"></div>
|
|
||||||
|
|
||||||
<!-- Slide-Over Drawer Content Container -->
|
|
||||||
<div class="drawer-panel relative z-10 w-full {{ $widthClass }} h-full bg-slate-900 {{ $borderClass }} border-slate-700/60 shadow-2xl flex flex-col text-white transform {{ $translateClosed }} transition-transform duration-300 ease-out [.active_&]:translate-x-0">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-start justify-between p-6 border-b border-white/10 shrink-0">
|
|
||||||
<div class="pr-4">
|
|
||||||
@if($title)
|
|
||||||
<h3 class="font-outfit text-xl font-bold text-white m-0 leading-snug">{{ $title }}</h3>
|
|
||||||
@endif
|
|
||||||
@if($description)
|
|
||||||
<p class="text-xs text-slate-400 mt-1 mb-0 leading-relaxed">{{ $description }}</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<button type="button" onclick="document.getElementById('{{ $id }}').classList.remove('active'); {{ $onClose ? $onClose . '();' : '' }}" class="w-8 h-8 rounded-lg bg-slate-800/80 border border-slate-700/60 text-slate-400 hover:text-white hover:bg-slate-700/60 transition-colors flex items-center justify-center cursor-pointer shrink-0 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scrollable Body Content -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-6 space-y-4">
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Optional Fixed Footer -->
|
|
||||||
@if(isset($footer))
|
|
||||||
<div class="p-6 border-t border-white/10 bg-slate-950/40 shrink-0">
|
|
||||||
{{ $footer }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
@props([
|
|
||||||
'align' => 'left', // left, right
|
|
||||||
'width' => 'w-80 sm:w-96',
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$alignmentClasses = $align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left';
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<details class="group relative inline-block text-left select-none">
|
|
||||||
<summary class="list-none cursor-pointer p-1.5 rounded-lg bg-slate-800/80 border border-slate-700/60 text-slate-300 hover:text-white hover:bg-slate-700/60 transition-colors flex items-center justify-center w-8 h-8 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
|
||||||
{{ $trigger ?? '' }}
|
|
||||||
@if(!isset($trigger))
|
|
||||||
<i class="fa-solid fa-bars text-sm"></i>
|
|
||||||
@endif
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<!-- Backdrop for click-outside dismissal -->
|
|
||||||
<div class="fixed inset-0 z-40" onclick="this.closest('details').removeAttribute('open')"></div>
|
|
||||||
|
|
||||||
<!-- Dropdown Content Panel -->
|
|
||||||
<div class="absolute {{ $alignmentClasses }} mt-2 {{ $width }} bg-slate-900 border border-slate-700/80 rounded-xl shadow-2xl p-3.5 z-50 backdrop-blur-xl text-white divide-y divide-white/10 font-normal">
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
@props([
|
|
||||||
'type' => 'text',
|
|
||||||
'name' => null,
|
|
||||||
'value' => null,
|
|
||||||
'placeholder' => null,
|
|
||||||
'required' => false,
|
|
||||||
'disabled' => false,
|
|
||||||
'size' => 'md', // sm, md, lg
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$sizeClasses = [
|
|
||||||
'sm' => 'px-2.5 py-1.5 text-xs',
|
|
||||||
'md' => 'px-3 py-2 text-xs',
|
|
||||||
'lg' => 'px-3.5 py-2.5 text-sm',
|
|
||||||
];
|
|
||||||
|
|
||||||
$classes = 'w-full bg-white/5 border border-slate-700/60 rounded-lg text-white outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40 placeholder-slate-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed ' . ($sizeClasses[$size] ?? $sizeClasses['md']);
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="{{ $type }}"
|
|
||||||
@if($name) name="{{ $name }}" @endif
|
|
||||||
@if($value !== null) value="{{ $value }}" @endif
|
|
||||||
@if($placeholder) placeholder="{{ $placeholder }}" @endif
|
|
||||||
@if($required) required @endif
|
|
||||||
@if($disabled) disabled @endif
|
|
||||||
{{ $attributes->merge(['class' => $classes]) }}
|
|
||||||
/>
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
@php
|
|
||||||
$screenshotActive = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1';
|
|
||||||
$onboardingActive = \App\Models\Setting::get('onboarding_enabled', '1') === '1';
|
|
||||||
$isAdmin = Auth::check() && Auth::user()->isAdmin();
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="mb-6 grid grid-cols-1 md:grid-cols-2 gap-3.5">
|
|
||||||
<!-- 1. Candidate System Screenshot Capture Option Card -->
|
|
||||||
<div class="bg-[#0e1422] border border-[#1b2233] rounded-xl p-4 flex flex-col justify-between hover:border-slate-700/60 transition-colors">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<div class="w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/25 flex items-center justify-center text-emerald-400 shrink-0 mt-0.5">
|
|
||||||
<i class="fa-solid fa-camera text-sm"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap mb-1">
|
|
||||||
<h4 class="text-xs font-bold text-white m-0">Screenshot Capture</h4>
|
|
||||||
@if($screenshotActive)
|
|
||||||
<x-pill variant="success" size="sm" icon="fa-solid fa-circle-check">Active</x-pill>
|
|
||||||
@else
|
|
||||||
<x-pill variant="danger" size="sm" icon="fa-solid fa-circle-xmark">Stopped</x-pill>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<p class="text-[11.5px] text-slate-400 m-0 leading-relaxed">
|
|
||||||
Captures candidate's desktop automatically on call start & tab switches.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($isAdmin)
|
|
||||||
<div class="flex items-center justify-end pt-2 border-t border-white/5">
|
|
||||||
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST" class="flex items-center gap-2">
|
|
||||||
@csrf
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer" title="{{ $screenshotActive ? 'Turn off screenshots' : 'Turn on screenshots' }}">
|
|
||||||
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ $screenshotActive ? 'checked' : '' }} class="sr-only peer">
|
|
||||||
<div class="w-8 h-4.5 bg-slate-800 border border-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-indigo-600 peer-checked:border-indigo-500"></div>
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2. Candidate Onboarding Option Card -->
|
|
||||||
<div class="bg-[#0e1422] border border-[#1b2233] rounded-xl p-3.5 flex flex-col justify-between gap-3 hover:border-slate-700/60 transition-colors">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<div class="w-8 h-8 rounded-lg bg-indigo-500/10 border border-indigo-500/25 flex items-center justify-center text-indigo-400 shrink-0 mt-0.5">
|
|
||||||
<i class="fa-solid fa-user-check text-sm"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap mb-1">
|
|
||||||
<h4 class="text-xs font-bold text-white m-0">Candidate Onboarding</h4>
|
|
||||||
@if($onboardingActive)
|
|
||||||
<x-pill variant="success" size="sm" icon="fa-solid fa-circle-check">Active</x-pill>
|
|
||||||
@else
|
|
||||||
<x-pill variant="danger" size="sm" icon="fa-solid fa-circle-xmark">Disabled</x-pill>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<p class="text-[11.5px] text-slate-400 m-0 leading-relaxed">
|
|
||||||
Automatic provisioning checklist and one-click revocation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($isAdmin)
|
|
||||||
<div class="flex items-center justify-end pt-2 border-t border-white/5">
|
|
||||||
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST" class="flex items-center gap-2">
|
|
||||||
@csrf
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer" title="{{ $onboardingActive ? 'Turn off onboarding' : 'Turn on onboarding' }}">
|
|
||||||
<input type="checkbox" name="onboarding_enabled" value="1" onchange="this.form.submit()" {{ $onboardingActive ? 'checked' : '' }} class="sr-only peer">
|
|
||||||
<div class="w-8 h-4.5 bg-slate-800 border border-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-indigo-600 peer-checked:border-indigo-500"></div>
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
<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" class="hidden" 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" class="hidden" 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" class="hidden">
|
|
||||||
Record Call (Silent)
|
|
||||||
</x-button>
|
|
||||||
<x-button id="btn-stop-recording" class="hidden" onclick="stopCallRecording()" variant="danger" icon="fa-solid fa-square" class="hidden 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" class="hidden">
|
|
||||||
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" class="hidden" title="End Call for All"></x-button>
|
|
||||||
</div>
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-wrap sm:flex-nowrap">
|
|
||||||
<x-hamburger-menu align="left">
|
|
||||||
<div class="pb-3">
|
|
||||||
<div class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-circle-info text-indigo-400"></i> Session Details
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div class="bg-slate-950/60 border border-slate-800 rounded-lg p-2">
|
|
||||||
<div class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Submission ID</div>
|
|
||||||
<div id="modal-cand-uid" class="text-[11px] font-mono text-slate-300 break-all">{{ $interview->submission_unique_id }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-950/60 border border-slate-800 rounded-lg p-2">
|
|
||||||
<div class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Temp Pass</div>
|
|
||||||
<div class="text-[11px] font-mono text-emerald-400 font-bold">{{ $interview->temp_password }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-950/60 border border-slate-800 rounded-lg p-2">
|
|
||||||
<div class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Language</div>
|
|
||||||
<div class="text-[11px] font-mono text-slate-300 uppercase font-bold">{{ $interview->language }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-950/60 border border-slate-800 rounded-lg p-2">
|
|
||||||
<div class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Timeline</div>
|
|
||||||
<div class="text-[11px] font-mono text-slate-300">{{ $interview->formatted_timeline }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-3 flex flex-col gap-2.5">
|
|
||||||
<div class="flex items-center justify-between px-1 text-xs text-slate-300 cursor-pointer select-none" onclick="toggleSosAutoTrigger()">
|
|
||||||
<span class="flex items-center gap-2 font-medium">
|
|
||||||
<i class="fa-solid fa-shield-halved text-indigo-400"></i>
|
|
||||||
<span>SOS auto-trigger alert</span>
|
|
||||||
</span>
|
|
||||||
<span class="toggle relative inline-flex h-4 w-8 shrink-0 cursor-pointer rounded-full border border-slate-700 bg-slate-900 transition-colors duration-200 ease-in-out [&.on]:bg-indigo-500/20 [&.on]:border-indigo-500/50 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-2.5 after:w-2.5 after:rounded-full after:bg-slate-500 after:transition-all [&.on]:after:left-4.25 [&.on]:after:bg-indigo-400" id="sosToggle"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-1.5 pt-1">
|
|
||||||
<x-button onclick="regenerateCandidatePassword()" variant="secondary" size="sm" icon="fa-solid fa-key" class="w-full justify-center text-[11px]">Password</x-button>
|
|
||||||
<x-button onclick="openReportPage()" variant="secondary" size="sm" icon="fa-solid fa-file-pdf" class="w-full justify-center text-[11px]">PDF Report</x-button>
|
|
||||||
<x-button onclick="blockCandidateAction()" variant="danger" size="sm" icon="fa-solid fa-ban" class="w-full justify-center bg-red-500/20 text-red-300 border-red-500/40 text-[11px]">Block</x-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-hamburger-menu>
|
|
||||||
|
|
||||||
<!-- Candidate Avatar Circle -->
|
|
||||||
<div class="w-8 h-8 rounded-full bg-linear-to-br from-indigo-500 to-sky-600 flex items-center justify-center text-white font-bold text-xs shrink-0 shadow-md shadow-indigo-500/20">
|
|
||||||
{{ $interview->candidate_initials }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Candidate Name & Email -->
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 id="modal-cand-name" class="text-sm font-semibold text-white m-0 leading-tight truncate">
|
|
||||||
{{ $interview->candidate_name }}
|
|
||||||
</h2>
|
|
||||||
<div class="text-[11px] text-slate-400 font-mono truncate">
|
|
||||||
{{ $interview->candidate_email }} · {{ $interview->candidate_phone }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Pills (Connected Status & Timeline Status) -->
|
|
||||||
<div class="flex items-center gap-1.5 shrink-0 ml-1">
|
|
||||||
<span id="candidate-joined-pill" class="pill gray inline-flex items-center gap-1.5 font-semibold text-[10.5px] px-2 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60">
|
|
||||||
<i class="fa-solid fa-circle text-[7px] text-slate-500"></i> Not Joined
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@if($interview->isExpired())
|
|
||||||
<x-pill id="timeline-status-pill" variant="danger" size="sm" icon="fa-solid fa-circle">Expired</x-pill>
|
|
||||||
@elseif($interview->status === 'completed')
|
|
||||||
<x-pill id="timeline-status-pill" variant="success" size="sm" icon="fa-solid fa-circle">Completed</x-pill>
|
|
||||||
@elseif($interview->call_status === 'active')
|
|
||||||
<x-pill id="timeline-status-pill" variant="info" size="sm" class="animate-pulse" icon="fa-solid fa-circle">Call in progress</x-pill>
|
|
||||||
@elseif($interview->isActiveTimeline())
|
|
||||||
<x-pill id="timeline-status-pill" variant="info" size="sm" icon="fa-solid fa-bolt">Timeline live</x-pill>
|
|
||||||
@else
|
|
||||||
<x-pill id="timeline-status-pill" variant="warning" size="sm" icon="fa-solid fa-clock">Scheduled</x-pill>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<div id="drawing-modal" class="fixed top-[70px] right-[360px] w-[550px] h-[420px] bg-slate-900 border-2 border-cyan-500 rounded-2xl shadow-2xl hidden flex-col z-50 overflow-hidden">
|
|
||||||
<div class="bg-slate-800 px-4 py-2.5 flex justify-between items-center border-b border-white/10">
|
|
||||||
<div class="font-bold text-xs text-white flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-pen-ruler text-cyan-400"></i>
|
|
||||||
<span>Candidate Live Drawing Board</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input type="color" id="draw-color" value="#38bdf8" class="w-6 h-6 border-none rounded-full cursor-pointer bg-transparent">
|
|
||||||
<button type="button" onclick="clearCanvasDraw()" class="px-2.5 py-1 text-[11px] font-semibold bg-white/10 hover:bg-white/20 text-white rounded-md border border-white/10 cursor-pointer transition-colors flex items-center gap-1">
|
|
||||||
<i class="fa-solid fa-eraser text-[10px]"></i>
|
|
||||||
<span>Clear</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="toggleDrawingModal()" class="w-6 h-6 rounded-md bg-slate-700/60 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 bg-[#050811] relative">
|
|
||||||
<canvas id="drawing-canvas" width="550" height="340" class="cursor-crosshair block w-full h-full"></canvas>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-1.5 bg-slate-900 text-[10.5px] text-cyan-400 text-right border-t border-white/5 font-medium flex items-center justify-end gap-1.5">
|
|
||||||
<i class="fa-solid fa-diagram-project text-[10px]"></i>
|
|
||||||
<span>Draw architecture / logic diagrams (Auto-synced to Interviewer)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
<header class="h-[60px] bg-slate-900 border-b border-white/10 flex items-center justify-between px-5 z-20 shrink-0">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="font-outfit font-bold text-base text-white flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-laptop-code text-indigo-400"></i>
|
|
||||||
<span>Candidate Assessment Room</span>
|
|
||||||
</div>
|
|
||||||
<code class="bg-white/10 px-2.5 py-1 rounded-md text-sky-400 text-xs font-mono">
|
|
||||||
ID: {{ $interview->submission_unique_id }}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<x-button type="button" onclick="toggleNotepadModal()" variant="secondary" size="sm" icon="fa-solid fa-note-sticky" class="bg-indigo-500/20 text-indigo-300 border-indigo-500/40 hover:bg-indigo-500/30">
|
|
||||||
Notepad
|
|
||||||
</x-button>
|
|
||||||
|
|
||||||
<x-button type="button" onclick="toggleDrawingModal()" variant="secondary" size="sm" icon="fa-solid fa-pen-ruler" class="bg-cyan-500/20 text-cyan-300 border-cyan-500/40 hover:bg-cyan-500/30">
|
|
||||||
Drawing Board
|
|
||||||
</x-button>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<label class="text-xs text-slate-400 font-medium">Language:</label>
|
|
||||||
<select id="language-select" class="px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 border border-white/10 text-white outline-none focus:border-indigo-500 transition-all cursor-pointer">
|
|
||||||
<option value="python" {{ strtolower($interview->language) === 'python' ? 'selected' : '' }}>Python 3</option>
|
|
||||||
<option value="cpp" {{ strtolower($interview->language) === 'cpp' ? 'selected' : '' }}>C++ (GCC)</option>
|
|
||||||
<option value="c" {{ strtolower($interview->language) === 'c' ? 'selected' : '' }}>C (GCC)</option>
|
|
||||||
<option value="java" {{ strtolower($interview->language) === 'java' ? 'selected' : '' }}>Java 15</option>
|
|
||||||
<option value="php" {{ strtolower($interview->language) === 'php' ? 'selected' : '' }}>PHP 8.2 / Laravel</option>
|
|
||||||
<option value="javascript" {{ strtolower($interview->language) === 'javascript' ? 'selected' : '' }}>JavaScript (Node.js)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-button type="button" onclick="runCode()" variant="primary" size="sm" icon="fa-solid fa-play" class="shadow-md shadow-indigo-600/30">
|
|
||||||
Run Code
|
|
||||||
</x-button>
|
|
||||||
|
|
||||||
<x-button type="button" onclick="submitSolution()" variant="success" size="sm" icon="fa-solid fa-check" class="shadow-md shadow-emerald-600/30">
|
|
||||||
Submit Solution
|
|
||||||
</x-button>
|
|
||||||
|
|
||||||
<x-button type="button" onclick="candidateLogoutAction()" variant="danger" size="sm" icon="fa-solid fa-right-from-bracket" class="bg-red-500/20 border-red-500/40 text-red-300 hover:bg-red-500/30">
|
|
||||||
Logout
|
|
||||||
</x-button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
<div id="candidate-incoming-modal" class="fixed inset-0 w-screen h-screen bg-[#050811]/85 backdrop-blur-md flex items-center justify-center z-[10000] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto">
|
|
||||||
<div class="max-w-[440px] w-[90%] bg-gradient-to-br from-slate-900 via-slate-900 to-indigo-950 border-2 border-emerald-500 rounded-3xl p-8 text-center text-white shadow-2xl shadow-emerald-500/30">
|
|
||||||
<div class="relative w-20 h-20 mx-auto mb-5 flex items-center justify-center">
|
|
||||||
<div class="absolute -inset-3 rounded-full border-2 border-emerald-500/60 animate-ping"></div>
|
|
||||||
<div class="absolute -inset-6 rounded-full border-2 border-indigo-500/40 animate-pulse"></div>
|
|
||||||
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-sky-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg shadow-emerald-500/50 z-10">
|
|
||||||
<i class="fa-solid fa-phone"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-[11px] uppercase tracking-widest text-emerald-400 font-extrabold mb-1.5 flex items-center justify-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-bolt"></i>
|
|
||||||
<span>INCOMING INTERVIEW CALL</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="font-outfit text-2xl font-extrabold text-white mb-1">
|
|
||||||
Interviewer Panel Calling...
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="text-xs text-indigo-300 mb-6">
|
|
||||||
The interviewer panel has initiated a live 2-way call.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 justify-center">
|
|
||||||
<button type="button" onclick="declineCandidateCall()" class="flex-1 py-3 px-4 bg-red-500/20 border border-red-500 text-red-300 font-bold text-xs rounded-xl hover:bg-red-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-phone-slash"></i>
|
|
||||||
<span>Decline / Busy</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="acceptCandidateCall()" class="flex-[1.4] py-3 px-4 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white font-extrabold text-xs rounded-xl border-none shadow-lg shadow-emerald-500/50 hover:from-emerald-600 hover:to-emerald-700 transition-all cursor-pointer animate-pulse flex items-center justify-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-phone"></i>
|
|
||||||
<span>Accept Call</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
@props([
|
|
||||||
'notes' => '',
|
|
||||||
])
|
|
||||||
|
|
||||||
<div id="notepad-modal" class="fixed top-[70px] right-[360px] w-[420px] h-[350px] bg-slate-900 border-2 border-indigo-500 rounded-2xl shadow-2xl hidden flex-col z-50 overflow-hidden">
|
|
||||||
<div class="bg-slate-800 px-4 py-2.5 flex justify-between items-center border-b border-white/10">
|
|
||||||
<div class="font-bold text-xs text-white flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-note-sticky text-indigo-400"></i>
|
|
||||||
<span>Candidate Notepad (Synced)</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" onclick="toggleNotepadModal()" class="w-6 h-6 rounded-md bg-slate-700/60 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea id="notepad-textarea" oninput="saveNotepadContent()" placeholder="Type rough notes, pseudo-code, or thoughts here..." class="flex-1 w-full p-3.5 bg-slate-950 text-slate-200 border-none outline-none font-mono text-xs resize-none placeholder-slate-600 leading-relaxed">{{ $notes }}</textarea>
|
|
||||||
<div class="px-3 py-1.5 bg-slate-900 text-[10.5px] text-emerald-400 text-right border-t border-white/5 font-medium flex items-center justify-end gap-1.5">
|
|
||||||
<i class="fa-solid fa-circle-check text-[10px]"></i>
|
|
||||||
<span>Auto-saved to interviewer portal</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
<aside class="w-[340px] bg-slate-900/90 border-l border-white/10 p-4 flex flex-col gap-4 overflow-y-auto shrink-0 select-none">
|
|
||||||
<!-- Candidate Camera Feed -->
|
|
||||||
<div id="cand-self-video-box" class="w-full aspect-[16/10] bg-black rounded-xl border-2 border-white/15 overflow-hidden relative shrink-0 transition-all duration-200">
|
|
||||||
<video id="webcam" autoplay muted playsinline class="w-full h-full object-cover -scale-x-100"></video>
|
|
||||||
<div id="webcam-avatar-placeholder" class="absolute inset-0 hidden flex-col items-center justify-center bg-slate-900 text-white z-10">
|
|
||||||
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-indigo-500 to-sky-600 flex items-center justify-center text-xl font-bold font-outfit shadow-lg shadow-indigo-500/40">
|
|
||||||
{{ $interview->candidate_initials }}
|
|
||||||
</div>
|
|
||||||
<div class="text-[11px] text-slate-400 mt-1.5 font-medium">Camera Disabled</div>
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-2 left-2 bg-black/70 px-2 py-0.5 rounded text-[10.5px] text-emerald-400 font-semibold z-10 flex items-center gap-1.5 backdrop-blur-xs">
|
|
||||||
<span>Candidate (You)</span>
|
|
||||||
<span class="opacity-40">|</span>
|
|
||||||
<i id="cand-self-mic-icon" class="fa-solid fa-microphone text-emerald-400 text-[10px]" title="Mic On"></i>
|
|
||||||
<i id="cand-self-cam-icon" class="fa-solid fa-video text-emerald-400 text-[10px]" title="Camera On"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Remote Interviewer Panelist Video Grid (Multi-Party WebRTC Mesh) -->
|
|
||||||
<div id="interviewer-mesh-grid" class="flex flex-col gap-2.5 w-full">
|
|
||||||
<div id="interviewer-placeholder-box" class="w-full aspect-[16/10] bg-black rounded-xl border-2 border-indigo-500/40 overflow-hidden relative shrink-0 transition-all duration-200">
|
|
||||||
<video id="interviewer-video-default" autoplay playsinline class="w-full h-full object-cover bg-slate-950 hidden -scale-x-100"></video>
|
|
||||||
<div id="interviewer-video-placeholder" class="absolute inset-0 flex flex-col items-center justify-center bg-slate-900/95 text-slate-400 text-xs text-center p-2.5 z-10">
|
|
||||||
<div id="interviewer-avatar-circle" class="w-14 h-14 rounded-full bg-gradient-to-br from-sky-400 to-indigo-500 flex items-center justify-center text-xl font-bold font-outfit text-white mb-1.5 shadow-lg shadow-sky-500/30">
|
|
||||||
IV
|
|
||||||
</div>
|
|
||||||
<div id="call-status-sub" class="text-xs text-slate-200 font-semibold">Interviewer Panel</div>
|
|
||||||
<div class="text-[10.5px] text-sky-400 mt-0.5 font-medium">Waiting for interviewer panelist to join call...</div>
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-2 left-2 bg-indigo-600/90 px-2 py-0.5 rounded text-[10.5px] text-white font-semibold z-10 backdrop-blur-xs flex items-center gap-1">
|
|
||||||
<i class="fa-solid fa-microphone-lines text-[9px]"></i>
|
|
||||||
<span>Interviewer / Panelist</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Proctor Canvas for Hidden AI Analysis -->
|
|
||||||
<canvas id="proctor-canvas" class="hidden" width="320" height="240"></canvas>
|
|
||||||
|
|
||||||
<!-- Candidate Profile Info -->
|
|
||||||
<div class="bg-white/[0.03] border border-white/10 rounded-xl p-3.5 text-xs leading-relaxed">
|
|
||||||
<div class="font-bold text-white mb-1.5 flex items-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-user-tie text-indigo-400"></i>
|
|
||||||
<span>Candidate Profile</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-slate-400">Name: <strong class="text-white">{{ $interview->candidate_name }}</strong></div>
|
|
||||||
<div class="text-slate-400">Email: <span class="text-white font-mono text-[11.5px]">{{ $interview->candidate_email }}</span></div>
|
|
||||||
<div class="text-slate-400 mt-1.5">Access Expires: <span class="text-red-400 font-semibold">{{ $interview->expires_at->format('H:i:s T') }}</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Real-Time Call Controls (Mic, Camera, Screen Sharing) -->
|
|
||||||
<div class="bg-indigo-500/10 border border-indigo-500/30 rounded-xl p-3.5">
|
|
||||||
<div class="text-xs font-bold text-white mb-2.5 flex items-center justify-between">
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-phone text-indigo-400"></i>
|
|
||||||
<span>Real-Time Interview Call</span>
|
|
||||||
</span>
|
|
||||||
<span id="call-status-badge" class="text-[10px] font-bold bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded-full">READY</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" id="candidate-join-active-call-btn" onclick="acceptCandidateCall()" class="hidden w-full p-2.5 mb-2.5 text-xs font-bold bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-lg border-none shadow-lg shadow-emerald-500/30 cursor-pointer animate-pulse">
|
|
||||||
<i class="fa-solid fa-phone-volume mr-1.5"></i> Call in Progress • Join Call Now
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex gap-2 mb-2.5">
|
|
||||||
<button type="button" id="toggle-mic-btn" onclick="toggleMic()" class="flex-1 py-2 px-2 text-xs font-semibold rounded-lg bg-emerald-500/20 border border-emerald-500 text-emerald-300 hover:bg-emerald-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-microphone"></i>
|
|
||||||
<span>Mic On</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" id="toggle-cam-btn" onclick="toggleCam()" class="flex-1 py-2 px-2 text-xs font-semibold rounded-lg bg-emerald-500/20 border border-emerald-500 text-emerald-300 hover:bg-emerald-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-video"></i>
|
|
||||||
<span>Cam On</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" id="leave-call-btn" onclick="candidateLeaveCall()" class="py-2 px-2.5 text-xs font-semibold rounded-lg bg-red-500/20 border border-red-500 text-red-300 hover:bg-red-500/30 transition-all cursor-pointer flex items-center justify-center gap-1" title="Leave 2-Way Call">
|
|
||||||
<i class="fa-solid fa-phone-slash"></i>
|
|
||||||
<span>Leave</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" id="share-screen-btn" onclick="startScreenShare()" class="w-full py-2.5 px-3 text-xs font-bold bg-gradient-to-r from-indigo-500 to-sky-600 hover:from-indigo-600 hover:to-sky-700 text-white rounded-lg border-none shadow-md shadow-indigo-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-desktop"></i>
|
|
||||||
<span>Share Screen with Interviewer</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2-Way Real-Time Chat Container with Interviewer/Panelist -->
|
|
||||||
<div class="bg-slate-900/80 border border-white/10 rounded-xl p-3">
|
|
||||||
<div class="text-xs font-bold text-white mb-2 flex items-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-comments text-indigo-400"></i>
|
|
||||||
<span>Live Chat with Interviewer</span>
|
|
||||||
</div>
|
|
||||||
<div id="candidate-chat-history" class="bg-[#050811] border border-white/10 rounded-lg p-2 h-[130px] overflow-y-auto text-xs mb-2 flex flex-col gap-1">
|
|
||||||
<div class="text-slate-500 italic text-center pt-10 text-[11px]">No messages yet. Send a message below.</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1.5">
|
|
||||||
<input type="text" id="candidate-chat-input" placeholder="Type message to panelist..." onkeydown="if(event.key==='Enter') sendCandidateMessage()" class="flex-1 text-xs bg-slate-950 text-white border border-white/10 rounded-lg px-2.5 py-1.5 outline-none focus:border-indigo-500 placeholder-slate-500">
|
|
||||||
<x-button type="button" onclick="sendCandidateMessage()" variant="primary" size="sm" icon="fa-solid fa-paper-plane">
|
|
||||||
Send
|
|
||||||
</x-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
<div class="h-[220px] bg-[#050811] border-t border-white/10 flex flex-col shrink-0">
|
|
||||||
<div class="h-9 bg-[#090e1a] border-b border-white/10 flex items-center justify-between px-4 text-xs font-semibold text-slate-400">
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<i class="fa-solid fa-terminal text-slate-500"></i>
|
|
||||||
<span>EXECUTION OUTPUT TERMINAL (STDOUT / STDERR)</span>
|
|
||||||
</span>
|
|
||||||
<span id="run-status" class="text-slate-400">Ready</span>
|
|
||||||
</div>
|
|
||||||
<div id="terminal-output" class="flex-1 p-3.5 font-['Fira_Code',monospace] text-xs text-emerald-400 overflow-y-auto whitespace-pre-wrap leading-relaxed">// Press 'Run Code' to execute candidate solution live...</div>
|
|
||||||
</div>
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
<x-modal id="incoming-call-modal" maxWidth="md">
|
|
||||||
<div class="p-4 sm:p-6 text-center">
|
|
||||||
<div class="relative w-20 h-20 mx-auto mb-5 flex items-center justify-center">
|
|
||||||
<div class="absolute -inset-3 rounded-full border-2 border-emerald-500/60 animate-ping"></div>
|
|
||||||
<div class="absolute -inset-6 rounded-full border-2 border-indigo-500/40 animate-pulse"></div>
|
|
||||||
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-sky-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg shadow-emerald-500/50 z-10">
|
|
||||||
<i class="fa-solid fa-phone"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-[11px] uppercase tracking-widest text-emerald-400 font-bold mb-1.5">
|
|
||||||
⚡ Incoming Assessment Call
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 id="incoming-cand-name" class="font-outfit text-2xl font-extrabold text-white mb-1">
|
|
||||||
Candidate Name
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div id="incoming-starter-info" class="text-xs text-sky-400 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>
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
<details id="inspector-details" class="relative group select-none">
|
|
||||||
<!-- Summary Header Bar -->
|
|
||||||
<summary class="list-none [&::-webkit-details-marker]:hidden bg-[#0e1422] border border-[#1b2233] rounded-xl p-3 flex items-center justify-between gap-2 cursor-pointer transition-all hover:border-indigo-500/40">
|
|
||||||
<div id="inspector-active-tab-title" class="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center gap-2">
|
|
||||||
<i id="inspector-active-tab-icon" class="fa-solid fa-layer-group text-indigo-400"></i>
|
|
||||||
<span id="inspector-active-tab-text">Inspector Panel</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Button Toolbar -->
|
|
||||||
<div class="inline-flex items-center gap-1 p-0.5 bg-[#090d16] border border-[#1b2233] rounded-lg">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-btn-logs"
|
|
||||||
title="Proctoring Logs"
|
|
||||||
onclick="switchIdeTab('logs', event)"
|
|
||||||
class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold transition-all cursor-pointer text-slate-400 hover:text-white hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-shield-halved"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-btn-notes"
|
|
||||||
title="Candidate Notepad"
|
|
||||||
onclick="switchIdeTab('notes', event)"
|
|
||||||
class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold transition-all cursor-pointer text-slate-400 hover:text-white hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-note-sticky"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-btn-drawing"
|
|
||||||
title="Candidate Drawing Board"
|
|
||||||
onclick="switchIdeTab('drawing', event)"
|
|
||||||
class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold transition-all cursor-pointer text-slate-400 hover:text-white hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-pen"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-btn-recordings"
|
|
||||||
title="Saved Video Recordings"
|
|
||||||
onclick="switchIdeTab('recordings', event)"
|
|
||||||
class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold transition-all cursor-pointer text-slate-400 hover:text-white hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-film"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-btn-screenshots"
|
|
||||||
title="Tab Switch Screenshots"
|
|
||||||
onclick="switchIdeTab('screenshots', event)"
|
|
||||||
class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold transition-all cursor-pointer text-slate-400 hover:text-white hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-image"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<!-- Floating Inspector Content Overlay -->
|
|
||||||
<div id="inspector-content-container" class="absolute top-[calc(100%+8px)] right-0 left-0 lg:left-auto lg:w-112.5 bg-[#0e1422] border border-[#1b2233] rounded-xl p-3 shadow-2xl shadow-black/80 backdrop-blur-md z-50 flex flex-col max-h-125 overflow-hidden">
|
|
||||||
<!-- Floating Header -->
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div id="inspector-floating-title" class="text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-2">
|
|
||||||
<i id="inspector-floating-icon" class="fa-solid fa-shield-halved text-indigo-400"></i>
|
|
||||||
<span id="inspector-floating-text">Proctoring Logs</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="inspector-close-btn"
|
|
||||||
title="Close Inspector"
|
|
||||||
onclick="closeInspectorPanel()"
|
|
||||||
class="w-6 h-6 rounded-md bg-white/5 hover:bg-white/15 text-slate-400 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Floating Tab Content Panes -->
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
|
||||||
<div id="tab-content-logs" class="hidden">
|
|
||||||
<x-audit-log id="modal-logs-display" :logs="$interview->proctor_logs ?? []" class="h-75 overflow-y-auto" />
|
|
||||||
</div>
|
|
||||||
<x-interview.tabs.notes-tab />
|
|
||||||
<x-interview.tabs.drawing-tab />
|
|
||||||
<x-interview.tabs.recordings-tab />
|
|
||||||
<x-interview.tabs.screenshots-tab />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
@props([
|
|
||||||
'activeNav' => 'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
<header class="h-17.5 bg-slate-900/80 backdrop-blur-md border-b border-slate-700/60 flex items-center justify-between px-6">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<a href="{{ route('interview.index') }}" class="w-9 h-9 rounded-lg bg-linear-to-br from-indigo-500 to-sky-600 flex items-center justify-center font-bold text-white shadow-md shadow-indigo-500/30 no-underline">
|
|
||||||
<i class="fa-solid fa-bolt text-lg"></i>
|
|
||||||
</a>
|
|
||||||
<div>
|
|
||||||
<h1 class="font-outfit text-lg font-bold text-white m-0 leading-tight">Candidate Live Assessment Portal</h1>
|
|
||||||
<div class="text-xs text-slate-400">Proctored IDE & Code Submissions</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
@if(Auth::check() && Auth::user()->isAdmin())
|
|
||||||
<a href="{{ route('admin.dashboard') }}">
|
|
||||||
<x-button variant="ghost" size="sm" icon="fa-solid fa-sliders" class="text-indigo-400 border-indigo-500/40">Control Panel</x-button>
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
<a href="{{ route('onboarding.index') }}">
|
|
||||||
<x-button variant="secondary" size="sm" icon="fa-solid fa-rocket">Onboarding Hub</x-button>
|
|
||||||
</a>
|
|
||||||
<a href="{{ route('dashboard') }}">
|
|
||||||
<x-button variant="secondary" size="sm" icon="fa-solid fa-user">User Portal</x-button>
|
|
||||||
</a>
|
|
||||||
<form action="{{ route('logout') }}" method="POST" id="logout-form" class="inline">
|
|
||||||
@csrf
|
|
||||||
<x-button type="button" onclick="interviewerLogoutAction()" variant="danger" size="sm" icon="fa-solid fa-right-from-bracket" class="bg-red-500/20 text-red-300 border-red-500/40 font-semibold">
|
|
||||||
Logout
|
|
||||||
</x-button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
@props([
|
|
||||||
'metrics' => [],
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$tabSwitches = $metrics['tab_switches'] ?? 0;
|
|
||||||
$focusLost = $metrics['focus_lost'] ?? 0;
|
|
||||||
$gazeAlerts = $metrics['gaze_alerts'] ?? 0;
|
|
||||||
$lowerGaze = $metrics['lower_gaze'] ?? 0;
|
|
||||||
$questionRepeat = $metrics['question_repeat'] ?? 0;
|
|
||||||
$externalAi = $metrics['external_ai'] ?? 0;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div {{ $attributes->merge(['class' => 'flex flex-col gap-0.5 text-xs']) }}>
|
|
||||||
<span class="{{ $tabSwitches > 0 ? 'text-red-400 font-bold' : 'text-slate-400' }}">
|
|
||||||
<i class="fa-solid fa-circle text-[0.5rem] mr-1 text-red-500"></i> Tab Switches: {{ $tabSwitches }}
|
|
||||||
</span>
|
|
||||||
<span class="{{ $focusLost > 0 ? 'text-amber-400 font-semibold' : 'text-slate-400' }}">
|
|
||||||
<i class="fa-solid fa-circle text-[0.5rem] mr-1 text-amber-500"></i> Focus Loss / Overlay: {{ $focusLost }}
|
|
||||||
</span>
|
|
||||||
<span class="{{ $gazeAlerts > 0 ? 'text-rose-400 font-semibold' : 'text-slate-400' }}">
|
|
||||||
<i class="fa-solid fa-eye mr-1"></i> Eye Gaze Anomaly: {{ $gazeAlerts }}
|
|
||||||
</span>
|
|
||||||
@if($lowerGaze > 0)
|
|
||||||
<span class="text-red-400 font-bold">
|
|
||||||
<i class="fa-solid fa-mobile-screen mr-1"></i> Lower Gaze (>10s): {{ $lowerGaze }}
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
@if($questionRepeat > 0)
|
|
||||||
<span class="text-rose-400 font-bold">
|
|
||||||
<i class="fa-solid fa-comments mr-1"></i> Question Repetition: {{ $questionRepeat }}
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
@if($externalAi > 0)
|
|
||||||
<span class="text-red-400 font-extrabold bg-red-500/20 px-1.5 py-0.5 rounded">
|
|
||||||
<i class="fa-solid fa-robot mr-1"></i> Parakeet / External AI: {{ $externalAi }}
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
@if(!empty($interview->candidate_notes) || !empty($interview->interviewer_notes) || !empty($interview->candidate_drawing) || (is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0))
|
|
||||||
<div class="font-outfit text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
📝 Workspace Artifacts, Evaluation Notes & Call Session Recordings
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4 mb-7">
|
|
||||||
@if(!empty($interview->candidate_notes))
|
|
||||||
<div class="text-xs font-bold text-slate-600 mb-1.5 uppercase tracking-wide">
|
|
||||||
CANDIDATE NOTEPAD NOTES (UNIQUE ID SYNCED):
|
|
||||||
</div>
|
|
||||||
<pre class="font-mono text-xs bg-white text-slate-800 p-3 rounded-lg border border-slate-200 mb-3.5 whitespace-pre-wrap">{{ $interview->candidate_notes }}</pre>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(!empty($interview->interviewer_notes))
|
|
||||||
<div class="text-xs font-bold text-emerald-700 mb-1.5 uppercase tracking-wide">
|
|
||||||
INTERVIEWER EVALUATION NOTES (STORED BY UNIQUE ID):
|
|
||||||
</div>
|
|
||||||
<pre class="font-sans text-xs bg-emerald-50 text-emerald-900 p-3 rounded-lg border border-emerald-200 mb-3.5 whitespace-pre-wrap">{{ $interview->interviewer_notes }}</pre>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(!empty($interview->candidate_drawing))
|
|
||||||
<div class="text-xs font-bold text-slate-600 mb-1.5 uppercase tracking-wide">
|
|
||||||
CANDIDATE DRAWING DIAGRAM:
|
|
||||||
</div>
|
|
||||||
<img src="{{ $interview->candidate_drawing }}" alt="Candidate Drawing" class="max-w-full max-h-[250px] rounded-lg border border-slate-200 mb-3.5" />
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0)
|
|
||||||
<div class="text-xs font-bold text-indigo-700 mb-2 flex justify-between items-center uppercase tracking-wide">
|
|
||||||
<span>📹 STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS):</span>
|
|
||||||
<span class="text-[11px] text-slate-400 font-medium">📜 Scroll to view all</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-h-[480px] overflow-y-auto space-y-3 p-2.5 bg-slate-100/60 rounded-lg border border-slate-200 mb-2">
|
|
||||||
@foreach($interview->formatted_recordings as $displayIdx => $rec)
|
|
||||||
<div class="bg-white border border-slate-200 rounded-lg p-2.5">
|
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
|
||||||
<span class="text-xs font-bold text-slate-900">Recording #{{ count($interview->formatted_recordings) - $displayIdx }} ({{ $rec['created_at'] }})</span>
|
|
||||||
<a href="{{ $rec['download_url'] }}" download class="text-[11px] text-indigo-600 font-bold hover:underline bg-indigo-50 px-2 py-1 rounded">
|
|
||||||
📥 Download MP4 Video
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<video controls preload="metadata" class="w-full max-h-[220px] rounded bg-black">
|
|
||||||
<source src="{{ $rec['stream_url'] }}" type="{{ $rec['mime_type'] ?? 'video/webm' }}">
|
|
||||||
<source src="{{ $rec['stream_url'] }}">
|
|
||||||
Your browser does not support video playback.
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
@props([
|
|
||||||
'logs' => [],
|
|
||||||
])
|
|
||||||
|
|
||||||
@if(is_array($logs) && count($logs) > 0)
|
|
||||||
<div class="overflow-x-auto w-full mb-7 border border-slate-200 rounded-xl">
|
|
||||||
<table class="w-full text-left text-xs border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-slate-100 border-b border-slate-200 text-slate-600 font-bold uppercase text-[10.5px]">
|
|
||||||
<th class="py-2.5 px-3.5">Timestamp</th>
|
|
||||||
<th class="py-2.5 px-3.5">Violation Category</th>
|
|
||||||
<th class="py-2.5 px-3.5">Audit Details</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-200 bg-white">
|
|
||||||
@foreach($logs as $log)
|
|
||||||
<tr>
|
|
||||||
<td class="py-2.5 px-3.5 text-slate-500 font-mono text-[11px] whitespace-nowrap">
|
|
||||||
{{ isset($log['timestamp']) ? \Carbon\Carbon::parse($log['timestamp'])->format('H:i:s T') : '--:--:--' }}
|
|
||||||
</td>
|
|
||||||
<td class="py-2.5 px-3.5 font-bold text-slate-800 uppercase">
|
|
||||||
{{ str_replace('_', ' ', $log['type'] ?? 'Violation') }}
|
|
||||||
</td>
|
|
||||||
<td class="py-2.5 px-3.5 text-slate-600">
|
|
||||||
{{ $log['details'] ?? 'Incident flagged by proctoring engine.' }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="bg-emerald-500/10 border border-emerald-500/25 text-emerald-700 px-4 py-3 rounded-xl text-xs font-semibold mb-7 flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-circle-check text-emerald-600 text-sm"></i>
|
|
||||||
<span>Zero proctoring violations or malpractice detected during the session.</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
@props([
|
|
||||||
'code' => null,
|
|
||||||
'output' => null,
|
|
||||||
'language' => 'code',
|
|
||||||
])
|
|
||||||
|
|
||||||
<!-- Code Solution Block -->
|
|
||||||
<div class="font-outfit text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
💻 Submitted Code Solution ({{ strtoupper($language) }})
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-950 text-sky-400 rounded-xl p-4 font-mono text-xs whitespace-pre-wrap mb-5 max-h-[300px] overflow-y-auto leading-relaxed border border-slate-800">
|
|
||||||
{{ $code ?? '// No code solution submitted by candidate.' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Code Output Block -->
|
|
||||||
<div class="font-outfit text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
🖥️ Engine Execution Stdout Output
|
|
||||||
</div>
|
|
||||||
<div class="bg-[#050811] text-emerald-400 rounded-xl p-3.5 font-mono text-xs whitespace-pre-wrap mb-7 max-h-[120px] overflow-y-auto leading-relaxed border border-slate-900">
|
|
||||||
{{ $output ?? 'No execution output recorded.' }}
|
|
||||||
</div>
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
<!-- Top Bar with Download PDF Button -->
|
|
||||||
<div class="flex justify-between items-center mb-5 pb-3 border-b border-slate-200 print:hidden">
|
|
||||||
<div class="text-sm font-bold text-slate-900 flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-file-lines text-indigo-600"></i>
|
|
||||||
<span>Executive Assessment Performance Report</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" onclick="window.print()" class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold text-xs py-2.5 px-4 rounded-xl cursor-pointer flex items-center gap-2 shadow-md shadow-indigo-600/30 transition-all">
|
|
||||||
<i class="fa-solid fa-download"></i>
|
|
||||||
<span>Download PDF Report</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex justify-between items-start border-b-2 border-indigo-600 pb-5 mb-6">
|
|
||||||
<div>
|
|
||||||
<div class="font-outfit text-2xl font-extrabold text-slate-900 flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-bolt text-indigo-600"></i> SingleLogin Enterprise Assessment
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-slate-500 mt-1">
|
|
||||||
Candidate Technical Competency & Behavioral Audit Report
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-right">
|
|
||||||
<span class="inline-block bg-indigo-500/10 text-indigo-600 text-[11px] font-bold px-3 py-1 rounded-full uppercase">
|
|
||||||
Executive Summary
|
|
||||||
</span>
|
|
||||||
<div class="text-xs text-slate-500 mt-1.5 font-mono">
|
|
||||||
Report ID: RPT-{{ $interview->submission_unique_id }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-slate-500">
|
|
||||||
Date: {{ now()->format('M d, Y - H:i:s T') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
@props([
|
|
||||||
'metrics' => [],
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$tabSwitches = $metrics['tab_switches'] ?? 0;
|
|
||||||
$focusLosses = $metrics['focus_lost'] ?? 0;
|
|
||||||
$gazeAnomalies = $metrics['gaze_alerts'] ?? 0;
|
|
||||||
$pasteEvents = $metrics['paste_events'] ?? 0;
|
|
||||||
$lowerGaze = $metrics['lower_gaze'] ?? 0;
|
|
||||||
$questionRepeat = $metrics['question_repeat'] ?? 0;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="font-outfit text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
🔍 Behavioral Audit & Proctoring Incident Breakdown
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2.5 mb-5">
|
|
||||||
<div class="bg-slate-50 p-2.5 rounded-xl border border-slate-200 text-center">
|
|
||||||
<div class="text-lg font-extrabold text-red-500">{{ $tabSwitches }}</div>
|
|
||||||
<div class="text-[10px] text-slate-500 font-bold uppercase mt-0.5">Tab Switches</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-50 p-2.5 rounded-xl border border-slate-200 text-center">
|
|
||||||
<div class="text-lg font-extrabold text-amber-500">{{ $focusLosses }}</div>
|
|
||||||
<div class="text-[10px] text-slate-500 font-bold uppercase mt-0.5">Focus Lost</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-50 p-2.5 rounded-xl border border-slate-200 text-center">
|
|
||||||
<div class="text-lg font-extrabold text-sky-500">{{ $gazeAnomalies }}</div>
|
|
||||||
<div class="text-[10px] text-slate-500 font-bold uppercase mt-0.5">Gaze Anomalies</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-50 p-2.5 rounded-xl border border-slate-200 text-center">
|
|
||||||
<div class="text-lg font-extrabold text-indigo-500">{{ $pasteEvents }}</div>
|
|
||||||
<div class="text-[10px] text-slate-500 font-bold uppercase mt-0.5">Code Pastes</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-50 p-2.5 rounded-xl border border-slate-200 text-center">
|
|
||||||
<div class="text-lg font-extrabold text-red-500">{{ $lowerGaze }}</div>
|
|
||||||
<div class="text-[10px] text-slate-500 font-bold uppercase mt-0.5">📱 Lower Gaze</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-50 p-2.5 rounded-xl border border-slate-200 text-center">
|
|
||||||
<div class="text-lg font-extrabold text-rose-500">{{ $questionRepeat }}</div>
|
|
||||||
<div class="text-[10px] text-slate-500 font-bold uppercase mt-0.5">🗣️ Question Repeat</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$statusColor = match($interview->status) {
|
|
||||||
'completed' => 'text-emerald-600',
|
|
||||||
'blocked' => 'text-red-600',
|
|
||||||
default => 'text-indigo-600',
|
|
||||||
};
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 bg-slate-50 border border-slate-200 rounded-xl p-4 mb-7">
|
|
||||||
<div>
|
|
||||||
<label class="block text-[11px] uppercase font-bold text-slate-500 mb-1">Candidate Name</label>
|
|
||||||
<span class="font-bold text-sm text-slate-900">{{ $interview->candidate_name }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[11px] uppercase font-bold text-slate-500 mb-1">Email Address</label>
|
|
||||||
<span class="font-semibold text-xs text-slate-700 font-mono">{{ $interview->candidate_email }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[11px] uppercase font-bold text-slate-500 mb-1">Language & Tech</label>
|
|
||||||
<span class="font-bold text-sm text-slate-900 uppercase font-mono">{{ $interview->language }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[11px] uppercase font-bold text-slate-500 mb-1">Assessment Status</label>
|
|
||||||
<span class="font-extrabold text-sm uppercase {{ $statusColor }}">
|
|
||||||
{{ $interview->status }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
@props([
|
|
||||||
'integrityScore' => 100,
|
|
||||||
'integrityLabel' => 'Optimal Candidate Trust',
|
|
||||||
'integrityColor' => '#10b981',
|
|
||||||
'incidentCount' => 0,
|
|
||||||
'codeScore' => 0,
|
|
||||||
'hasSubmittedCode' => false,
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$codeLabel = $codeScore >= 80 ? 'Strong Technical Match' : ($codeScore >= 50 ? 'Moderate Competency' : 'Needs Further Review');
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-7">
|
|
||||||
<!-- Behavioral Integrity Score -->
|
|
||||||
<div class="bg-slate-50 border border-slate-200 border-l-4 rounded-xl p-5 relative" style="border-left-color: {{ $integrityColor }};">
|
|
||||||
<div class="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
|
||||||
🛡️ Behavioral Integrity Rating
|
|
||||||
</div>
|
|
||||||
<div class="font-outfit text-4xl font-extrabold mt-1.5" style="color: {{ $integrityColor }};">
|
|
||||||
{{ $integrityScore }}%
|
|
||||||
</div>
|
|
||||||
<div class="text-xs font-bold mt-1" style="color: {{ $integrityColor }};">
|
|
||||||
{{ $integrityLabel }}
|
|
||||||
</div>
|
|
||||||
<div class="text-[11px] text-slate-500 mt-1.5 font-medium">
|
|
||||||
Recorded Violations: <strong class="text-slate-700">{{ $incidentCount }} incident(s)</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Technical Score -->
|
|
||||||
<div class="bg-slate-50 border border-slate-200 border-l-4 border-l-indigo-600 rounded-xl p-5 relative">
|
|
||||||
<div class="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
|
||||||
💻 Technical Code Competency
|
|
||||||
</div>
|
|
||||||
<div class="font-outfit text-4xl font-extrabold text-indigo-600 mt-1.5">
|
|
||||||
{{ $codeScore }} / 100
|
|
||||||
</div>
|
|
||||||
<div class="text-xs font-bold text-indigo-600 mt-1">
|
|
||||||
{{ $codeLabel }}
|
|
||||||
</div>
|
|
||||||
<div class="text-[11px] text-slate-500 mt-1.5 font-medium">
|
|
||||||
Solution Code: <strong class="text-slate-700">{{ $hasSubmittedCode ? 'Submitted' : 'Pending' }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
@props([
|
|
||||||
'recommended' => true,
|
|
||||||
])
|
|
||||||
|
|
||||||
<div class="border-t-2 border-slate-200 pt-5 flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<div class="text-[11px] font-bold text-slate-500 uppercase tracking-wider">
|
|
||||||
HR & Lead Panel Approval
|
|
||||||
</div>
|
|
||||||
<div class="font-bold text-sm text-slate-900 mt-1">
|
|
||||||
SingleLogin Enterprise HR Board
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-right">
|
|
||||||
<div class="text-[11px] font-bold text-slate-500 uppercase tracking-wider">
|
|
||||||
Final Recommendation
|
|
||||||
</div>
|
|
||||||
<div class="font-outfit font-extrabold text-base mt-1 {{ $recommended ? 'text-emerald-600' : 'text-red-600' }}">
|
|
||||||
{{ $recommended ? 'RECOMMENDED FOR HIRE' : 'REQUIRES FURTHER REVIEW / FLAGGED' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interviewers' => [],
|
|
||||||
])
|
|
||||||
|
|
||||||
<x-drawer
|
|
||||||
id="create-interview-drawer"
|
|
||||||
title="Schedule Candidate Assessment"
|
|
||||||
description="Provision candidate temporary credentials, IDE access timeline, and assign review panelists."
|
|
||||||
width="lg"
|
|
||||||
>
|
|
||||||
<form action="{{ route('interview.store') }}" method="POST">
|
|
||||||
@csrf
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<x-label required>Candidate Name</x-label>
|
|
||||||
<x-input type="text" name="candidate_name" required placeholder="e.g. John Doe" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-label required>Candidate Email</x-label>
|
|
||||||
<x-input type="email" name="candidate_email" required placeholder="john@gmail.com" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
|
|
||||||
<div>
|
|
||||||
<x-label required>Phone Number</x-label>
|
|
||||||
<x-input type="text" name="candidate_phone" required placeholder="9876543210" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<x-label required>Access Duration</x-label>
|
|
||||||
<x-select name="valid_hours" required>
|
|
||||||
<option value="1">1 Hour</option>
|
|
||||||
<option value="2" selected>2 Hours</option>
|
|
||||||
<option value="4">4 Hours</option>
|
|
||||||
<option value="12">12 Hours</option>
|
|
||||||
<option value="24">24 Hours</option>
|
|
||||||
</x-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-label>Start Time (Timeline)</x-label>
|
|
||||||
<x-input type="datetime-local" name="scheduled_at" value="{{ now()->format('Y-m-d\TH:i') }}" class="bg-slate-900" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-label required>Coding Language</x-label>
|
|
||||||
<x-select name="language" required>
|
|
||||||
<option value="python">Python 3</option>
|
|
||||||
<option value="cpp">C++ (GCC)</option>
|
|
||||||
<option value="c">C (GCC)</option>
|
|
||||||
<option value="java">Java 15</option>
|
|
||||||
<option value="php">PHP 8.2 / Laravel</option>
|
|
||||||
<option value="javascript">JavaScript (Node.js)</option>
|
|
||||||
</x-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
|
||||||
<x-label>Assign Internal Interviewers / Panelists</x-label>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2 max-h-[160px] overflow-y-auto bg-black/30 p-3 rounded-xl border border-slate-700/60">
|
|
||||||
@foreach($interviewers as $usr)
|
|
||||||
<label class="flex items-center gap-2.5 text-xs text-white cursor-pointer hover:text-indigo-300 transition-colors py-0.5">
|
|
||||||
<input type="checkbox" name="assigned_interviewers[]" value="{{ $usr->id }}" class="accent-indigo-500 rounded w-4 h-4">
|
|
||||||
<span>{{ $usr->name }} <span class="text-slate-400 font-mono text-[11px]">({{ $usr->email }})</span></span>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2.5 pt-6 mt-6 border-t border-white/10">
|
|
||||||
<x-button type="button" onclick="document.getElementById('create-interview-drawer').classList.remove('active')" variant="secondary">
|
|
||||||
Cancel
|
|
||||||
</x-button>
|
|
||||||
<x-button type="submit" variant="primary" icon="fa-solid fa-check">
|
|
||||||
Create Session & Password
|
|
||||||
</x-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</x-drawer>
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interviewers' => [],
|
|
||||||
])
|
|
||||||
|
|
||||||
<x-modal id="create-interview-modal" title="Schedule Candidate Assessment" maxWidth="lg">
|
|
||||||
<form action="{{ route('interview.store') }}" method="POST">
|
|
||||||
@csrf
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-3.5">
|
|
||||||
<div>
|
|
||||||
<x-label required>Candidate Name</x-label>
|
|
||||||
<x-input type="text" name="candidate_name" required placeholder="e.g. John Doe" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<x-label required>Candidate Email</x-label>
|
|
||||||
<x-input type="email" name="candidate_email" required placeholder="john@gmail.com" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3.5">
|
|
||||||
<div>
|
|
||||||
<x-label required>Phone Number</x-label>
|
|
||||||
<x-input type="text" name="candidate_phone" required placeholder="9876543210" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<x-label>Start Time (Timeline)</x-label>
|
|
||||||
<x-input type="datetime-local" name="scheduled_at" value="{{ now()->format('Y-m-d\TH:i') }}" class="bg-slate-900" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<x-label required>Access Duration</x-label>
|
|
||||||
<x-select name="valid_hours" required>
|
|
||||||
<option value="1">1 Hour</option>
|
|
||||||
<option value="2" selected>2 Hours</option>
|
|
||||||
<option value="4">4 Hours</option>
|
|
||||||
<option value="12">12 Hours</option>
|
|
||||||
<option value="24">24 Hours</option>
|
|
||||||
</x-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3.5">
|
|
||||||
<x-label required>Coding Language</x-label>
|
|
||||||
<x-select name="language" required>
|
|
||||||
<option value="python">Python 3</option>
|
|
||||||
<option value="cpp">C++ (GCC)</option>
|
|
||||||
<option value="c">C (GCC)</option>
|
|
||||||
<option value="java">Java 15</option>
|
|
||||||
<option value="php">PHP 8.2 / Laravel</option>
|
|
||||||
<option value="javascript">JavaScript (Node.js)</option>
|
|
||||||
</x-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
|
||||||
<x-label>Assign Internal Interviewers / Panelists</x-label>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[120px] overflow-y-auto bg-black/30 p-2.5 rounded-lg border border-slate-700/60">
|
|
||||||
@foreach($interviewers as $usr)
|
|
||||||
<label class="flex items-center gap-2 text-xs text-white cursor-pointer hover:text-indigo-300 transition-colors">
|
|
||||||
<input type="checkbox" name="assigned_interviewers[]" value="{{ $usr->id }}" class="accent-indigo-500 rounded">
|
|
||||||
{{ $usr->name }} ({{ $usr->email }})
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2.5 mt-5">
|
|
||||||
<x-button type="button" onclick="document.getElementById('create-interview-modal').classList.remove('active')" variant="secondary">
|
|
||||||
Cancel
|
|
||||||
</x-button>
|
|
||||||
<x-button type="submit" variant="primary" icon="fa-solid fa-check">
|
|
||||||
Create Session & Password
|
|
||||||
</x-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</x-modal>
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
@props([
|
|
||||||
'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$metrics = $interview->proctor_metrics;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<tr class="hover:bg-white/[0.02] transition-colors">
|
|
||||||
<!-- Candidate Info & Temp Pass -->
|
|
||||||
<td class="py-3.5 px-4">
|
|
||||||
<div>
|
|
||||||
<strong class="text-white text-sm block">{{ $interview->candidate_name }}</strong>
|
|
||||||
<div class="text-xs text-slate-400 mt-0.5">{{ $interview->candidate_email }} • {{ $interview->candidate_phone }}</div>
|
|
||||||
<div class="mt-1.5 flex flex-col gap-1">
|
|
||||||
<div class="text-[0.7rem] text-emerald-300 bg-emerald-500/10 px-2 py-0.5 rounded inline-block w-fit">
|
|
||||||
<i class="fa-solid fa-key text-[0.65rem] mr-1"></i> Temp Pass: <strong>{{ $interview->temp_password }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="text-[0.7rem] text-sky-400">
|
|
||||||
<i class="fa-solid fa-globe text-[0.65rem] mr-1"></i> Login URL: <a href="{{ route('interview.candidate.login') }}" target="_blank" class="text-sky-400 underline">{{ route('interview.candidate.login') }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Unique Submission ID -->
|
|
||||||
<td class="py-3.5 px-4">
|
|
||||||
<code class="bg-white/5 px-2 py-1 rounded text-sky-400 text-xs font-mono">
|
|
||||||
{{ $interview->submission_unique_id }}
|
|
||||||
</code>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Status & Validity -->
|
|
||||||
<td class="py-3.5 px-4">
|
|
||||||
@if($interview->isExpired())
|
|
||||||
<x-pill variant="danger" size="sm">EXPIRED</x-pill>
|
|
||||||
@elseif($interview->status === 'completed')
|
|
||||||
<x-pill variant="success" size="sm">COMPLETED</x-pill>
|
|
||||||
@elseif($interview->call_status === 'active')
|
|
||||||
<x-pill variant="success" size="sm" icon="fa-solid fa-phone" class="animate-pulse font-extrabold">CALL IN PROGRESS</x-pill>
|
|
||||||
@elseif($interview->isActiveTimeline())
|
|
||||||
<x-pill variant="info" size="sm" icon="fa-solid fa-bolt">TIMELINE LIVE</x-pill>
|
|
||||||
@else
|
|
||||||
<x-pill variant="warning" size="sm">SCHEDULED</x-pill>
|
|
||||||
@endif
|
|
||||||
<div class="text-[0.7rem] text-slate-400 mt-1">
|
|
||||||
Timeline: {{ $interview->formatted_timeline }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Language -->
|
|
||||||
<td class="py-3.5 px-4">
|
|
||||||
<x-pill variant="ghost" size="sm" class="uppercase font-bold">
|
|
||||||
{{ $interview->language }}
|
|
||||||
</x-pill>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Proctoring Alerts -->
|
|
||||||
<td class="py-3.5 px-4">
|
|
||||||
<x-interview.proctor-badges :metrics="$metrics" />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Review & Actions -->
|
|
||||||
<td class="py-3.5 px-4 text-right whitespace-nowrap">
|
|
||||||
<div class="inline-flex items-center gap-2 justify-end">
|
|
||||||
<!-- Button 1: Report Page -->
|
|
||||||
<a href="{{ route('interview.report', $interview->id) }}" target="_blank">
|
|
||||||
<x-button variant="success" size="sm" icon="fa-solid fa-file-pdf" class="bg-emerald-500/15 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/25">
|
|
||||||
PDF Report
|
|
||||||
</x-button>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Button 2: Join Call -->
|
|
||||||
@if($interview->call_status === 'active')
|
|
||||||
<a href="{{ route('interview.show', $interview->id) }}?join_call=1">
|
|
||||||
<x-button variant="success" size="sm" icon="fa-solid fa-phone-volume" class="animate-pulse font-extrabold shadow-md shadow-emerald-500/50">
|
|
||||||
Join Active Call
|
|
||||||
</x-button>
|
|
||||||
</a>
|
|
||||||
@else
|
|
||||||
<a href="{{ route('interview.show', $interview->id) }}">
|
|
||||||
<x-button variant="primary" size="sm" icon="fa-solid fa-video" class="bg-indigo-500/15 border-indigo-500/30 text-indigo-300 hover:bg-indigo-500/25">
|
|
||||||
Join Call
|
|
||||||
</x-button>
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<x-modal id="sos-modal" title="Candidate Potential Cheating Alert" maxWidth="lg" onClose="stopSosAlarm">
|
|
||||||
<div id="sos-violation-list" class="bg-red-500/10 border border-red-500/30 rounded-lg p-3.5 text-xs text-red-400 mb-4 max-h-[180px] overflow-y-auto font-medium">
|
|
||||||
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>
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<div id="tab-content-code" class="block">
|
|
||||||
<div class="text-[11.5px] text-slate-400 mb-2 font-medium">Current code solution — visible even if candidate does not share screen</div>
|
|
||||||
<pre id="modal-code-display" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3.5 font-mono text-[13px] text-emerald-300 min-h-[220px] max-h-[300px] overflow-y-auto whitespace-pre-wrap"></pre>
|
|
||||||
|
|
||||||
<div class="text-[11.5px] text-slate-400 mt-4 mb-2 font-medium">Execution stdout / stderr</div>
|
|
||||||
<pre id="modal-output-display" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3 font-mono text-xs text-slate-400 max-h-[100px] overflow-y-auto whitespace-pre-wrap"></pre>
|
|
||||||
</div>
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<div id="tab-content-drawing" class="hidden">
|
|
||||||
<div class="text-[11.5px] text-slate-400 mb-2 font-medium">Candidate live drawing / architecture diagram</div>
|
|
||||||
<div class="bg-slate-950/70 border border-slate-800 rounded-lg 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-slate-700" />
|
|
||||||
<div id="no-drawing-placeholder" class="text-slate-500 text-xs">No drawing data submitted by candidate yet.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
<div id="tab-content-notes" class="hidden">
|
|
||||||
<div class="text-[11.5px] text-slate-400 mb-2 font-medium">Candidate live notepad notes (auto-synced)</div>
|
|
||||||
<pre id="modal-notes-display" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3.5 font-mono text-xs text-slate-200 min-h-[250px] max-h-[350px] overflow-y-auto whitespace-pre-wrap"></pre>
|
|
||||||
</div>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<div id="tab-content-recordings" class="hidden">
|
|
||||||
<div class="text-[11.5px] text-slate-400 mb-2 font-medium">Saved candidate video sessions (sorted by newest first)</div>
|
|
||||||
<div id="modal-recordings-container" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3 min-h-[250px] max-h-[420px] overflow-y-auto">
|
|
||||||
<div class="text-slate-500 text-xs text-center pt-10">No video recordings saved for this candidate yet.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<div id="tab-content-screenshots" class="hidden">
|
|
||||||
<div class="bg-slate-950/70 border border-slate-800 rounded-lg 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-slate-400 max-w-xs leading-relaxed">Tab switch screenshot capturing and inspection is currently under maintenance.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
@props([
|
|
||||||
'required' => false,
|
|
||||||
])
|
|
||||||
|
|
||||||
<label {{ $attributes->merge(['class' => 'block text-xs font-medium text-slate-400 mb-1']) }}>
|
|
||||||
{{ $slot }}
|
|
||||||
@if($required)
|
|
||||||
<span class="text-red-500 font-bold ml-0.5">*</span>
|
|
||||||
@endif
|
|
||||||
</label>
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
@props([
|
|
||||||
'title' => 'Live Assessment Room',
|
|
||||||
])
|
|
||||||
|
|
||||||
<!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>{{ $title }} | 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=Fira+Code:wght@400;500;600&family=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&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>
|
|
||||||
|
|
||||||
<!-- WebRTC, PeerJS, Metered SDK, SweetAlert2 & html2canvas -->
|
|
||||||
<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>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Monaco Code Editor Loader -->
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
|
|
||||||
|
|
||||||
@stack('styles')
|
|
||||||
@stack('head-scripts')
|
|
||||||
</head>
|
|
||||||
<body {{ $attributes->merge(['class' => "bg-[#080c14] text-white h-screen flex flex-col font-['Instrument_Sans',sans-serif] overflow-hidden antialiased select-none"]) }}>
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
@stack('scripts')
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
@props([
|
|
||||||
'title' => 'Candidate Portal',
|
|
||||||
])
|
|
||||||
|
|
||||||
<!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>{{ $title }} | 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=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
|
||||||
|
|
||||||
@stack('styles')
|
|
||||||
@stack('head-scripts')
|
|
||||||
</head>
|
|
||||||
<body {{ $attributes->merge(['class' => "bg-[#0b0f19] text-white min-h-screen flex items-center justify-center p-4 relative overflow-hidden font-['Instrument_Sans',sans-serif] antialiased"]) }}>
|
|
||||||
|
|
||||||
<!-- Decorative background glow -->
|
|
||||||
<div class="absolute -top-[15%] -left-[15%] w-[60%] h-[60%] rounded-full bg-radial from-indigo-500/20 to-transparent pointer-events-none z-0"></div>
|
|
||||||
|
|
||||||
<div class="relative z-10 w-full flex items-center justify-center">
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@stack('scripts')
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
@props([
|
|
||||||
'title' => 'Candidate Live Assessment Portal',
|
|
||||||
'activeNav' => 'interview',
|
|
||||||
])
|
|
||||||
|
|
||||||
<!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>{{ $title }} | 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=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@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://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
|
||||||
|
|
||||||
@stack('styles')
|
|
||||||
@stack('head-scripts')
|
|
||||||
</head>
|
|
||||||
<body {{ $attributes->merge(['class' => "bg-[#0a0e17] text-white min-h-screen flex flex-col font-['Instrument_Sans',sans-serif] antialiased"]) }}>
|
|
||||||
|
|
||||||
<x-interview.navbar :activeNav="$activeNav" />
|
|
||||||
|
|
||||||
<main class="w-full mx-auto my-6 px-7 flex-1">
|
|
||||||
@if(session('success'))
|
|
||||||
<x-alert variant="success" class="mb-6">
|
|
||||||
{{ session('success') }}
|
|
||||||
</x-alert>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(session('error'))
|
|
||||||
<x-alert variant="danger" class="mb-6">
|
|
||||||
{{ session('error') }}
|
|
||||||
</x-alert>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Global shared Incoming Call Modal -->
|
|
||||||
<x-interview.incoming-call-modal />
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.MAX_INTERVIEWERS = {{ config('interview.max_interviewer', 4) }};
|
|
||||||
window.currentUserId = {{ Auth::id() ?? 'null' }};
|
|
||||||
window.currentUserName = "{{ Auth::check() ? addslashes(Auth::user()->name) : '' }}";
|
|
||||||
window.currentUserRole = "{{ Auth::check() && Auth::user()->isAdmin() ? 'admin' : 'interviewer' }}";
|
|
||||||
window.csrfToken = "{{ csrf_token() }}";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
@stack('scripts')
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
@props([
|
|
||||||
'title' => 'Executive Evaluation Report',
|
|
||||||
])
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{ $title }} | 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=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700;800&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
|
||||||
|
|
||||||
@stack('styles')
|
|
||||||
@stack('head-scripts')
|
|
||||||
</head>
|
|
||||||
<body {{ $attributes->merge(['class' => "bg-slate-100 text-slate-900 leading-normal p-5 md:p-10 font-['Instrument_Sans',sans-serif] antialiased print:bg-white print:p-0 print:text-black"]) }}>
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
@stack('scripts')
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
@props([
|
|
||||||
'name' => null,
|
|
||||||
'required' => false,
|
|
||||||
'disabled' => false,
|
|
||||||
'size' => 'md',
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$sizeClasses = [
|
|
||||||
'sm' => 'px-2.5 py-1.5 text-xs',
|
|
||||||
'md' => 'px-3 py-2 text-xs',
|
|
||||||
'lg' => 'px-3.5 py-2.5 text-sm',
|
|
||||||
];
|
|
||||||
|
|
||||||
$classes = 'w-full bg-slate-900 border border-slate-700/60 rounded-lg text-white outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed ' . ($sizeClasses[$size] ?? $sizeClasses['md']);
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<select
|
|
||||||
@if($name) name="{{ $name }}" @endif
|
|
||||||
@if($required) required @endif
|
|
||||||
@if($disabled) disabled @endif
|
|
||||||
{{ $attributes->merge(['class' => $classes]) }}
|
|
||||||
>
|
|
||||||
{{ $slot }}
|
|
||||||
</select>
|
|
||||||
@ -21,7 +21,7 @@
|
|||||||
'focused' => false,
|
'focused' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
<div id="{{ $id }}" {{ $attributes->twMerge(['class' => 'video-tile relative aspect-[16/10] bg-[#0d1220] border-2 rounded-xl overflow-hidden flex flex-col items-center justify-center transition-all duration-200 ' . ($focused ? 'border-[rgba(75,130,247,0.35)] shadow-[0_0_0_1px_rgba(75,130,247,0.35)]' : 'border-[#1b2233]')]) }}>
|
<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)
|
@if($showZoom || $showFullscreen)
|
||||||
<div class="tile-tools absolute top-2 right-2 z-20 flex gap-1">
|
<div class="tile-tools absolute top-2 right-2 z-20 flex gap-1">
|
||||||
@if($showZoom)
|
@if($showZoom)
|
||||||
@ -44,16 +44,16 @@
|
|||||||
@endif
|
@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="{{ $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-8 h-8 rounded-full bg-linear-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-white font-semibold text-xs mb-2.5 shadow-md shadow-[#4b82f7]/20">
|
<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 }}
|
{{ $initials }}
|
||||||
</div>
|
</div>
|
||||||
<div id="{{ $avatarNameId }}" class="name text-xs font-semibold text-white">{{ $avatarTitle }}</div>
|
<div id="{{ $avatarNameId }}" class="name text-[13.5px] font-semibold text-white">{{ $avatarTitle }}</div>
|
||||||
<div class="status text-[8px] text-[#5b637a] mt-1 text-center px-3">
|
<div class="status text-[11.5px] text-[#5b637a] mt-1 text-center px-3">
|
||||||
{{ $slot->isNotEmpty() ? $slot : "Waiting for candidate…" }}
|
{{ $slot->isNotEmpty() ? $slot : "Waiting for candidate…" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="tile-tag absolute left-1 bottom-1 text-[9px] 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 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>
|
<span>{{ $badgeText }}</span>
|
||||||
@if($micStatusId || $camStatusId)
|
@if($micStatusId || $camStatusId)
|
||||||
<span class="w-px h-2.5 bg-white/20 mx-0.5"></span>
|
<span class="w-px h-2.5 bg-white/20 mx-0.5"></span>
|
||||||
|
|||||||
@ -1,40 +1,103 @@
|
|||||||
<x-layouts.guest title="Candidate Assessment Login">
|
<!DOCTYPE html>
|
||||||
<div class="bg-slate-900/80 border border-white/20 rounded-3xl p-8 sm:p-10 w-full max-w-md backdrop-blur-2xl shadow-2xl relative z-10">
|
<html lang="en">
|
||||||
<div class="text-center mb-6">
|
<head>
|
||||||
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-indigo-500 to-sky-600 flex items-center justify-center text-2xl mx-auto mb-3 shadow-lg shadow-indigo-500/40">
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Candidate Assessment Login | 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=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #0b0f19;
|
||||||
|
--card-bg: rgba(15, 23, 42, 0.7);
|
||||||
|
--card-border: rgba(255, 255, 255, 0.22);
|
||||||
|
--accent-primary: #6366f1;
|
||||||
|
--accent-secondary: #0078d4;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Instrument Sans', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -15%;
|
||||||
|
left: -15%;
|
||||||
|
width: 60%;
|
||||||
|
height: 60%;
|
||||||
|
background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, rgba(0,0,0,0) 70%);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 36px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
box-shadow: 0 25px 50px rgba(0,0,0,0.5);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 12px rgba(99, 102, 241, 0.35);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div style="text-align: center; margin-bottom: 24px;">
|
||||||
|
<div style="width: 50px; height: 50px; border-radius: 14px; background: linear-gradient(135deg, #6366f1 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; margin: 0 auto 12px auto;">
|
||||||
⚡
|
⚡
|
||||||
</div>
|
</div>
|
||||||
<h2 class="font-outfit text-2xl font-bold text-white leading-tight">Candidate Assessment Portal</h2>
|
<h2 style="font-family: 'Outfit', sans-serif; font-size: 1.5rem; font-weight: 700; color: white;">Candidate Assessment Portal</h2>
|
||||||
<div class="text-xs text-slate-400 mt-1">Enter temporary login credentials provided by HR</div>
|
<div style="font-size: 0.8rem; color: #94a3b8; margin-top: 4px;">Enter temporary login credentials provided by HR</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(session('error'))
|
@if(session('error'))
|
||||||
<x-alert variant="danger" class="mb-5">
|
<div style="margin-bottom: 16px; background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; padding: 10px 14px; border-radius: 10px; font-size: 0.8rem;">
|
||||||
{{ session('error') }}
|
{{ session('error') }}
|
||||||
</x-alert>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<form action="{{ route('interview.candidate.login.submit') }}" method="POST">
|
<form action="{{ route('interview.candidate.login.submit') }}" method="POST">
|
||||||
@csrf
|
@csrf
|
||||||
<div class="mb-4">
|
<label style="display:block; font-size:0.8rem; color:#cbd5e1; margin-bottom:6px; font-weight:500;">Email or Phone Number</label>
|
||||||
<x-label required>Email or Phone Number</x-label>
|
<input type="text" name="identifier" required placeholder="alex@company.com or 9876543210" class="form-input">
|
||||||
<x-input type="text" name="identifier" required placeholder="alex@company.com or 9876543210" size="lg" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-5">
|
<label style="display:block; font-size:0.8rem; color:#cbd5e1; margin-bottom:6px; font-weight:500;">Temporary Password</label>
|
||||||
<x-label required>Temporary Password</x-label>
|
<input type="password" name="temp_password" required placeholder="e.g. Pass-829103" class="form-input">
|
||||||
<x-input type="password" name="temp_password" required placeholder="e.g. Pass-829103" size="lg" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-button type="submit" variant="primary" size="lg" class="w-full justify-center py-3 bg-gradient-to-r from-indigo-500 to-sky-600 hover:from-indigo-600 hover:to-sky-700 text-white font-bold border-none shadow-lg shadow-indigo-500/30">
|
<button type="submit" style="width: 100%; padding: 12px; border-radius: 10px; background: linear-gradient(135deg, #6366f1 0%, #0078d4 100%); color: white; border: none; font-weight: 700; font-size: 0.95rem; cursor: pointer; margin-top: 10px;">
|
||||||
Enter Proctored Assessment Room
|
Enter Proctored Assessment Room
|
||||||
</x-button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="text-center mt-6">
|
<div style="text-align: center; margin-top: 20px;">
|
||||||
<a href="{{ route('login') }}" class="text-slate-400 hover:text-slate-200 text-xs no-underline transition-colors">
|
<a href="{{ route('login') }}" style="color: #94a3b8; font-size: 0.8rem; text-decoration: none;">← Back to Corporate SSO Login</a>
|
||||||
← Back to Corporate SSO Login
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-layouts.guest>
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -1,40 +1,430 @@
|
|||||||
<x-layouts.candidate
|
<!DOCTYPE html>
|
||||||
:title="'Live Assessment Room - ' . $interview->candidate_name"
|
<html lang="en">
|
||||||
data-interview-id="{{ $interview->id }}"
|
<head>
|
||||||
data-candidate-name="{{ addslashes($interview->candidate_name) }}"
|
<meta charset="UTF-8">
|
||||||
data-candidate-email="{{ $interview->candidate_email }}"
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
data-submission-id="{{ $interview->submission_unique_id }}"
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
data-language="{{ strtolower($interview->language) }}"
|
<title>Live Assessment Room - {{ $interview->candidate_name }} | SingleLogin</title>
|
||||||
data-call-status="{{ $interview->call_status ?? 'idle' }}"
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
data-login-url="{{ route('interview.candidate.login') }}"
|
<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=Fira+Code:wght@400;500;600&family=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- PeerJS, Metered SDK, SweetAlert2 & html2canvas -->
|
||||||
|
<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>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Monaco Code Editor Loader (loaded after UMD libraries to prevent define.amd conflicts) -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #080c14;
|
||||||
|
--card-bg: #0f172a;
|
||||||
|
--card-border: rgba(255, 255, 255, 0.15);
|
||||||
|
--accent-primary: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Custom Scrollbars */
|
||||||
|
html, body, *, ::-webkit-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.4) rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Instrument Sans', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: white;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ide-header {
|
||||||
|
height: 60px;
|
||||||
|
background: #0d1322;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ide-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 340px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--card-border);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#monaco-editor-container {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-section {
|
||||||
|
height: 220px;
|
||||||
|
background: #050811;
|
||||||
|
border-top: 1px solid var(--card-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header {
|
||||||
|
height: 36px;
|
||||||
|
background: #090e1a;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#terminal-output {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #34d399;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proctor-sidebar {
|
||||||
|
background: #0d1322;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcam-box {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
min-height: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid var(--card-border);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webcam {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ide {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: white;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ide-primary {
|
||||||
|
background: #10b981;
|
||||||
|
border-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ide-run {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning Popup Overlay */
|
||||||
|
#warning-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 70px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(239, 68, 68, 0.95);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 10px 30px rgba(239, 68, 68, 0.5);
|
||||||
|
z-index: 9999;
|
||||||
|
display: none;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-interview-id="{{ $interview->id }}" data-candidate-name="{{ addslashes($interview->candidate_name) }}" data-candidate-email="{{ $interview->candidate_email }}" data-submission-id="{{ $interview->submission_unique_id }}" data-language="{{ strtolower($interview->language) }}" data-call-status="{{ $interview->call_status ?? 'idle' }}" data-login-url="{{ route('interview.candidate.login') }}">
|
||||||
|
|
||||||
|
<script id="candidate-submitted-code" type="application/json">@json($interview->submitted_code)</script>
|
||||||
|
<script id="candidate-drawing-data" type="application/json">@json($interview->candidate_drawing)</script>
|
||||||
|
|
||||||
<!-- IDE Header Bar -->
|
<!-- IDE Header Bar -->
|
||||||
<x-interview.candidate.ide-header :interview="$interview" />
|
<header class="ide-header">
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
<!-- Candidate Notepad Modal (Synced) -->
|
<div style="font-weight: 700; font-family: 'Outfit', sans-serif; font-size: 1.05rem; color: white;">
|
||||||
<x-interview.candidate.notepad-modal :notes="$interview->candidate_notes" />
|
Candidate Assessment Room
|
||||||
|
</div>
|
||||||
<!-- Candidate Canvas Drawing Modal -->
|
<code style="background: rgba(255,255,255,0.06); padding: 4px 10px; border-radius: 6px; color: #38bdf8; font-size: 0.75rem;">
|
||||||
<x-interview.candidate.drawing-modal />
|
ID: {{ $interview->submission_unique_id }}
|
||||||
|
</code>
|
||||||
<!-- IDE Main Grid Layout -->
|
|
||||||
<div class="grid grid-cols-[1fr_340px] h-[calc(100vh-60px)] w-full overflow-hidden">
|
|
||||||
<!-- Main Editor & Terminal Area -->
|
|
||||||
<div class="flex flex-col border-r border-white/10 h-full overflow-hidden">
|
|
||||||
<div id="monaco-editor-container" class="flex-1 w-full h-full min-h-0 bg-[#080c14]"></div>
|
|
||||||
|
|
||||||
<x-interview.candidate.terminal />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar: Real-Time 2-Way Interview Call & Proctor Controls -->
|
<div style="display: flex; align-items: center; gap: 14px;">
|
||||||
<x-interview.candidate.proctor-sidebar :interview="$interview" />
|
<button onclick="toggleNotepadModal()" class="btn-ide" style="background: rgba(99,102,241,0.2); border-color: #6366f1;">
|
||||||
|
📝 Open Notepad
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="toggleDrawingModal()" class="btn-ide" style="background: rgba(6,182,212,0.2); border-color: #06b6d4;">
|
||||||
|
🎨 Open Drawing
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<label style="font-size: 0.75rem; color: #94a3b8; font-weight: 500;">Language:</label>
|
||||||
|
<select id="language-select" class="btn-ide" style="background: #151b2c;">
|
||||||
|
<option value="python" {{ strtolower($interview->language) === 'python' ? 'selected' : '' }}>Python 3</option>
|
||||||
|
<option value="cpp" {{ strtolower($interview->language) === 'cpp' ? 'selected' : '' }}>C++ (GCC)</option>
|
||||||
|
<option value="c" {{ strtolower($interview->language) === 'c' ? 'selected' : '' }}>C (GCC)</option>
|
||||||
|
<option value="java" {{ strtolower($interview->language) === 'java' ? 'selected' : '' }}>Java 15</option>
|
||||||
|
<option value="php" {{ strtolower($interview->language) === 'php' ? 'selected' : '' }}>PHP 8.2 / Laravel</option>
|
||||||
|
<option value="javascript" {{ strtolower($interview->language) === 'javascript' ? 'selected' : '' }}>JavaScript (Node.js)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="runCode()" class="btn-ide btn-ide-run">
|
||||||
|
▶ Run Code
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="submitSolution()" class="btn-ide btn-ide-primary">
|
||||||
|
✓ Submit Final Solution
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="candidateLogoutAction()" class="btn-ide" style="background: rgba(239,68,68,0.2); border-color: #ef4444; color: #fca5a5; font-weight: 700;">
|
||||||
|
🚪 Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Candidate Notepad Modal -->
|
||||||
|
<div id="notepad-modal" style="position: fixed; top: 70px; right: 360px; width: 420px; height: 350px; background: #0f172a; border: 1.5px solid #6366f1; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); display: none; flex-direction: column; z-index: 1000; overflow: hidden;">
|
||||||
|
<div style="background: #1e293b; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<div style="font-weight: 700; font-size: 0.85rem; color: white;">📝 Candidate Notepad (Synced)</div>
|
||||||
|
<button onclick="toggleNotepadModal()" style="background: none; border: none; color: #94a3b8; font-size: 1.2rem; cursor: pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="notepad-textarea" oninput="saveNotepadContent()" placeholder="Type rough notes, pseudo-code, or thoughts here..." style="flex: 1; width: 100%; padding: 14px; background: #0b0f19; color: #e2e8f0; border: none; outline: none; font-family: 'Fira Code', monospace; font-size: 0.85rem; resize: none;">{{ $interview->candidate_notes }}</textarea>
|
||||||
|
<div style="padding: 6px 12px; background: #0d1322; font-size: 0.65rem; color: #34d399; text-align: right;">
|
||||||
|
✓ Auto-saved to interviewer portal
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Candidate Incoming Call Ringing Overlay -->
|
<!-- Candidate Canvas Drawing Modal -->
|
||||||
<x-interview.candidate.incoming-overlay />
|
<div id="drawing-modal" style="position: fixed; top: 70px; right: 360px; width: 550px; height: 420px; background: #0f172a; border: 1.5px solid #06b6d4; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); display: none; flex-direction: column; z-index: 1000; overflow: hidden;">
|
||||||
|
<div style="background: #1e293b; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<div style="font-weight: 700; font-size: 0.85rem; color: white;">🎨 Candidate Live Drawing Board</div>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<input type="color" id="draw-color" value="#38bdf8" style="width: 26px; height: 26px; border: none; border-radius: 50%; cursor: pointer;">
|
||||||
|
<button onclick="clearCanvasDraw()" class="btn-ide" style="padding: 2px 8px; font-size: 0.7rem;">Clear</button>
|
||||||
|
<button onclick="toggleDrawingModal()" style="background: none; border: none; color: #94a3b8; font-size: 1.2rem; cursor: pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; background: #050811; position: relative;">
|
||||||
|
<canvas id="drawing-canvas" width="550" height="340" style="cursor: crosshair; display: block; width: 100%; height: 100%;"></canvas>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 6px 12px; background: #0d1322; font-size: 0.65rem; color: #38bdf8; text-align: right;">
|
||||||
|
🎨 Draw architecture / logic diagrams (Auto-synced to Interviewer)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@push('scripts')
|
<!-- IDE Room Layout -->
|
||||||
<script id="candidate-submitted-code" type="application/json">@json($interview->submitted_code)</script>
|
<div class="ide-layout">
|
||||||
<script id="candidate-drawing-data" type="application/json">@json($interview->candidate_drawing)</script>
|
<!-- Main Editor & Terminal Area -->
|
||||||
@endpush
|
<div class="editor-section">
|
||||||
</x-layouts.candidate>
|
<div id="monaco-editor-container"></div>
|
||||||
|
|
||||||
|
<div class="terminal-section">
|
||||||
|
<div class="terminal-header">
|
||||||
|
<span>EXECUTION OUTPUT TERMINAL (STDOUT / STDERR)</span>
|
||||||
|
<span id="run-status" style="color: #94a3b8;">Ready</span>
|
||||||
|
</div>
|
||||||
|
<div id="terminal-output">// Press 'Run Code' to execute candidate solution live...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$words = preg_split('/\s+/', trim($interview->candidate_name));
|
||||||
|
if (count($words) >= 2) {
|
||||||
|
$candInitials = strtoupper(substr($words[0], 0, 1) . substr(end($words), 0, 1));
|
||||||
|
} else {
|
||||||
|
$nameStr = trim($interview->candidate_name);
|
||||||
|
$candInitials = strtoupper(substr($nameStr, 0, 1) . (strlen($nameStr) > 1 ? substr($nameStr, -1) : ''));
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<!-- Sidebar: Real-Time 2-Way Interview Call & Screen Share -->
|
||||||
|
<div class="proctor-sidebar">
|
||||||
|
<!-- Candidate Camera Feed -->
|
||||||
|
<div class="webcam-box" style="position: relative;">
|
||||||
|
<video id="webcam" autoplay muted playsinline style="width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1);"></video>
|
||||||
|
<div id="webcam-avatar-placeholder" style="position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center; background: #0f172a; color: white; z-index: 5;">
|
||||||
|
<div style="width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #6366f1 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 800; font-family: 'Outfit', sans-serif; box-shadow: 0 0 15px rgba(99,102,241,0.4);">
|
||||||
|
{{ $candInitials }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.7rem; color: #94a3b8; margin-top: 6px;">Camera Disabled</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600; z-index: 10; display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span>Candidate (You)</span>
|
||||||
|
<span style="opacity: 0.4;">|</span>
|
||||||
|
<i id="cand-self-mic-icon" class="fa-solid fa-microphone" style="color: #34d399; font-size: 10px;" title="Mic On"></i>
|
||||||
|
<i id="cand-self-cam-icon" class="fa-solid fa-video" style="color: #34d399; font-size: 10px;" title="Camera On"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remote Interviewer Panelist Video Grid (Multi-Party WebRTC Mesh) -->
|
||||||
|
<div id="interviewer-mesh-grid" style="display: flex; flex-direction: column; gap: 10px; width: 100%;">
|
||||||
|
<div id="interviewer-placeholder-box" class="webcam-box" style="position: relative; border-color: #6366f1;">
|
||||||
|
<video id="interviewer-video-default" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811; display: none; transform: scaleX(-1);"></video>
|
||||||
|
<div id="interviewer-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; text-align: center; padding: 10px; z-index: 5;">
|
||||||
|
<div id="interviewer-avatar-circle" style="width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 800; font-family: 'Outfit', sans-serif; color: white; margin-bottom: 6px; box-shadow: 0 0 15px rgba(56,189,248,0.4);">
|
||||||
|
IV
|
||||||
|
</div>
|
||||||
|
<div id="call-status-sub" style="font-size: 0.7rem; color: #e2e8f0; font-weight: 600;">Interviewer Panel</div>
|
||||||
|
<div style="font-size: 0.65rem; color: #38bdf8; margin-top: 2px;">Waiting for interviewer panelist to join call...</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10;">
|
||||||
|
🎙️ Interviewer / Panelist
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<canvas id="proctor-canvas" style="display: none;" width="320" height="240"></canvas>
|
||||||
|
|
||||||
|
<!-- Candidate Profile Info -->
|
||||||
|
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--card-border); border-radius: 12px; padding: 14px; font-size: 0.8rem;">
|
||||||
|
<div style="font-weight: 700; color: white; margin-bottom: 6px;">Candidate Profile</div>
|
||||||
|
<div style="color: #94a3b8;">Name: <strong style="color: white;">{{ $interview->candidate_name }}</strong></div>
|
||||||
|
<div style="color: #94a3b8;">Email: <span style="color: white;">{{ $interview->candidate_email }}</span></div>
|
||||||
|
<div style="color: #94a3b8; margin-top: 6px;">Access Expires: <span style="color: #fca5a5;">{{ $interview->expires_at->format('H:i:s T') }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real-Time Call Controls (Mic, Camera, Screen Sharing) -->
|
||||||
|
<div style="background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.3); border-radius: 14px; padding: 14px;">
|
||||||
|
<div style="font-size: 0.8rem; font-weight: 700; color: white; margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<span>📞 Real-Time Interview Call</span>
|
||||||
|
<span id="call-status-badge" style="font-size: 0.65rem; background: rgba(16,185,129,0.2); color: #34d399; padding: 2px 8px; border-radius: 999px;">READY</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="candidate-join-active-call-btn" onclick="acceptCandidateCall()" class="btn-ide" style="display: none; width: 100%; padding: 10px; margin-bottom: 10px; font-size: 0.8rem; font-weight: 700; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; border: none; box-shadow: 0 0 15px rgba(16,185,129,0.4); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
|
||||||
|
🟢 📞 Call in Progress • Join Call Now
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 10px;">
|
||||||
|
<button id="toggle-mic-btn" onclick="toggleMic()" class="btn-ide" style="flex: 1; padding: 8px; font-size: 0.75rem; background: rgba(16,185,129,0.2); border-color: #10b981;">
|
||||||
|
🎤 Mic On
|
||||||
|
</button>
|
||||||
|
<button id="toggle-cam-btn" onclick="toggleCam()" class="btn-ide" style="flex: 1; padding: 8px; font-size: 0.75rem; background: rgba(16,185,129,0.2); border-color: #10b981;">
|
||||||
|
📷 Cam On
|
||||||
|
</button>
|
||||||
|
<button id="leave-call-btn" onclick="candidateLeaveCall()" class="btn-ide" style="padding: 8px; font-size: 0.75rem; background: rgba(239,68,68,0.2); border-color: #ef4444; color: #fca5a5;" title="Leave 2-Way Call">
|
||||||
|
🚪 Leave Call
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="share-screen-btn" onclick="startScreenShare()" class="btn-ide btn-ide-run" style="width: 100%; padding: 10px; font-size: 0.8rem; background: linear-gradient(135deg, #6366f1 0%, #0078d4 100%);">
|
||||||
|
🖥️ Share Screen with Interviewer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2-Way Real-Time Chat Container with Interviewer/Panelist -->
|
||||||
|
<div style="background: rgba(15,23,42,0.8); border: 1px solid var(--card-border); border-radius: 14px; padding: 12px; margin-top: 10px;">
|
||||||
|
<div style="font-size: 0.8rem; font-weight: 700; color: white; margin-bottom: 8px; display: flex; align-items: center; gap: 6px;">
|
||||||
|
💬 Live Chat with Interviewer
|
||||||
|
</div>
|
||||||
|
<div id="candidate-chat-history" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 8px; height: 130px; overflow-y: auto; font-size: 0.72rem; margin-bottom: 8px;">
|
||||||
|
<div style="color: #94a3b8; font-style: italic; text-align: center; padding-top: 40px;">No messages yet. Send a message below.</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 6px;">
|
||||||
|
<input type="text" id="candidate-chat-input" placeholder="Type message to panelist..." onkeydown="if(event.key==='Enter') sendCandidateMessage()" class="form-input" style="font-size: 0.75rem; background: #0b0f19; color: #ffffff !important; border: 1px solid var(--card-border); padding: 6px 10px; flex: 1;">
|
||||||
|
<button onclick="sendCandidateMessage()" class="btn-ide" style="padding: 6px 10px; font-size: 0.75rem; background: #6366f1; border: none; font-weight: 600;">
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CANDIDATE INCOMING CALL MODAL / RINGING OVERLAY -->
|
||||||
|
<div id="candidate-incoming-modal" style="display: flex; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px); align-items: center; justify-content: center; z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;">
|
||||||
|
|
||||||
|
<div style="max-width: 440px; width: 90%; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #10b981; border-radius: 24px; padding: 32px; text-align: center; color: white; box-shadow: 0 0 50px rgba(16, 185, 129, 0.6);">
|
||||||
|
<div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<div style="position: absolute; inset: -15px; border-radius: 50%; border: 2px solid rgba(16, 185, 129, 0.6); animation: ringPulse 1.4s infinite ease-out;"></div>
|
||||||
|
<div style="position: absolute; inset: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
|
||||||
|
<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 2.4rem; font-weight: 800; color: white; box-shadow: 0 10px 25px rgba(16, 185, 129, 0.5); z-index: 5;">
|
||||||
|
📞
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
|
||||||
|
⚡ INCOMING INTERVIEW CALL
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.4rem; font-weight: 800; color: white; margin-bottom: 4px;">
|
||||||
|
Interviewer Panel Calling...
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="font-size: 0.8rem; color: #a5b4fc; margin-bottom: 20px;">
|
||||||
|
The interviewer panel has initiated a live 2-way call.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px; margin-top: 10px;">
|
||||||
|
<button onclick="declineCandidateCall()" style="flex: 1; padding: 14px; background: rgba(239, 68, 68, 0.2); border: 1.5px solid #ef4444; color: #fca5a5; font-weight: 700; font-size: 0.9rem; border-radius: 14px; cursor: pointer;">
|
||||||
|
🛑 Decline / Busy
|
||||||
|
</button>
|
||||||
|
<button onclick="acceptCandidateCall()" style="flex: 1.4; padding: 14px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 0.95rem; border-radius: 14px; box-shadow: 0 0 25px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
|
||||||
|
📞 Accept Call
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -1,12 +1,44 @@
|
|||||||
<x-layouts.guest title="Assessment Access Expired">
|
<!DOCTYPE html>
|
||||||
<div class="bg-slate-900/80 border border-red-500/30 rounded-3xl p-8 sm:p-10 text-center max-w-md w-full backdrop-blur-2xl shadow-2xl relative z-10">
|
<html lang="en">
|
||||||
<div class="text-5xl mb-4">⌛</div>
|
<head>
|
||||||
<h2 class="font-outfit text-2xl font-bold text-red-400 mb-2">Assessment Access Expired</h2>
|
<meta charset="UTF-8">
|
||||||
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
The temporary access window for <strong class="text-white">{{ $interview->candidate_name }}</strong> expired on {{ $interview->expires_at->format('Y-m-d H:i T') }}.
|
<title>Assessment Access Expired | 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=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Instrument Sans', sans-serif;
|
||||||
|
background-color: #0b0f19;
|
||||||
|
color: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 36px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 450px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: 16px;">⌛</div>
|
||||||
|
<h2 style="font-family: 'Outfit', sans-serif; font-size: 1.4rem; color: #ef4444; margin-bottom: 8px;">Assessment Access Expired</h2>
|
||||||
|
<p style="font-size: 0.85rem; color: #94a3b8; margin-bottom: 20px;">
|
||||||
|
The temporary access window for <strong>{{ $interview->candidate_name }}</strong> expired on {{ $interview->expires_at->format('Y-m-d H:i T') }}.
|
||||||
</p>
|
</p>
|
||||||
<div class="text-xs text-slate-300 bg-white/5 border border-white/10 p-3 rounded-xl font-mono">
|
<div style="font-size: 0.8rem; color: #cbd5e1; background: rgba(255,255,255,0.05); padding: 10px; border-radius: 8px;">
|
||||||
Unique Submission ID: <code class="text-sky-400 font-bold">{{ $interview->submission_unique_id }}</code>
|
Unique Submission ID: <code style="color: #38bdf8;">{{ $interview->submission_unique_id }}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-layouts.guest>
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -1,48 +1,395 @@
|
|||||||
<x-layouts.interview title="Candidate Live Assessment Portal">
|
<!DOCTYPE html>
|
||||||
<!-- Global Admin Settings & Proctoring Control Panel -->
|
<html lang="en">
|
||||||
<x-interview.admin-settings-card />
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Candidate Live Assessment Portal | 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=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#0b0f19] text-white min-h-screen flex flex-col font-['Instrument_Sans',sans-serif]">
|
||||||
|
|
||||||
<!-- Page Header Row -->
|
<!-- Header Navbar -->
|
||||||
<div class="flex justify-between items-center mb-6">
|
<header class="h-[70px] bg-slate-900/80 backdrop-blur-md border-b border-slate-700/60 flex items-center justify-between px-6 sticky top-0 z-50">
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="font-outfit text-2xl font-bold text-white m-0">Live Interview Assessments</h2>
|
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-500 to-sky-600 flex items-center justify-center font-bold text-white shadow-md shadow-indigo-500/30">
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Temporary Candidate Access, Real-Time Proctored IDE, and Unique Code Records</div>
|
<i class="fa-solid fa-bolt text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="font-outfit text-lg font-bold text-white m-0">Candidate Live Assessment Portal</h1>
|
||||||
|
<div class="text-xs text-slate-400">Proctored IDE & Code Submissions</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-button onclick="document.getElementById('create-interview-drawer').classList.add('active')" variant="primary" size="md" icon="fa-solid fa-plus">
|
<div class="flex items-center gap-3">
|
||||||
Schedule Candidate Assessment
|
@if(Auth::user()->isAdmin())
|
||||||
</x-button>
|
<a href="{{ route('admin.dashboard') }}">
|
||||||
</div>
|
<x-button variant="ghost" size="sm" icon="fa-solid fa-sliders" class="text-indigo-400 border-indigo-500/40">Control Panel</x-button>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
<a href="{{ route('onboarding.index') }}">
|
||||||
|
<x-button variant="secondary" size="sm" icon="fa-solid fa-rocket">Onboarding Hub</x-button>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('dashboard') }}">
|
||||||
|
<x-button variant="secondary" size="sm" icon="fa-solid fa-user">User Portal</x-button>
|
||||||
|
</a>
|
||||||
|
<form action="{{ route('logout') }}" method="POST" id="logout-form" class="inline">
|
||||||
|
@csrf
|
||||||
|
<x-button type="button" onclick="interviewerLogoutAction()" variant="danger" size="sm" icon="fa-solid fa-right-from-bracket" class="bg-red-500/20 text-red-300 border-red-500/40 font-semibold">
|
||||||
|
Logout
|
||||||
|
</x-button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Assessment Table Card -->
|
<main class="w-full mx-auto my-6 px-7 flex-1">
|
||||||
<x-card class="pt-4">
|
@if(session('success'))
|
||||||
<div class="overflow-x-auto w-full">
|
<div class="mb-6 bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 px-5 py-3 rounded-xl text-sm">
|
||||||
<table class="w-full text-left text-xs border-collapse">
|
{{ session('success') }}
|
||||||
<thead>
|
</div>
|
||||||
<tr class="border-b border-slate-700/60 text-slate-400 uppercase text-[0.7rem] tracking-wider">
|
@endif
|
||||||
<th class="pb-4 px-4">Candidate & Temp Pass</th>
|
|
||||||
<th class="pb-4 px-4">Unique Submission ID</th>
|
<!-- Global Admin Settings & Proctoring Control Panel -->
|
||||||
<th class="pb-4 px-4">Status & Validity</th>
|
<section class="mb-6 p-5 bg-linear-to-r from-slate-900/95 to-slate-800/95 border border-indigo-500/35 rounded-2xl shadow-xl">
|
||||||
<th class="pb-4 px-4">Language</th>
|
<div class="flex justify-between items-center pb-3 mb-4 border-b border-white/10">
|
||||||
<th class="pb-4 px-4">Proctoring Alerts</th>
|
<div>
|
||||||
<th class="pb-4 px-4 text-right">Review & Actions</th>
|
<h3 class="font-outfit text-xl font-bold text-white m-0 flex items-center gap-2">
|
||||||
</tr>
|
<i class="fa-solid fa-gear text-indigo-400"></i> Global Admin Proctoring & System Settings
|
||||||
</thead>
|
</h3>
|
||||||
<tbody class="divide-y divide-white/5">
|
<div class="text-xs text-slate-400 mt-1">Master control panel for candidate assessment rules, system screen capturing, and module toggles</div>
|
||||||
@forelse($interviews as $inv)
|
</div>
|
||||||
<x-interview.session-row :interview="$inv" />
|
<x-pill variant="primary" size="md" icon="fa-solid fa-shield-halved">
|
||||||
@empty
|
ADMIN CONTROL PANEL
|
||||||
<tr>
|
</x-pill>
|
||||||
<td colspan="6" class="text-center text-slate-400 py-8">
|
</div>
|
||||||
No candidate interview sessions found. Click "+ Schedule Candidate Assessment" above to create one.
|
|
||||||
</td>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- 1. Candidate System Screenshot Capture Master Toggle -->
|
||||||
|
<div class="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-xl">
|
||||||
|
<form action="{{ route('admin.settings.screenshot-toggle') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-sm text-white font-bold flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-camera text-emerald-400"></i> Candidate System Screenshot Capture
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||||
|
Captures candidate system desktop screen automatically on call start & tab switches. Admin can stop taking screenshots anytime across all candidates.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center pt-2.5 border-t border-white/10">
|
||||||
|
<span id="global-screenshot-status-text" class="text-xs font-bold {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'text-emerald-400' : 'text-red-400' }}">
|
||||||
|
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🟢 ENABLED (Active)' : '🔴 STOPPED / DISABLED' }}
|
||||||
|
</span>
|
||||||
|
@if(Auth::user() && Auth::user()->isAdmin())
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="enable_candidate_screenshots" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'checked' : '' }} class="accent-emerald-500 w-5 h-5 cursor-pointer">
|
||||||
|
<span class="text-xs font-bold text-white bg-white/10 px-3 py-1 rounded-md hover:bg-white/20 transition-colors">
|
||||||
|
{{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'Stop Screenshot Capture' : 'Enable Screenshot Capture' }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Onboarding & Offboarding Module Master Toggle -->
|
||||||
|
<div class="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-xl">
|
||||||
|
<form action="{{ route('admin.settings.onboarding-toggle') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-sm text-white font-bold flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-rocket text-indigo-400"></i> Candidate Provisioning & Onboarding Module
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||||
|
Master toggle to enable/disable automated candidate provisioning, onboarding checklists, and 1-click revocation.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center pt-2.5 border-t border-white/10">
|
||||||
|
<span class="text-xs font-bold {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'text-emerald-400' : 'text-red-400' }}">
|
||||||
|
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '🟢 ENABLED' : '🔴 DISABLED' }}
|
||||||
|
</span>
|
||||||
|
@if(Auth::user() && Auth::user()->isAdmin())
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="onboarding_enabled" value="1" onchange="this.form.submit()" {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'checked' : '' }} class="accent-emerald-500 w-5 h-5 cursor-pointer">
|
||||||
|
<span class="text-xs font-bold text-white bg-white/10 px-3 py-1 rounded-md hover:bg-white/20 transition-colors">
|
||||||
|
{{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'Disable Onboarding' : 'Enable Onboarding' }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Page Header Row -->
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-outfit text-2xl font-bold text-white m-0">Live Interview Assessments</h2>
|
||||||
|
<div class="text-xs text-slate-400 mt-0.5">Temporary Candidate Access, Real-Time Proctored IDE, and Unique Code Records</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-button onclick="document.getElementById('create-interview-modal').classList.add('active')" variant="primary" size="md" icon="fa-solid fa-plus">
|
||||||
|
Schedule Candidate Interview
|
||||||
|
</x-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assessment Table Card -->
|
||||||
|
<div class="bg-slate-900/80 border border-slate-700/60 rounded-2xl p-6 shadow-2xl backdrop-blur-md">
|
||||||
|
<div class="overflow-x-auto w-full">
|
||||||
|
<table class="w-full text-left text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-700/60 text-slate-400 uppercase text-[0.7rem] tracking-wider">
|
||||||
|
<th class="py-3 px-4">Candidate & Temp Pass</th>
|
||||||
|
<th class="py-3 px-4">Unique Submission ID</th>
|
||||||
|
<th class="py-3 px-4">Status & Validity</th>
|
||||||
|
<th class="py-3 px-4">Language</th>
|
||||||
|
<th class="py-3 px-4">Proctoring Alerts</th>
|
||||||
|
<th class="py-3 px-4 text-right">Review & Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@endforelse
|
</thead>
|
||||||
</tbody>
|
<tbody class="divide-y divide-white/5">
|
||||||
</table>
|
@forelse($interviews as $inv)
|
||||||
</div>
|
@php
|
||||||
</x-card>
|
$logs = $inv->proctor_logs ?? [];
|
||||||
|
$tabSwitches = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'tab_switch'));
|
||||||
|
$focusLost = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'focus_lost'));
|
||||||
|
$gazeAlerts = count(array_filter($logs, fn($l) => in_array($l['type'] ?? '', ['gaze_anomaly', 'gaze_fixed_staring'])));
|
||||||
|
$lowerGaze = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'gaze_lower_device'));
|
||||||
|
$questionRepeat = count(array_filter($logs, fn($l) => in_array($l['type'] ?? '', ['question_repeat_lower', 'question_repetition'])));
|
||||||
|
$externalAi = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'external_ai_detected'));
|
||||||
|
@endphp
|
||||||
|
<tr class="hover:bg-white/[0.02] transition-colors">
|
||||||
|
<td class="py-3.5 px-4">
|
||||||
|
<div>
|
||||||
|
<strong class="text-white text-sm block">{{ $inv->candidate_name }}</strong>
|
||||||
|
<div class="text-xs text-slate-400 mt-0.5">{{ $inv->candidate_email }} • {{ $inv->candidate_phone }}</div>
|
||||||
|
<div class="mt-1.5 flex flex-col gap-1">
|
||||||
|
<div class="text-[0.7rem] text-emerald-300 bg-emerald-500/10 px-2 py-0.5 rounded inline-block w-fit">
|
||||||
|
<i class="fa-solid fa-key text-[0.65rem] mr-1"></i> Temp Pass: <strong>{{ $inv->temp_password }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="text-[0.7rem] text-sky-400">
|
||||||
|
<i class="fa-solid fa-globe text-[0.65rem] mr-1"></i> Login URL: <a href="{{ route('interview.candidate.login') }}" target="_blank" class="text-sky-400 underline">{{ route('interview.candidate.login') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3.5 px-4">
|
||||||
|
<code class="bg-white/5 px-2 py-1 rounded text-sky-400 text-xs font-mono">
|
||||||
|
{{ $inv->submission_unique_id }}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td class="py-3.5 px-4">
|
||||||
|
@if($inv->isExpired())
|
||||||
|
<x-pill variant="danger" size="sm">EXPIRED</x-pill>
|
||||||
|
@elseif($inv->status === 'completed')
|
||||||
|
<x-pill variant="success" size="sm">COMPLETED</x-pill>
|
||||||
|
@elseif($inv->call_status === 'active')
|
||||||
|
<x-pill variant="success" size="sm" icon="fa-solid fa-phone" class="animate-pulse font-extrabold">CALL IN PROGRESS</x-pill>
|
||||||
|
@elseif($inv->isActiveTimeline())
|
||||||
|
<x-pill variant="info" size="sm" icon="fa-solid fa-bolt">TIMELINE LIVE</x-pill>
|
||||||
|
@else
|
||||||
|
<x-pill variant="warning" size="sm">SCHEDULED</x-pill>
|
||||||
|
@endif
|
||||||
|
<div class="text-[0.7rem] text-slate-400 mt-1">
|
||||||
|
Timeline: {{ ($inv->scheduled_at ?? $inv->created_at)->format('M d, H:i') }} – {{ $inv->expires_at->format('H:i') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3.5 px-4">
|
||||||
|
<x-pill variant="ghost" size="sm" class="uppercase font-bold">
|
||||||
|
{{ $inv->language }}
|
||||||
|
</x-pill>
|
||||||
|
</td>
|
||||||
|
<td class="py-3.5 px-4">
|
||||||
|
<div class="flex flex-col gap-0.5 text-xs">
|
||||||
|
<span class="{{ $tabSwitches > 0 ? 'text-red-400 font-bold' : 'text-slate-400' }}">
|
||||||
|
<i class="fa-solid fa-circle text-[0.5rem] mr-1 text-red-500"></i> Tab Switches: {{ $tabSwitches }}
|
||||||
|
</span>
|
||||||
|
<span class="{{ $focusLost > 0 ? 'text-amber-400 font-semibold' : 'text-slate-400' }}">
|
||||||
|
<i class="fa-solid fa-circle text-[0.5rem] mr-1 text-amber-500"></i> Focus Loss / Overlay: {{ $focusLost }}
|
||||||
|
</span>
|
||||||
|
<span class="{{ $gazeAlerts > 0 ? 'text-rose-400 font-semibold' : 'text-slate-400' }}">
|
||||||
|
<i class="fa-solid fa-eye mr-1"></i> Eye Gaze Anomaly: {{ $gazeAlerts }}
|
||||||
|
</span>
|
||||||
|
@if($lowerGaze > 0)
|
||||||
|
<span class="text-red-400 font-bold">
|
||||||
|
<i class="fa-solid fa-mobile-screen mr-1"></i> Lower Gaze (>10s): {{ $lowerGaze }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@if($questionRepeat > 0)
|
||||||
|
<span class="text-rose-400 font-bold">
|
||||||
|
<i class="fa-solid fa-comments mr-1"></i> Question Repetition: {{ $questionRepeat }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@if($externalAi > 0)
|
||||||
|
<span class="text-red-400 font-extrabold bg-red-500/20 px-1.5 py-0.5 rounded">
|
||||||
|
<i class="fa-solid fa-robot mr-1"></i> Parakeet / External AI: {{ $externalAi }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- Review & Actions: EXACTLY 2 BUTTONS USING FONT-AWESOME ICONS -->
|
||||||
|
<td class="py-3.5 px-4 text-right whitespace-nowrap">
|
||||||
|
<div class="inline-flex items-center gap-2 justify-end">
|
||||||
|
<!-- Button 1: Report Page -->
|
||||||
|
<a href="{{ route('interview.report', $inv->id) }}" target="_blank">
|
||||||
|
<x-button variant="success" size="sm" icon="fa-solid fa-file-pdf" class="bg-emerald-500/15 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/25">
|
||||||
|
PDF Report
|
||||||
|
</x-button>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Drawer: Schedule Candidate Assessment -->
|
<!-- Button 2: Join Call -->
|
||||||
<x-interview.schedule-drawer :interviewers="$interviewers" />
|
@if($inv->call_status === 'active')
|
||||||
</x-layouts.interview>
|
<a href="{{ route('interview.show', $inv->id) }}?join_call=1">
|
||||||
|
<x-button variant="success" size="sm" icon="fa-solid fa-phone-volume" class="animate-pulse font-extrabold shadow-md shadow-emerald-500/50">
|
||||||
|
Join Active Call
|
||||||
|
</x-button>
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('interview.show', $inv->id) }}">
|
||||||
|
<x-button variant="primary" size="sm" icon="fa-solid fa-video" class="bg-indigo-500/15 border-indigo-500/30 text-indigo-300 hover:bg-indigo-500/25">
|
||||||
|
Join Call
|
||||||
|
</x-button>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-slate-400 py-8">
|
||||||
|
No candidate interview sessions found. Click "+ Schedule Candidate Interview" above to create one.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Modal: Schedule Candidate Interview -->
|
||||||
|
<x-modal id="create-interview-modal" title="Schedule Candidate Assessment" maxWidth="lg">
|
||||||
|
<form action="{{ route('interview.store') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-3.5">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-400 mb-1">Candidate Name <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" name="candidate_name" required placeholder="e.g. John Doe" class="w-full px-3 py-2 bg-white/5 border border-slate-700/60 rounded-lg text-white text-xs outline-none focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-400 mb-1">Candidate Email <span class="text-red-500">*</span></label>
|
||||||
|
<input type="email" name="candidate_email" required placeholder="john@gmail.com" class="w-full px-3 py-2 bg-white/5 border border-slate-700/60 rounded-lg text-white text-xs outline-none focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3.5">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-400 mb-1">Phone Number <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" name="candidate_phone" required placeholder="9876543210" class="w-full px-3 py-2 bg-white/5 border border-slate-700/60 rounded-lg text-white text-xs outline-none focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-400 mb-1">Start Time (Timeline)</label>
|
||||||
|
<input type="datetime-local" name="scheduled_at" value="{{ now()->format('Y-m-d\TH:i') }}" class="w-full px-3 py-2 bg-slate-900 border border-slate-700/60 rounded-lg text-white text-xs outline-none focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-400 mb-1">Access Duration <span class="text-red-500">*</span></label>
|
||||||
|
<select name="valid_hours" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700/60 rounded-lg text-white text-xs outline-none focus:border-indigo-500">
|
||||||
|
<option value="1">1 Hour</option>
|
||||||
|
<option value="2" selected>2 Hours</option>
|
||||||
|
<option value="4">4 Hours</option>
|
||||||
|
<option value="12">12 Hours</option>
|
||||||
|
<option value="24">24 Hours</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3.5">
|
||||||
|
<label class="block text-xs font-medium text-slate-400 mb-1">Coding Language <span class="text-red-500">*</span></label>
|
||||||
|
<select name="language" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700/60 rounded-lg text-white text-xs outline-none focus:border-indigo-500">
|
||||||
|
<option value="python">Python 3</option>
|
||||||
|
<option value="cpp">C++ (GCC)</option>
|
||||||
|
<option value="c">C (GCC)</option>
|
||||||
|
<option value="java">Java 15</option>
|
||||||
|
<option value="php">PHP 8.2 / Laravel</option>
|
||||||
|
<option value="javascript">JavaScript (Node.js)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex justify-between items-center mb-1.5">
|
||||||
|
<label class="text-xs font-medium text-slate-400">Assign Internal Interviewers / Panelists</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[120px] overflow-y-auto bg-black/30 p-2.5 rounded-lg border border-slate-700/60">
|
||||||
|
@foreach($interviewers as $usr)
|
||||||
|
<label class="flex items-center gap-2 text-xs text-white cursor-pointer hover:text-indigo-300">
|
||||||
|
<input type="checkbox" name="assigned_interviewers[]" value="{{ $usr->id }}" class="accent-indigo-500">
|
||||||
|
{{ $usr->name }} ({{ $usr->email }})
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2.5 mt-5">
|
||||||
|
<x-button type="button" onclick="document.getElementById('create-interview-modal').classList.remove('active')" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</x-button>
|
||||||
|
<x-button type="submit" variant="primary" icon="fa-solid fa-check">
|
||||||
|
Create Session & Password
|
||||||
|
</x-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</x-modal>
|
||||||
|
|
||||||
|
<!-- Incoming Call Ring Modal -->
|
||||||
|
<x-modal id="incoming-call-modal" maxWidth="md">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<div class="relative w-20 h-20 mx-auto mb-5 flex items-center justify-center">
|
||||||
|
<div class="absolute -inset-3 rounded-full border-2 border-emerald-500/60 animate-ping"></div>
|
||||||
|
<div class="absolute -inset-6 rounded-full border-2 border-indigo-500/40 animate-pulse"></div>
|
||||||
|
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-sky-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg shadow-emerald-500/50 z-10">
|
||||||
|
<i class="fa-solid fa-phone"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-[11px] uppercase tracking-widest text-emerald-400 font-bold mb-1.5">
|
||||||
|
⚡ Incoming Assessment Call
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 id="incoming-cand-name" class="font-outfit text-2xl font-extrabold text-white mb-1">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.MAX_INTERVIEWERS = {{ config('interview.max_interviewer', 4) }};
|
||||||
|
window.currentUserId = {{ Auth::id() }};
|
||||||
|
window.currentUserName = "{{ addslashes(Auth::user()->name) }}";
|
||||||
|
window.currentUserRole = "{{ Auth::user()->is_admin ? 'admin' : 'interviewer' }}";
|
||||||
|
window.csrfToken = "{{ csrf_token() }}";
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -1,53 +1,451 @@
|
|||||||
<x-layouts.report :title="'Executive Evaluation Report - ' . $interview->candidate_name">
|
<!DOCTYPE html>
|
||||||
<!-- Floating Quick Print Button -->
|
<html lang="en">
|
||||||
<button type="button" onclick="window.print()" class="fixed bottom-7 right-7 bg-indigo-600 hover:bg-indigo-700 text-white font-bold text-sm py-3.5 px-7 rounded-full shadow-2xl shadow-indigo-600/50 flex items-center gap-2 z-50 cursor-pointer print:hidden transition-all">
|
<head>
|
||||||
<i class="fa-solid fa-download"></i>
|
<meta charset="UTF-8">
|
||||||
<span>Download / Print Executive PDF Report</span>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Executive Evaluation Report - {{ $interview->candidate_name }} | SingleLogin</title>
|
||||||
|
<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=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700;800&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #0f172a;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--text-dark: #0f172a;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--card-bg: #f8fafc;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Instrument Sans', sans-serif;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: var(--text-dark);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,0.08);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-badge {
|
||||||
|
background: rgba(99,102,241,0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item span {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-val {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-table th {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-table td {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-box {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #38bdf8;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-box {
|
||||||
|
background: #050811;
|
||||||
|
color: #34d399;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 30px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(99,102,241,0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.report-container {
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.print-btn, .top-download-pdf-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<button onclick="window.print()" class="print-btn">
|
||||||
|
📥 Download / Print Executive PDF Report
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="max-w-[900px] mx-auto bg-white rounded-2xl shadow-xl border border-slate-200 p-6 sm:p-10 relative print:shadow-none print:border-none print:p-0 print:max-w-full">
|
<div class="report-container">
|
||||||
<!-- Report Header -->
|
<!-- Top Bar with Download PDF Button -->
|
||||||
<x-interview.report.header :interview="$interview" />
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border);">
|
||||||
|
<div style="font-size: 0.85rem; font-weight: 700; color: var(--primary); display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span>📄 Executive Assessment Performance Report</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="window.print()" class="top-download-pdf-btn" style="background: #6366f1; color: white; border: none; font-weight: 700; font-size: 0.85rem; padding: 10px 20px; border-radius: 10px; cursor: pointer; display: flex; align-items: center; gap: 8px; box-shadow: 0 4px 14px rgba(99,102,241,0.3);">
|
||||||
|
📥 Download PDF Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Candidate Meta Grid -->
|
<!-- Header -->
|
||||||
<x-interview.report.meta-grid :interview="$interview" />
|
<div class="header-bar">
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">
|
||||||
|
⚡ SingleLogin Enterprise Assessment
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 4px;">
|
||||||
|
Candidate Technical Competency & Behavioral Audit Report
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<span class="report-badge">Executive Summary</span>
|
||||||
|
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 6px;">
|
||||||
|
Report ID: RPT-{{ $interview->submission_unique_id }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.7rem; color: var(--text-muted);">
|
||||||
|
Date: {{ now()->format('M d, Y - H:i:s T') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta Grid -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div class="meta-item">
|
||||||
|
<label>Candidate Name</label>
|
||||||
|
<span>{{ $interview->candidate_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<label>Email Address</label>
|
||||||
|
<span style="font-size: 0.8rem;">{{ $interview->candidate_email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<label>Language & Tech</label>
|
||||||
|
<span>{{ strtoupper($interview->language) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<label>Assessment Status</label>
|
||||||
|
<span style="color: {{ $interview->status === 'completed' ? '#10b981' : ($interview->status === 'blocked' ? '#ef4444' : '#6366f1') }};">
|
||||||
|
{{ strtoupper($interview->status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Scores Summary Grid -->
|
<!-- Scores Summary Grid -->
|
||||||
<x-interview.report.score-card
|
<div class="score-grid">
|
||||||
:integrityScore="$integrityScore"
|
<!-- Behavioral Integrity Score -->
|
||||||
:integrityLabel="$integrityLabel"
|
<div class="score-card" style="border-left: 5px solid {{ $integrityColor }};">
|
||||||
:integrityColor="$integrityColor"
|
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">
|
||||||
:incidentCount="is_array($logs) ? count($logs) : 0"
|
🛡️ Behavioral Integrity Rating
|
||||||
:codeScore="$codeScore"
|
</div>
|
||||||
:hasSubmittedCode="!empty($interview->submitted_code)"
|
<div class="score-val" style="color: {{ $integrityColor }};">
|
||||||
/>
|
{{ $integrityScore }}%
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.8rem; font-weight: 700; color: {{ $integrityColor }}; margin-top: 4px;">
|
||||||
|
{{ $integrityLabel }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 6px;">
|
||||||
|
Recorded Violations: <strong>{{ count($logs) }} incident(s)</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technical Score -->
|
||||||
|
<div class="score-card" style="border-left: 5px solid #6366f1;">
|
||||||
|
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">
|
||||||
|
💻 Technical Code Competency
|
||||||
|
</div>
|
||||||
|
<div class="score-val" style="color: #6366f1;">
|
||||||
|
{{ $codeScore }} / 100
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.8rem; font-weight: 700; color: #6366f1; margin-top: 4px;">
|
||||||
|
{{ $codeScore >= 80 ? 'Strong Technical Match' : ($codeScore >= 50 ? 'Moderate Competency' : 'Needs Further Review') }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 6px;">
|
||||||
|
Solution Code: <strong>{{ !empty($interview->submitted_code) ? 'Submitted' : 'Pending' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Proctoring Violation Metrics & Breakdown -->
|
<!-- Proctoring Violation Metrics & Breakdown -->
|
||||||
<x-interview.report.incident-grid
|
<div class="section-heading">
|
||||||
:metrics="[
|
🔍 Behavioral Audit & Proctoring Incident Breakdown
|
||||||
'tab_switches' => $tabSwitches,
|
</div>
|
||||||
'focus_lost' => $focusLosses,
|
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 20px;">
|
||||||
'gaze_alerts' => $gazeAnomalies,
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
'paste_events' => $pasteEvents,
|
<div style="font-size: 1.1rem; font-weight: 800; color: #ef4444;">{{ $tabSwitches }}</div>
|
||||||
'lower_gaze' => $lowerGazeViolations,
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Tab Switches</div>
|
||||||
'question_repeat' => $questionRepeatViolations,
|
</div>
|
||||||
]"
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
/>
|
<div style="font-size: 1.1rem; font-weight: 800; color: #f59e0b;">{{ $focusLosses }}</div>
|
||||||
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Focus Lost</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
|
<div style="font-size: 1.1rem; font-weight: 800; color: #38bdf8;">{{ $gazeAnomalies }}</div>
|
||||||
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Gaze Anomalies</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
|
<div style="font-size: 1.1rem; font-weight: 800; color: #818cf8;">{{ $pasteEvents }}</div>
|
||||||
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Code Pastes</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
|
<div style="font-size: 1.1rem; font-weight: 800; color: #ef4444;">{{ $lowerGazeViolations }}</div>
|
||||||
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">📱 Lower Gaze</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
|
<div style="font-size: 1.1rem; font-weight: 800; color: #f43f5e;">{{ $questionRepeatViolations }}</div>
|
||||||
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">🗣️ Question Repeat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Audit Log Table -->
|
@if(is_array($logs) && count($logs) > 0)
|
||||||
<x-interview.report.audit-table :logs="$logs" />
|
<table class="audit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Violation Category</th>
|
||||||
|
<th>Audit Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($logs as $log)
|
||||||
|
<tr>
|
||||||
|
<td style="color: var(--text-muted);">{{ \Carbon\Carbon::parse($log['timestamp'])->format('H:i:s T') }}</td>
|
||||||
|
<td><strong>{{ strtoupper($log['type']) }}</strong></td>
|
||||||
|
<td>{{ $log['details'] ?? 'Incident flagged by proctoring engine.' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@else
|
||||||
|
<div style="background: rgba(16,185,129,0.1); color: #10b981; padding: 12px; border-radius: 10px; font-size: 0.8rem; font-weight: 600; margin-bottom: 28px;">
|
||||||
|
✅ Zero proctoring violations or malpractice detected during the session.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Code Solution & Output Section -->
|
<!-- Code Solution Block -->
|
||||||
<x-interview.report.code-section
|
<div class="section-heading">
|
||||||
:code="$interview->submitted_code"
|
💻 Submitted Code Solution ({{ strtoupper($interview->submitted_language ?? $interview->language) }})
|
||||||
:output="$interview->code_output"
|
</div>
|
||||||
:language="$interview->submitted_language ?? $interview->language"
|
<div class="code-box">
|
||||||
/>
|
{{ $interview->submitted_code ?? '// No code solution submitted by candidate.' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Candidate Workspace Artifacts & Recordings -->
|
<!-- Code Output Block -->
|
||||||
<x-interview.report.artifacts-section :interview="$interview" />
|
<div class="section-heading">
|
||||||
|
🖥️ Engine Execution Stdout Output
|
||||||
|
</div>
|
||||||
|
<div class="output-box">
|
||||||
|
{{ $interview->code_output ?? 'No execution output recorded.' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Candidate Notes, Interviewer Evaluation Notes, Drawing & Video Recordings Section -->
|
||||||
|
@if(!empty($interview->candidate_notes) || !empty($interview->interviewer_notes) || !empty($interview->candidate_drawing) || (is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0))
|
||||||
|
<div class="section-heading">
|
||||||
|
📝 Workspace Artifacts, Evaluation Notes & Call Session Recordings
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 28px;">
|
||||||
|
@if(!empty($interview->candidate_notes))
|
||||||
|
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE NOTEPAD NOTES (UNIQUE ID SYNCED):</div>
|
||||||
|
<pre style="font-family: monospace; font-size: 0.8rem; background: white; padding: 10px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 12px;">{{ $interview->candidate_notes }}</pre>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(!empty($interview->interviewer_notes))
|
||||||
|
<div style="font-size: 0.75rem; font-weight: 700; color: #10b981; margin-bottom: 6px;">INTERVIEWER EVALUATION NOTES (STORED BY UNIQUE ID):</div>
|
||||||
|
<pre style="font-family: sans-serif; font-size: 0.8rem; background: #f0fdf4; color: #166534; padding: 10px; border-radius: 8px; border: 1px solid #bbf7d0; margin-bottom: 12px;">{{ $interview->interviewer_notes }}</pre>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(!empty($interview->candidate_drawing))
|
||||||
|
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE DRAWING DIAGRAM:</div>
|
||||||
|
<img src="{{ $interview->candidate_drawing }}" alt="Candidate Drawing" style="max-width: 100%; max-height: 250px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 12px;" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0)
|
||||||
|
<div style="font-size: 0.75rem; font-weight: 700; color: #6366f1; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span>📹 STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS - NEWEST FIRST):</span>
|
||||||
|
<span style="font-size: 0.65rem; color: #94a3b8;">📜 Scroll to view all</span>
|
||||||
|
</div>
|
||||||
|
<div style="max-height: 480px; overflow-y: auto; padding-right: 4px; border: 1px solid var(--border); border-radius: 8px; padding: 10px; background: #fafafa; margin-bottom: 12px;">
|
||||||
|
@foreach($interview->formatted_recordings as $displayIdx => $rec)
|
||||||
|
<div style="margin-bottom: 12px; background: white; border: 1px solid var(--border); border-radius: 8px; padding: 10px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||||
|
<span style="font-size: 0.75rem; font-weight: 700; color: var(--primary);">Recording #{{ count($interview->formatted_recordings) - $displayIdx }} ({{ $rec['created_at'] }})</span>
|
||||||
|
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none; padding: 3px 8px; background: rgba(99,102,241,0.1); border-radius: 4px;">📥 Download MP4 Video</a>
|
||||||
|
</div>
|
||||||
|
<video controls preload="metadata" style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;">
|
||||||
|
<source src="{{ $rec['stream_url'] }}" type="{{ $rec['mime_type'] ?? 'video/webm' }}">
|
||||||
|
<source src="{{ $rec['stream_url'] }}">
|
||||||
|
Your browser does not support video playback.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Panel Sign-Off Block -->
|
<!-- Panel Sign-Off Block -->
|
||||||
<x-interview.report.signoff :recommended="$integrityScore >= 80 && $codeScore >= 50" />
|
<div style="border-top: 2px solid var(--border); padding-top: 20px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.7rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">
|
||||||
|
HR & Lead Panel Approval
|
||||||
|
</div>
|
||||||
|
<div style="font-weight: 700; font-size: 0.9rem; color: var(--primary); margin-top: 4px;">
|
||||||
|
SingleLogin Enterprise HR Board
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<div style="font-size: 0.7rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">
|
||||||
|
Final Recommendation
|
||||||
|
</div>
|
||||||
|
<div style="font-weight: 800; font-size: 1rem; color: {{ $integrityScore >= 80 && $codeScore >= 50 ? '#10b981' : '#ef4444' }};">
|
||||||
|
{{ $integrityScore >= 80 && $codeScore >= 50 ? 'RECOMMENDED FOR HIRE' : 'REQUIRES FURTHER REVIEW / FLAGGED' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-layouts.report>
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -1,230 +1,450 @@
|
|||||||
<x-layouts.interview
|
<!DOCTYPE html>
|
||||||
:title="$interview->candidate_name . ' - Assessment Review'"
|
<html lang="en">
|
||||||
data-interview-id="{{ $interview->id }}"
|
<head>
|
||||||
data-submission-id="{{ $interview->submission_unique_id }}"
|
<meta charset="UTF-8">
|
||||||
>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@push('head-scripts')
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
|
<title>{{ $interview->candidate_name }} - Assessment Review | SingleLogin</title>
|
||||||
<script src="//cdn.metered.ca/sdk/video/1.4.6/sdk.min.js"></script>
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@endpush
|
<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>
|
||||||
|
|
||||||
<!-- Video & Screen Call Panel -->
|
<style>
|
||||||
<x-card>
|
:root {
|
||||||
<x-slot:header>
|
--bg: #0a0e17;
|
||||||
<x-interview.candidate-header :interview="$interview" />
|
--panel: #121826;
|
||||||
</x-slot:header>
|
--panel-2: #0d1220;
|
||||||
<x-slot:headerExtra>
|
--border: #232b3d;
|
||||||
<x-interview.call-controls :interview="$interview" />
|
--border-soft: #1b2233;
|
||||||
</x-slot:headerExtra>
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
<div class="p-4 lg:p-5 flex flex-col gap-4">
|
body {
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4 items-stretch">
|
background: radial-gradient(1200px 600px at 15% -10%, rgba(75,130,247,0.06), transparent 60%), var(--bg);
|
||||||
<!-- Stream Viewport -->
|
color: var(--text);
|
||||||
<div class="lg:col-span-8 flex flex-col gap-4 justify-center">
|
font-family: var(--sans);
|
||||||
<div id="primary-stream-container" class="aspect-video bg-[#090d16] border border-[#1b2233] rounded-xl overflow-hidden flex flex-col justify-center">
|
}
|
||||||
<!-- Candidate Video Main Slot (Default active when not sharing screen) -->
|
|
||||||
<div id="cand-main-slot" class="w-full h-full">
|
|
||||||
<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="{{ $interview->candidate_initials }}">
|
|
||||||
<span id="cand-status-text">Waiting for candidate…</span>
|
|
||||||
</x-video-tile>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Screen Share Tile (Active when candidate shares screen) -->
|
/* Toggle switch */
|
||||||
<div id="screen-slot" class="w-full h-full hidden">
|
.toggle {
|
||||||
<x-video-tile
|
width: 32px; height: 18px; border-radius: 20px; background: var(--panel-2);
|
||||||
id="screen-wrapper"
|
border: 1px solid var(--border); position: relative; cursor: pointer; flex-shrink: 0;
|
||||||
videoId="interviewer-screen-video"
|
display: inline-block;
|
||||||
placeholderId="screen-video-placeholder"
|
}
|
||||||
badgeText="Shared screen"
|
.toggle::after {
|
||||||
focused="true"
|
content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
|
||||||
showZoom="true"
|
border-radius: 50%; background: var(--text-faint); transition: left .15s ease;
|
||||||
showFullscreen="true"
|
}
|
||||||
zoomInFn="zoomScreen(0.25)"
|
.toggle.on { background: var(--accent-soft); border-color: var(--accent-border); }
|
||||||
zoomOutFn="zoomScreen(-0.25)"
|
.toggle.on::after { left: 16px; background: var(--accent); }
|
||||||
zoomResetFn="resetZoomScreen()"
|
|
||||||
fullscreenFn="toggleFullscreenScreen()"
|
|
||||||
containerId="screen-zoom-container"
|
|
||||||
avatarTitle="Candidate live screen"
|
|
||||||
initials="CD">
|
|
||||||
Waiting for candidate to share screen…
|
|
||||||
</x-video-tile>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inspector Panel & Dynamic Code/Chat -->
|
/* Accordion disclosure */
|
||||||
<div class="lg:col-span-4 flex flex-col gap-3.5">
|
details.meta summary {
|
||||||
<!-- Top Box: Inspector Panel with Proctoring Logs as First & Default Tab -->
|
list-style: none; cursor: pointer; user-select: none;
|
||||||
<x-interview.inspector-panel :interview="$interview" />
|
}
|
||||||
|
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="bg-[#0e1422] border border-[#1b2233] rounded-xl p-3 flex flex-col flex-1">
|
<div class="w-full mx-auto px-7 py-5 pb-16 flex-1">
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="inline-flex gap-1 p-0.5 bg-[#090d16] border border-[#1b2233] rounded-lg">
|
|
||||||
<button type="button" id="toggle-btn-code" onclick="toggleBottomSection('code')" class="px-2.5 py-1 text-[11px] font-bold rounded-md transition-all bg-indigo-600 text-white cursor-pointer">
|
|
||||||
<i class="fa-solid fa-code mr-1"></i> Live Code
|
|
||||||
</button>
|
|
||||||
<button type="button" id="toggle-btn-chat" onclick="toggleBottomSection('chat')" class="px-2.5 py-1 text-[11px] font-bold rounded-md transition-all text-slate-400 hover:text-white cursor-pointer">
|
|
||||||
<i class="fa-solid fa-comments mr-1"></i> Live Chat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Candidate Live Code Pane -->
|
<!-- Top bar -->
|
||||||
<div id="section-candidate-code" class="flex-1 flex flex-col">
|
<div class="flex items-center justify-between pb-4 mb-4 border-b border-[#1b2233]">
|
||||||
<pre id="modal-code-display" class="bg-slate-950/80 border border-slate-800 rounded-lg p-2.5 font-mono text-xs text-slate-300 flex-1 min-h-33.75 overflow-y-auto whitespace-pre-wrap leading-relaxed"></pre>
|
<div class="flex items-center gap-3">
|
||||||
<div class="text-[10.5px] text-slate-500 mt-2 mb-1 font-semibold uppercase">Execution Output</div>
|
<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">
|
||||||
<pre id="modal-output-display" class="bg-slate-950/80 border border-slate-800 rounded-lg p-2 font-mono text-[11px] text-emerald-300 max-h-14 overflow-y-auto whitespace-pre-wrap"></pre>
|
<i class="fa-solid fa-bolt"></i>
|
||||||
</div>
|
</a>
|
||||||
|
<div>
|
||||||
<!-- Live Chat Pane -->
|
<h1 class="text-base font-semibold text-white m-0 tracking-tight">Candidate Assessment Review</h1>
|
||||||
<div id="section-live-chat" class="hidden flex-1 flex flex-col justify-between">
|
<p class="text-xs text-[#5b637a] m-0 mt-0.5">Live proctored session & review room</p>
|
||||||
<x-chat-panel id="interviewer-chat-history" class="mb-2 overflow-y-auto h-full" :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-[#090d16] border border-[#232b3d] rounded-lg px-3 py-2 text-white text-xs outline-none focus:border-indigo-500 placeholder-[#5b637a]">
|
|
||||||
<x-button onclick="sendSilentWarning()" variant="primary" size="sm" icon="fa-solid fa-paper-plane">Send</x-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 overflow-x-auto p-2">
|
|
||||||
<!-- Candidate Camera PIP Container (Active when screen share is on) -->
|
|
||||||
<div id="cand-pip-slot" class="hidden w-55 aspect-video shrink-0"></div>
|
|
||||||
|
|
||||||
<!-- Panelist Self Tile -->
|
<div class="flex items-center gap-1.5">
|
||||||
@php
|
<a href="{{ route('interview.index') }}">
|
||||||
$usrWords = preg_split('/\s+/', trim(Auth::user()->name ?? 'Interviewer'));
|
<x-button variant="ghost" icon="fa-solid fa-arrow-left">Back to assessments</x-button>
|
||||||
$usrInitials = count($usrWords) >= 2 ? strtoupper(substr($usrWords[0],0,1).substr(end($usrWords),0,1)) : strtoupper(substr(trim(Auth::user()->name ?? 'IV'),0,2));
|
</a>
|
||||||
@endphp
|
@if(Auth::user()->isAdmin())
|
||||||
<div class="w-55 aspect-video shrink-0">
|
<a href="{{ route('admin.dashboard') }}">
|
||||||
<x-video-tile
|
<x-button variant="ghost" icon="fa-solid fa-sliders">Control panel</x-button>
|
||||||
id="self-video-wrapper"
|
</a>
|
||||||
videoId="interviewer-self-video"
|
@endif
|
||||||
placeholderId="self-video-placeholder"
|
<form action="{{ route('logout') }}" method="POST" id="logout-form" class="inline">
|
||||||
badgeText="Panelist self"
|
@csrf
|
||||||
micStatusId="self-mic-status"
|
<x-button type="button" onclick="interviewerLogoutAction()" variant="ghost" icon="fa-solid fa-arrow-right-from-bracket">Logout</x-button>
|
||||||
camStatusId="self-cam-status"
|
</form>
|
||||||
avatarTitle="{{ Auth::user()->name ?? 'Interviewer' }}"
|
|
||||||
initials="{{ $usrInitials }}">
|
|
||||||
Panelist (self)
|
|
||||||
</x-video-tile>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Multi-Party Panelist Video Grid (Appends remote panelists horizontally) -->
|
|
||||||
<div id="panelist-mesh-grid" class="flex items-center gap-3 shrink-0"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-card>
|
|
||||||
|
|
||||||
<!-- SOS Cheating Alert Modal -->
|
@if(session('success'))
|
||||||
<x-interview.sos-modal />
|
<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
|
||||||
|
|
||||||
@push('scripts')
|
<!-- Session Panel -->
|
||||||
<script>
|
<div class="bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden">
|
||||||
const interviewSessionKey = 'interview_bottom_section_{{ $interview->id }}';
|
<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>
|
||||||
|
|
||||||
function toggleBottomSection(mode, persist = true) {
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
if (persist) {
|
@if($interview->isExpired())
|
||||||
try {
|
<x-pill id="timeline-status-pill" variant="red" icon="fa-solid fa-circle">Expired</x-pill>
|
||||||
sessionStorage.setItem(interviewSessionKey, mode);
|
@elseif($interview->status === 'completed')
|
||||||
} catch(e) {}
|
<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
|
||||||
|
|
||||||
const codeSection = document.getElementById('section-candidate-code');
|
<div class="w-px h-[18px] bg-[#232b3d] mx-1"></div>
|
||||||
const chatSection = document.getElementById('section-live-chat');
|
|
||||||
const btnCode = document.getElementById('toggle-btn-code');
|
|
||||||
const btnChat = document.getElementById('toggle-btn-chat');
|
|
||||||
|
|
||||||
if (mode === 'code') {
|
<div class="flex items-center gap-2 text-xs text-[#9199ad] cursor-pointer" onclick="toggleSosAutoTrigger()">
|
||||||
if (codeSection) codeSection.classList.remove('hidden');
|
<span class="toggle" id="sosToggle"></span> SOS auto-trigger
|
||||||
if (chatSection) chatSection.classList.add('hidden');
|
</div>
|
||||||
if (btnCode) {
|
|
||||||
btnCode.classList.add('bg-indigo-600', 'text-white');
|
|
||||||
btnCode.classList.remove('text-slate-400');
|
|
||||||
}
|
|
||||||
if (btnChat) {
|
|
||||||
btnChat.classList.remove('bg-indigo-600', 'text-white');
|
|
||||||
btnChat.classList.add('text-slate-400');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (codeSection) codeSection.classList.add('hidden');
|
|
||||||
if (chatSection) chatSection.classList.remove('hidden');
|
|
||||||
if (btnChat) {
|
|
||||||
btnChat.classList.add('bg-indigo-600', 'text-white');
|
|
||||||
btnChat.classList.remove('text-slate-400');
|
|
||||||
}
|
|
||||||
if (btnCode) {
|
|
||||||
btnCode.classList.remove('bg-indigo-600', 'text-white');
|
|
||||||
btnCode.classList.add('text-slate-400');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.toggleBottomSection = toggleBottomSection;
|
|
||||||
|
|
||||||
let previousScreenShareLayout = null;
|
<div class="w-px h-[18px] bg-[#232b3d] mx-1"></div>
|
||||||
function updateScreenShareLayout(isSharing) {
|
|
||||||
const candVidWrapper = document.getElementById('cand-video-wrapper');
|
|
||||||
const candMainSlot = document.getElementById('cand-main-slot');
|
|
||||||
const candPipSlot = document.getElementById('cand-pip-slot');
|
|
||||||
const screenSlot = document.getElementById('screen-slot');
|
|
||||||
|
|
||||||
if (isSharing) {
|
<x-button onclick="regenerateCandidatePassword()" variant="secondary" icon="fa-solid fa-key">Password</x-button>
|
||||||
if (candVidWrapper && candPipSlot && !candPipSlot.contains(candVidWrapper)) {
|
<x-button onclick="openReportPage()" variant="secondary" icon="fa-solid fa-file-pdf">PDF report</x-button>
|
||||||
candPipSlot.appendChild(candVidWrapper);
|
<x-button onclick="blockCandidateAction()" variant="secondary" icon="fa-solid fa-ban">Block</x-button>
|
||||||
candPipSlot.classList.remove('hidden');
|
</div>
|
||||||
}
|
</div>
|
||||||
if (screenSlot) screenSlot.classList.remove('hidden');
|
|
||||||
if (candMainSlot) candMainSlot.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
if (candVidWrapper && candMainSlot && !candMainSlot.contains(candVidWrapper)) {
|
|
||||||
candMainSlot.appendChild(candVidWrapper);
|
|
||||||
candMainSlot.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
if (candPipSlot) candPipSlot.classList.add('hidden');
|
|
||||||
if (screenSlot) screenSlot.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user has chosen a mode explicitly, respect that mode across all polls/updates
|
<!-- Submission Details Disclosure Accordion -->
|
||||||
let activeBottomMode = 'code';
|
<details class="meta border-t border-[#1b2233]">
|
||||||
try {
|
<summary class="flex items-center gap-2 px-5 py-2.5 text-xs text-[#5b637a] font-medium hover:text-[#9199ad] transition-colors">
|
||||||
activeBottomMode = sessionStorage.getItem(interviewSessionKey) || (isSharing ? 'chat' : 'code');
|
<i class="fa-solid fa-chevron-down chev text-[10px]"></i> Show submission details
|
||||||
} catch(e) {
|
</summary>
|
||||||
activeBottomMode = isSharing ? 'chat' : 'code';
|
<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">
|
||||||
toggleBottomSection(activeBottomMode, false);
|
<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>
|
||||||
window.updateScreenShareLayout = updateScreenShareLayout;
|
</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>
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
<!-- Call Panel -->
|
||||||
openReviewModal(
|
<x-card>
|
||||||
{{ $interview->id }},
|
<x-slot:headerExtra>
|
||||||
"{{ addslashes($interview->candidate_name) }}",
|
<div class="flex items-center gap-2">
|
||||||
"{{ $interview->submission_unique_id }}",
|
<!-- 1. Mic Toggle (Icon Only) -->
|
||||||
{{ request('join_call') == '1' ? 'true' : 'false' }}
|
<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>
|
||||||
);
|
|
||||||
|
|
||||||
// Restore active bottom section selection
|
<!-- 2. Cam Toggle (Icon Only) -->
|
||||||
try {
|
<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>
|
||||||
const savedMode = sessionStorage.getItem(interviewSessionKey);
|
|
||||||
if (savedMode) {
|
<!-- 3. Start / Join / Restart Call -->
|
||||||
toggleBottomSection(savedMode, false);
|
@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">
|
||||||
} catch(e) {}
|
Call Ended
|
||||||
});
|
</x-button>
|
||||||
</script>
|
@elseif($interview->call_status === 'active')
|
||||||
@endpush
|
<x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone">
|
||||||
</x-layouts.interview>
|
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>
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"skills": {
|
|
||||||
"laravel-best-practices": {
|
|
||||||
"source": "laravel/boost",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": ".ai/laravel/skill/laravel-best-practices/SKILL.md",
|
|
||||||
"computedHash": "e3e661ba372b8157804510ae95e1a69f5a5ab77cdcea47611c8dae9ac8fc8eb6"
|
|
||||||
},
|
|
||||||
"laravel-security": {
|
|
||||||
"source": "affaan-m/everything-claude-code",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "skills/laravel-security/SKILL.md",
|
|
||||||
"computedHash": "6ec0611d65b0787530f725ca630d9075e5e2eb3d7779abb43447983174dce854"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user