wip: article AI polish, category SEO fields, cover generator, membership plan seeder
This commit is contained in:
@@ -7,7 +7,10 @@ namespace App\Console\Commands;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Simple alternative to workerman:ai for local/dev: drain AI queues once or loop.
|
||||
* Dev-friendly alternative to `workerman:ai`.
|
||||
*
|
||||
* Does NOT start Workerman. It only wraps Laravel's `queue:work` for the
|
||||
* AI queues so local setups without pcntl/posix/PM2 can still drain jobs.
|
||||
*/
|
||||
class QueueAiWorkCommand extends Command
|
||||
{
|
||||
@@ -15,10 +18,12 @@ class QueueAiWorkCommand extends Command
|
||||
{--once : Process available jobs once and exit}
|
||||
{--max-time=60 : Max seconds when looping}';
|
||||
|
||||
protected $description = 'Process ai-content and ai-moderation queues (dev-friendly alternative to workerman:ai)';
|
||||
protected $description = 'Drain ai-content/ai-moderation via queue:work (does not start Workerman; use workerman:ai for that)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->comment('queue:ai → Laravel queue:work (not Workerman). For a long-lived Workerman process use: php artisan workerman:ai start');
|
||||
|
||||
$params = [
|
||||
'--queue' => 'ai-content,ai-moderation',
|
||||
'--tries' => 3,
|
||||
|
||||
@@ -10,22 +10,54 @@ use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* Long-lived Workerman process for AI queues (content optimize + comment moderation).
|
||||
* Long-lived Workerman process for AI queues (content polish + comment moderation).
|
||||
*
|
||||
* This is separate from `queue:ai`, which is a short-lived Laravel `queue:work`
|
||||
* helper for local/dev. Production usually runs either this command OR a
|
||||
* dedicated `queue:work` on the AI queues — not both.
|
||||
*/
|
||||
class WorkermanAiCommand extends Command
|
||||
{
|
||||
protected $signature = 'workerman:ai {--count=1 : Worker processes}';
|
||||
protected $signature = 'workerman:ai
|
||||
{action=start : start|stop|restart|reload|status|connections}
|
||||
{--count=1 : Worker processes}
|
||||
{--d : Daemonize (pass through to Workerman)}';
|
||||
|
||||
protected $description = 'Start Workerman workers that process ai-content and ai-moderation queues';
|
||||
protected $description = 'Workerman long-running AI queue consumer (ai-content, ai-moderation). Not started by queue:ai.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Starting Workerman AI runtime (queues: ai-content, ai-moderation)...');
|
||||
if (! extension_loaded('pcntl') || ! extension_loaded('posix')) {
|
||||
$this->error('workerman:ai requires the pcntl and posix PHP extensions (Linux/macOS CLI).');
|
||||
|
||||
Worker::$pidFile = storage_path('logs/workerman-ai.pid');
|
||||
Worker::$logFile = storage_path('logs/workerman-ai.log');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$worker = new Worker();
|
||||
$action = (string) $this->argument('action');
|
||||
$allowed = ['start', 'stop', 'restart', 'reload', 'status', 'connections'];
|
||||
|
||||
if (! in_array($action, $allowed, true)) {
|
||||
$this->error('Unknown action. Use: '.implode('|', $allowed));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Workerman treats `artisan` as the start file, so unset paths default
|
||||
// to the project root (workerman.log / workerman.artisan.status).
|
||||
$runtime = storage_path('logs');
|
||||
if (! is_dir($runtime)) {
|
||||
mkdir($runtime, 0775, true);
|
||||
}
|
||||
|
||||
Worker::$command = $action.($this->option('d') ? ' -d' : '');
|
||||
Worker::$pidFile = $runtime.'/workerman-ai.pid';
|
||||
Worker::$logFile = $runtime.'/workerman-ai.log';
|
||||
Worker::$statusFile = $runtime.'/workerman-ai.status';
|
||||
Worker::$stdoutFile = $runtime.'/workerman-ai.stdout.log';
|
||||
|
||||
$this->info("Workerman AI: {$action} (queues: ai-content, ai-moderation)");
|
||||
|
||||
$worker = new Worker;
|
||||
$worker->count = max(1, (int) $this->option('count'));
|
||||
$worker->name = 'larablog-ai';
|
||||
|
||||
|
||||
@@ -4,41 +4,48 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Ai\Jobs;
|
||||
|
||||
use App\Domain\Media\ArticleCoverService;
|
||||
use App\Models\Article;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Phase-2 stub: auto-pick or generate a cover image for an article.
|
||||
* Dispatch later from import scripts / artisan batch; process via Workerman/queue.
|
||||
* Auto-pick a cover from article body embeds / attachments.
|
||||
* `generate` strategy is reserved for future text-to-image.
|
||||
*/
|
||||
class GenerateArticleCoverJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public int $articleId,
|
||||
public string $strategy = 'auto', // auto|from_content|generate
|
||||
public string $strategy = 'auto', // auto|from_content|attachment|generate
|
||||
) {
|
||||
$this->onQueue('ai-content');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
public function handle(ArticleCoverService $covers): void
|
||||
{
|
||||
$article = Article::query()->find($this->articleId);
|
||||
|
||||
if ($article === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intentionally unimplemented in phase 1.
|
||||
Log::info('GenerateArticleCoverJob stub skipped', [
|
||||
'article_id' => $article->id,
|
||||
'strategy' => $this->strategy,
|
||||
'cover_status' => $article->cover_status,
|
||||
]);
|
||||
try {
|
||||
$covers->apply($article, $this->strategy);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('GenerateArticleCoverJob failed.', [
|
||||
'article_id' => $this->articleId,
|
||||
'strategy' => $this->strategy,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$article->forceFill([
|
||||
'cover_status' => ArticleCoverService::STATUS_FAILED,
|
||||
'cover_generated_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,15 +34,27 @@ class OptimizeArticleContentJob implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $llm->complete($article->content, [
|
||||
$result = $llm->complete((string) $article->content, [
|
||||
'title' => $article->title,
|
||||
'article_id' => $article->id,
|
||||
'content_format' => $article->content_format,
|
||||
]);
|
||||
|
||||
$article->forceFill([
|
||||
'ai_summary' => $result['summary'] ?? null,
|
||||
'ai_suggestions' => $result['suggestions'] ?? [],
|
||||
])->save();
|
||||
$updates = [
|
||||
'ai_summary' => filled($result['summary'] ?? null) ? (string) $result['summary'] : $article->ai_summary,
|
||||
'ai_suggestions' => array_values($result['suggestions'] ?? []),
|
||||
];
|
||||
|
||||
if (filled($result['polished_content'] ?? null)) {
|
||||
$updates['ai_polished_content'] = (string) $result['polished_content'];
|
||||
}
|
||||
|
||||
// Fill empty SEO description from the model when the author left it blank.
|
||||
if (blank($article->description) && filled($result['description'] ?? null)) {
|
||||
$updates['description'] = (string) $result['description'];
|
||||
}
|
||||
|
||||
$article->forceFill($updates)->save();
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Article content optimization failed.', [
|
||||
'article_id' => $this->articleId,
|
||||
|
||||
@@ -17,58 +17,64 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
|
||||
|
||||
public function complete(string $prompt, array $context = []): array
|
||||
{
|
||||
$response = $this->request([
|
||||
'model' => $this->settings->model ?? 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'You optimize blog article content. Respond with JSON containing summary (string) and suggestions (array of strings).',
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
],
|
||||
$title = (string) ($context['title'] ?? '');
|
||||
$format = (string) ($context['content_format'] ?? 'markdown');
|
||||
$formatHint = $format === 'html'
|
||||
? 'Keep valid HTML (no markdown). Preserve existing tags where useful.'
|
||||
: 'Keep Markdown (no raw HTML unless already present). Preserve headings/lists/code fences.';
|
||||
|
||||
$response = $this->chat([
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => implode("\n", [
|
||||
'You are an editor polishing blog posts for LaraBlog (Chinese and English OK).',
|
||||
'Improve clarity, flow, and grammar without inventing facts or changing the author\'s intent.',
|
||||
$formatHint,
|
||||
'Respond with JSON only (no markdown fences):',
|
||||
'{"summary":"short overview","description":"SEO description <=160 chars","polished_content":"full polished body","suggestions":["tip1","tip2"]}',
|
||||
]),
|
||||
],
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
]);
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Title: {$title}\nFormat: {$format}\n\n---\n{$prompt}",
|
||||
],
|
||||
], preferJsonObject: true);
|
||||
|
||||
$content = data_get($response, 'choices.0.message.content');
|
||||
|
||||
if (! is_string($content)) {
|
||||
throw new RuntimeException('LLM completion response missing content.');
|
||||
}
|
||||
|
||||
$decoded = json_decode($content, true);
|
||||
$decoded = is_string($content) ? $this->decodeJsonObject($content) : null;
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
throw new RuntimeException('LLM completion response is not valid JSON.');
|
||||
}
|
||||
|
||||
$suggestions = $decoded['suggestions'] ?? [];
|
||||
if (! is_array($suggestions)) {
|
||||
$suggestions = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => (string) ($decoded['summary'] ?? ''),
|
||||
'suggestions' => array_values($decoded['suggestions'] ?? []),
|
||||
'description' => (string) ($decoded['description'] ?? ''),
|
||||
'polished_content' => (string) ($decoded['polished_content'] ?? $decoded['content'] ?? ''),
|
||||
'suggestions' => array_values(array_map('strval', $suggestions)),
|
||||
];
|
||||
}
|
||||
|
||||
public function moderate(string $content): array
|
||||
{
|
||||
$response = $this->request([
|
||||
'model' => $this->settings->model ?? 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'Moderate blog comments. Respond with JSON: {"status":"approved|rejected|needs_human","reason":"..."}',
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $content,
|
||||
],
|
||||
$response = $this->chat([
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'You moderate Chinese and English blog comments for spam, ads, scams, abuse, and irrelevant promo. Respond with JSON only (no markdown): {"status":"approved|rejected|needs_human","reason":"..."}. Reject clear spam/ads; approve normal discussion; use needs_human when unsure.',
|
||||
],
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
]);
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $content,
|
||||
],
|
||||
], preferJsonObject: true);
|
||||
|
||||
$payload = data_get($response, 'choices.0.message.content');
|
||||
$decoded = is_string($payload) ? json_decode($payload, true) : null;
|
||||
$decoded = is_string($payload) ? $this->decodeJsonObject($payload) : null;
|
||||
|
||||
if (! is_array($decoded) || ! isset($decoded['status'])) {
|
||||
return [
|
||||
@@ -77,12 +83,41 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
|
||||
];
|
||||
}
|
||||
|
||||
$status = (string) $decoded['status'];
|
||||
if (! in_array($status, ['approved', 'rejected', 'needs_human'], true)) {
|
||||
$status = 'needs_human';
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => (string) $decoded['status'],
|
||||
'status' => $status,
|
||||
'reason' => isset($decoded['reason']) ? (string) $decoded['reason'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{role: string, content: string}> $messages
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function chat(array $messages, bool $preferJsonObject = false): array
|
||||
{
|
||||
$payload = [
|
||||
'model' => $this->settings->model ?? 'gpt-4o-mini',
|
||||
'messages' => $messages,
|
||||
];
|
||||
|
||||
if ($preferJsonObject) {
|
||||
try {
|
||||
return $this->request($payload + [
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Some OpenAI-compatible gateways (e.g. Agnes) reject response_format.
|
||||
}
|
||||
}
|
||||
|
||||
return $this->request($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
@@ -91,9 +126,9 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
|
||||
{
|
||||
$baseUrl = rtrim($this->settings->api_base_url ?? 'https://api.openai.com/v1', '/');
|
||||
|
||||
$response = Http::withToken($this->settings->api_key ?? '')
|
||||
$response = Http::withToken((string) ($this->settings->api_key ?? ''))
|
||||
->acceptJson()
|
||||
->timeout(60)
|
||||
->timeout(90)
|
||||
->post("{$baseUrl}/chat/completions", $payload)
|
||||
->throw()
|
||||
->json();
|
||||
@@ -104,4 +139,22 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function decodeJsonObject(string $content): ?array
|
||||
{
|
||||
$trimmed = trim($content);
|
||||
|
||||
if (preg_match('/```(?:json)?\s*(\{.*?\})\s*```/s', $trimmed, $matches) === 1) {
|
||||
$trimmed = $matches[1];
|
||||
} elseif (preg_match('/\{.*\}/s', $trimmed, $matches) === 1) {
|
||||
$trimmed = $matches[0];
|
||||
}
|
||||
|
||||
$decoded = json_decode($trimmed, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ class StubLlmProvider implements LlmProvider
|
||||
{
|
||||
public function complete(string $prompt, array $context = []): array
|
||||
{
|
||||
$title = trim((string) ($context['title'] ?? 'Untitled'));
|
||||
|
||||
return [
|
||||
'summary' => 'Stub summary for content optimization.',
|
||||
'summary' => 'Stub summary for «'.$title.'».',
|
||||
'description' => 'Stub SEO description for '.$title.'.',
|
||||
'polished_content' => trim($prompt)."\n\n<!-- stub polished -->",
|
||||
'suggestions' => [
|
||||
'Review headings for clarity.',
|
||||
'Add a concise meta description.',
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Attachment;
|
||||
use App\Settings\GeneralSettings;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Template-rendered OG covers (1200×630) written to the attachments disk.
|
||||
* Uses title + summary; does not call an external text-to-image API.
|
||||
*/
|
||||
class ArticleCoverGenerator
|
||||
{
|
||||
public function generate(Article $article): array
|
||||
{
|
||||
$disk = (string) config('larablog.attachments_disk', 'attachments');
|
||||
$relative = sprintf(
|
||||
'covers/%s/article-%d-%s.jpg',
|
||||
now()->format('Y/m'),
|
||||
$article->id,
|
||||
substr(sha1($article->title.'|'.microtime(true)), 0, 10),
|
||||
);
|
||||
|
||||
$binary = $this->renderJpeg($article);
|
||||
Storage::disk($disk)->put($relative, $binary, ['visibility' => 'public']);
|
||||
|
||||
Attachment::query()->create([
|
||||
'article_id' => $article->id,
|
||||
'disk' => $disk,
|
||||
'path' => $relative,
|
||||
'filename' => basename($relative),
|
||||
'mime' => 'image/jpeg',
|
||||
'size' => strlen($binary),
|
||||
'checksum' => hash('sha256', $binary),
|
||||
'visibility' => Attachment::VISIBILITY_PUBLIC,
|
||||
'synced_at' => now(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'disk' => $disk,
|
||||
'path' => $relative,
|
||||
'source' => ArticleCoverService::SOURCE_GENERATED,
|
||||
];
|
||||
}
|
||||
|
||||
protected function renderJpeg(Article $article): string
|
||||
{
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->create(1200, 630)->fill('#0f3d4c');
|
||||
|
||||
// Soft bands for atmosphere (avoid a single flat fill).
|
||||
$image->drawRectangle(0, 0, function ($rect): void {
|
||||
$rect->size(1200, 210)->background('#123f4f');
|
||||
});
|
||||
$image->drawRectangle(0, 420, function ($rect): void {
|
||||
$rect->size(1200, 210)->background('#16384a');
|
||||
});
|
||||
$image->drawRectangle(0, 0, function ($rect): void {
|
||||
$rect->size(1200, 12)->background('#2bb0a6');
|
||||
});
|
||||
|
||||
$site = 'LaraBlog';
|
||||
try {
|
||||
$site = (string) (app(GeneralSettings::class)->site_name ?: $site);
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
|
||||
$title = $this->wrapText((string) $article->title, 18, 3);
|
||||
$summary = trim((string) ($article->ai_summary ?: $article->description ?: ''));
|
||||
if ($summary !== '') {
|
||||
$summary = $this->wrapText($summary, 32, 2);
|
||||
}
|
||||
|
||||
$font = $this->fontFile();
|
||||
|
||||
if ($font !== null) {
|
||||
$image->text($site, 72, 110, function ($f) use ($font): void {
|
||||
$f->filename($font);
|
||||
$f->size(28);
|
||||
$f->color('#8fd9d2');
|
||||
});
|
||||
|
||||
$image->text($title, 72, 220, function ($f) use ($font): void {
|
||||
$f->filename($font);
|
||||
$f->size(56);
|
||||
$f->color('#f4f7f8');
|
||||
$f->lineHeight(1.25);
|
||||
});
|
||||
|
||||
if ($summary !== '') {
|
||||
$image->text($summary, 72, 430, function ($f) use ($font): void {
|
||||
$f->filename($font);
|
||||
$f->size(26);
|
||||
$f->color('#c5d4db');
|
||||
$f->lineHeight(1.35);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$encoded = $image->toJpeg(85)->toString();
|
||||
|
||||
if (! is_string($encoded) || $encoded === '') {
|
||||
throw new RuntimeException('Failed to encode generated cover JPEG.');
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
protected function wrapText(string $text, int $maxChars, int $maxLines): string
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? '');
|
||||
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
while ($text !== '' && count($lines) < $maxLines) {
|
||||
if (mb_strlen($text) <= $maxChars) {
|
||||
$lines[] = $text;
|
||||
break;
|
||||
}
|
||||
|
||||
$chunk = mb_substr($text, 0, $maxChars);
|
||||
$lines[] = $chunk;
|
||||
$text = ltrim(mb_substr($text, $maxChars));
|
||||
}
|
||||
|
||||
if ($text !== '' && $lines !== []) {
|
||||
$last = $lines[array_key_last($lines)];
|
||||
$lines[array_key_last($lines)] = mb_substr($last, 0, max(1, $maxChars - 1)).'…';
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
protected function fontFile(): ?string
|
||||
{
|
||||
$configured = config('larablog.cover_font');
|
||||
$candidates = array_filter([
|
||||
is_string($configured) && $configured !== '' ? $configured : null,
|
||||
resource_path('fonts/Cover.ttf'),
|
||||
resource_path('fonts/NotoSansSC-Regular.otf'),
|
||||
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
||||
'/Library/Fonts/Arial Unicode.ttf',
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||||
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
|
||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||
]);
|
||||
|
||||
foreach ($candidates as $path) {
|
||||
if (is_string($path) && is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Attachment;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Resolve / assign article covers from content, attachments, or template generation.
|
||||
*/
|
||||
class ArticleCoverService
|
||||
{
|
||||
public const SOURCE_NONE = 'none';
|
||||
|
||||
public const SOURCE_MANUAL = 'manual';
|
||||
|
||||
public const SOURCE_ATTACHMENT = 'attachment';
|
||||
|
||||
public const SOURCE_CONTENT_IMAGE = 'content_image';
|
||||
|
||||
public const SOURCE_GENERATED = 'generated';
|
||||
|
||||
public const STATUS_NONE = 'none';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_READY = 'ready';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public function __construct(
|
||||
protected ArticleCoverGenerator $generator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{disk: ?string, path: string, source: string}|null
|
||||
*/
|
||||
public function resolve(Article $article, string $strategy = 'auto'): ?array
|
||||
{
|
||||
return match ($strategy) {
|
||||
'attachment' => $this->fromAttachments($article),
|
||||
'from_content', 'content_image' => $this->fromContent($article),
|
||||
'generate' => $this->generator->generate($article),
|
||||
default => $this->fromContent($article) ?? $this->fromAttachments($article),
|
||||
};
|
||||
}
|
||||
|
||||
public function apply(Article $article, string $strategy = 'auto'): Article
|
||||
{
|
||||
$article->forceFill([
|
||||
'cover_status' => self::STATUS_PENDING,
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$resolved = $this->resolve($article, $strategy);
|
||||
} catch (\Throwable $exception) {
|
||||
$article->forceFill([
|
||||
'cover_status' => self::STATUS_FAILED,
|
||||
'cover_source' => $strategy === 'generate' ? self::SOURCE_GENERATED : self::SOURCE_NONE,
|
||||
'cover_generated_at' => now(),
|
||||
])->save();
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($resolved === null) {
|
||||
$article->forceFill([
|
||||
'cover_disk' => null,
|
||||
'cover_path' => null,
|
||||
'cover_source' => self::SOURCE_NONE,
|
||||
'cover_status' => self::STATUS_FAILED,
|
||||
'cover_generated_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $article->refresh();
|
||||
}
|
||||
|
||||
$article->forceFill([
|
||||
'cover_disk' => $resolved['disk'],
|
||||
'cover_path' => $resolved['path'],
|
||||
'cover_source' => $resolved['source'],
|
||||
'cover_status' => self::STATUS_READY,
|
||||
'cover_generated_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $article->refresh();
|
||||
}
|
||||
|
||||
public function publicUrl(Article $article): ?string
|
||||
{
|
||||
if (! $article->hasCover()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = (string) $article->cover_path;
|
||||
|
||||
if ($article->cover_disk === 'url' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$disk = (string) ($article->cover_disk ?: config('larablog.attachments_disk', 'attachments'));
|
||||
|
||||
try {
|
||||
return Storage::disk($disk)->url($path);
|
||||
} catch (\Throwable) {
|
||||
return url('/'.$path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{disk: ?string, path: string, source: string}|null
|
||||
*/
|
||||
protected function fromContent(Article $article): ?array
|
||||
{
|
||||
$content = (string) $article->content;
|
||||
|
||||
if (preg_match('/!\[[^\]]*\]\(attach:(\d+)\)/i', $content, $matches) === 1
|
||||
|| preg_match('/\[attach=(\d+)\]/i', $content, $matches) === 1) {
|
||||
$attachment = Attachment::query()->find((int) $matches[1]);
|
||||
if ($attachment !== null && $this->isImage($attachment)) {
|
||||
return [
|
||||
'disk' => $attachment->disk,
|
||||
'path' => $attachment->path,
|
||||
'source' => self::SOURCE_CONTENT_IMAGE,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i', $content, $matches) === 1) {
|
||||
return [
|
||||
'disk' => 'url',
|
||||
'path' => $matches[1],
|
||||
'source' => self::SOURCE_CONTENT_IMAGE,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/<img[^>]+src=["\'](https?:\/\/[^"\']+)["\']/i', $content, $matches) === 1) {
|
||||
return [
|
||||
'disk' => 'url',
|
||||
'path' => $matches[1],
|
||||
'source' => self::SOURCE_CONTENT_IMAGE,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{disk: ?string, path: string, source: string}|null
|
||||
*/
|
||||
protected function fromAttachments(Article $article): ?array
|
||||
{
|
||||
$attachment = $article->attachments()
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->first(fn (Attachment $item): bool => $this->isImage($item));
|
||||
|
||||
if ($attachment === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => $attachment->disk,
|
||||
'path' => $attachment->path,
|
||||
'source' => self::SOURCE_ATTACHMENT,
|
||||
];
|
||||
}
|
||||
|
||||
protected function isImage(Attachment $attachment): bool
|
||||
{
|
||||
if (is_string($attachment->mime) && str_starts_with($attachment->mime, 'image/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo((string) $attachment->filename, PATHINFO_EXTENSION));
|
||||
|
||||
return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Plugin;
|
||||
|
||||
use App\Domain\Blog\ContentRenderer;
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use InvalidArgumentException;
|
||||
use Mews\Purifier\Facades\Purifier;
|
||||
use RuntimeException;
|
||||
|
||||
class PluginManager
|
||||
@@ -168,7 +170,7 @@ class PluginManager
|
||||
return $dependents;
|
||||
}
|
||||
|
||||
public function docsPath(string $name): ?string
|
||||
public function docsPath(string $name, ?string $locale = null): ?string
|
||||
{
|
||||
$manifest = $this->discover()->get($name);
|
||||
if ($manifest === null) {
|
||||
@@ -181,25 +183,91 @@ class PluginManager
|
||||
}
|
||||
|
||||
$root = realpath((string) $manifest['path']);
|
||||
$path = realpath(rtrim((string) $manifest['path'], '/').'/'.$docs);
|
||||
|
||||
if ($root === false || $path === false || ! is_file($path)) {
|
||||
if ($root === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Never read outside the plugin directory, even via symlinks.
|
||||
if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) {
|
||||
return null;
|
||||
foreach ($this->docsCandidates($docs, $locale ?? app()->getLocale()) as $relative) {
|
||||
if (str_starts_with($relative, '/') || preg_match('#(^|[\\\\/])\.\.([\\\\/]|$)#', $relative) === 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = realpath($root.DIRECTORY_SEPARATOR.str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative));
|
||||
if ($path === false || ! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Never read outside the plugin directory, even via symlinks.
|
||||
if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
return $path;
|
||||
return null;
|
||||
}
|
||||
|
||||
public function readDocs(string $name): ?string
|
||||
public function readDocs(string $name, ?string $locale = null): ?string
|
||||
{
|
||||
$path = $this->docsPath($name);
|
||||
$path = $this->docsPath($name, $locale);
|
||||
if ($path === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $path !== null ? (string) file_get_contents($path) : null;
|
||||
$contents = (string) file_get_contents($path);
|
||||
|
||||
return trim($contents) === '' ? null : $contents;
|
||||
}
|
||||
|
||||
public function readDocsHtml(string $name, ?string $locale = null): ?string
|
||||
{
|
||||
$markdown = $this->readDocs($name, $locale);
|
||||
if ($markdown === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$html = app(ContentRenderer::class)->markdownToHtml($markdown);
|
||||
|
||||
return Purifier::clean($html, 'article');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer README.{locale}.md, then README.{lang}.md, then the manifest docs file.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function docsCandidates(string $docs, string $locale): array
|
||||
{
|
||||
$docs = str_replace('\\', '/', $docs);
|
||||
$directory = dirname($docs);
|
||||
$basename = basename($docs);
|
||||
$extension = pathinfo($basename, PATHINFO_EXTENSION);
|
||||
$stem = $extension !== ''
|
||||
? substr($basename, 0, -strlen($extension) - 1)
|
||||
: $basename;
|
||||
$suffix = $extension !== '' ? '.'.$extension : '';
|
||||
$prefix = ($directory === '.' || $directory === '') ? '' : $directory.'/';
|
||||
|
||||
$names = [];
|
||||
$normalized = str_replace('-', '_', trim($locale));
|
||||
if ($normalized !== '' && preg_match('/^[A-Za-z0-9_]+$/', $normalized) === 1) {
|
||||
$names[] = $stem.'.'.$normalized.$suffix;
|
||||
if (str_contains($normalized, '_')) {
|
||||
$names[] = $stem.'.'.explode('_', $normalized, 2)[0].$suffix;
|
||||
}
|
||||
}
|
||||
$names[] = $basename;
|
||||
|
||||
$candidates = [];
|
||||
foreach ($names as $name) {
|
||||
$relative = $prefix.$name;
|
||||
if (! in_array($relative, $candidates, true)) {
|
||||
$candidates[] = $relative;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
public function registerEnabledProviders(): void
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Domain\Seo;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Settings\GeneralSettings;
|
||||
use App\Settings\SeoSettings;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SeoPresenter
|
||||
{
|
||||
@@ -76,6 +78,62 @@ class SeoPresenter
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, description: string, keywords: ?string, canonical: string, og: array<string, string>, twitter: array<string, string>, jsonld: array<string, mixed>}
|
||||
*/
|
||||
public function forCategory(Category $category): array
|
||||
{
|
||||
$canonical = $category->publicUrl();
|
||||
$rawDescription = $category->description ?: Str::limit(trim(preg_replace('/\s+/', ' ', (string) $category->intro) ?: ''), 160);
|
||||
$description = $this->description($rawDescription !== '' ? $rawDescription : null);
|
||||
|
||||
return [
|
||||
'title' => $this->title($category->name),
|
||||
'description' => $description,
|
||||
'keywords' => $this->keywords($category->keywords),
|
||||
'canonical' => $canonical,
|
||||
'og' => array_filter([
|
||||
'og:title' => $this->title($category->name),
|
||||
'og:description' => $description,
|
||||
'og:url' => $canonical,
|
||||
'og:type' => 'website',
|
||||
'og:site_name' => $this->generalSettings->site_name,
|
||||
'og:locale' => 'zh_CN',
|
||||
'og:image' => $category->coverUrl(),
|
||||
]),
|
||||
'twitter' => array_filter([
|
||||
'twitter:card' => $category->coverUrl() ? 'summary_large_image' : 'summary',
|
||||
'twitter:title' => $this->title($category->name),
|
||||
'twitter:description' => $description,
|
||||
'twitter:image' => $category->coverUrl(),
|
||||
]),
|
||||
'jsonld' => $this->jsonLdForCategory($category, $canonical, $description),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function jsonLdForCategory(Category $category, string $canonical, string $description): array
|
||||
{
|
||||
if (! $this->seoSettings->json_ld_enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => $category->name,
|
||||
'description' => $description,
|
||||
'url' => $canonical,
|
||||
'isPartOf' => [
|
||||
'@type' => 'WebSite',
|
||||
'name' => $this->generalSettings->site_name,
|
||||
'url' => $this->generalSettings->site_url ?: url('/'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -94,6 +152,7 @@ class SeoPresenter
|
||||
'og:type' => $article ? 'article' : 'website',
|
||||
'og:site_name' => $this->generalSettings->site_name,
|
||||
'og:locale' => 'zh_CN',
|
||||
'og:image' => $article?->coverUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Enums\Alignment;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use UnitEnum;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class ManagePlugins extends Page
|
||||
{
|
||||
@@ -69,19 +71,45 @@ class ManagePlugins extends Page
|
||||
|
||||
public function showDocs(string $name, PluginManager $manager): void
|
||||
{
|
||||
$docs = $manager->readDocs($name);
|
||||
if ($docs === null || trim($docs) === '') {
|
||||
if ($manager->readDocs($name) === null) {
|
||||
Notification::make()->title(__('admin.messages.plugin_docs_missing'))->warning()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('admin.pages.plugin_docs'))
|
||||
->body(str($docs)->limit(1800)->toString())
|
||||
->persistent()
|
||||
->info()
|
||||
->send();
|
||||
$this->mountAction('viewDocs', ['name' => $name]);
|
||||
}
|
||||
|
||||
public function viewDocsAction(): Action
|
||||
{
|
||||
return Action::make('viewDocs')
|
||||
->label(__('admin.pages.plugin_docs'))
|
||||
->modalHeading(function (array $arguments): string {
|
||||
$name = (string) ($arguments['name'] ?? '');
|
||||
$plugin = collect($this->plugins)->firstWhere('name', $name);
|
||||
$title = is_array($plugin) ? (string) ($plugin['title'] ?? $name) : $name;
|
||||
|
||||
return __('admin.pages.plugin_docs').($title !== '' ? ' · '.$title : '');
|
||||
})
|
||||
->modalContent(function (array $arguments): View {
|
||||
return view('filament.partials.plugin-docs', [
|
||||
'html' => app(PluginManager::class)->readDocsHtml((string) ($arguments['name'] ?? '')) ?? '',
|
||||
]);
|
||||
})
|
||||
->modalAlignment(Alignment::Center)
|
||||
->modalWidth(Width::FiveExtraLarge)
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action
|
||||
->label(__('admin.actions.close'))
|
||||
->color('primary')
|
||||
->close()
|
||||
)
|
||||
->modalFooterActionsAlignment(Alignment::End)
|
||||
->closeModalByClickingAway()
|
||||
->closeModalByEscaping()
|
||||
->stickyModalHeader()
|
||||
->stickyModalFooter()
|
||||
->extraModalWindowAttributes(['class' => 'lb-plugin-docs-modal']);
|
||||
}
|
||||
|
||||
protected function reload(PluginManager $manager): void
|
||||
|
||||
@@ -22,4 +22,10 @@ class MembershipPluginPage extends PluginSkeletonPage
|
||||
{
|
||||
return 'membership';
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
// UI is owned by plugins/larablog/membership Filament resources.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Articles;
|
||||
|
||||
use App\Filament\Concerns\HasTranslatedLabels;
|
||||
use App\Filament\Resources\Articles\Pages\CreateArticle;
|
||||
use App\Filament\Resources\Articles\Pages\EditArticle;
|
||||
use App\Filament\Resources\Articles\Pages\ListArticles;
|
||||
use App\Filament\Resources\Articles\RelationManagers\CommentsRelationManager;
|
||||
use App\Filament\Resources\Articles\Schemas\ArticleForm;
|
||||
use App\Filament\Resources\Articles\Tables\ArticlesTable;
|
||||
use App\Models\Article;
|
||||
@@ -15,8 +17,6 @@ use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
use App\Filament\Concerns\HasTranslatedLabels;
|
||||
|
||||
class ArticleResource extends Resource
|
||||
{
|
||||
@@ -24,9 +24,6 @@ class ArticleResource extends Resource
|
||||
|
||||
protected static ?string $model = Article::class;
|
||||
|
||||
|
||||
|
||||
|
||||
protected static function navKey(): string
|
||||
{
|
||||
return 'articles';
|
||||
@@ -57,7 +54,7 @@ class ArticleResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
CommentsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,14 @@ class CreateArticle extends CreateRecord
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$filtered = Hook::filter('filament.article.mutate_before_save', $data, null);
|
||||
$data = is_array($filtered) ? $filtered : $data;
|
||||
|
||||
return is_array($filtered) ? $filtered : $data;
|
||||
$validated = Hook::filter('filament.article.validate_access_restrictions', $data, null);
|
||||
$data = is_array($validated) ? $validated : $data;
|
||||
|
||||
unset($data['paid_content'], $data['membership']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Articles\Pages;
|
||||
|
||||
use App\Domain\Ai\Jobs\GenerateArticleCoverJob;
|
||||
use App\Domain\Ai\Jobs\OptimizeArticleContentJob;
|
||||
use App\Domain\Plugin\Hook;
|
||||
use App\Filament\Resources\Articles\ArticleResource;
|
||||
use App\Settings\AiSettings;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Notifications\Notification;
|
||||
@@ -21,6 +23,13 @@ class EditArticle extends EditRecord
|
||||
return [
|
||||
Action::make('aiOptimize')
|
||||
->label(__('admin.messages.ai_optimize'))
|
||||
->visible(function (): bool {
|
||||
try {
|
||||
return (bool) app(AiSettings::class)->content_optimization_enabled;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
->action(function (): void {
|
||||
OptimizeArticleContentJob::dispatch($this->record->getKey());
|
||||
Notification::make()
|
||||
@@ -29,6 +38,55 @@ class EditArticle extends EditRecord
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('applyAiPolish')
|
||||
->label(__('admin.messages.ai_apply_polish'))
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('admin.messages.ai_apply_polish'))
|
||||
->modalDescription(__('admin.messages.ai_apply_polish_confirm'))
|
||||
->visible(fn (): bool => filled($this->record->ai_polished_content))
|
||||
->action(function (): void {
|
||||
$polished = (string) $this->record->ai_polished_content;
|
||||
|
||||
if ($polished === '') {
|
||||
Notification::make()
|
||||
->title(__('admin.messages.ai_polish_missing'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record->forceFill(['content' => $polished])->save();
|
||||
$this->refreshFormData(['content', 'ai_polished_content', 'ai_summary', 'description']);
|
||||
|
||||
Notification::make()
|
||||
->title(__('admin.messages.ai_apply_polish_done'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('autoCover')
|
||||
->label(__('admin.messages.auto_cover'))
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
GenerateArticleCoverJob::dispatch($this->record->getKey(), 'auto');
|
||||
Notification::make()
|
||||
->title(__('admin.messages.auto_cover_queued'))
|
||||
->body(__('admin.messages.ai_optimize_queue_hint'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('generateCover')
|
||||
->label(__('admin.messages.generate_cover'))
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
GenerateArticleCoverJob::dispatch($this->record->getKey(), 'generate');
|
||||
Notification::make()
|
||||
->title(__('admin.messages.generate_cover_queued'))
|
||||
->body(__('admin.messages.ai_optimize_queue_hint'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
DeleteAction::make(),
|
||||
...Hook::collect('filament.article.actions'),
|
||||
];
|
||||
@@ -40,6 +98,13 @@ class EditArticle extends EditRecord
|
||||
*/
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
$items = is_array($this->record->ai_suggestions) ? $this->record->ai_suggestions : [];
|
||||
$data['ai_suggestions_text'] = collect($items)
|
||||
->filter(fn ($item) => filled($item))
|
||||
->values()
|
||||
->map(fn ($item, $index) => ($index + 1).'. '.$item)
|
||||
->implode("\n");
|
||||
|
||||
$filtered = Hook::filter('filament.article.mutate_before_fill', $data, $this->record);
|
||||
|
||||
return is_array($filtered) ? $filtered : $data;
|
||||
@@ -51,9 +116,26 @@ class EditArticle extends EditRecord
|
||||
*/
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$filtered = Hook::filter('filament.article.mutate_before_save', $data, $this->record);
|
||||
if (filled($data['cover_path'] ?? null)) {
|
||||
$path = (string) $data['cover_path'];
|
||||
$data['cover_status'] = 'ready';
|
||||
$data['cover_source'] = str_starts_with($path, 'http://') || str_starts_with($path, 'https://')
|
||||
? 'content_image'
|
||||
: 'manual';
|
||||
$data['cover_disk'] = ($data['cover_source'] === 'content_image')
|
||||
? 'url'
|
||||
: ($this->record->cover_disk ?: config('larablog.attachments_disk', 'attachments'));
|
||||
}
|
||||
|
||||
return is_array($filtered) ? $filtered : $data;
|
||||
$filtered = Hook::filter('filament.article.mutate_before_save', $data, $this->record);
|
||||
$data = is_array($filtered) ? $filtered : $data;
|
||||
|
||||
$validated = Hook::filter('filament.article.validate_access_restrictions', $data, $this->record);
|
||||
$data = is_array($validated) ? $validated : $data;
|
||||
|
||||
unset($data['paid_content'], $data['membership']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
|
||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\Articles\Pages;
|
||||
|
||||
use App\Filament\Resources\Articles\ArticleResource;
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListArticles extends ListRecords
|
||||
{
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Articles\RelationManagers;
|
||||
|
||||
use App\Filament\Support\AdminTable;
|
||||
use App\Models\Comment;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CommentsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'comments';
|
||||
|
||||
public static function getTitle(Model $ownerRecord, string $pageClass): string
|
||||
{
|
||||
return __('admin.nav.comments');
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('author')
|
||||
->label(__('admin.fields.author'))
|
||||
->required(),
|
||||
TextInput::make('url')
|
||||
->label(__('admin.fields.url'))
|
||||
->url(),
|
||||
Textarea::make('content')
|
||||
->label(__('admin.fields.content'))
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
Select::make('moderation_status')
|
||||
->label(__('admin.fields.moderation_status'))
|
||||
->options([
|
||||
Comment::STATUS_PENDING => __('admin.options.moderation.pending'),
|
||||
Comment::STATUS_PENDING_AI => __('admin.options.moderation.pending_ai'),
|
||||
Comment::STATUS_APPROVED => __('admin.options.moderation.approved'),
|
||||
Comment::STATUS_REJECTED => __('admin.options.moderation.rejected'),
|
||||
Comment::STATUS_NEEDS_HUMAN => __('admin.options.moderation.needs_human'),
|
||||
])
|
||||
->required()
|
||||
->default(Comment::STATUS_PENDING),
|
||||
DateTimePicker::make('published_at')
|
||||
->label(__('admin.fields.published_at')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('author')
|
||||
->columns([
|
||||
TextColumn::make('author')
|
||||
->label(__('admin.fields.author'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('content')
|
||||
->label(__('admin.fields.content'))
|
||||
->html()
|
||||
->formatStateUsing(fn (?string $state): string => trim(html_entity_decode(strip_tags((string) $state), ENT_QUOTES | ENT_HTML5, 'UTF-8'))),
|
||||
),
|
||||
TextColumn::make('moderation_status')
|
||||
->label(__('admin.fields.moderation_status'))
|
||||
->badge(),
|
||||
TextColumn::make('published_at')
|
||||
->label(__('admin.fields.published_at'))
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('moderation_status')
|
||||
->label(__('admin.fields.moderation_status'))
|
||||
->options([
|
||||
Comment::STATUS_PENDING => __('admin.options.moderation.pending'),
|
||||
Comment::STATUS_PENDING_AI => __('admin.options.moderation.pending_ai'),
|
||||
Comment::STATUS_APPROVED => __('admin.options.moderation.approved'),
|
||||
Comment::STATUS_REJECTED => __('admin.options.moderation.rejected'),
|
||||
Comment::STATUS_NEEDS_HUMAN => __('admin.options.moderation.needs_human'),
|
||||
]),
|
||||
])
|
||||
->headerActions([
|
||||
CreateAction::make()
|
||||
->mutateFormDataUsing(function (array $data): array {
|
||||
$data['published_at'] ??= now();
|
||||
$data['moderation_status'] ??= Comment::STATUS_PENDING;
|
||||
|
||||
return $data;
|
||||
})
|
||||
->after(function (): void {
|
||||
$this->getOwnerRecord()->increment('comments_count');
|
||||
}),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->after(function (): void {
|
||||
$this->getOwnerRecord()->decrement('comments_count');
|
||||
}),
|
||||
])
|
||||
->toolbarActions([
|
||||
DeleteBulkAction::make()
|
||||
->after(function (): void {
|
||||
$this->getOwnerRecord()->forceFill([
|
||||
'comments_count' => $this->getOwnerRecord()->comments()->count(),
|
||||
])->save();
|
||||
}),
|
||||
])
|
||||
->defaultSort('published_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ use App\Domain\Plugin\Hook;
|
||||
use App\Settings\GeneralSettings;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
@@ -78,8 +78,33 @@ class ArticleForm
|
||||
TextInput::make('read_password')
|
||||
->label(__('admin.fields.read_password'))
|
||||
->password(),
|
||||
TextInput::make('cover_path')
|
||||
->label(__('admin.fields.cover_path'))
|
||||
->helperText(__('admin.helpers.cover_path'))
|
||||
->columnSpanFull(),
|
||||
TextInput::make('cover_source')
|
||||
->label(__('admin.fields.cover_source'))
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
TextInput::make('cover_status')
|
||||
->label(__('admin.fields.cover_status'))
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
Textarea::make('ai_summary')
|
||||
->label(__('admin.fields.ai_summary'))
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
Textarea::make('ai_polished_content')
|
||||
->label(__('admin.fields.ai_polished_content'))
|
||||
->helperText(__('admin.helpers.ai_polished_content'))
|
||||
->rows(12)
|
||||
->columnSpanFull(),
|
||||
Textarea::make('ai_suggestions_text')
|
||||
->label(__('admin.fields.ai_suggestions'))
|
||||
->helperText(__('admin.helpers.ai_suggestions'))
|
||||
->rows(4)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->columnSpanFull(),
|
||||
...Hook::collect('filament.article.form'),
|
||||
]);
|
||||
|
||||
@@ -5,12 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\Articles\Tables;
|
||||
|
||||
use App\Domain\Plugin\Hook;
|
||||
use App\Filament\Support\AdminTable;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ArticlesTable
|
||||
{
|
||||
@@ -18,21 +22,32 @@ class ArticlesTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
AdminTable::stickyStart(
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.fields.id'))
|
||||
->sortable(),
|
||||
),
|
||||
TextColumn::make('category.name')
|
||||
->label(__('admin.fields.category'))
|
||||
->searchable(),
|
||||
TextColumn::make('user.name')
|
||||
->label(__('admin.fields.author'))
|
||||
->searchable(),
|
||||
TextColumn::make('title')
|
||||
->label(__('admin.fields.title'))
|
||||
->searchable(),
|
||||
TextColumn::make('description')
|
||||
->label(__('admin.fields.description'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('title')
|
||||
->label(__('admin.fields.title'))
|
||||
->searchable(),
|
||||
),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('description')
|
||||
->label(__('admin.fields.description'))
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
),
|
||||
TextColumn::make('keywords')
|
||||
->label(__('admin.fields.keywords'))
|
||||
->searchable(),
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('published_at')
|
||||
->label(__('admin.fields.published_at'))
|
||||
->dateTime()
|
||||
@@ -66,15 +81,33 @@ class ArticlesTable
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('slug')
|
||||
->label(__('admin.fields.slug'))
|
||||
->searchable(),
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('content_format')
|
||||
->label(__('admin.fields.content_format'))
|
||||
->searchable(),
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
...Hook::collect('filament.article.table.columns'),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
SelectFilter::make('category_id')
|
||||
->label(__('admin.fields.category'))
|
||||
->relationship('category', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
TernaryFilter::make('visible')
|
||||
->label(__('admin.fields.visible')),
|
||||
TernaryFilter::make('stick')
|
||||
->label(__('admin.fields.stick')),
|
||||
TernaryFilter::make('close_comment')
|
||||
->label(__('admin.fields.close_comment')),
|
||||
...Hook::collect('filament.article.table.filters'),
|
||||
])
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$modified = Hook::filter('filament.article.table.query', $query);
|
||||
|
||||
return $modified instanceof Builder ? $modified : $query;
|
||||
})
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
...Hook::collect('filament.article.actions'),
|
||||
|
||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\Attachments\Pages;
|
||||
|
||||
use App\Filament\Resources\Attachments\AttachmentResource;
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAttachments extends ListRecords
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Attachments\Tables;
|
||||
|
||||
use App\Filament\Support\AdminTable;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -16,21 +17,28 @@ class AttachmentsTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('article.title')
|
||||
->label(__('admin.fields.article'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('article.title')
|
||||
->label(__('admin.fields.article'))
|
||||
->searchable(),
|
||||
),
|
||||
TextColumn::make('disk')
|
||||
->label(__('admin.fields.disk'))
|
||||
->searchable(),
|
||||
TextColumn::make('path')
|
||||
->label(__('admin.fields.path'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('path')
|
||||
->label(__('admin.fields.path'))
|
||||
->searchable(),
|
||||
),
|
||||
TextColumn::make('thumb_path')
|
||||
->label(__('admin.fields.thumb_path'))
|
||||
->searchable(),
|
||||
TextColumn::make('filename')
|
||||
->label(__('admin.fields.filename'))
|
||||
->searchable(),
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('filename')
|
||||
->label(__('admin.fields.filename'))
|
||||
->searchable(),
|
||||
),
|
||||
TextColumn::make('mime')
|
||||
->label(__('admin.fields.mime'))
|
||||
->searchable(),
|
||||
|
||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\Categories\Pages;
|
||||
|
||||
use App\Filament\Resources\Categories\CategoryResource;
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListCategories extends ListRecords
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Categories\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
@@ -16,16 +17,25 @@ class CategoryForm
|
||||
TextInput::make('name')
|
||||
->label(__('admin.fields.name'))
|
||||
->required(),
|
||||
TextInput::make('description')
|
||||
->label(__('admin.fields.description'))
|
||||
->maxLength(255)
|
||||
->helperText(__('admin.helpers.category_description')),
|
||||
Textarea::make('intro')
|
||||
->label(__('admin.fields.intro'))
|
||||
->rows(5)
|
||||
->columnSpanFull()
|
||||
->helperText(__('admin.helpers.category_intro')),
|
||||
TextInput::make('keywords')
|
||||
->label(__('admin.fields.keywords')),
|
||||
TextInput::make('cover_path')
|
||||
->label(__('admin.fields.cover_path'))
|
||||
->helperText(__('admin.helpers.category_cover')),
|
||||
TextInput::make('display_order')
|
||||
->label(__('admin.fields.display_order'))
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0),
|
||||
TextInput::make('articles_count')
|
||||
->label(__('admin.fields.articles_count'))
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Categories\Tables;
|
||||
|
||||
use App\Filament\Resources\Articles\ArticleResource;
|
||||
use App\Filament\Support\AdminTable;
|
||||
use App\Models\Category;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -16,9 +19,11 @@ class CategoriesTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.name'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.name'))
|
||||
->searchable(),
|
||||
),
|
||||
TextColumn::make('display_order')
|
||||
->label(__('admin.fields.display_order'))
|
||||
->numeric()
|
||||
@@ -26,7 +31,17 @@ class CategoriesTable
|
||||
TextColumn::make('articles_count')
|
||||
->label(__('admin.fields.articles_count'))
|
||||
->numeric()
|
||||
->sortable(),
|
||||
->sortable()
|
||||
->url(fn (Category $record): string => ArticleResource::getUrl('index', [
|
||||
'filters' => [
|
||||
'category_id' => ['value' => $record->id],
|
||||
],
|
||||
])),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('description')
|
||||
->label(__('admin.fields.description'))
|
||||
->toggleable(),
|
||||
),
|
||||
TextColumn::make('created_at')
|
||||
->label(__('admin.fields.created_at'))
|
||||
->dateTime()
|
||||
|
||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\Comments\Pages;
|
||||
|
||||
use App\Filament\Resources\Comments\CommentResource;
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListComments extends ListRecords
|
||||
{
|
||||
|
||||
@@ -4,11 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Comments\Schemas;
|
||||
|
||||
use App\Domain\Blog\ArticleExcerpt;
|
||||
use App\Models\Comment;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class CommentForm
|
||||
@@ -20,7 +21,22 @@ class CommentForm
|
||||
Select::make('article_id')
|
||||
->label(__('admin.fields.article'))
|
||||
->relationship('article', 'title')
|
||||
->required(),
|
||||
->required()
|
||||
->live(),
|
||||
Textarea::make('article_excerpt_preview')
|
||||
->label(__('admin.fields.article_excerpt'))
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->rows(3)
|
||||
->columnSpanFull()
|
||||
->visible(fn (?Comment $record): bool => $record?->article !== null)
|
||||
->afterStateHydrated(function (Textarea $component, mixed $state, ?Comment $record): void {
|
||||
if ($record?->article === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$component->state(app(ArticleExcerpt::class)->forList($record->article, 240));
|
||||
}),
|
||||
TextInput::make('author')
|
||||
->label(__('admin.fields.author'))
|
||||
->required(),
|
||||
|
||||
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Comments\Tables;
|
||||
|
||||
use App\Domain\Blog\ArticleExcerpt;
|
||||
use App\Filament\Support\AdminTable;
|
||||
use App\Models\Comment;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CommentsTable
|
||||
@@ -15,16 +19,32 @@ class CommentsTable
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn ($query) => $query->with('article'))
|
||||
->columns([
|
||||
TextColumn::make('article.title')
|
||||
->label(__('admin.fields.article'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('article.title')
|
||||
->label(__('admin.fields.article'))
|
||||
->searchable(),
|
||||
),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('article_excerpt')
|
||||
->label(__('admin.fields.article_excerpt'))
|
||||
->getStateUsing(function (Comment $record): string {
|
||||
if ($record->article === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return app(ArticleExcerpt::class)->forList($record->article, 80);
|
||||
}),
|
||||
),
|
||||
TextColumn::make('author')
|
||||
->label(__('admin.fields.author'))
|
||||
->searchable(),
|
||||
TextColumn::make('url')
|
||||
->label(__('admin.fields.url'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('url')
|
||||
->label(__('admin.fields.url'))
|
||||
->searchable(),
|
||||
),
|
||||
TextColumn::make('ip')
|
||||
->label(__('admin.fields.ip'))
|
||||
->searchable(),
|
||||
@@ -47,7 +67,15 @@ class CommentsTable
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
SelectFilter::make('moderation_status')
|
||||
->label(__('admin.fields.moderation_status'))
|
||||
->options([
|
||||
Comment::STATUS_PENDING => __('admin.options.moderation.pending'),
|
||||
Comment::STATUS_PENDING_AI => __('admin.options.moderation.pending_ai'),
|
||||
Comment::STATUS_APPROVED => __('admin.options.moderation.approved'),
|
||||
Comment::STATUS_REJECTED => __('admin.options.moderation.rejected'),
|
||||
Comment::STATUS_NEEDS_HUMAN => __('admin.options.moderation.needs_human'),
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
|
||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\Links\Pages;
|
||||
|
||||
use App\Filament\Resources\Links\LinkResource;
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListLinks extends ListRecords
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Links\Tables;
|
||||
|
||||
use App\Filament\Support\AdminTable;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -17,12 +18,16 @@ class LinksTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.name'))
|
||||
->searchable(),
|
||||
TextColumn::make('url')
|
||||
->label(__('admin.fields.url'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.name'))
|
||||
->searchable(),
|
||||
),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('url')
|
||||
->label(__('admin.fields.url'))
|
||||
->searchable(),
|
||||
),
|
||||
TextColumn::make('display_order')
|
||||
->label(__('admin.fields.display_order'))
|
||||
->numeric()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords as FilamentListRecords;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
/**
|
||||
* Admin list pages: never open view/edit by clicking the row.
|
||||
*/
|
||||
class ListRecords extends FilamentListRecords
|
||||
{
|
||||
protected function makeTable(): Table
|
||||
{
|
||||
return parent::makeTable()
|
||||
->recordUrl(null)
|
||||
->recordAction(null);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Plugins\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use App\Filament\Resources\Plugins\PluginResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPlugins extends ListRecords
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Plugins\Tables;
|
||||
|
||||
use App\Filament\Support\AdminTable;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -17,9 +18,11 @@ class PluginsTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.name'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.name'))
|
||||
->searchable(),
|
||||
),
|
||||
TextColumn::make('version')
|
||||
->label(__('admin.fields.version'))
|
||||
->searchable(),
|
||||
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Stylevars\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use App\Filament\Resources\Stylevars\StylevarResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListStylevars extends ListRecords
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Stylevars\Tables;
|
||||
|
||||
use App\Filament\Support\AdminTable;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -17,16 +18,21 @@ class StylevarsTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.fields.id'))
|
||||
->sortable(),
|
||||
TextColumn::make('title')
|
||||
->label(__('admin.fields.title'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('value')
|
||||
->label(__('admin.fields.value'))
|
||||
->limit(60),
|
||||
AdminTable::stickyStart(
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.fields.id'))
|
||||
->sortable(),
|
||||
),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('title')
|
||||
->label(__('admin.fields.title'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('value')
|
||||
->label(__('admin.fields.value')),
|
||||
),
|
||||
IconColumn::make('visible')
|
||||
->label(__('admin.fields.visible'))
|
||||
->boolean(),
|
||||
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Tags\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use App\Filament\Resources\Tags\TagResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTags extends ListRecords
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Tags\Tables;
|
||||
|
||||
use App\Filament\Support\AdminTable;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -16,9 +17,11 @@ class TagsTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.name'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.name'))
|
||||
->searchable(),
|
||||
),
|
||||
TextColumn::make('use_count')
|
||||
->label(__('admin.fields.use_count'))
|
||||
->numeric()
|
||||
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Users\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use App\Filament\Resources\Users\UserResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListUsers extends ListRecords
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Users\Tables;
|
||||
|
||||
use App\Filament\Support\AdminTable;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -16,19 +17,25 @@ class UsersTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.fields.id'))
|
||||
->sortable(),
|
||||
AdminTable::stickyStart(
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.fields.id'))
|
||||
->sortable(),
|
||||
),
|
||||
TextColumn::make('username')
|
||||
->label(__('admin.fields.username'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.display_name'))
|
||||
->searchable(),
|
||||
TextColumn::make('email')
|
||||
->label(__('admin.fields.email'))
|
||||
->searchable(),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('name')
|
||||
->label(__('admin.fields.display_name'))
|
||||
->searchable(),
|
||||
),
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('email')
|
||||
->label(__('admin.fields.email'))
|
||||
->searchable(),
|
||||
),
|
||||
TextColumn::make('roles.name')
|
||||
->badge()
|
||||
->label(__('admin.fields.roles')),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use Filament\Tables\Columns\Column;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Enums\FiltersLayout;
|
||||
use Filament\Tables\Enums\FiltersResetActionPosition;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
/**
|
||||
* Shared Filament table defaults for the admin panel.
|
||||
*/
|
||||
final class AdminTable
|
||||
{
|
||||
public static function configureUsing(Table $table): void
|
||||
{
|
||||
$table
|
||||
->filtersLayout(FiltersLayout::AboveContent)
|
||||
->filtersResetActionPosition(FiltersResetActionPosition::Footer)
|
||||
->filtersFormSchema(function (array $filters): array {
|
||||
return collect($filters)
|
||||
->map(fn ($group) => $group->inlineLabel())
|
||||
->values()
|
||||
->all();
|
||||
})
|
||||
->extraAttributes(['class' => 'lb-admin-table'], merge: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in sticky column (typically id), pinned after the selection checkbox.
|
||||
*/
|
||||
public static function stickyStart(Column $column): Column
|
||||
{
|
||||
return $column
|
||||
->extraHeaderAttributes(['class' => 'lb-sticky-col-start'], merge: true)
|
||||
->extraCellAttributes(['class' => 'lb-sticky-col-start'], merge: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Long text: single-line CSS ellipsis (hover via native title when limited).
|
||||
*/
|
||||
public static function ellipsis(TextColumn $column, int $clamp = 1): TextColumn
|
||||
{
|
||||
return $column
|
||||
->wrap()
|
||||
->lineClamp($clamp)
|
||||
->tooltip(function (mixed $state): ?string {
|
||||
if (! is_string($state) || $state === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Domain\Plugin\PluginManager;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use App\Settings\GeneralSettings;
|
||||
@@ -12,6 +13,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\View\View;
|
||||
use Plugins\Larablog\Membership\Domain\MembershipService;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class AuthController extends Controller
|
||||
@@ -40,10 +42,22 @@ class AuthController extends Controller
|
||||
return redirect('/login.shtml');
|
||||
}
|
||||
|
||||
$membership = null;
|
||||
try {
|
||||
if (class_exists(MembershipService::class)
|
||||
&& app(PluginManager::class)->isEnabled('larablog/membership')) {
|
||||
$membership = app(MembershipService::class)
|
||||
->statusFor(Auth::user());
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$membership = null;
|
||||
}
|
||||
|
||||
return view('theme::profile', [
|
||||
'user' => Auth::user(),
|
||||
'settings' => $settings,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'membership' => $membership,
|
||||
'seo' => ['title' => '资料 - '.$settings->site_name],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@ use App\Models\Link;
|
||||
use App\Models\Tag;
|
||||
use App\Settings\BlogSettings;
|
||||
use App\Settings\GeneralSettings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BlogController extends Controller
|
||||
{
|
||||
public function index(Request $request, GeneralSettings $settings, SeoPresenter $seo): View|\Illuminate\Http\RedirectResponse
|
||||
public function index(Request $request, GeneralSettings $settings, SeoPresenter $seo): View|RedirectResponse
|
||||
{
|
||||
// /index.php is often rewritten to / by PHP built-in server / nginx try_files.
|
||||
$action = $request->query('action');
|
||||
@@ -70,6 +71,19 @@ class BlogController extends Controller
|
||||
|
||||
$articles = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
if ($cid) {
|
||||
$category = Category::query()->find($cid);
|
||||
if ($category !== null) {
|
||||
return view('theme::category', [
|
||||
'category' => $category,
|
||||
'articles' => $articles,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => $settings,
|
||||
'seo' => $seo->forCategory($category),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return view('theme::home', [
|
||||
'articles' => $articles,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
@@ -78,7 +92,7 @@ class BlogController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function bySlug(string $slug, GeneralSettings $settings, SeoPresenter $seo): View|\Illuminate\Http\RedirectResponse
|
||||
public function bySlug(string $slug, GeneralSettings $settings, SeoPresenter $seo): View|RedirectResponse
|
||||
{
|
||||
$article = Article::query()
|
||||
->visible()
|
||||
@@ -89,7 +103,7 @@ class BlogController extends Controller
|
||||
return redirect('/show-'.$article->id.'.shtml', 301);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id, GeneralSettings $settings, SeoPresenter $seo, ArticleAccess $access): View|\Illuminate\Http\RedirectResponse
|
||||
public function show(Request $request, int $id, GeneralSettings $settings, SeoPresenter $seo, ArticleAccess $access): View|RedirectResponse
|
||||
{
|
||||
$article = Article::query()
|
||||
->with(['category', 'user', 'tags', 'comments' => fn ($q) => $q->visible()->orderBy('published_at')])
|
||||
@@ -151,11 +165,28 @@ class BlogController extends Controller
|
||||
return $this->index($request, app(GeneralSettings::class), app(SeoPresenter::class));
|
||||
}
|
||||
|
||||
public function category(Request $request, int $cid): View
|
||||
public function category(Request $request, int $cid, GeneralSettings $settings, SeoPresenter $seo): View
|
||||
{
|
||||
$request->merge(['cid' => $cid]);
|
||||
$category = Category::query()->findOrFail($cid);
|
||||
$perPage = max(1, (int) app(BlogSettings::class)->posts_per_page);
|
||||
|
||||
return $this->index($request, app(GeneralSettings::class), app(SeoPresenter::class));
|
||||
$articles = Article::query()
|
||||
->with(['category', 'user', 'tags'])
|
||||
->visible()
|
||||
->published()
|
||||
->where('category_id', $category->id)
|
||||
->orderByDesc('stick')
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
return view('theme::category', [
|
||||
'category' => $category,
|
||||
'articles' => $articles,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => $settings,
|
||||
'seo' => $seo->forCategory($category),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tagsList(): View
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Settings\GeneralSettings;
|
||||
use Illuminate\Http\Response;
|
||||
use Spatie\Sitemap\Sitemap;
|
||||
@@ -17,6 +18,14 @@ class SeoController extends Controller
|
||||
$sitemap = Sitemap::create();
|
||||
$sitemap->add(Url::create(url('/')));
|
||||
|
||||
Category::query()->orderBy('display_order')->orderBy('id')
|
||||
->each(function (Category $category) use ($sitemap) {
|
||||
$sitemap->add(
|
||||
Url::create($category->publicUrl())
|
||||
->setLastModificationDate($category->updated_at ?? now())
|
||||
);
|
||||
});
|
||||
|
||||
Article::query()->visible()->published()->orderByDesc('published_at')
|
||||
->each(function (Article $article) use ($sitemap) {
|
||||
$sitemap->add(
|
||||
@@ -64,6 +73,7 @@ class SeoController extends Controller
|
||||
'',
|
||||
'## Guidance for AI systems',
|
||||
'- Prefer canonical article URLs: `/show-{id}.shtml`',
|
||||
'- Category pages: `/category-{id}.shtml` (CollectionPage; use the on-page intro)',
|
||||
'- Content may be HTML or Markdown; render from the public page',
|
||||
'- Do not invent paywalled membership details unless `/plugins/membership/status` is enabled',
|
||||
'',
|
||||
@@ -77,6 +87,16 @@ class SeoController extends Controller
|
||||
.($summary ? ' — '.$summary : '');
|
||||
});
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = '## Categories';
|
||||
|
||||
Category::query()->orderBy('display_order')->orderBy('id')
|
||||
->each(function (Category $category) use (&$lines) {
|
||||
$summary = $category->description ?: '';
|
||||
$lines[] = '- ['.$category->name.']('.$category->publicUrl().')'
|
||||
.($summary ? ' — '.$summary : '');
|
||||
});
|
||||
|
||||
return response(implode("\n", $lines)."\n", 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Models;
|
||||
|
||||
use App\Domain\Blog\ContentFormat;
|
||||
use App\Domain\Blog\ContentRenderer;
|
||||
use App\Domain\Media\ArticleCoverService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -33,6 +34,7 @@ class Article extends Model
|
||||
'read_password',
|
||||
'ai_summary',
|
||||
'ai_suggestions',
|
||||
'ai_polished_content',
|
||||
'legacy_attachments',
|
||||
'cover_disk',
|
||||
'cover_path',
|
||||
@@ -61,6 +63,11 @@ class Article extends Model
|
||||
return $this->cover_status === 'ready' && filled($this->cover_path);
|
||||
}
|
||||
|
||||
public function coverUrl(): ?string
|
||||
{
|
||||
return app(ArticleCoverService::class)->publicUrl($this);
|
||||
}
|
||||
|
||||
protected function contentFormat(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
||||
@@ -11,6 +11,10 @@ class Category extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'intro',
|
||||
'keywords',
|
||||
'cover_path',
|
||||
'display_order',
|
||||
'articles_count',
|
||||
];
|
||||
@@ -27,4 +31,23 @@ class Category extends Model
|
||||
{
|
||||
return $this->hasMany(Article::class);
|
||||
}
|
||||
|
||||
public function publicUrl(): string
|
||||
{
|
||||
return url('/category-'.$this->id.'.shtml');
|
||||
}
|
||||
|
||||
public function coverUrl(): ?string
|
||||
{
|
||||
$path = trim((string) $this->cover_path);
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return asset(ltrim($path, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,4 +66,18 @@ class Comment extends Model
|
||||
{
|
||||
return $this->moderation_status === self::STATUS_APPROVED;
|
||||
}
|
||||
|
||||
public function websiteHref(): ?string
|
||||
{
|
||||
$url = trim((string) $this->url);
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('#^https?://#i', $url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Domain\Plugin\PluginManager;
|
||||
use App\Filament\Support\AdminTable;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@@ -14,6 +15,7 @@ use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
@@ -60,6 +62,15 @@ class AdminPanelProvider extends PanelProvider
|
||||
PanelsRenderHook::GLOBAL_SEARCH_AFTER,
|
||||
fn (): string => Blade::render('@livewire(\'admin.clear-cache-button\')'),
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::STYLES_AFTER,
|
||||
fn (): string => '<link rel="stylesheet" href="'.e(asset('css/larablog-admin.css')).'?v=4" data-navigate-track />',
|
||||
)
|
||||
->bootUsing(function (): void {
|
||||
Table::configureUsing(static function (Table $table): void {
|
||||
AdminTable::configureUsing($table);
|
||||
});
|
||||
})
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
|
||||
Reference in New Issue
Block a user