Update terbaru

This commit is contained in:
affanardiansyah 2026-07-29 19:25:51 +07:00
parent ddad0a4f2b
commit c3beaf86a2
5 changed files with 649 additions and 89 deletions

View File

@ -1,5 +1,5 @@
class ApiConfig {
static const String host = '192.168.1.10';
static const String host = '192.168.1.36';
static const String port = '8000';
static String get baseUrl {

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Kategori;
use App\Models\User;
use App\Models\Wisata;
use Illuminate\View\View;
@ -11,11 +12,30 @@ class DashboardController extends Controller
{
public function __invoke(): View
{
$wisatas = Wisata::query()
->with('kategori')
->whereNotNull('latitude')
->whereNotNull('longitude')
->orderBy('title')
->get();
return view('admin.dashboard.index', [
'totalWisata' => Wisata::query()->count(),
'totalWisataAr' => Wisata::query()->whereNotNull('model_path')->count(),
'totalWisataVideo' => Wisata::query()->where('is_video', true)->count(),
'totalUser' => User::query()->where('role', 'user')->count(),
'totalKategori' => Kategori::query()->count(),
'mapWisatas' => $wisatas->map(fn (Wisata $wisata): array => [
'id' => $wisata->id,
'title' => $wisata->title,
'location' => $wisata->location,
'latitude' => (float) $wisata->latitude,
'longitude' => (float) $wisata->longitude,
'imageUrl' => $wisata->image_url,
'rating' => number_format((float) $wisata->rating, 1),
'category' => $wisata->kategori?->nama ?? 'Tanpa Kategori',
'mediaType' => $wisata->model_path ? 'ar' : ($wisata->video_path ? 'video' : 'ar'),
'mediaLabel' => $wisata->model_path ? 'Augmented Reality' : ($wisata->video_path ? 'Video Wisata' : 'Augmented Reality'),
'editUrl' => route('admin.wisata.edit', $wisata),
])->values(),
]);
}
}

View File

@ -0,0 +1,205 @@
.admin-map-card .card-body {
padding: 1.25rem;
}
.map-actions {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(130px, .42fr) auto auto;
gap: .75rem;
width: min(900px, 100%);
}
.map-shell {
position: relative;
height: 600px;
min-height: 600px;
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
background: #e9f2f6;
box-shadow: 0 18px 42px rgba(15, 76, 129, .1);
}
.tourism-map {
width: 100%;
height: 100%;
z-index: 1;
}
.leaflet-container {
width: 100%;
height: 100%;
position: relative;
}
.tourism-map::before {
content: attr(data-empty-message);
display: none;
place-items: center;
height: 100%;
padding: 2rem;
color: #64748b;
text-align: center;
}
.tourism-map.is-empty::before {
display: grid;
}
.map-legend {
position: absolute;
right: 14px;
bottom: 14px;
z-index: 500;
width: min(300px, calc(100% - 28px));
padding: .85rem 1rem;
border-radius: 12px;
background: rgba(255, 255, 255, .96);
border: 1px solid rgba(15, 76, 129, .14);
box-shadow: 0 14px 34px rgba(15, 76, 129, .16);
font-size: .9rem;
}
.legend-dot {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 0 4px rgba(15, 76, 129, .08);
}
.legend-dot-ar {
background: #dc2626;
}
.legend-dot-video {
background: #2563eb;
}
.map-stat {
border: 1px solid var(--line);
border-radius: 12px;
padding: 1rem;
background: #fff;
box-shadow: 0 10px 26px rgba(15, 76, 129, .07);
}
.map-stat small {
display: block;
color: #64748b;
font-weight: 700;
margin-bottom: .35rem;
}
.map-stat strong {
display: block;
color: var(--ink);
font-size: 1.8rem;
line-height: 1;
}
.tourism-marker {
background: transparent;
border: 0;
}
.tourism-marker-pin {
position: relative;
display: block;
width: 30px;
height: 30px;
border-radius: 50% 50% 50% 0;
background: var(--marker-color);
border: 3px solid #fff;
transform: rotate(-45deg);
box-shadow: 0 10px 24px rgba(15, 23, 42, .28);
}
.tourism-marker-pin::after {
content: "";
position: absolute;
inset: 7px;
border-radius: 50%;
background: #fff;
}
.leaflet-popup-content-wrapper {
border-radius: 14px;
padding: 0;
overflow: hidden;
box-shadow: 0 18px 42px rgba(15, 23, 42, .2);
}
.leaflet-popup-content {
margin: 0;
}
.map-popup-card {
width: 280px;
border-radius: 14px;
overflow: hidden;
}
.map-popup-card .card-img-top {
width: 100%;
height: 140px;
object-fit: cover;
}
.map-popup-card .card-body {
padding: .9rem;
}
.map-popup-title {
font-size: 1rem;
font-weight: 800;
line-height: 1.25;
}
.map-popup-row {
display: flex;
align-items: flex-start;
gap: .45rem;
color: #475569;
font-size: .86rem;
margin-top: .4rem;
}
.map-popup-row i {
color: var(--ocean);
line-height: 1.35;
}
.fullscreen-control {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 0;
border-bottom: 1px solid #ccc;
background: #fff;
color: #111827;
cursor: pointer;
font-size: 1rem;
}
.fullscreen-control:hover {
background: #f3f4f6;
}
@media (max-width: 991.98px) {
.map-actions {
grid-template-columns: 1fr;
width: 100%;
}
.map-shell {
height: 600px;
min-height: 600px;
}
.map-legend {
right: 12px;
bottom: 12px;
}
}

View File

@ -0,0 +1,328 @@
(function () {
const lumajangCenter = [-8.1269, 113.2248];
const lumajangBounds = L.latLngBounds(
L.latLng(-8.55, 112.75),
L.latLng(-7.70, 113.55),
);
const defaultZoom = 10;
const singleMarkerZoom = 14;
const minZoom = 10;
const maxZoom = 18;
const markerColors = {
ar: '#dc2626',
video: '#2563eb',
};
let map;
let markerLayer;
let markers = [];
let activeMedia = 'all';
let searchQuery = '';
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function createMarkerIcon(mediaType) {
const color = markerColors[mediaType] || markerColors.ar;
return L.divIcon({
className: 'tourism-marker',
html: `<span class="tourism-marker-pin" style="--marker-color:${color}"></span>`,
iconSize: [30, 42],
iconAnchor: [15, 42],
popupAnchor: [0, -38],
});
}
function buildPopupContent(item) {
const isVideo = item.mediaType === 'video';
const badgeClass = isVideo ? 'text-bg-primary' : 'text-bg-danger';
return `
<div class="card map-popup-card border-0 shadow-sm">
<img src="${escapeHtml(item.imageUrl)}" class="card-img-top" alt="${escapeHtml(item.title)}">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
<h3 class="map-popup-title mb-0">${escapeHtml(item.title)}</h3>
<span class="badge ${badgeClass}">${escapeHtml(item.mediaLabel)}</span>
</div>
<div class="map-popup-row"><i class="bi bi-geo-alt"></i><span>${escapeHtml(item.location)}</span></div>
<div class="map-popup-row"><i class="bi bi-tag"></i><span>${escapeHtml(item.category)}</span></div>
<a class="btn btn-sm btn-ocean w-100 mt-3" href="${escapeHtml(item.editUrl)}">Lihat Detail</a>
</div>
</div>
`;
}
function markerMatches(marker) {
const item = marker.tourismData;
const matchesMedia = activeMedia === 'all' || item.mediaType === activeMedia;
const haystack = `${item.title} ${item.location} ${item.category}`.toLowerCase();
const matchesSearch = !searchQuery || haystack.includes(searchQuery);
return matchesMedia && matchesSearch;
}
function getVisibleMarkers() {
return markers.filter((marker) => markerLayer.hasLayer(marker));
}
function setMarkerVisibility() {
markers.forEach((marker) => {
const shouldShow = markerMatches(marker);
const isShown = markerLayer.hasLayer(marker);
if (shouldShow && !isShown) {
markerLayer.addLayer(marker);
}
if (!shouldShow && isShown) {
markerLayer.removeLayer(marker);
}
});
const visibleMarkers = getVisibleMarkers();
updateStats();
if (!visibleMarkers.length) {
map.closePopup();
return;
}
const exactSearchMatch = searchQuery
? visibleMarkers.find((marker) => marker.tourismData.title.toLowerCase().includes(searchQuery))
: null;
if (exactSearchMatch) {
focusMarker(exactSearchMatch);
return;
}
fitMarkers(visibleMarkers);
}
function updateStats() {
const totalElement = document.getElementById('mapTotalMarkers');
const arElement = document.getElementById('mapArMarkers');
const videoElement = document.getElementById('mapVideoMarkers');
const arCount = markers.filter((marker) => marker.tourismData.mediaType === 'ar').length;
const videoCount = markers.filter((marker) => marker.tourismData.mediaType === 'video').length;
if (totalElement) totalElement.textContent = markers.length;
if (arElement) arElement.textContent = arCount;
if (videoElement) videoElement.textContent = videoCount;
}
function fitMarkers(targetMarkers) {
if (!targetMarkers.length) {
map.setView(lumajangCenter, defaultZoom);
return;
}
if (targetMarkers.length === 1) {
map.setView(targetMarkers[0].getLatLng(), singleMarkerZoom);
return;
}
const group = L.featureGroup(targetMarkers);
map.fitBounds(group.getBounds().pad(0.2), {
maxZoom,
});
}
function refreshMapSize(callback, delay = 140) {
window.setTimeout(() => {
if (!map) {
return;
}
map.invalidateSize(true);
if (typeof callback === 'function') {
callback();
}
}, delay);
}
function scheduleMapInvalidation() {
setTimeout(() => {
map.invalidateSize(true);
}, 500);
setTimeout(() => {
map.invalidateSize(true);
}, 1000);
setTimeout(() => {
map.invalidateSize(true);
}, 2000);
}
function focusMarker(marker) {
map.setView(marker.getLatLng(), singleMarkerZoom);
marker.openPopup();
}
function resetView() {
searchQuery = '';
activeMedia = 'all';
const searchInput = document.getElementById('tourismMapSearch');
const mediaSelect = document.getElementById('tourismMapMedia');
if (searchInput) searchInput.value = '';
if (mediaSelect) mediaSelect.value = 'all';
markers.forEach((marker) => {
if (!markerLayer.hasLayer(marker)) {
markerLayer.addLayer(marker);
}
});
map.closePopup();
updateStats();
refreshMapSize(() => map.setView(lumajangCenter, defaultZoom), 120);
}
function addFullscreenControl() {
const FullscreenControl = L.Control.extend({
options: { position: 'topleft' },
onAdd() {
const button = L.DomUtil.create('button', 'leaflet-control fullscreen-control');
button.type = 'button';
button.title = 'Fullscreen';
button.setAttribute('aria-label', 'Fullscreen');
button.innerHTML = '<i class="bi bi-fullscreen"></i>';
L.DomEvent.disableClickPropagation(button);
L.DomEvent.on(button, 'click', () => {
const container = map.getContainer();
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (container.requestFullscreen) {
container.requestFullscreen();
}
});
return button;
},
});
map.addControl(new FullscreenControl());
document.addEventListener('fullscreenchange', () => {
refreshMapSize(() => fitMarkers(getVisibleMarkers()), 180);
});
}
function bindControls() {
document.getElementById('tourismMapSearch')?.addEventListener('input', (event) => {
searchQuery = event.target.value.trim().toLowerCase();
setMarkerVisibility();
});
document.getElementById('tourismMapMedia')?.addEventListener('change', (event) => {
activeMedia = event.target.value;
setMarkerVisibility();
});
document.getElementById('tourismMapFit')?.addEventListener('click', () => {
fitMarkers(getVisibleMarkers());
});
document.getElementById('tourismMapReset')?.addEventListener('click', resetView);
window.addEventListener('resize', () => {
window.clearTimeout(window.tourismMapResizeTimer);
window.tourismMapResizeTimer = window.setTimeout(() => {
refreshMapSize(() => fitMarkers(getVisibleMarkers()), 40);
}, 180);
});
window.addEventListener('load', () => {
refreshMapSize(() => fitMarkers(getVisibleMarkers()), 220);
});
}
function initTourismMap() {
const mapElement = document.getElementById('tourismMap');
const data = Array.isArray(window.tourismMapData) ? window.tourismMapData : [];
if (!mapElement || typeof L === 'undefined') {
return;
}
if (mapElement._leaflet_id) return;
map = L.map(mapElement, {
center: lumajangCenter,
zoom: defaultZoom,
minZoom,
maxZoom,
maxBounds: lumajangBounds,
maxBoundsViscosity: 1,
zoomControl: true,
scrollWheelZoom: true,
});
map.setMaxBounds(lumajangBounds);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
subdomains: 'abcd',
maxZoom: 20,
noWrap: true,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(map);
scheduleMapInvalidation();
markerLayer = L.layerGroup().addTo(map);
markers = data.map((item) => {
const marker = L.marker([Number(item.latitude), Number(item.longitude)], {
icon: createMarkerIcon(item.mediaType),
title: item.title,
});
marker.tourismData = item;
marker.bindPopup(buildPopupContent(item), {
className: 'tourism-popup',
maxWidth: 300,
minWidth: 260,
});
markerLayer.addLayer(marker);
return marker;
});
addFullscreenControl();
bindControls();
updateStats();
if (markers.length) {
refreshMapSize(() => map.setView(lumajangCenter, defaultZoom), 180);
} else {
mapElement.classList.add('is-empty');
refreshMapSize(() => map.setView(lumajangCenter, defaultZoom), 180);
}
}
function bootTourismMap() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTourismMap, { once: true });
} else {
initTourismMap();
}
}
bootTourismMap();
})();

