blog
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Article;
|
||||||
|
|
||||||
|
class BlogController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$articles = Article::published()
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->paginate(12);
|
||||||
|
|
||||||
|
return view('blog.index', compact('articles'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Article $article)
|
||||||
|
{
|
||||||
|
abort_unless($article->is_published, 404);
|
||||||
|
|
||||||
|
return view('blog.show', compact('article'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,11 +66,26 @@ class SitemapController extends Controller
|
|||||||
{
|
{
|
||||||
$xml = $this->cacheService->remember(
|
$xml = $this->cacheService->remember(
|
||||||
'sitemap:jobs',
|
'sitemap:jobs',
|
||||||
1800, // 30 minutes cache (jobs change more frequently)
|
1800,
|
||||||
fn() => $this->sitemapService->generateJobsSitemap()
|
fn() => $this->sitemapService->generateJobsSitemap()
|
||||||
);
|
);
|
||||||
|
|
||||||
return response($xml, 200)
|
return response($xml, 200)
|
||||||
->header('Content-Type', 'application/xml');
|
->header('Content-Type', 'application/xml');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate and serve blog sitemap
|
||||||
|
*/
|
||||||
|
public function blogSitemap(): Response
|
||||||
|
{
|
||||||
|
$xml = $this->cacheService->remember(
|
||||||
|
'sitemap:blog',
|
||||||
|
3600,
|
||||||
|
fn() => $this->sitemapService->generateBlogSitemap()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response($xml, 200)
|
||||||
|
->header('Content-Type', 'application/xml');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Article extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'excerpt',
|
||||||
|
'body',
|
||||||
|
'published_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function (Article $article) {
|
||||||
|
if (empty($article->slug)) {
|
||||||
|
$article->slug = static::generateUniqueSlug($article->title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
static::updating(function (Article $article) {
|
||||||
|
if ($article->isDirty('title') && empty($article->slug)) {
|
||||||
|
$article->slug = static::generateUniqueSlug($article->title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateUniqueSlug(string $title): string
|
||||||
|
{
|
||||||
|
$baseSlug = Str::limit(Str::slug($title), 200, '');
|
||||||
|
$slug = $baseSlug;
|
||||||
|
$counter = 1;
|
||||||
|
|
||||||
|
while (static::where('slug', $slug)->exists()) {
|
||||||
|
$slug = $baseSlug . '-' . $counter;
|
||||||
|
$counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('published_at')->where('published_at', '<=', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsPublishedAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->published_at !== null && $this->published_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'slug';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
use App\Models\Job;
|
use App\Models\Job;
|
||||||
use App\Models\JobCategory;
|
use App\Models\JobCategory;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
@@ -92,6 +93,9 @@ class SitemapService
|
|||||||
// Add jobs sitemap
|
// Add jobs sitemap
|
||||||
$this->addSitemapToIndex($xml, route('sitemap.jobs'), now());
|
$this->addSitemapToIndex($xml, route('sitemap.jobs'), now());
|
||||||
|
|
||||||
|
// Add blog sitemap
|
||||||
|
$this->addSitemapToIndex($xml, route('sitemap.blog'), now());
|
||||||
|
|
||||||
return $xml->asXML();
|
return $xml->asXML();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +109,31 @@ class SitemapService
|
|||||||
$sitemapElement->addChild('lastmod', $lastmod->format('Y-m-d'));
|
$sitemapElement->addChild('lastmod', $lastmod->format('Y-m-d'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate blog sitemap
|
||||||
|
*/
|
||||||
|
public function generateBlogSitemap(): string
|
||||||
|
{
|
||||||
|
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>');
|
||||||
|
|
||||||
|
$this->addUrl($xml, route('blog.index'), now(), 'daily', '0.8');
|
||||||
|
|
||||||
|
Article::published()
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->get(['slug', 'published_at', 'updated_at'])
|
||||||
|
->each(function (Article $article) use ($xml) {
|
||||||
|
$this->addUrl(
|
||||||
|
$xml,
|
||||||
|
route('blog.show', $article),
|
||||||
|
$article->updated_at,
|
||||||
|
'monthly',
|
||||||
|
'0.7'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $xml->asXML();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate main sitemap (static pages and categories)
|
* Generate main sitemap (static pages and categories)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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::create('articles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->text('excerpt')->nullable();
|
||||||
|
$table->longText('body');
|
||||||
|
$table->timestamp('published_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('published_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('articles');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Blog - JobifyGarden')
|
||||||
|
@section('description', 'Career advice, job search tips, and hiring insights from the JobifyGarden team.')
|
||||||
|
@section('canonical', route('blog.index'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div class="mb-10">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900">Blog</h1>
|
||||||
|
<p class="text-gray-600 mt-2">Career advice, job search tips, and hiring insights.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($articles->isEmpty())
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
||||||
|
<p class="text-gray-500 text-lg">No articles published yet. Check back soon.</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
@foreach($articles as $article)
|
||||||
|
<article class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow duration-200 flex flex-col">
|
||||||
|
<div class="p-6 flex flex-col flex-1">
|
||||||
|
<div class="text-xs text-gray-500 mb-3">
|
||||||
|
{{ $article->published_at->format('M j, Y') }}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-3 leading-snug">
|
||||||
|
<a href="{{ route('blog.show', $article) }}" class="hover:text-emerald-600">
|
||||||
|
{{ $article->title }}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
@if($article->excerpt)
|
||||||
|
<p class="text-gray-600 text-sm flex-1 mb-4">{{ $article->excerpt }}</p>
|
||||||
|
@endif
|
||||||
|
<a href="{{ route('blog.show', $article) }}"
|
||||||
|
class="inline-flex items-center text-sm font-medium text-emerald-600 hover:text-emerald-700 mt-auto">
|
||||||
|
Read more
|
||||||
|
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10">
|
||||||
|
{{ $articles->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', $article->title . ' - JobifyGarden Blog')
|
||||||
|
@section('description', $article->excerpt ?? Str::limit(strip_tags($article->body), 155))
|
||||||
|
@section('canonical', route('blog.show', $article))
|
||||||
|
|
||||||
|
@push('structured_data')
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{!! json_encode([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'BlogPosting',
|
||||||
|
'headline' => $article->title,
|
||||||
|
'description' => $article->excerpt ?? Str::limit(strip_tags($article->body), 155),
|
||||||
|
'datePublished' => $article->published_at->toIso8601String(),
|
||||||
|
'dateModified' => $article->updated_at->toIso8601String(),
|
||||||
|
'url' => route('blog.show', $article),
|
||||||
|
'publisher' => ['@type' => 'Organization', 'name' => 'JobifyGarden'],
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<!-- Back -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<a href="{{ route('blog.index') }}" class="inline-flex items-center text-emerald-600 hover:text-emerald-700 text-sm">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to Blog
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="bg-white rounded-lg shadow-sm border border-gray-200 p-8 sm:p-12">
|
||||||
|
<header class="mb-8 pb-8 border-b border-gray-200">
|
||||||
|
<div class="text-sm text-gray-500 mb-3">{{ $article->published_at->format('F j, Y') }}</div>
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-bold text-gray-900 leading-tight">{{ $article->title }}</h1>
|
||||||
|
@if($article->excerpt)
|
||||||
|
<p class="mt-4 text-xl text-gray-600 leading-relaxed">{{ $article->excerpt }}</p>
|
||||||
|
@endif
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="prose prose-emerald max-w-none text-gray-700 leading-relaxed">
|
||||||
|
{!! nl2br(e($article->body)) !!}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -80,6 +80,10 @@
|
|||||||
class="inline-flex items-center px-1 pt-1 text-sm font-medium {{ request()->routeIs('jobs.remote') ? 'border-b-2 border-emerald-500 text-gray-900' : 'text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
class="inline-flex items-center px-1 pt-1 text-sm font-medium {{ request()->routeIs('jobs.remote') ? 'border-b-2 border-emerald-500 text-gray-900' : 'text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||||
Remote Jobs
|
Remote Jobs
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('blog.index') }}"
|
||||||
|
class="inline-flex items-center px-1 pt-1 text-sm font-medium {{ request()->routeIs('blog.*') ? 'border-b-2 border-emerald-500 text-gray-900' : 'text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||||
|
Blog
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,6 +143,10 @@
|
|||||||
class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('jobs.remote') ? 'border-emerald-500 text-emerald-700 bg-emerald-50' : 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700' }}">
|
class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('jobs.remote') ? 'border-emerald-500 text-emerald-700 bg-emerald-50' : 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700' }}">
|
||||||
Remote Jobs
|
Remote Jobs
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('blog.index') }}"
|
||||||
|
class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('blog.*') ? 'border-emerald-500 text-emerald-700 bg-emerald-50' : 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700' }}">
|
||||||
|
Blog
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-2 pb-3 border-t border-gray-200 space-y-1">
|
<div class="pt-2 pb-3 border-t border-gray-200 space-y-1">
|
||||||
@auth
|
@auth
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Http\Controllers\Web\BlogController;
|
||||||
use App\Http\Controllers\Web\BookmarkController;
|
use App\Http\Controllers\Web\BookmarkController;
|
||||||
use App\Http\Controllers\Web\HomeController;
|
use App\Http\Controllers\Web\HomeController;
|
||||||
use App\Http\Controllers\Web\JobController;
|
use App\Http\Controllers\Web\JobController;
|
||||||
@@ -26,11 +27,18 @@ Route::prefix('jobs')->group(function () {
|
|||||||
Route::get('/{job:slug}', [JobController::class, 'show'])->name('jobs.show');
|
Route::get('/{job:slug}', [JobController::class, 'show'])->name('jobs.show');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Blog routes
|
||||||
|
Route::prefix('blog')->name('blog.')->group(function () {
|
||||||
|
Route::get('/', [BlogController::class, 'index'])->name('index');
|
||||||
|
Route::get('/{article:slug}', [BlogController::class, 'show'])->name('show');
|
||||||
|
});
|
||||||
|
|
||||||
// Sitemap routes
|
// Sitemap routes
|
||||||
Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap');
|
Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap');
|
||||||
Route::get('/sitemap-index.xml', [SitemapController::class, 'sitemapIndex'])->name('sitemap.index');
|
Route::get('/sitemap-index.xml', [SitemapController::class, 'sitemapIndex'])->name('sitemap.index');
|
||||||
Route::get('/sitemap-main.xml', [SitemapController::class, 'mainSitemap'])->name('sitemap.main');
|
Route::get('/sitemap-main.xml', [SitemapController::class, 'mainSitemap'])->name('sitemap.main');
|
||||||
Route::get('/sitemap-jobs.xml', [SitemapController::class, 'jobsSitemap'])->name('sitemap.jobs');
|
Route::get('/sitemap-jobs.xml', [SitemapController::class, 'jobsSitemap'])->name('sitemap.jobs');
|
||||||
|
Route::get('/sitemap-blog.xml', [SitemapController::class, 'blogSitemap'])->name('sitemap.blog');
|
||||||
|
|
||||||
// Authenticated user routes
|
// Authenticated user routes
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user