Add SEO support with configuration and traits

- Introduced `config/seo.php` to centralize SEO settings.
- Implemented `SeoTrait` for dynamic SEO management.
- Added `SeoDataAttribute` to set SEO metadata at the class level.
- Updated various views to integrate dynamic SEO handling.
- Included fallback settings for titles, descriptions, images, and more.
This commit is contained in:
HolgerHatGarKeineNode
2025-11-22 22:12:45 +01:00
parent eb089f670c
commit 25843db5a9
9 changed files with 244 additions and 17 deletions

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Attributes;
use RalphJSmit\Laravel\SEO\Support\SEOData;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class SeoDataAttribute
{
public function __construct(
public ?string $key = null, // e.g., 'meetups_index', 'event_show', etc.
) {}
// Centralized SEO data definitions by key as SEOData instances (lazy initialized)
private static array $seoDefinitions;
private static function initDefinitions(): void
{
self::$seoDefinitions = [
'meetups_index' => new SEOData(
title: __('Meetups - Übersicht'),
description: __('Entdecke alle verfügbaren Meetups in deiner Region.'),
),
// Add more as needed
'default' => new SEOData(
title: __('Willkommen'),
description: __('Toximalistisches Infotainment für bullische Bitcoiner.'),
),
];
}
// Static method to get SEO data by key as SEOData instance
public static function getData(string $key): SEOData
{
if (empty(self::$seoDefinitions)) {
self::initDefinitions();
}
return self::$seoDefinitions[$key] ?? self::$seoDefinitions['default'];
}
// If direct SEOData is provided, return it; else fetch by key as SEOData
public function resolve(): SEOData
{
if ($this->key) {
return self::getData($this->key);
}
return self::getData('default'); // Fallback
}
}

31
app/Traits/SeoTrait.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace App\Traits;
use App\Attributes\SeoDataAttribute;
use Illuminate\Support\Facades\View;
trait SeoTrait
{
public function mountSeoTrait(): void
{
$this->setupSeo();
}
/**
* Setup SEO data from attributes and share it globally.
*/
protected function setupSeo(): void
{
$reflection = new \ReflectionClass($this);
$attributes = $reflection->getAttributes(SeoDataAttribute::class);
if (!empty($attributes)) {
$seoDataAttribute = $attributes[0]->newInstance();
$seoData = $seoDataAttribute->resolve();
} else {
$seoData = SeoDataAttribute::getData('default');
}
View::share('SEOData', $seoData);
}
}

