466 lines
22 KiB
PHP
466 lines
22 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', $project->title)
|
|
|
|
@section('content')
|
|
|
|
{{-- Header --}}
|
|
<div class="d-flex justify-content-between align-items-start mb-4">
|
|
<div>
|
|
<h4 class="fw-bold mb-1">{{ $project->title }}</h4>
|
|
@if($project->description)
|
|
<p class="text-muted mb-0">{{ $project->description }}</p>
|
|
@endif
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a href="{{ route('user.dashboard') }}" class="btn btn-outline-secondary">
|
|
<i class="bi bi-arrow-left me-1"></i>Dashboard
|
|
</a>
|
|
@if($project->isOwnedBy(auth()->user()))
|
|
<form method="POST" action="{{ route('user.projects.destroy', $project) }}"
|
|
onsubmit="return confirm('Padam projek ini? Tindakan ini tidak boleh dibatalkan.')">
|
|
@csrf @method('DELETE')
|
|
<button class="btn btn-outline-danger">
|
|
<i class="bi bi-trash me-1"></i>Padam Projek
|
|
</button>
|
|
</form>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-4">
|
|
|
|
{{-- Lajur Kiri: Audio + Transcript + Komen --}}
|
|
<div class="col-lg-8">
|
|
|
|
{{-- Status Badge --}}
|
|
@php
|
|
$statusConfig = [
|
|
'pending' => ['secondary', 'bi-hourglass', 'Menunggu dalam barisan...'],
|
|
'processing' => ['warning', 'bi-gear-wide-connected', 'Sedang ditranskripkan...'],
|
|
'completed' => ['success', 'bi-check-circle', 'Transkripsi selesai'],
|
|
'failed' => ['danger', 'bi-x-circle', 'Transkripsi gagal'],
|
|
];
|
|
[$sc, $si, $sl] = $statusConfig[$project->transcription_status] ?? ['secondary', 'bi-question', 'Tidak diketahui'];
|
|
@endphp
|
|
|
|
<div id="status-alert" class="alert alert-{{ $sc }} d-flex align-items-center justify-content-between mb-3 py-2">
|
|
<span id="status-text"><i class="bi {{ $si }} me-2"></i>{{ $sl }}</span>
|
|
@if($project->isFailed() && $project->isOwnedBy(auth()->user()))
|
|
<form method="POST" action="{{ route('user.projects.retry', $project) }}" id="retry-form">
|
|
@csrf
|
|
<button class="btn btn-sm btn-danger">
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Cuba Semula
|
|
</button>
|
|
</form>
|
|
@endif
|
|
@if($project->isProcessing() || $project->isPending())
|
|
<span id="status-spinner" class="spinner-border spinner-border-sm text-{{ $sc }}"></span>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Audio Player --}}
|
|
<div class="card mb-3">
|
|
<div class="card-header bg-white fw-semibold">
|
|
<i class="bi bi-music-note-beamed me-2 text-primary"></i>Audio Asal
|
|
<span class="text-muted fw-normal small ms-2">{{ $project->original_filename }}</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<audio controls class="w-100"
|
|
src="{{ route('user.projects.audio.stream', $project) }}"
|
|
preload="metadata">
|
|
Pelayar anda tidak menyokong audio HTML5.
|
|
</audio>
|
|
<div class="mt-2 small text-muted">
|
|
<i class="bi bi-hdd me-1"></i>{{ $project->fileSizeForHumans() }}
|
|
@if($project->durationForHumans())
|
|
• <i class="bi bi-clock me-1"></i>{{ $project->durationForHumans() }}
|
|
@endif
|
|
• <i class="bi bi-calendar me-1"></i>{{ $project->created_at->format('d/m/Y H:i') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Transcript Card --}}
|
|
<div class="card mb-3">
|
|
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
|
<span class="fw-semibold"><i class="bi bi-file-text me-2 text-primary"></i>Teks Transkripsi</span>
|
|
<div class="d-flex gap-2">
|
|
@if($project->transcriptVersions->count() > 0)
|
|
<a href="{{ route('user.projects.versions.index', $project) }}"
|
|
class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-clock-history me-1"></i>Sejarah Versi
|
|
<span class="badge bg-secondary ms-1">{{ $project->transcriptVersions->count() }}</span>
|
|
</a>
|
|
@endif
|
|
@can('editTranscript', $project)
|
|
@if($project->isCompleted() && $project->transcript_text)
|
|
<button class="btn btn-sm btn-outline-primary" id="btn-edit-toggle"
|
|
onclick="toggleEditor()">
|
|
<i class="bi bi-pencil me-1"></i>Edit
|
|
</button>
|
|
@endif
|
|
@endcan
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-body">
|
|
|
|
{{-- Read mode --}}
|
|
<div id="transcript-read">
|
|
@if($project->isCompleted() && $project->transcript_text)
|
|
<div class="p-3 bg-light rounded" style="white-space: pre-wrap; font-size: 0.95rem; line-height: 1.8;">{{ $project->transcript_text }}</div>
|
|
@if($project->transcript_confidence)
|
|
<div class="mt-2 small text-muted">
|
|
<i class="bi bi-bar-chart me-1"></i>
|
|
Keyakinan: {{ number_format($project->transcript_confidence * 100, 1) }}%
|
|
• Enjin: {{ $project->transcription_engine ?? '—' }}
|
|
</div>
|
|
@endif
|
|
@elseif($project->isFailed())
|
|
<div class="text-danger small">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
{{ $project->error_message ?? 'Ralat tidak diketahui.' }}
|
|
</div>
|
|
@else
|
|
<p class="text-muted mb-0 text-center py-3">
|
|
<i class="bi bi-hourglass-split fs-4 d-block mb-2"></i>
|
|
Teks transkripsi akan dipaparkan di sini setelah pemprosesan selesai.
|
|
</p>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Edit mode (hidden by default) --}}
|
|
@can('editTranscript', $project)
|
|
<div id="transcript-edit" style="display:none">
|
|
<form method="POST" action="{{ route('user.projects.transcript.update', $project) }}">
|
|
@csrf @method('PATCH')
|
|
<textarea class="form-control mb-2" name="transcript_text" rows="15"
|
|
style="font-size:0.92rem; line-height:1.7">{{ old('transcript_text', $project->transcript_text) }}</textarea>
|
|
<div class="mb-2">
|
|
<input type="text" class="form-control form-control-sm" name="change_summary"
|
|
placeholder="Ringkasan perubahan (pilihan)">
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" class="btn btn-sm btn-primary">
|
|
<i class="bi bi-save me-1"></i>Simpan
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="toggleEditor()">
|
|
Batal
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
@endcan
|
|
|
|
</div>
|
|
|
|
{{-- Card footer: Upload transkripsi luar --}}
|
|
@can('editTranscript', $project)
|
|
<div class="card-footer bg-white border-top-0 pt-0">
|
|
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal"
|
|
data-bs-target="#modalUploadTranscript">
|
|
<i class="bi bi-upload me-1"></i>Muat Naik Transkripsi Luar (.txt)
|
|
</button>
|
|
</div>
|
|
@endcan
|
|
|
|
</div>
|
|
|
|
{{-- Komen --}}
|
|
@can('view', [App\Models\ProjectComment::class, $project])
|
|
<div class="card mb-3">
|
|
<div class="card-header bg-white fw-semibold">
|
|
<i class="bi bi-chat-left-text me-2 text-secondary"></i>Perbincangan
|
|
@if($project->comments->count())
|
|
<span class="badge bg-secondary ms-1">{{ $project->comments->count() }}</span>
|
|
@endif
|
|
</div>
|
|
<div class="card-body">
|
|
|
|
{{-- Senarai komen --}}
|
|
@forelse($project->comments as $comment)
|
|
<div class="d-flex gap-2 mb-3">
|
|
<div class="flex-shrink-0">
|
|
<span class="badge bg-secondary rounded-circle p-2" style="font-size:0.75rem">
|
|
{{ mb_strtoupper(mb_substr($comment->user->name, 0, 1)) }}
|
|
</span>
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<span class="fw-semibold small">{{ $comment->user->name }}</span>
|
|
<div class="d-flex align-items-center gap-1">
|
|
<span class="text-muted" style="font-size:0.75rem">{{ $comment->created_at->format('d/m/Y H:i') }}</span>
|
|
@can('delete', $comment)
|
|
<form method="POST"
|
|
action="{{ route('user.projects.comments.destroy', [$project, $comment]) }}"
|
|
onsubmit="return confirm('Padam komen ini?')">
|
|
@csrf @method('DELETE')
|
|
<button class="btn btn-link btn-sm text-danger p-0 ms-1" title="Padam">
|
|
<i class="bi bi-x-circle"></i>
|
|
</button>
|
|
</form>
|
|
@endcan
|
|
</div>
|
|
</div>
|
|
<div class="mt-1" style="white-space:pre-wrap; font-size:0.9rem">{{ $comment->message }}</div>
|
|
</div>
|
|
</div>
|
|
@empty
|
|
<p class="text-muted small mb-3">Tiada perbincangan lagi.</p>
|
|
@endforelse
|
|
|
|
{{-- Form tambah komen --}}
|
|
@can('create', [App\Models\ProjectComment::class, $project])
|
|
<hr class="my-2">
|
|
<form method="POST" action="{{ route('user.projects.comments.store', $project) }}">
|
|
@csrf
|
|
<div class="mb-2">
|
|
<textarea class="form-control form-control-sm @error('message') is-invalid @enderror"
|
|
name="message" rows="2" placeholder="Tulis komen..." required></textarea>
|
|
@error('message')
|
|
<div class="invalid-feedback">{{ $message }}</div>
|
|
@enderror
|
|
</div>
|
|
<button type="submit" class="btn btn-sm btn-primary">
|
|
<i class="bi bi-send me-1"></i>Hantar
|
|
</button>
|
|
</form>
|
|
@endcan
|
|
|
|
</div>
|
|
</div>
|
|
@endcan
|
|
|
|
</div>
|
|
|
|
{{-- Lajur Kanan: Maklumat + Collaborators --}}
|
|
<div class="col-lg-4">
|
|
|
|
{{-- Project Info --}}
|
|
<div class="card mb-3">
|
|
<div class="card-header bg-white fw-semibold">
|
|
<i class="bi bi-info-circle me-2 text-secondary"></i>Maklumat Projek
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<table class="table table-sm mb-0">
|
|
<tr>
|
|
<td class="text-muted ps-3" width="40%">Pemilik</td>
|
|
<td class="fw-semibold">{{ $project->owner->name }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="text-muted ps-3">Bahasa</td>
|
|
<td>{{ strtoupper($project->language) }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="text-muted ps-3">Enjin</td>
|
|
<td>{{ $project->transcription_engine ?? '—' }}</td>
|
|
</tr>
|
|
@if($project->processed_at)
|
|
<tr>
|
|
<td class="text-muted ps-3">Selesai pada</td>
|
|
<td class="small">{{ $project->processed_at->format('d/m/Y H:i') }}</td>
|
|
</tr>
|
|
@endif
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Collaborators --}}
|
|
<div class="card">
|
|
<div class="card-header bg-white fw-semibold">
|
|
<i class="bi bi-people me-2 text-secondary"></i>Collaborators
|
|
</div>
|
|
<div class="card-body">
|
|
|
|
@forelse($project->collaborators as $collab)
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<div>
|
|
<span class="fw-semibold small">{{ $collab->name }}</span>
|
|
<span class="badge bg-light text-dark ms-1" style="font-size:0.7rem">{{ $collab->pivot->role }}</span>
|
|
<div class="text-muted" style="font-size:0.75rem">{{ $collab->email }}</div>
|
|
</div>
|
|
@if($project->isOwnedBy(auth()->user()))
|
|
<form method="POST"
|
|
action="{{ route('user.projects.collaborators.destroy', [$project, $collab]) }}"
|
|
onsubmit="return confirm('Buang {{ $collab->name }} daripada collaborator?')">
|
|
@csrf @method('DELETE')
|
|
<button class="btn btn-sm btn-outline-danger py-0 px-1" title="Buang">
|
|
<i class="bi bi-x"></i>
|
|
</button>
|
|
</form>
|
|
@endif
|
|
</div>
|
|
@empty
|
|
<p class="text-muted small mb-2">Tiada collaborator.</p>
|
|
@endforelse
|
|
|
|
@if($project->isOwnedBy(auth()->user()))
|
|
<hr class="my-2">
|
|
<form method="POST" action="{{ route('user.projects.collaborators.store', $project) }}">
|
|
@csrf
|
|
<div class="input-group input-group-sm mb-2">
|
|
<input type="email" class="form-control @error('email') is-invalid @enderror"
|
|
name="email" placeholder="emel@mbip.my" required>
|
|
<button class="btn btn-outline-primary" type="submit">
|
|
<i class="bi bi-person-plus"></i>
|
|
</button>
|
|
</div>
|
|
@error('email')
|
|
<div class="text-danger small">{{ $message }}</div>
|
|
@enderror
|
|
<div class="mb-2">
|
|
<select class="form-select form-select-sm" name="role">
|
|
<option value="editor">Editor — boleh edit teks</option>
|
|
<option value="viewer">Viewer — lihat sahaja</option>
|
|
</select>
|
|
</div>
|
|
</form>
|
|
@endif
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Modal: Upload Transkripsi Luar --}}
|
|
@can('editTranscript', $project)
|
|
<div class="modal fade" id="modalUploadTranscript" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-upload me-2"></i>Muat Naik Transkripsi Luar</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form method="POST"
|
|
action="{{ route('user.projects.transcript.upload', $project) }}"
|
|
enctype="multipart/form-data"
|
|
id="form-upload-transcript"
|
|
data-has-transcript="{{ $project->transcript_text ? 'true' : 'false' }}">
|
|
@csrf
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Fail Transkripsi (.txt) <span class="text-danger">*</span></label>
|
|
<input type="file" class="form-control @error('transcript_file') is-invalid @enderror"
|
|
name="transcript_file" accept=".txt,text/plain" required>
|
|
<div class="form-text">Hanya fail .txt, maksimum 10 MB. Pengekodan UTF-8 disyorkan.</div>
|
|
@error('transcript_file')
|
|
<div class="invalid-feedback">{{ $message }}</div>
|
|
@enderror
|
|
</div>
|
|
|
|
@if(config('speech2text.ollama.enabled'))
|
|
<div class="form-check mb-3">
|
|
<input class="form-check-input" type="checkbox" name="clean_with_ollama"
|
|
value="1" id="chkOllama">
|
|
<label class="form-check-label" for="chkOllama">
|
|
<i class="bi bi-stars text-warning me-1"></i>
|
|
Bersihkan teks dengan Ollama (AI)
|
|
</label>
|
|
<div class="form-text">Teks akan dibaiki ejaan dan tanda baca menggunakan model {{ config('speech2text.ollama.model') }}.</div>
|
|
</div>
|
|
@endif
|
|
|
|
@if($project->transcript_text)
|
|
<div class="alert alert-warning py-2 small mb-0">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
Projek ini sudah mempunyai transkripsi. Memuat naik fail baru akan <strong>menggantikan</strong> teks sedia ada dan menyimpan versi lama dalam sejarah.
|
|
</div>
|
|
@endif
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Batal</button>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-upload me-1"></i>Muat Naik
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endcan
|
|
|
|
@endsection
|
|
|
|
@push('styles')
|
|
<style>
|
|
#transcript-read .bg-light { font-family: 'Segoe UI', sans-serif; }
|
|
</style>
|
|
@endpush
|
|
|
|
@push('scripts')
|
|
|
|
{{-- AJAX status polling --}}
|
|
@if($project->isPending() || $project->isProcessing())
|
|
<script>
|
|
(function () {
|
|
const statusUrl = "{{ route('user.projects.status', $project) }}";
|
|
const alertEl = document.getElementById('status-alert');
|
|
const textEl = document.getElementById('status-text');
|
|
const spinnerEl = document.getElementById('status-spinner');
|
|
|
|
const statusMap = {
|
|
pending: { cls: 'warning', icon: 'bi-hourglass', label: 'Menunggu dalam barisan...', spin: true },
|
|
processing: { cls: 'warning', icon: 'bi-gear-wide-connected', label: 'Sedang ditranskripkan...', spin: true },
|
|
completed: { cls: 'success', icon: 'bi-check-circle', label: 'Transkripsi selesai', spin: false },
|
|
failed: { cls: 'danger', icon: 'bi-x-circle', label: 'Transkripsi gagal', spin: false },
|
|
};
|
|
|
|
let timer;
|
|
|
|
function poll() {
|
|
fetch(statusUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const cfg = statusMap[data.status] ?? statusMap.pending;
|
|
alertEl.className = `alert alert-${cfg.cls} d-flex align-items-center justify-content-between mb-3 py-2`;
|
|
textEl.innerHTML = `<i class="bi ${cfg.icon} me-2"></i>${cfg.label}`;
|
|
if (spinnerEl) spinnerEl.style.display = cfg.spin ? '' : 'none';
|
|
if (data.status === 'completed' || data.status === 'failed') {
|
|
clearInterval(timer);
|
|
location.reload();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
timer = setInterval(poll, 8000);
|
|
})();
|
|
</script>
|
|
@endif
|
|
|
|
{{-- Upload transcript modal: confirm if already has transcript --}}
|
|
<script>
|
|
(function () {
|
|
const form = document.getElementById('form-upload-transcript');
|
|
if (!form) return;
|
|
|
|
form.addEventListener('submit', function (e) {
|
|
if (form.dataset.hasTranscript === 'true') {
|
|
if (!confirm('Projek ini sudah ada transkripsi. Adakah anda pasti ingin menggantikannya? Versi lama akan disimpan dalam sejarah versi.')) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{{-- Transcript editor toggle --}}
|
|
<script>
|
|
function toggleEditor() {
|
|
const read = document.getElementById('transcript-read');
|
|
const edit = document.getElementById('transcript-edit');
|
|
const btnTgl = document.getElementById('btn-edit-toggle');
|
|
if (!read || !edit) return;
|
|
const editing = edit.style.display !== 'none';
|
|
read.style.display = editing ? '' : 'none';
|
|
edit.style.display = editing ? 'none' : '';
|
|
if (btnTgl) btnTgl.innerHTML = editing
|
|
? '<i class="bi bi-pencil me-1"></i>Edit'
|
|
: '<i class="bi bi-eye me-1"></i>Lihat';
|
|
}
|
|
</script>
|
|
|
|
@endpush
|