feat: Face ID (scan, register, liveness) + role baru KS/Waka/Ketua Tim + notifikasi WA ortu + scoping wali kelas + error pages premium

This commit is contained in:
Novy San87
2026-08-02 20:57:11 +07:00
parent 0a5f471854
commit 6e256661dd
76 changed files with 5335 additions and 888 deletions
+445
View File
@@ -0,0 +1,445 @@
@extends('layouts.app')
@section('title', 'Registrasi Wajah')
@section('content')
<div class="pb-10">
{{-- ===== HEADER PREMIUM ===== --}}
<div class="relative overflow-hidden rounded-3xl bg-gradient-to-r from-indigo-600 via-violet-500 to-fuchsia-400 shadow-xl shadow-indigo-500/20 px-6 py-6 mb-6">
<div class="absolute -top-16 -right-16 w-56 h-56 rounded-full bg-white/10 blur-2xl"></div>
<div class="absolute -bottom-20 left-1/3 w-48 h-48 rounded-full bg-fuchsia-300/20 blur-2xl"></div>
<div class="absolute top-0 right-24 w-24 h-24 rounded-full border border-white/10"></div>
<div class="relative flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-white/15 backdrop-blur flex items-center justify-center shadow-inner ring-1 ring-white/20">
<i class="fa-solid fa-user-plus text-white text-2xl"></i>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h2 class="text-xl font-extrabold text-white tracking-tight">Registrasi Wajah Siswa</h2>
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/20 text-white/90 tracking-wider uppercase">Face ID</span>
</div>
<p class="text-sm text-white/75 mt-0.5">Daftarkan foto wajah ke siswa yang sudah ada di database — Master Data</p>
</div>
</div>
</div>
{{-- ===== LANGKAH ===== --}}
<div class="flex items-center justify-center gap-2 mb-6">
@foreach (['Cari Siswa', 'Ambil Foto', 'Simpan'] as $i => $step)
@php($active = ($i === 0) || ($selected && $i === 1))
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300 {{ $active ? 'bg-indigo-500 text-white shadow-md shadow-indigo-500/30' : 'bg-gray-100 text-gray-400' }}">
{{ $i + 1 }}
</div>
<span class="text-xs font-semibold {{ $active ? 'text-indigo-600' : 'text-gray-300' }}">{{ $step }}</span>
</div>
@if ($i < 2)
<i class="fa-solid fa-chevron-right text-[10px] text-gray-300"></i>
@endif
</div>
@endforeach
</div>
<div class="grid lg:grid-cols-5 gap-6 items-start">
{{-- ===== KIRI: CARI + DAFTAR SISWA ===== --}}
<div class="lg:col-span-3 space-y-5">
{{-- Cari --}}
<form method="GET" action="{{ route('face.register') }}" class="flex items-center gap-3">
<div class="relative flex-1">
<i class="fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input type="text" name="q" value="{{ $q }}" placeholder="Cari NISN / nomor / nama siswa..."
class="w-full pl-11 pr-4 py-3 border border-gray-200 rounded-2xl text-sm bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition shadow-sm">
</div>
<button class="px-6 py-3 text-sm font-bold text-white bg-gradient-to-r from-indigo-500 to-violet-500 rounded-2xl hover:from-indigo-600 hover:to-violet-600 active:scale-95 transition shadow-lg shadow-indigo-500/25 inline-flex items-center gap-2">
<i class="fa-solid fa-magnifying-glass"></i> Cari
</button>
</form>
{{-- Daftar siswa --}}
<div class="bg-white rounded-3xl shadow-sm ring-1 ring-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-50 flex items-center justify-between">
<p class="text-sm font-bold text-gray-700 flex items-center gap-2">
<i class="fa-solid fa-users text-indigo-500"></i> Daftar Siswa
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-600">{{ $students->total() }} siswa</span>
</p>
<span class="text-[11px] text-gray-400 font-medium">Status wajah: <span class="text-emerald-600 font-bold">Terdaftar</span> / <span class="text-gray-500 font-bold">Belum</span></span>
</div>
@if ($students->count())
{{-- Desktop: tabel --}}
<div class="hidden md:block overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50/70 text-left text-[11px] text-gray-400 uppercase tracking-wider">
<th class="px-6 py-3 font-bold">Nama</th>
<th class="px-4 py-3 font-bold">NISN</th>
<th class="px-4 py-3 font-bold">Kelas</th>
<th class="px-4 py-3 font-bold">Status Wajah</th>
<th class="px-6 py-3 font-bold text-right">Aksi</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@foreach ($students as $student)
@php($registered = ($enrolledMap[(string) $student->id] ?? 0) > 0)
<tr class="hover:bg-indigo-50/30 transition {{ $selected && $selected->id === $student->id ? 'bg-indigo-50/50' : '' }}">
<td class="px-6 py-3.5">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500 to-violet-500 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
{{ strtoupper(substr($student->full_name, 0, 1)) }}
</div>
<span class="font-semibold text-gray-800">{{ $student->full_name }}</span>
</div>
</td>
<td class="px-4 py-3.5 text-gray-500 font-medium">{{ $student->nisn }}</td>
<td class="px-4 py-3.5">
<span class="text-xs font-bold px-2.5 py-1 rounded-full bg-gray-100 text-gray-600">{{ $student->class_name }}</span>
</td>
<td class="px-4 py-3.5">
@if ($registered)
<span class="inline-flex items-center gap-1 text-xs font-bold px-2.5 py-1 rounded-full bg-emerald-50 text-emerald-600">
<i class="fa-solid fa-check"></i> Terdaftar
</span>
@else
<span class="inline-flex items-center gap-1 text-xs font-bold px-2.5 py-1 rounded-full bg-gray-100 text-gray-500">
<i class="fa-solid fa-circle-minus"></i> Belum
</span>
@endif
</td>
<td class="px-6 py-3.5 text-right">
<a href="{{ route('face.register', ['student_id' => $student->id, 'q' => $q]) }}"
class="inline-flex items-center gap-1.5 text-xs font-bold px-3.5 py-2 rounded-xl border transition {{ $registered ? 'text-indigo-600 border-indigo-200 bg-white hover:bg-indigo-50' : 'text-white bg-gradient-to-r from-indigo-500 to-violet-500 border-transparent hover:from-indigo-600 hover:to-violet-600 shadow-md shadow-indigo-500/20' }}">
<i class="fa-solid fa-camera"></i> {{ $registered ? 'Perbarui' : 'Registrasi' }}
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Mobile: kartu (tanpa scroll samping) --}}
<div class="md:hidden divide-y divide-gray-50">
@foreach ($students as $student)
@php($registered = ($enrolledMap[(string) $student->id] ?? 0) > 0)
<div class="p-4 flex items-center gap-3 {{ $selected && $selected->id === $student->id ? 'bg-indigo-50/50' : '' }}">
<div class="w-11 h-11 rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-500 text-white flex items-center justify-center font-bold text-sm shadow-sm shrink-0">
{{ strtoupper(substr($student->full_name, 0, 1)) }}
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-gray-800 text-sm truncate">{{ $student->full_name }}</p>
<p class="text-xs text-gray-400 mt-0.5 truncate">{{ $student->nisn }} · {{ $student->class_name }}</p>
<div class="mt-1.5">
@if ($registered)
<span class="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-600">
<i class="fa-solid fa-check"></i> Wajah Terdaftar
</span>
@else
<span class="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">
<i class="fa-solid fa-circle-minus"></i> Belum Terdaftar
</span>
@endif
</div>
</div>
<a href="{{ route('face.register', ['student_id' => $student->id, 'q' => $q]) }}"
class="shrink-0 inline-flex items-center gap-1.5 text-xs font-bold px-3.5 py-2.5 rounded-xl transition {{ $registered ? 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100' : 'text-white bg-gradient-to-r from-indigo-500 to-violet-500 hover:from-indigo-600 hover:to-violet-600 shadow-md shadow-indigo-500/20' }}">
<i class="fa-solid fa-camera"></i> {{ $registered ? 'Perbarui' : 'Registrasi' }}
</a>
</div>
@endforeach
</div>
@else
<div class="py-12 text-center">
<i class="fa-solid fa-user-slash text-3xl text-gray-200 mb-3 block"></i>
<p class="text-gray-400 text-sm font-medium">Siswa tidak ditemukan</p>
<p class="text-xs text-gray-300 mt-1">Coba kata kunci lain</p>
</div>
@endif
<div class="px-6 py-4 border-t border-gray-50">{{ $students->links() }}</div>
</div>
</div>
{{-- ===== KANAN: KAMERA + FORM ===== --}}
<div class="lg:col-span-2 space-y-5 lg:sticky lg:top-6">
@if ($selected)
<div class="bg-white rounded-3xl shadow-lg shadow-indigo-500/5 ring-1 ring-gray-100 overflow-hidden" x-data="faceRegister()">
{{-- Siswa terpilih --}}
<div class="bg-gradient-to-r from-indigo-500 via-violet-500 to-fuchsia-400 px-6 py-4 flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-white/20 backdrop-blur text-white flex items-center justify-center font-extrabold text-lg shadow-inner ring-1 ring-white/20">
{{ strtoupper(substr($selected->full_name, 0, 1)) }}
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-bold truncate">{{ $selected->full_name }}</p>
<p class="text-indigo-50/90 text-xs">{{ $selected->nisn }} · {{ $selected->class_name }}</p>
</div>
<a href="{{ route('face.register', ['q' => $q]) }}" class="w-9 h-9 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-white flex items-center justify-center hover:bg-white/25 active:scale-90 transition" title="Ganti siswa">
<i class="fa-solid fa-xmark"></i>
</a>
</div>
<form action="{{ route('face.register.store') }}" method="POST" enctype="multipart/form-data"
class="p-6 space-y-5" @submit.prevent="if (shots.length) $el.submit()">
@csrf
<input type="hidden" name="student_id" value="{{ $selected->id }}">
<input type="hidden" name="q" value="{{ $q }}">
<input type="file" id="photos" name="photos[]" multiple accept="image/*" class="hidden" @change="onGallery">
{{-- Viewfinder kamera --}}
<div class="relative aspect-[4/3] rounded-2xl overflow-hidden bg-gradient-to-br from-gray-900 to-gray-950 ring-1 ring-gray-800/60 shadow-2xl shadow-gray-900/20">
{{-- Grid --}}
<div class="absolute inset-0 pointer-events-none opacity-20">
<div class="absolute left-1/3 top-0 bottom-0 w-px bg-white/30"></div>
<div class="absolute left-2/3 top-0 bottom-0 w-px bg-white/30"></div>
<div class="absolute top-1/3 left-0 right-0 h-px bg-white/30"></div>
<div class="absolute top-2/3 left-0 right-0 h-px bg-white/30"></div>
</div>
{{-- Belum buka kamera --}}
<template x-if="!cameraOn && !shots.length">
<div class="absolute inset-0 flex flex-col items-center justify-center text-center p-6">
<div class="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500/20 to-fuchsia-400/10 ring-1 ring-white/15 backdrop-blur flex items-center justify-center mb-3 shadow-xl">
<i class="fa-solid fa-video text-white text-2xl"></i>
</div>
<p class="text-white font-bold">Buka Kamera</p>
<p class="text-gray-400 text-xs mt-1">Pastikan wajah siswa terlihat jelas</p>
<button type="button" @click="startCamera()"
class="mt-4 inline-flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-gradient-to-r from-indigo-500 to-fuchsia-500 rounded-xl hover:from-indigo-600 hover:to-fuchsia-600 active:scale-95 transition shadow-lg shadow-indigo-500/30">
<i class="fa-solid fa-camera"></i> Buka Kamera
</button>
</div>
</template>
<video x-ref="video" x-show="cameraOn" autoplay playsinline muted class="absolute inset-0 w-full h-full object-cover"></video>
<canvas x-ref="overlay" x-show="cameraOn" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
{{-- Sudah ada foto & kamera mati --}}
<template x-if="shots.length && !cameraOn">
<div class="absolute inset-0 bg-gray-950/80 backdrop-blur-sm flex flex-col items-center justify-center text-center">
<i class="fa-solid fa-circle-check text-emerald-400 text-4xl mb-2"></i>
<p class="text-white font-bold text-sm" x-text="shots.length + ' dari 3 foto diambil'"></p>
<button type="button" @click="startCamera()" class="mt-3 text-xs font-bold text-indigo-300 hover:text-white transition">Tambah foto lagi</button>
</div>
</template>
{{-- Corner brackets --}}
<template x-if="cameraOn">
<div class="absolute inset-3 pointer-events-none">
<div class="absolute top-0 left-0 w-7 h-7 border-t-[3px] border-l-[3px] border-white/70 rounded-tl-2xl"></div>
<div class="absolute top-0 right-0 w-7 h-7 border-t-[3px] border-r-[3px] border-white/70 rounded-tr-2xl"></div>
<div class="absolute bottom-0 left-0 w-7 h-7 border-b-[3px] border-l-[3px] border-white/70 rounded-bl-2xl"></div>
<div class="absolute bottom-0 right-0 w-7 h-7 border-b-[3px] border-r-[3px] border-white/70 rounded-br-2xl"></div>
</div>
</template>
{{-- Status pill + ganti kamera --}}
<template x-if="cameraOn">
<div class="absolute top-3 inset-x-3 flex items-center justify-between">
<span id="faceHint"
class="text-[10px] font-bold tracking-wider px-3 py-1.5 rounded-full backdrop-blur-md transition-all duration-300 bg-red-500/80 text-white shadow-lg shadow-red-500/30">CARI WAJAH…</span>
<button type="button" @click="switchCamera()"
class="w-8 h-8 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-white flex items-center justify-center hover:bg-white/20 active:scale-90 transition shadow-lg">
<i class="fa-solid fa-camera-rotate text-xs"></i>
</button>
</div>
</template>
</div>
{{-- Tombol kamera --}}
<div class="flex items-center gap-2 flex-wrap">
<template x-if="cameraOn && shots.length < 3">
<button type="button" id="btnCapture" @click="capture()" disabled
class="inline-flex items-center gap-2 px-4 py-2.5 text-xs font-bold text-white bg-gradient-to-r from-emerald-500 to-teal-500 rounded-xl hover:from-emerald-600 hover:to-teal-600 active:scale-95 transition shadow-md shadow-emerald-500/25 disabled:opacity-40 disabled:cursor-not-allowed">
<i class="fa-solid fa-camera"></i> <span x-text="'Ambil Foto ' + (shots.length + 1)"></span>
</button>
</template>
<button type="button" x-show="cameraOn" @click="stopCamera()"
class="inline-flex items-center gap-2 px-3 py-2.5 text-xs font-medium text-gray-500 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition">
<i class="fa-solid fa-xmark"></i> Tutup
</button>
<label class="inline-flex items-center gap-2 px-3 py-2.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition cursor-pointer">
<i class="fa-solid fa-folder-open"></i> Galeri
<input type="file" class="hidden" accept="image/*" @change="onGalleryAlt">
</label>
</div>
<p x-show="cameraError" class="text-xs font-semibold text-red-500" x-text="cameraError"></p>
{{-- Hasil tangkapan --}}
<div>
<p class="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-2">Foto Diambil (1–3)</p>
<div class="grid grid-cols-3 gap-2.5">
<template x-for="(shot, i) in shots" :key="i">
<div class="relative aspect-square rounded-xl overflow-hidden border border-gray-200 group shadow-sm">
<img :src="shot.url" class="w-full h-full object-cover">
<button type="button" @click="removeShot(i)" title="Hapus foto ini"
class="absolute top-1.5 right-1.5 w-7 h-7 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 active:scale-90 transition shadow-md md:opacity-0 md:group-hover:opacity-100">
<i class="fa-solid fa-xmark"></i>
</button>
<span class="absolute bottom-1 left-1 text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/60 text-white" x-text="'Foto ' + (i + 1)"></span>
</div>
</template>
<template x-for="n in (3 - shots.length)" :key="'ph' + n">
<div class="aspect-square rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300 bg-gray-50/50">
<i class="fa-solid fa-image text-xl"></i>
</div>
</template>
<template x-if="!shots.length">
<div class="col-span-3 py-6 text-center text-xs text-gray-400 rounded-xl border border-dashed border-gray-200">
<i class="fa-solid fa-camera-retro text-2xl mb-2 block text-gray-300"></i>
Belum ada foto — ambil dari kamera atau galeri
</div>
</template>
</div>
<p class="mt-2 text-[11px] text-gray-400">Disarankan 3: frontal netral, sedikit miring, kondisi berbeda. Foto buram/gelap/wajah kecil otomatis ditolak sistem.</p>
<p x-show="formError" class="mt-2 text-xs font-semibold text-red-500" x-text="formError"></p>
@error('photos.*') <p class="mt-1.5 text-xs text-red-500 flex items-center gap-1"><i class="fa-solid fa-circle-exclamation"></i> {{ $message }}</p> @enderror
</div>
{{-- Submit --}}
<button type="submit" :disabled="!shots.length"
class="w-full inline-flex items-center justify-center gap-2 px-6 py-3.5 text-sm font-bold text-white bg-gradient-to-r from-indigo-500 to-violet-500 rounded-2xl hover:from-indigo-600 hover:to-violet-600 active:scale-[0.98] transition shadow-lg shadow-indigo-500/25 disabled:opacity-40 disabled:cursor-not-allowed">
<i class="fa-solid fa-user-plus"></i> Simpan Registrasi Wajah
</button>
</form>
</div>
@else
{{-- Placeholder: belum pilih siswa --}}
<div class="bg-white rounded-3xl shadow-sm ring-1 ring-gray-100 p-10 text-center">
<div class="w-16 h-16 mx-auto rounded-2xl bg-gradient-to-br from-indigo-100 to-violet-100 flex items-center justify-center mb-4">
<i class="fa-solid fa-hand-pointer text-indigo-400 text-2xl"></i>
</div>
<p class="font-bold text-gray-700">Pilih Siswa Terlebih Dahulu</p>
<p class="text-xs text-gray-400 mt-1.5 max-w-xs mx-auto">Cari siswa di kolom pencarian, lalu klik tombol <b>Registrasi</b> untuk mulai mengambil foto wajah</p>
</div>
@endif
@if (!$faceidConfigured)
<div class="p-4 rounded-2xl bg-red-50 border border-red-200 text-sm text-red-600 flex items-start gap-3">
<i class="fa-solid fa-triangle-exclamation mt-0.5"></i> Microservice FaceID belum dikonfigurasi (FACEID_BASE_URL / FACEID_API_KEY di .env).
</div>
@endif
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="module" src="/vendor/mediapipe/facefinder.js"></script>
<script>
function faceRegister() {
return {
cameraOn: false, cameraError: null, formError: null, detectorError: false, facing: 'environment',
shots: [],
init() {
window.addEventListener('beforeunload', () => this.stopCamera());
window.addEventListener('face-detector-error', () => {
this.detectorError = true;
const btn = document.getElementById('btnCapture');
if (btn) { btn.disabled = false; btn.classList.remove('opacity-40', 'cursor-not-allowed'); }
const hint = document.getElementById('faceHint');
if (hint) {
hint.textContent = 'DETEKSI OFF — FOTO TETAP BISA';
hint.className = 'text-[10px] font-bold tracking-wider px-3 py-1.5 rounded-full backdrop-blur-md transition-all duration-300 bg-amber-500/80 text-white shadow-lg shadow-amber-500/30';
}
});
},
async startCamera() {
this.cameraError = null;
if (!navigator.mediaDevices?.getUserMedia) {
this.cameraError = 'Perangkat ini tidak mendukung kamera langsung — gunakan tombol Galeri.';
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: this.facing, width: { ideal: 1280 } },
audio: false,
});
this.$refs.video.srcObject = stream;
this.cameraOn = true;
await this.$refs.video.play().catch(() => {});
if (window.__faceLoop) window.__faceLoop.start(this.$refs.video, this.$refs.overlay);
} catch (e) {
this.cameraError = 'Kamera tidak bisa diakses — izinkan akses kamera di browser, atau gunakan tombol Galeri.';
}
},
stopCamera() {
if (window.__faceLoop) window.__faceLoop.stop();
const v = this.$refs.video;
if (v && v.srcObject) {
v.srcObject.getTracks().forEach(t => t.stop());
v.srcObject = null;
}
this.cameraOn = false;
},
async switchCamera() {
const prev = this.facing;
this.facing = this.facing === 'user' ? 'environment' : 'user';
this.stopCamera();
this.cameraOn = false;
await this.startCamera();
if (this.cameraError) {
this.cameraError = null;
this.facing = prev;
this.stopCamera();
this.cameraOn = false;
await this.startCamera();
}
},
capture() {
if (!this.detectorError && window.__faceLoop && !window.__faceLoop.detected) {
this.cameraError = 'Tidak ada wajah terdeteksi — arahkan kamera ke wajah siswa.';
return;
}
const v = this.$refs.video;
if (!v || !v.srcObject || v.videoWidth === 0) return;
if (this.shots.length >= 3) return;
const canvas = document.createElement('canvas');
canvas.width = v.videoWidth;
canvas.height = v.videoHeight;
canvas.getContext('2d').drawImage(v, 0, 0);
canvas.toBlob((blob) => {
if (!blob) return;
this.addShot(new File([blob], 'foto-' + Date.now() + '.jpg', { type: 'image/jpeg' }));
}, 'image/jpeg', 0.92);
},
addShot(file) {
if (this.shots.length >= 3) return;
this.shots.push({ url: URL.createObjectURL(file), file });
this.formError = null;
this.syncInput();
if (this.shots.length >= 3) this.stopCamera();
},
removeShot(i) {
URL.revokeObjectURL(this.shots[i].url);
this.shots.splice(i, 1);
this.syncInput();
},
syncInput() {
const input = document.getElementById('photos');
if (!input || !window.DataTransfer) return;
const dt = new DataTransfer();
this.shots.forEach(s => dt.items.add(s.file));
input.files = dt.files;
},
onGallery(e) {
Array.from(e.target.files).slice(0, 3 - this.shots.length).forEach(f => this.addShot(f));
e.target.value = '';
},
onGalleryAlt(e) {
this.onGallery(e);
},
};
}
</script>
@endpush
+747
View File
@@ -0,0 +1,747 @@
@extends('layouts.app')
@section('title', 'Scan Wajah')
@section('content')
<div class="max-w-3xl mx-auto pb-10">
{{-- ===== HEADER PREMIUM ===== --}}
<div class="relative overflow-hidden rounded-3xl bg-gradient-to-r from-blue-600 via-indigo-500 to-cyan-400 shadow-xl shadow-blue-500/20 px-6 py-6 mb-6">
{{-- Dekorasi --}}
<div class="absolute -top-16 -right-16 w-56 h-56 rounded-full bg-white/10 blur-2xl"></div>
<div class="absolute -bottom-20 left-1/3 w-48 h-48 rounded-full bg-cyan-300/20 blur-2xl"></div>
<div class="absolute top-0 right-24 w-24 h-24 rounded-full border border-white/10"></div>
<div class="relative flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-white/15 backdrop-blur flex items-center justify-center shadow-inner ring-1 ring-white/20">
<i class="fa-solid fa-camera-retro text-white text-2xl"></i>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h2 class="text-xl font-extrabold text-white tracking-tight">Scan Wajah Siswa</h2>
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/20 text-white/90 tracking-wider uppercase">Face ID</span>
</div>
<p class="text-sm text-white/75 mt-0.5">Hadapkan kamera ke wajah siswa — sistem mengenali identitasnya</p>
</div>
</div>
</div>
{{-- ===== LANGKAH PROGRES ===== --}}
<div class="flex items-center justify-center gap-2 mb-6" x-data="{ step: 1 }" x-init="step = result ? 3 : (preview || loading ? 2 : 1)"
x-effect="step = result ? 3 : (preview || loading ? 2 : 1)">
<template x-for="(s, i) in ['Ambil Foto', 'Verifikasi', 'Catat Pelanggaran']" :key="i">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300"
:class="step > i ? 'bg-blue-500 text-white shadow-md shadow-blue-500/30' : (step === i + 1 ? 'bg-blue-100 text-blue-600 ring-2 ring-blue-500/40' : 'bg-gray-100 text-gray-400')">
<i :class="step > i ? 'fa-solid fa-check' : ''" x-show="step > i"></i>
<span x-show="step <= i" x-text="i + 1"></span>
</div>
<span class="text-xs font-semibold hidden sm:block transition-colors" :class="step === i + 1 ? 'text-blue-600' : (step > i ? 'text-gray-500' : 'text-gray-300')" x-text="s"></span>
</div>
<i x-show="i < 2" class="fa-solid fa-chevron-right text-[10px] text-gray-300"></i>
</div>
</template>
</div>
<div class="space-y-6" x-data="faceScan()">
{{-- ===== VIEWFINDER KAMERA ===== --}}
<div class="relative">
<div class="relative aspect-[4/3] rounded-3xl overflow-hidden bg-gradient-to-br from-gray-900 to-gray-950 shadow-2xl shadow-gray-900/30 ring-1 ring-gray-800/60"
:class="autoScanning ? 'ring-2 ring-cyan-400/70 shadow-cyan-500/10' : ''">
{{-- Grid rule-of-thirds --}}
<div class="absolute inset-0 pointer-events-none opacity-20">
<div class="absolute left-1/3 top-0 bottom-0 w-px bg-white/30"></div>
<div class="absolute left-2/3 top-0 bottom-0 w-px bg-white/30"></div>
<div class="absolute top-1/3 left-0 right-0 h-px bg-white/30"></div>
<div class="absolute top-2/3 left-0 right-0 h-px bg-white/30"></div>
</div>
{{-- Belum buka kamera --}}
<template x-if="!cameraOn && !preview && !loading">
<div class="absolute inset-0 flex flex-col items-center justify-center p-8 text-center">
<div class="absolute -top-10 -left-10 w-40 h-40 rounded-full bg-blue-500/10 blur-2xl"></div>
<div class="absolute -bottom-12 -right-8 w-44 h-44 rounded-full bg-cyan-400/10 blur-2xl"></div>
<div class="relative w-20 h-20 rounded-3xl bg-gradient-to-br from-blue-500/20 to-cyan-400/10 ring-1 ring-white/15 backdrop-blur flex items-center justify-center mb-4 shadow-xl">
<i class="fa-solid fa-video text-white text-3xl"></i>
</div>
<p class="relative text-white font-bold text-lg">Buka Kamera</p>
<p class="relative text-gray-400 text-sm mt-1 max-w-xs">Izinkan akses kamera untuk deteksi wajah real-time</p>
<button type="button" @click="startCamera()"
class="relative mt-5 inline-flex items-center gap-2.5 px-7 py-3 text-sm font-bold text-white bg-gradient-to-r from-blue-500 to-cyan-400 rounded-2xl hover:from-blue-600 hover:to-cyan-500 active:scale-95 transition shadow-lg shadow-blue-500/30">
<i class="fa-solid fa-camera"></i> Buka Kamera
</button>
</div>
</template>
{{-- Video live --}}
<video x-ref="video" x-show="cameraOn && !preview" autoplay playsinline muted
class="absolute inset-0 w-full h-full object-cover"></video>
<canvas x-ref="overlay" x-show="cameraOn && !preview" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
{{-- Hasil tangkapan --}}
<template x-if="preview">
<img :src="preview" class="absolute inset-0 w-full h-full object-cover">
</template>
{{-- Loading scan --}}
<template x-if="loading">
<div class="absolute inset-0 bg-gray-950/70 backdrop-blur-sm flex flex-col items-center justify-center">
<div class="relative">
<div class="w-16 h-16 rounded-full border-4 border-white/10 border-t-cyan-400 animate-spin"></div>
<div class="absolute inset-0 flex items-center justify-center">
<i class="fa-solid fa-camera-retro text-cyan-300 text-xl animate-pulse"></i>
</div>
</div>
<p class="text-white font-semibold mt-4 text-sm">Mengenali wajah...</p>
<p class="text-gray-400 text-xs mt-1">Membandingkan dengan database siswa</p>
</div>
</template>
{{-- Corner brackets viewfinder --}}
<template x-if="cameraOn && !preview">
<div class="absolute inset-3 pointer-events-none">
<div class="absolute top-0 left-0 w-8 h-8 border-t-[3px] border-l-[3px] border-white/70 rounded-tl-2xl"></div>
<div class="absolute top-0 right-0 w-8 h-8 border-t-[3px] border-r-[3px] border-white/70 rounded-tr-2xl"></div>
<div class="absolute bottom-0 left-0 w-8 h-8 border-b-[3px] border-l-[3px] border-white/70 rounded-bl-2xl"></div>
<div class="absolute bottom-0 right-0 w-8 h-8 border-b-[3px] border-r-[3px] border-white/70 rounded-br-2xl"></div>
</div>
</template>
{{-- Status pill + kamera switch (overlay atas) --}}
<template x-if="cameraOn && !preview">
<div class="absolute top-4 inset-x-4 flex items-center justify-between">
<span id="faceHint"
class="text-[11px] font-bold tracking-wider px-3.5 py-1.5 rounded-full backdrop-blur-md transition-all duration-300 bg-red-500/80 text-white shadow-lg shadow-red-500/30">CARI WAJAH…</span>
<div class="flex items-center gap-2">
<span x-show="autoScanOn"
class="flex items-center gap-1.5 text-[10px] font-bold tracking-widest px-2.5 py-1.5 rounded-full bg-cyan-500/90 text-white shadow-lg shadow-cyan-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-white animate-pulse"></span> AUTO
</span>
<button type="button" @click="switchCamera()"
class="w-9 h-9 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-white flex items-center justify-center hover:bg-white/20 active:scale-90 transition shadow-lg">
<i class="fa-solid fa-camera-rotate text-sm"></i>
</button>
</div>
</div>
</template>
{{-- Shutter + tutup kamera (overlay bawah) --}}
<template x-if="cameraOn && !preview">
<div class="absolute bottom-4 inset-x-0 flex items-center justify-center">
<div class="flex items-center gap-6">
<button type="button" @click="stopCamera()"
class="w-11 h-11 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-white/80 flex items-center justify-center hover:bg-white/20 active:scale-90 transition" title="Tutup kamera">
<i class="fa-solid fa-xmark"></i>
</button>
<button type="button" id="btnCapture" @click="capture()" disabled
class="w-[72px] h-[72px] rounded-full bg-white/15 backdrop-blur ring-4 ring-white/60 flex items-center justify-center hover:ring-white/80 active:scale-95 transition disabled:opacity-50 disabled:ring-white/30">
<span class="w-14 h-14 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 shadow-lg shadow-emerald-500/40 ring-2 ring-white/50"></span>
</button>
<label class="w-11 h-11 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-white/80 flex items-center justify-center hover:bg-white/20 active:scale-90 transition cursor-pointer" title="Pilih dari galeri">
<i class="fa-solid fa-images"></i>
<input type="file" class="hidden" accept="image/*" @change="onFile">
</label>
</div>
</div>
</template>
</div>
{{-- Tracker sinyal liveness live --}}
<div x-show="cameraOn && !preview" class="flex items-center justify-center gap-2 mt-3 flex-wrap">
<span class="text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full transition-all duration-300" :class="signalsLive.blink ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400'">
<i class="fa-solid fa-eye mr-1"></i>Kedip <span x-show="signalsLive.blink">✓</span>
</span>
<span class="text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full transition-all duration-300" :class="signalsLive.smile ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400'">
<i class="fa-solid fa-face-smile mr-1"></i>Senyum <span x-show="signalsLive.smile">✓</span>
</span>
<span class="text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full transition-all duration-300" :class="signalsLive.depth ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400'">
<i class="fa-solid fa-cube mr-1"></i>3D <span x-show="signalsLive.depth">✓</span>
</span>
<span class="text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full transition-all duration-300" :class="signalsLive.motion ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400'">
<i class="fa-solid fa-wind mr-1"></i>Gerak <span x-show="signalsLive.motion">✓</span>
</span>
</div>
{{-- Petunjuk kecil --}}
<div class="flex items-center justify-center gap-2 mt-3 text-[11px] text-gray-400 min-h-[18px]">
<i class="fa-solid fa-lightbulb text-amber-400"></i>
<span x-show="cameraOn && !preview && !engineOk" class="text-red-500 font-bold"><i class="fa-solid fa-triangle-exclamation mr-1"></i>Mesin deteksi tidak termuat — muat ulang halaman</span>
<span x-show="!preview && !autoScanning && !autoNoMatch && !needBlink && !nudge && engineOk">Scan otomatis aktif — arahkan kamera ke wajah, hasil muncul sendiri</span>
<span x-show="autoScanning" class="text-cyan-500 font-semibold"><i class="fa-solid fa-circle-notch fa-spin mr-1"></i>Mencocokkan dengan database…</span>
<span x-show="autoNoMatch && !autoScanning && !preview && !result" class="text-amber-500 font-semibold"><i class="fa-solid fa-eye-slash mr-1"></i>Belum dikenali — posisikan wajah lebih jelas</span>
<span x-show="needBlink && !autoScanning && !preview && !result" class="text-cyan-500 font-semibold"><i class="fa-solid fa-eye mr-1"></i>Kedipkan mata / tersenyum / gerakkan kepala sebentar…</span>
<span x-show="nudge && !autoScanning && !preview && !result" class="text-cyan-600 font-bold animate-pulse"><i class="fa-solid fa-hand mr-1"></i>Silakan berkedip / tersenyum / gelengkan kepala</span>
<span x-show="preview && !result">Foto diambil — sentuh <b class="text-gray-500">Scan Lagi</b> untuk mengambil ulang</span>
</div>
</div>
{{-- Tombol aksi saat preview (setelah capture / dari galeri) --}}
<template x-if="preview && !loading">
<div class="flex items-center justify-center gap-3">
<button type="button" @click="captureAgain()"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-gray-600 bg-white border border-gray-200 rounded-2xl hover:bg-gray-50 active:scale-95 transition shadow-sm">
<i class="fa-solid fa-rotate text-gray-400"></i> Ambil Ulang
</button>
<button type="button" @click="scan()"
class="inline-flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-gradient-to-r from-emerald-500 to-teal-500 rounded-2xl hover:from-emerald-600 hover:to-teal-600 active:scale-95 transition shadow-lg shadow-emerald-500/25">
<i class="fa-solid fa-camera-retro"></i> Scan Sekarang
</button>
</div>
</template>
{{-- Error kamera --}}
<div x-show="cameraError" class="p-4 rounded-2xl bg-red-50 border border-red-200 text-sm text-red-600 flex items-start gap-3">
<i class="fa-solid fa-triangle-exclamation mt-0.5"></i> <span x-text="cameraError"></span>
</div>
<div x-show="error && !cameraError" class="p-4 rounded-2xl bg-amber-50 border border-amber-200 text-sm text-amber-700 flex items-start gap-3">
<i class="fa-solid fa-circle-exclamation mt-0.5"></i> <span x-text="error"></span>
</div>
<div x-show="livenessOff" class="p-4 rounded-2xl bg-orange-50 border border-orange-200 text-sm font-semibold text-orange-600 flex items-start gap-3">
<i class="fa-solid fa-triangle-exclamation mt-0.5"></i> Mesin liveness tidak aktif di perangkat ini — hasil scan memerlukan konfirmasi manual staff.
</div>
{{-- ===== HASIL: SISWA DIKENALI ===== --}}
<template x-if="result && result.matched && result.candidates.length">
<div class="overflow-hidden rounded-3xl bg-white shadow-xl shadow-emerald-500/5 ring-1 ring-emerald-100">
<div class="relative overflow-hidden bg-gradient-to-r from-emerald-500 to-teal-400 px-6 py-4 flex items-center gap-3">
<div class="absolute -top-8 -right-8 w-32 h-32 rounded-full bg-white/10 blur-xl pointer-events-none"></div>
<div class="absolute bottom-0 right-16 w-20 h-20 rounded-full border border-white/15 pointer-events-none"></div>
<div class="w-12 h-12 rounded-2xl bg-white/20 backdrop-blur flex items-center justify-center shadow-inner ring-1 ring-white/30">
<i class="fa-solid fa-circle-check text-white text-xl"></i>
</div>
<div class="flex-1">
<p class="text-white font-bold text-base">Siswa Dikenali!</p>
<p class="text-emerald-50/90 text-xs">Identitas cocok dengan database siswa</p>
</div>
<div class="flex flex-col items-end gap-1.5">
<span x-show="!livenessWarn" class="flex items-center gap-1 text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full bg-emerald-700/40 text-white">
<i class="fa-solid fa-eye"></i> LIVENESS OK · <span x-text="(signalCount || 1) + ' sinyal'"></span>
</span>
<span class="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-wider px-3 py-1.5 rounded-full bg-white/15 text-white ring-1 ring-white/30 backdrop-blur">
<i class="fa-solid fa-user-check"></i> TERVERIFIKASI
</span>
</div>
</div>
<div class="p-6 space-y-5">
<p x-show="result.ambiguous" class="p-3.5 rounded-2xl bg-amber-50 border border-amber-200 text-xs font-semibold text-amber-700 flex items-center gap-2">
<i class="fa-solid fa-triangle-exclamation"></i> Hasil ragu (kemungkinan kembar) — pilih siswa yang benar di bawah
</p>
<p x-show="livenessWarn" class="p-3.5 rounded-2xl bg-amber-50 border border-amber-200 text-xs font-semibold text-amber-700 flex items-center gap-2">
<i class="fa-solid fa-triangle-exclamation"></i> Liveness tidak terverifikasi — pastikan siswa asli berada di depan kamera (bukan foto).
</p>
<label x-show="livenessWarn" class="flex items-center gap-2.5 p-3.5 rounded-2xl bg-amber-50/60 border border-amber-200 text-xs font-semibold text-amber-800 cursor-pointer">
<input type="checkbox" x-model="confirmLiveness" class="w-4 h-4 rounded accent-amber-600">
Saya konfirmasi siswa asli berada di depan kamera saat scan ini
</label>
{{-- Kandidat --}}
<div class="space-y-3">
<template x-for="c in result.candidates" :key="c.id">
<div class="rounded-2xl p-4 transition-all duration-200 cursor-pointer border-2"
:class="c.id === chosenId ? 'border-emerald-400 bg-emerald-50/50 shadow-lg shadow-emerald-500/10' : 'border-gray-100 bg-white hover:border-gray-200'"
@click="result.candidates.length > 1 && (chosenId = c.id)">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-500 text-white flex items-center justify-center font-extrabold text-lg shadow-md shrink-0"
x-text="c.full_name.charAt(0).toUpperCase()"></div>
<div class="flex-1 min-w-0">
<p class="font-bold text-gray-800 truncate" x-text="c.full_name"></p>
<p class="text-xs text-gray-500 mt-0.5" x-text="c.nisn + ' · ' + c.class_name"></p>
</div>
<div class="relative w-14 h-14 shrink-0">
<svg class="w-14 h-14 -rotate-90" viewBox="0 0 56 56">
<circle cx="28" cy="28" r="24" fill="none" stroke="#e2e8f0" stroke-width="5"></circle>
<circle cx="28" cy="28" r="24" fill="none" stroke="#10b981" stroke-width="5" stroke-linecap="round"
:stroke-dasharray="150.8" :stroke-dashoffset="150.8 * (1 - c.score)"></circle>
</svg>
<div class="absolute inset-0 flex items-center justify-center text-[11px] font-black" :class="(c.score*100) >= 95 ? 'text-emerald-600' : 'text-amber-600'" x-text="Math.round(c.score*100) + '%'"></div>
</div>
</div>
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-gray-50" x-show="result.candidates.length > 1">
<span class="text-xs text-gray-400"><i class="fa-solid fa-hand-pointer mr-1"></i>Pilih kandidat yang benar:</span>
<span class="text-[11px] font-bold px-2.5 py-1 rounded-lg"
:class="c.id === chosenId ? 'bg-emerald-500 text-white' : 'bg-gray-100 text-gray-500'"
x-text="c.id === chosenId ? '✓ Dipilih' : 'Ketuk untuk pilih'"></span>
</div>
</div>
</template>
</div>
{{-- Profil + poin --}}
<div class="grid grid-cols-3 gap-3">
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 p-3.5 text-center shadow-md shadow-indigo-500/20">
<div class="absolute right-0 top-0 w-10 h-10 opacity-15"><i class="fa-solid fa-chart-simple text-3xl"></i></div>
<p class="text-[10px] font-bold text-indigo-100 uppercase tracking-wider">Total Poin</p>
<p class="text-xl font-extrabold text-white mt-1" x-text="selectedCandidate ? selectedCandidate.total_points : 0"></p>
</div>
<div class="rounded-2xl bg-gray-50 p-3.5 text-center">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Kelas</p>
<p class="text-sm font-bold text-gray-700 mt-1 truncate px-1" x-text="selectedCandidate ? (selectedCandidate.class_name || '—') : '—'"></p>
</div>
<div class="rounded-2xl bg-gray-50 p-3.5 text-center">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">NISN</p>
<p class="text-sm font-bold text-gray-700 mt-1 truncate px-1" x-text="selectedCandidate ? (selectedCandidate.nisn || '—') : '—'"></p>
</div>
</div>
{{-- Pelanggaran terakhir --}}
<div x-show="selectedCandidate && selectedCandidate.recent_violations.length" class="rounded-2xl border border-gray-100 p-4">
<p class="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<i class="fa-solid fa-clock-rotate-left"></i> Pelanggaran Terakhir
</p>
<div class="space-y-2.5">
<template x-for="(v, i) in selectedCandidate.recent_violations" :key="i">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="w-7 h-7 rounded-lg bg-red-50 text-red-500 flex items-center justify-center shrink-0">
<i class="fa-solid fa-gavel text-[10px]"></i>
</div>
<span class="text-sm text-gray-600 truncate" x-text="v.date + ' — ' + v.type"></span>
</div>
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-red-50 text-red-600 shrink-0" x-text="'+' + v.points + ' poin'"></span>
</div>
</template>
</div>
</div>
{{-- Aksi --}}
<div class="flex flex-col sm:flex-row items-stretch gap-3 pt-1">
<a :href="(livenessWarn && !confirmLiveness) ? null : '{{ route('violations.create') }}?student_id=' + chosenId + '&name=' + encodeURIComponent(chosenName) + '&info=' + encodeURIComponent(chosenInfo)"
:class="(livenessWarn && !confirmLiveness) ? 'opacity-40 pointer-events-none' : ''"
class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-3.5 text-sm font-bold text-white bg-gradient-to-r from-blue-500 to-indigo-500 rounded-2xl hover:from-blue-600 hover:to-indigo-600 active:scale-[0.98] transition shadow-lg shadow-blue-500/25">
<i class="fa-solid fa-plus"></i> Tambah Pelanggaran
</a>
<button type="button" @click="reset()"
class="inline-flex items-center justify-center gap-2 px-5 py-3.5 text-sm font-semibold text-gray-600 bg-white border border-gray-200 rounded-2xl hover:bg-gray-50 active:scale-[0.98] transition">
<i class="fa-solid fa-rotate text-gray-400"></i> Scan Lagi
</button>
</div>
</div>
</div>
</template>
{{-- ===== HASIL: TIDAK DIKENALI ===== --}}
<template x-if="result && !result.matched">
<div class="overflow-hidden rounded-3xl bg-white shadow-xl ring-1 ring-amber-100">
<div class="bg-gradient-to-r from-amber-500 to-orange-400 px-6 py-4 flex items-center gap-3">
<div class="w-11 h-11 rounded-2xl bg-white/20 backdrop-blur flex items-center justify-center shadow-inner">
<i class="fa-solid fa-user-slash text-white text-lg"></i>
</div>
<div class="flex-1">
<p class="text-white font-bold text-base">Siswa Belum Terdaftar</p>
<p class="text-amber-50/90 text-xs">Wajah tidak dikenali di database siswa</p>
</div>
</div>
<div class="p-6">
<div class="rounded-2xl bg-amber-50/60 border border-amber-100 p-4 text-sm text-amber-700 mb-5 flex items-start gap-3">
<i class="fa-solid fa-circle-info mt-0.5"></i>
<span>Wajah tidak dikenali — simpan foto ini atas nama siswa agar bisa dikenali di kemudian hari.</span>
</div>
{{-- Sukses daftar --}}
<template x-if="regDone">
<div class="rounded-2xl bg-emerald-50 border border-emerald-200 p-4 mb-4">
<p class="text-sm font-bold text-emerald-700 flex items-center gap-2"><i class="fa-solid fa-circle-check"></i> Wajah <span x-text="regName"></span> berhasil terdaftar (1 foto)</p>
<p class="text-xs text-emerald-600 mt-1">Scan ulang sekarang untuk verifikasi. Tambahkan foto lain di halaman Registrasi Wajah untuk akurasi lebih baik.</p>
</div>
</template>
{{-- Form simpan cepat --}}
<template x-if="!regDone">
<div class="rounded-2xl border border-gray-100 p-4 mb-4">
<div class="flex items-center gap-3 mb-3">
<img :src="lastScanPreview" class="w-14 h-14 rounded-xl object-cover border border-gray-200 shadow-sm">
<div>
<p class="text-sm font-bold text-gray-700">Simpan foto ini atas nama siswa:</p>
<p class="text-xs text-gray-400">Foto hasil scan akan dipakai sebagai referensi wajah</p>
</div>
</div>
<div class="relative">
<i class="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input type="text" x-model="regQuery" @input.debounce="searchStudent" placeholder="Cari NISN / nama siswa..."
class="w-full pl-9 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-400 transition">
</div>
<div x-show="regResults.length > 0" class="mt-1.5 max-h-44 overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg">
<template x-for="s in regResults" :key="s.id">
<button type="button" @click="selectRegStudent(s)"
class="w-full text-left px-3.5 py-2.5 hover:bg-amber-50 transition flex items-center gap-2.5 border-b border-gray-50 last:border-0">
<div class="w-8 h-8 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center font-bold text-xs shrink-0" x-text="s.full_name.charAt(0)"></div>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-700 truncate" x-text="s.full_name"></p>
<p class="text-[11px] text-gray-400" x-text="s.nisn + ' · ' + (s.class_name || '')"></p>
</div>
</button>
</template>
</div>
<div x-show="regStudentId && !regResults.length" class="mt-3 flex items-center justify-between gap-3 p-3 rounded-xl bg-amber-50 border border-amber-200">
<p class="text-sm font-semibold text-amber-800"><i class="fa-solid fa-user-check mr-1.5"></i><span x-text="regName"></span></p>
<button type="button" @click="regStudentId = null; regQuery = ''" class="text-xs text-gray-400 hover:text-gray-600">ubah</button>
</div>
<p x-show="regError" class="mt-2 text-xs font-semibold text-red-500" x-text="regError"></p>
<button type="button" @click="saveReg()" :disabled="!regStudentId || regSaving"
class="mt-3 w-full inline-flex items-center justify-center gap-2 px-5 py-3 text-sm font-bold text-white bg-gradient-to-r from-amber-500 to-orange-500 rounded-xl hover:from-amber-600 hover:to-orange-600 active:scale-[0.98] transition shadow-lg shadow-amber-500/25 disabled:opacity-40 disabled:cursor-not-allowed">
<i class="fa-solid fa-floppy-disk" x-show="!regSaving"></i>
<i class="fa-solid fa-spinner fa-spin" x-show="regSaving"></i>
<span x-text="regSaving ? 'Menyimpan...' : 'Simpan Wajah untuk Siswa Ini'"></span>
</button>
<a :href="'{{ route('face.register') }}?q=' + encodeURIComponent(queryHint)"
class="mt-2.5 block text-center text-xs font-semibold text-gray-400 hover:text-amber-600 transition">
Atau buka halaman Registrasi Wajah (ambil 3 foto) →
</a>
</div>
</template>
<div class="flex flex-col sm:flex-row items-stretch gap-3">
<button type="button" @click="reset()"
class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-3.5 text-sm font-bold text-white bg-gradient-to-r from-blue-500 to-indigo-500 rounded-2xl hover:from-blue-600 hover:to-indigo-600 active:scale-[0.98] transition shadow-lg shadow-blue-500/25">
<i class="fa-solid fa-rotate"></i> Scan Siswa Lain
</button>
</div>
</div>
</div>
</template>
</div>
@if (!$faceidConfigured)
<div class="mt-6 p-4 rounded-2xl bg-red-50 border border-red-200 text-sm text-red-600 flex items-start gap-3">
<i class="fa-solid fa-triangle-exclamation mt-0.5"></i> Microservice FaceID belum dikonfigurasi (FACEID_BASE_URL / FACEID_API_KEY di .env). Fitur scan nonaktif sementara — gunakan input manual.
</div>
@endif
</div>
@endsection
@push('scripts')
<script type="module" src="/vendor/mediapipe/facefinder.js"></script>
<script>
function faceScan() {
return {
preview: null, file: null, loading: false, error: null, cameraError: null,
result: null, chosenId: null, cameraOn: false, detectorError: false, facing: 'environment',
autoScanOn: false, autoTimer: null, autoBusy: false, autoScanning: false, autoNoMatch: false, autoAttempts: 0,
needBlink: false, livenessWarn: false, noBlinkTicks: 0, nudge: false, confirmLiveness: false, signalCount: 0,
livenessOff: false, engineOk: true, signalTimer: null, signalsLive: { blink: false, smile: false, depth: false, motion: false },
lastScanFile: null, lastScanPreview: null, regQuery: '', regResults: [], regStudentId: null, regName: '', regSaving: false, regDone: false, regError: null,
get chosenName() { return this._chosen()?.full_name || ''; },
get chosenInfo() {
const c = this._chosen();
return c ? (c.nisn + ' - ' + (c.class_name || '')) : '';
},
get selectedCandidate() { return this._chosen() || null; },
get queryHint() { return this.selectedCandidate ? this.selectedCandidate.full_name : ''; },
_chosen() {
return (this.result?.candidates || []).find(x => x.id === this.chosenId) || this.result?.candidates?.[0] || null;
},
init() {
window.addEventListener('beforeunload', () => this.stopCamera());
window.addEventListener('face-detector-error', () => {
this.detectorError = true;
const btn = document.getElementById('btnCapture');
if (btn) { btn.disabled = false; btn.classList.remove('opacity-50', 'disabled:ring-white/30'); }
const hint = document.getElementById('faceHint');
if (hint) {
hint.textContent = 'DETEKSI OFF — FOTO TETAP BISA';
hint.className = 'text-[11px] font-bold tracking-wider px-3.5 py-1.5 rounded-full backdrop-blur-md transition-all duration-300 bg-amber-500/80 text-white shadow-lg shadow-amber-500/30';
}
});
window.addEventListener('face-liveness-off', () => {
this.livenessOff = true;
});
},
async startCamera() {
this.cameraError = null;
if (!navigator.mediaDevices?.getUserMedia) {
this.cameraError = 'Perangkat ini tidak mendukung kamera langsung — gunakan ikon galeri di viewfinder.';
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: this.facing, width: { ideal: 1280 } },
audio: false,
});
this.$refs.video.srcObject = stream;
this.cameraOn = true;
this.engineOk = !!window.__faceLoop;
await this.$refs.video.play().catch(() => {});
if (window.__faceLoop) window.__faceLoop.start(this.$refs.video, this.$refs.overlay);
this.needBlink = false; this.livenessWarn = false; this.noBlinkTicks = 0; this.nudge = false; this.confirmLiveness = false;
this.startSignalPoll();
this.startAutoScan();
} catch (e) {
this.cameraError = 'Kamera tidak bisa diakses — izinkan akses kamera di browser, atau gunakan ikon galeri di viewfinder.';
}
},
stopCamera() {
this.stopAutoScan();
this.stopSignalPoll();
if (window.__faceLoop) window.__faceLoop.stop();
const v = this.$refs.video;
if (v && v.srcObject) {
v.srcObject.getTracks().forEach(t => t.stop());
v.srcObject = null;
}
this.cameraOn = false;
},
async switchCamera() {
const prev = this.facing;
this.facing = this.facing === 'user' ? 'environment' : 'user';
this.stopCamera();
this.cameraOn = false;
await this.startCamera();
if (this.cameraError) {
this.cameraError = null;
this.facing = prev;
this.stopCamera();
this.cameraOn = false;
await this.startCamera();
}
},
// ===== AUTO-SCAN: cocokkan frame live dengan database tanpa tombol =====
startSignalPoll() {
if (this.signalTimer) return;
this.signalTimer = setInterval(() => {
const s = window.__faceLoop?.signals;
if (s) this.signalsLive = { ...s };
}, 500);
},
stopSignalPoll() {
if (this.signalTimer) { clearInterval(this.signalTimer); this.signalTimer = null; }
},
startAutoScan() {
if (this.autoTimer) return;
this.autoScanOn = true;
this.autoAttempts = 0;
this.autoNoMatch = false;
// Tick pertama segera (setelah kamera stabil), lalu tiap 1.2 detik
setTimeout(() => this.autoTick(), 400);
this.autoTimer = setInterval(() => this.autoTick(), 1200);
},
stopAutoScan() {
if (this.autoTimer) { clearInterval(this.autoTimer); this.autoTimer = null; }
this.autoScanOn = false;
this.autoBusy = false;
this.autoScanning = false;
},
grabFrame() {
return new Promise((resolve) => {
const v = this.$refs.video;
if (!v || !v.videoWidth) { resolve(null); return; }
// Downscale ke maks 960px + kompresi 0.82 → upload kecil, matching tetap akurat
const maxDim = 960;
const scale = Math.min(1, maxDim / Math.max(v.videoWidth, v.videoHeight));
const canvas = document.createElement('canvas');
canvas.width = Math.round(v.videoWidth * scale);
canvas.height = Math.round(v.videoHeight * scale);
canvas.getContext('2d').drawImage(v, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) { resolve(null); return; }
resolve(new File([blob], 'auto-' + Date.now() + '.jpg', { type: 'image/jpeg' }));
}, 'image/jpeg', 0.82);
});
},
async autoTick() {
if (this.autoBusy || this.loading || this.result || this.preview || !this.cameraOn) return;
// Tanpa wajah di frame → tunggu (kecuali deteksi otomatis sedang error)
if (!this.detectorError && window.__faceLoop && !window.__faceLoop.detected) {
this.autoNoMatch = true;
return;
}
this.autoBusy = true;
this.autoScanning = true;
const file = await this.grabFrame();
if (!file) { this.autoBusy = false; this.autoScanning = false; return; }
this.lastScanFile = file;
if (this.lastScanPreview) URL.revokeObjectURL(this.lastScanPreview);
this.lastScanPreview = URL.createObjectURL(file);
const fd = new FormData();
fd.append('photo', file);
fetch('{{ route('face.scan.verify') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: fd,
})
.then(r => r.json())
.then(d => {
if (!d.ok) { this.autoNoMatch = true; return; }
this.autoAttempts++;
const top = d.candidates?.[0];
// Ragu (kemungkinan kembar) → tampilkan kandidat untuk dipilih
if (d.matched && d.ambiguous) {
this._maybeFinishAuto(d);
return;
}
// Cocok kuat → langsung tampilkan hasil
if (d.matched && top && top.score >= 0.85) {
this._maybeFinishAuto(d);
return;
}
this.autoNoMatch = this.autoAttempts >= 3;
if (this.autoAttempts >= 3) {
// Belum dikenal → tampilkan kartu simpan-cepat (registrasi atas nama siswa)
this.result = { matched: false, candidates: [] };
}
})
.catch(() => {})
.finally(() => {
this.autoBusy = false;
setTimeout(() => { this.autoScanning = false; }, 500);
});
},
_maybeFinishAuto(d) {
const loop = window.__faceLoop;
// Penerimaan: kedip / senyum (sinyal kuat). Depth & motion hanya info (foto dipegang ikut bergerak)
if (loop && loop.livenessAvailable && loop.signalCount > 0) {
this._finishAuto(d);
return;
}
// Mesin liveness tidak aktif → jangan auto-accept; wajib konfirmasi staff
if (loop && !loop.livenessAvailable) {
this.livenessWarn = true;
this._finishAuto(d);
return;
}
// Wajah cocok tapi belum ada sinyal → minta gerakan dulu (anti foto diam)
this.needBlink = true;
this.noBlinkTicks++;
if (this.noBlinkTicks >= 3) this.nudge = true;
if (this.noBlinkTicks >= 6) {
// Terlalu lama tanpa liveness → tampilkan dengan peringatan + konfirmasi staff
this.livenessWarn = true;
this._finishAuto(d);
}
},
_finishAuto(d) {
this.result = d;
this.signalCount = window.__faceLoop ? window.__faceLoop.signalCount : 0;
if (d.candidates.length) this.chosenId = d.candidates[0].id;
this.stopAutoScan();
this.stopCamera();
},
capture() {
this.stopAutoScan();
if (!this.detectorError && window.__faceLoop && !window.__faceLoop.detected) {
this.error = 'Tidak ada wajah terdeteksi — hadapkan kamera ke wajah siswa.';
return;
}
const v = this.$refs.video;
if (!v || !v.srcObject || v.videoWidth === 0) return;
const maxDim = 960;
const scale = Math.min(1, maxDim / Math.max(v.videoWidth, v.videoHeight));
const canvas = document.createElement('canvas');
canvas.width = Math.round(v.videoWidth * scale);
canvas.height = Math.round(v.videoHeight * scale);
canvas.getContext('2d').drawImage(v, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) return;
this.file = new File([blob], 'scan-' + Date.now() + '.jpg', { type: 'image/jpeg' });
this.preview = URL.createObjectURL(this.file);
this.result = null; this.chosenId = null; this.error = null;
this.stopCamera();
this.scan();
}, 'image/jpeg', 0.85);
},
captureAgain() {
this.preview = null; this.file = null; this.result = null;
this.startCamera();
},
onFile(e) {
const f = e.target.files[0];
if (!f) return;
this.file = f;
this.preview = URL.createObjectURL(f);
this.result = null; this.chosenId = null; this.error = null;
},
scan() {
if (!this.file) { this.error = 'Ambil foto wajah dulu.'; return; }
this.lastScanFile = this.file;
this.lastScanPreview = this.preview;
this.loading = true; this.error = null; this.result = null;
const fd = new FormData();
fd.append('photo', this.file);
fetch('{{ route('face.scan.verify') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: fd,
})
.then(r => r.json())
.then(d => {
if (!d.ok) {
this.error = d.error === 'faceid_down'
? 'Layanan FaceID sedang mati — gunakan input manual.'
: 'Verifikasi gagal: ' + (d.error || 'unknown');
return;
}
this.result = d;
if (d.candidates.length) this.chosenId = d.candidates[0].id;
})
.catch(() => { this.error = 'Terjadi kesalahan jaringan.'; })
.finally(() => { this.loading = false; });
},
searchStudent() {
if (this.regQuery.length < 2) { this.regResults = []; return; }
fetch('{{ route('api.students.search') }}?q=' + encodeURIComponent(this.regQuery))
.then(r => r.json())
.then(data => { this.regResults = data; });
},
selectRegStudent(s) {
this.regStudentId = s.id;
this.regName = s.full_name;
this.regQuery = s.full_name;
this.regResults = [];
},
saveReg() {
if (!this.regStudentId || !this.lastScanFile) return;
this.regSaving = true; this.regError = null;
const fd = new FormData();
fd.append('student_id', this.regStudentId);
fd.append('photo', this.lastScanFile);
fetch('{{ route('face.register.quick') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: fd,
})
.then(r => r.json())
.then(d => {
if (!d.ok) {
this.regError = d.detail && d.detail.length
? 'Foto ditolak quality gate: ' + d.detail.map(x => x.reason).join(', ')
: 'Registrasi gagal: ' + (d.error || 'unknown');
return;
}
this.regDone = true;
})
.catch(() => { this.regError = 'Terjadi kesalahan jaringan.'; })
.finally(() => { this.regSaving = false; });
},
reset() {
this.preview = null; this.file = null; this.result = null;
this.chosenId = null; this.error = null; this.cameraError = null;
this.needBlink = false; this.livenessWarn = false; this.noBlinkTicks = 0; this.nudge = false; this.confirmLiveness = false;
this.regQuery = ''; this.regResults = []; this.regStudentId = null; this.regName = ''; this.regSaving = false; this.regDone = false; this.regError = null;
if (window.__faceLoop) window.__faceLoop.resetLiveness();
this.stopCamera();
},
};
}
</script>
@endpush