121
config/seo.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
use RalphJSmit\Laravel\SEO\Models\SEO;
return [
/**
* The SEO model. You can use this setting to override the model used by the package.
* Make sure to always extend the old model, so that you'll not lose functionality during upgrades.
*/
'model' => SEO::class,
/**
* Use this setting to specify the site name that will be used in OpenGraph tags.
*/
'site_name' => env('APP_NAME'),
/**
* Use this setting to specify the path to the sitemap of your website. This exact path will outputted, so
* you can use both a hardcoded url and a relative path. We recommend the later.
*
* Example: '/storage/sitemap.xml'
* Do not forget the slash at the start. This will tell the search engine that the path is relative
* to the root domain and not relative to the current URL. The `spatie/laraavel-sitemap` package
* is a great package to generate sitemaps for your application.
*/
'sitemap' => null,
/**
* Use this setting to specify whether you want self-referencing `<link rel="canonical" href="$url">` tags to
* be added to the head of every page. There has been some debate whether this a good practice, but experts
* from Google and Yoast say that this is the best strategy.
* See https://yoast.com/rel-canonical/.
*/
'canonical_link' => true,
'robots' => [
/**
* Use this setting to specify the default value of the robots meta tag. `<meta name="robots" content="noindex">`
* Overwrite it with the robots attribute of the SEOData object. `SEOData->robots = 'noindex, nofollow'`
* "max-snippet:-1" Use n chars (-1: Search engine chooses) as a search result snippet.
* "max-image-preview:large" Max size of a preview in search results.
* "max-video-preview:-1" Use max seconds (-1: There is no limit) as a video snippet in search results.
* See https://developers.google.com/search/docs/advanced/robots/robots_meta_tag
* Default: 'max-snippet:-1, max-image-preview:large, max-video-preview:-1'
*/
'default' => 'max-snippet:-1,max-image-preview:large,max-video-preview:-1',
/**
* Force set the robots `default` value and make it impossible to overwrite it. (e.g. via SEOData->robots)
* Use case: You need to set `noindex, nofollow` for the entire website without exception.
* Default: false
*/
'force_default' => false,
],
/**
* Use this setting to specify the path to the favicon for your website. The url to it will be generated using the `secure_url()` function,
* so make sure to make the favicon accessibly from the `public` folder.
*
* You can use the following filetypes: ico, png, gif, jpeg, svg.
*/
'favicon' => '/img/favicon.svg',
'title' => [
/**
* Use this setting to let the package automatically infer a title from the url, if no other title
* was given. This will be very useful on pages where you don't have an Eloquent model for, or where you
* don't want to hardcode the title.
*
* For example, if you have a with the url '/foo/about-me', we'll automatically set the title to 'About me' and append the site suffix.
*/
'infer_title_from_url' => true,
/**
* Use this setting to provide a suffix that will be added after the title on each page.
* If you don't want a suffix, you should specify an empty string.
*/
'suffix' => ' - ' . env('APP_NAME'),
/**
* Use this setting to provide a custom title for the homepage. We will not use the suffix on the homepage,
* so you'll need to add the suffix manually if you want that. If set to null, we'll determine the title
* just like the other pages.
*/
'homepage_title' => null,
],
'description' => [
/**
* Use this setting to specify a fallback description, which will be used on places
* where we don't have a description set via an associated ->seo model or via
* the ->getDynamicSEOData() method.
*/
'fallback' => 'Toximalist infotainment for bullish bitcoiners.',
],
'image' => [
/**
* Use this setting to specify a fallback image, which will be used on places where you
* don't have an image set via an associated ->seo model or via the ->getDynamicSEOData() method.
* This should be a path to an image. The url to the path is generated using the `secure_url()` function (`secure_url($yourProvidedPath)`).
*/
'fallback' => '/img/social.jpg',
],
'author' => [
/**
* Use this setting to specify a fallback author, which will be used on places where you
* don't have an author set via an associated ->seo model or via the ->getDynamicSEOData() method.
*/
'fallback' => 'einundzwanzig',
],
'twitter' => [
/**
* Use this setting to enter your username and include that with the Twitter Card tags.
* Enter the username like 'yourUserName', so without the '@'.
*/
'@username' => '_einundzwanzig_',
],
];

BIN
public/img/social.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

