SEO additions
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CmsPage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class CmsPageController extends Controller
|
||||
{
|
||||
public function index() { return view('admin.cms.index', ['pages' => CmsPage::latest()->paginate(25)]); }
|
||||
public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); }
|
||||
public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); }
|
||||
public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); }
|
||||
public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); }
|
||||
public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); }
|
||||
|
||||
private function validated(Request $request, ?CmsPage $page = null): array
|
||||
{
|
||||
$request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]);
|
||||
$data = $request->validate([
|
||||
'title' => ['required','string','max:160'],
|
||||
'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)],
|
||||
'excerpt' => ['nullable','string','max:1000'],
|
||||
'body' => ['required','string'],
|
||||
'meta_title' => ['nullable','string','max:160'],
|
||||
'meta_description' => ['nullable','string','max:320'],
|
||||
'meta_keywords' => ['nullable','string','max:500'],
|
||||
'canonical_url' => ['nullable','url:http,https','max:2048'],
|
||||
'og_title' => ['nullable','string','max:160'],
|
||||
'og_description' => ['nullable','string','max:320'],
|
||||
'og_image' => ['nullable','url:http,https','max:2048'],
|
||||
'sitemap_priority' => ['required','numeric','min:0','max:1'],
|
||||
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
|
||||
]);
|
||||
$data['robots_index'] = $request->boolean('robots_index');
|
||||
$data['robots_follow'] = $request->boolean('robots_follow');
|
||||
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class SeoSettingsController extends Controller
|
||||
{
|
||||
private const KEYS = [
|
||||
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
|
||||
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
|
||||
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
|
||||
];
|
||||
|
||||
public function edit(): View
|
||||
{
|
||||
return view('admin.seo.edit', [
|
||||
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
|
||||
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'seo_site_name' => ['required', 'string', 'max:80'],
|
||||
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
|
||||
'seo_default_title' => ['required', 'string', 'max:160'],
|
||||
'seo_default_description' => ['required', 'string', 'max:320'],
|
||||
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
|
||||
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
|
||||
'seo_organization_name' => ['nullable', 'string', 'max:120'],
|
||||
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
foreach (self::KEYS as $key) {
|
||||
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
|
||||
}
|
||||
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
|
||||
|
||||
return back()->with('status', 'SEO settings updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CmsPage;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class SeoController extends Controller
|
||||
{
|
||||
public function sitemap(): Response
|
||||
{
|
||||
$static = collect([
|
||||
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
|
||||
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
|
||||
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
|
||||
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
])->filter(fn (array $item) => Route::has($item['route']))->values();
|
||||
|
||||
$pages = CmsPage::query()
|
||||
->where('is_published', true)
|
||||
->where('include_in_sitemap', true)
|
||||
->where('robots_index', true)
|
||||
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
|
||||
->orderBy('slug')
|
||||
->get();
|
||||
|
||||
return response()
|
||||
->view('seo.sitemap', compact('static', 'pages'))
|
||||
->header('Content-Type', 'application/xml; charset=UTF-8')
|
||||
->header('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
|
||||
public function robots(): Response
|
||||
{
|
||||
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
|
||||
|
||||
$body = $indexable
|
||||
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
|
||||
: "User-agent: *\nDisallow: /\n";
|
||||
|
||||
return response($body, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class CmsPage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
|
||||
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
|
||||
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
|
||||
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class SiteSetting extends Model
|
||||
{
|
||||
protected $table = 'sonardesk_site_settings';
|
||||
protected $primaryKey = 'key';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
protected $fillable = ['key', 'value'];
|
||||
|
||||
public static function valueFor(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
|
||||
}
|
||||
|
||||
public static function booleanFor(string $key, bool $default = false): bool
|
||||
{
|
||||
$value = static::valueFor($key);
|
||||
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
public static function put(string $key, ?string $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
Cache::forget('sonardesk.setting.'.$key);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,17 @@ final class CmsPageController extends Controller
|
||||
'body' => ['required','string'],
|
||||
'meta_title' => ['nullable','string','max:160'],
|
||||
'meta_description' => ['nullable','string','max:320'],
|
||||
'meta_keywords' => ['nullable','string','max:500'],
|
||||
'canonical_url' => ['nullable','url:http,https','max:2048'],
|
||||
'og_title' => ['nullable','string','max:160'],
|
||||
'og_description' => ['nullable','string','max:320'],
|
||||
'og_image' => ['nullable','url:http,https','max:2048'],
|
||||
'sitemap_priority' => ['required','numeric','min:0','max:1'],
|
||||
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
|
||||
]);
|
||||
$data['robots_index'] = $request->boolean('robots_index');
|
||||
$data['robots_follow'] = $request->boolean('robots_follow');
|
||||
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
|
||||
return $data;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class SeoSettingsController extends Controller
|
||||
{
|
||||
private const KEYS = [
|
||||
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
|
||||
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
|
||||
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
|
||||
];
|
||||
|
||||
public function edit(): View
|
||||
{
|
||||
return view('admin.seo.edit', [
|
||||
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
|
||||
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'seo_site_name' => ['required', 'string', 'max:80'],
|
||||
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
|
||||
'seo_default_title' => ['required', 'string', 'max:160'],
|
||||
'seo_default_description' => ['required', 'string', 'max:320'],
|
||||
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
|
||||
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
|
||||
'seo_organization_name' => ['nullable', 'string', 'max:120'],
|
||||
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
foreach (self::KEYS as $key) {
|
||||
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
|
||||
}
|
||||
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
|
||||
|
||||
return back()->with('status', 'SEO settings updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CmsPage;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class SeoController extends Controller
|
||||
{
|
||||
public function sitemap(): Response
|
||||
{
|
||||
$static = collect([
|
||||
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
|
||||
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
|
||||
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
|
||||
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
])->filter(fn (array $item) => Route::has($item['route']))->values();
|
||||
|
||||
$pages = CmsPage::query()
|
||||
->where('is_published', true)
|
||||
->where('include_in_sitemap', true)
|
||||
->where('robots_index', true)
|
||||
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
|
||||
->orderBy('slug')
|
||||
->get();
|
||||
|
||||
return response()
|
||||
->view('seo.sitemap', compact('static', 'pages'))
|
||||
->header('Content-Type', 'application/xml; charset=UTF-8')
|
||||
->header('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
|
||||
public function robots(): Response
|
||||
{
|
||||
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
|
||||
|
||||
$body = $indexable
|
||||
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
|
||||
: "User-agent: *\nDisallow: /\n";
|
||||
|
||||
return response($body, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,17 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class CmsPage extends Model
|
||||
{
|
||||
protected $fillable = ['title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'is_published', 'published_at'];
|
||||
protected $fillable = [
|
||||
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
|
||||
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
|
||||
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_published' => 'boolean', 'published_at' => 'datetime'];
|
||||
return [
|
||||
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
|
||||
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ final class SiteSetting extends Model
|
||||
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
|
||||
}
|
||||
|
||||
public static function booleanFor(string $key, bool $default = false): bool
|
||||
{
|
||||
$value = static::valueFor($key);
|
||||
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
public static function put(string $key, ?string $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cms_pages', function (Blueprint $table): void {
|
||||
$table->string('meta_keywords', 500)->nullable()->after('meta_description');
|
||||
$table->string('canonical_url', 2048)->nullable()->after('meta_keywords');
|
||||
$table->string('og_title')->nullable()->after('canonical_url');
|
||||
$table->string('og_description', 320)->nullable()->after('og_title');
|
||||
$table->string('og_image', 2048)->nullable()->after('og_description');
|
||||
$table->boolean('robots_index')->default(true)->after('og_image');
|
||||
$table->boolean('robots_follow')->default(true)->after('robots_index');
|
||||
$table->boolean('include_in_sitemap')->default(true)->after('robots_follow')->index();
|
||||
$table->decimal('sitemap_priority', 2, 1)->default(0.5)->after('include_in_sitemap');
|
||||
$table->string('sitemap_change_frequency', 20)->default('monthly')->after('sitemap_priority');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_pages', function (Blueprint $table): void {
|
||||
$table->dropIndex(['include_in_sitemap']);
|
||||
$table->dropColumn([
|
||||
'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image',
|
||||
'robots_index', 'robots_follow', 'include_in_sitemap', 'sitemap_priority',
|
||||
'sitemap_change_frequency',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,17 @@
|
||||
<label>Slug<input name="slug" value="{{ old('slug',$page->slug) }}" placeholder="generated-from-title" maxlength="180"></label>
|
||||
<label>Excerpt<textarea name="excerpt" rows="3" maxlength="1000">{{ old('excerpt',$page->excerpt) }}</textarea></label>
|
||||
<label>Body<textarea name="body" rows="18" required>{{ old('body',$page->body) }}</textarea><small>Plain text is rendered safely with paragraphs and line breaks.</small></label>
|
||||
<div class="form-grid two"><label>SEO title<input name="meta_title" value="{{ old('meta_title',$page->meta_title) }}" maxlength="160"></label><label>SEO description<textarea name="meta_description" rows="3" maxlength="320">{{ old('meta_description',$page->meta_description) }}</textarea></label></div>
|
||||
<section class="seo-section" style="padding:24px 0 4px;border-top:1px solid var(--line);margin-top:24px">
|
||||
<div class="seo-section-head"><div><span class="eyebrow">Search appearance</span><h2>Page SEO</h2><p>Override global defaults only where this page needs specific search or social metadata.</p></div><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">Global SEO settings</a></div>
|
||||
<div class="form-grid two"><label>SEO title<input name="meta_title" value="{{ old('meta_title',$page->meta_title) }}" maxlength="160" placeholder="Defaults to the page title"></label><label>Canonical URL<input type="url" name="canonical_url" value="{{ old('canonical_url',$page->canonical_url) }}" maxlength="2048" placeholder="Leave blank for this page URL"></label></div>
|
||||
<label>SEO description<textarea name="meta_description" rows="3" maxlength="320" placeholder="Defaults to the excerpt">{{ old('meta_description',$page->meta_description) }}</textarea></label>
|
||||
<label>Keywords<input name="meta_keywords" value="{{ old('meta_keywords',$page->meta_keywords) }}" maxlength="500" placeholder="Optional comma-separated search topics"></label>
|
||||
<div class="form-grid two"><label>Open Graph title<input name="og_title" value="{{ old('og_title',$page->og_title) }}" maxlength="160" placeholder="Defaults to SEO title"></label><label>Open Graph image URL<input type="url" name="og_image" value="{{ old('og_image',$page->og_image) }}" maxlength="2048" placeholder="Defaults to global social image"></label></div>
|
||||
<label>Open Graph description<textarea name="og_description" rows="3" maxlength="320" placeholder="Defaults to SEO description">{{ old('og_description',$page->og_description) }}</textarea></label>
|
||||
<div class="form-grid two"><label>Sitemap priority<input type="number" name="sitemap_priority" value="{{ old('sitemap_priority',$page->sitemap_priority ?? '0.5') }}" min="0" max="1" step="0.1" required></label><label>Change frequency<select name="sitemap_change_frequency" required>@foreach(['always','hourly','daily','weekly','monthly','yearly','never'] as $frequency)<option value="{{ $frequency }}" @selected(old('sitemap_change_frequency',$page->sitemap_change_frequency ?? 'monthly') === $frequency)>{{ ucfirst($frequency) }}</option>@endforeach</select></label></div>
|
||||
<div class="form-grid two"><label class="consent-check"><input type="checkbox" name="robots_index" value="1" @checked(old('robots_index',$page->exists ? $page->robots_index : true))><span>Allow search engines to index this page</span></label><label class="consent-check"><input type="checkbox" name="robots_follow" value="1" @checked(old('robots_follow',$page->exists ? $page->robots_follow : true))><span>Allow crawlers to follow links</span></label></div>
|
||||
<label class="consent-check"><input type="checkbox" name="include_in_sitemap" value="1" @checked(old('include_in_sitemap',$page->exists ? $page->include_in_sitemap : true))><span>Include this page in sitemap.xml</span></label>
|
||||
</section>
|
||||
<label class="consent-check"><input type="checkbox" name="is_published" value="1" @checked(old('is_published',$page->is_published))><span>Published</span></label>
|
||||
<div class="support-actions"><button type="submit">Save page</button>@if($page->exists && $page->is_published)<a class="btn btn-secondary" target="_blank" href="{{ route('cms.show',$page->slug) }}">View page</a>@endif</div>
|
||||
</form></div>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
@section('content')
|
||||
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
|
||||
<div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">SEO & search</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
@extends('layouts.app')
|
||||
@section('content')
|
||||
<section class="portal-header admin-release-head">
|
||||
<div><span class="eyebrow">Search & discovery</span><h1>SEO settings</h1><p class="lead compact">Control how SonarDesk appears in search engines and when pages are shared.</p></div>
|
||||
<div class="support-actions"><a class="btn btn-secondary" target="_blank" href="{{ route('sitemap') }}">View sitemap</a><a class="btn btn-secondary" target="_blank" href="{{ route('robots') }}">View robots.txt</a></div>
|
||||
</section>
|
||||
<form method="post" action="{{ route('admin.seo.update') }}" class="seo-admin-form">@csrf @method('PUT')
|
||||
<section class="panel seo-section">
|
||||
<div class="seo-section-head"><div><span class="eyebrow">Search appearance</span><h2>Site defaults</h2><p>Fallback metadata used whenever a page does not provide its own SEO values.</p></div><span class="seo-badge">Global</span></div>
|
||||
<div class="form-grid two">
|
||||
<label>Site name<input name="seo_site_name" value="{{ old('seo_site_name',$settings['seo_site_name'] ?: 'SonarDesk') }}" required maxlength="80"></label>
|
||||
<label>Title suffix<input name="seo_title_suffix" value="{{ old('seo_title_suffix',$settings['seo_title_suffix'] ?: 'SonarDesk') }}" maxlength="80"><small>Appended to page titles where appropriate.</small></label>
|
||||
</div>
|
||||
<label>Default SEO title<input name="seo_default_title" value="{{ old('seo_default_title',$settings['seo_default_title'] ?: 'SonarDesk — Native SonarQube Monitoring for macOS') }}" required maxlength="160"></label>
|
||||
<label>Default meta description<textarea name="seo_default_description" rows="3" maxlength="320" required>{{ old('seo_default_description',$settings['seo_default_description'] ?: 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.') }}</textarea></label>
|
||||
<label>Default keywords<input name="seo_default_keywords" value="{{ old('seo_default_keywords',$settings['seo_default_keywords']) }}" maxlength="500" placeholder="SonarQube, macOS, code quality, developer tools"></label>
|
||||
<label>Canonical base URL<input type="url" name="seo_canonical_base_url" value="{{ old('seo_canonical_base_url',$settings['seo_canonical_base_url']) }}" maxlength="2048" placeholder="https://sonardesk.app"><small>Leave blank to use the application URL. Use the public production origin only.</small></label>
|
||||
</section>
|
||||
|
||||
<section class="panel seo-section">
|
||||
<div class="seo-section-head"><div><span class="eyebrow">Social sharing</span><h2>Open Graph & social</h2><p>Professional defaults for link previews across social networks and messaging applications.</p></div></div>
|
||||
<label>Default social image URL<input type="url" name="seo_default_og_image" value="{{ old('seo_default_og_image',$settings['seo_default_og_image']) }}" maxlength="2048" placeholder="https://…/og-sonardesk.png"><small>Recommended: 1200 × 630 px.</small></label>
|
||||
<div class="form-grid two"><label>Organization name<input name="seo_organization_name" value="{{ old('seo_organization_name',$settings['seo_organization_name'] ?: 'SonarDesk') }}" maxlength="120"></label><label>Organization logo URL<input type="url" name="seo_organization_logo" value="{{ old('seo_organization_logo',$settings['seo_organization_logo']) }}" maxlength="2048"></label></div>
|
||||
<label>X / Twitter account<input name="seo_twitter_site" value="{{ old('seo_twitter_site',$settings['seo_twitter_site']) }}" maxlength="40" placeholder="@SonarDesk"></label>
|
||||
</section>
|
||||
|
||||
<section class="panel seo-section">
|
||||
<div class="seo-section-head"><div><span class="eyebrow">Google Search Console</span><h2>Domain verification</h2><p>Use a Domain property in Google Search Console. Google will provide a TXT value that you add at your DNS provider.</p></div><span class="seo-badge success">DNS TXT</span></div>
|
||||
<div class="seo-dns-callout"><strong>Recommended verification method</strong><span>Add Google’s <code>google-site-verification=…</code> TXT record to the root domain. DNS verification covers all protocols and subdomains.</span></div>
|
||||
<label>Optional Google verification token<input name="seo_google_site_verification" value="{{ old('seo_google_site_verification',$settings['seo_google_site_verification']) }}" maxlength="255" placeholder="Only needed if you also choose HTML-tag verification"><small>DNS TXT verification does not require this field. It is available as a secondary verification method.</small></label>
|
||||
</section>
|
||||
|
||||
<section class="panel seo-section">
|
||||
<div class="seo-section-head"><div><span class="eyebrow">Crawler control</span><h2>Indexing</h2><p>Production crawler controls. Non-production environments remain blocked automatically.</p></div></div>
|
||||
<label class="consent-check"><input type="checkbox" name="seo_robots_enabled" value="1" @checked(old('seo_robots_enabled',$robotsEnabled))><span><strong>Allow production search-engine indexing</strong><small>When disabled, robots.txt blocks crawling and pages emit noindex.</small></span></label>
|
||||
</section>
|
||||
<div class="seo-savebar"><div><strong>Search configuration</strong><span>Changes apply to public metadata immediately.</span></div><button type="submit" class="btn btn-primary">Save SEO settings</button></div>
|
||||
</form>
|
||||
@endsection
|
||||
@@ -1,4 +1,14 @@
|
||||
@extends('layouts.app', ['title' => $page->meta_title ?: $page->title.' — SonarDesk', 'description' => $page->meta_description ?: $page->excerpt])
|
||||
@extends('layouts.app', [
|
||||
'title' => $page->meta_title ?: $page->title.' — '.\App\Models\SiteSetting::valueFor('seo_site_name', 'SonarDesk'),
|
||||
'description' => $page->meta_description ?: $page->excerpt,
|
||||
'keywords' => $page->meta_keywords,
|
||||
'canonical' => $page->canonical_url ?: null,
|
||||
'ogTitle' => $page->og_title ?: null,
|
||||
'ogDescription' => $page->og_description ?: null,
|
||||
'ogImage' => $page->og_image ?: null,
|
||||
'robotsIndex' => $page->robots_index,
|
||||
'robotsFollow' => $page->robots_follow,
|
||||
])
|
||||
@section('content')
|
||||
<article class="legal-page"><header class="legal-head"><span class="eyebrow">SonarDesk</span><h1>{{ $page->title }}</h1>@if($page->excerpt)<p>{{ $page->excerpt }}</p>@endif</header><div class="cms-body">{!! nl2br(e($page->body)) !!}</div></article>
|
||||
@endsection
|
||||
|
||||
@@ -6,26 +6,45 @@
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
@php
|
||||
$pageTitle = $title ?? 'SonarDesk';
|
||||
$pageDescription = $description ?? 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.';
|
||||
$canonicalUrl = url()->current();
|
||||
$socialImage = url('/icons/og-sonardesk.png');
|
||||
$seoSiteName = \App\Models\SiteSetting::valueFor('seo_site_name', 'SonarDesk');
|
||||
$seoDefaultTitle = \App\Models\SiteSetting::valueFor('seo_default_title', 'SonarDesk — Native SonarQube Monitoring for macOS');
|
||||
$seoDefaultDescription = \App\Models\SiteSetting::valueFor('seo_default_description', 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.');
|
||||
$pageTitle = $title ?? $seoDefaultTitle;
|
||||
$pageDescription = $description ?? $seoDefaultDescription;
|
||||
$pageKeywords = $keywords ?? \App\Models\SiteSetting::valueFor('seo_default_keywords');
|
||||
$canonicalBase = rtrim(\App\Models\SiteSetting::valueFor('seo_canonical_base_url', config('app.url')), '/');
|
||||
$canonicalUrl = $canonical ?? ($canonicalBase . request()->getPathInfo());
|
||||
$socialImage = $ogImage ?? \App\Models\SiteSetting::valueFor('seo_default_og_image', url('/icons/og-sonardesk.png'));
|
||||
$ogTitle = $ogTitle ?? $pageTitle;
|
||||
$ogDescription = $ogDescription ?? $pageDescription;
|
||||
$robotsIndex = $robotsIndex ?? true;
|
||||
$robotsFollow = $robotsFollow ?? true;
|
||||
$productionIndexing = app()->environment('production') && \App\Models\SiteSetting::booleanFor('seo_robots_enabled', true);
|
||||
$robots = $productionIndexing ? (($robotsIndex ? 'index' : 'noindex').','.($robotsFollow ? 'follow' : 'nofollow').',max-image-preview:large,max-snippet:-1,max-video-preview:-1') : 'noindex,nofollow';
|
||||
$twitterSite = \App\Models\SiteSetting::valueFor('seo_twitter_site');
|
||||
$googleVerification = \App\Models\SiteSetting::valueFor('seo_google_site_verification');
|
||||
$organizationName = \App\Models\SiteSetting::valueFor('seo_organization_name', $seoSiteName);
|
||||
$organizationLogo = \App\Models\SiteSetting::valueFor('seo_organization_logo', url('/icons/sonardesk-icon-512.png'));
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org', '@type' => 'SoftwareApplication', 'name' => $seoSiteName,
|
||||
'applicationCategory' => 'DeveloperApplication', 'operatingSystem' => 'macOS', 'url' => route('home'),
|
||||
'description' => $seoDefaultDescription, 'publisher' => ['@type' => 'Organization', 'name' => $organizationName, 'logo' => ['@type' => 'ImageObject', 'url' => $organizationLogo]],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<title>{{ $pageTitle }}</title>
|
||||
<meta name="description" content="{{ $pageDescription }}">
|
||||
@if($pageKeywords)<meta name="keywords" content="{{ $pageKeywords }}">@endif
|
||||
<meta name="robots" content="{{ $robots }}">
|
||||
<meta name="googlebot" content="{{ $robots }}">
|
||||
@if($googleVerification)<meta name="google-site-verification" content="{{ $googleVerification }}">@endif
|
||||
<meta name="theme-color" content="#0b1220">
|
||||
<meta name="application-name" content="SonarDesk">
|
||||
<meta name="apple-mobile-web-app-title" content="SonarDesk">
|
||||
<meta name="application-name" content="{{ $seoSiteName }}">
|
||||
<meta name="apple-mobile-web-app-title" content="{{ $seoSiteName }}">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="msapplication-TileColor" content="#2563eb">
|
||||
<meta name="msapplication-config" content="{{ asset('browserconfig.xml') }}">
|
||||
@if(app()->environment('production'))
|
||||
<meta name="robots" content="index,follow,max-image-preview:large">
|
||||
@else
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
@endif
|
||||
|
||||
<link rel="canonical" href="{{ $canonicalUrl }}">
|
||||
<link rel="icon" href="{{ asset('favicon.ico') }}" sizes="any">
|
||||
@@ -36,17 +55,18 @@
|
||||
<link rel="manifest" href="{{ asset('site.webmanifest') }}">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="SonarDesk">
|
||||
<meta property="og:title" content="{{ $pageTitle }}">
|
||||
<meta property="og:description" content="{{ $pageDescription }}">
|
||||
<meta property="og:site_name" content="{{ $seoSiteName }}">
|
||||
<meta property="og:title" content="{{ $ogTitle }}">
|
||||
<meta property="og:description" content="{{ $ogDescription }}">
|
||||
<meta property="og:url" content="{{ $canonicalUrl }}">
|
||||
<meta property="og:image" content="{{ $socialImage }}">
|
||||
<meta property="og:image:width" content="512">
|
||||
<meta property="og:image:height" content="512">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{ $pageTitle }}">
|
||||
<meta name="twitter:description" content="{{ $pageDescription }}">
|
||||
<meta property="og:image:alt" content="{{ $ogTitle }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
@if($twitterSite)<meta name="twitter:site" content="{{ str_starts_with($twitterSite, '@') ? $twitterSite : '@'.$twitterSite }}">@endif
|
||||
<meta name="twitter:title" content="{{ $ogTitle }}">
|
||||
<meta name="twitter:description" content="{{ $ogDescription }}">
|
||||
<meta name="twitter:image" content="{{ $socialImage }}">
|
||||
<script type="application/ld+json">{!! json_encode($schema, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE) !!}</script>
|
||||
|
||||
<style>
|
||||
:root{
|
||||
@@ -176,6 +196,8 @@
|
||||
@media(max-width:900px){.download-hero .download-shell{grid-template-columns:1fr}.product-visual{display:none}.download-copy h1{font-size:52px}.confidence-grid{grid-template-columns:repeat(2,1fr);gap:20px}.confidence-grid div{border:0;padding:0}.download-content,.release-editor{grid-template-columns:1fr}.sticky-card{position:static}.download-support .download-shell,.admin-release-head{align-items:flex-start;flex-direction:column}}@media(max-width:600px){.download-hero{padding:64px 0}.download-copy h1{font-size:42px}.download-actions,.release-heading{flex-direction:column}.download-primary,.download-secondary{width:100%}.confidence-grid{grid-template-columns:1fr}.release-history-list article{align-items:flex-start;gap:12px;flex-direction:column}.download-content{padding-top:40px;padding-bottom:40px}.release-notes-panel,.requirements-panel{padding:22px}.form-grid{grid-template-columns:1fr}.download-support .download-shell{align-items:stretch}}
|
||||
.portal-download-banner{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 20px;margin-bottom:22px;background:linear-gradient(135deg,#0f172a,#172554);color:#fff;border-radius:14px;box-shadow:var(--shadow-md)}.portal-download-banner strong,.portal-download-banner small{display:block}.portal-download-banner strong{font-size:18px;margin:3px 0}.portal-download-banner small{color:#cbd5e1}.portal-download-banner .eyebrow{color:#93c5fd}@media(max-width:650px){.portal-download-banner{align-items:stretch;flex-direction:column}}
|
||||
.home-download-showcase{display:grid;grid-template-columns:1.25fr .75fr;gap:36px;align-items:center;margin:0 0 64px;padding:38px;background:linear-gradient(135deg,#f8fafc,#eff6ff);border:1px solid #dbeafe;border-radius:22px}.home-download-copy h2{font-size:38px;letter-spacing:-.035em;margin:7px 0 10px}.home-download-copy p{color:#667085;font-size:16px;max-width:680px}.home-download-meta{background:#0b1220;color:#fff;border-radius:16px;padding:24px;box-shadow:0 18px 40px rgba(15,23,42,.16)}.home-download-meta>span{display:block;color:#93c5fd;text-transform:uppercase;font-size:9px;font-weight:900;letter-spacing:.12em}.home-download-meta>strong{display:block;font-size:24px;margin:7px 0 3px}.home-download-meta>small{color:#94a3b8}.home-download-meta>div{margin-top:20px;padding-top:16px;border-top:1px solid rgba(255,255,255,.1)}.home-download-meta b,.home-download-meta code{display:block}.home-download-meta b{font-size:9px;color:#64748b;letter-spacing:.1em}.home-download-meta code{font-size:10px;color:#cbd5e1;margin-top:5px}@media(max-width:760px){.home-download-showcase{grid-template-columns:1fr;padding:24px}}
|
||||
|
||||
.seo-admin-form{display:grid;gap:18px}.seo-section{padding:28px}.seo-section h2{margin:4px 0 4px;font-size:22px;letter-spacing:-.02em}.seo-section-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;padding-bottom:20px;margin-bottom:22px;border-bottom:1px solid var(--line)}.seo-section-head p{margin:0;color:var(--muted);max-width:680px}.seo-badge{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:var(--blue-soft);color:var(--blue-dark);font-size:11px;font-weight:850;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap}.seo-badge.success{background:var(--green-soft);color:var(--green)}.seo-dns-callout{display:grid;gap:5px;margin-bottom:22px;padding:16px 18px;border:1px solid #bfdbfe;border-radius:12px;background:#eff6ff;color:#1e3a8a}.seo-dns-callout span{color:#475569}.seo-dns-callout code{font-weight:800}.seo-savebar{position:sticky;bottom:18px;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:15px 18px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.96);backdrop-filter:blur(14px);box-shadow:0 14px 38px rgba(15,23,42,.14)}.seo-savebar strong,.seo-savebar span{display:block}.seo-savebar span{font-size:12px;color:var(--muted);margin-top:2px}.seo-preview{border:1px solid var(--line);border-radius:12px;padding:18px;background:#fff}.seo-preview-label{font-size:10px;font-weight:850;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:10px}.seo-preview-title{color:#1a0dab;font-size:19px;line-height:1.3}.seo-preview-url{color:#188038;font-size:12px;margin:4px 0}.seo-preview-description{color:#4d5156;font-size:13px;line-height:1.45}@media(max-width:700px){.seo-section-head,.seo-savebar{align-items:stretch;flex-direction:column}.seo-savebar .btn{width:100%}}
|
||||
</style>
|
||||
@stack('head')
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{!! '<?xml version="1.0" encoding="UTF-8"?>' !!}
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
@foreach($static as $item)
|
||||
<url>
|
||||
<loc>{{ route($item['route']) }}</loc>
|
||||
<changefreq>{{ $item['frequency'] }}</changefreq>
|
||||
<priority>{{ $item['priority'] }}</priority>
|
||||
</url>
|
||||
@endforeach
|
||||
@foreach($pages as $page)
|
||||
<url>
|
||||
<loc>{{ route('cms.show', $page->slug) }}</loc>
|
||||
<lastmod>{{ $page->updated_at->toAtomString() }}</lastmod>
|
||||
<changefreq>{{ $page->sitemap_change_frequency }}</changefreq>
|
||||
<priority>{{ number_format((float) $page->sitemap_priority, 1, '.', '') }}</priority>
|
||||
</url>
|
||||
@endforeach
|
||||
</urlset>
|
||||
@@ -4,6 +4,8 @@ use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Http\Controllers\Admin\SupportTicketController;
|
||||
use App\Http\Controllers\Admin\ReleaseController as AdminReleaseController;
|
||||
use App\Http\Controllers\Admin\CmsPageController as AdminCmsPageController;
|
||||
use App\Http\Controllers\Admin\SeoSettingsController;
|
||||
use App\Http\Controllers\SeoController;
|
||||
use App\Http\Controllers\CmsPageController;
|
||||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationController;
|
||||
@@ -16,6 +18,9 @@ use App\Http\Controllers\Support\SupportController;
|
||||
use App\Http\Middleware\AdminOnly;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/sitemap.xml', [SeoController::class, 'sitemap'])->name('sitemap');
|
||||
Route::get('/robots.txt', [SeoController::class, 'robots'])->name('robots');
|
||||
|
||||
Route::get('/', fn () => view('portal.welcome', ['latestRelease' => \App\Models\SonarDeskRelease::published()->where('channel', 'stable')->latest('published_at')->first()]))->name('home');
|
||||
Route::get('/buy', CheckoutController::class)->name('buy');
|
||||
Route::view('/features', 'features')->name('features');
|
||||
@@ -59,6 +64,8 @@ Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name
|
||||
Route::get('/licences/{license}', [AdminController::class, 'show'])->name('license');
|
||||
Route::post('/licences/{license}/revoke', [AdminController::class, 'revoke'])->name('revoke');
|
||||
Route::post('/licences/{license}/restore', [AdminController::class, 'restore'])->name('restore');
|
||||
Route::get('/seo', [SeoSettingsController::class, 'edit'])->name('seo.edit');
|
||||
Route::put('/seo', [SeoSettingsController::class, 'update'])->name('seo.update');
|
||||
Route::get('/cms', [AdminCmsPageController::class, 'index'])->name('cms.index');
|
||||
Route::get('/cms/create', [AdminCmsPageController::class, 'create'])->name('cms.create');
|
||||
Route::post('/cms', [AdminCmsPageController::class, 'store'])->name('cms.store');
|
||||
|
||||
Reference in New Issue
Block a user