View File

@ -1,112 +1,119 @@
@extends('admin.layouts.app')
@section('title', 'Dashboard')
@section('page_title', 'Dashboard')
@section('page_subtitle', 'Pusat manajemen data Explore Lumajang')
@section('page_title', 'Dashboard Admin')
@section('page_subtitle', 'Ringkasan data dan peta persebaran wisata Kabupaten Lumajang')
@section('content')
<div class="row g-3 g-xl-4">
<div class="col-12 col-sm-6 col-xl-3">
<div class="card soft-card dashboard-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<div class="text-muted small fw-semibold text-uppercase">Total Wisata</div>
<div class="display-6 fw-bold mb-0">{{ $totalWisata }}</div>
</div>
<div class="dashboard-icon text-bg-ocean">
<i class="bi bi-map"></i>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="card soft-card h-100">
<div class="card-body d-flex align-items-center justify-content-between gap-3">
<div>
<small class="text-muted fw-semibold">Total Wisata</small>
<div class="display-6 fw-bold mb-0">{{ number_format($totalWisata) }}</div>
</div>
<div class="text-muted small mt-3">Semua destinasi yang tersedia di aplikasi.</div>
<div class="stat-icon"><i class="bi bi-map"></i></div>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="card soft-card dashboard-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<div class="text-muted small fw-semibold text-uppercase">Total Wisata AR</div>
<div class="display-6 fw-bold mb-0">{{ $totalWisataAr }}</div>
</div>
<div class="dashboard-icon text-bg-info">
<i class="bi bi-badge-3d"></i>
</div>
<div class="col-12 col-md-4">
<div class="card soft-card h-100">
<div class="card-body d-flex align-items-center justify-content-between gap-3">
<div>
<small class="text-muted fw-semibold">Total User</small>
<div class="display-6 fw-bold mb-0">{{ number_format($totalUser) }}</div>
</div>
<div class="text-muted small mt-3">Destinasi dengan model 3D untuk fitur AR.</div>
<div class="stat-icon"><i class="bi bi-people"></i></div>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="card soft-card dashboard-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<div class="text-muted small fw-semibold text-uppercase">Total Wisata Video</div>
<div class="display-6 fw-bold mb-0">{{ $totalWisataVideo }}</div>
</div>
<div class="dashboard-icon text-bg-dark">
<i class="bi bi-play-btn"></i>
</div>
<div class="col-12 col-md-4">
<div class="card soft-card h-100">
<div class="card-body d-flex align-items-center justify-content-between gap-3">
<div>
<small class="text-muted fw-semibold">Total Kategori</small>
<div class="display-6 fw-bold mb-0">{{ number_format($totalKategori) }}</div>
</div>
<div class="text-muted small mt-3">Destinasi yang menampilkan media video.</div>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="card soft-card dashboard-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<div class="text-muted small fw-semibold text-uppercase">Total User</div>
<div class="display-6 fw-bold mb-0">{{ $totalUser }}</div>
</div>
<div class="dashboard-icon text-bg-primary">
<i class="bi bi-people"></i>
</div>
</div>
<div class="text-muted small mt-3">Pengguna aplikasi Flutter yang terdaftar.</div>
<div class="stat-icon"><i class="bi bi-tags"></i></div>
</div>
</div>
</div>
</div>
<div class="card soft-card mt-3 mt-xl-4">
<div class="card-body d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
<div>
<h2 class="h6 mb-1">Kelola Data Wisata</h2>
<p class="text-muted mb-0">Tambah, ubah, dan lengkapi media destinasi untuk kebutuhan aplikasi Flutter.</p>
<div class="card soft-card admin-map-card">
<div class="card-body">
<div class="d-flex flex-column flex-xl-row justify-content-between gap-3 mb-3">
<div>
<h2 class="h5 fw-bold mb-1">Peta Persebaran Wisata Kabupaten Lumajang</h2>
<p class="text-muted mb-0">Visualisasi lokasi seluruh destinasi wisata berdasarkan data yang tersimpan pada sistem.</p>
</div>
<div class="map-actions">
<div class="input-group">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
<input class="form-control" id="tourismMapSearch" type="search" placeholder="Cari wisata">
</div>
<select class="form-select" id="tourismMapMedia" aria-label="Filter marker">
<option value="all">Semua</option>
<option value="ar">AR</option>
<option value="video">Video</option>
</select>
<button class="btn btn-outline-ocean" id="tourismMapFit" type="button">
<i class="bi bi-arrows-fullscreen me-1"></i>Fit Marker
</button>
<button class="btn btn-outline-ocean" id="tourismMapReset" type="button">
<i class="bi bi-arrow-clockwise me-1"></i>Reset View
</button>
</div>
</div>
<div class="map-shell">
<div id="tourismMap" class="tourism-map" data-empty-message="Belum ada wisata dengan koordinat latitude dan longitude."></div>
<div class="map-legend">
<div class="fw-semibold mb-2">Legenda</div>
<div class="d-flex align-items-center gap-2 mb-1"><span class="legend-dot legend-dot-ar"></span><span>Wisata Augmented Reality</span></div>
<div class="d-flex align-items-center gap-2"><span class="legend-dot legend-dot-video"></span><span>Wisata Video</span></div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-12 col-md-4">
<div class="map-stat">
<small>Jumlah Marker</small>
<strong id="mapTotalMarkers">0</strong>
</div>
</div>
<div class="col-12 col-md-4">
<div class="map-stat">
<small>Jumlah Wisata AR</small>
<strong id="mapArMarkers">0</strong>
</div>
</div>
<div class="col-12 col-md-4">
<div class="map-stat">
<small>Jumlah Wisata Video</small>
<strong id="mapVideoMarkers">0</strong>
</div>
</div>
</div>
<a class="btn btn-nature" href="{{ route('admin.wisata.index') }}">
<i class="bi bi-arrow-right-circle me-1"></i> Buka Data Wisata
</a>
</div>
</div>
@endsection
@push('styles')
<style>
.dashboard-card {
transition: transform .18s ease, box-shadow .18s ease;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 34px rgba(15, 76, 129, .11);
}
.dashboard-icon {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 12px;
font-size: 1.35rem;
flex: 0 0 auto;
}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="{{ asset('css/admin-dashboard-map.css') }}">
@endpush
@push('scripts')
<script>
window.tourismMapData = @json($mapWisatas);
</script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="{{ asset('js/admin-dashboard-map.js') }}"></script>
@endpush