@@ -3,6 +3,7 @@
use App\Models\LoginKey; use App\Models\LoginKey;
use App\Models\User; use App\Models\User;
use App\Notifications\ModelCreatedNotification; use App\Notifications\ModelCreatedNotification;
use App\Traits\SeoTrait;
use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@@ -20,6 +21,8 @@ use eza\lnurl;
new #[Layout('components.layouts.auth')] new #[Layout('components.layouts.auth')]
class extends Component { class extends Component {
use SeoTrait;
#[Validate('required|string|email')] #[Validate('required|string|email')]
public string $email = ''; public string $email = '';

View File

@@ -2,10 +2,13 @@
use App\Models\Meetup; use App\Models\Meetup;
use App\Models\MeetupEvent; use App\Models\MeetupEvent;
use App\Traits\SeoTrait;
use Livewire\Volt\Component; use Livewire\Volt\Component;
use Flux\Flux; use Flux\Flux;
new class extends Component { new class extends Component {
use SeoTrait;
public $selectedMeetupId = null; public $selectedMeetupId = null;
public $country = 'de'; public $country = 'de';

View File

@@ -1,11 +1,16 @@
<?php <?php
use App\Attributes\SeoDataAttribute;
use App\Models\Meetup; use App\Models\Meetup;
use App\Traits\SeoTrait;
use Livewire\Volt\Component; use Livewire\Volt\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
new class extends Component { new
#[SeoDataAttribute(key: 'meetups_index')]
class extends Component {
use WithPagination; use WithPagination;
use SeoTrait;
public $country = 'de'; public $country = 'de';
public $search = ''; public $search = '';
@@ -20,10 +25,11 @@ new class extends Component {
return [ return [
'meetups' => Meetup::with(['city.country', 'createdBy']) 'meetups' => Meetup::with(['city.country', 'createdBy'])
->withExists([ ->withExists([
'meetupEvents as has_future_events' => fn($query) => $query->where('start', '>=', now()) 'meetupEvents as has_future_events' => fn($query) => $query->where('start', '>=', now()),
]) ])
->leftJoin('meetup_events', function ($join) { ->leftJoin('meetup_events', function ($join) {
$join->on('meetups.id', '=', 'meetup_events.meetup_id') $join
->on('meetups.id', '=', 'meetup_events.meetup_id')
->where('meetup_events.start', '>=', now()); ->where('meetup_events.start', '>=', now());
}) })
->selectRaw('meetups.*, MIN(meetup_events.start) as next_event_start') ->selectRaw('meetups.*, MIN(meetup_events.start) as next_event_start')
@@ -43,14 +49,16 @@ new class extends Component {
<div class="flex items-center justify-between flex-col md:flex-row mb-6"> <div class="flex items-center justify-between flex-col md:flex-row mb-6">
<flux:heading size="xl">{{ __('Meetups') }}</flux:heading> <flux:heading size="xl">{{ __('Meetups') }}</flux:heading>
<div class="flex flex-col md:flex-row items-center gap-4"> <div class="flex flex-col md:flex-row items-center gap-4">
<flux:button class="cursor-pointer" x-copy-to-clipboard="'{{ route('ics') }}'" icon="calendar-date-range">{{ __('Kalender-Stream-URL kopieren') }}</flux:button> <flux:button class="cursor-pointer" x-copy-to-clipboard="'{{ route('ics') }}'"
icon="calendar-date-range">{{ __('Kalender-Stream-URL kopieren') }}</flux:button>
<flux:input <flux:input
wire:model.live="search" wire:model.live="search"
:placeholder="__('Suche nach Meetups...')" :placeholder="__('Suche nach Meetups...')"
clearable clearable
/> />
@auth @auth
<flux:button class="cursor-pointer" :href="route_with_country('meetups.create')" icon="plus" variant="primary"> <flux:button class="cursor-pointer" :href="route_with_country('meetups.create')" icon="plus"
variant="primary">
{{ __('Meetup erstellen') }} {{ __('Meetup erstellen') }}
</flux:button> </flux:button>
@endauth @endauth
@@ -70,9 +78,10 @@ new class extends Component {
@foreach ($meetups as $meetup) @foreach ($meetups as $meetup)
<flux:table.row :key="$meetup->id"> <flux:table.row :key="$meetup->id">
<flux:table.cell variant="strong" class="flex items-center gap-3"> <flux:table.cell variant="strong" class="flex items-center gap-3">
<flux:avatar <flux:avatar
class="[:where(&)]:size-24 [:where(&)]:text-base" size="xl" class="[:where(&)]:size-24 [:where(&)]:text-base" size="xl"
:href="route('meetups.landingpage', ['meetup' => $meetup, 'country' => $country])" src="{{ $meetup->getFirstMedia('logo') ? $meetup->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}"/> :href="route('meetups.landingpage', ['meetup' => $meetup, 'country' => $country])"
src="{{ $meetup->getFirstMedia('logo') ? $meetup->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}"/>
<div> <div>
@if($meetup->city) @if($meetup->city)
<a href="{{ route('meetups.landingpage', ['meetup' => $meetup, 'country' => $country]) }}"> <a href="{{ route('meetups.landingpage', ['meetup' => $meetup, 'country' => $country]) }}">
@@ -107,12 +116,14 @@ new class extends Component {
<flux:table.cell> <flux:table.cell>
<div class="flex gap-2"> <div class="flex gap-2">
@if($meetup->telegram_link) @if($meetup->telegram_link)
<flux:link :href="$meetup->telegram_link" external variant="subtle" title="{{ __('Telegram') }}"> <flux:link :href="$meetup->telegram_link" external variant="subtle"
title="{{ __('Telegram') }}">
<flux:icon.paper-airplane variant="mini"/> <flux:icon.paper-airplane variant="mini"/>
</flux:link> </flux:link>
@endif @endif
@if($meetup->webpage) @if($meetup->webpage)
<flux:link :href="$meetup->webpage" external variant="subtle" title="{{ __('Website') }}"> <flux:link :href="$meetup->webpage" external variant="subtle"
title="{{ __('Website') }}">
<flux:icon.globe-alt variant="mini"/> <flux:icon.globe-alt variant="mini"/>
</flux:link> </flux:link>
@endif @endif
@@ -123,7 +134,8 @@ new class extends Component {
</flux:link> </flux:link>
@endif @endif
@if($meetup->matrix_group) @if($meetup->matrix_group)
<flux:link :href="$meetup->matrix_group" external variant="subtle" title="{{ __('Matrix') }}"> <flux:link :href="$meetup->matrix_group" external variant="subtle"
title="{{ __('Matrix') }}">
<flux:icon.chat-bubble-left variant="mini"/> <flux:icon.chat-bubble-left variant="mini"/>
</flux:link> </flux:link>
@endif @endif
@@ -134,7 +146,8 @@ new class extends Component {
</flux:link> </flux:link>
@endif @endif
@if($meetup->simplex) @if($meetup->simplex)
<flux:link :href="$meetup->simplex" external variant="subtle" title="{{ __('Simplex') }}"> <flux:link :href="$meetup->simplex" external variant="subtle"
title="{{ __('Simplex') }}">
<flux:icon.chat-bubble-bottom-center-text variant="mini"/> <flux:icon.chat-bubble-bottom-center-text variant="mini"/>
</flux:link> </flux:link>
@endif @endif
@@ -151,13 +164,15 @@ new class extends Component {
<div> <div>
<flux:button <flux:button
:disabled="!$meetup->belongsToMe" :disabled="!$meetup->belongsToMe"
:href="$meetup->belongsToMe ? route_with_country('meetups.edit', ['meetup' => $meetup]) : null" size="xs" :href="$meetup->belongsToMe ? route_with_country('meetups.edit', ['meetup' => $meetup]) : null"
size="xs"
variant="filled" icon="pencil"> variant="filled" icon="pencil">
{{ __('Bearbeiten') }} {{ __('Bearbeiten') }}
</flux:button> </flux:button>
</div> </div>
<div> <div>
<flux:button :href="route_with_country('meetups.events.create', ['meetup' => $meetup])" size="xs" variant="filled" icon="calendar"> <flux:button :href="route_with_country('meetups.events.create', ['meetup' => $meetup])"
size="xs" variant="filled" icon="calendar">
{{ __('Neues Event erstellen') }} {{ __('Neues Event erstellen') }}
</flux:button> </flux:button>
</div> </div>

View File

@@ -1,10 +1,13 @@
<?php <?php
use App\Traits\SeoTrait;
use Livewire\Volt\Component; use Livewire\Volt\Component;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
new #[Layout('components.layouts.auth')] new #[Layout('components.layouts.auth')]
class extends Component { class extends Component {
use SeoTrait;
public function goToMeetups(): void public function goToMeetups(): void
{ {
$this->redirect(route_with_country('meetups.index'), navigate: true); $this->redirect(route_with_country('meetups.index'), navigate: true);
@@ -23,7 +26,8 @@ class extends Component {
<div class="flex justify-center"> <div class="flex justify-center">
<a href="/" class="group flex items-center gap-3"> <a href="/" class="group flex items-center gap-3">
<div> <div>
<flux:avatar class="[:where(&)]:size-32 [:where(&)]:text-base" size="xl" src="{{ asset('img/einundzwanzig-square.svg') }}" /> <flux:avatar class="[:where(&)]:size-32 [:where(&)]:text-base" size="xl"
src="{{ asset('img/einundzwanzig-square.svg') }}"/>
</div> </div>
</a> </a>
</div> </div>
@@ -53,7 +57,8 @@ class extends Component {
{{ __('Kartenansicht öffnen') }} {{ __('Kartenansicht öffnen') }}
</flux:button> </flux:button>
<flux:button :href="route('dashboard', ['country' => 'de'])" class="cursor-pointer w-full" icon="arrow-right-start-on-rectangle"> <flux:button :href="route('dashboard', ['country' => 'de'])" class="cursor-pointer w-full"
icon="arrow-right-start-on-rectangle">
{{ __('Login') }} {{ __('Login') }}
</flux:button> </flux:button>
</div> </div>

View File

@@ -1,7 +1,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ $title ?? config('app.name') }}</title> {!! seo($SEOData) !!}
<link rel="apple-touch-icon" href="/img/apple_touch_icon.png"/> <link rel="apple-touch-icon" href="/img/apple_touch_icon.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">