Teilen-Funktion: Öffentliche Share-Seite mit OG-Meta-Tags und Share-Button
- Öffentliche Route /e/{event} für Social-Media-Crawler (WhatsApp, Facebook)
- Share-View mit OG-Meta-Tags (Titel, Datum, Bild) für Link-Vorschau
- Teilen-Button auf Event-Detailseite (Web Share API + Clipboard-Fallback)
- Buttons: Teilen (helles Blau) + Bearbeiten (Standard-Blau)
- Hinweistext mit 3,5s Anzeige nach Link-Kopieren
- Event-Typ-Logos als neue Bilddateien
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,4 +134,20 @@ class EventController extends Controller
|
|||||||
|
|
||||||
return view('events.show', compact('event', 'userChildIds', 'userChildren', 'myCatering', 'myTimekeeper', 'myCarpool', 'cateringHistory', 'timekeeperHistory'));
|
return view('events.show', compact('event', 'userChildIds', 'userChildren', 'myCatering', 'myTimekeeper', 'myCarpool', 'cateringHistory', 'timekeeperHistory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Share-Seite mit OG-Meta-Tags für Social-Media-Vorschau.
|
||||||
|
*/
|
||||||
|
public function share(Event $event): View
|
||||||
|
{
|
||||||
|
// Nur veröffentlichte/abgesagte Events (keine Entwürfe)
|
||||||
|
if ($event->status === EventStatus::Draft) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->load('team');
|
||||||
|
$appName = Setting::get('app_name', config('app.name'));
|
||||||
|
|
||||||
|
return view('events.share', compact('event', 'appName'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/images/Logo_Auswärtsspiel.png
Normal file
BIN
public/images/Logo_Auswärtsspiel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
BIN
public/images/Logo_Besprechung.png
Normal file
BIN
public/images/Logo_Besprechung.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
public/images/Logo_Heimspiel.png
Normal file
BIN
public/images/Logo_Heimspiel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
public/images/Logo_Sonstiges.png
Normal file
BIN
public/images/Logo_Sonstiges.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
public/images/Logo_Training.png
Normal file
BIN
public/images/Logo_Training.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
public/images/Logo_Turnier.png
Normal file
BIN
public/images/Logo_Turnier.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
138
resources/views/events/share.blade.php
Normal file
138
resources/views/events/share.blade.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>{{ $event->title }} - {{ $appName }}</title>
|
||||||
|
|
||||||
|
{{-- OG Meta Tags für Social-Media-Vorschau --}}
|
||||||
|
@php
|
||||||
|
$ogDescription = $event->start_at->translatedFormat(__('ui.date_format_long')) . ' ' . __('ui.clock');
|
||||||
|
if ($event->location_name) {
|
||||||
|
$ogDescription .= ' · ' . $event->location_name;
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="{{ route('events.share', $event) }}">
|
||||||
|
<meta property="og:title" content="{{ $event->title }}">
|
||||||
|
<meta property="og:description" content="{{ $ogDescription }}">
|
||||||
|
<meta property="og:image" content="{{ $event->imageUrl() }}">
|
||||||
|
<meta property="og:site_name" content="{{ $appName }}">
|
||||||
|
|
||||||
|
{{-- Twitter Card --}}
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:title" content="{{ $event->title }}">
|
||||||
|
<meta name="twitter:description" content="{{ $ogDescription }}">
|
||||||
|
<meta name="twitter:image" content="{{ $event->imageUrl() }}">
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
@include('components.tailwind-config')
|
||||||
|
@php $favicon = \App\Models\Setting::get('app_favicon'); @endphp
|
||||||
|
@if ($favicon)
|
||||||
|
<link rel="icon" href="{{ asset('storage/' . $favicon) }}">
|
||||||
|
@else
|
||||||
|
<link rel="icon" href="{{ asset('favicon.ico') }}">
|
||||||
|
@endif
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gray-100 flex flex-col">
|
||||||
|
<main class="flex-1 flex items-center justify-center px-4 py-12">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
{{-- App-Logo --}}
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
@php $logoLogin = \App\Models\Setting::get('app_logo_login'); @endphp
|
||||||
|
<img src="{{ $logoLogin ? asset('storage/' . $logoLogin) : asset('images/vereinos_logo.png') }}"
|
||||||
|
alt="{{ $appName }}" class="mx-auto h-16 mb-2 object-contain">
|
||||||
|
<p class="text-sm text-gray-500">{{ $appName }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
{{-- Event-Bild --}}
|
||||||
|
<div class="flex justify-center bg-gray-50 py-6">
|
||||||
|
<img src="{{ $event->imageUrl() }}" alt="{{ $event->type->label() }}"
|
||||||
|
class="h-24 w-24 object-contain">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
{{-- Abgesagt-Banner --}}
|
||||||
|
@if ($event->status === \App\Enums\EventStatus::Cancelled)
|
||||||
|
<div class="bg-red-600 text-white text-center py-2 px-3 rounded-md mb-4 text-sm font-semibold">
|
||||||
|
{{ __('events.share_cancelled_notice') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Event-Typ Badge + Team --}}
|
||||||
|
<div class="mb-2">
|
||||||
|
<x-event-type-badge :type="$event->type" />
|
||||||
|
<span class="text-xs text-gray-500 ml-1">{{ $event->team->name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Titel --}}
|
||||||
|
<h1 class="text-xl font-bold {{ $event->status === \App\Enums\EventStatus::Cancelled ? 'line-through text-gray-400' : 'text-gray-900' }}">
|
||||||
|
{{ $event->title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{{-- Gegner und Ergebnis (bei Spielen) --}}
|
||||||
|
@if ($event->type->isGameType() && $event->opponent)
|
||||||
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
|
{{ __('events.vs') }} {{ $event->opponent }}
|
||||||
|
@if ($event->hasScore())
|
||||||
|
<span class="font-semibold ml-2">{{ $event->scoreDisplay() }}</span>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Datum und Uhrzeit --}}
|
||||||
|
<div class="mt-4 space-y-2 text-sm text-gray-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{{ $event->start_at->translatedFormat(__('ui.date_format_long')) }} {{ __('ui.clock') }}
|
||||||
|
@if ($event->end_at)
|
||||||
|
– {{ $event->end_at->format('H:i') }} {{ __('ui.clock') }}
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($event->location_name || $event->address_text)
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<svg class="w-4 h-4 text-gray-400 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
@if ($event->location_name)
|
||||||
|
<span class="font-medium">{{ $event->location_name }}</span>
|
||||||
|
@endif
|
||||||
|
@if ($event->location_name && $event->address_text)
|
||||||
|
<br>
|
||||||
|
@endif
|
||||||
|
@if ($event->address_text)
|
||||||
|
<span class="text-gray-500">{{ $event->address_text }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- CTA-Button --}}
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="{{ route('events.show', $event) }}"
|
||||||
|
class="block w-full text-center bg-blue-600 text-white py-3 rounded-md font-medium hover:bg-blue-700 transition">
|
||||||
|
{{ __('events.share_view_in_app') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Footer --}}
|
||||||
|
<div class="text-center mt-6 text-sm text-gray-500">
|
||||||
|
<a href="/impressum" class="hover:underline">{{ __('ui.footer_impressum') }}</a>
|
||||||
|
<span class="mx-2">|</span>
|
||||||
|
<a href="/datenschutz" class="hover:underline">{{ __('ui.footer_privacy') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -32,10 +32,26 @@
|
|||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{{-- Teilen-Button --}}
|
||||||
|
<div x-data="shareButton()" class="relative">
|
||||||
|
<button @click="doShare()" type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm font-medium text-white bg-blue-400 hover:bg-blue-500 px-3 py-1.5 rounded-md transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
|
||||||
|
</svg>
|
||||||
|
{{ __('events.share') }}
|
||||||
|
</button>
|
||||||
|
<div x-show="copied" x-transition.opacity class="absolute top-full mt-2 right-0 bg-blue-600 text-white text-sm px-4 py-2 rounded-md shadow-lg whitespace-nowrap z-10">
|
||||||
|
{{ __('events.share_link_copied') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@if (auth()->user()->canAccessAdminPanel())
|
@if (auth()->user()->canAccessAdminPanel())
|
||||||
<a href="{{ route('admin.events.edit', $event) }}" class="text-sm text-blue-600 hover:underline">{{ __('ui.edit') }}</a>
|
<a href="{{ route('admin.events.edit', $event) }}" class="inline-flex items-center gap-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md transition">{{ __('ui.edit') }}</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-2 text-sm text-gray-700">
|
<div class="mt-4 space-y-2 text-sm text-gray-700">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -595,6 +611,44 @@
|
|||||||
<a href="{{ route('events.index') }}" class="text-sm text-blue-600 hover:underline">← {{ __('events.back_to_list') }}</a>
|
<a href="{{ route('events.index') }}" class="text-sm text-blue-600 hover:underline">← {{ __('events.back_to_list') }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Share-Button Script --}}
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
function shareButton() {
|
||||||
|
return {
|
||||||
|
copied: false,
|
||||||
|
async doShare() {
|
||||||
|
const shareData = {
|
||||||
|
title: @js($event->title),
|
||||||
|
text: @js($event->title . ' — ' . $event->start_at->translatedFormat(__('ui.date_format_long')) . ' ' . __('ui.clock')),
|
||||||
|
url: @js(route('events.share', $event)),
|
||||||
|
};
|
||||||
|
if (navigator.share) {
|
||||||
|
try { await navigator.share(shareData); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareData.url);
|
||||||
|
this.copied = true;
|
||||||
|
setTimeout(() => this.copied = false, 3500);
|
||||||
|
} catch (e) {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = shareData.url;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
this.copied = true;
|
||||||
|
setTimeout(() => this.copied = false, 3500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
{{-- Leaflet.js Karte --}}
|
{{-- Leaflet.js Karte --}}
|
||||||
@if ($event->hasCoordinates())
|
@if ($event->hasCoordinates())
|
||||||
@push('styles')
|
@push('styles')
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ Route::get('/impressum', fn () => view('legal.impressum'))->name('impressum');
|
|||||||
Route::get('/datenschutz', fn () => view('legal.datenschutz'))->name('datenschutz');
|
Route::get('/datenschutz', fn () => view('legal.datenschutz'))->name('datenschutz');
|
||||||
Route::get('/offline', fn () => view('offline'))->name('offline');
|
Route::get('/offline', fn () => view('offline'))->name('offline');
|
||||||
|
|
||||||
|
// Event-Share (öffentlich für Social-Media-Crawler)
|
||||||
|
Route::get('/e/{event}', [\App\Http\Controllers\EventController::class, 'share'])->name('events.share')->middleware('throttle:60,1');
|
||||||
|
|
||||||
// Club-Logo — öffentlich erreichbar für externe Dienste (z.B. Support-Backend)
|
// Club-Logo — öffentlich erreichbar für externe Dienste (z.B. Support-Backend)
|
||||||
Route::get('/club-logo', function () {
|
Route::get('/club-logo', function () {
|
||||||
// 1. Dynamisches Favicon aus Settings
|
// 1. Dynamisches Favicon aus Settings
|
||||||
|
|||||||
Reference in New Issue
Block a user