- docker/php/Dockerfile: PHP 8.4-FPM + GD + imagick (PECL) + semua extension Laravel - docker/php/php.ini: upload 20MB, memory 512MB, opcache, Asia/Kuala_Lumpur - docker/php/php-dev.ini: validate_timestamps=1, display_errors=On (dev) - docker/nginx/default.conf: gzip, security headers, static asset caching - docker/entrypoint.sh: tunggu MySQL → migrate → seed AdminSeeder → cache (prod) - docker-compose.yml: dev stack — port 8003, DB host 33060, queue worker - docker-compose.prod.yml: production overrides — storage volume, no DB port exposed - .env.docker: template env untuk Docker (DB_HOST=db) - .dockerignore: exclude node_modules, vendor, .env, logs fix: testGenerate try/catch kembalikan JSON error (bukan HTML 500) fix: loadPreview() semak r.ok, tunjuk error alert, loading spinner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
282 lines
15 KiB
PHP
282 lines
15 KiB
PHP
@extends('layouts.admin')
|
||
|
||
@section('title', 'Template Sijil — ' . $program->title)
|
||
@section('header', 'Template Sijil')
|
||
|
||
@section('breadcrumb')
|
||
<li class="breadcrumb-item"><a href="{{ route('admin.programs.index') }}">Program</a></li>
|
||
<li class="breadcrumb-item"><a href="{{ route('admin.programs.show', $program) }}">{{ Str::limit($program->title, 30) }}</a></li>
|
||
<li class="breadcrumb-item active">Template Sijil</li>
|
||
@endsection
|
||
|
||
@section('header-actions')
|
||
<a href="{{ route('admin.programs.show', $program) }}#tab-template" class="btn btn-sm btn-outline-secondary">
|
||
<i class="bi bi-arrow-left me-1"></i> Kembali
|
||
</a>
|
||
@endsection
|
||
|
||
@section('content')
|
||
|
||
<div class="row g-4">
|
||
{{-- Left: Current template --}}
|
||
<div class="col-md-7">
|
||
|
||
@if($template)
|
||
<div class="card border-0 shadow-sm mb-4">
|
||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0 fw-semibold"><i class="bi bi-image me-2 text-primary"></i>Template Aktif</h6>
|
||
<form method="POST" action="{{ route('admin.programs.template.destroy', $program) }}"
|
||
onsubmit="return confirm('Padam template sijil ini? Tindakan ini tidak boleh diundur.')">
|
||
@csrf @method('DELETE')
|
||
<button class="btn btn-sm btn-outline-danger">
|
||
<i class="bi bi-trash me-1"></i> Padam
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="text-center mb-3">
|
||
<div class="cert-preview-wrapper border rounded overflow-hidden" style="max-height:380px;">
|
||
<img src="{{ route('admin.programs.template.preview', $program) }}"
|
||
id="templatePreview"
|
||
alt="Template Preview"
|
||
class="img-fluid w-100"
|
||
style="object-fit:contain;">
|
||
</div>
|
||
<div class="mt-2 text-muted small">{{ $template->original_filename }}</div>
|
||
</div>
|
||
|
||
{{-- Test Generate --}}
|
||
<div class="border rounded p-3 bg-light">
|
||
<label class="form-label small fw-medium">Jana Pratonton</label>
|
||
<div class="row g-2">
|
||
<div class="col-8">
|
||
<input type="text" id="sampleName" class="form-control form-control-sm"
|
||
value="NAMA PESERTA CONTOH" placeholder="Nama untuk pratonton">
|
||
</div>
|
||
<div class="col-4">
|
||
<button type="button" id="previewBtn" class="btn btn-sm btn-primary w-100" onclick="loadPreview()">
|
||
<i class="bi bi-eye me-1"></i> Pratonton
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Config Editor --}}
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header bg-white py-3">
|
||
<h6 class="mb-0 fw-semibold"><i class="bi bi-sliders me-2 text-warning"></i>Konfigurasi Kedudukan Teks</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
@php $config = $template->config_json ?? []; $fields = $config['fields'] ?? []; @endphp
|
||
<form method="POST" action="{{ route('admin.programs.template.config', $program) }}">
|
||
@csrf @method('PUT')
|
||
|
||
<p class="text-muted small mb-3">
|
||
Koordinat X dan Y dikira dari sudut kiri atas imej (piksel).
|
||
Imej template: <strong>{{ $config['width'] ?? '—' }} × {{ $config['height'] ?? '—' }}</strong> piksel.
|
||
</p>
|
||
|
||
{{-- Name field --}}
|
||
<div class="card border mb-3">
|
||
<div class="card-header py-2 bg-light">
|
||
<span class="fw-medium small">Nama Peserta</span>
|
||
</div>
|
||
<div class="card-body py-3">
|
||
<div class="row g-2">
|
||
<div class="col-4">
|
||
<label class="form-label small">X (Piksel)</label>
|
||
<input type="number" name="fields[name][x]" class="form-control form-control-sm"
|
||
value="{{ $fields['name']['x'] ?? 800 }}">
|
||
</div>
|
||
<div class="col-4">
|
||
<label class="form-label small">Y (Piksel)</label>
|
||
<input type="number" name="fields[name][y]" class="form-control form-control-sm"
|
||
value="{{ $fields['name']['y'] ?? 400 }}">
|
||
</div>
|
||
<div class="col-4">
|
||
<label class="form-label small">Saiz Font</label>
|
||
<input type="number" name="fields[name][font_size]" class="form-control form-control-sm"
|
||
value="{{ $fields['name']['font_size'] ?? 52 }}">
|
||
</div>
|
||
<div class="col-4">
|
||
<label class="form-label small">Warna</label>
|
||
<input type="color" name="fields[name][font_color]" class="form-control form-control-color form-control-sm"
|
||
value="{{ $fields['name']['font_color'] ?? '#1a3a6b' }}">
|
||
</div>
|
||
<div class="col-4">
|
||
<label class="form-label small">Align</label>
|
||
<select name="fields[name][align]" class="form-select form-select-sm">
|
||
<option value="left" {{ ($fields['name']['align'] ?? 'center') === 'left' ? 'selected' : '' }}>Kiri</option>
|
||
<option value="center" {{ ($fields['name']['align'] ?? 'center') === 'center' ? 'selected' : '' }}>Tengah</option>
|
||
<option value="right" {{ ($fields['name']['align'] ?? 'center') === 'right' ? 'selected' : '' }}>Kanan</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Certificate No (optional) --}}
|
||
<div class="card border mb-4">
|
||
<div class="card-header py-2 bg-light d-flex justify-content-between">
|
||
<span class="fw-medium small">No. Sijil <span class="text-muted">(Pilihan)</span></span>
|
||
<div class="form-check form-switch mb-0">
|
||
<input class="form-check-input" type="checkbox" id="showCertNo"
|
||
onchange="toggleCertNo(this)"
|
||
{{ isset($fields['certificate_no']) ? 'checked' : '' }}>
|
||
<label class="form-check-label small" for="showCertNo">Papar</label>
|
||
</div>
|
||
</div>
|
||
<div class="card-body py-3" id="certNoFields" {{ isset($fields['certificate_no']) ? '' : 'style=display:none' }}>
|
||
<div class="row g-2">
|
||
<div class="col-4">
|
||
<label class="form-label small">X</label>
|
||
<input type="number" name="fields[certificate_no][x]" class="form-control form-control-sm"
|
||
value="{{ $fields['certificate_no']['x'] ?? 800 }}">
|
||
</div>
|
||
<div class="col-4">
|
||
<label class="form-label small">Y</label>
|
||
<input type="number" name="fields[certificate_no][y]" class="form-control form-control-sm"
|
||
value="{{ $fields['certificate_no']['y'] ?? 460 }}">
|
||
</div>
|
||
<div class="col-4">
|
||
<label class="form-label small">Saiz Font</label>
|
||
<input type="number" name="fields[certificate_no][font_size]" class="form-control form-control-sm"
|
||
value="{{ $fields['certificate_no']['font_size'] ?? 28 }}">
|
||
</div>
|
||
<div class="col-4">
|
||
<label class="form-label small">Warna</label>
|
||
<input type="color" name="fields[certificate_no][font_color]" class="form-control form-control-color form-control-sm"
|
||
value="{{ $fields['certificate_no']['font_color'] ?? '#555555' }}">
|
||
</div>
|
||
<div class="col-4">
|
||
<label class="form-label small">Align</label>
|
||
<select name="fields[certificate_no][align]" class="form-select form-select-sm">
|
||
<option value="left" {{ ($fields['certificate_no']['align'] ?? 'center') === 'left' ? 'selected' : '' }}>Kiri</option>
|
||
<option value="center" {{ ($fields['certificate_no']['align'] ?? 'center') === 'center' ? 'selected' : '' }}>Tengah</option>
|
||
<option value="right" {{ ($fields['certificate_no']['align'] ?? 'center') === 'right' ? 'selected' : '' }}>Kanan</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="bi bi-save me-1"></i> Simpan Konfigurasi
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
@else
|
||
{{-- Upload Form --}}
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header bg-white py-3">
|
||
<h6 class="mb-0 fw-semibold"><i class="bi bi-upload me-2 text-primary"></i>Muat Naik Template Sijil</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="alert alert-info small mb-4">
|
||
<i class="bi bi-lightbulb me-1"></i>
|
||
Muat naik imej template sijil dalam format <strong>JPG atau PNG</strong>.
|
||
Saiz maksimum: <strong>10MB</strong>. Resolusi disyorkan: <strong>1754 × 1240px</strong> (A4 landscape 150dpi).
|
||
</div>
|
||
|
||
<form method="POST" action="{{ route('admin.programs.template.store', $program) }}" enctype="multipart/form-data">
|
||
@csrf
|
||
<div class="mb-4">
|
||
<label class="form-label fw-medium">Fail Template <span class="text-danger">*</span></label>
|
||
<input type="file" name="template_image" accept="image/jpeg,image/png"
|
||
class="form-control @error('template_image') is-invalid @enderror"
|
||
onchange="previewImage(this)">
|
||
@error('template_image')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||
<div class="form-text">Format: JPG, PNG — Maksimum 10MB</div>
|
||
</div>
|
||
|
||
<div id="imagePreviewBox" class="mb-4 d-none">
|
||
<label class="form-label small text-muted">Pratonton:</label>
|
||
<div class="border rounded overflow-hidden">
|
||
<img id="imagePreviewEl" src="" alt="preview" class="img-fluid w-100">
|
||
</div>
|
||
</div>
|
||
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="bi bi-upload me-1"></i> Muat Naik
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
|
||
</div>
|
||
|
||
{{-- Right: Tips --}}
|
||
<div class="col-md-5">
|
||
<div class="card border-0 bg-light">
|
||
<div class="card-body p-4">
|
||
<h6 class="fw-semibold mb-3"><i class="bi bi-info-circle me-2 text-primary"></i>Panduan Template</h6>
|
||
<ul class="small text-muted mb-0">
|
||
<li class="mb-2">Gunakan imej resolusi tinggi (≥1600px lebar) untuk hasil cetak berkualiti.</li>
|
||
<li class="mb-2">Pastikan ruang untuk nama peserta tidak dihalang oleh grafik template.</li>
|
||
<li class="mb-2">Koordinat (0,0) adalah sudut <strong>kiri atas</strong> imej.</li>
|
||
<li class="mb-2">Gunakan butang <strong>Pratonton</strong> untuk menyemak kedudukan teks sebelum jana sijil sebenar.</li>
|
||
<li class="mb-0">Sijil dijana dalam format JPG.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@endsection
|
||
|
||
@push('scripts')
|
||
<script>
|
||
function previewImage(input) {
|
||
if (input.files && input.files[0]) {
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
document.getElementById('imagePreviewEl').src = e.target.result;
|
||
document.getElementById('imagePreviewBox').classList.remove('d-none');
|
||
};
|
||
reader.readAsDataURL(input.files[0]);
|
||
}
|
||
}
|
||
|
||
function toggleCertNo(cb) {
|
||
document.getElementById('certNoFields').style.display = cb.checked ? '' : 'none';
|
||
}
|
||
|
||
function loadPreview() {
|
||
const name = document.getElementById('sampleName').value.trim() || 'NAMA PESERTA CONTOH';
|
||
const img = document.getElementById('templatePreview');
|
||
const btn = document.getElementById('previewBtn');
|
||
const url = "{{ route('admin.programs.template.test', $program) }}";
|
||
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span> Memuatkan...';
|
||
|
||
const form = new FormData();
|
||
form.append('_token', '{{ csrf_token() }}');
|
||
form.append('sample_name', name);
|
||
|
||
fetch(url, { method: 'POST', body: form })
|
||
.then(r => {
|
||
if (!r.ok) return r.json().then(j => { throw new Error(j.error || 'Ralat pelayan (' + r.status + ')'); });
|
||
return r.blob();
|
||
})
|
||
.then(blob => {
|
||
const prevSrc = img.src;
|
||
img.src = URL.createObjectURL(blob);
|
||
if (prevSrc.startsWith('blob:')) URL.revokeObjectURL(prevSrc);
|
||
})
|
||
.catch(err => {
|
||
alert('Gagal jana pratonton: ' + err.message);
|
||
})
|
||
.finally(() => {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<i class="bi bi-eye me-1"></i> Pratonton';
|
||
});
|
||
}
|
||
</script>
|
||
@endpush
|