- Step 1 - Membangun PdfService (Clean Code)
- Step 2 - Background Job (Ekstraksi PDF)
- Step 2 - Controller & Routing
- Step 3 - UI Premium: Riwayat Skripsi (Index.vue)
- Step 4 - UI Premium: Unggah Skripsi (Create.vue)
- Kesimpulan
Halo semuanya! 👋
Di Part 4 ini, kita akan membangun fondasi UI yang sangat profesional. Kita akan membuat halaman Riwayat Skripsi dan Halaman Unggah dengan desain Elite Dark yang seragam dengan dashboard utama.
Step 1 - Membangun PdfService (Clean Code)
Sebelum membuat Job, kita buat sebuah Service agar logika ekstraksi PDF bisa digunakan berulang kali dengan rapi.
app/Services/PdfService.php
namespace App\Services;
use Smalot\PdfParser\Parser;
class PdfService
{
public function extractText(string $filePath): string
{
$parser = new Parser();
$pdf = $parser->parseFile(storage_path('app/private/' . $filePath));
return trim($pdf->getText());
}
}
Step 2 - Background Job (Ekstraksi PDF)
Agar proses ekstraksi teks tidak membebani server, kita akan menggunakan Queue Job yang memanggil PdfService.
app/Jobs/ProcessThesisJob.php
namespace App\Jobs;
use App\Models\Thesis;
use App\Models\ThesisSection;
use App\Services\PdfService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessThesisJob implements ShouldQueue
{
use Queueable;
public function __construct(public Thesis $thesis) {}
public function handle(PdfService $pdfService): void
{
try {
$this->thesis->update(['status' => 'processing']);
$text = $pdfService->extractText($this->thesis->file_path);
// Simpan chunk teks untuk pencarian nanti
$chunks = str_split($text, 2000);
foreach ($chunks as $index => $chunk) {
$this->thesis->sections()->create([
'section_name' => "Bagian " . ($index + 1),
'content' => $chunk,
'order' => $index,
]);
}
$this->thesis->update(['status' => 'completed']);
} catch (\Exception $e) {
$this->thesis->update(['status' => 'failed']);
}
}
}
Step 2 - Controller & Routing
Kita gunakan helper to_route() untuk navigasi yang lebih modern.
app/Http/Controllers/DocumentController.php
use Illuminate\Support\Facades\Storage;
public function index()
{
$theses = auth()->user()->theses()->latest()->get();
return Inertia::render('Documents/Index', ['theses' => $theses]);
}
public function create()
{
return Inertia::render('Documents/Create');
}
public function store(Request $request)
{
$request->validate([
'title' => 'required',
'section' => 'required',
'file' => 'required|mimes:pdf|max:10000',
]);
$path = $request->file('file')->store('theses');
$thesis = Thesis::create([
'user_id' => auth()->id(),
'title' => $request->title,
'section' => $request->section,
'file_path' => $path,
'status' => 'pending',
]);
ProcessThesisJob::dispatch($thesis);
return to_route('documents.show', $thesis->id);
}
public function show(Thesis $thesis)
{
return Inertia::render('Documents/Show', [
'thesis' => $thesis->load('analyses')
]);
}
public function file(Thesis $thesis)
{
// Pastikan hanya pemilik yang bisa melihat file ini
if ($thesis->user_id !== auth()->id()) {
abort(403);
}
// Ambil file dari storage privat dan stream ke browser
return Storage::response($thesis->file_path);
}
[!NOTE] Kita menggunakan route
filekarena file skripsi disimpan di folder privat (storage/app/private/theses). File di folder ini tidak bisa diakses langsung via URL browser demi keamanan.
Step 3 - UI Premium: Riwayat Skripsi (Index.vue)
Gunakan desain tabel yang bersih dan konsisten dengan halaman Manajemen User.
resources/js/Pages/Documents/Index.vue
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import { Card } from '@/Components/ui/card';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { FileText, Plus, FileSearch, Eye } from 'lucide-vue-next';
const props = defineProps({
theses: Array,
});
const getStatusVariant = (status) => {
switch (status) {
case 'completed': return 'success';
case 'processing': return 'warning';
case 'failed': return 'destructive';
default: return 'secondary';
}
};
const getStatusLabel = (status) => {
switch (status) {
case 'completed': return 'Selesai';
case 'processing': return 'Diproses';
case 'failed': return 'Gagal';
case 'pending': return 'Menunggu';
default: return status;
}
};
</script>
<template>
<Head title="Riwayat Skripsi" />
<AuthenticatedLayout>
<div class="py-8 px-4 md:px-8 max-w-7xl mx-auto space-y-6">
<!-- Header Section -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Riwayat Skripsi</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">Kelola dan tinjau hasil analisis AI untuk setiap draf Anda.</p>
</div>
<Link :href="route('documents.create')">
<Button class="gap-2 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:text-indigo-800 dark:bg-indigo-500/10 dark:text-indigo-400 dark:hover:bg-indigo-500/20 border-none shadow-none">
<Plus class="h-4 w-4" />
Unggah Baru
</Button>
</Link>
</div>
<!-- Table Section -->
<Card class="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 border-b border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-300">
<tr>
<th class="px-6 py-4 font-semibold">Dokumen</th>
<th class="px-6 py-4 font-semibold">Tanggal</th>
<th class="px-6 py-4 font-semibold">Status</th>
<th class="px-6 py-4 font-semibold text-right">Aksi</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-for="thesis in theses" :key="thesis.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-800/50 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-600 dark:text-slate-400 flex-shrink-0">
<FileText class="h-4 w-4" />
</div>
<div class="flex flex-col max-w-[200px] sm:max-w-xs md:max-w-md">
<span class="font-medium text-slate-900 dark:text-white truncate" :title="thesis.title">{{ thesis.title }}</span>
<span class="text-xs text-slate-500 dark:text-slate-400 uppercase">{{ thesis.section }}</span>
</div>
</div>
</td>
<td class="px-6 py-4 text-slate-600 dark:text-slate-400 whitespace-nowrap">
{{ new Date(thesis.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' }) }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<Badge :variant="getStatusVariant(thesis.status)" class="px-2.5 py-0.5 font-medium uppercase text-[10px]">
{{ getStatusLabel(thesis.status) }}
</Badge>
</td>
<td class="px-6 py-4 text-right">
<Link :href="route('documents.show', thesis.id)">
<Button variant="ghost" size="icon" class="h-8 w-8 text-slate-500 hover:text-indigo-600">
<Eye class="h-4 w-4" />
</Button>
</Link>
</td>
</tr>
<tr v-if="theses.length === 0">
<td colspan="4" class="px-6 py-12 text-center text-slate-500 dark:text-slate-400">
<div class="flex flex-col items-center justify-center space-y-3">
<div class="h-12 w-12 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
<FileSearch class="h-6 w-6 text-slate-400 dark:text-slate-500" />
</div>
<p>Belum ada dokumen.</p>
<Link :href="route('documents.create')">
<Button variant="outline" size="sm" class="mt-2 dark:border-slate-800 dark:hover:bg-slate-800">Unggah Sekarang</Button>
</Link>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</div>
</AuthenticatedLayout>
</template>
Step 4 - UI Premium: Unggah Skripsi (Create.vue)
Gunakan desain form dalam satu kartu terpusat (single-card layout) yang bersih dan profesional, senada dengan form tambah user.
resources/js/Pages/Documents/Create.vue
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, useForm, Link } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Card } from '@/Components/ui/card';
import { Input } from '@/Components/ui/input';
import { Textarea } from '@/Components/ui/textarea';
import { Label } from '@/Components/ui/label';
import { Button } from '@/Components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { ArrowLeft, FileText, CheckCircle2 } from 'lucide-vue-next';
const form = useForm({
title: '',
section: 'keseluruhan',
file: null,
});
const isDragging = ref(false);
const sections = [
{ label: 'Keseluruhan Skripsi', value: 'keseluruhan' },
{ label: 'Bab 1: Pendahuluan', value: 'bab 1' },
{ label: 'Bab 2: Tinjauan Pustaka', value: 'bab 2' },
{ label: 'Bab 3: Metodologi', value: 'bab 3' },
{ label: 'Bab 4: Hasil & Pembahasan', value: 'bab 4' },
{ label: 'Bab 5: Kesimpulan', value: 'bab 5' },
];
const handleDrop = (e) => {
isDragging.value = false;
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].type === 'application/pdf') {
form.file = files[0];
}
};
const submit = () => {
form.post(route('documents.store'));
};
</script>
<template>
<Head title="Unggah Skripsi" />
<AuthenticatedLayout>
<div class="py-8 px-4 md:px-8 max-w-3xl mx-auto space-y-6">
<div class="flex items-center gap-4">
<Link :href="route('documents.index')">
<Button variant="ghost" size="icon" class="h-8 w-8">
<ArrowLeft class="h-4 w-4" />
</Button>
</Link>
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Unggah Skripsi</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">Pilih dokumen PDF skripsi untuk dianalisis oleh AI.</p>
</div>
</div>
<Card class="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-xl">
<form @submit.prevent="submit" class="p-6 md:p-8 space-y-6">
<div class="space-y-4">
<div class="space-y-2">
<Label for="title">Judul Penelitian</Label>
<Textarea
id="title"
v-model="form.title"
placeholder="Masukkan judul skripsi atau penelitian"
required
rows="3"
/>
<div v-if="form.errors.title" class="text-xs text-red-600">{{ form.errors.title }}</div>
</div>
<div class="space-y-2">
<Label for="section">Bab yang Ingin Dianalisis</Label>
<Select v-model="form.section">
<SelectTrigger class="w-full">
<SelectValue placeholder="Pilih bab skripsi" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="s in sections" :key="s.value" :value="s.value">
{{ s.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="form.errors.section" class="text-xs text-red-600">{{ form.errors.section }}</div>
</div>
<div class="space-y-2">
<Label for="file">Dokumen PDF</Label>
<div
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
:class="[
isDragging ? 'border-indigo-400 bg-indigo-50/50 dark:bg-indigo-500/10' : 'border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50',
'group relative border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer hover:border-indigo-300 dark:hover:border-indigo-500 hover:bg-indigo-50/30 dark:hover:bg-indigo-500/5'
]"
>
<input type="file" id="file" class="hidden" @input="form.file = $event.target.files[0]" accept=".pdf" required />
<label for="file" class="cursor-pointer flex flex-col items-center gap-3">
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-100 dark:border-slate-700 group-hover:scale-110 transition-transform">
<FileText v-if="!form.file" class="h-6 w-6 text-slate-400 group-hover:text-indigo-500 transition-colors" />
<CheckCircle2 v-else class="h-6 w-6 text-green-500" />
</div>
<div class="space-y-1">
<span class="text-sm font-medium text-slate-900 dark:text-white block">
{{ form.file ? form.file.name : 'Klik atau tarik file PDF ke sini' }}
</span>
<p v-if="!form.file" class="text-xs text-slate-500 dark:text-slate-400">Maksimal 10MB</p>
</div>
</label>
</div>
<div v-if="form.errors.file" class="text-xs text-red-600">{{ form.errors.file }}</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100 dark:border-slate-800">
<Link :href="route('documents.index')">
<Button type="button" variant="outline" class="dark:border-slate-800 dark:hover:bg-slate-800">Batal</Button>
</Link>
<Button type="submit" :disabled="form.processing" class="bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:text-indigo-800 dark:bg-indigo-500/10 dark:text-indigo-400 dark:hover:bg-indigo-500/20 shadow-none">
<template v-if="form.processing">
Menyimpan...
</template>
<template v-else>
Simpan & Analisis
</template>
</Button>
</div>
</form>
</Card>
</div>
</AuthenticatedLayout>
</template>
Kesimpulan
Aplikasi Anda kini memiliki fondasi UI yang sangat kuat. Pada Part 5, kita akan menyuntikkan "Otak AI" untuk menganalisis dokumen tersebut secara otomatis!
Artikel ini dibaca sebanyak 103 kali
{ setTimeout(() => { showShimmer = false; }, 300); })"
:class="{'opacity-0': !loaded, 'opacity-100': loaded}"
class="lazy w-full h-auto rounded-xl border border-white dark:border-neutral-700/80 transition-opacity duration-500"
loading="lazy"
/>
SAWERIA
Memuat komentar...