Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill

- 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>
This commit is contained in:
Rhino
2026-03-02 07:30:37 +01:00
commit 2e24a40d68
9633 changed files with 1300799 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
<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>