From 263b98b2186c05ab6f750221108b08253cca0728 Mon Sep 17 00:00:00 2001
From: ak
Date: Wed, 12 Aug 2026 01:15:38 +0800
Subject: [PATCH] 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.
---
.editorconfig | 18 +
.env.example | 96 +
.gitattributes | 11 +
.github/workflows/ci.yml | 84 +
.gitignore | 25 +
README.md | 62 +
app/Auth/LaraBlogUserProvider.php | 71 +
app/Console/Commands/PluginsSyncCommand.php | 35 +
app/Console/Commands/QueueAiWorkCommand.php | 37 +
app/Console/Commands/SablogImportCommand.php | 473 +
app/Console/Commands/ThemesPublishCommand.php | 49 +
app/Console/Commands/WorkermanAiCommand.php | 48 +
app/Contracts/LlmProvider.php | 19 +
.../Ai/Jobs/GenerateArticleCoverJob.php | 44 +
app/Domain/Ai/Jobs/ModerateCommentJob.php | 63 +
.../Ai/Jobs/OptimizeArticleContentJob.php | 53 +
app/Domain/Ai/OpenAiCompatibleLlmProvider.php | 107 +
app/Domain/Ai/StubLlmProvider.php | 31 +
app/Domain/Blog/AccessDecision.php | 101 +
app/Domain/Blog/ArticleAccess.php | 75 +
app/Domain/Blog/ArticleExcerpt.php | 34 +
app/Domain/Blog/AttachEmbed.php | 198 +
app/Domain/Blog/ContentFormat.php | 29 +
app/Domain/Blog/ContentRenderer.php | 172 +
app/Domain/Blog/HtmlTeaser.php | 41 +
app/Domain/Media/AttachmentStorageService.php | 193 +
app/Domain/Plugin/Hook.php | 96 +
app/Domain/Plugin/PluginManager.php | 243 +
app/Domain/Seo/SeoPresenter.php | 150 +
app/Domain/Theme/RegistersSnippetSlots.php | 34 +
app/Domain/Theme/ThemeManager.php | 192 +
app/Domain/Theme/ThemeSlot.php | 112 +
app/Domain/Theme/ThemeSlotReport.php | 222 +
app/Filament/Concerns/HasTranslatedLabels.php | 40 +
app/Filament/Pages/ManagePlugins.php | 125 +
app/Filament/Pages/ManageThemes.php | 113 +
app/Filament/Pages/MembershipPluginPage.php | 25 +
app/Filament/Pages/PaymentPluginPage.php | 31 +
app/Filament/Pages/PluginMarketplacePage.php | 25 +
app/Filament/Pages/PluginSkeletonPage.php | 75 +
app/Filament/Pages/SiteSettings.php | 357 +
app/Filament/Pages/ThemeMarketplacePage.php | 25 +
.../Resources/Articles/ArticleResource.php | 72 +
.../Articles/Pages/CreateArticle.php | 30 +
.../Resources/Articles/Pages/EditArticle.php | 63 +
.../Resources/Articles/Pages/ListArticles.php | 21 +
.../Articles/Schemas/ArticleForm.php | 87 +
.../Articles/Tables/ArticlesTable.php | 88 +
.../Attachments/AttachmentResource.php | 71 +
.../Attachments/Pages/CreateAttachment.php | 49 +
.../Attachments/Pages/EditAttachment.php | 26 +
.../Attachments/Pages/ListAttachments.php | 21 +
.../Attachments/Schemas/AttachmentForm.php | 54 +
.../Attachments/Tables/AttachmentsTable.php | 81 +
.../Resources/Categories/CategoryResource.php | 71 +
.../Categories/Pages/CreateCategory.php | 13 +
.../Categories/Pages/EditCategory.php | 21 +
.../Categories/Pages/ListCategories.php | 21 +
.../Categories/Schemas/CategoryForm.php | 31 +
.../Categories/Tables/CategoriesTable.php | 53 +
.../Resources/Comments/CommentResource.php | 71 +
.../Comments/Pages/CreateComment.php | 13 +
.../Resources/Comments/Pages/EditComment.php | 21 +
.../Resources/Comments/Pages/ListComments.php | 21 +
.../Comments/Schemas/CommentForm.php | 51 +
.../Comments/Tables/CommentsTable.php | 61 +
app/Filament/Resources/Links/LinkResource.php | 71 +
.../Resources/Links/Pages/CreateLink.php | 13 +
.../Resources/Links/Pages/EditLink.php | 21 +
.../Resources/Links/Pages/ListLinks.php | 21 +
.../Resources/Links/Schemas/LinkForm.php | 38 +
.../Resources/Links/Tables/LinksTable.php | 56 +
.../Resources/Plugins/Pages/CreatePlugin.php | 13 +
.../Resources/Plugins/Pages/EditPlugin.php | 21 +
.../Resources/Plugins/Pages/ListPlugins.php | 21 +
.../Resources/Plugins/PluginResource.php | 57 +
.../Resources/Plugins/Schemas/PluginForm.php | 36 +
.../Resources/Plugins/Tables/PluginsTable.php | 55 +
.../Stylevars/Pages/CreateStylevar.php | 13 +
.../Stylevars/Pages/EditStylevar.php | 21 +
.../Stylevars/Pages/ListStylevars.php | 21 +
.../Stylevars/Schemas/StylevarForm.php | 31 +
.../Resources/Stylevars/StylevarResource.php | 65 +
.../Stylevars/Tables/StylevarsTable.php | 47 +
.../Resources/Tags/Pages/CreateTag.php | 13 +
app/Filament/Resources/Tags/Pages/EditTag.php | 21 +
.../Resources/Tags/Pages/ListTags.php | 21 +
.../Resources/Tags/Schemas/TagForm.php | 26 +
.../Resources/Tags/Tables/TagsTable.php | 49 +
app/Filament/Resources/Tags/TagResource.php | 71 +
.../Resources/Users/Pages/CreateUser.php | 13 +
.../Resources/Users/Pages/EditUser.php | 21 +
.../Resources/Users/Pages/ListUsers.php | 21 +
.../Resources/Users/Schemas/UserForm.php | 48 +
.../Resources/Users/Tables/UsersTable.php | 54 +
app/Filament/Resources/Users/UserResource.php | 65 +
.../Controllers/Api/V1/ArticleController.php | 102 +
.../Controllers/Api/V1/CategoryController.php | 48 +
.../Controllers/Api/V1/MetaController.php | 32 +
app/Http/Controllers/Api/V1/TagController.php | 45 +
app/Http/Controllers/AttachmentController.php | 27 +
app/Http/Controllers/AuthController.php | 145 +
app/Http/Controllers/BlogController.php | 240 +
app/Http/Controllers/CommentController.php | 50 +
app/Http/Controllers/Controller.php | 10 +
app/Http/Controllers/LegacyGoneController.php | 19 +
app/Http/Controllers/SeoController.php | 98 +
app/Livewire/Admin/ClearCacheButton.php | 54 +
app/Models/Article.php | 130 +
app/Models/Attachment.php | 58 +
app/Models/Category.php | 30 +
app/Models/Comment.php | 69 +
app/Models/Link.php | 32 +
app/Models/Plugin.php | 26 +
app/Models/Stylevar.php | 29 +
app/Models/Tag.php | 28 +
app/Models/User.php | 79 +
app/Providers/AppServiceProvider.php | 79 +
app/Providers/Filament/AdminPanelProvider.php | 91 +
app/Settings/AiSettings.php | 27 +
app/Settings/BlogSettings.php | 31 +
app/Settings/CommentSettings.php | 25 +
app/Settings/GeneralSettings.php | 34 +
app/Settings/SeoSettings.php | 29 +
app/Settings/SnippetSettings.php | 59 +
app/Support/LegacyPassword.php | 20 +
artisan | 18 +
bootstrap/app.php | 21 +
bootstrap/cache/.gitignore | 2 +
bootstrap/providers.php | 6 +
composer.json | 105 +
composer.lock | 12515 ++++++++++++++++
config/app.php | 126 +
config/auth.php | 117 +
config/cache.php | 117 +
config/database.php | 208 +
config/filesystems.php | 111 +
config/image.php | 48 +
config/larablog.php | 34 +
config/logging.php | 132 +
config/mail.php | 118 +
config/permission.php | 206 +
config/purifier.php | 116 +
config/queue.php | 129 +
config/services.php | 38 +
config/session.php | 217 +
config/settings.php | 72 +
database/.gitignore | 1 +
database/factories/UserFactory.php | 45 +
.../0001_01_01_000000_create_users_table.php | 49 +
.../0001_01_01_000001_create_cache_table.php | 35 +
.../0001_01_01_000002_create_jobs_table.php | 57 +
...022_12_14_083707_create_settings_table.php | 24 +
..._08_11_092526_create_permission_tables.php | 134 +
...08_11_092528_create_activity_log_table.php | 27 +
...add_event_column_to_activity_log_table.php | 22 +
...atch_uuid_column_to_activity_log_table.php | 22 +
.../2026_08_11_100000_create_blog_tables.php | 158 +
...026_08_11_100003_drop_trackback_legacy.php | 49 +
...8_11_100004_add_article_content_format.php | 26 +
..._100005_add_article_cover_placeholders.php | 44 +
database/seeders/DatabaseSeeder.php | 13 +
database/seeders/DemoBlogSeeder.php | 122 +
..._08_11_100001_create_larablog_settings.php | 33 +
...002_add_attachments_url_prefix_setting.php | 15 +
..._08_11_100003_remove_trackback_setting.php | 13 +
..._11_100004_add_content_format_settings.php | 18 +
...11_220000_expand_blog_comment_settings.php | 34 +
...6_08_11_230000_create_snippet_settings.php | 20 +
deploy/nginx.conf | 27 +
docs/api/README.md | 21 +
docs/api/openapi.yaml | 83 +
docs/architecture.md | 43 +
docs/ops/deploy.md | 45 +
docs/plugins.md | 111 +
docs/routing.md | 21 +
docs/specs/larablog-platform/CHECKLIST.md | 39 +
docs/specs/larablog-platform/SPEC.md | 156 +
docs/specs/larablog-platform/TESTPLAN.md | 33 +
.../plugin-extension-commerce/CHECKLIST.md | 59 +
docs/specs/plugin-extension-commerce/SPEC.md | 176 +
.../plugin-extension-commerce/TESTPLAN.md | 57 +
docs/themes.md | 116 +
ecosystem.config.cjs | 77 +
herdy.yaml | 4 +
lang/en/admin.php | 384 +
lang/en/frontend.php | 44 +
lang/en/payment.php | 21 +
lang/zh_CN/admin.php | 384 +
lang/zh_CN/frontend.php | 44 +
lang/zh_CN/pagination.php | 6 +
lang/zh_CN/payment.php | 21 +
package.json | 17 +
phpunit.xml | 40 +
.../ai-comment-moderation/plugin.json | 7 +
.../src/PluginServiceProvider.php | 24 +
plugins/larablog/membership/plugin.json | 7 +
.../membership/src/PluginServiceProvider.php | 39 +
plugins/larablog/paid-content/README.md | 36 +
...2_002000_create_article_products_table.php | 29 +
plugins/larablog/paid-content/plugin.json | 9 +
.../src/Models/ArticleProduct.php | 37 +
.../src/PluginServiceProvider.php | 190 +
plugins/larablog/payment/README.md | 62 +
.../2026_08_12_001000_create_orders_table.php | 28 +
..._08_12_001001_create_order_items_table.php | 26 +
...08_12_001002_create_entitlements_table.php | 29 +
...1003_create_payment_transactions_table.php | 26 +
plugins/larablog/payment/plugin.json | 8 +
.../views/checkout/confirm.blade.php | 129 +
.../resources/views/checkout/error.blade.php | 35 +
.../filament/pages/payment-settings.blade.php | 11 +
.../payment/src/Domain/OrderService.php | 195 +
.../payment/src/Domain/ProductType.php | 26 +
.../Filament/Pages/PaymentSettingsPage.php | 56 +
.../src/Filament/Resources/OrderResource.php | 147 +
.../OrderResource/Pages/ListOrders.php | 13 +
.../OrderResource/Pages/ViewOrder.php | 57 +
.../payment/src/Models/Entitlement.php | 52 +
plugins/larablog/payment/src/Models/Order.php | 65 +
.../larablog/payment/src/Models/OrderItem.php | 32 +
.../payment/src/Models/PaymentTransaction.php | 31 +
.../payment/src/PluginServiceProvider.php | 198 +
.../larablog/plugin-marketplace/plugin.json | 7 +
.../src/PluginServiceProvider.php | 19 +
.../larablog/theme-marketplace/plugin.json | 7 +
.../src/PluginServiceProvider.php | 19 +
public/.htaccess | 25 +
public/css/filament/filament/app.css | 2 +
public/favicon.ico | 0
.../fonts/filament/filament/inter/index.css | 1 +
...er-cyrillic-ext-wght-normal-IYF56FF6.woff2 | Bin 0 -> 25960 bytes
.../inter-cyrillic-wght-normal-JEOLYBOO.woff2 | Bin 0 -> 18748 bytes
...inter-greek-ext-wght-normal-EOVOK2B5.woff2 | Bin 0 -> 11232 bytes
.../inter-greek-wght-normal-IRE366VL.woff2 | Bin 0 -> 18996 bytes
...inter-latin-ext-wght-normal-HA22NDSG.woff2 | Bin 0 -> 85068 bytes
.../inter-latin-wght-normal-NRMW37G5.woff2 | Bin 0 -> 48256 bytes
...nter-vietnamese-wght-normal-CE5GGD3W.woff2 | Bin 0 -> 10252 bytes
public/index.php | 20 +
public/js/filament/actions/actions.js | 1 +
public/js/filament/filament/app.js | 1 +
public/js/filament/filament/echo.js | 13 +
.../forms/components/checkbox-list.js | 1 +
.../filament/forms/components/code-editor.js | 38 +
.../filament/forms/components/color-picker.js | 1 +
.../forms/components/date-time-picker.js | 1 +
.../filament/forms/components/file-upload.js | 116 +
.../js/filament/forms/components/key-value.js | 1 +
.../forms/components/markdown-editor.js | 51 +
.../filament/forms/components/rich-editor.js | 161 +
public/js/filament/forms/components/select.js | 11 +
public/js/filament/forms/components/slider.js | 1 +
.../filament/forms/components/tags-input.js | 1 +
.../js/filament/forms/components/textarea.js | 1 +
.../filament/notifications/notifications.js | 1 +
.../js/filament/schemas/components/actions.js | 1 +
public/js/filament/schemas/components/tabs.js | 1 +
.../js/filament/schemas/components/wizard.js | 1 +
public/js/filament/schemas/schemas.js | 1 +
public/js/filament/support/support.js | 46 +
.../tables/components/columns/checkbox.js | 1 +
.../tables/components/columns/select.js | 11 +
.../tables/components/columns/text-input.js | 1 +
.../tables/components/columns/toggle.js | 1 +
public/js/filament/tables/tables.js | 1 +
.../js/filament/widgets/components/chart.js | 31 +
.../components/stats-overview/stat/chart.js | 20 +
public/robots.txt | 2 +
public/themes/default/preview.svg | 27 +
public/themes/default/style.css | 443 +
public/themes/example/preview.svg | 27 +
public/themes/example/style.css | 128 +
resources/css/app.css | 11 +
resources/js/app.js | 1 +
resources/js/bootstrap.js | 4 +
.../filament/pages/manage-plugins.blade.php | 82 +
.../filament/pages/manage-themes.blade.php | 130 +
.../filament/pages/plugin-skeleton.blade.php | 20 +
.../filament/pages/site-settings.blade.php | 4 +
.../admin/clear-cache-button.blade.php | 5 +
resources/views/rss/feed.blade.php | 25 +
resources/views/welcome.blade.php | 277 +
routes/api.php | 23 +
routes/console.php | 14 +
routes/legacy.php | 69 +
routes/web.php | 78 +
storage/app/.gitignore | 4 +
storage/app/private/.gitignore | 2 +
storage/app/public/.gitignore | 2 +
storage/framework/.gitignore | 9 +
storage/framework/cache/.gitignore | 3 +
storage/framework/cache/data/.gitignore | 2 +
storage/framework/sessions/.gitignore | 2 +
storage/framework/testing/.gitignore | 2 +
storage/framework/views/.gitignore | 2 +
storage/logs/.gitignore | 2 +
tests/Feature/AiPipelineTest.php | 81 +
tests/Feature/ApiV1Test.php | 36 +
tests/Feature/AuthFrontendTest.php | 65 +
tests/Feature/BlogFrontendTest.php | 40 +
tests/Feature/ExampleTest.php | 16 +
tests/Feature/LocalAttachmentServeTest.php | 87 +
tests/Feature/PaidContentCommerceTest.php | 382 +
tests/Feature/SablogImportTest.php | 99 +
tests/TestCase.php | 10 +
tests/Unit/AttachEmbedTest.php | 66 +
tests/Unit/ContentRendererTest.php | 37 +
tests/Unit/ContentTocTest.php | 41 +
tests/Unit/ExampleTest.php | 16 +
tests/Unit/ThemeSlotReportTest.php | 35 +
tests/fixtures/sablog/README.md | 8 +
.../sablog/attachments/2024/08/demo.txt | 1 +
tests/fixtures/sablog/schema.sql | 103 +
tests/fixtures/sablog/seed.sql | 45 +
themes/default/assets/preview.svg | 27 +
themes/default/assets/style.css | 463 +
themes/default/theme.json | 7 +
themes/default/views/.gitkeep | 0
themes/default/views/article.blade.php | 101 +
themes/default/views/comments.blade.php | 17 +
themes/default/views/home.blade.php | 24 +
themes/default/views/layout.blade.php | 113 +
themes/default/views/links.blade.php | 14 +
themes/default/views/login.blade.php | 21 +
themes/default/views/password.blade.php | 17 +
themes/default/views/paywall.blade.php | 32 +
themes/default/views/profile.blade.php | 33 +
themes/default/views/register.blade.php | 30 +
themes/default/views/search.blade.php | 21 +
themes/default/views/tag.blade.php | 13 +
themes/default/views/tags.blade.php | 13 +
themes/example/assets/preview.svg | 27 +
themes/example/assets/style.css | 128 +
themes/example/theme.json | 7 +
themes/example/views/.gitkeep | 0
themes/example/views/layout.blade.php | 98 +
vite.config.js | 18 +
337 files changed, 31393 insertions(+)
create mode 100644 .editorconfig
create mode 100644 .env.example
create mode 100644 .gitattributes
create mode 100644 .github/workflows/ci.yml
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 app/Auth/LaraBlogUserProvider.php
create mode 100644 app/Console/Commands/PluginsSyncCommand.php
create mode 100644 app/Console/Commands/QueueAiWorkCommand.php
create mode 100644 app/Console/Commands/SablogImportCommand.php
create mode 100644 app/Console/Commands/ThemesPublishCommand.php
create mode 100644 app/Console/Commands/WorkermanAiCommand.php
create mode 100644 app/Contracts/LlmProvider.php
create mode 100644 app/Domain/Ai/Jobs/GenerateArticleCoverJob.php
create mode 100644 app/Domain/Ai/Jobs/ModerateCommentJob.php
create mode 100644 app/Domain/Ai/Jobs/OptimizeArticleContentJob.php
create mode 100644 app/Domain/Ai/OpenAiCompatibleLlmProvider.php
create mode 100644 app/Domain/Ai/StubLlmProvider.php
create mode 100644 app/Domain/Blog/AccessDecision.php
create mode 100644 app/Domain/Blog/ArticleAccess.php
create mode 100644 app/Domain/Blog/ArticleExcerpt.php
create mode 100644 app/Domain/Blog/AttachEmbed.php
create mode 100644 app/Domain/Blog/ContentFormat.php
create mode 100644 app/Domain/Blog/ContentRenderer.php
create mode 100644 app/Domain/Blog/HtmlTeaser.php
create mode 100644 app/Domain/Media/AttachmentStorageService.php
create mode 100644 app/Domain/Plugin/Hook.php
create mode 100644 app/Domain/Plugin/PluginManager.php
create mode 100644 app/Domain/Seo/SeoPresenter.php
create mode 100644 app/Domain/Theme/RegistersSnippetSlots.php
create mode 100644 app/Domain/Theme/ThemeManager.php
create mode 100644 app/Domain/Theme/ThemeSlot.php
create mode 100644 app/Domain/Theme/ThemeSlotReport.php
create mode 100644 app/Filament/Concerns/HasTranslatedLabels.php
create mode 100644 app/Filament/Pages/ManagePlugins.php
create mode 100644 app/Filament/Pages/ManageThemes.php
create mode 100644 app/Filament/Pages/MembershipPluginPage.php
create mode 100644 app/Filament/Pages/PaymentPluginPage.php
create mode 100644 app/Filament/Pages/PluginMarketplacePage.php
create mode 100644 app/Filament/Pages/PluginSkeletonPage.php
create mode 100644 app/Filament/Pages/SiteSettings.php
create mode 100644 app/Filament/Pages/ThemeMarketplacePage.php
create mode 100644 app/Filament/Resources/Articles/ArticleResource.php
create mode 100644 app/Filament/Resources/Articles/Pages/CreateArticle.php
create mode 100644 app/Filament/Resources/Articles/Pages/EditArticle.php
create mode 100644 app/Filament/Resources/Articles/Pages/ListArticles.php
create mode 100644 app/Filament/Resources/Articles/Schemas/ArticleForm.php
create mode 100644 app/Filament/Resources/Articles/Tables/ArticlesTable.php
create mode 100644 app/Filament/Resources/Attachments/AttachmentResource.php
create mode 100644 app/Filament/Resources/Attachments/Pages/CreateAttachment.php
create mode 100644 app/Filament/Resources/Attachments/Pages/EditAttachment.php
create mode 100644 app/Filament/Resources/Attachments/Pages/ListAttachments.php
create mode 100644 app/Filament/Resources/Attachments/Schemas/AttachmentForm.php
create mode 100644 app/Filament/Resources/Attachments/Tables/AttachmentsTable.php
create mode 100644 app/Filament/Resources/Categories/CategoryResource.php
create mode 100644 app/Filament/Resources/Categories/Pages/CreateCategory.php
create mode 100644 app/Filament/Resources/Categories/Pages/EditCategory.php
create mode 100644 app/Filament/Resources/Categories/Pages/ListCategories.php
create mode 100644 app/Filament/Resources/Categories/Schemas/CategoryForm.php
create mode 100644 app/Filament/Resources/Categories/Tables/CategoriesTable.php
create mode 100644 app/Filament/Resources/Comments/CommentResource.php
create mode 100644 app/Filament/Resources/Comments/Pages/CreateComment.php
create mode 100644 app/Filament/Resources/Comments/Pages/EditComment.php
create mode 100644 app/Filament/Resources/Comments/Pages/ListComments.php
create mode 100644 app/Filament/Resources/Comments/Schemas/CommentForm.php
create mode 100644 app/Filament/Resources/Comments/Tables/CommentsTable.php
create mode 100644 app/Filament/Resources/Links/LinkResource.php
create mode 100644 app/Filament/Resources/Links/Pages/CreateLink.php
create mode 100644 app/Filament/Resources/Links/Pages/EditLink.php
create mode 100644 app/Filament/Resources/Links/Pages/ListLinks.php
create mode 100644 app/Filament/Resources/Links/Schemas/LinkForm.php
create mode 100644 app/Filament/Resources/Links/Tables/LinksTable.php
create mode 100644 app/Filament/Resources/Plugins/Pages/CreatePlugin.php
create mode 100644 app/Filament/Resources/Plugins/Pages/EditPlugin.php
create mode 100644 app/Filament/Resources/Plugins/Pages/ListPlugins.php
create mode 100644 app/Filament/Resources/Plugins/PluginResource.php
create mode 100644 app/Filament/Resources/Plugins/Schemas/PluginForm.php
create mode 100644 app/Filament/Resources/Plugins/Tables/PluginsTable.php
create mode 100644 app/Filament/Resources/Stylevars/Pages/CreateStylevar.php
create mode 100644 app/Filament/Resources/Stylevars/Pages/EditStylevar.php
create mode 100644 app/Filament/Resources/Stylevars/Pages/ListStylevars.php
create mode 100644 app/Filament/Resources/Stylevars/Schemas/StylevarForm.php
create mode 100644 app/Filament/Resources/Stylevars/StylevarResource.php
create mode 100644 app/Filament/Resources/Stylevars/Tables/StylevarsTable.php
create mode 100644 app/Filament/Resources/Tags/Pages/CreateTag.php
create mode 100644 app/Filament/Resources/Tags/Pages/EditTag.php
create mode 100644 app/Filament/Resources/Tags/Pages/ListTags.php
create mode 100644 app/Filament/Resources/Tags/Schemas/TagForm.php
create mode 100644 app/Filament/Resources/Tags/Tables/TagsTable.php
create mode 100644 app/Filament/Resources/Tags/TagResource.php
create mode 100644 app/Filament/Resources/Users/Pages/CreateUser.php
create mode 100644 app/Filament/Resources/Users/Pages/EditUser.php
create mode 100644 app/Filament/Resources/Users/Pages/ListUsers.php
create mode 100644 app/Filament/Resources/Users/Schemas/UserForm.php
create mode 100644 app/Filament/Resources/Users/Tables/UsersTable.php
create mode 100644 app/Filament/Resources/Users/UserResource.php
create mode 100644 app/Http/Controllers/Api/V1/ArticleController.php
create mode 100644 app/Http/Controllers/Api/V1/CategoryController.php
create mode 100644 app/Http/Controllers/Api/V1/MetaController.php
create mode 100644 app/Http/Controllers/Api/V1/TagController.php
create mode 100644 app/Http/Controllers/AttachmentController.php
create mode 100644 app/Http/Controllers/AuthController.php
create mode 100644 app/Http/Controllers/BlogController.php
create mode 100644 app/Http/Controllers/CommentController.php
create mode 100644 app/Http/Controllers/Controller.php
create mode 100644 app/Http/Controllers/LegacyGoneController.php
create mode 100644 app/Http/Controllers/SeoController.php
create mode 100644 app/Livewire/Admin/ClearCacheButton.php
create mode 100644 app/Models/Article.php
create mode 100644 app/Models/Attachment.php
create mode 100644 app/Models/Category.php
create mode 100644 app/Models/Comment.php
create mode 100644 app/Models/Link.php
create mode 100644 app/Models/Plugin.php
create mode 100644 app/Models/Stylevar.php
create mode 100644 app/Models/Tag.php
create mode 100644 app/Models/User.php
create mode 100644 app/Providers/AppServiceProvider.php
create mode 100644 app/Providers/Filament/AdminPanelProvider.php
create mode 100644 app/Settings/AiSettings.php
create mode 100644 app/Settings/BlogSettings.php
create mode 100644 app/Settings/CommentSettings.php
create mode 100644 app/Settings/GeneralSettings.php
create mode 100644 app/Settings/SeoSettings.php
create mode 100644 app/Settings/SnippetSettings.php
create mode 100644 app/Support/LegacyPassword.php
create mode 100755 artisan
create mode 100644 bootstrap/app.php
create mode 100644 bootstrap/cache/.gitignore
create mode 100644 bootstrap/providers.php
create mode 100644 composer.json
create mode 100644 composer.lock
create mode 100644 config/app.php
create mode 100644 config/auth.php
create mode 100644 config/cache.php
create mode 100644 config/database.php
create mode 100644 config/filesystems.php
create mode 100644 config/image.php
create mode 100644 config/larablog.php
create mode 100644 config/logging.php
create mode 100644 config/mail.php
create mode 100644 config/permission.php
create mode 100644 config/purifier.php
create mode 100644 config/queue.php
create mode 100644 config/services.php
create mode 100644 config/session.php
create mode 100644 config/settings.php
create mode 100644 database/.gitignore
create mode 100644 database/factories/UserFactory.php
create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php
create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php
create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php
create mode 100644 database/migrations/2022_12_14_083707_create_settings_table.php
create mode 100644 database/migrations/2026_08_11_092526_create_permission_tables.php
create mode 100644 database/migrations/2026_08_11_092528_create_activity_log_table.php
create mode 100644 database/migrations/2026_08_11_092529_add_event_column_to_activity_log_table.php
create mode 100644 database/migrations/2026_08_11_092530_add_batch_uuid_column_to_activity_log_table.php
create mode 100644 database/migrations/2026_08_11_100000_create_blog_tables.php
create mode 100644 database/migrations/2026_08_11_100003_drop_trackback_legacy.php
create mode 100644 database/migrations/2026_08_11_100004_add_article_content_format.php
create mode 100644 database/migrations/2026_08_11_100005_add_article_cover_placeholders.php
create mode 100644 database/seeders/DatabaseSeeder.php
create mode 100644 database/seeders/DemoBlogSeeder.php
create mode 100644 database/settings/2026_08_11_100001_create_larablog_settings.php
create mode 100644 database/settings/2026_08_11_100002_add_attachments_url_prefix_setting.php
create mode 100644 database/settings/2026_08_11_100003_remove_trackback_setting.php
create mode 100644 database/settings/2026_08_11_100004_add_content_format_settings.php
create mode 100644 database/settings/2026_08_11_220000_expand_blog_comment_settings.php
create mode 100644 database/settings/2026_08_11_230000_create_snippet_settings.php
create mode 100644 deploy/nginx.conf
create mode 100644 docs/api/README.md
create mode 100644 docs/api/openapi.yaml
create mode 100644 docs/architecture.md
create mode 100644 docs/ops/deploy.md
create mode 100644 docs/plugins.md
create mode 100644 docs/routing.md
create mode 100644 docs/specs/larablog-platform/CHECKLIST.md
create mode 100644 docs/specs/larablog-platform/SPEC.md
create mode 100644 docs/specs/larablog-platform/TESTPLAN.md
create mode 100644 docs/specs/plugin-extension-commerce/CHECKLIST.md
create mode 100644 docs/specs/plugin-extension-commerce/SPEC.md
create mode 100644 docs/specs/plugin-extension-commerce/TESTPLAN.md
create mode 100644 docs/themes.md
create mode 100644 ecosystem.config.cjs
create mode 100644 herdy.yaml
create mode 100644 lang/en/admin.php
create mode 100644 lang/en/frontend.php
create mode 100644 lang/en/payment.php
create mode 100644 lang/zh_CN/admin.php
create mode 100644 lang/zh_CN/frontend.php
create mode 100644 lang/zh_CN/pagination.php
create mode 100644 lang/zh_CN/payment.php
create mode 100644 package.json
create mode 100644 phpunit.xml
create mode 100644 plugins/larablog/ai-comment-moderation/plugin.json
create mode 100644 plugins/larablog/ai-comment-moderation/src/PluginServiceProvider.php
create mode 100644 plugins/larablog/membership/plugin.json
create mode 100644 plugins/larablog/membership/src/PluginServiceProvider.php
create mode 100644 plugins/larablog/paid-content/README.md
create mode 100644 plugins/larablog/paid-content/database/migrations/2026_08_12_002000_create_article_products_table.php
create mode 100644 plugins/larablog/paid-content/plugin.json
create mode 100644 plugins/larablog/paid-content/src/Models/ArticleProduct.php
create mode 100644 plugins/larablog/paid-content/src/PluginServiceProvider.php
create mode 100644 plugins/larablog/payment/README.md
create mode 100644 plugins/larablog/payment/database/migrations/2026_08_12_001000_create_orders_table.php
create mode 100644 plugins/larablog/payment/database/migrations/2026_08_12_001001_create_order_items_table.php
create mode 100644 plugins/larablog/payment/database/migrations/2026_08_12_001002_create_entitlements_table.php
create mode 100644 plugins/larablog/payment/database/migrations/2026_08_12_001003_create_payment_transactions_table.php
create mode 100644 plugins/larablog/payment/plugin.json
create mode 100644 plugins/larablog/payment/resources/views/checkout/confirm.blade.php
create mode 100644 plugins/larablog/payment/resources/views/checkout/error.blade.php
create mode 100644 plugins/larablog/payment/resources/views/filament/pages/payment-settings.blade.php
create mode 100644 plugins/larablog/payment/src/Domain/OrderService.php
create mode 100644 plugins/larablog/payment/src/Domain/ProductType.php
create mode 100644 plugins/larablog/payment/src/Filament/Pages/PaymentSettingsPage.php
create mode 100644 plugins/larablog/payment/src/Filament/Resources/OrderResource.php
create mode 100644 plugins/larablog/payment/src/Filament/Resources/OrderResource/Pages/ListOrders.php
create mode 100644 plugins/larablog/payment/src/Filament/Resources/OrderResource/Pages/ViewOrder.php
create mode 100644 plugins/larablog/payment/src/Models/Entitlement.php
create mode 100644 plugins/larablog/payment/src/Models/Order.php
create mode 100644 plugins/larablog/payment/src/Models/OrderItem.php
create mode 100644 plugins/larablog/payment/src/Models/PaymentTransaction.php
create mode 100644 plugins/larablog/payment/src/PluginServiceProvider.php
create mode 100644 plugins/larablog/plugin-marketplace/plugin.json
create mode 100644 plugins/larablog/plugin-marketplace/src/PluginServiceProvider.php
create mode 100644 plugins/larablog/theme-marketplace/plugin.json
create mode 100644 plugins/larablog/theme-marketplace/src/PluginServiceProvider.php
create mode 100644 public/.htaccess
create mode 100644 public/css/filament/filament/app.css
create mode 100644 public/favicon.ico
create mode 100644 public/fonts/filament/filament/inter/index.css
create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-IYF56FF6.woff2
create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-JEOLYBOO.woff2
create mode 100644 public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-EOVOK2B5.woff2
create mode 100644 public/fonts/filament/filament/inter/inter-greek-wght-normal-IRE366VL.woff2
create mode 100644 public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-HA22NDSG.woff2
create mode 100644 public/fonts/filament/filament/inter/inter-latin-wght-normal-NRMW37G5.woff2
create mode 100644 public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-CE5GGD3W.woff2
create mode 100644 public/index.php
create mode 100644 public/js/filament/actions/actions.js
create mode 100644 public/js/filament/filament/app.js
create mode 100644 public/js/filament/filament/echo.js
create mode 100644 public/js/filament/forms/components/checkbox-list.js
create mode 100644 public/js/filament/forms/components/code-editor.js
create mode 100644 public/js/filament/forms/components/color-picker.js
create mode 100644 public/js/filament/forms/components/date-time-picker.js
create mode 100644 public/js/filament/forms/components/file-upload.js
create mode 100644 public/js/filament/forms/components/key-value.js
create mode 100644 public/js/filament/forms/components/markdown-editor.js
create mode 100644 public/js/filament/forms/components/rich-editor.js
create mode 100644 public/js/filament/forms/components/select.js
create mode 100644 public/js/filament/forms/components/slider.js
create mode 100644 public/js/filament/forms/components/tags-input.js
create mode 100644 public/js/filament/forms/components/textarea.js
create mode 100644 public/js/filament/notifications/notifications.js
create mode 100644 public/js/filament/schemas/components/actions.js
create mode 100644 public/js/filament/schemas/components/tabs.js
create mode 100644 public/js/filament/schemas/components/wizard.js
create mode 100644 public/js/filament/schemas/schemas.js
create mode 100644 public/js/filament/support/support.js
create mode 100644 public/js/filament/tables/components/columns/checkbox.js
create mode 100644 public/js/filament/tables/components/columns/select.js
create mode 100644 public/js/filament/tables/components/columns/text-input.js
create mode 100644 public/js/filament/tables/components/columns/toggle.js
create mode 100644 public/js/filament/tables/tables.js
create mode 100644 public/js/filament/widgets/components/chart.js
create mode 100644 public/js/filament/widgets/components/stats-overview/stat/chart.js
create mode 100644 public/robots.txt
create mode 100644 public/themes/default/preview.svg
create mode 100644 public/themes/default/style.css
create mode 100644 public/themes/example/preview.svg
create mode 100644 public/themes/example/style.css
create mode 100644 resources/css/app.css
create mode 100644 resources/js/app.js
create mode 100644 resources/js/bootstrap.js
create mode 100644 resources/views/filament/pages/manage-plugins.blade.php
create mode 100644 resources/views/filament/pages/manage-themes.blade.php
create mode 100644 resources/views/filament/pages/plugin-skeleton.blade.php
create mode 100644 resources/views/filament/pages/site-settings.blade.php
create mode 100644 resources/views/livewire/admin/clear-cache-button.blade.php
create mode 100644 resources/views/rss/feed.blade.php
create mode 100644 resources/views/welcome.blade.php
create mode 100644 routes/api.php
create mode 100644 routes/console.php
create mode 100644 routes/legacy.php
create mode 100644 routes/web.php
create mode 100644 storage/app/.gitignore
create mode 100644 storage/app/private/.gitignore
create mode 100644 storage/app/public/.gitignore
create mode 100644 storage/framework/.gitignore
create mode 100644 storage/framework/cache/.gitignore
create mode 100644 storage/framework/cache/data/.gitignore
create mode 100644 storage/framework/sessions/.gitignore
create mode 100644 storage/framework/testing/.gitignore
create mode 100644 storage/framework/views/.gitignore
create mode 100644 storage/logs/.gitignore
create mode 100644 tests/Feature/AiPipelineTest.php
create mode 100644 tests/Feature/ApiV1Test.php
create mode 100644 tests/Feature/AuthFrontendTest.php
create mode 100644 tests/Feature/BlogFrontendTest.php
create mode 100644 tests/Feature/ExampleTest.php
create mode 100644 tests/Feature/LocalAttachmentServeTest.php
create mode 100644 tests/Feature/PaidContentCommerceTest.php
create mode 100644 tests/Feature/SablogImportTest.php
create mode 100644 tests/TestCase.php
create mode 100644 tests/Unit/AttachEmbedTest.php
create mode 100644 tests/Unit/ContentRendererTest.php
create mode 100644 tests/Unit/ContentTocTest.php
create mode 100644 tests/Unit/ExampleTest.php
create mode 100644 tests/Unit/ThemeSlotReportTest.php
create mode 100644 tests/fixtures/sablog/README.md
create mode 100644 tests/fixtures/sablog/attachments/2024/08/demo.txt
create mode 100644 tests/fixtures/sablog/schema.sql
create mode 100644 tests/fixtures/sablog/seed.sql
create mode 100644 themes/default/assets/preview.svg
create mode 100644 themes/default/assets/style.css
create mode 100644 themes/default/theme.json
create mode 100644 themes/default/views/.gitkeep
create mode 100644 themes/default/views/article.blade.php
create mode 100644 themes/default/views/comments.blade.php
create mode 100644 themes/default/views/home.blade.php
create mode 100644 themes/default/views/layout.blade.php
create mode 100644 themes/default/views/links.blade.php
create mode 100644 themes/default/views/login.blade.php
create mode 100644 themes/default/views/password.blade.php
create mode 100644 themes/default/views/paywall.blade.php
create mode 100644 themes/default/views/profile.blade.php
create mode 100644 themes/default/views/register.blade.php
create mode 100644 themes/default/views/search.blade.php
create mode 100644 themes/default/views/tag.blade.php
create mode 100644 themes/default/views/tags.blade.php
create mode 100644 themes/example/assets/preview.svg
create mode 100644 themes/example/assets/style.css
create mode 100644 themes/example/theme.json
create mode 100644 themes/example/views/.gitkeep
create mode 100644 themes/example/views/layout.blade.php
create mode 100644 vite.config.js
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..a186cd2
--- /dev/null
+++ b/.editorconfig
@@ -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
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..977002b
--- /dev/null
+++ b/.env.example
@@ -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}"
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..fcb21d3
--- /dev/null
+++ b/.gitattributes
@@ -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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..30f0a7a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..439bb4c
--- /dev/null
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c73ded5
--- /dev/null
+++ b/README.md
@@ -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
+```
diff --git a/app/Auth/LaraBlogUserProvider.php b/app/Auth/LaraBlogUserProvider.php
new file mode 100644
index 0000000..b6e5b54
--- /dev/null
+++ b/app/Auth/LaraBlogUserProvider.php
@@ -0,0 +1,71 @@
+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);
+ }
+}
diff --git a/app/Console/Commands/PluginsSyncCommand.php b/app/Console/Commands/PluginsSyncCommand.php
new file mode 100644
index 0000000..278a505
--- /dev/null
+++ b/app/Console/Commands/PluginsSyncCommand.php
@@ -0,0 +1,35 @@
+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;
+ }
+}
diff --git a/app/Console/Commands/QueueAiWorkCommand.php b/app/Console/Commands/QueueAiWorkCommand.php
new file mode 100644
index 0000000..9b1f592
--- /dev/null
+++ b/app/Console/Commands/QueueAiWorkCommand.php
@@ -0,0 +1,37 @@
+ '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);
+ }
+}
diff --git a/app/Console/Commands/SablogImportCommand.php b/app/Console/Commands/SablogImportCommand.php
new file mode 100644
index 0000000..d97ee01
--- /dev/null
+++ b/app/Console/Commands/SablogImportCommand.php
@@ -0,0 +1,473 @@
+ */
+ 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 $modelClass
+ * @param array $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;
+ }
+}
diff --git a/app/Console/Commands/ThemesPublishCommand.php b/app/Console/Commands/ThemesPublishCommand.php
new file mode 100644
index 0000000..92937c2
--- /dev/null
+++ b/app/Console/Commands/ThemesPublishCommand.php
@@ -0,0 +1,49 @@
+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;
+ }
+}
diff --git a/app/Console/Commands/WorkermanAiCommand.php b/app/Console/Commands/WorkermanAiCommand.php
new file mode 100644
index 0000000..127b87f
--- /dev/null
+++ b/app/Console/Commands/WorkermanAiCommand.php
@@ -0,0 +1,48 @@
+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;
+ }
+}
diff --git a/app/Contracts/LlmProvider.php b/app/Contracts/LlmProvider.php
new file mode 100644
index 0000000..4fd80df
--- /dev/null
+++ b/app/Contracts/LlmProvider.php
@@ -0,0 +1,19 @@
+ $context
+ * @return array
+ */
+ public function complete(string $prompt, array $context = []): array;
+
+ /**
+ * @return array{status: string, reason?: string}
+ */
+ public function moderate(string $content): array;
+}
diff --git a/app/Domain/Ai/Jobs/GenerateArticleCoverJob.php b/app/Domain/Ai/Jobs/GenerateArticleCoverJob.php
new file mode 100644
index 0000000..1c46e5b
--- /dev/null
+++ b/app/Domain/Ai/Jobs/GenerateArticleCoverJob.php
@@ -0,0 +1,44 @@
+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,
+ ]);
+ }
+}
diff --git a/app/Domain/Ai/Jobs/ModerateCommentJob.php b/app/Domain/Ai/Jobs/ModerateCommentJob.php
new file mode 100644
index 0000000..8a55eb4
--- /dev/null
+++ b/app/Domain/Ai/Jobs/ModerateCommentJob.php
@@ -0,0 +1,63 @@
+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]);
+ }
+ }
+}
diff --git a/app/Domain/Ai/Jobs/OptimizeArticleContentJob.php b/app/Domain/Ai/Jobs/OptimizeArticleContentJob.php
new file mode 100644
index 0000000..9febd99
--- /dev/null
+++ b/app/Domain/Ai/Jobs/OptimizeArticleContentJob.php
@@ -0,0 +1,53 @@
+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(),
+ ]);
+ }
+ }
+}
diff --git a/app/Domain/Ai/OpenAiCompatibleLlmProvider.php b/app/Domain/Ai/OpenAiCompatibleLlmProvider.php
new file mode 100644
index 0000000..b57a372
--- /dev/null
+++ b/app/Domain/Ai/OpenAiCompatibleLlmProvider.php
@@ -0,0 +1,107 @@
+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 $payload
+ * @return array
+ */
+ 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;
+ }
+}
diff --git a/app/Domain/Ai/StubLlmProvider.php b/app/Domain/Ai/StubLlmProvider.php
new file mode 100644
index 0000000..89bbf36
--- /dev/null
+++ b/app/Domain/Ai/StubLlmProvider.php
@@ -0,0 +1,31 @@
+ '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,
+ ];
+ }
+}
diff --git a/app/Domain/Blog/AccessDecision.php b/app/Domain/Blog/AccessDecision.php
new file mode 100644
index 0000000..3283947
--- /dev/null
+++ b/app/Domain/Blog/AccessDecision.php
@@ -0,0 +1,101 @@
+ */
+ 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,
+ ];
+ }
+}
diff --git a/app/Domain/Blog/ArticleAccess.php b/app/Domain/Blog/ArticleAccess.php
new file mode 100644
index 0000000..47aab0f
--- /dev/null
+++ b/app/Domain/Blog/ArticleAccess.php
@@ -0,0 +1,75 @@
+|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');
+ }
+}
diff --git a/app/Domain/Blog/ArticleExcerpt.php b/app/Domain/Blog/ArticleExcerpt.php
new file mode 100644
index 0000000..de80500
--- /dev/null
+++ b/app/Domain/Blog/ArticleExcerpt.php
@@ -0,0 +1,34 @@
+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);
+ }
+}
diff --git a/app/Domain/Blog/AttachEmbed.php b/app/Domain/Blog/AttachEmbed.php
new file mode 100644
index 0000000..e49a44e
--- /dev/null
+++ b/app/Domain/Blog/AttachEmbed.php
@@ -0,0 +1,198 @@
+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}
+ */
+ 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 $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 '
';
+ }
+
+ $label = e($attachment?->filename ?: "attachment-{$id}");
+
+ return ''.$label.'';
+ }, $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);
+ }
+}
diff --git a/app/Domain/Blog/ContentFormat.php b/app/Domain/Blog/ContentFormat.php
new file mode 100644
index 0000000..7977474
--- /dev/null
+++ b/app/Domain/Blog/ContentFormat.php
@@ -0,0 +1,29 @@
+render($content, $format, $articleId)['html'];
+ }
+
+ /**
+ * @return array{html: string, toc: list}
+ */
+ 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}
+ */
+ protected function applyTocAnchors(string $html): array
+ {
+ if (trim($html) === '') {
+ return ['html' => '', 'toc' => []];
+ }
+
+ $document = new DOMDocument;
+ $previous = libxml_use_internal_errors(true);
+ $document->loadHTML(
+ ''.$html.'
',
+ 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';
+ }
+}
diff --git a/app/Domain/Blog/HtmlTeaser.php b/app/Domain/Blog/HtmlTeaser.php
new file mode 100644
index 0000000..e2670ae
--- /dev/null
+++ b/app/Domain/Blog/HtmlTeaser.php
@@ -0,0 +1,41 @@
+'.e($snippet).'
', 'article');
+ }
+}
diff --git a/app/Domain/Media/AttachmentStorageService.php b/app/Domain/Media/AttachmentStorageService.php
new file mode 100644
index 0000000..fe8aaf5
--- /dev/null
+++ b/app/Domain/Media/AttachmentStorageService.php
@@ -0,0 +1,193 @@
+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'), '/');
+ }
+}
diff --git a/app/Domain/Plugin/Hook.php b/app/Domain/Plugin/Hook.php
new file mode 100644
index 0000000..f6feb39
--- /dev/null
+++ b/app/Domain/Plugin/Hook.php
@@ -0,0 +1,96 @@
+> */
+ 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 $initial
+ * @return array
+ */
+ 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
+ */
+ public static function listeners(string $event): array
+ {
+ return static::$listeners[$event] ?? [];
+ }
+
+ public static function flush(): void
+ {
+ static::$listeners = [];
+ }
+}
diff --git a/app/Domain/Plugin/PluginManager.php b/app/Domain/Plugin/PluginManager.php
new file mode 100644
index 0000000..d61e265
--- /dev/null
+++ b/app/Domain/Plugin/PluginManager.php
@@ -0,0 +1,243 @@
+> */
+ 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
+ */
+ 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
+ */
+ 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;
+ }
+}
diff --git a/app/Domain/Seo/SeoPresenter.php b/app/Domain/Seo/SeoPresenter.php
new file mode 100644
index 0000000..8200339
--- /dev/null
+++ b/app/Domain/Seo/SeoPresenter.php
@@ -0,0 +1,150 @@
+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, twitter: array, jsonld: array}
+ */
+ 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, twitter: array, jsonld: array}
+ */
+ 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
+ */
+ 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
+ */
+ 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
+ */
+ 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'),
+ ];
+ }
+}
diff --git a/app/Domain/Theme/RegistersSnippetSlots.php b/app/Domain/Theme/RegistersSnippetSlots.php
new file mode 100644
index 0000000..f97cd3d
--- /dev/null
+++ b/app/Domain/Theme/RegistersSnippetSlots.php
@@ -0,0 +1,34 @@
+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;
+ });
+ }
+ }
+}
diff --git a/app/Domain/Theme/ThemeManager.php b/app/Domain/Theme/ThemeManager.php
new file mode 100644
index 0000000..b00cbdc
--- /dev/null
+++ b/app/Domain/Theme/ThemeManager.php
@@ -0,0 +1,192 @@
+> */
+ 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
+ */
+ 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
+ */
+ 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;
+ }
+ }
+}
diff --git a/app/Domain/Theme/ThemeSlot.php b/app/Domain/Theme/ThemeSlot.php
new file mode 100644
index 0000000..b5d41ed
--- /dev/null
+++ b/app/Domain/Theme/ThemeSlot.php
@@ -0,0 +1,112 @@
+ */
+ 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
+ */
+ 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,
+ ]);
+ }
+}
diff --git a/app/Domain/Theme/ThemeSlotReport.php b/app/Domain/Theme/ThemeSlotReport.php
new file mode 100644
index 0000000..831d190
--- /dev/null
+++ b/app/Domain/Theme/ThemeSlotReport.php
@@ -0,0 +1,222 @@
+,
+ * scanned: list,
+ * status: 'full'|'partial'|'undeclared'|'mismatch',
+ * missing_standard: list,
+ * declared_but_unused: list,
+ * used_but_undeclared: list,
+ * label: string,
+ * }
+ */
+final class ThemeSlotReport
+{
+ /**
+ * @param array $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|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 */
+ 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 */
+ 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 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 */
+ 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);
+ }
+}
diff --git a/app/Filament/Concerns/HasTranslatedLabels.php b/app/Filament/Concerns/HasTranslatedLabels.php
new file mode 100644
index 0000000..9278d64
--- /dev/null
+++ b/app/Filament/Concerns/HasTranslatedLabels.php
@@ -0,0 +1,40 @@
+> */
+ 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();
+ }),
+ ];
+ }
+}
diff --git a/app/Filament/Pages/ManageThemes.php b/app/Filament/Pages/ManageThemes.php
new file mode 100644
index 0000000..c9e568b
--- /dev/null
+++ b/app/Filament/Pages/ManageThemes.php
@@ -0,0 +1,113 @@
+> */
+ 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();
+ }),
+ ];
+ }
+}
diff --git a/app/Filament/Pages/MembershipPluginPage.php b/app/Filament/Pages/MembershipPluginPage.php
new file mode 100644
index 0000000..446bdc6
--- /dev/null
+++ b/app/Filament/Pages/MembershipPluginPage.php
@@ -0,0 +1,25 @@
+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(),
+ ];
+ }
+}
diff --git a/app/Filament/Pages/SiteSettings.php b/app/Filament/Pages/SiteSettings.php
new file mode 100644
index 0000000..1ec9d22
--- /dev/null
+++ b/app/Filament/Pages/SiteSettings.php
@@ -0,0 +1,357 @@
+|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();
+ }
+}
diff --git a/app/Filament/Pages/ThemeMarketplacePage.php b/app/Filament/Pages/ThemeMarketplacePage.php
new file mode 100644
index 0000000..1ee04a0
--- /dev/null
+++ b/app/Filament/Pages/ThemeMarketplacePage.php
@@ -0,0 +1,25 @@
+ ListArticles::route('/'),
+ 'create' => CreateArticle::route('/create'),
+ 'edit' => EditArticle::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Articles/Pages/CreateArticle.php b/app/Filament/Resources/Articles/Pages/CreateArticle.php
new file mode 100644
index 0000000..0a9d4b1
--- /dev/null
+++ b/app/Filament/Resources/Articles/Pages/CreateArticle.php
@@ -0,0 +1,30 @@
+ $data
+ * @return array
+ */
+ 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());
+ }
+}
diff --git a/app/Filament/Resources/Articles/Pages/EditArticle.php b/app/Filament/Resources/Articles/Pages/EditArticle.php
new file mode 100644
index 0000000..23908d2
--- /dev/null
+++ b/app/Filament/Resources/Articles/Pages/EditArticle.php
@@ -0,0 +1,63 @@
+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 $data
+ * @return array
+ */
+ 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 $data
+ * @return array
+ */
+ 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());
+ }
+}
diff --git a/app/Filament/Resources/Articles/Pages/ListArticles.php b/app/Filament/Resources/Articles/Pages/ListArticles.php
new file mode 100644
index 0000000..1c4ce23
--- /dev/null
+++ b/app/Filament/Resources/Articles/Pages/ListArticles.php
@@ -0,0 +1,21 @@
+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'),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Articles/Tables/ArticlesTable.php b/app/Filament/Resources/Articles/Tables/ArticlesTable.php
new file mode 100644
index 0000000..416c5c2
--- /dev/null
+++ b/app/Filament/Resources/Articles/Tables/ArticlesTable.php
@@ -0,0 +1,88 @@
+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(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Attachments/AttachmentResource.php b/app/Filament/Resources/Attachments/AttachmentResource.php
new file mode 100644
index 0000000..8f4e5c5
--- /dev/null
+++ b/app/Filament/Resources/Attachments/AttachmentResource.php
@@ -0,0 +1,71 @@
+ ListAttachments::route('/'),
+ 'create' => CreateAttachment::route('/create'),
+ 'edit' => EditAttachment::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Attachments/Pages/CreateAttachment.php b/app/Filament/Resources/Attachments/Pages/CreateAttachment.php
new file mode 100644
index 0000000..940f56c
--- /dev/null
+++ b/app/Filament/Resources/Attachments/Pages/CreateAttachment.php
@@ -0,0 +1,49 @@
+ $data
+ * @return array
+ */
+ 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;
+ }
+}
diff --git a/app/Filament/Resources/Attachments/Pages/EditAttachment.php b/app/Filament/Resources/Attachments/Pages/EditAttachment.php
new file mode 100644
index 0000000..c918fd0
--- /dev/null
+++ b/app/Filament/Resources/Attachments/Pages/EditAttachment.php
@@ -0,0 +1,26 @@
+before(function (Attachment $record, AttachmentStorageService $storage): void {
+ $storage->deleteFromDisk($record);
+ }),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Attachments/Pages/ListAttachments.php b/app/Filament/Resources/Attachments/Pages/ListAttachments.php
new file mode 100644
index 0000000..58679f0
--- /dev/null
+++ b/app/Filament/Resources/Attachments/Pages/ListAttachments.php
@@ -0,0 +1,21 @@
+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(),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Attachments/Tables/AttachmentsTable.php b/app/Filament/Resources/Attachments/Tables/AttachmentsTable.php
new file mode 100644
index 0000000..d011c36
--- /dev/null
+++ b/app/Filament/Resources/Attachments/Tables/AttachmentsTable.php
@@ -0,0 +1,81 @@
+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(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Categories/CategoryResource.php b/app/Filament/Resources/Categories/CategoryResource.php
new file mode 100644
index 0000000..cc6a124
--- /dev/null
+++ b/app/Filament/Resources/Categories/CategoryResource.php
@@ -0,0 +1,71 @@
+ ListCategories::route('/'),
+ 'create' => CreateCategory::route('/create'),
+ 'edit' => EditCategory::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Categories/Pages/CreateCategory.php b/app/Filament/Resources/Categories/Pages/CreateCategory.php
new file mode 100644
index 0000000..648b2af
--- /dev/null
+++ b/app/Filament/Resources/Categories/Pages/CreateCategory.php
@@ -0,0 +1,13 @@
+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),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Categories/Tables/CategoriesTable.php b/app/Filament/Resources/Categories/Tables/CategoriesTable.php
new file mode 100644
index 0000000..acf429d
--- /dev/null
+++ b/app/Filament/Resources/Categories/Tables/CategoriesTable.php
@@ -0,0 +1,53 @@
+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(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Comments/CommentResource.php b/app/Filament/Resources/Comments/CommentResource.php
new file mode 100644
index 0000000..c9af343
--- /dev/null
+++ b/app/Filament/Resources/Comments/CommentResource.php
@@ -0,0 +1,71 @@
+ ListComments::route('/'),
+ 'create' => CreateComment::route('/create'),
+ 'edit' => EditComment::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Comments/Pages/CreateComment.php b/app/Filament/Resources/Comments/Pages/CreateComment.php
new file mode 100644
index 0000000..2471711
--- /dev/null
+++ b/app/Filament/Resources/Comments/Pages/CreateComment.php
@@ -0,0 +1,13 @@
+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')),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Comments/Tables/CommentsTable.php b/app/Filament/Resources/Comments/Tables/CommentsTable.php
new file mode 100644
index 0000000..b97ca3e
--- /dev/null
+++ b/app/Filament/Resources/Comments/Tables/CommentsTable.php
@@ -0,0 +1,61 @@
+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(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Links/LinkResource.php b/app/Filament/Resources/Links/LinkResource.php
new file mode 100644
index 0000000..b05abd8
--- /dev/null
+++ b/app/Filament/Resources/Links/LinkResource.php
@@ -0,0 +1,71 @@
+ ListLinks::route('/'),
+ 'create' => CreateLink::route('/create'),
+ 'edit' => EditLink::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Links/Pages/CreateLink.php b/app/Filament/Resources/Links/Pages/CreateLink.php
new file mode 100644
index 0000000..feddbc2
--- /dev/null
+++ b/app/Filament/Resources/Links/Pages/CreateLink.php
@@ -0,0 +1,13 @@
+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(),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Links/Tables/LinksTable.php b/app/Filament/Resources/Links/Tables/LinksTable.php
new file mode 100644
index 0000000..f3429c4
--- /dev/null
+++ b/app/Filament/Resources/Links/Tables/LinksTable.php
@@ -0,0 +1,56 @@
+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(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Plugins/Pages/CreatePlugin.php b/app/Filament/Resources/Plugins/Pages/CreatePlugin.php
new file mode 100644
index 0000000..98c350a
--- /dev/null
+++ b/app/Filament/Resources/Plugins/Pages/CreatePlugin.php
@@ -0,0 +1,13 @@
+ ListPlugins::route('/'),
+ 'create' => CreatePlugin::route('/create'),
+ 'edit' => EditPlugin::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Plugins/Schemas/PluginForm.php b/app/Filament/Resources/Plugins/Schemas/PluginForm.php
new file mode 100644
index 0000000..9dfff28
--- /dev/null
+++ b/app/Filament/Resources/Plugins/Schemas/PluginForm.php
@@ -0,0 +1,36 @@
+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(),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Plugins/Tables/PluginsTable.php b/app/Filament/Resources/Plugins/Tables/PluginsTable.php
new file mode 100644
index 0000000..156a222
--- /dev/null
+++ b/app/Filament/Resources/Plugins/Tables/PluginsTable.php
@@ -0,0 +1,55 @@
+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(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Stylevars/Pages/CreateStylevar.php b/app/Filament/Resources/Stylevars/Pages/CreateStylevar.php
new file mode 100644
index 0000000..f719193
--- /dev/null
+++ b/app/Filament/Resources/Stylevars/Pages/CreateStylevar.php
@@ -0,0 +1,13 @@
+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),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Stylevars/StylevarResource.php b/app/Filament/Resources/Stylevars/StylevarResource.php
new file mode 100644
index 0000000..40005ad
--- /dev/null
+++ b/app/Filament/Resources/Stylevars/StylevarResource.php
@@ -0,0 +1,65 @@
+ ListStylevars::route('/'),
+ 'create' => CreateStylevar::route('/create'),
+ 'edit' => EditStylevar::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Stylevars/Tables/StylevarsTable.php b/app/Filament/Resources/Stylevars/Tables/StylevarsTable.php
new file mode 100644
index 0000000..cf763a0
--- /dev/null
+++ b/app/Filament/Resources/Stylevars/Tables/StylevarsTable.php
@@ -0,0 +1,47 @@
+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(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Tags/Pages/CreateTag.php b/app/Filament/Resources/Tags/Pages/CreateTag.php
new file mode 100644
index 0000000..4a7d2ac
--- /dev/null
+++ b/app/Filament/Resources/Tags/Pages/CreateTag.php
@@ -0,0 +1,13 @@
+components([
+ TextInput::make('name')
+ ->label(__('admin.fields.name'))
+ ->required(),
+ TextInput::make('use_count')
+ ->label(__('admin.fields.use_count'))
+ ->required()
+ ->numeric()
+ ->default(0),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Tags/Tables/TagsTable.php b/app/Filament/Resources/Tags/Tables/TagsTable.php
new file mode 100644
index 0000000..803a29a
--- /dev/null
+++ b/app/Filament/Resources/Tags/Tables/TagsTable.php
@@ -0,0 +1,49 @@
+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(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Tags/TagResource.php b/app/Filament/Resources/Tags/TagResource.php
new file mode 100644
index 0000000..369c6b6
--- /dev/null
+++ b/app/Filament/Resources/Tags/TagResource.php
@@ -0,0 +1,71 @@
+ ListTags::route('/'),
+ 'create' => CreateTag::route('/create'),
+ 'edit' => EditTag::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Users/Pages/CreateUser.php b/app/Filament/Resources/Users/Pages/CreateUser.php
new file mode 100644
index 0000000..aa160e7
--- /dev/null
+++ b/app/Filament/Resources/Users/Pages/CreateUser.php
@@ -0,0 +1,13 @@
+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(),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php
new file mode 100644
index 0000000..ade08a1
--- /dev/null
+++ b/app/Filament/Resources/Users/Tables/UsersTable.php
@@ -0,0 +1,54 @@
+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(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Users/UserResource.php b/app/Filament/Resources/Users/UserResource.php
new file mode 100644
index 0000000..bb9b157
--- /dev/null
+++ b/app/Filament/Resources/Users/UserResource.php
@@ -0,0 +1,65 @@
+ ListUsers::route('/'),
+ 'create' => CreateUser::route('/create'),
+ 'edit' => EditUser::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Http/Controllers/Api/V1/ArticleController.php b/app/Http/Controllers/Api/V1/ArticleController.php
new file mode 100644
index 0000000..91bce87
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/ArticleController.php
@@ -0,0 +1,102 @@
+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
+ */
+ 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(),
+ ];
+ }
+}
diff --git a/app/Http/Controllers/Api/V1/CategoryController.php b/app/Http/Controllers/Api/V1/CategoryController.php
new file mode 100644
index 0000000..53e32eb
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/CategoryController.php
@@ -0,0 +1,48 @@
+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(),
+ ],
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/V1/MetaController.php b/app/Http/Controllers/Api/V1/MetaController.php
new file mode 100644
index 0000000..157978a
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/MetaController.php
@@ -0,0 +1,32 @@
+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'),
+ ],
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/V1/TagController.php b/app/Http/Controllers/Api/V1/TagController.php
new file mode 100644
index 0000000..e273560
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/TagController.php
@@ -0,0 +1,45 @@
+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(),
+ ],
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php
new file mode 100644
index 0000000..038081c
--- /dev/null
+++ b/app/Http/Controllers/AttachmentController.php
@@ -0,0 +1,27 @@
+integer('id');
+
+ return $this->attachments->resolveRedirectResponseById($id, $request->ip());
+ }
+
+ public function byPath(string $path)
+ {
+ return $this->attachments->resolveRedirectResponseByLegacyPath($path, request()->ip());
+ }
+}
diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php
new file mode 100644
index 0000000..12e8cbd
--- /dev/null
+++ b/app/Http/Controllers/AuthController.php
@@ -0,0 +1,145 @@
+ $settings,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'seo' => ['title' => '登录 - '.$settings->site_name],
+ ]);
+ }
+
+ public function showRegister(GeneralSettings $settings): View
+ {
+ return view('theme::register', [
+ 'settings' => $settings,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'seo' => ['title' => '注册 - '.$settings->site_name],
+ ]);
+ }
+
+ public function showProfile(GeneralSettings $settings): View|RedirectResponse
+ {
+ if (! Auth::check()) {
+ return redirect('/login.shtml');
+ }
+
+ return view('theme::profile', [
+ 'user' => Auth::user(),
+ 'settings' => $settings,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'seo' => ['title' => '资料 - '.$settings->site_name],
+ ]);
+ }
+
+ public function login(Request $request): RedirectResponse
+ {
+ $key = 'login:'.$request->ip();
+ if (RateLimiter::tooManyAttempts($key, 10)) {
+ abort(429, 'Too many login attempts.');
+ }
+ RateLimiter::hit($key, 60);
+
+ $data = $request->validate([
+ 'login' => ['required', 'string', 'max:120'],
+ 'password' => ['required', 'string', 'max:120'],
+ ]);
+
+ if (! Auth::attempt(['login' => $data['login'], 'password' => $data['password']], $request->boolean('remember'))) {
+ return back()->withErrors(['login' => '用户名或密码错误'])->withInput($request->only('login'));
+ }
+
+ $request->session()->regenerate();
+
+ /** @var User $user */
+ $user = Auth::user();
+ $user->forceFill([
+ 'login_count' => ((int) $user->login_count) + 1,
+ 'login_ip' => $request->ip(),
+ 'login_at' => now(),
+ ])->save();
+
+ return redirect()->intended('/')->with('status', '登录成功');
+ }
+
+ public function register(Request $request): RedirectResponse
+ {
+ $key = 'register:'.$request->ip();
+ if (RateLimiter::tooManyAttempts($key, 5)) {
+ abort(429, 'Too many registration attempts.');
+ }
+ RateLimiter::hit($key, 3600);
+
+ $data = $request->validate([
+ 'username' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash', 'unique:users,username'],
+ 'email' => ['nullable', 'email', 'max:120', 'unique:users,email'],
+ 'password' => ['required', 'string', 'min:6', 'max:120', 'confirmed'],
+ 'url' => ['nullable', 'url', 'max:255'],
+ ]);
+
+ Role::findOrCreate('member');
+
+ $user = User::query()->create([
+ 'name' => $data['username'],
+ 'username' => $data['username'],
+ 'email' => $data['email'] ?? null,
+ 'password' => $data['password'],
+ 'url' => $data['url'] ?? null,
+ 'reg_ip' => $request->ip(),
+ ]);
+ $user->assignRole('member');
+
+ Auth::login($user);
+ $request->session()->regenerate();
+
+ return redirect('/')->with('status', '注册成功');
+ }
+
+ public function updateProfile(Request $request): RedirectResponse
+ {
+ /** @var User $user */
+ $user = Auth::user();
+ abort_unless($user !== null, 403);
+
+ $data = $request->validate([
+ 'url' => ['nullable', 'url', 'max:255'],
+ 'email' => ['nullable', 'email', 'max:120', 'unique:users,email,'.$user->id],
+ 'password' => ['nullable', 'string', 'min:6', 'max:120', 'confirmed'],
+ ]);
+
+ $user->url = $data['url'] ?? null;
+ $user->email = $data['email'] ?? null;
+ if (! empty($data['password'])) {
+ $user->password = $data['password'];
+ $user->password_legacy = null;
+ }
+ $user->save();
+
+ return back()->with('status', '资料已更新');
+ }
+
+ public function logout(Request $request): RedirectResponse
+ {
+ Auth::logout();
+ $request->session()->invalidate();
+ $request->session()->regenerateToken();
+
+ return redirect('/')->with('status', '已退出登录');
+ }
+}
diff --git a/app/Http/Controllers/BlogController.php b/app/Http/Controllers/BlogController.php
new file mode 100644
index 0000000..e16dfbf
--- /dev/null
+++ b/app/Http/Controllers/BlogController.php
@@ -0,0 +1,240 @@
+query('action');
+ if ($action === 'tags' && $request->filled('item')) {
+ return redirect('/tag/'.rawurlencode((string) $request->query('item')), 301);
+ }
+
+ if (is_string($action) && $action !== '') {
+ return match ($action) {
+ 'login' => app(AuthController::class)->showLogin($settings),
+ 'reg' => app(AuthController::class)->showRegister($settings),
+ 'profile' => app(AuthController::class)->showProfile($settings),
+ 'links' => $this->links(),
+ 'comments' => $this->comments(),
+ 'tagslist' => $this->tagsList(),
+ 'search' => $this->search($request),
+ 'show' => $this->show($request, (int) $request->query('id'), $settings, $seo),
+ 'tags' => $this->tagsList(),
+ default => $this->indexWithoutAction($request, $settings, $seo),
+ };
+ }
+
+ return $this->indexWithoutAction($request, $settings, $seo);
+ }
+
+ protected function indexWithoutAction(Request $request, GeneralSettings $settings, SeoPresenter $seo): View
+ {
+ $cid = $request->integer('cid') ?: null;
+ $setdate = $request->query('setdate');
+ $perPage = max(1, (int) app(BlogSettings::class)->posts_per_page);
+
+ $query = Article::query()
+ ->with(['category', 'user', 'tags'])
+ ->visible()
+ ->published()
+ ->orderByDesc('stick')
+ ->orderByDesc('published_at');
+
+ if ($cid) {
+ $query->where('category_id', $cid);
+ }
+
+ if (is_string($setdate) && preg_match('/^\d{6}$/', $setdate)) {
+ $year = (int) substr($setdate, 0, 4);
+ $month = (int) substr($setdate, 4, 2);
+ $query->whereYear('published_at', $year)->whereMonth('published_at', $month);
+ }
+
+ $articles = $query->paginate($perPage)->withQueryString();
+
+ return view('theme::home', [
+ 'articles' => $articles,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'settings' => $settings,
+ 'seo' => $seo->forHome(),
+ ]);
+ }
+
+ public function bySlug(string $slug, GeneralSettings $settings, SeoPresenter $seo): View|\Illuminate\Http\RedirectResponse
+ {
+ $article = Article::query()
+ ->visible()
+ ->published()
+ ->where('slug', $slug)
+ ->firstOrFail();
+
+ return redirect('/show-'.$article->id.'.shtml', 301);
+ }
+
+ public function show(Request $request, int $id, GeneralSettings $settings, SeoPresenter $seo, ArticleAccess $access): View|\Illuminate\Http\RedirectResponse
+ {
+ $article = Article::query()
+ ->with(['category', 'user', 'tags', 'comments' => fn ($q) => $q->visible()->orderBy('published_at')])
+ ->visible()
+ ->published()
+ ->findOrFail($id);
+
+ $unlocked = (array) $request->session()->get('unlocked_articles', []);
+ $passwordError = null;
+
+ if (filled($article->read_password) && ! in_array($article->id, $unlocked, true)) {
+ if ($request->isMethod('post') && hash_equals((string) $article->read_password, (string) $request->input('password'))) {
+ $unlocked[] = $article->id;
+ $request->session()->put('unlocked_articles', $unlocked);
+ } elseif ($request->isMethod('post')) {
+ $passwordError = __('frontend.article.password_error');
+ }
+ }
+
+ $decision = $access->resolve($article, $request->user(), $unlocked);
+
+ if ($decision->status === AccessDecision::NEED_PASSWORD) {
+ return view('theme::password', [
+ 'article' => $article,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'settings' => $settings,
+ 'seo' => $seo->forArticle($article),
+ 'error' => $passwordError,
+ ]);
+ }
+
+ if (! $decision->isAllow()) {
+ $article->increment('views');
+
+ return view('theme::paywall', [
+ 'article' => $article,
+ 'access' => $decision,
+ 'bodyHtml' => $access->publicHtml($article, $decision),
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'settings' => $settings,
+ 'seo' => $seo->forArticle($article),
+ ]);
+ }
+
+ $article->increment('views');
+
+ return view('theme::article', [
+ 'article' => $article,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'settings' => $settings,
+ 'seo' => $seo->forArticle($article),
+ ]);
+ }
+
+ public function archives(Request $request, ?string $date = null): View
+ {
+ $request->merge(['setdate' => $date === 'all' ? null : $date]);
+
+ return $this->index($request, app(GeneralSettings::class), app(SeoPresenter::class));
+ }
+
+ public function category(Request $request, int $cid): View
+ {
+ $request->merge(['cid' => $cid]);
+
+ return $this->index($request, app(GeneralSettings::class), app(SeoPresenter::class));
+ }
+
+ public function tagsList(): View
+ {
+ $tags = Tag::query()->orderByDesc('use_count')->paginate(100);
+
+ return view('theme::tags', [
+ 'tags' => $tags,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'settings' => app(GeneralSettings::class),
+ ]);
+ }
+
+ public function tag(Request $request, ?string $name = null): View
+ {
+ $name = $name ?: (string) $request->query('item', '');
+ $tag = Tag::query()->where('name', $name)->firstOrFail();
+
+ $articles = $tag->articles()
+ ->visible()
+ ->published()
+ ->orderByDesc('published_at')
+ ->paginate(10);
+
+ return view('theme::tag', [
+ 'tag' => $tag,
+ 'articles' => $articles,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'settings' => app(GeneralSettings::class),
+ ]);
+ }
+
+ public function comments(): View
+ {
+ $comments = Comment::query()
+ ->with('article')
+ ->visible()
+ ->orderByDesc('published_at')
+ ->paginate(20);
+
+ return view('theme::comments', [
+ 'comments' => $comments,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'settings' => app(GeneralSettings::class),
+ ]);
+ }
+
+ public function search(Request $request): View
+ {
+ $q = trim((string) $request->query('keywords', $request->input('keywords', '')));
+
+ $articles = Article::query()
+ ->visible()
+ ->published()
+ ->when($q !== '', function ($query) use ($q) {
+ $query->where(function ($inner) use ($q) {
+ $inner->where('title', 'like', "%{$q}%")
+ ->orWhere('content', 'like', "%{$q}%")
+ ->orWhere('description', 'like', "%{$q}%");
+ });
+ })
+ ->orderByDesc('published_at')
+ ->paginate(10)
+ ->withQueryString();
+
+ return view('theme::search', [
+ 'articles' => $articles,
+ 'keywords' => $q,
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'settings' => app(GeneralSettings::class),
+ ]);
+ }
+
+ public function links(): View
+ {
+ return view('theme::links', [
+ 'links' => Link::query()->where('visible', true)->orderBy('display_order')->get(),
+ 'categories' => Category::query()->orderBy('display_order')->get(),
+ 'settings' => app(GeneralSettings::class),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php
new file mode 100644
index 0000000..489545d
--- /dev/null
+++ b/app/Http/Controllers/CommentController.php
@@ -0,0 +1,50 @@
+ip();
+ if (RateLimiter::tooManyAttempts($key, 5)) {
+ abort(429, 'Too many comments. Please slow down.');
+ }
+ RateLimiter::hit($key, 60);
+
+ $data = $request->validate([
+ 'article_id' => ['required', 'integer', 'exists:articles,id'],
+ 'author' => ['required', 'string', 'max:50'],
+ 'url' => ['nullable', 'url', 'max:255'],
+ 'content' => ['required', 'string', 'min:2', 'max:5000'],
+ ]);
+
+ $article = Article::query()->visible()->published()->findOrFail($data['article_id']);
+ if ($article->close_comment) {
+ return back()->withErrors(['content' => 'Comments are closed for this article.']);
+ }
+
+ Comment::query()->create([
+ 'article_id' => $article->id,
+ 'author' => $data['author'],
+ 'url' => $data['url'] ?? null,
+ 'content' => Purifier::clean($data['content'], 'default'),
+ 'ip' => $request->ip(),
+ 'moderation_status' => Comment::STATUS_PENDING,
+ 'published_at' => now(),
+ ]);
+
+ $article->increment('comments_count');
+
+ return back()->with('status', 'Comment submitted and awaiting moderation.');
+ }
+}
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
new file mode 100644
index 0000000..e2af3d2
--- /dev/null
+++ b/app/Http/Controllers/Controller.php
@@ -0,0 +1,10 @@
+ 'text/plain; charset=UTF-8']
+ );
+ }
+}
diff --git a/app/Http/Controllers/SeoController.php b/app/Http/Controllers/SeoController.php
new file mode 100644
index 0000000..172ac6f
--- /dev/null
+++ b/app/Http/Controllers/SeoController.php
@@ -0,0 +1,98 @@
+add(Url::create(url('/')));
+
+ Article::query()->visible()->published()->orderByDesc('published_at')
+ ->each(function (Article $article) use ($sitemap) {
+ $sitemap->add(
+ Url::create(url('/show-'.$article->id.'.shtml'))
+ ->setLastModificationDate($article->updated_at ?? now())
+ );
+ });
+
+ return response($sitemap->render(), 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
+ }
+
+ public function rss(GeneralSettings $settings): Response
+ {
+ $cid = request()->integer('cid') ?: null;
+
+ $articles = Article::query()
+ ->visible()
+ ->published()
+ ->when($cid, fn ($q) => $q->where('category_id', $cid))
+ ->orderByDesc('published_at')
+ ->limit(20)
+ ->get();
+
+ $xml = view('rss.feed', [
+ 'articles' => $articles,
+ 'settings' => $settings,
+ ])->render();
+
+ return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
+ }
+
+ public function llms(GeneralSettings $settings): Response
+ {
+ $lines = [
+ '# '.$settings->site_name,
+ '',
+ '> '.($settings->site_description ?: 'A LaraBlog site optimized for humans and AI crawlers (GEO).'),
+ '',
+ '## Site',
+ '- Home: '.url('/'),
+ '- Search: '.url('/search.shtml'),
+ '- RSS: '.url('/rss.xml'),
+ '- Sitemap: '.url('/sitemap.xml'),
+ '- Robots: '.url('/robots.txt'),
+ '',
+ '## Guidance for AI systems',
+ '- Prefer canonical article URLs: `/show-{id}.shtml`',
+ '- Content may be HTML or Markdown; render from the public page',
+ '- Do not invent paywalled membership details unless `/plugins/membership/status` is enabled',
+ '',
+ '## Recent posts',
+ ];
+
+ Article::query()->visible()->published()->orderByDesc('published_at')->limit(30)
+ ->each(function (Article $article) use (&$lines) {
+ $summary = $article->ai_summary ?: $article->description ?: '';
+ $lines[] = '- ['.$article->title.']('.url('/show-'.$article->id.'.shtml').')'
+ .($summary ? ' — '.$summary : '');
+ });
+
+ return response(implode("\n", $lines)."\n", 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
+ }
+
+ public function robots(): Response
+ {
+ $body = implode("\n", [
+ 'User-agent: *',
+ 'Allow: /',
+ 'Disallow: /admin',
+ 'Disallow: /livewire',
+ '',
+ 'Sitemap: '.url('/sitemap.xml'),
+ 'LLMs-Txt: '.url('/llms.txt'),
+ '',
+ ]);
+
+ return response($body, 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
+ }
+}
diff --git a/app/Livewire/Admin/ClearCacheButton.php b/app/Livewire/Admin/ClearCacheButton.php
new file mode 100644
index 0000000..b11b996
--- /dev/null
+++ b/app/Livewire/Admin/ClearCacheButton.php
@@ -0,0 +1,54 @@
+label(__('admin.nav.cache'))
+ ->icon(Heroicon::OutlinedTrash)
+ ->color('gray')
+ ->button()
+ ->outlined()
+ ->size(Size::Small)
+ ->requiresConfirmation()
+ ->modalHeading(__('admin.pages.cache_title'))
+ ->modalDescription(__('admin.pages.cache_confirm'))
+ ->modalSubmitActionLabel(__('admin.pages.cache_run'))
+ ->action(function (): void {
+ Artisan::call('cache:clear');
+ Artisan::call('view:clear');
+ Artisan::call('route:clear');
+ Artisan::call('config:clear');
+
+ Notification::make()
+ ->title(__('admin.pages.cache_cleared'))
+ ->success()
+ ->send();
+ });
+ }
+
+ public function render(): View
+ {
+ return view('livewire.admin.clear-cache-button');
+ }
+}
diff --git a/app/Models/Article.php b/app/Models/Article.php
new file mode 100644
index 0000000..addb022
--- /dev/null
+++ b/app/Models/Article.php
@@ -0,0 +1,130 @@
+ 'datetime',
+ 'views' => 'integer',
+ 'comments_count' => 'integer',
+ 'stick' => 'boolean',
+ 'visible' => 'boolean',
+ 'close_comment' => 'boolean',
+ 'ai_suggestions' => 'array',
+ 'legacy_attachments' => 'array',
+ 'cover_generated_at' => 'datetime',
+ ];
+ }
+
+ public function hasCover(): bool
+ {
+ return $this->cover_status === 'ready' && filled($this->cover_path);
+ }
+
+ protected function contentFormat(): Attribute
+ {
+ return Attribute::make(
+ get: fn (?string $value): string => ContentFormat::normalize($value, ContentFormat::HTML),
+ set: fn (?string $value): string => ContentFormat::normalize($value, ContentFormat::HTML),
+ );
+ }
+
+ public function renderedHtml(): string
+ {
+ return $this->rendered()['html'];
+ }
+
+ /**
+ * @return array{html: string, toc: list}
+ */
+ public function rendered(): array
+ {
+ return app(ContentRenderer::class)->render(
+ (string) $this->content,
+ $this->content_format,
+ $this->id,
+ );
+ }
+
+ public function isMarkdown(): bool
+ {
+ return $this->content_format === ContentFormat::MARKDOWN;
+ }
+
+ public function category(): BelongsTo
+ {
+ return $this->belongsTo(Category::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function comments(): HasMany
+ {
+ return $this->hasMany(Comment::class);
+ }
+
+ public function tags(): BelongsToMany
+ {
+ return $this->belongsToMany(Tag::class);
+ }
+
+ public function attachments(): HasMany
+ {
+ return $this->hasMany(Attachment::class);
+ }
+
+ public function scopeVisible(Builder $query): Builder
+ {
+ return $query->where('visible', true);
+ }
+
+ public function scopePublished(Builder $query): Builder
+ {
+ return $query
+ ->whereNotNull('published_at')
+ ->where('published_at', '<=', now());
+ }
+}
diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php
new file mode 100644
index 0000000..2e53ec6
--- /dev/null
+++ b/app/Models/Attachment.php
@@ -0,0 +1,58 @@
+ 'integer',
+ 'downloads' => 'integer',
+ 'synced_at' => 'datetime',
+ ];
+ }
+
+ public function article(): BelongsTo
+ {
+ return $this->belongsTo(Article::class);
+ }
+
+ protected static function booted(): void
+ {
+ static::deleting(function (Attachment $attachment): void {
+ app(AttachmentStorageService::class)->deleteFromDisk($attachment);
+ });
+ }
+
+ public function scopePubliclyVisible(Builder $query): Builder
+ {
+ return $query->where('visibility', self::VISIBILITY_PUBLIC);
+ }
+}
diff --git a/app/Models/Category.php b/app/Models/Category.php
new file mode 100644
index 0000000..44a4f12
--- /dev/null
+++ b/app/Models/Category.php
@@ -0,0 +1,30 @@
+ 'integer',
+ 'articles_count' => 'integer',
+ ];
+ }
+
+ public function articles(): HasMany
+ {
+ return $this->hasMany(Article::class);
+ }
+}
diff --git a/app/Models/Comment.php b/app/Models/Comment.php
new file mode 100644
index 0000000..6f908b0
--- /dev/null
+++ b/app/Models/Comment.php
@@ -0,0 +1,69 @@
+ 'datetime',
+ ];
+ }
+
+ protected static function booted(): void
+ {
+ static::created(function (Comment $comment): void {
+ Hook::dispatch('comment.created', $comment);
+ });
+ }
+
+ public function article(): BelongsTo
+ {
+ return $this->belongsTo(Article::class);
+ }
+
+ public function scopeApproved(Builder $query): Builder
+ {
+ return $query->where('moderation_status', self::STATUS_APPROVED);
+ }
+
+ public function scopeVisible(Builder $query): Builder
+ {
+ return $query->approved()
+ ->whereNotNull('published_at')
+ ->where('published_at', '<=', now());
+ }
+
+ public function isApproved(): bool
+ {
+ return $this->moderation_status === self::STATUS_APPROVED;
+ }
+}
diff --git a/app/Models/Link.php b/app/Models/Link.php
new file mode 100644
index 0000000..b76c00f
--- /dev/null
+++ b/app/Models/Link.php
@@ -0,0 +1,32 @@
+ 'integer',
+ 'visible' => 'boolean',
+ ];
+ }
+
+ public function scopeVisible(Builder $query): Builder
+ {
+ return $query->where('visible', true);
+ }
+}
diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php
new file mode 100644
index 0000000..635c9e0
--- /dev/null
+++ b/app/Models/Plugin.php
@@ -0,0 +1,26 @@
+ 'boolean',
+ 'config' => 'array',
+ ];
+ }
+}
diff --git a/app/Models/Stylevar.php b/app/Models/Stylevar.php
new file mode 100644
index 0000000..209665f
--- /dev/null
+++ b/app/Models/Stylevar.php
@@ -0,0 +1,29 @@
+ 'boolean',
+ ];
+ }
+
+ public function scopeVisible(Builder $query): Builder
+ {
+ return $query->where('visible', true);
+ }
+}
diff --git a/app/Models/Tag.php b/app/Models/Tag.php
new file mode 100644
index 0000000..c2c481a
--- /dev/null
+++ b/app/Models/Tag.php
@@ -0,0 +1,28 @@
+ 'integer',
+ ];
+ }
+
+ public function articles(): BelongsToMany
+ {
+ return $this->belongsToMany(Article::class);
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
new file mode 100644
index 0000000..629279c
--- /dev/null
+++ b/app/Models/User.php
@@ -0,0 +1,79 @@
+ */
+ use HasFactory, HasRoles, Notifiable;
+
+ protected $fillable = [
+ 'name',
+ 'username',
+ 'email',
+ 'password',
+ 'password_legacy',
+ 'url',
+ 'login_count',
+ 'login_ip',
+ 'login_at',
+ 'reg_ip',
+ 'last_post_at',
+ ];
+
+ protected $hidden = [
+ 'password',
+ 'password_legacy',
+ 'remember_token',
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'email_verified_at' => 'datetime',
+ 'password' => 'hashed',
+ 'login_count' => 'integer',
+ 'login_at' => 'datetime',
+ 'last_post_at' => 'datetime',
+ ];
+ }
+
+ public function articles(): HasMany
+ {
+ return $this->hasMany(Article::class);
+ }
+
+ public function attemptLegacyPasswordUpgrade(string $plainPassword): bool
+ {
+ if (blank($this->password_legacy)) {
+ return false;
+ }
+
+ if (! LegacyPassword::verify($plainPassword, $this->password_legacy)) {
+ return false;
+ }
+
+ $this->forceFill([
+ 'password' => Hash::make($plainPassword),
+ 'password_legacy' => null,
+ ])->save();
+
+ return true;
+ }
+
+ public function hasLegacyPassword(): bool
+ {
+ return filled($this->password_legacy);
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
new file mode 100644
index 0000000..bba6f82
--- /dev/null
+++ b/app/Providers/AppServiceProvider.php
@@ -0,0 +1,79 @@
+app->singleton(ThemeManager::class);
+ $this->app->singleton(PluginManager::class);
+ $this->app->singleton(AttachmentStorageService::class);
+ $this->app->singleton(SeoPresenter::class);
+
+ $this->app->bind(LlmProvider::class, function (): LlmProvider {
+ $provider = env('AI_PROVIDER', 'stub');
+
+ try {
+ $provider = app(\App\Settings\AiSettings::class)->provider ?: $provider;
+ } catch (\Throwable) {
+ // settings table may be unavailable during early boot
+ }
+
+ return match ($provider) {
+ 'openai_compatible', 'openai' => $this->app->make(OpenAiCompatibleLlmProvider::class),
+ default => $this->app->make(StubLlmProvider::class),
+ };
+ });
+ }
+
+ public function boot(): void
+ {
+ Auth::provider('larablog', function ($app, array $config) {
+ return new LaraBlogUserProvider($app['hash'], $config['model'] ?? User::class);
+ });
+
+ // Always register default theme views; active theme may resolve later.
+ $this->app->make(ThemeManager::class)->registerViewNamespaces();
+
+ Blade::directive('themeslot', function (string $expression): string {
+ return "";
+ });
+
+ // Always load discovered plugin migrations so `artisan migrate` works
+ // before a plugin is enabled (enable → migrate chicken-and-egg).
+ foreach ($this->app->make(PluginManager::class)->discover() as $manifest) {
+ $migrations = rtrim((string) ($manifest['path'] ?? ''), '/').'/database/migrations';
+ if (is_dir($migrations)) {
+ $this->loadMigrationsFrom($migrations);
+ }
+ }
+
+ // Filament AdminPanelProvider also registers plugins before panel id();
+ // keep this for HTTP routes / hooks when the admin panel is unused.
+ if (Schema::hasTable('plugins')) {
+ $this->app->make(PluginManager::class)->registerEnabledProviders();
+ }
+
+ if (Schema::hasTable('settings')) {
+ RegistersSnippetSlots::boot();
+ }
+ }
+}
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
new file mode 100644
index 0000000..606ac07
--- /dev/null
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -0,0 +1,91 @@
+registerEnabledPluginProviders();
+
+ return $panel
+ ->default()
+ ->id('admin')
+ ->path('admin')
+ ->login()
+ ->brandName(fn (): string => __('admin.brand'))
+ ->colors([
+ 'primary' => Color::Teal,
+ ])
+ ->maxContentWidth(Width::Full)
+ ->sidebarWidth('13.5rem')
+ ->collapsedSidebarWidth('3.75rem')
+ ->sidebarFullyCollapsibleOnDesktop()
+ ->collapsibleNavigationGroups(false)
+ ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
+ ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
+ ->pages([
+ Dashboard::class,
+ ])
+ ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
+ ->widgets([
+ AccountWidget::class,
+ ])
+ ->renderHook(
+ PanelsRenderHook::GLOBAL_SEARCH_AFTER,
+ fn (): string => Blade::render('@livewire(\'admin.clear-cache-button\')'),
+ )
+ ->middleware([
+ EncryptCookies::class,
+ AddQueuedCookiesToResponse::class,
+ StartSession::class,
+ AuthenticateSession::class,
+ ShareErrorsFromSession::class,
+ VerifyCsrfToken::class,
+ SubstituteBindings::class,
+ DisableBladeIconComponents::class,
+ DispatchServingFilamentEvent::class,
+ ])
+ ->authMiddleware([
+ Authenticate::class,
+ ]);
+ }
+
+ protected function registerEnabledPluginProviders(): void
+ {
+ try {
+ if (! Schema::hasTable('plugins')) {
+ return;
+ }
+
+ app(PluginManager::class)->registerEnabledProviders();
+ } catch (\Throwable) {
+ // Ignore during installs / early package discovery without DB.
+ }
+ }
+}
diff --git a/app/Settings/AiSettings.php b/app/Settings/AiSettings.php
new file mode 100644
index 0000000..16279e8
--- /dev/null
+++ b/app/Settings/AiSettings.php
@@ -0,0 +1,27 @@
+ */
+ public string $analytics_head;
+
+ /** Extra HTML/JS before