M2: 主题系统(sablog经典+modern简约)+ 前台全部页面 + 老 URL 301 兼容 + markdown 双份导入

This commit is contained in:
ak
2026-08-11 17:58:29 +08:00
parent f16bb75538
commit 6214976a88
66 changed files with 2763 additions and 10 deletions
@@ -0,0 +1,34 @@
<?php
namespace App\Blog\Controllers;
use App\Models\Post;
use Carbon\Carbon;
class ArchiveController extends Controller
{
public function index()
{
$posts = Post::published()->orderByDesc('published_at')->get(['id', 'title', 'slug', 'published_at']);
$archives = $posts->groupBy(fn (Post $post) => $post->published_at->format('Y'))
->map(fn ($yearPosts) => $yearPosts->groupBy(fn (Post $post) => $post->published_at->format('m')));
return theme_view('archives', compact('archives'));
}
public function month(string $year, string $month)
{
$start = Carbon::create((int) $year, (int) $month, 1)->startOfMonth();
$end = (clone $start)->endOfMonth();
$posts = Post::query()
->whereBetween('published_at', [$start, $end])
->published()
->orderByDesc('published_at')
->paginate((int) blog_setting('per_page', config('blog.per_page')));
return theme_view('list', compact('posts'))
->with('archiveTitle', "{$year}{$month}");
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Blog\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
public function showLogin()
{
if (auth()->check()) {
return redirect()->route('home');
}
return theme_view('login');
}
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'string'],
'password' => ['required', 'string'],
]);
$field = filter_var($credentials['email'], FILTER_VALIDATE_EMAIL) ? 'email' : 'name';
$credentials[$field] = $credentials['email'];
unset($credentials['email']);
$user = User::query()->where($field, $credentials[$field])->first();
if ($user && $user->verifyPassword($request->password)) {
Auth::login($user, $request->boolean('remember'));
$user->forceFill([
'logincount' => $user->logincount + 1,
'loginip' => $request->ip(),
'logintime' => now(),
])->save();
return redirect()->intended(route('home'));
}
return back()->withErrors(['email' => '账号或密码错误'])->withInput();
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('home');
}
public function showRegister()
{
if (auth()->check()) {
return redirect()->route('home');
}
return theme_view('register');
}
public function register(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:50', 'unique:users,name'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'url' => ['nullable', 'url', 'max:255'],
]);
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'url' => $data['url'] ?? null,
'regip' => $request->ip(),
]);
$user->assignRole('member');
Auth::login($user);
return redirect()->route('profile');
}
public function profile()
{
return theme_view('profile');
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Blog\Controllers;
use App\Models\Category;
use App\Models\Post;
class CategoryController extends Controller
{
public function show(string $slug)
{
$category = Category::query()->where('slug', $slug)->orWhere('id', (int) $slug)->firstOrFail();
$posts = $category->posts()
->published()
->latest('published_at')
->paginate((int) blog_setting('per_page', config('blog.per_page')));
return theme_view('list', compact('category', 'posts'));
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Blog\Controllers;
use App\Models\Comment;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class CommentController extends Controller
{
public function index()
{
$comments = Comment::query()
->where('status', 'published')
->latest('created_at')
->with('post:id,title,slug')
->paginate(30);
return theme_view('comments', compact('comments'));
}
public function store(Request $request, Post $post)
{
abort_unless($post->status === 'published', 404);
if ($post->close_comment) {
return back()->with('error', '该文章已关闭评论');
}
$data = $request->validate([
'author_name' => ['required', 'string', 'max:50'],
'author_email' => ['nullable', 'email', 'max:255'],
'author_url' => ['nullable', 'url', 'max:255'],
'content' => ['required', 'string', 'min:'.(int) blog_setting('comment_min_len', 2), 'max:'.(int) blog_setting('comment_max_len', 6000)],
]);
// 蜜罐:隐藏字段被填写则直接丢弃(现代垃圾评论防护)
if ($request->filled('website')) {
return back()->with('error', '提交失败,请重试');
}
$minInterval = (int) blog_setting('comment_post_space', 20);
if ($minInterval > 0) {
$last = Comment::query()->where('ip', $request->ip())->latest('created_at')->first();
if ($last && $last->created_at->diffInSeconds(now()) < $minInterval) {
return back()->with('error', '评论太频繁,请稍后再试')->withInput();
}
}
$status = (int) blog_setting('comment_audit', 0) === 1 ? 'pending' : 'published';
$comment = DB::transaction(function () use ($post, $data, $request, $status) {
$comment = $post->comments()->create([
'user_id' => auth()->id(),
'author_name' => $data['author_name'],
'author_email' => $data['author_email'] ?? null,
'author_url' => $data['author_url'] ?? null,
'content' => $data['content'],
'ip' => $request->ip(),
'status' => $status,
]);
if ($status === 'published') {
$post->increment('comment_count');
}
return $comment;
});
if ($status === 'pending') {
return back()->with('success', '评论已提交,等待审核通过后显示')->withFragment('comments');
}
return back()->with('success', '评论已发布')->withFragment('comments');
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
namespace App\Blog\Controllers;
class Controller
{
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Blog\Controllers;
use App\Models\Post;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class FeedController extends Controller
{
public function rss()
{
$posts = Post::query()
->published()
->latest('published_at')
->limit((int) blog_setting('rss_num', config('blog.rss_num')))
->with('category', 'author', 'tags')
->get();
$content = view('feed.rss', [
'posts' => $posts,
'siteName' => blog_setting('site_name', config('blog.name')),
'siteDescription' => blog_setting('site_description', config('blog.description')),
'xmlDeclaration' => '<?xml version="1.0" encoding="UTF-8"?>',
])->render();
return response($content)->header('Content-Type', 'application/rss+xml; charset=utf-8');
}
public function sitemap()
{
$posts = Post::query()
->published()
->latest('published_at')
->get(['id', 'slug', 'updated_at']);
$content = view('feed.sitemap', [
'posts' => $posts,
'xmlDeclaration' => '<?xml version="1.0" encoding="UTF-8"?>',
])->render();
return response($content)->header('Content-Type', 'application/xml; charset=utf-8');
}
public function robots(): Response
{
$disallow = array_merge(config('robots.disallow'), (array) blog_setting('seo_robots_disallow', []));
$sitemap = config('robots.sitemap');
$lines = ['User-agent: *'];
foreach ($disallow as $path) {
$lines[] = "Disallow: {$path}";
}
$lines[] = "Sitemap: ".url($sitemap);
return response(implode("\n", $lines)."\n")->header('Content-Type', 'text/plain; charset=utf-8');
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Blog\Controllers;
use App\Models\Post;
class HomeController extends Controller
{
public function index()
{
$posts = Post::query()
->published()
->orderByDesc('is_sticky')
->latest('published_at')
->paginate((int) blog_setting('per_page', config('blog.per_page')));
return theme_view('index', compact('posts'));
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
namespace App\Blog\Controllers;
use App\Models\Category;
use App\Models\Comment;
use App\Models\Post;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\Tags\Tag;
/**
* sablog URL 兼容:全部 301 到新规范 URL,保证 SEO 不丢。
*/
class LegacyController extends Controller
{
/**
* 兼容首页查询串式:/?action=xxx
*/
public function resolveAction(Request $request)
{
$action = $request->query('action');
return match ($action) {
'show' => $this->post($request->integer('id')),
'category' => $this->category($request->integer('cid')),
'index' => $request->filled('setdate') ? $this->month($request->query('setdate')) : redirect()->route('home'),
'tags', 'tag', 'tagslist' => $this->tagList($request),
'search' => redirect()->route('search', ['q' => $request->query('keyword', $request->query('q', ''))]),
'links' => redirect()->route('links'),
'archives' => redirect()->route('archives'),
'comments' => redirect()->route('comments'),
'login' => redirect()->route('login'),
'reg' => redirect()->route('register'),
'finduser' => $this->findUser($request->integer('uid')),
default => abort(404),
};
}
/**
* /show-{id}-{page}.html
*/
public function showRewritten(string $id)
{
return $this->post((int) $id);
}
/**
* /category-{cid}-{page}.html
*/
public function categoryRewritten(string $cid)
{
return $this->category((int) $cid);
}
/**
* /archives-{date}-{page}.html
*/
public function archivesRewritten(string $date)
{
return $this->month($date);
}
/**
* /tag/{name}
*/
public function tagByName(string $name)
{
$tag = Tag::findFromString($name, 'post');
return $tag
? redirect()->route('tags.show', $tag->slug ?: $tag->name, 301)
: abort(404);
}
/**
* attachment.php?id=N -> 媒体地址
*/
public function attachment(Request $request)
{
$id = $request->integer('id');
$media = Media::query()
->where('collection_name', 'attachments')
->where('custom_properties->legacy_attachmentid', $id)
->first();
if (! $media) {
abort(404, '附件不存在');
}
if ($media->getCustomProperty('pending_sync')) {
abort(404, '附件尚未同步到存储');
}
return redirect()->away($media->getUrl(), 301);
}
/**
* post.php 表单兼容:登录 / 注册 / 登出 / 评论 / 搜索
*/
public function postAction(Request $request)
{
return match ($request->input('action')) {
'dologin' => app(AuthController::class)->login($request),
'logout', 'clearcookies' => app(AuthController::class)->logout($request),
'register' => app(AuthController::class)->register($request),
'addcomment' => $this->addLegacyComment($request),
'search' => redirect()->route('search', ['q' => $request->input('keyword', '')]),
default => abort(404),
};
}
private function addLegacyComment(Request $request)
{
$post = Post::find((int) $request->input('articleid', $request->input('id')));
if (! $post || $post->status !== 'published') {
return redirect()->route('home');
}
$data = $request->validate([
'author' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'content' => ['required', 'string'],
]);
$status = (int) Setting::get('comment_audit', 0) === 1 ? 'pending' : 'published';
$comment = $post->comments()->create([
'user_id' => auth()->id(),
'author_name' => $data['author'] ?? (auth()->user()->name ?? '匿名'),
'author_email' => $data['email'] ?? null,
'author_url' => $data['url'] ?? null,
'content' => $data['content'],
'ip' => $request->ip(),
'status' => $status,
]);
if ($status === 'published') {
$post->increment('comment_count');
}
return redirect()->route('posts.show', $post->slug ?: $post->id)
->with($status === 'published' ? 'success' : 'error', $status === 'published' ? '评论已发布' : '评论已提交,等待审核')
->withFragment('comments');
}
private function post(int $id)
{
$post = Post::find($id);
return $post
? redirect()->route('posts.show', $post->slug ?: $post->id, 301)
: abort(404);
}
private function category(int $cid)
{
$category = Category::find($cid);
return $category
? redirect()->route('category.show', $category->slug ?: $category->id, 301)
: abort(404);
}
private function month(string $date)
{
if (! preg_match('/^(\d{4})(\d{2})?/', $date, $m)) {
return redirect()->route('archives');
}
$year = $m[1];
$month = $m[2] ?? null;
return $month
? redirect()->route('archives.month', [$year, $month], 301)
: redirect()->route('archives.month', [$year, '01'], 301);
}
private function tagList(Request $request)
{
if ($request->filled('id')) {
// 老 sablog tagid 与新媒体 id 可能不一致,通过导入时记录的映射解析
$legacyMap = json_decode((string) Setting::get('legacy_tags', '[]'), true) ?: [];
$tagId = $legacyMap[(int) $request->query('id')] ?? null;
$tag = $tagId ? Tag::find((int) $tagId) : Tag::find((int) $request->query('id'));
return $tag
? redirect()->route('tags.show', $tag->slug ?: $tag->name, 301)
: abort(404);
}
return redirect()->route('tags', [], 301);
}
private function findUser(int $uid)
{
if (auth()->check() && auth()->id() === $uid) {
return redirect()->route('profile');
}
$user = User::find($uid);
return $user
? redirect()->route('profile', [], 301)
: abort(404);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Blog\Controllers;
use App\Models\Link;
use App\Models\Post;
class LinkController extends Controller
{
public function index()
{
$links = Link::query()->where('visible', true)->orderBy('display_order')->orderBy('name')->get();
return theme_view('links', compact('links'));
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Blog\Controllers;
use App\Blog\Services\PostContentRenderer;
use App\Models\Post;
class PostController extends Controller
{
public function __construct(private PostContentRenderer $renderer)
{
}
public function show(string $slug)
{
$post = Post::query()
->where('slug', $slug)
->orWhere('id', (int) $slug)
->first();
abort_unless($post && $post->status === 'published', 404);
$this->ensureReadable($post);
$post->increment('views');
$comments = $post->comments()
->where('status', 'published')
->latest('created_at')
->paginate(20);
$contentHtml = $this->renderer->render($post);
return theme_view('show', compact('post', 'comments', 'contentHtml'));
}
private function ensureReadable(Post $post): void
{
if (! $post->read_password) {
return;
}
$granted = session()->get("post_read_password.{$post->id}") === $post->read_password;
$isEditor = auth()->check() && (auth()->user()->isAdmin() || auth()->user()->hasRole('editor'));
if (! $granted && ! $isEditor) {
abort(403, '该文章需要访问密码');
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Blog\Controllers;
use App\Models\Post;
class SearchController extends Controller
{
public function index()
{
$keyword = trim(request('q', request('keyword', '')));
$posts = Post::query()
->published()
->search($keyword)
->orderByDesc('published_at')
->paginate((int) blog_setting('per_page', config('blog.per_page')));
return theme_view('search', compact('posts', 'keyword'));
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Blog\Controllers;
use App\Models\Post;
use Spatie\Tags\Tag;
class TagController extends Controller
{
public function index()
{
$counts = \Illuminate\Support\Facades\DB::table('taggables')
->selectRaw('tag_id, COUNT(*) as total')
->where('taggable_type', Post::class)
->groupBy('tag_id')
->pluck('total', 'tag_id');
$tags = Tag::query()
->where('type', 'post')
->get()
->map(fn (Tag $tag) => $tag->setAttribute('posts_count', (int) ($counts[$tag->id] ?? 0)))
->sortByDesc('posts_count')
->values();
return theme_view('tags', compact('tags'));
}
public function show(string $slug)
{
$tag = Tag::findFromString($slug, 'post');
abort_unless($tag, 404);
$posts = Post::query()
->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
->published()
->latest('published_at')
->paginate((int) blog_setting('per_page', config('blog.per_page')));
return theme_view('tag', compact('tag', 'posts'));
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Blog\Controllers;
use App\Blog\Support\ThemeManager;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
class ThemeAssetController extends Controller
{
public function show(ThemeManager $themes, string $theme, string $path)
{
if (! $themes->exists($theme)) {
abort(404);
}
$fullPath = $themes->path($theme).'/assets/'.$path;
if (! File::isFile($fullPath)) {
abort(404);
}
return response(File::get($fullPath), 200, [
'Content-Type' => $this->mimeType($fullPath),
'Cache-Control' => 'public, max-age=86400',
]);
}
private function mimeType(string $path): string
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return match ($ext) {
'css' => 'text/css',
'js' => 'application/javascript',
'png' => 'image/png',
'jpg', 'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ico' => 'image/x-icon',
default => 'application/octet-stream',
};
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Blog\Providers;
use App\Blog\Support\ThemeManager;
use App\Blog\View\Composers\SidebarComposer;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ThemeManager::class);
}
public function boot(): void
{
// 激活主题的 views 目录前置到全局视图路径,实现主题覆盖 + 默认兜底
$this->prependActiveThemeViews();
// 侧边栏数据共享:所有前台视图自动获得 $categories/$recentPosts/$hotTags/$links 等
View::composer(
['index', 'show', 'list', 'archives', 'tags', 'tag', 'search', 'links', 'comments', 'login', 'register', 'profile'],
SidebarComposer::class
);
}
private function prependActiveThemeViews(): void
{
$manager = app(ThemeManager::class);
$views = $manager->path($manager->active()).'/views';
if (! is_dir($views)) {
return;
}
$paths = config('view.paths');
array_unshift($paths, $views);
config(['view.paths' => $paths]);
app('view')->getFinder()->setPaths($paths);
}
}
+1
View File
@@ -26,6 +26,7 @@ class AttachmentImporter
$properties = [
'downloads' => (int) ($row['downloads'] ?? 0),
'isimage' => (bool) ($row['isimage'] ?? false),
'legacy_attachmentid' => (int) ($row['attachmentid'] ?? 0),
'legacy_filepath' => $row['filepath'] ?? null,
'legacy_thumb' => $row['thumb_filepath'] ?? null,
'legacy_articleid' => (int) ($row['articleid'] ?? 0),
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Blog\Services;
use App\Models\Post;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\MarkdownConverter;
class PostContentRenderer
{
private MarkdownConverter $converter;
public function __construct()
{
$environment = new Environment([
'html_input' => 'allow',
'allow_unsafe_links' => true,
'max_nesting_level' => 100,
]);
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new GithubFlavoredMarkdownExtension);
$this->converter = new MarkdownConverter($environment);
}
/**
* 将文章内容按格式渲染为 HTML。
* markdown -> HTMLhtml 原样返回。两种格式都会解析附件令牌。
*/
public function render(Post $post): string
{
$content = $post->content_format === 'markdown'
? $this->toHtml($post->content)
: $post->content;
return $this->renderShortcodes($content, $post);
}
public function toHtml(string $markdown): string
{
return $this->converter->convert($markdown)->getContent();
}
/**
* 解析附件引用。
*
* 兼容两种写法:
* - sablog HTML 内容: [attach=xx] / [img=xx]
* - markdown 转换导入: {{attach:xx}} / {{img:xx}}[] markdown 保留字符,转换时改为令牌)
*
* xx sablog attachmentid;找不到时回退到按出现顺序的第 N 个附件。
*/
public function renderShortcodes(string $html, Post $post): string
{
$pattern = '/\[(attach|img)=(\d+)\]|\{\{(attach|img):(\d+)\}\}/';
if (! preg_match_all($pattern, $html, $matches, PREG_SET_ORDER)) {
return $html;
}
$mediaItems = $post->getMedia('attachments');
$byLegacyId = $mediaItems->keyBy(fn ($m) => (int) $m->getCustomProperty('legacy_attachmentid'));
$ordered = $mediaItems->values();
foreach ($matches as $match) {
$full = $match[0];
$type = $match[1] !== '' ? $match[1] : $match[3];
$id = (int) ($match[2] !== '' ? $match[2] : $match[4]);
$media = $byLegacyId->get($id);
if (! $media) {
// 兼容按索引引用的旧写法:第 $id 个附件(从 1 开始)
$media = $ordered->get($id - 1);
}
if (! $media) {
continue;
}
$replacement = $type === 'img'
? sprintf('<img src="%s" alt="%s" loading="lazy" />', $media->getUrl(), e($media->name))
: sprintf('<a href="%s">%s</a>', $media->getUrl(), e($media->file_name));
$html = str_replace($full, $replacement, $html);
}
return $html;
}
/**
* 把老 sablog HTML 里的 [attach=xx]/[img=xx] 换成 markdown 安全令牌,
* HTML -> markdown 转换前调用。
*/
public function toMarkdownSafeTokens(string $html): string
{
return preg_replace('/\[(attach|img)=(\d+)\]/', '{{$1:$2}}', $html);
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Blog\Support;
use App\Models\Setting;
use Illuminate\Support\Facades\File;
class ThemeManager
{
public function path(?string $theme = null): string
{
return config('themes.path').($theme ? '/'.$theme : '');
}
public function active(): string
{
$active = Setting::get('active_theme', config('themes.default'));
return $this->exists($active) ? $active : config('themes.default');
}
public function exists(string $theme): bool
{
return is_dir($this->path($theme)) && File::exists($this->path($theme).'/theme.json');
}
public function manifest(string $theme): array
{
$file = $this->path($theme).'/theme.json';
if (! File::exists($file)) {
return [];
}
return json_decode(File::get($file), true) ?? [];
}
/**
* 扫描主题目录,返回所有主题 manifest。
*/
public function all(): array
{
$themes = [];
foreach (File::directories(config('themes.path')) as $dir) {
$name = basename($dir);
if (! File::exists($dir.'/theme.json')) {
continue;
}
$manifest = $this->manifest($name);
$themes[$name] = [
'name' => $name,
'title' => $manifest['title'] ?? $name,
'version' => $manifest['version'] ?? '0.0.0',
'description' => $manifest['description'] ?? '',
'author' => $manifest['author'] ?? '',
'screenshot' => $manifest['screenshot'] ?? null,
'active' => $name === $this->active(),
];
}
return $themes;
}
public function activate(string $theme): void
{
if (! $this->exists($theme)) {
throw new \InvalidArgumentException("主题不存在: {$theme}");
}
Setting::set('active_theme', $theme);
}
/**
* 解析主题下的一个视图名。
* 优先使用激活主题的视图,缺失时回退到内置兜底视图(blog-default)。
*/
public function view(string $view): string
{
$active = $this->active();
if (view()->exists("theme.{$active}.{$view}")) {
return "theme.{$active}.{$view}";
}
return "blog-default::{$view}";
}
public function assetUrl(string $path, ?string $theme = null): string
{
$theme = $theme ?? $this->active();
return url('/themes/'.$theme.'/assets/'.ltrim($path, '/'));
}
/**
* 主题变量(原 sablog stylevars 的现代化替代:主题级自定义配置)。
*/
public function variables(?string $theme = null): array
{
$theme = $theme ?? $this->active();
$vars = Setting::get('theme_vars_'.$theme, []);
return is_array($vars) ? $vars : (json_decode((string) $vars, true) ?: []);
}
public function variable(string $key, mixed $default = null, ?string $theme = null): mixed
{
return $this->variables($theme)[$key] ?? $default;
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Blog\View\Composers;
use App\Models\Category;
use App\Models\Link;
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
use Spatie\Tags\Tag;
class SidebarComposer
{
public function compose(View $view): void
{
$view->with('categories', Cache::remember('blog.sidebar.categories', now()->addHour(), function () {
return Category::query()->orderBy('display_order')->orderBy('name')->get();
}));
$view->with('recentPosts', Cache::remember('blog.sidebar.recent_posts', now()->addHour(), function () {
return Post::published()->latest('published_at')->limit(10)->get(['id', 'title', 'slug']);
}));
$view->with('recentComments', Cache::remember('blog.sidebar.recent_comments', now()->addHour(), function () {
return \App\Models\Comment::query()
->where('status', 'published')
->latest('created_at')
->limit(10)
->with('post:id,title,slug')
->get();
}));
$view->with('hotTags', Cache::remember('blog.sidebar.hot_tags', now()->addHour(), function () {
$counts = \Illuminate\Support\Facades\DB::table('taggables')
->selectRaw('tag_id, COUNT(*) as total')
->where('taggable_type', Post::class)
->groupBy('tag_id')
->pluck('total', 'tag_id');
return Tag::query()
->where('type', 'post')
->get()
->map(fn (Tag $tag) => $tag->setAttribute('posts_count', (int) ($counts[$tag->id] ?? 0)))
->sortByDesc('posts_count')
->take(20)
->values();
}));
$view->with('links', Cache::remember('blog.sidebar.links', now()->addHour(), function () {
return Link::query()->where('visible', true)->orderBy('display_order')->orderBy('name')->get();
}));
$view->with('blogStats', Cache::remember('blog.sidebar.stats', now()->addHour(), function () {
return [
'posts' => Post::published()->count(),
'comments' => \App\Models\Comment::query()->where('status', 'published')->count(),
'categories' => Category::query()->count(),
'tags' => Tag::query()->count(),
];
}));
$view->with('siteName', blog_setting('site_name', config('blog.name')));
$view->with('siteDescription', blog_setting('site_description', config('blog.description')));
$view->with('siteIcp', blog_setting('site_icp', config('blog.icp')));
}
}