seedDefaultDestinationsIfEmpty(); $wisatas = Wisata::query() ->with('kategori') ->when($request->search, function ($query, string $search): void { $query->where(function ($query) use ($search): void { $query->where('title', 'like', "%{$search}%") ->orWhere('location', 'like', "%{$search}%"); }); }) ->latest() ->paginate(10) ->withQueryString(); return view('admin.wisata.index', compact('wisatas')); } private function seedDefaultDestinationsIfEmpty(): void { if (Wisata::query()->exists()) { return; } Artisan::call('db:seed', [ '--class' => DestinationSeeder::class, '--force' => true, ]); } public function create(): View { return view('admin.wisata.form', [ 'wisata' => new Wisata(), 'kategoris' => Kategori::query()->orderBy('nama')->get(), ]); } public function show(Wisata $wisata): View { $wisata->load('kategori'); return view('admin.wisata.show', compact('wisata')); } public function store(Request $request): RedirectResponse { $validated = $request->validate($this->rules(), $this->messages()); $validated['slug'] = $this->uniqueSlug($validated['title']); $validated['is_video'] = $request->boolean('is_video'); $validated = $this->storeUploads($request, $validated); Wisata::query()->create($validated); return redirect()->route('admin.wisata.index')->with('success', 'Data wisata berhasil ditambahkan.'); } public function edit(Wisata $wisata): View { return view('admin.wisata.form', [ 'wisata' => $wisata, 'kategoris' => Kategori::query()->orderBy('nama')->get(), ]); } public function update(Request $request, Wisata $wisata): RedirectResponse { $validated = $request->validate($this->rules($wisata), $this->messages()); $validated['slug'] = $this->uniqueSlug($validated['title'], $wisata->id); $validated['is_video'] = $request->boolean('is_video'); $validated = $this->storeUploads($request, $validated, $wisata); $wisata->update($validated); return redirect()->route('admin.wisata.index')->with('success', 'Data wisata berhasil diperbarui.'); } public function destroy(Wisata $wisata): RedirectResponse { $this->deleteFiles($wisata); $wisata->delete(); return back()->with('success', 'Data wisata berhasil dihapus.'); } private function rules(?Wisata $wisata = null): array { return [ 'kategori_id' => ['required', 'exists:kategoris,id'], 'title' => ['required', 'string', 'max:180', Rule::unique('wisatas', 'title')->ignore($wisata)], 'location' => ['required', 'string', 'max:255'], 'rating' => ['required', 'numeric', 'min:0', 'max:5'], 'distance' => ['nullable', 'string', 'max:80'], 'elevation' => ['nullable', 'string', 'max:80'], 'tiket_parkir' => ['nullable', 'string', 'max:120'], 'jam_operasional' => ['nullable', 'string', 'max:160'], 'short_description' => ['nullable', 'string', 'max:500'], 'overview' => ['nullable', 'string'], 'image' => [$wisata ? 'nullable' : 'required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], 'model_path' => ['nullable', 'file', 'max:51200', $this->modelFileRule()], 'video_path' => ['nullable', 'file', 'mimes:mp4,webm,mov', 'max:102400'], 'is_video' => ['nullable', 'boolean'], 'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'], ]; } private function messages(): array { return [ 'model_path.file' => 'File model 3D tidak valid. Silakan pilih file .glb atau .gltf.', 'model_path.max' => 'Ukuran file model 3D maksimal 50MB.', 'image.required' => 'Gambar wisata wajib diunggah.', 'image.image' => 'File gambar wisata harus berupa gambar yang valid.', 'image.mimes' => 'Gambar wisata harus berformat JPG, JPEG, PNG, atau WEBP.', 'image.max' => 'Ukuran gambar wisata maksimal 4MB.', 'video_path.mimes' => 'Video wisata harus berformat MP4, WEBM, atau MOV.', 'video_path.max' => 'Ukuran video wisata maksimal 100MB.', ]; } private function modelFileRule(): \Closure { return function (string $attribute, mixed $value, \Closure $fail): void { unset($attribute); if (! $value) { return; } $extension = Str::lower($value->getClientOriginalExtension()); if (! in_array($extension, ['glb', 'gltf'], true)) { $fail('File model 3D harus berformat .glb atau .gltf.'); return; } $mimeType = Str::lower($value->getMimeType() ?: $value->getClientMimeType() ?: ''); $allowedMimeTypes = [ 'model/gltf-binary', 'model/gltf+json', 'application/octet-stream', 'application/json', 'text/plain', 'text/json', ]; if ($mimeType && ! in_array($mimeType, $allowedMimeTypes, true)) { $fail('File model 3D tidak dikenali. Gunakan file .glb atau .gltf yang valid.'); } }; } private function storeUploads(Request $request, array $data, ?Wisata $wisata = null): array { foreach (['image' => 'wisata', 'model_path' => 'models', 'video_path' => 'videos'] as $field => $folder) { if ($request->hasFile($field)) { if ($wisata?->{$field}) { Storage::disk('public')->delete($wisata->publicStoragePath($wisata->{$field})); } $data[$field] = $field === 'image' ? Storage::disk('public')->putFile('wisata', $request->file('image')) : $request->file($field)->storeAs( $folder, $this->uniqueUploadName($request->file($field)), 'public' ); } } return $data; } private function uniqueUploadName(\Illuminate\Http\UploadedFile $file): string { $extension = Str::lower($file->getClientOriginalExtension() ?: $file->extension()); return Str::uuid().($extension ? ".{$extension}" : ''); } private function deleteFiles(Wisata $wisata): void { Storage::disk('public')->delete(array_filter([ $wisata->publicStoragePath($wisata->image), $wisata->publicStoragePath($wisata->model_path), $wisata->publicStoragePath($wisata->video_path), ])); } private function uniqueSlug(string $name, ?int $ignoreId = null): string { $slug = Str::slug($name); $original = $slug; $counter = 1; while (Wisata::query()->where('slug', $slug)->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId))->exists()) { $slug = "{$original}-{$counter}"; $counter++; } return $slug; } }