- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er) - Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link - Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest - Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen) - Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
197 lines
13 KiB
PHP
Executable File
197 lines
13 KiB
PHP
Executable File
<x-layouts.admin :title="__('admin.locations_title')">
|
||
@push('styles')
|
||
<style>
|
||
.photon-dropdown { position: absolute; z-index: 10; margin-top: 0.25rem; width: 100%; background: white; border: 1px solid #d1d5db; border-radius: 0.375rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,.1); max-height: 15rem; overflow-y: auto; }
|
||
</style>
|
||
@endpush
|
||
|
||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.locations_title') }}</h1>
|
||
|
||
{{-- Neuen Ort anlegen --}}
|
||
<div class="bg-white rounded-lg shadow p-6 mb-6 max-w-3xl" x-data="locationForm()">
|
||
<h2 class="text-lg font-semibold mb-4">{{ __('admin.location_add') }}</h2>
|
||
<form method="POST" action="{{ route('admin.locations.store') }}">
|
||
@csrf
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.location_name_label') }} *</label>
|
||
<input type="text" name="name" value="{{ old('name') }}" required
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="{{ __('admin.location_name_placeholder') }}">
|
||
@error('name')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||
</div>
|
||
<div class="relative">
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.search_address') }}</label>
|
||
<input type="text" x-model="query" @input.debounce.300ms="search()" @keydown.escape="results = []"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="{{ __('admin.search_address_hint') }}" autocomplete="off">
|
||
<div x-show="results.length > 0" x-cloak @click.outside="results = []" class="photon-dropdown">
|
||
<template x-for="(r, idx) in results" :key="idx">
|
||
<button type="button" @click="select(r)" class="w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 text-sm">
|
||
<span class="block font-medium text-gray-900" x-text="r.title"></span>
|
||
<span class="block text-xs text-gray-500" x-text="r.subtitle"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||
<div class="md:col-span-2">
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.address') }}</label>
|
||
<input type="text" name="address_text" x-model="addressText"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="{{ __('admin.address_manual_hint') }}">
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Lat</label>
|
||
<input type="text" name="location_lat" x-model="lat" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" readonly>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Lng</label>
|
||
<input type="text" name="location_lng" x-model="lng" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" readonly>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('admin.location_save') }}</button>
|
||
</form>
|
||
</div>
|
||
|
||
{{-- Bestehende Orte --}}
|
||
@if ($locations->isNotEmpty())
|
||
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto max-w-3xl">
|
||
<table class="w-full text-sm">
|
||
<thead class="bg-gray-50 border-b">
|
||
<tr>
|
||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.location_name_label') }}</th>
|
||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.address') }}</th>
|
||
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-100">
|
||
@foreach ($locations as $location)
|
||
<tr class="hover:bg-gray-50" x-data="{ editing: false }">
|
||
{{-- Anzeige-Modus --}}
|
||
<template x-if="!editing">
|
||
<td class="px-4 py-3 font-medium text-gray-900">{{ $location->name }}</td>
|
||
</template>
|
||
<template x-if="!editing">
|
||
<td class="px-4 py-3 text-gray-600">
|
||
{{ $location->address_text ?: '–' }}
|
||
@if ($location->location_lat)
|
||
<span class="text-xs text-gray-400 ml-1">({{ number_format($location->location_lat, 4) }}, {{ number_format($location->location_lng, 4) }})</span>
|
||
@endif
|
||
</td>
|
||
</template>
|
||
<template x-if="!editing">
|
||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||
<button type="button" @click="editing = true" class="text-xs text-blue-600 hover:text-blue-800 mr-2">{{ __('ui.edit') }}</button>
|
||
<form method="POST" action="{{ route('admin.locations.destroy', $location) }}" class="inline" onsubmit="return confirm(@js(__('admin.location_confirm_delete')))">
|
||
@csrf
|
||
@method('DELETE')
|
||
<button type="submit" class="text-xs text-red-600 hover:text-red-800">{{ __('ui.delete') }}</button>
|
||
</form>
|
||
</td>
|
||
</template>
|
||
|
||
{{-- Bearbeitungs-Modus --}}
|
||
<template x-if="editing">
|
||
<td colspan="3" class="px-4 py-3" x-data="locationForm(@js($location->address_text ?? ''), @js($location->location_lat), @js($location->location_lng))">
|
||
<form method="POST" action="{{ route('admin.locations.update', $location) }}">
|
||
@csrf
|
||
@method('PUT')
|
||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 items-end">
|
||
<div>
|
||
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.location_name_label') }}</label>
|
||
<input type="text" name="name" value="{{ $location->name }}" required class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
|
||
</div>
|
||
<div class="relative">
|
||
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.search_address') }}</label>
|
||
<input type="text" x-model="query" @input.debounce.300ms="search()" @keydown.escape="results = []"
|
||
class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm" autocomplete="off">
|
||
<div x-show="results.length > 0" x-cloak @click.outside="results = []" class="photon-dropdown">
|
||
<template x-for="(r, idx) in results" :key="idx">
|
||
<button type="button" @click="select(r)" class="w-full text-left px-2 py-1.5 hover:bg-blue-50 border-b border-gray-100 text-xs">
|
||
<span class="block font-medium" x-text="r.title"></span>
|
||
<span class="block text-gray-500" x-text="r.subtitle"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.address') }}</label>
|
||
<input type="text" name="address_text" x-model="addressText" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
|
||
<input type="hidden" name="location_lat" x-model="lat">
|
||
<input type="hidden" name="location_lng" x-model="lng">
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button type="submit" class="bg-blue-600 text-white px-3 py-1.5 rounded-md text-xs font-medium hover:bg-blue-700">{{ __('ui.save') }}</button>
|
||
<button type="button" @click="editing = false" class="text-xs text-gray-600 hover:underline">{{ __('ui.cancel') }}</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</td>
|
||
</template>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
@else
|
||
<p class="text-sm text-gray-500">{{ __('admin.locations_empty') }}</p>
|
||
@endif
|
||
|
||
@push('scripts')
|
||
<script>
|
||
function locationForm(initAddress = '', initLat = '', initLng = '') {
|
||
return {
|
||
query: '',
|
||
results: [],
|
||
addressText: initAddress || '',
|
||
lat: initLat || '',
|
||
lng: initLng || '',
|
||
_abortCtrl: null,
|
||
formatFeature(f) {
|
||
const p = f.properties;
|
||
const street = [p.street, p.housenumber].filter(Boolean).join(' ');
|
||
const effectiveStreet = street || (p.name && p.name !== p.city ? p.name : '');
|
||
const cityLine = [p.postcode, p.city].filter(Boolean).join(' ');
|
||
const address = [effectiveStreet, cityLine].filter(Boolean).join(', ');
|
||
const name = p.name || '';
|
||
const isPlace = name && name !== effectiveStreet && name !== p.city && name !== p.street;
|
||
return {
|
||
title: isPlace ? name : (address || name || ''),
|
||
subtitle: isPlace ? address : (cityLine || p.state || ''),
|
||
address: address || name || '',
|
||
lat: f.geometry.coordinates[1],
|
||
lon: f.geometry.coordinates[0],
|
||
};
|
||
},
|
||
async search() {
|
||
const q = this.query.trim();
|
||
if (q.length < 2) { this.results = []; return; }
|
||
if (this._abortCtrl) this._abortCtrl.abort();
|
||
this._abortCtrl = new AbortController();
|
||
try {
|
||
const params = new URLSearchParams({
|
||
q: q, lang: 'de', limit: '7',
|
||
lat: '51.4', lon: '7.5',
|
||
bbox: '5.87,50.32,9.46,52.53',
|
||
});
|
||
const resp = await fetch('https://photon.komoot.io/api/?' + params, { signal: this._abortCtrl.signal });
|
||
const data = await resp.json();
|
||
this.results = (data.features || []).map(f => this.formatFeature(f));
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') this.results = [];
|
||
}
|
||
},
|
||
select(r) {
|
||
this.addressText = r.address;
|
||
this.lat = r.lat;
|
||
this.lng = r.lon;
|
||
this.query = r.address;
|
||
this.results = [];
|
||
}
|
||
};
|
||
}
|
||
</script>
|
||
@endpush
|
||
</x-layouts.admin>
|