Add user classification UI and predict flow
This commit is contained in:
parent
fa3902fcea
commit
e639b0ac57
|
|
@ -6,27 +6,73 @@
|
|||
use App\Models\Classification;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class ClassificationController extends Controller
|
||||
{
|
||||
public function store(Request $request)
|
||||
public function index()
|
||||
{
|
||||
$classifications = Classification::where('user_id', auth()->id())->get();
|
||||
return inertia('user/classification/ClassificationIndex', [
|
||||
'classifications' => $classifications,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function predict(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|image|max:2048',
|
||||
'result' => 'required|string',
|
||||
'confidence' => 'required',
|
||||
]);
|
||||
|
||||
// Simpan gambar ke storage public
|
||||
$path = $request->file('image')->store('classifications', 'public');
|
||||
$image = $request->file('image');
|
||||
|
||||
// Simpan ke Database
|
||||
Classification::create([
|
||||
'user_id' => auth()->id(), // null jika guest, id jika login
|
||||
'image_path' => $path,
|
||||
'result' => $request->result,
|
||||
'confidence' => (float) filter_var($request->confidence, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION),
|
||||
]);
|
||||
try {
|
||||
// 1. Kirim ke AI Server untuk prediksi
|
||||
$response = Http::attach(
|
||||
'image',
|
||||
file_get_contents($image),
|
||||
$image->getClientOriginalName()
|
||||
)->post('http://127.0.0.1:5001/predict');
|
||||
|
||||
return response()->json(['message' => 'Data tersimpan ke history']);
|
||||
if ($response->failed()) {
|
||||
return response()->json(['error' => 'Gagal terhubung ke AI server'], 500);
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
// 2. Jika sukses, simpan gambar dan hasil ke database
|
||||
$path = $image->store('classifications', 'public');
|
||||
|
||||
Classification::create([
|
||||
'user_id' => auth()->id() ?? null,
|
||||
'image_path' => $path,
|
||||
'result' => $data['label'],
|
||||
'confidence' => (float) filter_var($data['confidence'], FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'label' => $data['label'],
|
||||
'confidence' => $data['confidence'],
|
||||
'message' => 'Hasil klasifikasi berhasil disimpan'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => 'Terjadi kesalahan: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
public function destroy(Classification $classification)
|
||||
{
|
||||
if ($classification->user_id !== auth()->id()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Hapus file dari storage jika ada
|
||||
if ($classification->image_path && \Storage::disk('public')->exists($classification->image_path)) {
|
||||
\Storage::disk('public')->delete($classification->image_path);
|
||||
}
|
||||
|
||||
$classification->delete();
|
||||
|
||||
return back()->with('success', 'Klasifikasi berhasil dihapus');
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,8 @@ createInertiaApp({
|
|||
return [AppLayout, SettingsLayout];
|
||||
case name.startsWith('admin/'):
|
||||
return [AppLayout];
|
||||
case name.startsWith('user/'):
|
||||
return [AppLayout];
|
||||
default:
|
||||
return PublicLayout;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import { route } from 'ziggy-js';
|
||||
import { BookOpen, FolderGit2, LayoutGrid, Users } from 'lucide-vue-next';
|
||||
import { BookOpen, FolderGit2, LayoutGrid, Users, ScanSearch } from 'lucide-vue-next';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
|
|
@ -23,8 +23,7 @@ const page = usePage();
|
|||
// Ambil data user dari auth yang dikirim via HandleInertiaRequests
|
||||
const user = computed(() => page.props.auth.user);
|
||||
|
||||
// 1. Definisikan semua menu, tambahkan properti 'role' untuk menu yang dibatasi
|
||||
const allNavItems: NavItem[] = [
|
||||
const adminNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: route('admin.dashboard'),
|
||||
|
|
@ -34,20 +33,41 @@ const allNavItems: NavItem[] = [
|
|||
title: 'Users Management',
|
||||
href: route('admin.users.index'),
|
||||
icon: Users,
|
||||
role: 'admin', // Hanya untuk admin
|
||||
},
|
||||
];
|
||||
|
||||
// 2. Filter menu berdasarkan role user yang sedang login
|
||||
const filteredNavItems = computed(() => {
|
||||
return allNavItems.filter(item => {
|
||||
// Jika menu tidak punya batasan role, tampilkan untuk semua
|
||||
if (!item.role) return true;
|
||||
// Jika ada batasan role, cek apakah role user cocok (admin === admin)
|
||||
return item.role === user.value?.role;
|
||||
});
|
||||
const userNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: route('dashboard'),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Klasifikasi',
|
||||
href: route('classifications.index'),
|
||||
icon: ScanSearch,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const navItems = computed(() => {
|
||||
if (user.value?.role === 'admin') {
|
||||
return adminNavItems;
|
||||
} else {
|
||||
return userNavItems;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Filter menu berdasarkan role user yang sedang login
|
||||
// const filteredNavItems = computed(() => {
|
||||
// return allNavItems.filter(item => {
|
||||
// // Jika menu tidak punya batasan role, tampilkan untuk semua
|
||||
// if (!item.role) return true;
|
||||
// // Jika ada batasan role, cek apakah role user cocok (admin === admin)
|
||||
// return item.role === user.value?.role;
|
||||
// });
|
||||
// });
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
|
|
@ -77,7 +97,7 @@ const footerNavItems: NavItem[] = [
|
|||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain :items="filteredNavItems" />
|
||||
<NavMain :items="navItems" />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@
|
|||
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight font-display">
|
||||
Pantau Riwayat Klasifikasi
|
||||
</h2>
|
||||
<p class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||
<p v-if="!user" class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||
Masuk ke akun Anda sekarang untuk menyimpan hasil dan melihat kembali seluruh riwayat klasifikasi biji kopi yang pernah Anda lakukan.
|
||||
</p>
|
||||
<p v-else class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||
Pantau riwayat klasifikasi biji kopi Anda sekarang.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<div v-if="!user" class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
||||
|
|
@ -24,6 +27,15 @@
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-sm text-zinc-500">Gratis sepenuhnya. Jangan biarkan riwayat klasifikasi Anda hilang.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -31,5 +43,9 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight } from 'lucide-vue-next'
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import { Link, usePage } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const page = usePage()
|
||||
const user = computed(() => page.props.auth?.user)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -31,18 +31,37 @@
|
|||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
Masuk
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
||||
>
|
||||
Daftar
|
||||
</Link>
|
||||
<div v-if="user">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
class="shimmer-btn bg-red-500 text-zinc-950 hover:bg-red-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95 inline-flex items-center"
|
||||
:href="route('logout')"
|
||||
method="post"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</Link>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Link
|
||||
href="/login"
|
||||
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
Masuk
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
||||
>
|
||||
Daftar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
|
|
@ -88,9 +107,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Menu, X } from 'lucide-vue-next'
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Link, usePage, router } from '@inertiajs/vue3'
|
||||
import { LogOut, Menu, X } from 'lucide-vue-next'
|
||||
import { route } from 'ziggy-js';
|
||||
|
||||
const page = usePage()
|
||||
const user = computed(() => page.props.auth?.user)
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Klasifikasi', href: '#klasifikasi' },
|
||||
|
|
@ -99,6 +122,10 @@ const navItems = [
|
|||
{ label: 'F.A.Q', href: '#faq' },
|
||||
]
|
||||
|
||||
const handleLogout = () => {
|
||||
router.flushAll();
|
||||
};
|
||||
|
||||
const hoveredIndex = ref<number | null>(null)
|
||||
const mobileMenuOpen = ref(false)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { route } from 'ziggy-js'
|
||||
import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next'
|
||||
|
||||
const imagePreview = ref<string | null>(null)
|
||||
|
|
@ -128,27 +130,37 @@ const startClassification = async () => {
|
|||
formData.append('image', selectedFile.value)
|
||||
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:5001/predict', {
|
||||
const response = await fetch('/predict', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || ''
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("Gagal terhubung ke AI server")
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || "Gagal melakukan klasifikasi")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
// Pastikan data yang dikirim Flask sesuai (label & confidence)
|
||||
predictionResult.value = {
|
||||
label: data.label,
|
||||
confidence: data.confidence
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error: Pastikan backend-ai sudah dijalankan (python app.py)")
|
||||
|
||||
// Refresh data jika di Inertia context (dashboard)
|
||||
if (typeof route === 'function' && route().current('classifications.index')) {
|
||||
router.reload({ only: ['classifications'] })
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const clearImage = () => {
|
||||
imagePreview.value = null
|
||||
selectedFile.value = null
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -42,30 +42,61 @@ const flash = computed(() => page.props.flash || {})
|
|||
const message = ref(null)
|
||||
const type = ref(null)
|
||||
const isOpen = ref(false)
|
||||
let activeTimeout = null
|
||||
|
||||
watch(
|
||||
flash,
|
||||
() => {
|
||||
if (flash.value.success) {
|
||||
message.value = flash.value.success
|
||||
type.value = 'success'
|
||||
isOpen.value = true
|
||||
} else if (flash.value.error) {
|
||||
message.value = flash.value.error
|
||||
type.value = 'error'
|
||||
isOpen.value = true
|
||||
}
|
||||
const trigger = (msg, t) => {
|
||||
if (activeTimeout) clearTimeout(activeTimeout)
|
||||
|
||||
if (message.value) {
|
||||
setTimeout(() => {
|
||||
isOpen.value = false
|
||||
setTimeout(() => {
|
||||
message.value = null
|
||||
type.value = null
|
||||
}, 300)
|
||||
}, 2500)
|
||||
message.value = msg
|
||||
type.value = t
|
||||
isOpen.value = true
|
||||
|
||||
activeTimeout = setTimeout(() => {
|
||||
isOpen.value = false
|
||||
setTimeout(() => {
|
||||
message.value = null
|
||||
type.value = null
|
||||
activeTimeout = null
|
||||
}, 300)
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
const showNotification = (msg, t) => {
|
||||
if (!msg) return
|
||||
|
||||
// Jika sudah terbuka, kita reset agar animasinya terpicu ulang
|
||||
if (isOpen.value) {
|
||||
isOpen.value = false
|
||||
setTimeout(() => trigger(msg, t), 100)
|
||||
} else {
|
||||
trigger(msg, t)
|
||||
}
|
||||
}
|
||||
|
||||
// Gunakan router event agar selalu terdeteksi setiap kali request selesai
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
let unregisterFinishEvent = null
|
||||
|
||||
onMounted(() => {
|
||||
// Cek saat mount awal
|
||||
if (flash.value.success || flash.value.error) {
|
||||
showNotification(flash.value.success || flash.value.error, flash.value.success ? 'success' : 'error')
|
||||
}
|
||||
|
||||
// Dengarkan setiap kali aksi Inertia selesai
|
||||
unregisterFinishEvent = router.on('finish', () => {
|
||||
const currentFlash = page.props.flash || {}
|
||||
if (currentFlash.success || currentFlash.error) {
|
||||
showNotification(currentFlash.success || currentFlash.error, currentFlash.success ? 'success' : 'error')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unregisterFinishEvent) unregisterFinishEvent()
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, Plus, History, ScanSearch, Calendar, AlertCircle } from 'lucide-vue-next';
|
||||
import Scanner from '@/components/public/Scanner.vue';
|
||||
import { ref } from 'vue';
|
||||
import { route } from 'ziggy-js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
defineOptions({
|
||||
layout: {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Klasifikasi',
|
||||
href: route('classifications.index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
classifications: any[];
|
||||
}>();
|
||||
|
||||
const showScanner = ref(false);
|
||||
const isDeleteDialogOpen = ref(false);
|
||||
const itemToDelete = ref<number | null>(null);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
const confirmDelete = (id: number) => {
|
||||
itemToDelete.value = id;
|
||||
isDeleteDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const deleteClassification = () => {
|
||||
if (!itemToDelete.value) return;
|
||||
|
||||
isDeleting.value = true;
|
||||
router.delete(route('classifications.destroy', itemToDelete.value), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
isDeleteDialogOpen.value = false;
|
||||
itemToDelete.value = null;
|
||||
},
|
||||
onFinish: () => {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const toggleScanner = () => {
|
||||
showScanner.value = !showScanner.value;
|
||||
if (!showScanner.value) {
|
||||
// Refresh data saat menutup scanner jika ada data baru
|
||||
router.reload({ only: ['classifications'] });
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Klasifikasi Kopi" />
|
||||
|
||||
<div class="flex h-full flex-1 flex-col gap-8 rounded-xl p-4 md:p-8 overflow-x-hidden">
|
||||
<!-- Header Section -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight flex items-center gap-3">
|
||||
<History class="w-8 h-8 text-emerald-500" />
|
||||
Riwayat Klasifikasi
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1">Kelola dan lihat hasil analisis biji kopi Anda.</p>
|
||||
</div>
|
||||
<Button
|
||||
@click="toggleScanner"
|
||||
:variant="showScanner ? 'outline' : 'default'"
|
||||
class="w-full sm:w-auto h-12 px-6 rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
||||
>
|
||||
<template v-if="!showScanner">
|
||||
<Plus class="mr-2 h-5 w-5" />
|
||||
Klasifikasi Baru
|
||||
</template>
|
||||
<template v-else>
|
||||
Tutup Scanner
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Scanner Section -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="transform -translate-y-4 opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="transform translate-y-0 opacity-100"
|
||||
leave-to-class="transform -translate-y-4 opacity-0"
|
||||
>
|
||||
<div v-if="showScanner" class="mb-12 rounded-3xl overflow-hidden shadow-2xl border border-emerald-500/20">
|
||||
<Scanner />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- History Grid -->
|
||||
<div v-if="classifications.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-12">
|
||||
<div
|
||||
v-for="item in classifications"
|
||||
:key="item.id"
|
||||
class="group relative bg-white dark:bg-zinc-900 rounded-[2rem] border border-zinc-200 dark:border-zinc-800 p-4 flex gap-5 items-center shadow-sm hover:shadow-xl hover:border-emerald-500/30 transition-all duration-300"
|
||||
>
|
||||
<!-- Small Photo Thumbnail -->
|
||||
<div class="w-24 h-24 sm:w-32 sm:h-32 rounded-2xl overflow-hidden bg-zinc-100 dark:bg-zinc-950 flex-shrink-0 relative">
|
||||
<img
|
||||
:src="'/storage/' + item.image_path"
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info Section -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between py-1 pr-6">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-2 text-[9px] font-bold text-zinc-400 uppercase tracking-widest mb-1">
|
||||
<Calendar class="w-3 h-3" />
|
||||
{{ new Date(item.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }) }}
|
||||
</div>
|
||||
<h3 class="text-xl font-black text-emerald-600 dark:text-emerald-500 italic truncate leading-none">{{ item.result }}</h3>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-[9px] font-bold text-zinc-400 uppercase tracking-widest">Confidence</p>
|
||||
<p class="text-lg font-bold text-zinc-900 dark:text-zinc-100 leading-none mt-1">{{ Number(item.confidence).toFixed(2) }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-emerald-500 rounded-full transition-all duration-1000"
|
||||
:style="{ width: item.confidence + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tombol Hapus Pojok Kanan Bawah -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="confirmDelete(item.id)"
|
||||
class="absolute bottom-3 right-3 h-8 w-8 text-zinc-400 hover:text-red-500 hover:bg-red-50/50 dark:hover:bg-red-950/20 transition-colors"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!showScanner" class="text-center py-32 bg-white dark:bg-zinc-900 rounded-[2.5rem] border-2 border-dashed border-zinc-200 dark:border-zinc-800 shadow-sm mb-12">
|
||||
<div class="inline-flex p-6 rounded-3xl bg-zinc-50 dark:bg-zinc-950 mb-6">
|
||||
<ScanSearch class="w-12 h-12 text-zinc-400" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-zinc-900 dark:text-white mb-2">Belum ada riwayat</h3>
|
||||
<p class="text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto mb-8">Anda belum pernah melakukan klasifikasi biji kopi. Mulai sekarang untuk melihat hasilnya di sini.</p>
|
||||
<Button @click="showScanner = true" class="h-12 px-8 rounded-xl font-bold">
|
||||
Mulai Klasifikasi Pertama
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<Dialog :open="isDeleteDialogOpen" @update:open="isDeleteDialogOpen = $event">
|
||||
<DialogContent class="sm:max-w-[425px] rounded-3xl">
|
||||
<DialogHeader>
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4">
|
||||
<AlertCircle class="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<DialogTitle class="text-center text-xl font-bold">Hapus Riwayat?</DialogTitle>
|
||||
<DialogDescription class="text-center">
|
||||
Tindakan ini tidak dapat dibatalkan. Riwayat klasifikasi dan foto akan dihapus secara permanen dari server kami.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter class="flex flex-col sm:flex-row gap-2 mt-4">
|
||||
<Button variant="outline" @click="isDeleteDialogOpen = false" class="rounded-xl flex-1 h-12 font-bold">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="deleteClassification"
|
||||
:disabled="isDeleting"
|
||||
class="rounded-xl flex-1 h-12 font-bold"
|
||||
>
|
||||
{{ isDeleting ? 'Menghapus...' : 'Ya, Hapus' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@
|
|||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Public\ClassificationController; // Import Controller
|
||||
|
||||
Route::redirect('dashboard', 'admin/dashboard')->name('dashboard');
|
||||
Route::get('dashboard', function () {
|
||||
if (auth()->user()->role === 'admin') {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
return app(DashboardController::class)->index();
|
||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||
|
||||
Route::get('/', function () {
|
||||
return inertia('public/Home', [
|
||||
|
|
@ -14,7 +19,13 @@
|
|||
]);
|
||||
})->name('home');
|
||||
|
||||
Route::post('/classifications', [ClassificationController::class, 'store'])->name('public.classifications.store');
|
||||
Route::post('/predict', [ClassificationController::class, 'predict'])->name('public.predict');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/classifications', [ClassificationController::class, 'index'])->name('classifications.index');
|
||||
Route::delete('/classifications/{classification}', [ClassificationController::class, 'destroy'])->name('classifications.destroy');
|
||||
});
|
||||
|
||||
|
||||
Route::prefix('admin')->name('admin.')->middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||
|
|
|
|||
Loading…
Reference in New Issue