diff --git a/android/wisata_app/lib/config/api_config.dart b/android/wisata_app/lib/config/api_config.dart index a6f0453..88da5e9 100644 --- a/android/wisata_app/lib/config/api_config.dart +++ b/android/wisata_app/lib/config/api_config.dart @@ -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 { diff --git a/website/wisata_web/app/Http/Controllers/Admin/DashboardController.php b/website/wisata_web/app/Http/Controllers/Admin/DashboardController.php index e1b2642..6e22ef4 100644 --- a/website/wisata_web/app/Http/Controllers/Admin/DashboardController.php +++ b/website/wisata_web/app/Http/Controllers/Admin/DashboardController.php @@ -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(), ]); } } diff --git a/website/wisata_web/public/css/admin-dashboard-map.css b/website/wisata_web/public/css/admin-dashboard-map.css new file mode 100644 index 0000000..be61efa --- /dev/null +++ b/website/wisata_web/public/css/admin-dashboard-map.css @@ -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; + } +} diff --git a/website/wisata_web/public/js/admin-dashboard-map.js b/website/wisata_web/public/js/admin-dashboard-map.js new file mode 100644 index 0000000..3e02e5e --- /dev/null +++ b/website/wisata_web/public/js/admin-dashboard-map.js @@ -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, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function createMarkerIcon(mediaType) { + const color = markerColors[mediaType] || markerColors.ar; + + return L.divIcon({ + className: 'tourism-marker', + html: ``, + 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 ` +
+ ${escapeHtml(item.title)} +
+
+

${escapeHtml(item.title)}

+ ${escapeHtml(item.mediaLabel)} +
+
${escapeHtml(item.location)}
+
${escapeHtml(item.category)}
+ Lihat Detail +
+
+ `; + } + + 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 = ''; + + 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: '© OpenStreetMap contributors © CARTO', + }).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(); +})(); diff --git a/website/wisata_web/resources/views/admin/dashboard/index.blade.php b/website/wisata_web/resources/views/admin/dashboard/index.blade.php index aaa0309..2d65e68 100644 --- a/website/wisata_web/resources/views/admin/dashboard/index.blade.php +++ b/website/wisata_web/resources/views/admin/dashboard/index.blade.php @@ -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') -
-
-
-
-
-
-
Total Wisata
-
{{ $totalWisata }}
-
-
- -
+
+
+
+
+
+ Total Wisata +
{{ number_format($totalWisata) }}
-
Semua destinasi yang tersedia di aplikasi.
+
- -
-
-
-
-
-
Total Wisata AR
-
{{ $totalWisataAr }}
-
-
- -
+
+
+
+
+ Total User +
{{ number_format($totalUser) }}
-
Destinasi dengan model 3D untuk fitur AR.
+
- -
-
-
-
-
-
Total Wisata Video
-
{{ $totalWisataVideo }}
-
-
- -
+
+
+
+
+ Total Kategori +
{{ number_format($totalKategori) }}
-
Destinasi yang menampilkan media video.
-
-
-
- -
-
-
-
-
-
Total User
-
{{ $totalUser }}
-
-
- -
-
-
Pengguna aplikasi Flutter yang terdaftar.
+
-
-
-
-

Kelola Data Wisata

-

Tambah, ubah, dan lengkapi media destinasi untuk kebutuhan aplikasi Flutter.

+
+
+
+
+

Peta Persebaran Wisata Kabupaten Lumajang

+

Visualisasi lokasi seluruh destinasi wisata berdasarkan data yang tersimpan pada sistem.

+
+
+
+ + +
+ + + +
+
+ +
+
+
+
Legenda
+
Wisata Augmented Reality
+
Wisata Video
+
+
+ +
+
+
+ Jumlah Marker + 0 +
+
+
+
+ Jumlah Wisata AR + 0 +
+
+
+
+ Jumlah Wisata Video + 0 +
+
- - Buka Data Wisata -
@endsection @push('styles') - + + +@endpush + + +@push('scripts') + + + + + + + @endpush