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:
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:  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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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($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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user