feat: susun semula layout urus template sijil

- Panduan Template di bahagian atas, boleh lipat/kembang
- Template Aktif (kiri) bersebelahan Konfigurasi Teks (kanan) — col-lg-6
- Auto-detect portrait/landscape dari naturalWidth/naturalHeight imej
- Portrait: max-height 520px | Landscape: max-height 340px
- Badge orientasi (hijau=Landscape, biru=Portrait) dalam header kad
- Laras tinggi juga untuk pratonton upload form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Saufi
2026-05-18 21:32:27 +08:00
parent 29d85eea86
commit 0417a6698a

View File

@@ -17,14 +17,71 @@
@section('content') @section('content')
<div class="row g-4"> @php
{{-- Left: Current template --}} $config = $template?->config_json ?? [];
<div class="col-md-7"> $fields = $config['fields'] ?? [];
$imgWidth = $config['width'] ?? 0;
$imgHeight = $config['height'] ?? 0;
$isPortrait = $imgHeight > $imgWidth && $imgWidth > 0;
@endphp
{{-- ── Panduan Template (atas, boleh lipat) ────────────────────────────── --}}
<div class="card border-0 bg-light mb-4">
<div class="card-header bg-transparent border-0 py-2 d-flex justify-content-between align-items-center"
role="button" data-bs-toggle="collapse" data-bs-target="#guidePanel" aria-expanded="false">
<span class="fw-semibold small"><i class="bi bi-info-circle me-2 text-primary"></i>Panduan Template</span>
<i class="bi bi-chevron-down small text-muted" id="guideChevron"></i>
</div>
<div class="collapse" id="guidePanel">
<div class="card-body pt-0 pb-3">
<div class="row g-3">
<div class="col-md-4">
<div class="d-flex gap-2">
<i class="bi bi-aspect-ratio text-primary mt-1 flex-shrink-0"></i>
<div class="small text-muted">
Resolusi disyorkan <strong>1754 × 1240px</strong> (A4 landscape 150dpi)
atau <strong>1240 × 1754px</strong> (portrait).
</div>
</div>
</div>
<div class="col-md-4">
<div class="d-flex gap-2">
<i class="bi bi-cursor text-primary mt-1 flex-shrink-0"></i>
<div class="small text-muted">
Koordinat <strong>(0, 0)</strong> adalah sudut kiri atas imej.
X bertambah ke kanan, Y bertambah ke bawah.
</div>
</div>
</div>
<div class="col-md-4">
<div class="d-flex gap-2">
<i class="bi bi-eye text-primary mt-1 flex-shrink-0"></i>
<div class="small text-muted">
Guna butang <strong>Pratonton</strong> untuk semak kedudukan teks
sebelum jana sijil sebenar.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@if($template) @if($template)
<div class="card border-0 shadow-sm mb-4">
{{-- ── Template Aktif (kiri) + Konfigurasi (kanan) ─────────────────────── --}}
<div class="row g-4">
{{-- Kiri: Template Aktif --}}
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center"> <div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-image me-2 text-primary"></i>Template Aktif</h6> <h6 class="mb-0 fw-semibold"><i class="bi bi-image me-2 text-primary"></i>Template Aktif</h6>
<span id="orientationBadge" class="badge bg-secondary" style="font-size:.7rem;">
{{ $isPortrait ? 'Portrait' : ($imgWidth > 0 ? 'Landscape' : '—') }}
</span>
</div>
<form method="POST" action="{{ route('admin.programs.template.destroy', $program) }}" <form method="POST" action="{{ route('admin.programs.template.destroy', $program) }}"
onsubmit="return confirm('Padam template sijil ini? Tindakan ini tidak boleh diundur.')"> onsubmit="return confirm('Padam template sijil ini? Tindakan ini tidak boleh diundur.')">
@csrf @method('DELETE') @csrf @method('DELETE')
@@ -33,52 +90,64 @@
</button> </button>
</form> </form>
</div> </div>
<div class="card-body"> <div class="card-body d-flex flex-column">
<div class="text-center mb-3">
<div class="cert-preview-wrapper border rounded overflow-hidden" style="max-height:380px;"> {{-- Image viewer tinggi berubah ikut orientasi --}}
<div id="previewWrapper"
class="border rounded overflow-hidden mb-2 d-flex align-items-center justify-content-center bg-light"
style="max-height:{{ $isPortrait ? '520px' : '340px' }}; transition: max-height .3s ease;">
<img src="{{ route('admin.programs.template.preview', $program) }}" <img src="{{ route('admin.programs.template.preview', $program) }}"
id="templatePreview" id="templatePreview"
alt="Template Preview" alt="Template Preview"
class="img-fluid w-100" class="img-fluid"
style="object-fit:contain;"> style="max-width:100%; max-height:{{ $isPortrait ? '520px' : '340px' }}; object-fit:contain;">
</div> </div>
<div class="mt-2 text-muted small">{{ $template->original_filename }}</div> <div class="text-muted small text-center mb-3">
{{ $template->original_filename }}
@if($imgWidth > 0)
&nbsp;·&nbsp;<span id="dimensionLabel">{{ $imgWidth }} × {{ $imgHeight }} px</span>
@endif
</div> </div>
{{-- Test Generate --}} {{-- Jana Pratonton --}}
<div class="border rounded p-3 bg-light"> <div class="border rounded p-3 bg-light mt-auto">
<label class="form-label small fw-medium">Jana Pratonton</label> <label class="form-label small fw-medium mb-2">Jana Pratonton</label>
<div class="row g-2"> <div class="row g-2">
<div class="col-8"> <div class="col-8">
<input type="text" id="sampleName" class="form-control form-control-sm" <input type="text" id="sampleName" class="form-control form-control-sm"
value="NAMA PESERTA CONTOH" placeholder="Nama untuk pratonton"> value="NAMA PESERTA CONTOH" placeholder="Nama untuk pratonton">
</div> </div>
<div class="col-4"> <div class="col-4">
<button type="button" id="previewBtn" class="btn btn-sm btn-primary w-100" onclick="loadPreview()"> <button type="button" id="previewBtn" class="btn btn-sm btn-primary w-100"
onclick="loadPreview()">
<i class="bi bi-eye me-1"></i> Pratonton <i class="bi bi-eye me-1"></i> Pratonton
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
</div> </div>
{{-- Config Editor --}} {{-- Kanan: Konfigurasi Kedudukan Teks --}}
<div class="card border-0 shadow-sm"> <div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white py-3"> <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> <h6 class="mb-0 fw-semibold"><i class="bi bi-sliders me-2 text-warning"></i>Konfigurasi Kedudukan Teks</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
@php $config = $template->config_json ?? []; $fields = $config['fields'] ?? []; @endphp <p class="text-muted small mb-3">
Koordinat X dan Y dikira dari sudut kiri atas imej (piksel).
@if($imgWidth > 0)
Saiz imej: <strong>{{ $imgWidth }} × {{ $imgHeight }} px</strong>.
@endif
</p>
<form method="POST" action="{{ route('admin.programs.template.config', $program) }}"> <form method="POST" action="{{ route('admin.programs.template.config', $program) }}">
@csrf @method('PUT') @csrf @method('PUT')
<p class="text-muted small mb-3"> {{-- Nama Peserta --}}
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 border mb-3">
<div class="card-header py-2 bg-light"> <div class="card-header py-2 bg-light">
<span class="fw-medium small">Nama Peserta</span> <span class="fw-medium small">Nama Peserta</span>
@@ -86,12 +155,12 @@
<div class="card-body py-3"> <div class="card-body py-3">
<div class="row g-2"> <div class="row g-2">
<div class="col-4"> <div class="col-4">
<label class="form-label small">X (Piksel)</label> <label class="form-label small">X (px)</label>
<input type="number" name="fields[name][x]" class="form-control form-control-sm" <input type="number" name="fields[name][x]" class="form-control form-control-sm"
value="{{ $fields['name']['x'] ?? 800 }}"> value="{{ $fields['name']['x'] ?? 800 }}">
</div> </div>
<div class="col-4"> <div class="col-4">
<label class="form-label small">Y (Piksel)</label> <label class="form-label small">Y (px)</label>
<input type="number" name="fields[name][y]" class="form-control form-control-sm" <input type="number" name="fields[name][y]" class="form-control form-control-sm"
value="{{ $fields['name']['y'] ?? 400 }}"> value="{{ $fields['name']['y'] ?? 400 }}">
</div> </div>
@@ -102,7 +171,8 @@
</div> </div>
<div class="col-4"> <div class="col-4">
<label class="form-label small">Warna</label> <label class="form-label small">Warna</label>
<input type="color" name="fields[name][font_color]" class="form-control form-control-color form-control-sm" <input type="color" name="fields[name][font_color]"
class="form-control form-control-color form-control-sm"
value="{{ $fields['name']['font_color'] ?? '#1a3a6b' }}"> value="{{ $fields['name']['font_color'] ?? '#1a3a6b' }}">
</div> </div>
<div class="col-4"> <div class="col-4">
@@ -117,9 +187,9 @@
</div> </div>
</div> </div>
{{-- Certificate No (optional) --}} {{-- No. Sijil (pilihan) --}}
<div class="card border mb-4"> <div class="card border mb-4">
<div class="card-header py-2 bg-light d-flex justify-content-between"> <div class="card-header py-2 bg-light d-flex justify-content-between align-items-center">
<span class="fw-medium small">No. Sijil <span class="text-muted">(Pilihan)</span></span> <span class="fw-medium small">No. Sijil <span class="text-muted">(Pilihan)</span></span>
<div class="form-check form-switch mb-0"> <div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="showCertNo" <input class="form-check-input" type="checkbox" id="showCertNo"
@@ -128,15 +198,16 @@
<label class="form-check-label small" for="showCertNo">Papar</label> <label class="form-check-label small" for="showCertNo">Papar</label>
</div> </div>
</div> </div>
<div class="card-body py-3" id="certNoFields" {{ isset($fields['certificate_no']) ? '' : 'style=display:none' }}> <div class="card-body py-3" id="certNoFields"
{{ isset($fields['certificate_no']) ? '' : 'style=display:none' }}>
<div class="row g-2"> <div class="row g-2">
<div class="col-4"> <div class="col-4">
<label class="form-label small">X</label> <label class="form-label small">X (px)</label>
<input type="number" name="fields[certificate_no][x]" class="form-control form-control-sm" <input type="number" name="fields[certificate_no][x]" class="form-control form-control-sm"
value="{{ $fields['certificate_no']['x'] ?? 800 }}"> value="{{ $fields['certificate_no']['x'] ?? 800 }}">
</div> </div>
<div class="col-4"> <div class="col-4">
<label class="form-label small">Y</label> <label class="form-label small">Y (px)</label>
<input type="number" name="fields[certificate_no][y]" class="form-control form-control-sm" <input type="number" name="fields[certificate_no][y]" class="form-control form-control-sm"
value="{{ $fields['certificate_no']['y'] ?? 460 }}"> value="{{ $fields['certificate_no']['y'] ?? 460 }}">
</div> </div>
@@ -147,7 +218,8 @@
</div> </div>
<div class="col-4"> <div class="col-4">
<label class="form-label small">Warna</label> <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" <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' }}"> value="{{ $fields['certificate_no']['font_color'] ?? '#555555' }}">
</div> </div>
<div class="col-4"> <div class="col-4">
@@ -168,9 +240,15 @@
</form> </form>
</div> </div>
</div> </div>
</div>
</div><!-- /.row -->
@else @else
{{-- Upload Form --}}
{{-- Tiada template form upload --}}
<div class="row g-4">
<div class="col-lg-7">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3"> <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> <h6 class="mb-0 fw-semibold"><i class="bi bi-upload me-2 text-primary"></i>Muat Naik Template Sijil</h6>
@@ -178,11 +256,13 @@
<div class="card-body"> <div class="card-body">
<div class="alert alert-info small mb-4"> <div class="alert alert-info small mb-4">
<i class="bi bi-lightbulb me-1"></i> <i class="bi bi-lightbulb me-1"></i>
Muat naik imej template sijil dalam format <strong>JPG atau PNG</strong>. Format <strong>JPG atau PNG</strong>, maksimum <strong>10MB</strong>.
Saiz maksimum: <strong>10MB</strong>. Resolusi disyorkan: <strong>1754 × 1240px</strong> (A4 landscape 150dpi). Resolusi disyorkan: <strong>1754 × 1240px</strong> (A4 landscape) atau
<strong>1240 × 1754px</strong> (portrait).
</div> </div>
<form method="POST" action="{{ route('admin.programs.template.store', $program) }}" enctype="multipart/form-data"> <form method="POST" action="{{ route('admin.programs.template.store', $program) }}"
enctype="multipart/form-data">
@csrf @csrf
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-medium">Fail Template <span class="text-danger">*</span></label> <label class="form-label fw-medium">Fail Template <span class="text-danger">*</span></label>
@@ -195,7 +275,7 @@
<div id="imagePreviewBox" class="mb-4 d-none"> <div id="imagePreviewBox" class="mb-4 d-none">
<label class="form-label small text-muted">Pratonton:</label> <label class="form-label small text-muted">Pratonton:</label>
<div class="border rounded overflow-hidden"> <div id="uploadPreviewWrapper" class="border rounded overflow-hidden">
<img id="imagePreviewEl" src="" alt="preview" class="img-fluid w-100"> <img id="imagePreviewEl" src="" alt="preview" class="img-fluid w-100">
</div> </div>
</div> </div>
@@ -206,46 +286,79 @@
</form> </form>
</div> </div>
</div> </div>
</div>
</div>
@endif @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 @endsection
@push('scripts') @push('scripts')
<script> <script>
function previewImage(input) { // ── Auto-detect orientasi dan laras tinggi viewer ─────────────────────────────
if (input.files && input.files[0]) { function applyOrientation(naturalW, naturalH) {
const reader = new FileReader(); const wrapper = document.getElementById('previewWrapper');
reader.onload = e => { const img = document.getElementById('templatePreview');
document.getElementById('imagePreviewEl').src = e.target.result; const badge = document.getElementById('orientationBadge');
document.getElementById('imagePreviewBox').classList.remove('d-none'); if (!wrapper || !img) return;
};
reader.readAsDataURL(input.files[0]); const isPortrait = naturalH > naturalW;
const maxH = isPortrait ? '520px' : '340px';
wrapper.style.maxHeight = maxH;
img.style.maxHeight = maxH;
if (badge) {
badge.textContent = isPortrait ? 'Portrait' : 'Landscape';
badge.className = 'badge ' + (isPortrait ? 'bg-info' : 'bg-success');
} }
} }
// Jalankan sekali apabila imej template dimuatkan
const templateImg = document.getElementById('templatePreview');
if (templateImg) {
if (templateImg.complete && templateImg.naturalWidth) {
applyOrientation(templateImg.naturalWidth, templateImg.naturalHeight);
} else {
templateImg.addEventListener('load', function () {
applyOrientation(this.naturalWidth, this.naturalHeight);
});
}
}
// ── Upload pratonton (form muat naik) ─────────────────────────────────────────
function previewImage(input) {
if (!input.files || !input.files[0]) return;
const reader = new FileReader();
reader.onload = e => {
const el = document.getElementById('imagePreviewEl');
const box = document.getElementById('imagePreviewBox');
el.src = e.target.result;
box.classList.remove('d-none');
// Laras pratonton upload ikut orientasi
el.onload = function () {
const wrap = document.getElementById('uploadPreviewWrapper');
if (wrap) wrap.style.maxHeight = this.naturalHeight > this.naturalWidth ? '520px' : '340px';
};
};
reader.readAsDataURL(input.files[0]);
}
// ── Toggle No. Sijil ──────────────────────────────────────────────────────────
function toggleCertNo(cb) { function toggleCertNo(cb) {
document.getElementById('certNoFields').style.display = cb.checked ? '' : 'none'; document.getElementById('certNoFields').style.display = cb.checked ? '' : 'none';
} }
// ── Toggle panduan (ikon chevron) ─────────────────────────────────────────────
document.getElementById('guidePanel')?.addEventListener('show.bs.collapse', () => {
document.getElementById('guideChevron').className = 'bi bi-chevron-up small text-muted';
});
document.getElementById('guidePanel')?.addEventListener('hide.bs.collapse', () => {
document.getElementById('guideChevron').className = 'bi bi-chevron-down small text-muted';
});
// ── Jana Pratonton ────────────────────────────────────────────────────────────
function loadPreview() { function loadPreview() {
const name = document.getElementById('sampleName').value.trim() || 'NAMA PESERTA CONTOH'; const name = document.getElementById('sampleName').value.trim() || 'NAMA PESERTA CONTOH';
const img = document.getElementById('templatePreview'); const img = document.getElementById('templatePreview');