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:
Rhino
2026-03-03 11:53:11 +01:00
parent f9abc4561e
commit 7726fffb79
10 changed files with 214 additions and 3 deletions

View File

@@ -134,4 +134,20 @@ class EventController extends Controller
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'));
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View 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>

View File

@@ -32,9 +32,25 @@
</p>
@endif
</div>
@if (auth()->user()->canAccessAdminPanel())
<a href="{{ route('admin.events.edit', $event) }}" class="text-sm text-blue-600 hover:underline">{{ __('ui.edit') }}</a>
@endif
<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())
<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
</div>
</div>
<div class="mt-4 space-y-2 text-sm text-gray-700">
@@ -595,6 +611,44 @@
<a href="{{ route('events.index') }}" class="text-sm text-blue-600 hover:underline">&larr; {{ __('events.back_to_list') }}</a>
</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 --}}
@if ($event->hasCoordinates())
@push('styles')

View File

@@ -64,6 +64,9 @@ Route::get('/impressum', fn () => view('legal.impressum'))->name('impressum');
Route::get('/datenschutz', fn () => view('legal.datenschutz'))->name('datenschutz');
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)
Route::get('/club-logo', function () {
// 1. Dynamisches Favicon aus Settings