Initial baseline: LaraBlog core with plugin commerce surface.

Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
This commit is contained in:
ak
2026-08-12 01:15:38 +08:00
commit 263b98b218
337 changed files with 31393 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4
+96
View File
@@ -0,0 +1,96 @@
APP_NAME=LaraBlog
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=zh_CN
APP_FALLBACK_LOCALE=zh_CN
APP_FAKER_LOCALE=zh_CN
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Custom key prefix (recommended in shared Redis)
REDIS_PREFIX=larablog_
CACHE_PREFIX=larablog_cache_
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_URL=
AWS_ENDPOINT=
AWS_USE_PATH_STYLE_ENDPOINT=false
# For R2/MinIO often true; set AWS_URL to public/CDN base if any
# Blog attachments (S3-compatible: R2 / COS / OSS / MinIO)
ATTACHMENTS_DISK=attachments
ATTACHMENTS_URL_PREFIX=attachments
# local = storage/app/attachments (dev without MinIO/S3); s3 = object storage
ATTACHMENTS_DRIVER=local
# Sablog source DB for: php artisan sablog:import --mode=raw|markdown
SABLOG_DB_DRIVER=mysql
SABLOG_DB_HOST=127.0.0.1
SABLOG_DB_PORT=3306
SABLOG_DB_DATABASE=sablog
SABLOG_DB_USERNAME=root
SABLOG_DB_PASSWORD=
SABLOG_DB_CHARSET=utf8mb4
# Content formats
LARABLOG_DEFAULT_CONTENT_FORMAT=markdown
LARABLOG_IMPORT_CONTENT_FORMAT=html
# AI provider: stub | openai_compatible
AI_PROVIDER=stub
AI_API_BASE_URL=https://api.openai.com/v1
AI_API_KEY=
AI_MODEL=gpt-4o-mini
VITE_APP_NAME="${APP_NAME}"
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+84
View File
@@ -0,0 +1,84 @@
name: CI
on:
push:
branches: [main, master, dev, ak-local]
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
name: PHPUnit (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3']
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, sqlite, pdo_sqlite, gd, zip, intl, bcmath, redis
coverage: none
- name: Get Composer cache dir
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Prepare env
run: |
cp .env.example .env
php artisan key:generate
mkdir -p database
touch database/database.sqlite
- name: Run migrations (sqlite file smoke)
env:
DB_CONNECTION: sqlite
DB_DATABASE: ${{ github.workspace }}/database/database.sqlite
APP_ENV: testing
ATTACHMENTS_DRIVER: local
AI_PROVIDER: stub
run: php artisan migrate --force
- name: PHPUnit
env:
APP_ENV: testing
APP_LOCALE: zh_CN
DB_CONNECTION: sqlite
DB_DATABASE: ':memory:'
ATTACHMENTS_DRIVER: local
AI_PROVIDER: stub
CACHE_STORE: array
QUEUE_CONNECTION: sync
SESSION_DRIVER: array
run: php artisan test --compact
deploy:
name: Deploy (manual gate)
needs: tests
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Placeholder deploy hook
run: |
echo "Wire this job to your host (rsync/ssh + php artisan migrate --force + pm2 reload ecosystem.config.cjs)."
echo "See docs/ops/deploy.md"
+25
View File
@@ -0,0 +1,25 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.memsearch
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
+62
View File
@@ -0,0 +1,62 @@
# LaraBlog
Laravel 12 + Filament 5 + Livewire + Spatie + Workerman 的现代博客平台,支持 sablog 内容迁移、主题/插件、S3 兼容附件、HTML/Markdown 双轨、AI 队列与 `/api/v1` 只读接口。
## 快速开始
```bash
composer install
cp .env.example .env
php artisan key:generate
# 配置 DB_*;开发可设 ATTACHMENTS_DRIVER=local,生产配 AWS_* / MinIO
php artisan migrate
php artisan db:seed
php artisan plugins:sync --enable=larablog/ai-comment-moderation
php artisan themes:publish
php artisan serve
```
- 前台:`/``/show-{id}.shtml``/login.shtml`
- 后台:`/admin``admin@larablog.test` / `password`
- API`/api/v1/*`OpenAPI`/docs/api/openapi.yaml`
## 文档索引
| 文档 | 内容 |
|---|---|
| [docs/architecture.md](docs/architecture.md) | 架构、分层、开发思想 |
| [docs/themes.md](docs/themes.md) | 皮肤目录格式与开发 |
| [docs/plugins.md](docs/plugins.md) | 插件格式 / Hook |
| [docs/routing.md](docs/routing.md) | `.shtml` 路由策略 |
| [docs/api/](docs/api/) | OpenAPI + 小程序向只读 API |
| [docs/ops/deploy.md](docs/ops/deploy.md) | PM2 / Redis 前缀 / 部署 |
| [docs/specs/larablog-platform/](docs/specs/larablog-platform/) | SPEC / CHECKLIST / TESTPLAN |
## 进程与 CI
- PM2`ecosystem.config.cjs`queue / schedule / workerman
- GitHub Actions`.github/workflows/ci.yml`PHP 8.2/8.3 + PHPUnit
## 测试
```bash
php artisan test
```
- 默认 **sqlite `:memory:`**(见 `phpunit.xml`
- 覆盖:legacy URL、导入 fixture、auth、附件、AI stub、API v1 等
- **不是**全站手工 E2E;支付/会员真实链路等属二期
## Redis 前缀
```env
REDIS_PREFIX=larablog_
CACHE_PREFIX=larablog_cache_
```
## sablog 导入
```bash
php artisan sablog:import --mode=raw --attachments=/path/to/attachments
php artisan sablog:import --mode=markdown --attachments=/path/to/attachments
```
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Models\User;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Hash;
class LaraBlogUserProvider extends EloquentUserProvider
{
public function retrieveByCredentials(array $credentials): ?Authenticatable
{
if ($credentials === []) {
return null;
}
$query = $this->newModelQuery();
foreach ($credentials as $key => $value) {
if (in_array($key, ['password', 'token'], true)) {
continue;
}
if ($key === 'login') {
$query->where(function ($builder) use ($value): void {
$builder->where('email', $value)
->orWhere('username', $value);
});
continue;
}
$query->where($key, $value);
}
return $query->first();
}
public function validateCredentials(Authenticatable $user, array $credentials): bool
{
$plain = (string) ($credentials['password'] ?? '');
if ($plain === '') {
return false;
}
if ($this->hasValidBcryptPassword($user, $plain)) {
return true;
}
if ($user instanceof User) {
return $user->attemptLegacyPasswordUpgrade($plain);
}
return false;
}
protected function hasValidBcryptPassword(Authenticatable $user, string $plain): bool
{
$hashed = $user->getAuthPassword();
if (! is_string($hashed) || $hashed === '') {
return false;
}
return Hash::check($plain, $hashed);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Domain\Plugin\PluginManager;
use Illuminate\Console\Command;
class PluginsSyncCommand extends Command
{
protected $signature = 'plugins:sync {--enable= : Comma-separated plugin names to enable after sync}';
protected $description = 'Discover plugins on disk and sync into the plugins table';
public function handle(PluginManager $manager): int
{
$discovered = $manager->syncDiscoveredPlugins();
$this->info('Synced '.$discovered->count().' plugins.');
foreach ($discovered as $name => $manifest) {
$this->line('- '.$name.' v'.($manifest['version'] ?? '1.0.0'));
}
$enable = trim((string) $this->option('enable'));
if ($enable !== '') {
foreach (array_filter(array_map('trim', explode(',', $enable))) as $name) {
$manager->enable($name);
$this->info("Enabled: {$name}");
}
}
return self::SUCCESS;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
/**
* Simple alternative to workerman:ai for local/dev: drain AI queues once or loop.
*/
class QueueAiWorkCommand extends Command
{
protected $signature = 'queue:ai
{--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)';
public function handle(): int
{
$params = [
'--queue' => 'ai-content,ai-moderation',
'--tries' => 3,
'--sleep' => 1,
];
if ($this->option('once')) {
$params['--stop-when-empty'] = true;
$params['--max-jobs'] = 50;
} else {
$params['--max-time'] = (int) $this->option('max-time');
}
return $this->call('queue:work', $params);
}
}
@@ -0,0 +1,473 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Domain\Blog\ContentFormat;
use App\Domain\Blog\ContentRenderer;
use App\Models\Article;
use App\Models\Attachment;
use App\Models\Category;
use App\Models\Comment;
use App\Models\Link;
use App\Models\Stylevar;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
class SablogImportCommand extends Command
{
protected $signature = 'sablog:import
{--connection=sablog : Laravel DB connection name for the source sablog database}
{--prefix=sablog_ : Source table prefix}
{--attachments= : Absolute path to sablog attachments directory}
{--mode=raw : Import mode: raw (keep HTML) or markdown (convert + rewrite attach embeds)}
{--encoding=auto : utf8|gbk|auto}
{--disk=attachments : Target attachments disk}
{--attachments-url-prefix=attachments : Legacy URL prefix}
{--retry-failed : Retry attachment rows that failed previously}
{--dry-run : Do not write to the target database or object storage}';
protected $description = 'Import sablog content. Modes: raw (HTML) or markdown (converted).';
/** @var array<string, int> */
protected array $report = [
'users' => 0,
'categories' => 0,
'articles' => 0,
'comments' => 0,
'tags' => 0,
'links' => 0,
'stylevars' => 0,
'attachments_ok' => 0,
'attachments_missing' => 0,
'attachments_failed' => 0,
'skipped_trackbacks' => 0,
'skipped_searchindex' => 0,
'skipped_sessions' => 0,
];
public function handle(ContentRenderer $renderer): int
{
$mode = strtolower((string) $this->option('mode'));
if (! in_array($mode, ['raw', 'markdown'], true)) {
$this->error('--mode must be raw or markdown');
return self::FAILURE;
}
$connection = (string) $this->option('connection');
$prefix = (string) $this->option('prefix');
$attachmentsRoot = $this->option('attachments');
$disk = (string) $this->option('disk');
$dryRun = (bool) $this->option('dry-run');
$encoding = (string) $this->option('encoding');
if (! config("database.connections.{$connection}")) {
$this->error("Database connection [{$connection}] is not configured. Add it to config/database.php / .env (SABLOG_DB_*).");
return self::FAILURE;
}
$this->info("Import mode: {$mode}".($dryRun ? ' (dry-run)' : ''));
try {
$source = DB::connection($connection);
$source->select('select 1');
} catch (Throwable $e) {
$this->error('Cannot connect to sablog database: '.$e->getMessage());
return self::FAILURE;
}
$this->reportSkipped($source, $prefix);
if (! $dryRun) {
DB::transaction(function () use ($source, $prefix, $encoding, $mode, $renderer, $attachmentsRoot, $disk) {
$this->importUsers($source, $prefix, $encoding);
$this->importCategories($source, $prefix, $encoding);
$this->importArticles($source, $prefix, $encoding, $mode, $renderer);
$this->importComments($source, $prefix, $encoding);
$this->importTags($source, $prefix, $encoding);
$this->importLinks($source, $prefix, $encoding);
$this->importStylevars($source, $prefix, $encoding);
$this->importAttachments($source, $prefix, $attachmentsRoot, $disk);
});
} else {
$this->importUsers($source, $prefix, $encoding, true);
$this->importCategories($source, $prefix, $encoding, true);
$this->importArticles($source, $prefix, $encoding, $mode, $renderer, true);
$this->countTable($source, $prefix.'comments', 'comments');
$this->countTable($source, $prefix.'tags', 'tags');
$this->countTable($source, $prefix.'links', 'links');
$this->countTable($source, $prefix.'stylevars', 'stylevars');
$this->countTable($source, $prefix.'attachments', 'attachments_ok');
}
$this->table(array_keys($this->report), [array_values($this->report)]);
$this->info('Done. Source database/files were not modified.');
return self::SUCCESS;
}
protected function reportSkipped($source, string $prefix): void
{
foreach ([
'trackbacks' => 'skipped_trackbacks',
'trackbacklog' => 'skipped_trackbacks',
'searchindex' => 'skipped_searchindex',
'sessions' => 'skipped_sessions',
] as $table => $key) {
$name = $prefix.$table;
if ($this->sourceTableExists($source, $name)) {
$this->report[$key] += (int) $source->table($name)->count();
}
}
}
protected function importUsers($source, string $prefix, string $encoding, bool $countOnly = false): void
{
$table = $prefix.'users';
if (! $this->sourceTableExists($source, $table)) {
return;
}
foreach ($source->table($table)->orderBy('userid')->cursor() as $row) {
$this->report['users']++;
if ($countOnly) {
continue;
}
$this->upsertWithId(User::class, (int) $row->userid, [
'name' => $this->decode((string) $row->username, $encoding),
'username' => $this->decode((string) $row->username, $encoding),
'email' => null,
'password' => Hash::make(Str::password(32)),
'password_legacy' => (string) $row->password,
'url' => $this->decode((string) ($row->url ?? ''), $encoding) ?: null,
'login_count' => (int) ($row->logincount ?? 0),
'login_ip' => $row->loginip ?? null,
'login_at' => $this->fromUnix($row->logintime ?? null),
'reg_ip' => $row->regip ?? null,
'created_at' => $this->fromUnix($row->regdateline ?? null) ?? now(),
'updated_at' => now(),
]);
}
}
protected function importCategories($source, string $prefix, string $encoding, bool $countOnly = false): void
{
$table = $prefix.'categories';
if (! $this->sourceTableExists($source, $table)) {
return;
}
foreach ($source->table($table)->orderBy('cid')->cursor() as $row) {
$this->report['categories']++;
if ($countOnly) {
continue;
}
$this->upsertWithId(Category::class, (int) $row->cid, [
'name' => $this->decode((string) $row->name, $encoding),
'display_order' => (int) ($row->displayorder ?? 0),
'articles_count' => (int) ($row->articles ?? 0),
]);
}
}
protected function importArticles($source, string $prefix, string $encoding, string $mode, ContentRenderer $renderer, bool $countOnly = false): void
{
$table = $prefix.'articles';
if (! $this->sourceTableExists($source, $table)) {
return;
}
foreach ($source->table($table)->orderBy('articleid')->cursor() as $row) {
$this->report['articles']++;
if ($countOnly) {
continue;
}
$content = $this->decode((string) $row->content, $encoding);
$format = ContentFormat::HTML;
if ($mode === 'markdown') {
$content = $renderer->convertImportedHtmlToMarkdown($content, (int) $row->articleid);
$format = ContentFormat::MARKDOWN;
}
$legacyAttachments = null;
if (! empty($row->attachments)) {
$legacyAttachments = @unserialize($row->attachments);
if ($legacyAttachments === false) {
$legacyAttachments = ['raw' => $row->attachments];
}
}
$this->upsertWithId(Article::class, (int) $row->articleid, [
'category_id' => (int) $row->cid,
'user_id' => (int) $row->uid,
'title' => $this->decode((string) $row->title, $encoding),
'content' => $content,
'content_format' => $format,
'description' => $this->decode((string) ($row->description ?? ''), $encoding) ?: null,
'keywords' => $this->decode((string) ($row->keywords ?? ''), $encoding) ?: null,
'published_at' => $this->fromUnix($row->dateline ?? null),
'views' => (int) ($row->views ?? 0),
'comments_count' => (int) ($row->comments ?? 0),
'stick' => (bool) ($row->stick ?? false),
'visible' => (bool) ($row->visible ?? true),
'close_comment' => (bool) ($row->closecomment ?? false),
'read_password' => ($row->readpassword ?? '') !== '' ? (string) $row->readpassword : null,
'legacy_attachments' => $legacyAttachments,
]);
}
}
protected function importComments($source, string $prefix, string $encoding): void
{
$table = $prefix.'comments';
if (! $this->sourceTableExists($source, $table)) {
return;
}
foreach ($source->table($table)->orderBy('commentid')->cursor() as $row) {
$this->report['comments']++;
// Avoid plugin hooks (e.g. AI moderation) rewriting imported statuses / flooding queues.
Comment::withoutEvents(function () use ($row, $encoding): void {
$this->upsertWithId(Comment::class, (int) $row->commentid, [
'article_id' => (int) $row->articleid,
'author' => $this->decode((string) $row->author, $encoding),
'url' => $this->decode((string) ($row->url ?? ''), $encoding) ?: null,
'content' => $this->decode((string) $row->content, $encoding),
'ip' => $row->ipaddress ?? null,
'moderation_status' => ((int) ($row->visible ?? 0)) === 1
? Comment::STATUS_APPROVED
: Comment::STATUS_PENDING,
'published_at' => $this->fromUnix($row->dateline ?? null),
]);
});
}
}
protected function importTags($source, string $prefix, string $encoding): void
{
$table = $prefix.'tags';
if (! $this->sourceTableExists($source, $table)) {
return;
}
foreach ($source->table($table)->orderBy('tagid')->cursor() as $row) {
$this->report['tags']++;
$tag = $this->upsertWithId(Tag::class, (int) $row->tagid, [
'name' => $this->decode((string) $row->tag, $encoding),
'use_count' => (int) ($row->usenum ?? 0),
]);
$ids = preg_split('/\s*,\s*/', (string) ($row->aids ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$articleIds = collect($ids)->map(fn ($id) => (int) $id)->filter()->unique()->values()->all();
$tag->articles()->sync($articleIds);
}
}
protected function importLinks($source, string $prefix, string $encoding): void
{
$table = $prefix.'links';
if (! $this->sourceTableExists($source, $table)) {
return;
}
foreach ($source->table($table)->orderBy('linkid')->cursor() as $row) {
$this->report['links']++;
$this->upsertWithId(Link::class, (int) $row->linkid, [
'name' => $this->decode((string) $row->name, $encoding),
'url' => (string) $row->url,
'note' => $this->decode((string) ($row->note ?? ''), $encoding) ?: null,
'display_order' => (int) ($row->displayorder ?? 0),
'visible' => (bool) ($row->visible ?? true),
]);
}
}
protected function importStylevars($source, string $prefix, string $encoding): void
{
$table = $prefix.'stylevars';
if (! $this->sourceTableExists($source, $table)) {
return;
}
foreach ($source->table($table)->orderBy('stylevarid')->cursor() as $row) {
$this->report['stylevars']++;
$this->upsertWithId(Stylevar::class, (int) $row->stylevarid, [
'title' => $this->decode((string) $row->title, $encoding),
'value' => $this->decode((string) ($row->value ?? ''), $encoding),
'visible' => (bool) ($row->visible ?? true),
]);
}
}
protected function importAttachments($source, string $prefix, ?string $attachmentsRoot, string $disk): void
{
$table = $prefix.'attachments';
if (! $this->sourceTableExists($source, $table)) {
return;
}
$retryFailed = (bool) $this->option('retry-failed');
foreach ($source->table($table)->orderBy('attachmentid')->cursor() as $row) {
$id = (int) $row->attachmentid;
$legacy = ltrim(str_replace('\\', '/', (string) $row->filepath), '/');
$existing = Attachment::query()->find($id);
if ($existing && $existing->synced_at && ! $retryFailed) {
$this->report['attachments_ok']++;
continue;
}
$local = $attachmentsRoot
? rtrim($attachmentsRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $legacy)
: null;
$articleId = (int) ($row->articleid ?? 0) ?: null;
$filename = (string) ($row->filename ?: basename($legacy));
$key = sprintf(
'attachments/%s/%d/%s.%s',
$articleId ?: 'orphan',
$id,
substr(hash('sha256', $legacy.$id), 0, 16),
pathinfo($filename, PATHINFO_EXTENSION) ?: 'bin'
);
$payload = [
'article_id' => $articleId,
'disk' => $disk,
'path' => $key,
'thumb_path' => null,
'filename' => $filename,
'mime' => $row->filetype ?: null,
'size' => (int) ($row->filesize ?? 0),
'checksum' => null,
'visibility' => Attachment::VISIBILITY_PUBLIC,
'legacy_filepath' => $legacy,
'downloads' => (int) ($row->downloads ?? 0),
'synced_at' => null,
];
if (! $local || ! is_file($local)) {
$this->report['attachments_missing']++;
$this->upsertWithId(Attachment::class, $id, $payload);
continue;
}
try {
$payload['checksum'] = hash_file('sha256', $local) ?: null;
$payload['size'] = filesize($local) ?: $payload['size'];
$payload['mime'] = mime_content_type($local) ?: $payload['mime'];
$stream = fopen($local, 'r');
Storage::disk($disk)->put($key, $stream, ['visibility' => 'public']);
if (is_resource($stream)) {
fclose($stream);
}
$thumbLegacy = ltrim(str_replace('\\', '/', (string) ($row->thumb_filepath ?? '')), '/');
if ($attachmentsRoot && $thumbLegacy !== '') {
$thumbLocal = rtrim($attachmentsRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $thumbLegacy);
if (is_file($thumbLocal)) {
$thumbKey = preg_replace('/(\.[^.]+)?$/', '_thumb$1', $key) ?: $key.'_thumb';
$thumbStream = fopen($thumbLocal, 'r');
Storage::disk($disk)->put($thumbKey, $thumbStream, ['visibility' => 'public']);
if (is_resource($thumbStream)) {
fclose($thumbStream);
}
$payload['thumb_path'] = $thumbKey;
}
}
$payload['synced_at'] = now();
$this->upsertWithId(Attachment::class, $id, $payload);
$this->report['attachments_ok']++;
} catch (Throwable $e) {
$this->report['attachments_failed']++;
$this->warn("Attachment #{$id} failed: ".$e->getMessage());
$this->upsertWithId(Attachment::class, $id, $payload);
}
}
}
/**
* @param class-string<Model> $modelClass
* @param array<string, mixed> $values
*/
protected function upsertWithId(string $modelClass, int $id, array $values): Model
{
/** @var Model $model */
$model = $modelClass::query()->find($id) ?? new $modelClass;
$model->forceFill(['id' => $id] + $values)->save();
return $model;
}
protected function countTable($source, string $table, string $key): void
{
if ($this->sourceTableExists($source, $table)) {
$this->report[$key] = (int) $source->table($table)->count();
}
}
protected function sourceTableExists($source, string $table): bool
{
try {
return Schema::connection($source->getName())->hasTable($table);
} catch (Throwable) {
return false;
}
}
protected function decode(string $value, string $encoding): string
{
if ($value === '') {
return $value;
}
$encoding = strtolower($encoding);
if ($encoding === 'utf8' || $encoding === 'utf-8') {
return $value;
}
if ($encoding === 'gbk' || $encoding === 'gb2312') {
return mb_convert_encoding($value, 'UTF-8', 'GBK');
}
if (! mb_check_encoding($value, 'UTF-8')) {
$converted = @mb_convert_encoding($value, 'UTF-8', 'GBK');
return $converted !== false ? $converted : $value;
}
return $value;
}
protected function fromUnix(mixed $timestamp): ?\Illuminate\Support\Carbon
{
$ts = (int) $timestamp;
return $ts > 0 ? \Illuminate\Support\Carbon::createFromTimestamp($ts) : null;
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class ThemesPublishCommand extends Command
{
protected $signature = 'themes:publish {theme? : Theme slug to publish; omit for all}';
protected $description = 'Copy theme assets into public/themes for correct static MIME serving';
public function handle(): int
{
$themePath = config('larablog.theme_path', base_path('themes'));
$publicBase = public_path('themes');
File::ensureDirectoryExists($publicBase);
$only = $this->argument('theme');
$directories = collect(File::directories($themePath))
->when($only, fn ($c) => $c->filter(fn ($dir) => basename($dir) === $only));
if ($directories->isEmpty()) {
$this->error($only ? "Theme [{$only}] not found." : 'No themes found.');
return self::FAILURE;
}
foreach ($directories as $directory) {
$slug = basename($directory);
$assets = $directory.'/assets';
if (! is_dir($assets)) {
$this->warn("Skip {$slug}: no assets/");
continue;
}
$target = $publicBase.'/'.$slug;
File::deleteDirectory($target);
File::copyDirectory($assets, $target);
$this->info("Published theme assets: {$slug} → public/themes/{$slug}");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Workerman\Timer;
use Workerman\Worker;
/**
* Long-lived Workerman process for AI queues (content optimize + comment moderation).
*/
class WorkermanAiCommand extends Command
{
protected $signature = 'workerman:ai {--count=1 : Worker processes}';
protected $description = 'Start Workerman workers that process ai-content and ai-moderation queues';
public function handle(): int
{
$this->info('Starting Workerman AI runtime (queues: ai-content, ai-moderation)...');
Worker::$pidFile = storage_path('logs/workerman-ai.pid');
Worker::$logFile = storage_path('logs/workerman-ai.log');
$worker = new Worker();
$worker->count = max(1, (int) $this->option('count'));
$worker->name = 'larablog-ai';
$worker->onWorkerStart = function () {
Timer::add(1, function () {
Artisan::call('queue:work', [
'--queue' => 'ai-content,ai-moderation',
'--stop-when-empty' => true,
'--max-time' => 50,
'--sleep' => 1,
'--tries' => 3,
]);
});
};
Worker::runAll();
return self::SUCCESS;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
interface LlmProvider
{
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
public function complete(string $prompt, array $context = []): array;
/**
* @return array{status: string, reason?: string}
*/
public function moderate(string $content): array;
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai\Jobs;
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\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.
*/
class GenerateArticleCoverJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $articleId,
public string $strategy = 'auto', // auto|from_content|generate
) {
$this->onQueue('ai-content');
}
public function handle(): 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,
]);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai\Jobs;
use App\Contracts\LlmProvider;
use App\Models\Comment;
use App\Settings\AiSettings;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ModerateCommentJob implements ShouldQueue
{
use Queueable;
public function __construct(
public int $commentId,
) {
$this->onQueue('ai-moderation');
}
public function handle(LlmProvider $llm, AiSettings $settings): void
{
if (! $settings->comment_moderation_enabled) {
return;
}
$comment = Comment::query()->find($this->commentId);
if ($comment === null) {
return;
}
if ($comment->moderation_status !== Comment::STATUS_PENDING_AI) {
$comment->update(['moderation_status' => Comment::STATUS_PENDING_AI]);
}
try {
$result = $llm->moderate($comment->content);
$status = match ($result['status'] ?? 'needs_human') {
'approved' => Comment::STATUS_APPROVED,
'rejected' => Comment::STATUS_REJECTED,
default => Comment::STATUS_NEEDS_HUMAN,
};
$comment->forceFill([
'moderation_status' => $status,
'published_at' => $status === Comment::STATUS_APPROVED
? ($comment->published_at ?? now())
: $comment->published_at,
])->save();
} catch (\Throwable $exception) {
Log::warning('Comment moderation failed.', [
'comment_id' => $this->commentId,
'message' => $exception->getMessage(),
]);
$comment->update(['moderation_status' => Comment::STATUS_NEEDS_HUMAN]);
}
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai\Jobs;
use App\Contracts\LlmProvider;
use App\Models\Article;
use App\Settings\AiSettings;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class OptimizeArticleContentJob implements ShouldQueue
{
use Queueable;
public function __construct(
public int $articleId,
) {
$this->onQueue('ai-content');
}
public function handle(LlmProvider $llm, AiSettings $settings): void
{
if (! $settings->content_optimization_enabled) {
return;
}
$article = Article::query()->find($this->articleId);
if ($article === null) {
return;
}
try {
$result = $llm->complete($article->content, [
'title' => $article->title,
'article_id' => $article->id,
]);
$article->forceFill([
'ai_summary' => $result['summary'] ?? null,
'ai_suggestions' => $result['suggestions'] ?? [],
])->save();
} catch (\Throwable $exception) {
Log::warning('Article content optimization failed.', [
'article_id' => $this->articleId,
'message' => $exception->getMessage(),
]);
}
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai;
use App\Contracts\LlmProvider;
use App\Settings\AiSettings;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class OpenAiCompatibleLlmProvider implements LlmProvider
{
public function __construct(
protected AiSettings $settings,
) {}
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,
],
],
'response_format' => ['type' => 'json_object'],
]);
$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);
if (! is_array($decoded)) {
throw new RuntimeException('LLM completion response is not valid JSON.');
}
return [
'summary' => (string) ($decoded['summary'] ?? ''),
'suggestions' => array_values($decoded['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_format' => ['type' => 'json_object'],
]);
$payload = data_get($response, 'choices.0.message.content');
$decoded = is_string($payload) ? json_decode($payload, true) : null;
if (! is_array($decoded) || ! isset($decoded['status'])) {
return [
'status' => 'needs_human',
'reason' => 'Unable to parse moderation response.',
];
}
return [
'status' => (string) $decoded['status'],
'reason' => isset($decoded['reason']) ? (string) $decoded['reason'] : null,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function request(array $payload): array
{
$baseUrl = rtrim($this->settings->api_base_url ?? 'https://api.openai.com/v1', '/');
$response = Http::withToken($this->settings->api_key ?? '')
->acceptJson()
->timeout(60)
->post("{$baseUrl}/chat/completions", $payload)
->throw()
->json();
if (! is_array($response)) {
throw new RuntimeException('LLM provider returned an invalid response.');
}
return $response;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai;
use App\Contracts\LlmProvider;
class StubLlmProvider implements LlmProvider
{
public function complete(string $prompt, array $context = []): array
{
return [
'summary' => 'Stub summary for content optimization.',
'suggestions' => [
'Review headings for clarity.',
'Add a concise meta description.',
],
];
}
public function moderate(string $content): array
{
$blocked = str_contains(strtolower($content), 'spam');
return [
'status' => $blocked ? 'rejected' : 'approved',
'reason' => $blocked ? 'Detected spam keyword in stub provider.' : null,
];
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
final class AccessDecision
{
public const ALLOW = 'allow';
public const NEED_LOGIN = 'need_login';
public const NEED_PURCHASE = 'need_purchase';
public const NEED_PASSWORD = 'need_password';
/** @var array<string, int> */
private const SEVERITY = [
self::ALLOW => 0,
self::NEED_LOGIN => 1,
self::NEED_PURCHASE => 2,
self::NEED_PASSWORD => 3,
];
public function __construct(
public string $status,
public ?string $teaserHtml = null,
public ?string $checkoutUrl = null,
public ?string $message = null,
) {}
public static function allow(): self
{
return new self(self::ALLOW);
}
public static function needPassword(?string $message = null): self
{
return new self(self::NEED_PASSWORD, message: $message);
}
public static function needPurchase(?string $teaserHtml = null, ?string $checkoutUrl = null, ?string $message = null): self
{
return new self(self::NEED_PURCHASE, $teaserHtml, $checkoutUrl, $message);
}
public function isAllow(): bool
{
return $this->status === self::ALLOW;
}
public function severity(): int
{
return self::SEVERITY[$this->status] ?? 0;
}
/**
* Only allow tightening (higher severity). Metadata from the stricter side wins when status changes;
* otherwise fill empty meta from $other.
*/
public function tightenWith(self $other): self
{
if ($other->severity() < $this->severity()) {
return new self(
$this->status,
$this->teaserHtml ?? $other->teaserHtml,
$this->checkoutUrl ?? $other->checkoutUrl,
$this->message ?? $other->message,
);
}
if ($other->severity() > $this->severity()) {
return new self(
$other->status,
$other->teaserHtml ?? $this->teaserHtml,
$other->checkoutUrl ?? $this->checkoutUrl,
$other->message ?? $this->message,
);
}
return new self(
$this->status,
$other->teaserHtml ?? $this->teaserHtml,
$other->checkoutUrl ?? $this->checkoutUrl,
$other->message ?? $this->message,
);
}
/**
* @return array{status: string, teaser_html: ?string, checkout_url: ?string, message: ?string}
*/
public function toArray(): array
{
return [
'status' => $this->status,
'teaser_html' => $this->teaserHtml,
'checkout_url' => $this->checkoutUrl,
'message' => $this->message,
];
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use App\Domain\Plugin\Hook;
use App\Models\Article;
use App\Models\User;
use Illuminate\Support\Facades\Session;
class ArticleAccess
{
/**
* Pure decision no HTTP side effects.
*
* @param list<int>|null $unlockedArticleIds session unlock list; null = read from session
*/
public function resolve(Article $article, ?User $user = null, ?array $unlockedArticleIds = null): AccessDecision
{
if ($user !== null && $this->isPrivileged($article, $user)) {
return AccessDecision::allow();
}
if (filled($article->read_password)) {
$unlocked = $unlockedArticleIds ?? (array) Session::get('unlocked_articles', []);
if (! in_array($article->id, $unlocked, true)) {
return AccessDecision::needPassword();
}
}
$decision = AccessDecision::allow();
$context = [
'article' => $article,
'user' => $user,
];
// Fold every listener result instead of letting the last one win, so a
// plugin can only tighten access and a broken listener cannot re-open it.
foreach (Hook::listeners('article.access') as $listener) {
$result = $listener($decision, $context);
if ($result instanceof AccessDecision) {
$decision = $decision->tightenWith($result);
}
}
return $decision;
}
/**
* HTML safe to expose for the given decision (full body or teaser/description).
*/
public function publicHtml(Article $article, AccessDecision $decision): string
{
if ($decision->isAllow()) {
return $article->renderedHtml();
}
if (filled($decision->teaserHtml)) {
return $decision->teaserHtml;
}
return e((string) ($article->description ?: ''));
}
protected function isPrivileged(Article $article, User $user): bool
{
if ((int) $article->user_id === (int) $user->id) {
return true;
}
return $user->hasRole('admin');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use App\Models\Article;
use Illuminate\Support\Str;
class ArticleExcerpt
{
public function __construct(
protected ArticleAccess $access,
) {}
/**
* Safe list/card excerpt never render full body for restricted articles.
*/
public function forList(Article $article, int $limit = 160): string
{
if (filled($article->description)) {
return Str::limit((string) $article->description, $limit);
}
$decision = $this->access->resolve($article, null, []);
if (! $decision->isAllow()) {
$html = $this->access->publicHtml($article, $decision);
return Str::limit(trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')), $limit);
}
return Str::limit(trim(strip_tags($article->renderedHtml())), $limit);
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use App\Models\Attachment;
use Illuminate\Support\Facades\Cache;
/**
* Attachment embeds:
* - Legacy HTML: [attach=123] or src/href="[attach=123]"
* - Markdown-safe: ![alt](attach:123) or [label](attach:123)
*
* DB stores references only; URLs are resolved at render/preview time.
*/
class AttachEmbed
{
public const LEGACY_PATTERN = '/\[attach=(\d+)\]/i';
public const MD_PROTOCOL = 'attach:';
public function legacyToMarkdownReference(string $content, ?int $articleId = null): string
{
return preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use ($articleId): string {
$id = (int) $matches[1];
$attachment = $this->findAttachment($id, $articleId);
$name = $attachment?->filename ?: "attachment-{$id}";
$alt = pathinfo($name, PATHINFO_FILENAME) ?: $name;
if ($attachment && $this->isImage($attachment)) {
return '!['.$this->escapeMdLabel($alt).']('.self::MD_PROTOCOL.$id.')';
}
return '['.$this->escapeMdLabel($name).']('.self::MD_PROTOCOL.$id.')';
}, $content) ?? $content;
}
/**
* Protect sablog attach tokens before HTML→Markdown conversion.
* Tokens avoid underscores so html-to-markdown won't escape them.
*
* @return array{0: string, 1: array<string, array{id:int, kind:string, role:string}>}
*/
public function protectLegacyTokensForHtmlConversion(string $html, ?int $articleId = null): array
{
$map = [];
// Attribute values become markdown link/image destinations → restore as attach:ID only.
$html = preg_replace_callback(
'/\b(src|href)=([\'"])\[attach=(\d+)\]\2/i',
function (array $matches) use (&$map, $articleId): string {
$id = (int) $matches[3];
$role = 'dest';
$kind = strtolower($matches[1]) === 'src' ? 'image' : 'file';
$token = 'LBATTACHDEST'.$id.'X';
$map[$token] = ['id' => $id, 'kind' => $kind, 'role' => $role, 'article_id' => $articleId];
return $matches[1].'='.$matches[2].$token.$matches[2];
},
$html
) ?? $html;
// Bare tokens become full markdown embeds after conversion.
$html = preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use (&$map, $articleId): string {
$id = (int) $matches[1];
$attachment = $this->findAttachment($id, $articleId);
$kind = ($attachment && $this->isImage($attachment)) ? 'image' : 'file';
$token = 'LBATTACHBARE'.$id.'X';
$map[$token] = ['id' => $id, 'kind' => $kind, 'role' => 'bare', 'article_id' => $articleId];
return $token;
}, $html) ?? $html;
return [$html, $map];
}
/**
* @param array<string, array{id:int, kind:string, role:string}> $map
*/
public function restoreProtectedTokensToMarkdown(string $markdown, array $map): string
{
foreach ($map as $token => $meta) {
$id = (int) $meta['id'];
if (($meta['role'] ?? '') === 'dest') {
$markdown = str_replace($token, self::MD_PROTOCOL.$id, $markdown);
continue;
}
$attachment = $this->findAttachment($id, $meta['article_id'] ?? null);
$name = $attachment?->filename ?: "attachment-{$id}";
$alt = pathinfo($name, PATHINFO_FILENAME) ?: $name;
$replacement = ($meta['kind'] ?? 'file') === 'image'
? '!['.$this->escapeMdLabel($alt).']('.self::MD_PROTOCOL.$id.')'
: '['.$this->escapeMdLabel($name).']('.self::MD_PROTOCOL.$id.')';
$markdown = str_replace($token, $replacement, $markdown);
}
return $markdown;
}
/**
* Render-time only: turn attach:123 into /attachment.php?id=123 for CommonMark.
*/
public function expandMarkdownAttachProtocol(string $markdown): string
{
return preg_replace_callback(
'/\]\(attach:(\d+)\)/i',
fn (array $matches): string => ']('.$this->publicEntryUrl((int) $matches[1]).')',
$markdown
) ?? $markdown;
}
public function hydrateHtml(string $html, ?int $articleId = null): string
{
// 1) Attribute form: src/href="[attach=N]" (must run before bare token replace)
$html = preg_replace_callback(
'/\b(src|href)=([\'"])\[attach=(\d+)\]\2/i',
function (array $matches): string {
$attr = strtolower($matches[1]);
$quote = $matches[2];
$id = (int) $matches[3];
return $attr.'='.$quote.$this->publicEntryUrl($id).$quote;
},
$html
) ?? $html;
// 2) Safety net for attach: protocol left in HTML attributes
$html = preg_replace_callback(
'/\b(href|src)=([\'"])attach:(\d+)\2/i',
function (array $matches): string {
$attr = strtolower($matches[1]);
$quote = $matches[2];
$id = (int) $matches[3];
return $attr.'='.$quote.$this->publicEntryUrl($id).$quote;
},
$html
) ?? $html;
// 3) Bare legacy [attach=id] tokens
$html = preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use ($articleId): string {
$id = (int) $matches[1];
$attachment = $this->findAttachment($id, $articleId);
$url = $this->publicEntryUrl($id);
if ($attachment && $this->isImage($attachment)) {
$alt = e(pathinfo($attachment->filename, PATHINFO_FILENAME) ?: $attachment->filename);
return '<img src="'.e($url).'" alt="'.$alt.'" />';
}
$label = e($attachment?->filename ?: "attachment-{$id}");
return '<a href="'.e($url).'">'.$label.'</a>';
}, $html) ?? $html;
return $html;
}
public function publicEntryUrl(int $attachmentId): string
{
return url('/attachment.php?id='.$attachmentId);
}
protected function findAttachment(int $id, ?int $articleId = null): ?Attachment
{
try {
return Cache::remember("attach-embed:{$id}", 60, function () use ($id) {
return Attachment::query()->find($id);
});
} catch (\Throwable) {
return null;
}
}
protected function isImage(Attachment $attachment): bool
{
if (is_string($attachment->mime) && str_starts_with($attachment->mime, 'image/')) {
return true;
}
$ext = strtolower(pathinfo($attachment->filename, PATHINFO_EXTENSION));
return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true);
}
protected function escapeMdLabel(string $label): string
{
return str_replace(['[', ']', '!'], ['\\[', '\\]', '\\!'], $label);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
final class ContentFormat
{
public const HTML = 'html';
public const MARKDOWN = 'markdown';
public static function all(): array
{
return [self::HTML, self::MARKDOWN];
}
public static function isValid(string $format): bool
{
return in_array($format, self::all(), true);
}
public static function normalize(?string $format, string $fallback = self::HTML): string
{
$format = strtolower(trim((string) $format));
return self::isValid($format) ? $format : $fallback;
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use DOMDocument;
use DOMElement;
use DOMXPath;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
use League\CommonMark\MarkdownConverter;
use League\HTMLToMarkdown\HtmlConverter;
use Mews\Purifier\Facades\Purifier;
class ContentRenderer
{
public function __construct(
protected AttachEmbed $attachEmbed,
) {}
public function toHtml(string $content, string $format, ?int $articleId = null): string
{
return $this->render($content, $format, $articleId)['html'];
}
/**
* @return array{html: string, toc: list<array{level: int, id: string, text: string}>}
*/
public function render(string $content, string $format, ?int $articleId = null): array
{
$format = ContentFormat::normalize($format);
if ($format === ContentFormat::MARKDOWN) {
$content = $this->attachEmbed->legacyToMarkdownReference($content, $articleId);
$content = $this->attachEmbed->expandMarkdownAttachProtocol($content);
$html = $this->markdownToHtml($content);
} else {
$html = $content;
}
$html = $this->attachEmbed->hydrateHtml($html, $articleId);
$html = Purifier::clean($html, 'article');
return $this->applyTocAnchors($html);
}
public function markdownToHtml(string $markdown): string
{
$environment = new Environment([
'html_input' => 'strip',
'allow_unsafe_links' => false,
'heading_permalink' => [
'html_class' => 'heading-permalink',
'id_prefix' => '',
'fragment_prefix' => '',
'insert' => 'none',
'apply_id_to_heading' => true,
'heading_class' => '',
],
]);
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new GithubFlavoredMarkdownExtension);
$environment->addExtension(new HeadingPermalinkExtension);
return (string) (new MarkdownConverter($environment))->convert($markdown);
}
public function htmlToMarkdown(string $html): string
{
$converter = new HtmlConverter([
'strip_tags' => true,
'hard_break' => true,
]);
return trim($converter->convert($html));
}
/**
* Convert sablog HTML article body into Markdown storage form.
* Protects [attach=id] / src="[attach=id]" with placeholders during conversion.
*/
public function convertImportedHtmlToMarkdown(string $html, ?int $articleId = null): string
{
[$protected, $map] = $this->attachEmbed->protectLegacyTokensForHtmlConversion($html, $articleId);
$markdown = $this->htmlToMarkdown($protected);
return $this->attachEmbed->restoreProtectedTokensToMarkdown($markdown, $map);
}
/**
* @return array{html: string, toc: list<array{level: int, id: string, text: string}>}
*/
protected function applyTocAnchors(string $html): array
{
if (trim($html) === '') {
return ['html' => '', 'toc' => []];
}
$document = new DOMDocument;
$previous = libxml_use_internal_errors(true);
$document->loadHTML(
'<?xml encoding="UTF-8"><div id="larablog-toc-root">'.$html.'</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$xpath = new DOMXPath($document);
$nodes = $xpath->query('//*[@id="larablog-toc-root"]//*[self::h2 or self::h3 or self::h4]');
if ($nodes === false) {
return ['html' => $html, 'toc' => []];
}
$toc = [];
$usedIds = [];
foreach ($nodes as $node) {
if (! $node instanceof DOMElement) {
continue;
}
$text = trim(preg_replace('/\s+/u', ' ', $node->textContent ?? '') ?? '');
if ($text === '') {
continue;
}
$level = (int) substr(strtolower($node->tagName), 1);
$id = $node->getAttribute('id');
if ($id === '') {
$id = $this->slugifyHeading($text);
}
$base = $id;
$i = 2;
while (isset($usedIds[$id])) {
$id = $base.'-'.$i;
$i++;
}
$usedIds[$id] = true;
$node->setAttribute('id', $id);
$toc[] = [
'level' => $level,
'id' => $id,
'text' => $text,
];
}
$root = $document->getElementById('larablog-toc-root');
$out = $html;
if ($root instanceof DOMElement) {
$out = '';
foreach ($root->childNodes as $child) {
$out .= $document->saveHTML($child);
}
}
return ['html' => $out, 'toc' => $toc];
}
protected function slugifyHeading(string $text): string
{
$slug = strtolower(trim($text));
$slug = preg_replace('/[^\p{L}\p{N}\-_]+/u', '-', $slug) ?? '';
$slug = trim($slug, '-');
return $slug !== '' ? $slug : 'section';
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use Mews\Purifier\Facades\Purifier;
class HtmlTeaser
{
/**
* Build a teaser from already-rendered HTML.
*
* The result is always a plain-text excerpt and always withholds part of the
* body: when the configured limit covers the whole article we still cut it
* in half, so a gated article can never be served in full through a teaser.
*/
public function truncate(string $html, int $maxChars): string
{
if ($maxChars <= 0 || trim($html) === '') {
return '';
}
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? '');
if ($text === '') {
return '';
}
$length = mb_strlen($text);
$limit = min($maxChars, max(1, (int) floor($length / 2)));
$snippet = mb_substr($text, 0, $limit);
if ($limit < $length) {
$snippet .= '…';
}
return Purifier::clean('<p>'.e($snippet).'</p>', 'article');
}
}
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Models\Attachment;
use App\Settings\GeneralSettings;
use Illuminate\Http\RedirectResponse;
use Throwable;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class AttachmentStorageService
{
public function uploadLocalFileToDisk(
string $localPath,
string $destinationPath,
?string $disk = null,
string $visibility = Attachment::VISIBILITY_PUBLIC,
): Attachment {
if (! is_file($localPath)) {
throw new RuntimeException("Local file [{$localPath}] does not exist.");
}
$disk ??= config('larablog.attachments_disk', 'attachments');
$normalizedPath = ltrim(str_replace('\\', '/', $destinationPath), '/');
$mime = mime_content_type($localPath) ?: null;
$allowed = config('larablog.allowed_attachment_mimes', []);
if (is_string($mime) && $allowed !== [] && ! in_array($mime, $allowed, true)) {
throw new RuntimeException("MIME type [{$mime}] is not allowed for attachments.");
}
$stream = fopen($localPath, 'r');
if ($stream === false) {
throw new RuntimeException("Unable to read local file [{$localPath}].");
}
Storage::disk($disk)->put($normalizedPath, $stream, [
'visibility' => $visibility === Attachment::VISIBILITY_PUBLIC ? 'public' : 'private',
]);
if (is_resource($stream)) {
fclose($stream);
}
return Attachment::query()->create([
'disk' => $disk,
'path' => $normalizedPath,
'filename' => basename($normalizedPath),
'mime' => $mime,
'size' => filesize($localPath) ?: 0,
'checksum' => hash_file('sha256', $localPath) ?: null,
'visibility' => $visibility,
'synced_at' => now(),
]);
}
public function publicUrl(Attachment $attachment): string
{
if ($attachment->visibility !== Attachment::VISIBILITY_PUBLIC) {
return $this->temporaryUrl($attachment);
}
$disk = Storage::disk($attachment->disk);
if (method_exists($disk, 'url')) {
return $disk->url($attachment->path);
}
return $this->temporaryUrl($attachment);
}
public function temporaryUrl(Attachment $attachment, int $minutes = 30): string
{
return Storage::disk($attachment->disk)->temporaryUrl(
$attachment->path,
now()->addMinutes($minutes),
[
'ResponseContentDisposition' => 'attachment; filename="'.addslashes($attachment->filename).'"',
],
);
}
public function resolveRedirectResponseById(int $id, ?string $ip = null): RedirectResponse|Response
{
$attachment = Attachment::query()->find($id);
if ($attachment === null) {
abort(SymfonyResponse::HTTP_NOT_FOUND);
}
return $this->resolveRedirectResponse($attachment, $ip);
}
public function resolveRedirectResponseByLegacyPath(string $legacyPath, ?string $ip = null): RedirectResponse|Response
{
$raw = ltrim(str_replace('\\', '/', $legacyPath), '/');
$normalized = $this->normalizeLegacyPath($legacyPath);
$prefix = $this->attachmentsUrlPrefix();
$withPrefix = ($prefix !== '' && ! Str::startsWith($raw, $prefix.'/'))
? $prefix.'/'.$raw
: $raw;
$candidates = array_values(array_unique(array_filter([$raw, $normalized, $withPrefix])));
$attachment = Attachment::query()
->where(function ($query) use ($candidates) {
$query->whereIn('legacy_filepath', $candidates)
->orWhereIn('path', $candidates);
})
->first();
if ($attachment === null) {
abort(SymfonyResponse::HTTP_NOT_FOUND);
}
return $this->resolveRedirectResponse($attachment, $ip);
}
public function resolveRedirectResponse(Attachment $attachment, ?string $ip = null): RedirectResponse
{
if ($attachment->visibility !== Attachment::VISIBILITY_PUBLIC) {
abort(SymfonyResponse::HTTP_FORBIDDEN);
}
$this->incrementDownloads($attachment, $ip);
$targetUrl = $attachment->visibility === Attachment::VISIBILITY_PUBLIC
? $this->publicUrl($attachment)
: $this->temporaryUrl($attachment);
return redirect()->away($targetUrl, SymfonyResponse::HTTP_FOUND);
}
public function incrementDownloads(Attachment $attachment, ?string $ip = null): void
{
$ip ??= request()->ip() ?? 'unknown';
$lockKey = "attachment-download:{$attachment->id}:{$ip}";
$lock = Cache::lock($lockKey, 60);
if (! $lock->get()) {
return;
}
$attachment->increment('downloads');
}
protected function normalizeLegacyPath(string $legacyPath): string
{
$path = str_replace('\\', '/', $legacyPath);
$path = ltrim($path, '/');
$prefix = $this->attachmentsUrlPrefix();
if ($prefix !== '' && Str::startsWith($path, $prefix.'/')) {
$path = Str::after($path, $prefix.'/');
}
return $path;
}
public function deleteFromDisk(Attachment $attachment): void
{
$disk = Storage::disk($attachment->disk);
if ($attachment->path !== '' && $disk->exists($attachment->path)) {
$disk->delete($attachment->path);
}
if (filled($attachment->thumb_path) && $disk->exists($attachment->thumb_path)) {
$disk->delete($attachment->thumb_path);
}
}
protected function attachmentsUrlPrefix(): string
{
try {
$fromSettings = app(GeneralSettings::class)->attachments_url_prefix;
if (is_string($fromSettings) && $fromSettings !== '') {
return trim($fromSettings, '/');
}
} catch (Throwable) {
// settings unavailable during early boot / migrate
}
return trim((string) config('larablog.attachments_url_prefix', 'attachments'), '/');
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Domain\Plugin;
class Hook
{
/** @var array<string, list<callable>> */
protected static array $listeners = [];
public static function listen(string $event, callable $listener): void
{
static::$listeners[$event][] = $listener;
}
public static function dispatch(string $event, mixed ...$payload): void
{
foreach (static::$listeners[$event] ?? [] as $listener) {
$listener(...$payload);
}
}
/**
* Collect string fragments from listeners (for theme injection points).
*/
public static function gather(string $event, string $initial = '', mixed ...$payload): string
{
$buffer = $initial;
foreach (static::$listeners[$event] ?? [] as $listener) {
$result = $listener($buffer, ...$payload);
if (is_string($result)) {
$buffer = $result;
}
}
return $buffer;
}
/**
* Merge array fragments from listeners (for Filament schema/columns/actions).
*
* @param array<int|string, mixed> $initial
* @return array<int|string, mixed>
*/
public static function collect(string $event, array $initial = [], mixed ...$payload): array
{
$items = $initial;
foreach (static::$listeners[$event] ?? [] as $listener) {
$result = $listener($items, ...$payload);
if (! is_array($result)) {
continue;
}
foreach ($result as $key => $value) {
if (is_int($key)) {
$items[] = $value;
} else {
$items[$key] = $value;
}
}
}
return $items;
}
/**
* Pipe a value through listeners (each may replace it).
*/
public static function filter(string $event, mixed $value, mixed ...$payload): mixed
{
foreach (static::$listeners[$event] ?? [] as $listener) {
$value = $listener($value, ...$payload);
}
return $value;
}
/**
* Registered listeners for an event, for callers that need to fold results
* themselves instead of letting each listener replace the value outright.
*
* @return list<callable>
*/
public static function listeners(string $event): array
{
return static::$listeners[$event] ?? [];
}
public static function flush(): void
{
static::$listeners = [];
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Domain\Plugin;
use App\Models\Plugin;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;
use RuntimeException;
class PluginManager
{
/** @var array<string, array<string, mixed>> */
protected array $discovered = [];
public function discover(): Collection
{
$pluginPath = config('larablog.plugin_path', base_path('plugins'));
if (! is_dir($pluginPath)) {
return collect();
}
$plugins = collect();
foreach (File::directories($pluginPath) as $vendorDirectory) {
foreach (File::directories($vendorDirectory) as $pluginDirectory) {
$manifestPath = $pluginDirectory.'/plugin.json';
if (! is_file($manifestPath)) {
continue;
}
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (! is_array($manifest)) {
continue;
}
$name = (string) ($manifest['name'] ?? basename($pluginDirectory));
$relativePath = str_replace(base_path().'/', '', $pluginDirectory);
$plugins->put($name, array_merge($manifest, [
'name' => $name,
'path' => $pluginDirectory,
'relative_path' => $relativePath,
'provider' => $manifest['provider'] ?? null,
'requires' => array_values(array_filter(
array_map('strval', (array) ($manifest['requires'] ?? [])),
fn (string $item): bool => $item !== '',
)),
'optional' => array_values(array_filter(
array_map('strval', (array) ($manifest['optional'] ?? [])),
fn (string $item): bool => $item !== '',
)),
'docs' => (string) ($manifest['docs'] ?? 'README.md'),
]));
}
}
$this->discovered = $plugins->all();
return $plugins;
}
public function syncDiscoveredPlugins(): Collection
{
$discovered = $this->discover();
foreach ($discovered as $name => $manifest) {
Plugin::query()->updateOrCreate(
['name' => $name],
[
'version' => (string) ($manifest['version'] ?? '1.0.0'),
'path' => (string) ($manifest['relative_path'] ?? $manifest['path']),
],
);
}
return $discovered;
}
public function isEnabled(string $name): bool
{
return Plugin::query()
->where('name', $name)
->where('enabled', true)
->exists();
}
/**
* @return list<string>
*/
public function missingRequires(string $name): array
{
$manifest = $this->discover()->get($name);
if ($manifest === null) {
throw new InvalidArgumentException("Plugin [{$name}] is not installed.");
}
$missing = [];
foreach ((array) ($manifest['requires'] ?? []) as $required) {
if (! $this->isEnabled((string) $required)) {
$missing[] = (string) $required;
}
}
return $missing;
}
public function enable(string $name): Plugin
{
$missing = $this->missingRequires($name);
if ($missing !== []) {
throw new RuntimeException(
__('admin.messages.plugin_requires', [
'plugin' => $name,
'requires' => implode(', ', $missing),
])
);
}
$plugin = $this->findPluginRecord($name);
$plugin->update(['enabled' => true]);
return $plugin->refresh();
}
public function disable(string $name): Plugin
{
$dependents = $this->enabledDependentsOf($name);
if ($dependents !== []) {
throw new RuntimeException(
__('admin.messages.plugin_required_by', [
'plugin' => $name,
'dependents' => implode(', ', $dependents),
])
);
}
$plugin = $this->findPluginRecord($name);
$plugin->update(['enabled' => false]);
return $plugin->refresh();
}
/**
* @return list<string>
*/
public function enabledDependentsOf(string $name): array
{
$dependents = [];
foreach ($this->discover() as $candidate => $manifest) {
if ($candidate === $name || ! $this->isEnabled((string) $candidate)) {
continue;
}
$requires = array_map('strval', (array) ($manifest['requires'] ?? []));
if (in_array($name, $requires, true)) {
$dependents[] = (string) $candidate;
}
}
return $dependents;
}
public function docsPath(string $name): ?string
{
$manifest = $this->discover()->get($name);
if ($manifest === null) {
return null;
}
$docs = trim((string) ($manifest['docs'] ?? 'README.md'));
if ($docs === '' || str_starts_with($docs, '/') || preg_match('#(^|[\\\\/])\.\.([\\\\/]|$)#', $docs) === 1) {
return null;
}
$root = realpath((string) $manifest['path']);
$path = realpath(rtrim((string) $manifest['path'], '/').'/'.$docs);
if ($root === false || $path === false || ! is_file($path)) {
return null;
}
// Never read outside the plugin directory, even via symlinks.
if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) {
return null;
}
return $path;
}
public function readDocs(string $name): ?string
{
$path = $this->docsPath($name);
return $path !== null ? (string) file_get_contents($path) : null;
}
public function registerEnabledProviders(): void
{
$discovered = $this->discover();
Plugin::query()
->where('enabled', true)
->get()
->each(function (Plugin $plugin) use ($discovered): void {
$manifest = $discovered->get($plugin->name);
if ($manifest === null) {
return;
}
$providerClass = $manifest['provider'] ?? null;
if (! is_string($providerClass) || ! class_exists($providerClass)) {
return;
}
if (! is_subclass_of($providerClass, ServiceProvider::class)) {
return;
}
app()->register($providerClass);
});
}
protected function findPluginRecord(string $name): Plugin
{
$plugin = Plugin::query()->where('name', $name)->first();
if ($plugin === null) {
throw new InvalidArgumentException("Plugin [{$name}] is not installed.");
}
return $plugin;
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Domain\Seo;
use App\Models\Article;
use App\Settings\GeneralSettings;
use App\Settings\SeoSettings;
class SeoPresenter
{
public function __construct(
protected GeneralSettings $generalSettings,
protected SeoSettings $seoSettings,
) {}
public function title(?string $title = null): string
{
$suffix = $this->seoSettings->meta_title_suffix;
if ($title === null || $title === '') {
return $this->generalSettings->site_name;
}
return $suffix
? "{$title} {$suffix}"
: "{$title} - {$this->generalSettings->site_name}";
}
public function description(?string $description = null): string
{
return $description
?: ($this->seoSettings->default_description
?: ($this->generalSettings->site_description ?? ''));
}
public function keywords(?string $keywords = null): ?string
{
return $keywords ?: $this->seoSettings->default_keywords;
}
/**
* @return array{title: string, description: string, keywords: ?string, canonical: string, og: array<string, string>, twitter: array<string, string>, jsonld: array<string, mixed>}
*/
public function forHome(): array
{
$canonical = rtrim($this->generalSettings->site_url ?: url('/'), '/') ?: url('/');
return [
'title' => $this->title(),
'description' => $this->description(),
'keywords' => $this->keywords(),
'canonical' => $canonical,
'og' => $this->openGraph(),
'twitter' => $this->twitterCards(),
'jsonld' => $this->jsonLd(),
];
}
/**
* @return array{title: string, description: string, keywords: ?string, canonical: string, og: array<string, string>, twitter: array<string, string>, jsonld: array<string, mixed>}
*/
public function forArticle(Article $article): array
{
$canonical = url('/show-'.$article->id.'.shtml');
return [
'title' => $this->title($article->title),
'description' => $this->description($article->description),
'keywords' => $this->keywords($article->keywords),
'canonical' => $canonical,
'og' => $this->openGraph($article),
'twitter' => $this->twitterCards($article),
'jsonld' => $this->jsonLd($article),
];
}
/**
* @return array<string, string>
*/
public function openGraph(?Article $article = null): array
{
$title = $this->title($article?->title);
$description = $this->description($article?->description);
$url = $article
? url('/show-'.$article->id.'.shtml')
: ($this->generalSettings->site_url ?: url('/'));
return array_filter([
'og:title' => $title,
'og:description' => $description,
'og:url' => $url,
'og:type' => $article ? 'article' : 'website',
'og:site_name' => $this->generalSettings->site_name,
'og:locale' => 'zh_CN',
]);
}
/**
* @return array<string, string>
*/
public function twitterCards(?Article $article = null): array
{
return array_filter([
'twitter:card' => 'summary',
'twitter:title' => $this->title($article?->title),
'twitter:description' => $this->description($article?->description),
]);
}
/**
* @return array<string, mixed>
*/
public function jsonLd(?Article $article = null): array
{
if (! $this->seoSettings->json_ld_enabled) {
return [];
}
if ($article === null) {
return [
'@context' => 'https://schema.org',
'@type' => 'WebSite',
'name' => $this->generalSettings->site_name,
'url' => $this->generalSettings->site_url ?: url('/'),
'description' => $this->description(),
'potentialAction' => [
'@type' => 'SearchAction',
'target' => url('/search.shtml').'?keywords={search_term_string}',
'query-input' => 'required name=search_term_string',
],
];
}
return [
'@context' => 'https://schema.org',
'@type' => 'BlogPosting',
'headline' => $article->title,
'description' => $this->description($article->description),
'datePublished' => optional($article->published_at)?->toIso8601String(),
'dateModified' => optional($article->updated_at)?->toIso8601String(),
'author' => [
'@type' => 'Person',
'name' => $article->user?->name ?? $article->user?->username,
],
'mainEntityOfPage' => url('/show-'.$article->id.'.shtml'),
];
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Domain\Theme;
use App\Domain\Plugin\Hook;
use App\Settings\SnippetSettings;
use Throwable;
final class RegistersSnippetSlots
{
public static function boot(): void
{
try {
$snippets = app(SnippetSettings::class);
$map = $snippets->slotMap();
} catch (Throwable) {
// Settings group may be missing until migrations finish.
return;
}
foreach ($map as $slot => $html) {
if (! is_string($html) || trim($html) === '') {
continue;
}
$payload = $html;
Hook::listen(ThemeSlot::event($slot), function (string $buffer) use ($payload): string {
return $buffer.$payload;
});
}
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Domain\Theme;
use App\Settings\GeneralSettings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
class ThemeManager
{
protected string $activeTheme = 'default';
/** @var array<string, array<string, mixed>> */
protected array $discovered = [];
public function discover(): Collection
{
$themePath = config('larablog.theme_path', base_path('themes'));
if (! is_dir($themePath)) {
return collect();
}
$this->discovered = collect(File::directories($themePath))
->mapWithKeys(function (string $directory) use ($themePath): array {
$slug = basename($directory);
$manifestPath = $directory.'/theme.json';
$manifest = [];
if (is_file($manifestPath)) {
$decoded = json_decode((string) file_get_contents($manifestPath), true);
$manifest = is_array($decoded) ? $decoded : [];
}
$theme = array_merge([
'slug' => $slug,
'name' => $slug,
'path' => $directory,
], $manifest);
$fallback = $slug === 'default' ? null : $themePath.'/default/views';
$theme['slot_report'] = ThemeSlotReport::analyze($slug, $manifest, $directory, $fallback);
return [$slug => $theme];
})
->all();
return collect($this->discovered);
}
/**
* Slots declared in theme.json (expanded). Empty if undeclared.
*
* @return list<string>
*/
public function declaredSlots(?string $slug = null): array
{
$slug ??= $this->active();
$theme = $this->discover()->get($slug);
if (! is_array($theme)) {
return [];
}
return $theme['slot_report']['declared'] ?? [];
}
/**
* @return array<string, mixed>
*/
public function slotReport(?string $slug = null): array
{
$slug ??= $this->active();
$theme = $this->discover()->get($slug);
if (! is_array($theme) || ! isset($theme['slot_report'])) {
return ThemeSlotReport::analyze($slug, [], $this->path($slug));
}
return $theme['slot_report'];
}
public function supportsSlot(string $slot, ?string $slug = null): bool
{
$declared = $this->declaredSlots($slug);
if ($declared === []) {
// Undeclared themes: assume unknown (not supported for soft checks).
return false;
}
return in_array($slot, $declared, true);
}
public function setActive(string $slug): void
{
$themes = $this->discover();
if (! $themes->has($slug)) {
throw new InvalidArgumentException("Theme [{$slug}] was not found.");
}
$this->activeTheme = $slug;
$settings = $this->settings();
if ($settings !== null) {
$settings->active_theme = $slug;
$settings->save();
}
$this->registerViewNamespaces();
}
public function active(): string
{
try {
$settings = app(GeneralSettings::class);
if (filled($settings->active_theme)) {
return $settings->active_theme;
}
} catch (Throwable) {
// Settings may be unavailable or incomplete during migrations.
}
return $this->activeTheme;
}
public function path(?string $slug = null): string
{
$slug ??= $this->active();
return rtrim(config('larablog.theme_path', base_path('themes')), '/').'/'.$slug;
}
public function registerViewNamespaces(): void
{
try {
$themePath = config('larablog.theme_path', base_path('themes'));
$defaultPath = $themePath.'/default';
if (is_dir($defaultPath)) {
View::addNamespace('theme', $defaultPath.'/views');
}
$active = $this->active();
$activePath = $this->path($active).'/views';
if ($active !== 'default' && is_dir($activePath)) {
View::prependNamespace('theme', $activePath);
}
} catch (Throwable) {
// Ignore during incomplete settings/bootstrap states.
}
}
public function assetUrl(string $path): string
{
$trimmed = ltrim($path, '/');
return url('/themes/'.$this->active().'/'.$trimmed);
}
public function ensureActiveThemeExists(): void
{
if (! is_dir($this->path($this->active()))) {
if ($this->active() !== 'default' && is_dir($this->path('default'))) {
$this->activeTheme = 'default';
return;
}
throw new RuntimeException('No valid theme directory found.');
}
}
protected function settings(): ?GeneralSettings
{
try {
return app(GeneralSettings::class);
} catch (Throwable) {
return null;
}
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Domain\Theme;
use App\Domain\Plugin\Hook;
/**
* Named injection points for themes / plugins / site snippets.
*
* Themes call ThemeSlot::render('head') (or @themeslot('head')).
* Plugins listen on Hook event "theme.{slot}".
* Admin SnippetSettings also appends into these slots at boot.
*/
final class ThemeSlot
{
public const HEAD = 'head';
public const BODY_START = 'body_start';
public const BODY_END = 'body_end';
public const HEADER_AFTER = 'header_after';
public const NAV_AFTER = 'nav_after';
public const CONTENT_BEFORE = 'content_before';
public const CONTENT_AFTER = 'content_after';
public const ARTICLE_TOP = 'article_top';
public const ARTICLE_BOTTOM = 'article_bottom';
public const SIDEBAR_BEFORE = 'sidebar_before';
public const SIDEBAR = 'sidebar';
public const SIDEBAR_AFTER = 'sidebar_after';
public const FOOTER_BEFORE = 'footer_before';
public const FOOTER_AFTER = 'footer_after';
/** @return list<string> */
public static function all(): array
{
return [
self::HEAD,
self::BODY_START,
self::BODY_END,
self::HEADER_AFTER,
self::NAV_AFTER,
self::CONTENT_BEFORE,
self::CONTENT_AFTER,
self::ARTICLE_TOP,
self::ARTICLE_BOTTOM,
self::SIDEBAR_BEFORE,
self::SIDEBAR,
self::SIDEBAR_AFTER,
self::FOOTER_BEFORE,
self::FOOTER_AFTER,
];
}
public static function event(string $slot): string
{
return 'theme.'.$slot;
}
public static function render(string $slot): string
{
return Hook::gather(self::event($slot), '');
}
/**
* Soft contract for theme authors & admin UI.
* Sizes are recommendations only the active theme's CSS wins.
*
* @return array<string, array{label: string, place: string, size: string, multi: bool}>
*/
public static function catalog(): array
{
$catalog = [];
foreach (self::all() as $slot) {
$catalog[$slot] = [
'label' => __('admin.slots.'.$slot.'.label'),
'place' => __('admin.slots.'.$slot.'.place'),
'size' => __('admin.slots.'.$slot.'.size'),
'multi' => true,
];
}
return $catalog;
}
public static function hint(string $slot): string
{
$meta = self::catalog()[$slot] ?? null;
if ($meta === null) {
return __('admin.slots.custom', ['slot' => $slot]);
}
return __('admin.slots.hint', [
'place' => $meta['place'],
'size' => $meta['size'],
'slot' => $slot,
]);
}
}
+222
View File
@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Domain\Theme;
use Illuminate\Support\Facades\File;
/**
* Compares theme.json "slots" with standard catalog + Blade usage.
*
* @phpstan-type Report array{
* slug: string,
* declared: list<string>,
* scanned: list<string>,
* status: 'full'|'partial'|'undeclared'|'mismatch',
* missing_standard: list<string>,
* declared_but_unused: list<string>,
* used_but_undeclared: list<string>,
* label: string,
* }
*/
final class ThemeSlotReport
{
/**
* @param array<string, mixed> $manifest
* @return Report
*/
public static function analyze(
string $slug,
array $manifest,
string $themePath,
?string $fallbackViewsPath = null,
): array {
$declared = self::normalizeDeclared($manifest['slots'] ?? null);
$scanned = self::scanViewsWithFallback($themePath.'/views', $fallbackViewsPath);
$standard = ThemeSlot::all();
if ($declared === null) {
return [
'slug' => $slug,
'declared' => [],
'scanned' => $scanned,
'status' => 'undeclared',
'missing_standard' => $standard,
'declared_but_unused' => [],
'used_but_undeclared' => $scanned,
'label' => __('admin.slots.status_undeclared'),
];
}
$missingStandard = array_values(array_diff($standard, $declared));
$declaredButUnused = array_values(array_diff($declared, $scanned));
$usedButUndeclared = array_values(array_diff($scanned, $declared));
if ($missingStandard === [] && $declaredButUnused === [] && $usedButUndeclared === []) {
$status = 'full';
$label = __('admin.slots.status_full');
} elseif ($missingStandard !== []) {
$status = 'partial';
$label = __('admin.slots.status_partial', ['count' => count($missingStandard)]);
} else {
$status = 'mismatch';
$label = __('admin.slots.status_mismatch');
}
return [
'slug' => $slug,
'declared' => $declared,
'scanned' => $scanned,
'status' => $status,
'missing_standard' => $missingStandard,
'declared_but_unused' => $declaredButUnused,
'used_but_undeclared' => $usedButUndeclared,
'label' => $label,
];
}
/**
* @return list<string>|null null = key absent
*/
public static function normalizeDeclared(mixed $slots): ?array
{
if ($slots === null) {
return null;
}
if ($slots === '*' || $slots === 'all') {
return ThemeSlot::all();
}
if (! is_array($slots)) {
return [];
}
$out = [];
foreach ($slots as $item) {
if (! is_string($item) || $item === '') {
continue;
}
if ($item === '*' || $item === 'all') {
foreach (ThemeSlot::all() as $standard) {
$out[$standard] = true;
}
continue;
}
$out[$item] = true;
}
$list = array_keys($out);
sort($list);
return $list;
}
/** @return list<string> */
public static function scanViewsWithFallback(string $viewsPath, ?string $fallbackViewsPath): array
{
$found = [];
foreach (self::scanViews($viewsPath) as $slot) {
$found[$slot] = true;
}
if ($fallbackViewsPath !== null && is_dir($fallbackViewsPath) && realpath($fallbackViewsPath) !== realpath($viewsPath)) {
// Count slots from default views that this theme does not override.
$ownFiles = self::relativeBladeFiles($viewsPath);
foreach (array_keys(self::relativeBladeFiles($fallbackViewsPath)) as $relative) {
if (isset($ownFiles[$relative])) {
continue;
}
$path = $fallbackViewsPath.'/'.$relative;
foreach (self::extractSlotsFromFile($path) as $slot) {
$found[$slot] = true;
}
}
}
$list = array_keys($found);
sort($list);
return $list;
}
/** @return list<string> */
public static function scanViews(string $viewsPath): array
{
if (! is_dir($viewsPath)) {
return [];
}
$found = [];
foreach (array_keys(self::relativeBladeFiles($viewsPath)) as $relative) {
foreach (self::extractSlotsFromFile($viewsPath.'/'.$relative) as $slot) {
$found[$slot] = true;
}
}
$list = array_keys($found);
sort($list);
return $list;
}
/** @return array<string, true> relative path => true */
private static function relativeBladeFiles(string $viewsPath): array
{
if (! is_dir($viewsPath)) {
return [];
}
$out = [];
$root = realpath($viewsPath) ?: $viewsPath;
$root = rtrim(str_replace('\\', '/', $root), '/');
foreach (File::allFiles($viewsPath) as $file) {
if (! str_ends_with(strtolower($file->getFilename()), '.blade.php')) {
continue;
}
$full = str_replace('\\', '/', $file->getPathname());
$real = realpath($full) ?: $full;
$real = str_replace('\\', '/', $real);
$relative = str_starts_with($real, $root.'/')
? substr($real, strlen($root) + 1)
: $file->getFilename();
$out[$relative] = true;
}
return $out;
}
/** @return list<string> */
private static function extractSlotsFromFile(string $path): array
{
if (! is_file($path)) {
return [];
}
$contents = (string) file_get_contents($path);
$found = [];
if (preg_match_all("/@themeslot\\(\\s*['\"]([a-z0-9_]+)['\"]\\s*\\)/i", $contents, $m)) {
foreach ($m[1] as $slot) {
$found[$slot] = true;
}
}
if (preg_match_all("/ThemeSlot::render\\(\\s*['\"]([a-z0-9_]+)['\"]\\s*\\)/i", $contents, $m2)) {
foreach ($m2[1] as $slot) {
$found[$slot] = true;
}
}
// Fully-qualified calls: \App\Domain\Theme\ThemeSlot::render('sidebar')
if (preg_match_all("/\\\\ThemeSlot::render\\(\\s*['\"]([a-z0-9_]+)['\"]\\s*\\)/i", $contents, $m3)) {
foreach ($m3[1] as $slot) {
$found[$slot] = true;
}
}
return array_keys($found);
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Filament\Concerns;
trait HasTranslatedLabels
{
/** Navigation / plural key under admin.nav and admin.models, e.g. articles */
abstract protected static function navKey(): string;
/** Singular model key under admin.models, e.g. article */
abstract protected static function modelKey(): string;
/** Group key under admin.groups: content|system|plugins */
protected static function groupKey(): string
{
return 'content';
}
public static function getNavigationLabel(): string
{
return __('admin.nav.'.static::navKey());
}
public static function getModelLabel(): string
{
return __('admin.models.'.static::modelKey());
}
public static function getPluralModelLabel(): string
{
return __('admin.models.'.static::navKey());
}
public static function getNavigationGroup(): ?string
{
return __('admin.groups.'.static::groupKey());
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Domain\Plugin\PluginManager;
use App\Models\Plugin;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use UnitEnum;
class ManagePlugins extends Page
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPuzzlePiece;
protected static ?int $navigationSort = 81;
protected string $view = 'filament.pages.manage-plugins';
/** @var array<int, array<string, mixed>> */
public array $plugins = [];
public static function getNavigationGroup(): ?string
{
return __('admin.groups.system');
}
public static function getNavigationLabel(): string
{
return __('admin.nav.plugins');
}
public function getTitle(): string
{
return __('admin.pages.plugins_title');
}
public function mount(PluginManager $manager): void
{
$manager->syncDiscoveredPlugins();
$this->reload($manager);
}
public function enable(string $name, PluginManager $manager): void
{
try {
$manager->enable($name);
$this->reload($manager);
Notification::make()->title(__('admin.pages.enable').''.$name)->success()->send();
} catch (\Throwable $e) {
Notification::make()->title($e->getMessage())->danger()->send();
}
}
public function disable(string $name, PluginManager $manager): void
{
try {
$manager->disable($name);
$this->reload($manager);
Notification::make()->title(__('admin.pages.disable').''.$name)->success()->send();
} catch (\Throwable $e) {
Notification::make()->title($e->getMessage())->danger()->send();
}
}
public function showDocs(string $name, PluginManager $manager): void
{
$docs = $manager->readDocs($name);
if ($docs === null || trim($docs) === '') {
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();
}
protected function reload(PluginManager $manager): void
{
$discovered = $manager->discover();
$records = Plugin::query()->get()->keyBy('name');
$accents = ['#0f8a7a', '#c45c26', '#2563eb', '#7c3aed', '#db2777', '#0891b2'];
$this->plugins = $discovered->values()->map(function (array $manifest, int $index) use ($records, $accents, $manager) {
$name = (string) $manifest['name'];
$titleKey = 'admin.plugins.'.$name.'.title';
$descKey = 'admin.plugins.'.$name.'.description';
$record = $records->get($name);
$requires = (array) ($manifest['requires'] ?? []);
return [
'name' => $name,
'title' => __($titleKey) !== $titleKey ? __($titleKey) : ($manifest['title'] ?? $name),
'version' => $manifest['version'] ?? '1.0.0',
'description' => __($descKey) !== $descKey ? __($descKey) : ($manifest['description'] ?? ''),
'enabled' => (bool) ($record?->enabled),
'requires' => $requires,
'has_docs' => $manager->docsPath($name) !== null,
'accent' => $accents[$index % count($accents)],
];
})->all();
}
protected function getHeaderActions(): array
{
return [
Action::make('sync')
->label(__('admin.pages.plugins_sync'))
->action(function (PluginManager $manager): void {
$manager->syncDiscoveredPlugins();
$this->reload($manager);
Notification::make()->title(__('admin.pages.plugins_sync'))->success()->send();
}),
];
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Domain\Theme\ThemeManager;
use App\Settings\GeneralSettings;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Artisan;
use UnitEnum;
class ManageThemes extends Page
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSwatch;
protected static ?int $navigationSort = 80;
protected string $view = 'filament.pages.manage-themes';
public string $activeTheme = 'default';
/** @var array<int, array<string, mixed>> */
public array $themes = [];
public static function getNavigationGroup(): ?string
{
return __('admin.groups.system');
}
public static function getNavigationLabel(): string
{
return __('admin.nav.themes');
}
public function getTitle(): string
{
return __('admin.pages.themes_title');
}
public function mount(ThemeManager $themes, GeneralSettings $settings): void
{
$this->reload($themes, $settings);
}
public function activate(string $slug, ThemeManager $themes, GeneralSettings $settings): void
{
$themes->setActive($slug);
Artisan::call('themes:publish', ['theme' => $slug]);
$this->reload($themes, $settings);
$report = $themes->slotReport($slug);
$notification = Notification::make()->title(__('admin.pages.activate').''.$slug);
if (($report['status'] ?? '') === 'full') {
$notification->success()->send();
return;
}
$notification
->warning()
->body(__('admin.messages.theme_slots_warn', [
'label' => $report['label'] ?? __('admin.slots.status_undeclared'),
]))
->send();
}
protected function reload(ThemeManager $themes, GeneralSettings $settings): void
{
$this->activeTheme = $settings->active_theme ?: 'default';
$this->themes = $themes->discover()->map(function (array $theme) use ($themes) {
$slug = (string) ($theme['slug'] ?? $theme['name'] ?? '');
$titleKey = 'admin.themes.'.$slug.'.title';
$descKey = 'admin.themes.'.$slug.'.description';
$previewFile = base_path('themes/'.$slug.'/assets/preview.svg');
$report = $theme['slot_report'] ?? $themes->slotReport($slug);
return [
'slug' => $slug,
'title' => __($titleKey) !== $titleKey ? __($titleKey) : ($theme['title'] ?? $slug),
'description' => __($descKey) !== $descKey ? __($descKey) : ($theme['description'] ?? ''),
'version' => $theme['version'] ?? '1.0.0',
'preview' => is_file($previewFile)
? url('/themes/'.$slug.'/preview.svg').'?v='.filemtime($previewFile)
: null,
'slots_status' => $report['status'] ?? 'undeclared',
'slots_label' => $report['label'] ?? __('admin.slots.status_undeclared'),
'slots_missing' => $report['missing_standard'] ?? [],
'slots_declared_count' => count($report['declared'] ?? []),
];
})->values()->all();
}
protected function getHeaderActions(): array
{
return [
Action::make('refresh')
->label(__('admin.pages.themes_refresh'))
->action(fn (ThemeManager $themes, GeneralSettings $settings) => $this->reload($themes, $settings)),
Action::make('publish')
->label(__('admin.pages.themes_publish'))
->action(function (): void {
Artisan::call('themes:publish');
Notification::make()->title(__('admin.pages.themes_publish'))->success()->send();
}),
];
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use BackedEnum;
use Filament\Support\Icons\Heroicon;
class MembershipPluginPage extends PluginSkeletonPage
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedIdentification;
protected static ?int $navigationSort = 102;
protected static function pluginName(): string
{
return 'larablog/membership';
}
protected static function navKey(): string
{
return 'membership';
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use BackedEnum;
use Filament\Support\Icons\Heroicon;
class PaymentPluginPage extends PluginSkeletonPage
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
protected static ?int $navigationSort = 101;
protected static function pluginName(): string
{
return 'larablog/payment';
}
protected static function navKey(): string
{
return 'payment';
}
public static function shouldRegisterNavigation(): bool
{
// UI is owned by plugins/larablog/payment Filament resources/pages.
return false;
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use BackedEnum;
use Filament\Support\Icons\Heroicon;
class PluginMarketplacePage extends PluginSkeletonPage
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShoppingBag;
protected static ?int $navigationSort = 103;
protected static function pluginName(): string
{
return 'larablog/plugin-marketplace';
}
protected static function navKey(): string
{
return 'plugin_marketplace';
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\Plugin;
use BackedEnum;
use Filament\Pages\Page;
abstract class PluginSkeletonPage extends Page
{
protected static ?int $navigationSort = 100;
protected string $view = 'filament.pages.plugin-skeleton';
abstract protected static function pluginName(): string;
abstract protected static function navKey(): string;
public static function getNavigationGroup(): ?string
{
return __('admin.groups.plugins');
}
public static function getNavigationLabel(): string
{
return __('admin.nav.'.static::navKey());
}
public function getTitle(): string
{
return static::translatedTitle();
}
public function getHeading(): string
{
return static::translatedTitle();
}
protected static function translatedTitle(): string
{
$key = 'admin.plugins.'.static::pluginName().'.title';
return __($key) !== $key ? __($key) : __('admin.nav.'.static::navKey());
}
protected static function translatedDescription(): string
{
$key = 'admin.plugins.'.static::pluginName().'.description';
return __($key) !== $key ? __($key) : '';
}
public static function shouldRegisterNavigation(): bool
{
return Plugin::query()
->where('name', static::pluginName())
->where('enabled', true)
->exists();
}
public function getViewData(): array
{
return [
'pluginName' => static::pluginName(),
'pluginTitle' => static::translatedTitle(),
'pluginDescription' => static::translatedDescription(),
'enabled' => Plugin::query()
->where('name', static::pluginName())
->where('enabled', true)
->exists(),
];
}
}
+357
View File
@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Domain\Blog\ContentFormat;
use App\Domain\Theme\ThemeSlot;
use App\Settings\AiSettings;
use App\Settings\BlogSettings;
use App\Settings\CommentSettings;
use App\Settings\GeneralSettings;
use App\Settings\SeoSettings;
use App\Settings\SnippetSettings;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
/**
* @property-read Schema $form
*/
class SiteSettings extends Page
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCog6Tooth;
protected static ?int $navigationSort = 85;
public static function getNavigationGroup(): ?string
{
return __('admin.groups.system');
}
public static function getNavigationLabel(): string
{
return __('admin.nav.settings');
}
public function getTitle(): string
{
return __('admin.pages.settings_title');
}
/** @var array<string, mixed>|null */
public ?array $data = [];
public function mount(
GeneralSettings $general,
SeoSettings $seo,
AiSettings $ai,
BlogSettings $blog,
CommentSettings $comment,
SnippetSettings $snippets,
): void {
$this->form->fill([
'site_name' => $general->site_name,
'site_url' => $general->site_url,
'site_description' => $general->site_description,
'active_theme' => $general->active_theme,
'attachments_url_prefix' => $general->attachments_url_prefix,
'default_content_format' => $general->default_content_format,
'import_content_format' => $general->import_content_format,
'import_convert_html_to_markdown' => $general->import_convert_html_to_markdown,
'posts_per_page' => $blog->posts_per_page,
'allow_comments' => $blog->allow_comments,
'comment_order' => $blog->comment_order,
'show_views' => $blog->show_views,
'show_author' => $blog->show_author,
'date_format' => $blog->date_format,
'close_comments_on_old_posts' => $blog->close_comments_on_old_posts,
'close_comments_days' => $blog->close_comments_days,
'guest_can_comment' => $comment->guest_can_comment,
'require_moderation' => $comment->require_moderation,
'rate_limit_per_minute' => $comment->rate_limit_per_minute,
'enable_website_field' => $comment->enable_website_field,
'forbidden_words' => $comment->forbidden_words,
'meta_title_suffix' => $seo->meta_title_suffix,
'default_description' => $seo->default_description,
'default_keywords' => $seo->default_keywords,
'json_ld_enabled' => $seo->json_ld_enabled,
'robots_index' => $seo->robots_index,
'twitter_site' => $seo->twitter_site,
'canonical_force_https' => $seo->canonical_force_https,
'ai_provider' => $ai->provider,
'ai_api_base_url' => $ai->api_base_url,
'ai_api_key' => $ai->api_key,
'ai_model' => $ai->model,
'comment_moderation_enabled' => $ai->comment_moderation_enabled,
'content_optimization_enabled' => $ai->content_optimization_enabled,
'analytics_head' => $snippets->analytics_head,
'body_end' => $snippets->body_end,
'ads_sidebar' => $snippets->ads_sidebar,
'ads_article_top' => $snippets->ads_article_top,
'ads_article_bottom' => $snippets->ads_article_bottom,
'header_banner' => $snippets->header_banner,
'custom_links_html' => $snippets->custom_links_html,
'footer_html' => $snippets->footer_html,
]);
}
public function defaultForm(Schema $schema): Schema
{
return $schema->statePath('data');
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Tabs::make('settings')
->persistTabInQueryString()
->columnSpanFull()
->tabs([
Tab::make(__('admin.settings.tabs.site'))
->icon(Heroicon::OutlinedGlobeAlt)
->schema([
TextInput::make('site_name')->label(__('admin.settings.site_name'))->required(),
TextInput::make('site_url')->label(__('admin.settings.site_url'))->required()->url(),
Textarea::make('site_description')->label(__('admin.settings.site_description'))->rows(3),
TextInput::make('active_theme')->label(__('admin.settings.active_theme'))->required(),
TextInput::make('attachments_url_prefix')->label(__('admin.settings.attachments_url_prefix'))->required(),
Select::make('default_content_format')
->label(__('admin.settings.default_content_format'))
->options([
ContentFormat::MARKDOWN => __('admin.options.markdown'),
ContentFormat::HTML => __('admin.options.html'),
])->required(),
Select::make('import_content_format')
->label(__('admin.settings.import_content_format'))
->options([
ContentFormat::HTML => __('admin.options.html'),
ContentFormat::MARKDOWN => __('admin.options.markdown'),
])->required(),
Toggle::make('import_convert_html_to_markdown')
->label(__('admin.settings.import_convert_html_to_markdown')),
]),
Tab::make(__('admin.settings.tabs.reading'))
->icon(Heroicon::OutlinedNewspaper)
->schema([
TextInput::make('posts_per_page')->label(__('admin.settings.posts_per_page'))->numeric()->required()->minValue(1)->maxValue(100),
TextInput::make('date_format')->label(__('admin.settings.date_format'))->required(),
Toggle::make('show_views')->label(__('admin.settings.show_views')),
Toggle::make('show_author')->label(__('admin.settings.show_author')),
Toggle::make('allow_comments')->label(__('admin.settings.allow_comments')),
Select::make('comment_order')
->label(__('admin.settings.comment_order'))
->options([
'asc' => __('admin.options.comment_order_asc'),
'desc' => __('admin.options.comment_order_desc'),
])
->required(),
Toggle::make('close_comments_on_old_posts')->label(__('admin.settings.close_comments_on_old_posts')),
TextInput::make('close_comments_days')->label(__('admin.settings.close_comments_days'))->numeric()->minValue(1),
]),
Tab::make(__('admin.settings.tabs.comments'))
->icon(Heroicon::OutlinedChatBubbleLeftRight)
->schema([
Toggle::make('guest_can_comment')->label(__('admin.settings.guest_can_comment')),
Toggle::make('require_moderation')->label(__('admin.settings.require_moderation')),
TextInput::make('rate_limit_per_minute')->label(__('admin.settings.rate_limit_per_minute'))->numeric()->minValue(1),
Toggle::make('enable_website_field')->label(__('admin.settings.enable_website_field')),
Textarea::make('forbidden_words')->label(__('admin.settings.forbidden_words'))->rows(3),
]),
Tab::make(__('admin.settings.tabs.seo'))
->icon(Heroicon::OutlinedMagnifyingGlass)
->schema([
TextInput::make('meta_title_suffix')->label(__('admin.settings.meta_title_suffix')),
Textarea::make('default_description')->label(__('admin.settings.default_description'))->rows(3),
TextInput::make('default_keywords')->label(__('admin.settings.default_keywords')),
Toggle::make('json_ld_enabled')->label(__('admin.settings.json_ld_enabled')),
Toggle::make('robots_index')->label(__('admin.settings.robots_index')),
TextInput::make('twitter_site')->label(__('admin.settings.twitter_site')),
Toggle::make('canonical_force_https')->label(__('admin.settings.canonical_force_https')),
]),
Tab::make(__('admin.settings.tabs.storage'))
->icon(Heroicon::OutlinedCloudArrowUp)
->schema([
TextInput::make('attachments_url_prefix')
->label(__('admin.settings.attachments_legacy_prefix'))
->helperText(__('admin.helpers.attachments_prefix'))
->required(),
]),
Tab::make(__('admin.settings.tabs.ai'))
->icon(Heroicon::OutlinedSparkles)
->schema([
Select::make('ai_provider')->label(__('admin.settings.ai_provider'))->options([
'stub' => __('admin.options.ai_provider_stub'),
'openai_compatible' => __('admin.options.ai_provider_openai'),
])->required(),
TextInput::make('ai_api_base_url')->label(__('admin.settings.ai_api_base_url')),
TextInput::make('ai_api_key')->label(__('admin.settings.ai_api_key'))->password()->revealable(),
TextInput::make('ai_model')->label(__('admin.settings.ai_model')),
Toggle::make('comment_moderation_enabled')->label(__('admin.settings.comment_moderation_enabled')),
Toggle::make('content_optimization_enabled')->label(__('admin.settings.content_optimization_enabled')),
]),
Tab::make(__('admin.settings.tabs.snippets'))
->icon(Heroicon::OutlinedCodeBracket)
->schema([
Section::make(__('admin.settings.snippet_groups.analytics'))
->description(__('admin.settings.snippet_groups.analytics_help'))
->icon(Heroicon::OutlinedChartBar)
->collapsible()
->schema([
Textarea::make('analytics_head')
->label(__('admin.settings.analytics_head'))
->helperText(ThemeSlot::hint(ThemeSlot::HEAD).' '.__('admin.helpers.analytics_extra'))
->rows(5)
->columnSpanFull(),
Textarea::make('body_end')
->label(__('admin.settings.body_end'))
->helperText(ThemeSlot::hint(ThemeSlot::BODY_END))
->rows(3)
->columnSpanFull(),
]),
Section::make(__('admin.settings.snippet_groups.ads'))
->description(__('admin.settings.snippet_groups.ads_help'))
->icon(Heroicon::OutlinedMegaphone)
->collapsed()
->schema([
Textarea::make('header_banner')
->label(__('admin.settings.header_banner'))
->helperText(ThemeSlot::hint(ThemeSlot::HEADER_AFTER))
->rows(3)
->columnSpanFull(),
Textarea::make('ads_sidebar')
->label(__('admin.settings.ads_sidebar'))
->helperText(ThemeSlot::hint(ThemeSlot::SIDEBAR).' '.__('admin.helpers.ads_sidebar_extra'))
->rows(3)
->columnSpanFull(),
Textarea::make('ads_article_top')
->label(__('admin.settings.ads_article_top'))
->helperText(ThemeSlot::hint(ThemeSlot::ARTICLE_TOP))
->rows(3)
->columnSpanFull(),
Textarea::make('ads_article_bottom')
->label(__('admin.settings.ads_article_bottom'))
->helperText(ThemeSlot::hint(ThemeSlot::ARTICLE_BOTTOM))
->rows(3)
->columnSpanFull(),
]),
Section::make(__('admin.settings.snippet_groups.misc'))
->description(__('admin.settings.snippet_groups.misc_help'))
->icon(Heroicon::OutlinedLink)
->collapsed()
->schema([
Textarea::make('custom_links_html')
->label(__('admin.settings.custom_links_html'))
->helperText(ThemeSlot::hint(ThemeSlot::SIDEBAR_AFTER))
->rows(3)
->columnSpanFull(),
Textarea::make('footer_html')
->label(__('admin.settings.footer_html'))
->helperText(ThemeSlot::hint(ThemeSlot::FOOTER_BEFORE))
->rows(3)
->columnSpanFull(),
]),
]),
]),
]);
}
public function content(Schema $schema): Schema
{
return $schema->components([
Form::make([EmbeddedSchema::make('form')])
->id('form')
->livewireSubmitHandler('save')
->footer([
Actions::make([
Action::make('save')
->label(__('admin.actions.save'))
->submit('save'),
]),
]),
]);
}
public function save(
GeneralSettings $general,
SeoSettings $seo,
AiSettings $ai,
BlogSettings $blog,
CommentSettings $comment,
SnippetSettings $snippets,
): void {
$data = $this->form->getState();
$general->site_name = $data['site_name'];
$general->site_url = $data['site_url'];
$general->site_description = $data['site_description'] ?? null;
$general->active_theme = $data['active_theme'];
$general->attachments_url_prefix = $data['attachments_url_prefix'];
$general->default_content_format = $data['default_content_format'];
$general->import_content_format = $data['import_content_format'];
$general->import_convert_html_to_markdown = (bool) $data['import_convert_html_to_markdown'];
$general->save();
config(['larablog.attachments_url_prefix' => $general->attachments_url_prefix]);
$blog->posts_per_page = (int) $data['posts_per_page'];
$blog->allow_comments = (bool) $data['allow_comments'];
$blog->comment_order = $data['comment_order'];
$blog->show_views = (bool) $data['show_views'];
$blog->show_author = (bool) $data['show_author'];
$blog->date_format = $data['date_format'];
$blog->close_comments_on_old_posts = (bool) $data['close_comments_on_old_posts'];
$blog->close_comments_days = (int) $data['close_comments_days'];
$blog->save();
$comment->guest_can_comment = (bool) $data['guest_can_comment'];
$comment->require_moderation = (bool) $data['require_moderation'];
$comment->rate_limit_per_minute = (int) $data['rate_limit_per_minute'];
$comment->enable_website_field = (bool) $data['enable_website_field'];
$comment->forbidden_words = (string) ($data['forbidden_words'] ?? '');
$comment->save();
$seo->meta_title_suffix = $data['meta_title_suffix'] ?? null;
$seo->default_description = $data['default_description'] ?? null;
$seo->default_keywords = $data['default_keywords'] ?? null;
$seo->json_ld_enabled = (bool) $data['json_ld_enabled'];
$seo->robots_index = (bool) $data['robots_index'];
$seo->twitter_site = $data['twitter_site'] ?? null;
$seo->canonical_force_https = (bool) $data['canonical_force_https'];
$seo->save();
$ai->provider = $data['ai_provider'];
$ai->api_base_url = $data['ai_api_base_url'] ?? null;
$ai->api_key = $data['ai_api_key'] ?? null;
$ai->model = $data['ai_model'] ?? null;
$ai->comment_moderation_enabled = (bool) $data['comment_moderation_enabled'];
$ai->content_optimization_enabled = (bool) $data['content_optimization_enabled'];
$ai->save();
$snippets->analytics_head = (string) ($data['analytics_head'] ?? '');
$snippets->body_end = (string) ($data['body_end'] ?? '');
$snippets->ads_sidebar = (string) ($data['ads_sidebar'] ?? '');
$snippets->ads_article_top = (string) ($data['ads_article_top'] ?? '');
$snippets->ads_article_bottom = (string) ($data['ads_article_bottom'] ?? '');
$snippets->header_banner = (string) ($data['header_banner'] ?? '');
$snippets->custom_links_html = (string) ($data['custom_links_html'] ?? '');
$snippets->footer_html = (string) ($data['footer_html'] ?? '');
$snippets->save();
Notification::make()->title(__('admin.messages.settings_saved'))->success()->send();
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use BackedEnum;
use Filament\Support\Icons\Heroicon;
class ThemeMarketplacePage extends PluginSkeletonPage
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSwatch;
protected static ?int $navigationSort = 104;
protected static function pluginName(): string
{
return 'larablog/theme-marketplace';
}
protected static function navKey(): string
{
return 'theme_marketplace';
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Articles;
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\Schemas\ArticleForm;
use App\Filament\Resources\Articles\Tables\ArticlesTable;
use App\Models\Article;
use BackedEnum;
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
{
use HasTranslatedLabels;
protected static ?string $model = Article::class;
protected static function navKey(): string
{
return 'articles';
}
protected static function modelKey(): string
{
return 'article';
}
protected static function groupKey(): string
{
return 'content';
}
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
public static function form(Schema $schema): Schema
{
return ArticleForm::configure($schema);
}
public static function table(Table $table): Table
{
return ArticlesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListArticles::route('/'),
'create' => CreateArticle::route('/create'),
'edit' => EditArticle::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Articles\Pages;
use App\Domain\Plugin\Hook;
use App\Filament\Resources\Articles\ArticleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateArticle extends CreateRecord
{
protected static string $resource = ArticleResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$filtered = Hook::filter('filament.article.mutate_before_save', $data, null);
return is_array($filtered) ? $filtered : $data;
}
protected function afterCreate(): void
{
Hook::dispatch('filament.article.after_save', $this->record, $this->form->getState());
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Articles\Pages;
use App\Domain\Ai\Jobs\OptimizeArticleContentJob;
use App\Domain\Plugin\Hook;
use App\Filament\Resources\Articles\ArticleResource;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditArticle extends EditRecord
{
protected static string $resource = ArticleResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('aiOptimize')
->label(__('admin.messages.ai_optimize'))
->action(function (): void {
OptimizeArticleContentJob::dispatch($this->record->getKey());
Notification::make()
->title(__('admin.messages.ai_optimize_queued'))
->body(__('admin.messages.ai_optimize_queue_hint'))
->success()
->send();
}),
DeleteAction::make(),
...Hook::collect('filament.article.actions'),
];
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeFill(array $data): array
{
$filtered = Hook::filter('filament.article.mutate_before_fill', $data, $this->record);
return is_array($filtered) ? $filtered : $data;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
$filtered = Hook::filter('filament.article.mutate_before_save', $data, $this->record);
return is_array($filtered) ? $filtered : $data;
}
protected function afterSave(): void
{
Hook::dispatch('filament.article.after_save', $this->record, $this->form->getState());
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Articles\Pages;
use App\Filament\Resources\Articles\ArticleResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListArticles extends ListRecords
{
protected static string $resource = ArticleResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Articles\Schemas;
use App\Domain\Blog\ContentFormat;
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\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
class ArticleForm
{
public static function configure(Schema $schema): Schema
{
$defaultFormat = ContentFormat::MARKDOWN;
try {
$defaultFormat = ContentFormat::normalize(app(GeneralSettings::class)->default_content_format, ContentFormat::MARKDOWN);
} catch (\Throwable) {
//
}
return $schema
->components([
Select::make('category_id')
->label(__('admin.fields.category'))
->relationship('category', 'name')
->required(),
Select::make('user_id')
->label(__('admin.fields.author'))
->relationship('user', 'name')
->required(),
TextInput::make('title')
->label(__('admin.fields.title'))
->required()
->columnSpanFull(),
Select::make('content_format')
->label(__('admin.fields.content_format'))
->options([
ContentFormat::MARKDOWN => __('admin.options.markdown_recommended'),
ContentFormat::HTML => __('admin.options.html_legacy'),
])
->default($defaultFormat)
->required()
->live()
->helperText(__('admin.helpers.article_content')),
Textarea::make('content')
->label(fn (Get $get): string => $get('content_format') === ContentFormat::MARKDOWN
? __('admin.fields.content_markdown')
: __('admin.fields.content_html'))
->required()
->rows(18)
->columnSpanFull(),
TextInput::make('description')
->label(__('admin.fields.description')),
TextInput::make('keywords')
->label(__('admin.fields.keywords')),
TextInput::make('slug')
->label(__('admin.fields.slug')),
DateTimePicker::make('published_at')
->label(__('admin.fields.published_at'))
->default(now()),
Toggle::make('stick')
->label(__('admin.fields.stick'))
->default(false),
Toggle::make('visible')
->label(__('admin.fields.visible'))
->default(true),
Toggle::make('close_comment')
->label(__('admin.fields.close_comment'))
->default(false),
TextInput::make('read_password')
->label(__('admin.fields.read_password'))
->password(),
Textarea::make('ai_summary')
->label(__('admin.fields.ai_summary'))
->columnSpanFull(),
...Hook::collect('filament.article.form'),
]);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Articles\Tables;
use App\Domain\Plugin\Hook;
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\Table;
class ArticlesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
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(),
TextColumn::make('keywords')
->label(__('admin.fields.keywords'))
->searchable(),
TextColumn::make('published_at')
->label(__('admin.fields.published_at'))
->dateTime()
->sortable(),
TextColumn::make('views')
->label(__('admin.fields.views'))
->numeric()
->sortable(),
TextColumn::make('comments_count')
->label(__('admin.fields.comments_count'))
->numeric()
->sortable(),
IconColumn::make('stick')
->label(__('admin.fields.stick'))
->boolean(),
IconColumn::make('visible')
->label(__('admin.fields.visible'))
->boolean(),
IconColumn::make('close_comment')
->label(__('admin.fields.close_comment'))
->boolean(),
TextColumn::make('created_at')
->label(__('admin.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label(__('admin.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('slug')
->label(__('admin.fields.slug'))
->searchable(),
TextColumn::make('content_format')
->label(__('admin.fields.content_format'))
->searchable(),
...Hook::collect('filament.article.table.columns'),
])
->filters([
//
])
->recordActions([
EditAction::make(),
...Hook::collect('filament.article.actions'),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Attachments;
use App\Filament\Resources\Attachments\Pages\CreateAttachment;
use App\Filament\Resources\Attachments\Pages\EditAttachment;
use App\Filament\Resources\Attachments\Pages\ListAttachments;
use App\Filament\Resources\Attachments\Schemas\AttachmentForm;
use App\Filament\Resources\Attachments\Tables\AttachmentsTable;
use App\Models\Attachment;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use App\Filament\Concerns\HasTranslatedLabels;
class AttachmentResource extends Resource
{
use HasTranslatedLabels;
protected static ?string $model = Attachment::class;
protected static function navKey(): string
{
return 'attachments';
}
protected static function modelKey(): string
{
return 'attachment';
}
protected static function groupKey(): string
{
return 'content';
}
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return AttachmentForm::configure($schema);
}
public static function table(Table $table): Table
{
return AttachmentsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListAttachments::route('/'),
'create' => CreateAttachment::route('/create'),
'edit' => EditAttachment::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Attachments\Pages;
use App\Filament\Resources\Attachments\AttachmentResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Storage;
class CreateAttachment extends CreateRecord
{
protected static string $resource = AttachmentResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$disk = config('larablog.attachments_disk', 'attachments');
$path = $data['upload'] ?? null;
unset($data['upload']);
if (! is_string($path) || $path === '') {
throw new \InvalidArgumentException(__('admin.messages.upload_required'));
}
$storage = Storage::disk($disk);
$mime = method_exists($storage, 'mimeType') ? ($storage->mimeType($path) ?: null) : null;
$allowed = config('larablog.allowed_attachment_mimes', []);
if (is_string($mime) && $allowed !== [] && ! in_array($mime, $allowed, true)) {
$storage->delete($path);
throw new \InvalidArgumentException(__('admin.messages.mime_not_allowed', ['mime' => $mime]));
}
$data['disk'] = $disk;
$data['path'] = $path;
$data['filename'] = $data['filename'] ?: basename($path);
$data['mime'] = $mime;
$data['size'] = $storage->size($path) ?: 0;
$data['checksum'] = hash('sha256', (string) $storage->get($path));
$data['synced_at'] = now();
$data['visibility'] = $data['visibility'] ?? 'public';
$data['downloads'] = (int) ($data['downloads'] ?? 0);
return $data;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Attachments\Pages;
use App\Domain\Media\AttachmentStorageService;
use App\Filament\Resources\Attachments\AttachmentResource;
use App\Models\Attachment;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditAttachment extends EditRecord
{
protected static string $resource = AttachmentResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->before(function (Attachment $record, AttachmentStorageService $storage): void {
$storage->deleteFromDisk($record);
}),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Attachments\Pages;
use App\Filament\Resources\Attachments\AttachmentResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAttachments extends ListRecords
{
protected static string $resource = AttachmentResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Attachments\Schemas;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class AttachmentForm
{
public static function configure(Schema $schema): Schema
{
$disk = config('larablog.attachments_disk', 'attachments');
$mimes = config('larablog.allowed_attachment_mimes', []);
return $schema
->components([
Select::make('article_id')
->label(__('admin.fields.article'))
->relationship('article', 'title')
->searchable()
->preload(),
FileUpload::make('upload')
->label(__('admin.fields.upload'))
->disk($disk)
->directory(fn (): string => 'uploads/'.now()->format('Y/m'))
->visibility('public')
->acceptedFileTypes($mimes)
->maxSize(20480)
->required(fn (string $operation): bool => $operation === 'create')
->dehydrated(fn ($state): bool => filled($state))
->helperText(__('admin.helpers.attachment_upload')),
TextInput::make('filename')
->label(__('admin.fields.filename'))
->maxLength(255),
TextInput::make('visibility')
->label(__('admin.fields.visibility'))
->default('public')
->required(),
TextInput::make('legacy_filepath')
->label(__('admin.fields.legacy_filepath'))
->maxLength(255),
TextInput::make('downloads')
->label(__('admin.fields.downloads'))
->numeric()
->default(0)
->disabled()
->dehydrated(),
]);
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Attachments\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class AttachmentsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
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(),
TextColumn::make('thumb_path')
->label(__('admin.fields.thumb_path'))
->searchable(),
TextColumn::make('filename')
->label(__('admin.fields.filename'))
->searchable(),
TextColumn::make('mime')
->label(__('admin.fields.mime'))
->searchable(),
TextColumn::make('size')
->label(__('admin.fields.size'))
->numeric()
->sortable(),
TextColumn::make('checksum')
->label(__('admin.fields.checksum'))
->searchable(),
TextColumn::make('visibility')
->label(__('admin.fields.visibility'))
->searchable(),
TextColumn::make('legacy_filepath')
->label(__('admin.fields.legacy_filepath'))
->searchable(),
TextColumn::make('synced_at')
->label(__('admin.fields.synced_at'))
->dateTime()
->sortable(),
TextColumn::make('downloads')
->label(__('admin.fields.downloads'))
->numeric()
->sortable(),
TextColumn::make('created_at')
->label(__('admin.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label(__('admin.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Categories;
use App\Filament\Resources\Categories\Pages\CreateCategory;
use App\Filament\Resources\Categories\Pages\EditCategory;
use App\Filament\Resources\Categories\Pages\ListCategories;
use App\Filament\Resources\Categories\Schemas\CategoryForm;
use App\Filament\Resources\Categories\Tables\CategoriesTable;
use App\Models\Category;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use App\Filament\Concerns\HasTranslatedLabels;
class CategoryResource extends Resource
{
use HasTranslatedLabels;
protected static ?string $model = Category::class;
protected static function navKey(): string
{
return 'categories';
}
protected static function modelKey(): string
{
return 'category';
}
protected static function groupKey(): string
{
return 'content';
}
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return CategoryForm::configure($schema);
}
public static function table(Table $table): Table
{
return CategoriesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListCategories::route('/'),
'create' => CreateCategory::route('/create'),
'edit' => EditCategory::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Categories\Pages;
use App\Filament\Resources\Categories\CategoryResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCategory extends CreateRecord
{
protected static string $resource = CategoryResource::class;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Categories\Pages;
use App\Filament\Resources\Categories\CategoryResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditCategory extends EditRecord
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Categories\Pages;
use App\Filament\Resources\Categories\CategoryResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListCategories extends ListRecords
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Categories\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class CategoryForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label(__('admin.fields.name'))
->required(),
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),
]);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Categories\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class CategoriesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('admin.fields.name'))
->searchable(),
TextColumn::make('display_order')
->label(__('admin.fields.display_order'))
->numeric()
->sortable(),
TextColumn::make('articles_count')
->label(__('admin.fields.articles_count'))
->numeric()
->sortable(),
TextColumn::make('created_at')
->label(__('admin.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label(__('admin.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Comments;
use App\Filament\Resources\Comments\Pages\CreateComment;
use App\Filament\Resources\Comments\Pages\EditComment;
use App\Filament\Resources\Comments\Pages\ListComments;
use App\Filament\Resources\Comments\Schemas\CommentForm;
use App\Filament\Resources\Comments\Tables\CommentsTable;
use App\Models\Comment;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use App\Filament\Concerns\HasTranslatedLabels;
class CommentResource extends Resource
{
use HasTranslatedLabels;
protected static ?string $model = Comment::class;
protected static function navKey(): string
{
return 'comments';
}
protected static function modelKey(): string
{
return 'comment';
}
protected static function groupKey(): string
{
return 'content';
}
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return CommentForm::configure($schema);
}
public static function table(Table $table): Table
{
return CommentsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListComments::route('/'),
'create' => CreateComment::route('/create'),
'edit' => EditComment::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Comments\Pages;
use App\Filament\Resources\Comments\CommentResource;
use Filament\Resources\Pages\CreateRecord;
class CreateComment extends CreateRecord
{
protected static string $resource = CommentResource::class;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Comments\Pages;
use App\Filament\Resources\Comments\CommentResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditComment extends EditRecord
{
protected static string $resource = CommentResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Comments\Pages;
use App\Filament\Resources\Comments\CommentResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListComments extends ListRecords
{
protected static string $resource = CommentResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Comments\Schemas;
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\Schemas\Schema;
class CommentForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('article_id')
->label(__('admin.fields.article'))
->relationship('article', 'title')
->required(),
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(),
TextInput::make('ip')
->label(__('admin.fields.ip')),
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')),
]);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Comments\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class CommentsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('article.title')
->label(__('admin.fields.article'))
->searchable(),
TextColumn::make('author')
->label(__('admin.fields.author'))
->searchable(),
TextColumn::make('url')
->label(__('admin.fields.url'))
->searchable(),
TextColumn::make('ip')
->label(__('admin.fields.ip'))
->searchable(),
TextColumn::make('moderation_status')
->label(__('admin.fields.moderation_status'))
->searchable(),
TextColumn::make('published_at')
->label(__('admin.fields.published_at'))
->dateTime()
->sortable(),
TextColumn::make('created_at')
->label(__('admin.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label(__('admin.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Links;
use App\Filament\Resources\Links\Pages\CreateLink;
use App\Filament\Resources\Links\Pages\EditLink;
use App\Filament\Resources\Links\Pages\ListLinks;
use App\Filament\Resources\Links\Schemas\LinkForm;
use App\Filament\Resources\Links\Tables\LinksTable;
use App\Models\Link;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use App\Filament\Concerns\HasTranslatedLabels;
class LinkResource extends Resource
{
use HasTranslatedLabels;
protected static ?string $model = Link::class;
protected static function navKey(): string
{
return 'links';
}
protected static function modelKey(): string
{
return 'link';
}
protected static function groupKey(): string
{
return 'content';
}
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return LinkForm::configure($schema);
}
public static function table(Table $table): Table
{
return LinksTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListLinks::route('/'),
'create' => CreateLink::route('/create'),
'edit' => EditLink::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Links\Pages;
use App\Filament\Resources\Links\LinkResource;
use Filament\Resources\Pages\CreateRecord;
class CreateLink extends CreateRecord
{
protected static string $resource = LinkResource::class;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Links\Pages;
use App\Filament\Resources\Links\LinkResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditLink extends EditRecord
{
protected static string $resource = LinkResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Links\Pages;
use App\Filament\Resources\Links\LinkResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListLinks extends ListRecords
{
protected static string $resource = LinkResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Links\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class LinkForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label(__('admin.fields.name'))
->required(),
TextInput::make('url')
->label(__('admin.fields.url'))
->url()
->required(),
Textarea::make('note')
->label(__('admin.fields.note'))
->columnSpanFull(),
TextInput::make('display_order')
->label(__('admin.fields.display_order'))
->required()
->numeric()
->default(0),
Toggle::make('visible')
->label(__('admin.fields.visible'))
->required(),
]);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Links\Tables;
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\Table;
class LinksTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('admin.fields.name'))
->searchable(),
TextColumn::make('url')
->label(__('admin.fields.url'))
->searchable(),
TextColumn::make('display_order')
->label(__('admin.fields.display_order'))
->numeric()
->sortable(),
IconColumn::make('visible')
->label(__('admin.fields.visible'))
->boolean(),
TextColumn::make('created_at')
->label(__('admin.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label(__('admin.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Plugins\Pages;
use App\Filament\Resources\Plugins\PluginResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePlugin extends CreateRecord
{
protected static string $resource = PluginResource::class;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Plugins\Pages;
use App\Filament\Resources\Plugins\PluginResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditPlugin extends EditRecord
{
protected static string $resource = PluginResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Plugins\Pages;
use App\Filament\Resources\Plugins\PluginResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListPlugins extends ListRecords
{
protected static string $resource = PluginResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Plugins;
use App\Filament\Resources\Plugins\Pages\CreatePlugin;
use App\Filament\Resources\Plugins\Pages\EditPlugin;
use App\Filament\Resources\Plugins\Pages\ListPlugins;
use App\Filament\Resources\Plugins\Schemas\PluginForm;
use App\Filament\Resources\Plugins\Tables\PluginsTable;
use App\Models\Plugin;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class PluginResource extends Resource
{
protected static ?string $model = Plugin::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPuzzlePiece;
protected static bool $shouldRegisterNavigation = false;
public static function canCreate(): bool
{
return false;
}
public static function form(Schema $schema): Schema
{
return PluginForm::configure($schema);
}
public static function table(Table $table): Table
{
return PluginsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListPlugins::route('/'),
'create' => CreatePlugin::route('/create'),
'edit' => EditPlugin::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Plugins\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class PluginForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label(__('admin.fields.name'))
->required(),
TextInput::make('version')
->label(__('admin.fields.version'))
->required()
->default('1.0.0'),
Toggle::make('enabled')
->label(__('admin.fields.enabled'))
->required(),
TextInput::make('path')
->label(__('admin.fields.path'))
->required(),
Textarea::make('config')
->label(__('admin.fields.config'))
->columnSpanFull(),
]);
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Plugins\Tables;
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\Table;
class PluginsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('admin.fields.name'))
->searchable(),
TextColumn::make('version')
->label(__('admin.fields.version'))
->searchable(),
IconColumn::make('enabled')
->label(__('admin.fields.enabled'))
->boolean(),
TextColumn::make('path')
->label(__('admin.fields.path'))
->searchable(),
TextColumn::make('created_at')
->label(__('admin.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label(__('admin.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Stylevars\Pages;
use App\Filament\Resources\Stylevars\StylevarResource;
use Filament\Resources\Pages\CreateRecord;
class CreateStylevar extends CreateRecord
{
protected static string $resource = StylevarResource::class;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Stylevars\Pages;
use App\Filament\Resources\Stylevars\StylevarResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditStylevar extends EditRecord
{
protected static string $resource = StylevarResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Stylevars\Pages;
use App\Filament\Resources\Stylevars\StylevarResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListStylevars extends ListRecords
{
protected static string $resource = StylevarResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Stylevars\Schemas;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class StylevarForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('title')
->label(__('admin.fields.title'))
->required()
->maxLength(120),
Textarea::make('value')
->label(__('admin.fields.value'))
->rows(8)
->columnSpanFull(),
Toggle::make('visible')
->label(__('admin.fields.visible'))
->default(true),
]);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Stylevars;
use App\Filament\Resources\Stylevars\Pages\CreateStylevar;
use App\Filament\Resources\Stylevars\Pages\EditStylevar;
use App\Filament\Resources\Stylevars\Pages\ListStylevars;
use App\Filament\Resources\Stylevars\Schemas\StylevarForm;
use App\Filament\Resources\Stylevars\Tables\StylevarsTable;
use App\Models\Stylevar;
use BackedEnum;
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 StylevarResource extends Resource
{
use HasTranslatedLabels;
protected static ?string $model = Stylevar::class;
protected static function navKey(): string
{
return 'stylevars';
}
protected static function modelKey(): string
{
return 'stylevar';
}
protected static function groupKey(): string
{
return 'content';
}
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
public static function form(Schema $schema): Schema
{
return StylevarForm::configure($schema);
}
public static function table(Table $table): Table
{
return StylevarsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListStylevars::route('/'),
'create' => CreateStylevar::route('/create'),
'edit' => EditStylevar::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Stylevars\Tables;
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\Table;
class StylevarsTable
{
public static function configure(Table $table): Table
{
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),
IconColumn::make('visible')
->label(__('admin.fields.visible'))
->boolean(),
TextColumn::make('updated_at')
->label(__('admin.fields.updated_at'))
->dateTime()
->sortable(),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Tags\Pages;
use App\Filament\Resources\Tags\TagResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTag extends CreateRecord
{
protected static string $resource = TagResource::class;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Tags\Pages;
use App\Filament\Resources\Tags\TagResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditTag extends EditRecord
{
protected static string $resource = TagResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Tags\Pages;
use App\Filament\Resources\Tags\TagResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListTags extends ListRecords
{
protected static string $resource = TagResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Tags\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class TagForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label(__('admin.fields.name'))
->required(),
TextInput::make('use_count')
->label(__('admin.fields.use_count'))
->required()
->numeric()
->default(0),
]);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Tags\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class TagsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('admin.fields.name'))
->searchable(),
TextColumn::make('use_count')
->label(__('admin.fields.use_count'))
->numeric()
->sortable(),
TextColumn::make('created_at')
->label(__('admin.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label(__('admin.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Tags;
use App\Filament\Resources\Tags\Pages\CreateTag;
use App\Filament\Resources\Tags\Pages\EditTag;
use App\Filament\Resources\Tags\Pages\ListTags;
use App\Filament\Resources\Tags\Schemas\TagForm;
use App\Filament\Resources\Tags\Tables\TagsTable;
use App\Models\Tag;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use App\Filament\Concerns\HasTranslatedLabels;
class TagResource extends Resource
{
use HasTranslatedLabels;
protected static ?string $model = Tag::class;
protected static function navKey(): string
{
return 'tags';
}
protected static function modelKey(): string
{
return 'tag';
}
protected static function groupKey(): string
{
return 'content';
}
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return TagForm::configure($schema);
}
public static function table(Table $table): Table
{
return TagsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListTags::route('/'),
'create' => CreateTag::route('/create'),
'edit' => EditTag::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class UserForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label(__('admin.fields.display_name'))
->required()
->maxLength(120),
TextInput::make('username')
->label(__('admin.fields.username'))
->required()
->maxLength(40)
->unique(ignoreRecord: true),
TextInput::make('email')
->label(__('admin.fields.email'))
->email()
->maxLength(120)
->unique(ignoreRecord: true),
TextInput::make('url')
->label(__('admin.fields.website'))
->url()
->maxLength(255),
TextInput::make('password')
->label(__('admin.fields.password'))
->password()
->revealable()
->dehydrated(fn (?string $state): bool => filled($state))
->required(fn (string $operation): bool => $operation === 'create'),
Select::make('roles')
->label(__('admin.fields.roles'))
->multiple()
->relationship('roles', 'name')
->preload(),
]);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class UsersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
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(),
TextColumn::make('roles.name')
->badge()
->label(__('admin.fields.roles')),
TextColumn::make('login_at')
->label(__('admin.fields.login_at'))
->dateTime()
->sortable(),
TextColumn::make('created_at')
->label(__('admin.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Users;
use App\Filament\Resources\Users\Pages\CreateUser;
use App\Filament\Resources\Users\Pages\EditUser;
use App\Filament\Resources\Users\Pages\ListUsers;
use App\Filament\Resources\Users\Schemas\UserForm;
use App\Filament\Resources\Users\Tables\UsersTable;
use App\Models\User;
use BackedEnum;
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 UserResource extends Resource
{
use HasTranslatedLabels;
protected static ?string $model = User::class;
protected static function navKey(): string
{
return 'users';
}
protected static function modelKey(): string
{
return 'user';
}
protected static function groupKey(): string
{
return 'system';
}
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers;
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema);
}
public static function table(Table $table): Table
{
return UsersTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListUsers::route('/'),
'create' => CreateUser::route('/create'),
'edit' => EditUser::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Domain\Blog\ArticleAccess;
use App\Http\Controllers\Controller;
use App\Models\Article;
use App\Settings\BlogSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ArticleController extends Controller
{
public function index(Request $request, BlogSettings $blog): JsonResponse
{
$perPage = max(1, min(50, $request->integer('per_page') ?: $blog->posts_per_page));
$articles = Article::query()
->with(['category:id,name', 'user:id,name,username', 'tags:id,name'])
->visible()
->published()
->orderByDesc('stick')
->orderByDesc('published_at')
->paginate($perPage);
return response()->json([
'data' => $articles->getCollection()->map(fn (Article $article) => $this->summary($article))->values(),
'meta' => [
'current_page' => $articles->currentPage(),
'last_page' => $articles->lastPage(),
'per_page' => $articles->perPage(),
'total' => $articles->total(),
],
]);
}
public function show(int $id, ArticleAccess $access): JsonResponse
{
$article = Article::query()
->with(['category:id,name', 'user:id,name,username', 'tags:id,name'])
->visible()
->published()
->findOrFail($id);
// Anonymous API: never pass a user (purchased/admin full text is Web-only this phase).
$decision = $access->resolve($article, null, []);
$payload = [
...$this->summary($article),
'content_format' => $article->content_format,
'keywords' => $article->keywords,
'access' => [
'status' => $decision->status,
'message' => $decision->message,
'checkout_url' => $decision->checkoutUrl,
],
];
if ($decision->isAllow()) {
$payload['content'] = $article->content;
$payload['content_html'] = $article->renderedHtml();
} else {
$payload['content'] = null;
$payload['content_html'] = $access->publicHtml($article, $decision);
$payload['teaser_html'] = $payload['content_html'];
}
return response()->json(['data' => $payload]);
}
/**
* @return array<string, mixed>
*/
protected function summary(Article $article): array
{
return [
'id' => $article->id,
'title' => $article->title,
'slug' => $article->slug,
'description' => $article->description,
'published_at' => optional($article->published_at)?->toIso8601String(),
'views' => $article->views,
'stick' => (bool) $article->stick,
'url' => url('/show-'.$article->id.'.shtml'),
'category' => $article->category ? [
'id' => $article->category->id,
'name' => $article->category->name,
] : null,
'author' => $article->user ? [
'id' => $article->user->id,
'name' => $article->user->name,
'username' => $article->user->username,
] : null,
'tags' => $article->tags->map(fn ($tag) => [
'id' => $tag->id,
'name' => $tag->name,
])->values(),
];
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Article;
use App\Models\Category;
use App\Settings\BlogSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
public function index(): JsonResponse
{
$categories = Category::query()->orderBy('display_order')->get(['id', 'name', 'articles_count', 'display_order']);
return response()->json([
'data' => $categories,
]);
}
public function articles(Request $request, int $id, BlogSettings $blog): JsonResponse
{
Category::query()->findOrFail($id);
$perPage = max(1, min(50, $request->integer('per_page') ?: $blog->posts_per_page));
$articles = Article::query()
->with(['category:id,name', 'tags:id,name'])
->visible()
->published()
->where('category_id', $id)
->orderByDesc('published_at')
->paginate($perPage);
return response()->json([
'data' => $articles->items(),
'meta' => [
'current_page' => $articles->currentPage(),
'last_page' => $articles->lastPage(),
'per_page' => $articles->perPage(),
'total' => $articles->total(),
],
]);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Settings\GeneralSettings;
use App\Settings\SeoSettings;
use Illuminate\Http\JsonResponse;
class MetaController extends Controller
{
public function show(GeneralSettings $general, SeoSettings $seo): JsonResponse
{
return response()->json([
'name' => $general->site_name,
'url' => $general->site_url,
'description' => $general->site_description,
'theme' => $general->active_theme,
'seo' => [
'default_description' => $seo->default_description,
'default_keywords' => $seo->default_keywords,
'robots_index' => $seo->robots_index,
],
'api' => [
'version' => 'v1',
'openapi' => url('/docs/api/openapi.yaml'),
],
]);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use App\Settings\BlogSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TagController extends Controller
{
public function index(): JsonResponse
{
return response()->json([
'data' => Tag::query()->orderByDesc('use_count')->get(['id', 'name', 'use_count']),
]);
}
public function articles(Request $request, string $name, BlogSettings $blog): JsonResponse
{
$tag = Tag::query()->where('name', $name)->firstOrFail();
$perPage = max(1, min(50, $request->integer('per_page') ?: $blog->posts_per_page));
$articles = $tag->articles()
->with(['category:id,name'])
->visible()
->published()
->orderByDesc('published_at')
->paginate($perPage);
return response()->json([
'data' => $articles->items(),
'meta' => [
'tag' => ['id' => $tag->id, 'name' => $tag->name],
'current_page' => $articles->currentPage(),
'last_page' => $articles->lastPage(),
'per_page' => $articles->perPage(),
'total' => $articles->total(),
],
]);
}
}

Some files were not shown because too many files have changed in this diff Show More