├── resources ├── js │ ├── app.js │ └── article.js ├── css │ └── app.css └── views │ ├── components │ └── layouts │ │ └── app.blade.php │ └── frontend │ ├── articles │ ├── index.blade.php │ └── show.blade.php │ └── pages │ └── show.blade.php ├── database ├── .gitignore ├── migrations │ ├── 2025_12_08_221543_create_media_items_table.php │ ├── 2025_12_08_111943_create_categories_table.php │ ├── 0001_01_01_000001_create_cache_table.php │ ├── 2025_12_08_213017_create_article_views_table.php │ ├── 2025_12_08_111944_create_pages_table.php │ ├── 2025_12_08_221137_create_media_table.php │ ├── 2025_12_08_111944_create_articles_table.php │ ├── 0001_01_01_000000_create_users_table.php │ └── 0001_01_01_000002_create_jobs_table.php ├── factories │ └── UserFactory.php └── seeders │ └── DatabaseSeeder.php ├── bootstrap ├── cache │ └── .gitignore ├── providers.php └── app.php ├── storage ├── logs │ └── .gitignore ├── app │ ├── private │ │ └── .gitignore │ ├── public │ │ └── .gitignore │ └── .gitignore └── framework │ ├── testing │ └── .gitignore │ ├── views │ └── .gitignore │ ├── cache │ ├── data │ │ └── .gitignore │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── public ├── robots.txt ├── favicon.png ├── fonts │ └── filament │ │ └── filament │ │ └── inter │ │ ├── inter-greek-wght-normal-AXVTPQD5.woff2 │ │ ├── inter-greek-wght-normal-IRE366VL.woff2 │ │ ├── inter-greek-wght-normal-N43DBLU2.woff2 │ │ ├── inter-latin-wght-normal-NRMW37G5.woff2 │ │ ├── inter-latin-wght-normal-O25CN4JL.woff2 │ │ ├── inter-latin-wght-normal-OPIJAQLS.woff2 │ │ ├── inter-cyrillic-wght-normal-EWLSKVKN.woff2 │ │ ├── inter-cyrillic-wght-normal-JEOLYBOO.woff2 │ │ ├── inter-cyrillic-wght-normal-R5CMSONN.woff2 │ │ ├── inter-greek-ext-wght-normal-7GGTF7EK.woff2 │ │ ├── inter-greek-ext-wght-normal-EOVOK2B5.woff2 │ │ ├── inter-greek-ext-wght-normal-ZEVLMORV.woff2 │ │ ├── inter-latin-ext-wght-normal-5SRY4DMZ.woff2 │ │ ├── inter-latin-ext-wght-normal-GZCIV3NH.woff2 │ │ ├── inter-latin-ext-wght-normal-HA22NDSG.woff2 │ │ ├── inter-cyrillic-ext-wght-normal-ASVAGXXE.woff2 │ │ ├── inter-cyrillic-ext-wght-normal-IYF56FF6.woff2 │ │ ├── inter-cyrillic-ext-wght-normal-XKHXBTUO.woff2 │ │ ├── inter-vietnamese-wght-normal-CE5GGD3W.woff2 │ │ ├── inter-vietnamese-wght-normal-TWG5UU7E.woff2 │ │ └── index.css ├── index.php ├── js │ └── filament │ │ ├── forms │ │ └── components │ │ │ ├── textarea.js │ │ │ ├── tags-input.js │ │ │ ├── key-value.js │ │ │ └── checkbox-list.js │ │ ├── tables │ │ └── components │ │ │ └── columns │ │ │ ├── checkbox.js │ │ │ ├── toggle.js │ │ │ └── text-input.js │ │ ├── schemas │ │ ├── components │ │ │ ├── actions.js │ │ │ ├── wizard.js │ │ │ └── tabs.js │ │ └── schemas.js │ │ ├── actions │ │ └── actions.js │ │ └── notifications │ │ └── notifications.js └── .htaccess ├── app ├── Http │ └── Controllers │ │ ├── Controller.php │ │ └── Frontend │ │ ├── PageController.php │ │ ├── CategoryController.php │ │ ├── HomeController.php │ │ └── ArticleController.php ├── Filament │ ├── Resources │ │ ├── Pages │ │ │ ├── Pages │ │ │ │ ├── CreatePage.php │ │ │ │ ├── EditPage.php │ │ │ │ └── ListPages.php │ │ │ ├── PageResource.php │ │ │ ├── Tables │ │ │ │ └── PagesTable.php │ │ │ └── Schemas │ │ │ │ └── PageForm.php │ │ ├── Users │ │ │ ├── Pages │ │ │ │ ├── CreateUser.php │ │ │ │ ├── EditUser.php │ │ │ │ └── ListUsers.php │ │ │ ├── Schemas │ │ │ │ └── UserForm.php │ │ │ ├── UserResource.php │ │ │ └── Tables │ │ │ │ └── UsersTable.php │ │ ├── Articles │ │ │ ├── Pages │ │ │ │ ├── CreateArticle.php │ │ │ │ ├── EditArticle.php │ │ │ │ └── ListArticles.php │ │ │ ├── ArticleResource.php │ │ │ ├── Tables │ │ │ │ └── ArticlesTable.php │ │ │ └── Schemas │ │ │ │ └── ArticleForm.php │ │ ├── Categories │ │ │ ├── Pages │ │ │ │ ├── CreateCategory.php │ │ │ │ ├── EditCategory.php │ │ │ │ └── ListCategories.php │ │ │ ├── Schemas │ │ │ │ └── CategoryForm.php │ │ │ ├── CategoryResource.php │ │ │ └── Tables │ │ │ │ └── CategoriesTable.php │ │ ├── ArticleViews │ │ │ ├── Pages │ │ │ │ └── ListArticleViews.php │ │ │ ├── ArticleViewResource.php │ │ │ └── Tables │ │ │ │ └── ArticleViewsTable.php │ │ └── Media │ │ │ ├── MediaResource.php │ │ │ ├── Pages │ │ │ └── ListMedia.php │ │ │ └── Tables │ │ │ └── MediaTable.php │ └── Widgets │ │ ├── ArticleViewsChart.php │ │ ├── StatsOverview.php │ │ ├── TopArticlesTable.php │ │ └── RecentActivityTable.php ├── Enums │ └── UserRole.php ├── Models │ ├── MediaItem.php │ ├── ArticleView.php │ ├── Category.php │ ├── Page.php │ ├── User.php │ └── Article.php ├── Providers │ ├── AppServiceProvider.php │ └── Filament │ │ └── AdminPanelProvider.php └── View │ └── Composers │ └── NavigationComposer.php ├── tests ├── TestCase.php ├── Unit │ ├── UserTest.php │ ├── CategoryTest.php │ ├── ArticleTest.php │ ├── PageTest.php │ └── ArticleViewTest.php └── Feature │ └── FrontendTest.php ├── .gitattributes ├── routes ├── console.php └── web.php ├── .editorconfig ├── .gitignore ├── vite.config.js ├── artisan ├── package.json ├── config ├── services.php ├── filesystems.php ├── mail.php ├── cache.php ├── auth.php ├── queue.php ├── app.php ├── logging.php └── database.php ├── .env.example ├── phpunit.xml ├── composer.json └── lang └── en └── frontend.php /resources/js/app.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite* 2 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /storage/app/private/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !private/ 3 | !public/ 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozdemirburak/laravel-9-simple-cms/HEAD/public/favicon.png -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 8 | })->purpose('Display an inspiring quote'); 9 | -------------------------------------------------------------------------------- /app/Filament/Resources/Pages/Pages/CreatePage.php: -------------------------------------------------------------------------------- 1 | 'Admin', 14 | self::Editor => 'Editor', 15 | }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/Filament/Resources/Categories/Pages/CreateCategory.php: -------------------------------------------------------------------------------- 1 | with('children')->where('slug', $slug)->firstOrFail(); 13 | return view('frontend.pages.show', compact('page')); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/Filament/Resources/ArticleViews/Pages/ListArticleViews.php: -------------------------------------------------------------------------------- 1 | handleCommand(new ArgvInput); 17 | 18 | exit($status); 19 | -------------------------------------------------------------------------------- /app/Filament/Resources/Pages/Pages/EditPage.php: -------------------------------------------------------------------------------- 1 | addMediaCollection('images') 21 | ->useDisk('public'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://www.schemastore.org/package.json", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "vite build", 7 | "dev": "vite" 8 | }, 9 | "dependencies": { 10 | "daisyui": "^5.5.8", 11 | "social-share-buttons": "^0.1.1" 12 | }, 13 | "devDependencies": { 14 | "@tailwindcss/typography": "^0.5.19", 15 | "@tailwindcss/vite": "^4.0.0", 16 | "laravel-vite-plugin": "^2.0.0", 17 | "tailwindcss": "^4.0.0", 18 | "vite": "^7.0.7" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withRouting( 9 | web: __DIR__.'/../routes/web.php', 10 | commands: __DIR__.'/../routes/console.php', 11 | health: '/up', 12 | ) 13 | ->withMiddleware(function (Middleware $middleware): void { 14 | // 15 | }) 16 | ->withExceptions(function (Exceptions $exceptions): void { 17 | // 18 | })->create(); 19 | -------------------------------------------------------------------------------- /resources/js/article.js: -------------------------------------------------------------------------------- 1 | import SocialShareButtons from 'social-share-buttons'; 2 | import 'social-share-buttons/styles'; 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | const container = document.getElementById('share-buttons'); 6 | if (container) { 7 | new SocialShareButtons({ 8 | container: '#share-buttons', 9 | platforms: ['x', 'facebook', 'linkedin'], 10 | labels: { 11 | x: 'Post on X', 12 | facebook: 'Share on Facebook', 13 | linkedin: 'Share on LinkedIn' 14 | } 15 | }); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handleRequest(Request::capture()); 21 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | 'datetime', 22 | ]; 23 | } 24 | 25 | public function article(): BelongsTo 26 | { 27 | return $this->belongsTo(Article::class); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Http/Controllers/Frontend/CategoryController.php: -------------------------------------------------------------------------------- 1 | where('is_active', true) 14 | ->firstOrFail(); 15 | 16 | $articles = $category->articles() 17 | ->published() 18 | ->latest('published_at') 19 | ->paginate(12); 20 | 21 | return view('frontend.categories.show', compact('category', 'articles')); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /public/js/filament/forms/components/textarea.js: -------------------------------------------------------------------------------- 1 | function r({initialHeight:i,shouldAutosize:s,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),s?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=i+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let e=this.$el.style.height;this.$el.style.height="0px";let t=this.$el.scrollHeight+"px";this.$el.style.height=e,this.wrapperEl.style.height!==t&&(this.wrapperEl.style.height=t)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default}; 2 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | name('home'); 10 | 11 | Route::get('/articles', [ArticleController::class, 'index'])->name('articles.index'); 12 | Route::get('/article/{slug}', [ArticleController::class, 'show'])->name('articles.show'); 13 | 14 | Route::get('/category/{slug}', [CategoryController::class, 'show'])->name('categories.show'); 15 | 16 | Route::get('/page/{slug}', [PageController::class, 'show'])->name('pages.show'); 17 | -------------------------------------------------------------------------------- /app/Http/Controllers/Frontend/HomeController.php: -------------------------------------------------------------------------------- 1 | with('category')->latest('published_at')->take(6)->get(); 15 | $pages = Page::published()->roots()->orderBy('sort_order')->get(); 16 | $categories = Category::where('is_active', true)->whereHas('articles', fn($q) => $q->published())->withCount(['articles' => fn($q) => $q->published()])->get(); 17 | return view('frontend.home', compact('articles', 'pages', 'categories')); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/View/Composers/NavigationComposer.php: -------------------------------------------------------------------------------- 1 | roots() 20 | ->orderBy('sort_order') 21 | ->get(); 22 | 23 | $view->with([ 24 | 'navPages' => $pages->filter(fn ($p) => !in_array($p->slug, self::$footerOnlySlugs)), 25 | 'footerPages' => $pages->filter(fn ($p) => in_array($p->slug, self::$footerOnlySlugs)), 26 | ]); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /database/migrations/2025_12_08_221543_create_media_items_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('name')->nullable(); 17 | $table->text('description')->nullable(); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | */ 25 | public function down(): void 26 | { 27 | Schema::dropIfExists('media_items'); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /public/js/filament/forms/components/tags-input.js: -------------------------------------------------------------------------------- 1 | function s({state:n,splitKeys:a}){return{newTag:"",state:n,createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag(t){this.state=this.state.filter(e=>e!==t)},reorderTags(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...a].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(a.length===0){this.createTag();return}let t=a.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{s as default}; 2 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Handle X-XSRF-Token Header 13 | RewriteCond %{HTTP:x-xsrf-token} . 14 | RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] 15 | 16 | # Redirect Trailing Slashes If Not A Folder... 17 | RewriteCond %{REQUEST_FILENAME} !-d 18 | RewriteCond %{REQUEST_URI} (.+)/$ 19 | RewriteRule ^ %1 [L,R=301] 20 | 21 | # Send Requests To Front Controller... 22 | RewriteCond %{REQUEST_FILENAME} !-d 23 | RewriteCond %{REQUEST_FILENAME} !-f 24 | RewriteRule ^ index.php [L] 25 | 26 | -------------------------------------------------------------------------------- /public/js/filament/tables/components/columns/checkbox.js: -------------------------------------------------------------------------------- 1 | function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default}; 2 | -------------------------------------------------------------------------------- /public/js/filament/tables/components/columns/toggle.js: -------------------------------------------------------------------------------- 1 | function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default}; 2 | -------------------------------------------------------------------------------- /app/Models/Category.php: -------------------------------------------------------------------------------- 1 | 'boolean', 20 | ]; 21 | 22 | protected static function booted(): void 23 | { 24 | static::creating(function (Category $category) { 25 | if (empty($category->slug)) { 26 | $category->slug = Str::slug($category->title); 27 | } 28 | }); 29 | } 30 | 31 | public function articles(): HasMany 32 | { 33 | return $this->hasMany(Article::class); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/js/filament/schemas/components/actions.js: -------------------------------------------------------------------------------- 1 | var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default}; 2 | -------------------------------------------------------------------------------- /database/migrations/2025_12_08_111943_create_categories_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('title'); 17 | $table->string('slug')->unique(); 18 | $table->text('description')->nullable(); 19 | $table->boolean('is_active')->default(true); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | */ 27 | public function down(): void 28 | { 29 | Schema::dropIfExists('categories'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /public/js/filament/forms/components/key-value.js: -------------------------------------------------------------------------------- 1 | function h({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{h as default}; 2 | -------------------------------------------------------------------------------- /public/js/filament/actions/actions.js: -------------------------------------------------------------------------------- 1 | (()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex)})},syncActionModals(t){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}if(this.actionNestingIndex!==null&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})(); 2 | -------------------------------------------------------------------------------- /public/js/filament/tables/components/columns/text-input.js: -------------------------------------------------------------------------------- 1 | function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:d,respond:u})=>{n(({snapshot:f,effect:h})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||this.getNormalizedState()===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e}}}export{o as default}; 2 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000001_create_cache_table.php: -------------------------------------------------------------------------------- 1 | string('key')->primary(); 16 | $table->mediumText('value'); 17 | $table->integer('expiration'); 18 | }); 19 | 20 | Schema::create('cache_locks', function (Blueprint $table) { 21 | $table->string('key')->primary(); 22 | $table->string('owner'); 23 | $table->integer('expiration'); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | */ 30 | public function down(): void 31 | { 32 | Schema::dropIfExists('cache'); 33 | Schema::dropIfExists('cache_locks'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /app/Http/Controllers/Frontend/ArticleController.php: -------------------------------------------------------------------------------- 1 | with('category')->latest('published_at')->paginate(12); 13 | return view('frontend.articles.index', compact('articles')); 14 | } 15 | 16 | public function show(string $slug) 17 | { 18 | $article = Article::published()->with('category')->where('slug', $slug)->firstOrFail(); 19 | $others = Article::published()->where('id', '!=', $article->id)->latest('published_at')->take(5)->get(); 20 | 21 | // Record the view with details 22 | $article->recordView( 23 | request()->ip(), 24 | request()->userAgent(), 25 | request()->header('referer') 26 | ); 27 | 28 | return view('frontend.articles.show', compact('article', 'others')); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /database/migrations/2025_12_08_213017_create_article_views_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->foreignId('article_id')->constrained()->cascadeOnDelete(); 17 | $table->string('ip_address', 45)->nullable(); 18 | $table->string('user_agent')->nullable(); 19 | $table->string('referer')->nullable(); 20 | $table->timestamp('viewed_at'); 21 | $table->timestamps(); 22 | 23 | $table->index(['article_id', 'viewed_at']); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | */ 30 | public function down(): void 31 | { 32 | Schema::dropIfExists('article_views'); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /database/migrations/2025_12_08_111944_create_pages_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->foreignId('parent_id')->nullable()->constrained('pages')->nullOnDelete(); 17 | $table->string('title'); 18 | $table->string('slug')->unique(); 19 | $table->text('excerpt')->nullable(); 20 | $table->longText('content')->nullable(); 21 | $table->boolean('is_published')->default(false); 22 | $table->integer('sort_order')->default(0); 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | */ 30 | public function down(): void 31 | { 32 | Schema::dropIfExists('pages'); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /database/migrations/2025_12_08_221137_create_media_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->morphs('model'); 14 | $table->uuid()->nullable()->unique(); 15 | $table->string('collection_name'); 16 | $table->string('name'); 17 | $table->string('file_name'); 18 | $table->string('mime_type')->nullable(); 19 | $table->string('disk'); 20 | $table->string('conversions_disk')->nullable(); 21 | $table->unsignedBigInteger('size'); 22 | $table->json('manipulations'); 23 | $table->json('custom_properties'); 24 | $table->json('generated_conversions'); 25 | $table->json('responsive_images'); 26 | $table->unsignedInteger('order_column')->nullable()->index(); 27 | $table->nullableTimestamps(); 28 | }); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'key' => env('POSTMARK_API_KEY'), 19 | ], 20 | 21 | 'resend' => [ 22 | 'key' => env('RESEND_API_KEY'), 23 | ], 24 | 25 | 'ses' => [ 26 | 'key' => env('AWS_ACCESS_KEY_ID'), 27 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 28 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 29 | ], 30 | 31 | 'slack' => [ 32 | 'notifications' => [ 33 | 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 34 | 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), 35 | ], 36 | ], 37 | 38 | ]; 39 | -------------------------------------------------------------------------------- /database/migrations/2025_12_08_111944_create_articles_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete(); 17 | $table->string('title'); 18 | $table->string('slug')->unique(); 19 | $table->text('excerpt')->nullable(); 20 | $table->longText('content')->nullable(); 21 | $table->string('featured_image')->nullable(); 22 | $table->boolean('is_published')->default(false); 23 | $table->timestamp('published_at')->nullable(); 24 | $table->unsignedBigInteger('read_count')->default(0); 25 | $table->timestamps(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | */ 32 | public function down(): void 33 | { 34 | Schema::dropIfExists('articles'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /app/Filament/Resources/ArticleViews/ArticleViewResource.php: -------------------------------------------------------------------------------- 1 | ListArticleViews::route('/'), 34 | ]; 35 | } 36 | 37 | public static function canCreate(): bool 38 | { 39 | return false; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserFactory extends Factory 13 | { 14 | /** 15 | * The current password being used by the factory. 16 | */ 17 | protected static ?string $password; 18 | 19 | /** 20 | * Define the model's default state. 21 | * 22 | * @return array 23 | */ 24 | public function definition(): array 25 | { 26 | return [ 27 | 'name' => fake()->name(), 28 | 'email' => fake()->unique()->safeEmail(), 29 | 'email_verified_at' => now(), 30 | 'password' => static::$password ??= Hash::make('password'), 31 | 'remember_token' => Str::random(10), 32 | ]; 33 | } 34 | 35 | /** 36 | * Indicate that the model's email address should be unverified. 37 | */ 38 | public function unverified(): static 39 | { 40 | return $this->state(fn (array $attributes) => [ 41 | 'email_verified_at' => null, 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/Filament/Resources/Media/MediaResource.php: -------------------------------------------------------------------------------- 1 | ListMedia::route('/'), 36 | ]; 37 | } 38 | 39 | public static function canCreate(): bool 40 | { 41 | return false; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME="Laravel Simple CMS" 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://localhost 6 | 7 | APP_LOCALE=en 8 | APP_FALLBACK_LOCALE=en 9 | APP_FAKER_LOCALE=en_US 10 | 11 | APP_MAINTENANCE_DRIVER=file 12 | # APP_MAINTENANCE_STORE=database 13 | 14 | # PHP_CLI_SERVER_WORKERS=4 15 | 16 | BCRYPT_ROUNDS=12 17 | 18 | LOG_CHANNEL=stack 19 | LOG_STACK=single 20 | LOG_DEPRECATIONS_CHANNEL=null 21 | LOG_LEVEL=debug 22 | 23 | DB_CONNECTION=sqlite 24 | # DB_HOST=127.0.0.1 25 | # DB_PORT=3306 26 | # DB_DATABASE=laravel 27 | # DB_USERNAME=root 28 | # DB_PASSWORD= 29 | 30 | SESSION_DRIVER=database 31 | SESSION_LIFETIME=120 32 | SESSION_ENCRYPT=false 33 | SESSION_PATH=/ 34 | SESSION_DOMAIN=null 35 | 36 | BROADCAST_CONNECTION=log 37 | FILESYSTEM_DISK=local 38 | QUEUE_CONNECTION=database 39 | 40 | CACHE_STORE=database 41 | # CACHE_PREFIX= 42 | 43 | MEMCACHED_HOST=127.0.0.1 44 | 45 | REDIS_CLIENT=phpredis 46 | REDIS_HOST=127.0.0.1 47 | REDIS_PASSWORD=null 48 | REDIS_PORT=6379 49 | 50 | MAIL_MAILER=log 51 | MAIL_SCHEME=null 52 | MAIL_HOST=127.0.0.1 53 | MAIL_PORT=2525 54 | MAIL_USERNAME=null 55 | MAIL_PASSWORD=null 56 | MAIL_FROM_ADDRESS="hello@example.com" 57 | MAIL_FROM_NAME="${APP_NAME}" 58 | 59 | AWS_ACCESS_KEY_ID= 60 | AWS_SECRET_ACCESS_KEY= 61 | AWS_DEFAULT_REGION=us-east-1 62 | AWS_BUCKET= 63 | AWS_USE_PATH_STYLE_ENDPOINT=false 64 | 65 | VITE_APP_NAME="${APP_NAME}" 66 | -------------------------------------------------------------------------------- /public/js/filament/schemas/components/wizard.js: -------------------------------------------------------------------------------- 1 | function o({isSkippable:s,isStepPersistedInQueryString:i,key:r,startStep:h,stepQueryStringKey:n}){return{step:null,init(){this.$watch("step",()=>this.updateQueryString()),this.step=this.getSteps().at(h-1),this.autofocusFields()},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(){this.$nextTick(()=>this.$refs[`step-${this.step}`].querySelector("[autofocus]")?.focus())},getStepIndex(t){let e=this.getSteps().findIndex(p=>p===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return s||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!i)return;let t=new URL(window.location.href);t.searchParams.set(n,this.step),history.replaceState(null,document.title,t.toString())}}}export{o as default}; 2 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests/Unit 10 | 11 | 12 | tests/Feature 13 | 14 | 15 | 16 | 17 | app 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/Models/Page.php: -------------------------------------------------------------------------------- 1 | 'boolean', 24 | 'sort_order' => 'integer', 25 | ]; 26 | 27 | protected static function booted(): void 28 | { 29 | static::creating(function (Page $page) { 30 | if (empty($page->slug)) { 31 | $page->slug = Str::slug($page->title); 32 | } 33 | }); 34 | } 35 | 36 | public function parent(): BelongsTo 37 | { 38 | return $this->belongsTo(Page::class, 'parent_id'); 39 | } 40 | 41 | public function children(): HasMany 42 | { 43 | return $this->hasMany(Page::class, 'parent_id')->orderBy('sort_order'); 44 | } 45 | 46 | public function scopePublished($query) 47 | { 48 | return $query->where('is_published', true); 49 | } 50 | 51 | public function scopeRoots($query) 52 | { 53 | return $query->whereNull('parent_id'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | 'admin@admin.com'], 27 | [ 28 | 'name' => 'Admin', 29 | 'role' => UserRole::Admin, 30 | 'password' => bcrypt('password'), 31 | ] 32 | ); 33 | 34 | // Create editor user 35 | User::firstOrCreate( 36 | ['email' => 'editor@editor.com'], 37 | [ 38 | 'name' => 'Editor', 39 | 'role' => UserRole::Editor, 40 | 'password' => bcrypt('password'), 41 | ] 42 | ); 43 | 44 | // To seed with sample content, run: php artisan db:seed --class=ContentSeeder 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/Filament/Resources/Pages/PageResource.php: -------------------------------------------------------------------------------- 1 | ListPages::route('/'), 44 | 'create' => CreatePage::route('/create'), 45 | 'edit' => EditPage::route('/{record}/edit'), 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Filament/Resources/Categories/Schemas/CategoryForm.php: -------------------------------------------------------------------------------- 1 | components([ 18 | Section::make() 19 | ->schema([ 20 | TextInput::make('title') 21 | ->required() 22 | ->maxLength(255) 23 | ->live(onBlur: true) 24 | ->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))), 25 | TextInput::make('slug') 26 | ->required() 27 | ->maxLength(255) 28 | ->unique(ignoreRecord: true), 29 | Textarea::make('description') 30 | ->rows(3) 31 | ->columnSpanFull(), 32 | Toggle::make('is_active') 33 | ->default(true), 34 | ]) 35 | ->columns(2), 36 | ]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/Filament/Resources/ArticleViews/Tables/ArticleViewsTable.php: -------------------------------------------------------------------------------- 1 | columns([ 15 | TextColumn::make('article.title') 16 | ->label('Article') 17 | ->searchable() 18 | ->sortable() 19 | ->limit(40), 20 | TextColumn::make('ip_address') 21 | ->label('IP Address') 22 | ->searchable() 23 | ->toggleable(), 24 | TextColumn::make('referer') 25 | ->label('Referrer') 26 | ->limit(30) 27 | ->toggleable(isToggledHiddenByDefault: true), 28 | TextColumn::make('viewed_at') 29 | ->label('Viewed At') 30 | ->dateTime() 31 | ->sortable(), 32 | ]) 33 | ->filters([ 34 | SelectFilter::make('article') 35 | ->relationship('article', 'title') 36 | ->searchable() 37 | ->preload(), 38 | ]) 39 | ->defaultSort('viewed_at', 'desc'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/Filament/Resources/Articles/ArticleResource.php: -------------------------------------------------------------------------------- 1 | ListArticles::route('/'), 44 | 'create' => CreateArticle::route('/create'), 45 | 'edit' => EditArticle::route('/{record}/edit'), 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Filament/Resources/Categories/CategoryResource.php: -------------------------------------------------------------------------------- 1 | ListCategories::route('/'), 44 | 'create' => CreateCategory::route('/create'), 45 | 'edit' => EditCategory::route('/{record}/edit'), 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Filament/Widgets/ArticleViewsChart.php: -------------------------------------------------------------------------------- 1 | map(function ($daysAgo) { 29 | $date = Carbon::today()->subDays($daysAgo); 30 | return [ 31 | 'date' => $date->format('M d'), 32 | 'count' => ArticleView::whereDate('viewed_at', $date)->count(), 33 | ]; 34 | }); 35 | 36 | return [ 37 | 'datasets' => [ 38 | [ 39 | 'label' => 'Views', 40 | 'data' => $data->pluck('count')->toArray(), 41 | 'fill' => true, 42 | 'backgroundColor' => 'rgba(251, 191, 36, 0.1)', 43 | 'borderColor' => 'rgb(251, 191, 36)', 44 | 'tension' => 0.3, 45 | ], 46 | ], 47 | 'labels' => $data->pluck('date')->toArray(), 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/Filament/Resources/Categories/Tables/CategoriesTable.php: -------------------------------------------------------------------------------- 1 | columns([ 19 | TextColumn::make('title') 20 | ->searchable(), 21 | TextColumn::make('slug') 22 | ->searchable(), 23 | IconColumn::make('is_active') 24 | ->boolean(), 25 | TextColumn::make('created_at') 26 | ->dateTime() 27 | ->sortable() 28 | ->toggleable(isToggledHiddenByDefault: true), 29 | TextColumn::make('updated_at') 30 | ->dateTime() 31 | ->sortable() 32 | ->toggleable(isToggledHiddenByDefault: true), 33 | ]) 34 | ->filters([ 35 | // 36 | ]) 37 | ->recordActions([ 38 | EditAction::make(), 39 | DeleteAction::make(), 40 | ]) 41 | ->toolbarActions([ 42 | BulkActionGroup::make([ 43 | DeleteBulkAction::make(), 44 | ]), 45 | ]); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/Filament/Resources/Users/Schemas/UserForm.php: -------------------------------------------------------------------------------- 1 | components([ 16 | TextInput::make('name') 17 | ->required() 18 | ->maxLength(255), 19 | TextInput::make('email') 20 | ->label('Email address') 21 | ->email() 22 | ->required() 23 | ->unique(ignoreRecord: true) 24 | ->maxLength(255), 25 | TextInput::make('password') 26 | ->password() 27 | ->revealable() 28 | ->dehydrated(fn ($state) => filled($state)) 29 | ->required(fn (string $operation): bool => $operation === 'create') 30 | ->confirmed() 31 | ->maxLength(255), 32 | TextInput::make('password_confirmation') 33 | ->password() 34 | ->revealable() 35 | ->requiredWith('password') 36 | ->dehydrated(false) 37 | ->maxLength(255), 38 | Select::make('role') 39 | ->options(UserRole::class) 40 | ->default(UserRole::Editor) 41 | ->required(), 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/Filament/Resources/Users/UserResource.php: -------------------------------------------------------------------------------- 1 | isAdmin() ?? false; 29 | } 30 | 31 | public static function form(Schema $schema): Schema 32 | { 33 | return UserForm::configure($schema); 34 | } 35 | 36 | public static function table(Table $table): Table 37 | { 38 | return UsersTable::configure($table); 39 | } 40 | 41 | public static function getRelations(): array 42 | { 43 | return [ 44 | // 45 | ]; 46 | } 47 | 48 | public static function getPages(): array 49 | { 50 | return [ 51 | 'index' => ListUsers::route('/'), 52 | 'create' => CreateUser::route('/create'), 53 | 'edit' => EditUser::route('/{record}/edit'), 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /public/js/filament/forms/components/checkbox-list.js: -------------------------------------------------------------------------------- 1 | function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default}; 2 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('name'); 17 | $table->string('email')->unique(); 18 | $table->string('role')->default('editor'); 19 | $table->timestamp('email_verified_at')->nullable(); 20 | $table->string('password'); 21 | $table->rememberToken(); 22 | $table->timestamps(); 23 | }); 24 | 25 | Schema::create('password_reset_tokens', function (Blueprint $table) { 26 | $table->string('email')->primary(); 27 | $table->string('token'); 28 | $table->timestamp('created_at')->nullable(); 29 | }); 30 | 31 | Schema::create('sessions', function (Blueprint $table) { 32 | $table->string('id')->primary(); 33 | $table->foreignId('user_id')->nullable()->index(); 34 | $table->string('ip_address', 45)->nullable(); 35 | $table->text('user_agent')->nullable(); 36 | $table->longText('payload'); 37 | $table->integer('last_activity')->index(); 38 | }); 39 | } 40 | 41 | /** 42 | * Reverse the migrations. 43 | */ 44 | public function down(): void 45 | { 46 | Schema::dropIfExists('users'); 47 | Schema::dropIfExists('password_reset_tokens'); 48 | Schema::dropIfExists('sessions'); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /app/Filament/Resources/Pages/Tables/PagesTable.php: -------------------------------------------------------------------------------- 1 | columns([ 19 | TextColumn::make('parent.title') 20 | ->searchable(), 21 | TextColumn::make('title') 22 | ->searchable(), 23 | TextColumn::make('slug') 24 | ->searchable(), 25 | IconColumn::make('is_published') 26 | ->boolean(), 27 | TextColumn::make('sort_order') 28 | ->numeric() 29 | ->sortable(), 30 | TextColumn::make('created_at') 31 | ->dateTime() 32 | ->sortable() 33 | ->toggleable(isToggledHiddenByDefault: true), 34 | TextColumn::make('updated_at') 35 | ->dateTime() 36 | ->sortable() 37 | ->toggleable(isToggledHiddenByDefault: true), 38 | ]) 39 | ->filters([ 40 | // 41 | ]) 42 | ->recordActions([ 43 | EditAction::make(), 44 | DeleteAction::make(), 45 | ]) 46 | ->toolbarActions([ 47 | BulkActionGroup::make([ 48 | DeleteBulkAction::make(), 49 | ]), 50 | ]); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/Filament/Resources/Users/Tables/UsersTable.php: -------------------------------------------------------------------------------- 1 | columns([ 20 | TextColumn::make('name') 21 | ->searchable() 22 | ->sortable(), 23 | TextColumn::make('email') 24 | ->label('Email address') 25 | ->searchable() 26 | ->sortable(), 27 | TextColumn::make('role') 28 | ->badge() 29 | ->color(fn (UserRole $state): string => match ($state) { 30 | UserRole::Admin => 'danger', 31 | UserRole::Editor => 'info', 32 | }) 33 | ->sortable(), 34 | TextColumn::make('created_at') 35 | ->dateTime() 36 | ->sortable() 37 | ->toggleable(isToggledHiddenByDefault: true), 38 | ]) 39 | ->filters([ 40 | SelectFilter::make('role') 41 | ->options(UserRole::class), 42 | ]) 43 | ->recordActions([ 44 | EditAction::make(), 45 | DeleteAction::make(), 46 | ]) 47 | ->toolbarActions([ 48 | BulkActionGroup::make([ 49 | DeleteBulkAction::make(), 50 | ]), 51 | ]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/Filament/Widgets/StatsOverview.php: -------------------------------------------------------------------------------- 1 | count(); 27 | $weekViews = ArticleView::where('viewed_at', '>=', now()->subWeek())->count(); 28 | 29 | return [ 30 | Stat::make('Total Views', Number::abbreviate($totalViews)) 31 | ->description('Today: ' . $todayViews . ' | This week: ' . $weekViews) 32 | ->color('primary'), 33 | Stat::make('Total Articles', Article::count()) 34 | ->description('Published: ' . Article::where('is_published', true)->count()) 35 | ->color('success'), 36 | Stat::make('Total Categories', Category::count()) 37 | ->description('Active: ' . Category::where('is_active', true)->count()) 38 | ->color('info'), 39 | Stat::make('Total Pages', Page::count()) 40 | ->description('Published: ' . Page::where('is_published', true)->count()) 41 | ->color('warning'), 42 | Stat::make('Total Users', User::count()) 43 | ->description('Admins: ' . User::where('role', 'admin')->count() . ' | Editors: ' . User::where('role', 'editor')->count()) 44 | ->color('gray'), 45 | ]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Unit/UserTest.php: -------------------------------------------------------------------------------- 1 | 'Admin User', 18 | 'email' => 'admin@test.com', 19 | 'password' => bcrypt('password'), 20 | 'role' => UserRole::Admin, 21 | ]); 22 | 23 | $this->assertTrue($admin->isAdmin()); 24 | $this->assertFalse($admin->isEditor()); 25 | } 26 | 27 | public function test_user_is_editor(): void 28 | { 29 | $editor = User::create([ 30 | 'name' => 'Editor User', 31 | 'email' => 'editor@test.com', 32 | 'password' => bcrypt('password'), 33 | 'role' => UserRole::Editor, 34 | ]); 35 | 36 | $this->assertTrue($editor->isEditor()); 37 | $this->assertFalse($editor->isAdmin()); 38 | } 39 | 40 | public function test_user_role_defaults_to_editor(): void 41 | { 42 | $user = User::create([ 43 | 'name' => 'New User', 44 | 'email' => 'new@test.com', 45 | 'password' => bcrypt('password'), 46 | ]); 47 | 48 | $this->assertEquals(UserRole::Editor, $user->role); 49 | $this->assertTrue($user->isEditor()); 50 | } 51 | 52 | public function test_user_role_enum_has_correct_values(): void 53 | { 54 | $this->assertEquals('admin', UserRole::Admin->value); 55 | $this->assertEquals('editor', UserRole::Editor->value); 56 | } 57 | 58 | public function test_user_role_enum_has_labels(): void 59 | { 60 | $this->assertEquals('Admin', UserRole::Admin->label()); 61 | $this->assertEquals('Editor', UserRole::Editor->label()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/Models/User.php: -------------------------------------------------------------------------------- 1 | */ 16 | use HasFactory, Notifiable; 17 | 18 | /** 19 | * The attributes that are mass assignable. 20 | * 21 | * @var list 22 | */ 23 | protected $fillable = [ 24 | 'name', 25 | 'email', 26 | 'role', 27 | 'password', 28 | ]; 29 | 30 | /** 31 | * The model's default values for attributes. 32 | * 33 | * @var array 34 | */ 35 | protected $attributes = [ 36 | 'role' => 'editor', 37 | ]; 38 | 39 | /** 40 | * The attributes that should be hidden for serialization. 41 | * 42 | * @var list 43 | */ 44 | protected $hidden = [ 45 | 'password', 46 | 'remember_token', 47 | ]; 48 | 49 | /** 50 | * Get the attributes that should be cast. 51 | * 52 | * @return array 53 | */ 54 | protected function casts(): array 55 | { 56 | return [ 57 | 'email_verified_at' => 'datetime', 58 | 'password' => 'hashed', 59 | 'role' => UserRole::class, 60 | ]; 61 | } 62 | 63 | public function canAccessPanel(Panel $panel): bool 64 | { 65 | return true; 66 | } 67 | 68 | public function isAdmin(): bool 69 | { 70 | return $this->role === UserRole::Admin; 71 | } 72 | 73 | public function isEditor(): bool 74 | { 75 | return $this->role === UserRole::Editor; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /public/fonts/filament/filament/inter/index.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-IYF56FF6.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-JEOLYBOO.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-EOVOK2B5.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-IRE366VL.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-CE5GGD3W.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-HA22NDSG.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-NRMW37G5.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD} 2 | -------------------------------------------------------------------------------- /app/Filament/Resources/Pages/Schemas/PageForm.php: -------------------------------------------------------------------------------- 1 | components([ 20 | Section::make() 21 | ->schema([ 22 | TextInput::make('title') 23 | ->required() 24 | ->maxLength(255) 25 | ->live(onBlur: true) 26 | ->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))), 27 | TextInput::make('slug') 28 | ->required() 29 | ->maxLength(255) 30 | ->unique(ignoreRecord: true), 31 | Select::make('parent_id') 32 | ->relationship('parent', 'title') 33 | ->searchable() 34 | ->preload(), 35 | Toggle::make('is_published') 36 | ->default(false), 37 | TextInput::make('sort_order') 38 | ->numeric() 39 | ->default(0), 40 | ]) 41 | ->columns(3), 42 | Section::make() 43 | ->schema([ 44 | Textarea::make('excerpt') 45 | ->rows(3), 46 | RichEditor::make('content'), 47 | ]), 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000002_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('queue')->index(); 17 | $table->longText('payload'); 18 | $table->unsignedTinyInteger('attempts'); 19 | $table->unsignedInteger('reserved_at')->nullable(); 20 | $table->unsignedInteger('available_at'); 21 | $table->unsignedInteger('created_at'); 22 | }); 23 | 24 | Schema::create('job_batches', function (Blueprint $table) { 25 | $table->string('id')->primary(); 26 | $table->string('name'); 27 | $table->integer('total_jobs'); 28 | $table->integer('pending_jobs'); 29 | $table->integer('failed_jobs'); 30 | $table->longText('failed_job_ids'); 31 | $table->mediumText('options')->nullable(); 32 | $table->integer('cancelled_at')->nullable(); 33 | $table->integer('created_at'); 34 | $table->integer('finished_at')->nullable(); 35 | }); 36 | 37 | Schema::create('failed_jobs', function (Blueprint $table) { 38 | $table->id(); 39 | $table->string('uuid')->unique(); 40 | $table->text('connection'); 41 | $table->text('queue'); 42 | $table->longText('payload'); 43 | $table->longText('exception'); 44 | $table->timestamp('failed_at')->useCurrent(); 45 | }); 46 | } 47 | 48 | /** 49 | * Reverse the migrations. 50 | */ 51 | public function down(): void 52 | { 53 | Schema::dropIfExists('jobs'); 54 | Schema::dropIfExists('job_batches'); 55 | Schema::dropIfExists('failed_jobs'); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /app/Filament/Widgets/TopArticlesTable.php: -------------------------------------------------------------------------------- 1 | heading('Top Articles by Views') 21 | ->description('Most viewed articles in the last 30 days') 22 | ->query( 23 | Article::query() 24 | ->withCount(['views' => fn ($query) => $query->where('viewed_at', '>=', now()->subDays(30))]) 25 | ->orderByDesc('views_count') 26 | ->limit(5) 27 | ) 28 | ->columns([ 29 | ImageColumn::make('featured_image') 30 | ->label('') 31 | ->circular() 32 | ->defaultImageUrl(fn (Article $record) => 'https://ui-avatars.com/api/?name=' . urlencode($record->title) . '&background=random'), 33 | TextColumn::make('title') 34 | ->searchable() 35 | ->limit(40) 36 | ->url(fn (Article $record) => route('articles.show', $record->slug)) 37 | ->openUrlInNewTab(), 38 | TextColumn::make('category.title') 39 | ->badge() 40 | ->color('gray'), 41 | TextColumn::make('views_count') 42 | ->label('Views (30d)') 43 | ->sortable() 44 | ->badge() 45 | ->color('primary'), 46 | TextColumn::make('published_at') 47 | ->label('Published') 48 | ->date('M d, Y') 49 | ->color('gray'), 50 | ]) 51 | ->paginated(false); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/Filament/Resources/Articles/Tables/ArticlesTable.php: -------------------------------------------------------------------------------- 1 | columns([ 20 | TextColumn::make('category.title') 21 | ->searchable(), 22 | TextColumn::make('title') 23 | ->searchable(), 24 | TextColumn::make('slug') 25 | ->searchable(), 26 | ImageColumn::make('featured_image'), 27 | IconColumn::make('is_published') 28 | ->boolean(), 29 | TextColumn::make('views_count') 30 | ->label('Views') 31 | ->counts('views') 32 | ->sortable() 33 | ->badge() 34 | ->color('gray'), 35 | TextColumn::make('published_at') 36 | ->dateTime() 37 | ->sortable(), 38 | TextColumn::make('created_at') 39 | ->dateTime() 40 | ->sortable() 41 | ->toggleable(isToggledHiddenByDefault: true), 42 | TextColumn::make('updated_at') 43 | ->dateTime() 44 | ->sortable() 45 | ->toggleable(isToggledHiddenByDefault: true), 46 | ]) 47 | ->filters([ 48 | // 49 | ]) 50 | ->recordActions([ 51 | EditAction::make(), 52 | DeleteAction::make(), 53 | ]) 54 | ->toolbarActions([ 55 | BulkActionGroup::make([ 56 | DeleteBulkAction::make(), 57 | ]), 58 | ]); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /resources/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "@tailwindcss/typography"; 3 | @plugin "daisyui" { themes: false; exclude: properties; } 4 | 5 | @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; 6 | @source '../../storage/framework/views/*.php'; 7 | @source '../**/*.blade.php'; 8 | @source '../**/*.js'; 9 | 10 | /* Editorial - A refined newspaper/magazine light theme */ 11 | @plugin "daisyui/theme" { 12 | name: "editorial"; 13 | default: true; 14 | color-scheme: light; 15 | /* Warm off-white paper backgrounds */ 16 | --color-base-100: oklch(98% 0.006 90); 17 | --color-base-200: oklch(95% 0.008 90); 18 | --color-base-300: oklch(90% 0.01 90); 19 | --color-base-content: oklch(20% 0.02 60); 20 | /* Deep burgundy/wine primary - classic editorial */ 21 | --color-primary: oklch(40% 0.15 15); 22 | --color-primary-content: oklch(98% 0.006 90); 23 | /* Muted teal secondary */ 24 | --color-secondary: oklch(45% 0.1 200); 25 | --color-secondary-content: oklch(98% 0.006 90); 26 | /* Gold accent */ 27 | --color-accent: oklch(70% 0.15 80); 28 | --color-accent-content: oklch(20% 0.02 60); 29 | /* Dark charcoal neutral for footer */ 30 | --color-neutral: oklch(25% 0.015 260); 31 | --color-neutral-content: oklch(90% 0.01 90); 32 | /* Status colors */ 33 | --color-info: oklch(55% 0.15 230); 34 | --color-info-content: oklch(98% 0.006 90); 35 | --color-success: oklch(55% 0.18 145); 36 | --color-success-content: oklch(98% 0.006 90); 37 | --color-warning: oklch(75% 0.15 80); 38 | --color-warning-content: oklch(20% 0.02 60); 39 | --color-error: oklch(55% 0.2 25); 40 | --color-error-content: oklch(98% 0.006 90); 41 | /* Subtle rounded corners */ 42 | --radius-box: 0.5rem; 43 | --radius-field: 0.375rem; 44 | --radius-selector: 0.25rem; 45 | --border: 1px; 46 | --depth: 1; 47 | --noise: 0; 48 | } 49 | 50 | /* Gradient text for hero */ 51 | .text-gradient { 52 | background: linear-gradient(135deg, oklch(40% 0.15 15), oklch(55% 0.12 25)); 53 | -webkit-background-clip: text; 54 | -webkit-text-fill-color: transparent; 55 | background-clip: text; 56 | } 57 | -------------------------------------------------------------------------------- /tests/Unit/CategoryTest.php: -------------------------------------------------------------------------------- 1 | 'Web Development', 18 | 'is_active' => true, 19 | ]); 20 | 21 | $this->assertEquals('web-development', $category->slug); 22 | } 23 | 24 | public function test_category_has_many_articles(): void 25 | { 26 | $category = Category::create([ 27 | 'title' => 'Technology', 28 | 'slug' => 'technology', 29 | 'is_active' => true, 30 | ]); 31 | 32 | Article::create([ 33 | 'title' => 'Article 1', 34 | 'slug' => 'article-1', 35 | 'content' => 'Content', 36 | 'category_id' => $category->id, 37 | 'is_published' => true, 38 | 'published_at' => now(), 39 | ]); 40 | 41 | Article::create([ 42 | 'title' => 'Article 2', 43 | 'slug' => 'article-2', 44 | 'content' => 'Content', 45 | 'category_id' => $category->id, 46 | 'is_published' => true, 47 | 'published_at' => now(), 48 | ]); 49 | 50 | $this->assertCount(2, $category->articles); 51 | } 52 | 53 | public function test_active_scope_filters_correctly(): void 54 | { 55 | Category::create([ 56 | 'title' => 'Active Category', 57 | 'slug' => 'active-category', 58 | 'is_active' => true, 59 | ]); 60 | 61 | Category::create([ 62 | 'title' => 'Inactive Category', 63 | 'slug' => 'inactive-category', 64 | 'is_active' => false, 65 | ]); 66 | 67 | $activeCategories = Category::where('is_active', true)->get(); 68 | 69 | $this->assertCount(1, $activeCategories); 70 | $this->assertEquals('Active Category', $activeCategories->first()->title); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/Models/Article.php: -------------------------------------------------------------------------------- 1 | 'boolean', 30 | 'published_at' => 'datetime', 31 | ]; 32 | 33 | protected static function booted(): void 34 | { 35 | static::creating(function (Article $article) { 36 | if (empty($article->slug)) { 37 | $article->slug = Str::slug($article->title); 38 | } 39 | }); 40 | } 41 | 42 | public function registerMediaCollections(): void 43 | { 44 | $this->addMediaCollection('featured') 45 | ->singleFile(); 46 | } 47 | 48 | public function category(): BelongsTo 49 | { 50 | return $this->belongsTo(Category::class); 51 | } 52 | 53 | public function views(): HasMany 54 | { 55 | return $this->hasMany(ArticleView::class); 56 | } 57 | 58 | public function recordView(?string $ipAddress = null, ?string $userAgent = null, ?string $referer = null): ArticleView 59 | { 60 | return $this->views()->create([ 61 | 'ip_address' => $ipAddress, 62 | 'user_agent' => $userAgent, 63 | 'referer' => $referer, 64 | 'viewed_at' => now(), 65 | ]); 66 | } 67 | 68 | public function scopePublished($query) 69 | { 70 | return $query->where('is_published', true) 71 | ->whereNotNull('published_at') 72 | ->where('published_at', '<=', now()); 73 | } 74 | 75 | public function getViewCountAttribute(): int 76 | { 77 | return $this->views()->count(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/Filament/Widgets/RecentActivityTable.php: -------------------------------------------------------------------------------- 1 | heading('Recent Articles') 22 | ->description('Latest article updates') 23 | ->query($this->getRecentArticlesQuery()) 24 | ->columns([ 25 | TextColumn::make('title') 26 | ->searchable() 27 | ->limit(50) 28 | ->url(fn ($record) => ArticleResource::getUrl('edit', ['record' => $record->id])) 29 | ->weight('medium'), 30 | TextColumn::make('category.title') 31 | ->label('Category') 32 | ->badge() 33 | ->color('gray'), 34 | TextColumn::make('status') 35 | ->badge() 36 | ->color(fn (string $state): string => match ($state) { 37 | 'Published' => 'success', 38 | 'Draft' => 'gray', 39 | default => 'gray', 40 | }), 41 | TextColumn::make('updated_at') 42 | ->label('Updated') 43 | ->since() 44 | ->color('gray'), 45 | ]) 46 | ->paginated(false); 47 | } 48 | 49 | protected function getRecentArticlesQuery(): Builder 50 | { 51 | return Article::query() 52 | ->with('category') 53 | ->select([ 54 | 'id', 55 | 'title', 56 | 'slug', 57 | 'category_id', 58 | 'updated_at', 59 | ]) 60 | ->selectRaw("CASE WHEN is_published = 1 THEN 'Published' ELSE 'Draft' END as status") 61 | ->latest('updated_at') 62 | ->limit(5); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/Providers/Filament/AdminPanelProvider.php: -------------------------------------------------------------------------------- 1 | default() 27 | ->id('admin') 28 | ->path('admin') 29 | ->login() 30 | ->favicon(asset('favicon.png')) 31 | ->colors([ 32 | 'primary' => Color::Amber, 33 | ]) 34 | ->font(false) 35 | ->maxContentWidth(Width::Full) 36 | ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') 37 | ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') 38 | ->pages([ 39 | Dashboard::class, 40 | ]) 41 | ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') 42 | ->widgets([]) 43 | ->middleware([ 44 | EncryptCookies::class, 45 | AddQueuedCookiesToResponse::class, 46 | StartSession::class, 47 | AuthenticateSession::class, 48 | ShareErrorsFromSession::class, 49 | VerifyCsrfToken::class, 50 | SubstituteBindings::class, 51 | DisableBladeIconComponents::class, 52 | DispatchServingFilamentEvent::class, 53 | ]) 54 | ->authMiddleware([ 55 | Authenticate::class, 56 | ]); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/Filament/Resources/Media/Pages/ListMedia.php: -------------------------------------------------------------------------------- 1 | label('Upload Images') 22 | ->icon('heroicon-o-arrow-up-tray') 23 | ->form([ 24 | TextInput::make('name') 25 | ->label('Name (optional)') 26 | ->placeholder('e.g., Blog header images') 27 | ->maxLength(255), 28 | FileUpload::make('images') 29 | ->label('Select Images') 30 | ->multiple() 31 | ->reorderable() 32 | ->image() 33 | ->imageEditor() 34 | ->maxSize(5120) 35 | ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp']) 36 | ->required() 37 | ->helperText('Max 5MB per file. Supported: JPG, PNG, GIF, WebP'), 38 | ]) 39 | ->action(function (array $data): void { 40 | $mediaItem = MediaItem::create([ 41 | 'name' => $data['name'] ?? 'Uploaded ' . now()->format('Y-m-d H:i'), 42 | ]); 43 | 44 | foreach ($data['images'] as $image) { 45 | $mediaItem->addMedia(storage_path('app/private/livewire-tmp/' . $image)) 46 | ->toMediaCollection('images'); 47 | } 48 | 49 | $count = count($data['images']); 50 | 51 | Notification::make() 52 | ->title("Uploaded {$count} image(s) successfully") 53 | ->success() 54 | ->send(); 55 | }), 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/Filament/Resources/Articles/Schemas/ArticleForm.php: -------------------------------------------------------------------------------- 1 | components([ 22 | Section::make() 23 | ->schema([ 24 | TextInput::make('title') 25 | ->required() 26 | ->maxLength(255) 27 | ->live(onBlur: true) 28 | ->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))), 29 | TextInput::make('slug') 30 | ->required() 31 | ->maxLength(255) 32 | ->unique(ignoreRecord: true), 33 | Textarea::make('excerpt') 34 | ->rows(8), 35 | RichEditor::make('content') 36 | ->columnSpanFull(), 37 | ]), 38 | Section::make('Settings') 39 | ->schema([ 40 | Select::make('category_id') 41 | ->relationship('category', 'title') 42 | ->searchable() 43 | ->preload(), 44 | FileUpload::make('featured_image') 45 | ->image() 46 | ->imageEditor() 47 | ->disk('public') 48 | ->directory('articles'), 49 | Toggle::make('is_published') 50 | ->default(false), 51 | DateTimePicker::make('published_at'), 52 | ]) 53 | ->columns(2) 54 | ->collapsible(), 55 | ]); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /public/js/filament/schemas/schemas.js: -------------------------------------------------------------------------------- 1 | (()=>{var d=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let t=this.$el.parentElement;t&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(t),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let t=this.$el.parentElement;if(!t)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=t.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var u=function(t,e,n){let i=t;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)i=i.includes(".")?i.slice(0,i.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(i)?e:["",null,void 0].includes(e)?i:`${i}.${e}`},c=t=>{let e=Alpine.findClosest(t,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:t})=>({handleFormValidationError(e){e.detail.livewireId===t&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let i=n;for(;i;)i.dispatchEvent(new CustomEvent("expand")),i=i.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:t,containerPath:e,$wire:n})=>({$statePath:t,$get:(i,s)=>n.$get(u(e,i,s)),$set:(i,s,a,o=!1)=>n.$set(u(e,i,a),s,o),get $state(){return n.$get(t)}})),window.Alpine.data("filamentActionsSchemaComponent",d),Livewire.hook("commit",({component:t,commit:e,respond:n,succeed:i,fail:s})=>{i(({snapshot:a,effects:o})=>{o.dispatches?.forEach(r=>{if(!r.params?.awaitSchemaComponent)return;let l=Array.from(t.el.querySelectorAll(`[wire\\:partial="schema-component::${r.params.awaitSchemaComponent}"]`)).filter(h=>c(h)===t);if(l.length!==1){if(l.length>1)throw`Multiple schema components found with key [${r.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${t.id}-${r.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(r.name,{detail:r.params}))},{once:!0})}})})})});})(); 2 | -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | env('FILESYSTEM_DISK', 'local'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Filesystem Disks 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Below you may configure as many filesystem disks as necessary, and you 24 | | may even configure multiple disks for the same driver. Examples for 25 | | most supported storage drivers are configured here for reference. 26 | | 27 | | Supported drivers: "local", "ftp", "sftp", "s3" 28 | | 29 | */ 30 | 31 | 'disks' => [ 32 | 33 | 'local' => [ 34 | 'driver' => 'local', 35 | 'root' => storage_path('app/private'), 36 | 'serve' => true, 37 | 'throw' => false, 38 | 'report' => false, 39 | ], 40 | 41 | 'public' => [ 42 | 'driver' => 'local', 43 | 'root' => storage_path('app/public'), 44 | 'url' => env('APP_URL').'/storage', 45 | 'visibility' => 'public', 46 | 'throw' => false, 47 | 'report' => false, 48 | ], 49 | 50 | 's3' => [ 51 | 'driver' => 's3', 52 | 'key' => env('AWS_ACCESS_KEY_ID'), 53 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 54 | 'region' => env('AWS_DEFAULT_REGION'), 55 | 'bucket' => env('AWS_BUCKET'), 56 | 'url' => env('AWS_URL'), 57 | 'endpoint' => env('AWS_ENDPOINT'), 58 | 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 59 | 'throw' => false, 60 | 'report' => false, 61 | ], 62 | 63 | ], 64 | 65 | /* 66 | |-------------------------------------------------------------------------- 67 | | Symbolic Links 68 | |-------------------------------------------------------------------------- 69 | | 70 | | Here you may configure the symbolic links that will be created when the 71 | | `storage:link` Artisan command is executed. The array keys should be 72 | | the locations of the links and the values should be their targets. 73 | | 74 | */ 75 | 76 | 'links' => [ 77 | public_path('storage') => storage_path('app/public'), 78 | ], 79 | 80 | ]; 81 | -------------------------------------------------------------------------------- /public/js/filament/schemas/components/tabs.js: -------------------------------------------------------------------------------- 1 | function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;ib+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default}; 2 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://getcomposer.org/schema.json", 3 | "name": "laravel/laravel", 4 | "type": "project", 5 | "description": "The skeleton application for the Laravel framework.", 6 | "keywords": ["laravel", "framework"], 7 | "license": "MIT", 8 | "require": { 9 | "php": "^8.3", 10 | "filament/filament": "~4.3", 11 | "filament/spatie-laravel-media-library-plugin": "^4.3", 12 | "laravel/framework": "^12.0", 13 | "laravel/tinker": "^v2.10", 14 | "mallardduck/blade-lucide-icons": "^1.24" 15 | }, 16 | "require-dev": { 17 | "fakerphp/faker": "^1.23", 18 | "laravel/pail": "^1.2", 19 | "laravel/pint": "^1.26", 20 | "laravel/sail": "^1.50", 21 | "mockery/mockery": "^1.6", 22 | "nunomaduro/collision": "^8.7", 23 | "phpunit/phpunit": "^11.5.3" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "App\\": "app/", 28 | "Database\\Factories\\": "database/factories/", 29 | "Database\\Seeders\\": "database/seeders/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Tests\\": "tests/" 35 | } 36 | }, 37 | "scripts": { 38 | "setup": [ 39 | "composer install", 40 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", 41 | "@php artisan key:generate", 42 | "@php artisan migrate --force", 43 | "npm install", 44 | "npm run build" 45 | ], 46 | "dev": [ 47 | "Composer\\Config::disableProcessTimeout", 48 | "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" 49 | ], 50 | "test": [ 51 | "@php artisan config:clear --ansi", 52 | "@php artisan test" 53 | ], 54 | "post-autoload-dump": [ 55 | "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", 56 | "@php artisan package:discover --ansi", 57 | "@php artisan filament:upgrade" 58 | ], 59 | "post-update-cmd": [ 60 | "@php artisan vendor:publish --tag=laravel-assets --ansi --force" 61 | ], 62 | "post-root-package-install": [ 63 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 64 | ], 65 | "post-create-project-cmd": [ 66 | "@php artisan key:generate --ansi", 67 | "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", 68 | "@php artisan migrate --graceful --ansi" 69 | ], 70 | "pre-package-uninstall": [ 71 | "Illuminate\\Foundation\\ComposerScripts::prePackageUninstall" 72 | ] 73 | }, 74 | "extra": { 75 | "laravel": { 76 | "dont-discover": [] 77 | } 78 | }, 79 | "config": { 80 | "optimize-autoloader": true, 81 | "preferred-install": "dist", 82 | "sort-packages": true, 83 | "allow-plugins": { 84 | "pestphp/pest-plugin": true, 85 | "php-http/discovery": true 86 | } 87 | }, 88 | "minimum-stability": "stable", 89 | "prefer-stable": true 90 | } 91 | -------------------------------------------------------------------------------- /lang/en/frontend.php: -------------------------------------------------------------------------------- 1 | 'Laravel Simple CMS', 6 | 'site_tagline' => 'Stories & insights that matter.', 7 | 'site_description' => 'A curated collection of thoughtful articles exploring technology, design, lifestyle, and business.', 8 | 9 | // Navigation 10 | 'nav' => [ 11 | 'home' => 'Home', 12 | 'articles' => 'Articles', 13 | 'browse_articles' => 'Browse Articles', 14 | 'latest_posts' => 'Latest Posts', 15 | 'view_all' => 'View all', 16 | 'navigate' => 'Navigate', 17 | 'info' => 'Info', 18 | ], 19 | 20 | // Articles 21 | 'articles' => [ 22 | 'title' => 'All Articles', 23 | 'description' => 'Explore our collection of articles on technology, design, lifestyle, and business.', 24 | 'latest' => 'Latest Articles', 25 | 'browse_by_topic' => 'Browse by Topic', 26 | 'read_more' => 'Read more', 27 | 'read_article' => 'Read article', 28 | 'min_read' => ':minutes min read', 29 | 'published_on' => 'Published on :date', 30 | 'article_count' => ':count article|:count articles', 31 | 'articles_in_category' => ':count article in this category|:count articles in this category', 32 | 'no_articles' => 'No articles found.', 33 | 'no_articles_in_category' => 'No articles yet', 34 | 'no_articles_in_category_desc' => 'There are no published articles in this category.', 35 | 'all_articles' => 'All articles', 36 | 'browse_all_articles' => 'Browse all articles', 37 | 'view_all_articles' => 'View All Articles', 38 | 'share' => 'Share This Article', 39 | 'related' => 'Related Articles', 40 | 'other_articles' => 'Other Articles', 41 | 'filter_by_topic' => 'Filter by topic:', 42 | 'archive' => 'Archive', 43 | ], 44 | 45 | // Categories 46 | 'categories' => [ 47 | 'title' => 'Categories', 48 | 'category' => 'Category', 49 | 'articles_in' => 'Articles in :category', 50 | 'explore_other' => 'Explore Other Topics', 51 | ], 52 | 53 | // Pages 54 | 'pages' => [ 55 | 'not_found' => 'Page not found.', 56 | 'last_updated' => 'Last updated: :date', 57 | 'related_pages' => 'Related Pages', 58 | 'quick_contact' => 'Quick Contact', 59 | 'contact_address' => '123 Demo Street, City, Country', 60 | 'contact_email' => 'hello@example.com', 61 | 'contact_hours' => 'Mon - Fri: 9:00 - 17:00', 62 | ], 63 | 64 | // Social Share 65 | 'share' => [ 66 | 'x' => 'Post on X', 67 | 'facebook' => 'Share on Facebook', 68 | 'linkedin' => 'Share on LinkedIn', 69 | ], 70 | 71 | // Footer 72 | 'footer' => [ 73 | 'copyright' => '© :year :name. All rights reserved.', 74 | 'built_with' => 'Built with Laravel, Filament & DaisyUI.', 75 | ], 76 | 77 | // Empty States 78 | 'empty' => [ 79 | 'no_content' => 'No content yet', 80 | 'check_back' => 'Check back soon for articles.', 81 | ], 82 | 83 | // Errors 84 | 'errors' => [ 85 | '404' => [ 86 | 'title' => 'Page Not Found', 87 | 'message' => 'Sorry, the page you are looking for could not be found.', 88 | 'back_home' => 'Back to Home', 89 | ], 90 | '500' => [ 91 | 'title' => 'Server Error', 92 | 'message' => 'Something went wrong. Please try again later.', 93 | ], 94 | ], 95 | ]; 96 | -------------------------------------------------------------------------------- /tests/Unit/ArticleTest.php: -------------------------------------------------------------------------------- 1 | 'My First Blog Post', 18 | 'content' => 'Content here', 19 | 'is_published' => true, 20 | 'published_at' => now(), 21 | ]); 22 | 23 | $this->assertEquals('my-first-blog-post', $article->slug); 24 | } 25 | 26 | public function test_article_belongs_to_category(): void 27 | { 28 | $category = Category::create([ 29 | 'title' => 'Technology', 30 | 'slug' => 'technology', 31 | 'is_active' => true, 32 | ]); 33 | 34 | $article = Article::create([ 35 | 'title' => 'Tech Article', 36 | 'slug' => 'tech-article', 37 | 'content' => 'Content', 38 | 'category_id' => $category->id, 39 | 'is_published' => true, 40 | 'published_at' => now(), 41 | ]); 42 | 43 | $this->assertInstanceOf(Category::class, $article->category); 44 | $this->assertEquals('Technology', $article->category->title); 45 | } 46 | 47 | public function test_published_scope_filters_correctly(): void 48 | { 49 | // Create published article 50 | Article::create([ 51 | 'title' => 'Published Article', 52 | 'slug' => 'published-article', 53 | 'content' => 'Content', 54 | 'is_published' => true, 55 | 'published_at' => now()->subDay(), 56 | ]); 57 | 58 | // Create unpublished article 59 | Article::create([ 60 | 'title' => 'Draft Article', 61 | 'slug' => 'draft-article', 62 | 'content' => 'Content', 63 | 'is_published' => false, 64 | ]); 65 | 66 | // Create future scheduled article 67 | Article::create([ 68 | 'title' => 'Future Article', 69 | 'slug' => 'future-article', 70 | 'content' => 'Content', 71 | 'is_published' => true, 72 | 'published_at' => now()->addDay(), 73 | ]); 74 | 75 | $publishedArticles = Article::published()->get(); 76 | 77 | $this->assertCount(1, $publishedArticles); 78 | $this->assertEquals('Published Article', $publishedArticles->first()->title); 79 | } 80 | 81 | public function test_article_can_have_null_category(): void 82 | { 83 | $article = Article::create([ 84 | 'title' => 'Uncategorized Article', 85 | 'slug' => 'uncategorized-article', 86 | 'content' => 'Content', 87 | 'category_id' => null, 88 | 'is_published' => true, 89 | 'published_at' => now(), 90 | ]); 91 | 92 | $this->assertNull($article->category); 93 | } 94 | 95 | public function test_read_count_defaults_to_zero(): void 96 | { 97 | $article = Article::create([ 98 | 'title' => 'New Article', 99 | 'slug' => 'new-article', 100 | 'content' => 'Content', 101 | 'is_published' => true, 102 | 'published_at' => now(), 103 | ]); 104 | 105 | $this->assertEquals(0, $article->read_count); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/Unit/PageTest.php: -------------------------------------------------------------------------------- 1 | 'About Our Company', 17 | 'content' => 'Content here', 18 | 'is_published' => true, 19 | ]); 20 | 21 | $this->assertEquals('about-our-company', $page->slug); 22 | } 23 | 24 | public function test_published_scope_filters_correctly(): void 25 | { 26 | Page::create([ 27 | 'title' => 'Published Page', 28 | 'slug' => 'published-page', 29 | 'content' => 'Content', 30 | 'is_published' => true, 31 | ]); 32 | 33 | Page::create([ 34 | 'title' => 'Draft Page', 35 | 'slug' => 'draft-page', 36 | 'content' => 'Content', 37 | 'is_published' => false, 38 | ]); 39 | 40 | $publishedPages = Page::published()->get(); 41 | 42 | $this->assertCount(1, $publishedPages); 43 | $this->assertEquals('Published Page', $publishedPages->first()->title); 44 | } 45 | 46 | public function test_page_can_have_parent(): void 47 | { 48 | $parent = Page::create([ 49 | 'title' => 'Parent Page', 50 | 'slug' => 'parent-page', 51 | 'content' => 'Parent content', 52 | 'is_published' => true, 53 | ]); 54 | 55 | $child = Page::create([ 56 | 'title' => 'Child Page', 57 | 'slug' => 'child-page', 58 | 'content' => 'Child content', 59 | 'parent_id' => $parent->id, 60 | 'is_published' => true, 61 | ]); 62 | 63 | $this->assertEquals($parent->id, $child->parent_id); 64 | $this->assertInstanceOf(Page::class, $child->parent); 65 | $this->assertEquals('Parent Page', $child->parent->title); 66 | } 67 | 68 | public function test_page_can_have_children(): void 69 | { 70 | $parent = Page::create([ 71 | 'title' => 'Parent Page', 72 | 'slug' => 'parent-page', 73 | 'content' => 'Parent content', 74 | 'is_published' => true, 75 | ]); 76 | 77 | Page::create([ 78 | 'title' => 'Child 1', 79 | 'slug' => 'child-1', 80 | 'content' => 'Content', 81 | 'parent_id' => $parent->id, 82 | 'is_published' => true, 83 | ]); 84 | 85 | Page::create([ 86 | 'title' => 'Child 2', 87 | 'slug' => 'child-2', 88 | 'content' => 'Content', 89 | 'parent_id' => $parent->id, 90 | 'is_published' => true, 91 | ]); 92 | 93 | $this->assertCount(2, $parent->children); 94 | } 95 | 96 | public function test_roots_scope_returns_only_top_level_pages(): void 97 | { 98 | $root1 = Page::create([ 99 | 'title' => 'Root 1', 100 | 'slug' => 'root-1', 101 | 'content' => 'Content', 102 | 'is_published' => true, 103 | ]); 104 | 105 | $root2 = Page::create([ 106 | 'title' => 'Root 2', 107 | 'slug' => 'root-2', 108 | 'content' => 'Content', 109 | 'is_published' => true, 110 | ]); 111 | 112 | Page::create([ 113 | 'title' => 'Child', 114 | 'slug' => 'child', 115 | 'content' => 'Content', 116 | 'parent_id' => $root1->id, 117 | 'is_published' => true, 118 | ]); 119 | 120 | $roots = Page::roots()->get(); 121 | 122 | $this->assertCount(2, $roots); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /config/mail.php: -------------------------------------------------------------------------------- 1 | env('MAIL_MAILER', 'log'), 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Mailer Configurations 22 | |-------------------------------------------------------------------------- 23 | | 24 | | Here you may configure all of the mailers used by your application plus 25 | | their respective settings. Several examples have been configured for 26 | | you and you are free to add your own as your application requires. 27 | | 28 | | Laravel supports a variety of mail "transport" drivers that can be used 29 | | when delivering an email. You may specify which one you're using for 30 | | your mailers below. You may also add additional mailers if needed. 31 | | 32 | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", 33 | | "postmark", "resend", "log", "array", 34 | | "failover", "roundrobin" 35 | | 36 | */ 37 | 38 | 'mailers' => [ 39 | 40 | 'smtp' => [ 41 | 'transport' => 'smtp', 42 | 'scheme' => env('MAIL_SCHEME'), 43 | 'url' => env('MAIL_URL'), 44 | 'host' => env('MAIL_HOST', '127.0.0.1'), 45 | 'port' => env('MAIL_PORT', 2525), 46 | 'username' => env('MAIL_USERNAME'), 47 | 'password' => env('MAIL_PASSWORD'), 48 | 'timeout' => null, 49 | 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), 50 | ], 51 | 52 | 'ses' => [ 53 | 'transport' => 'ses', 54 | ], 55 | 56 | 'postmark' => [ 57 | 'transport' => 'postmark', 58 | // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), 59 | // 'client' => [ 60 | // 'timeout' => 5, 61 | // ], 62 | ], 63 | 64 | 'resend' => [ 65 | 'transport' => 'resend', 66 | ], 67 | 68 | 'sendmail' => [ 69 | 'transport' => 'sendmail', 70 | 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), 71 | ], 72 | 73 | 'log' => [ 74 | 'transport' => 'log', 75 | 'channel' => env('MAIL_LOG_CHANNEL'), 76 | ], 77 | 78 | 'array' => [ 79 | 'transport' => 'array', 80 | ], 81 | 82 | 'failover' => [ 83 | 'transport' => 'failover', 84 | 'mailers' => [ 85 | 'smtp', 86 | 'log', 87 | ], 88 | 'retry_after' => 60, 89 | ], 90 | 91 | 'roundrobin' => [ 92 | 'transport' => 'roundrobin', 93 | 'mailers' => [ 94 | 'ses', 95 | 'postmark', 96 | ], 97 | 'retry_after' => 60, 98 | ], 99 | 100 | ], 101 | 102 | /* 103 | |-------------------------------------------------------------------------- 104 | | Global "From" Address 105 | |-------------------------------------------------------------------------- 106 | | 107 | | You may wish for all emails sent by your application to be sent from 108 | | the same address. Here you may specify a name and address that is 109 | | used globally for all emails that are sent by your application. 110 | | 111 | */ 112 | 113 | 'from' => [ 114 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 115 | 'name' => env('MAIL_FROM_NAME', 'Example'), 116 | ], 117 | 118 | ]; 119 | -------------------------------------------------------------------------------- /config/cache.php: -------------------------------------------------------------------------------- 1 | env('CACHE_STORE', 'database'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Cache Stores 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the cache "stores" for your application as 26 | | well as their drivers. You may even define multiple stores for the 27 | | same cache driver to group types of items stored in your caches. 28 | | 29 | | Supported drivers: "array", "database", "file", "memcached", 30 | | "redis", "dynamodb", "octane", 31 | | "failover", "null" 32 | | 33 | */ 34 | 35 | 'stores' => [ 36 | 37 | 'array' => [ 38 | 'driver' => 'array', 39 | 'serialize' => false, 40 | ], 41 | 42 | 'database' => [ 43 | 'driver' => 'database', 44 | 'connection' => env('DB_CACHE_CONNECTION'), 45 | 'table' => env('DB_CACHE_TABLE', 'cache'), 46 | 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), 47 | 'lock_table' => env('DB_CACHE_LOCK_TABLE'), 48 | ], 49 | 50 | 'file' => [ 51 | 'driver' => 'file', 52 | 'path' => storage_path('framework/cache/data'), 53 | 'lock_path' => storage_path('framework/cache/data'), 54 | ], 55 | 56 | 'memcached' => [ 57 | 'driver' => 'memcached', 58 | 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 59 | 'sasl' => [ 60 | env('MEMCACHED_USERNAME'), 61 | env('MEMCACHED_PASSWORD'), 62 | ], 63 | 'options' => [ 64 | // Memcached::OPT_CONNECT_TIMEOUT => 2000, 65 | ], 66 | 'servers' => [ 67 | [ 68 | 'host' => env('MEMCACHED_HOST', '127.0.0.1'), 69 | 'port' => env('MEMCACHED_PORT', 11211), 70 | 'weight' => 100, 71 | ], 72 | ], 73 | ], 74 | 75 | 'redis' => [ 76 | 'driver' => 'redis', 77 | 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), 78 | 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), 79 | ], 80 | 81 | 'dynamodb' => [ 82 | 'driver' => 'dynamodb', 83 | 'key' => env('AWS_ACCESS_KEY_ID'), 84 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 85 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 86 | 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 87 | 'endpoint' => env('DYNAMODB_ENDPOINT'), 88 | ], 89 | 90 | 'octane' => [ 91 | 'driver' => 'octane', 92 | ], 93 | 94 | 'failover' => [ 95 | 'driver' => 'failover', 96 | 'stores' => [ 97 | 'database', 98 | 'array', 99 | ], 100 | ], 101 | 102 | ], 103 | 104 | /* 105 | |-------------------------------------------------------------------------- 106 | | Cache Key Prefix 107 | |-------------------------------------------------------------------------- 108 | | 109 | | When utilizing the APC, database, memcached, Redis, and DynamoDB cache 110 | | stores, there might be other applications using the same cache. For 111 | | that reason, you may prefix every cache key to avoid collisions. 112 | | 113 | */ 114 | 115 | 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), 116 | 117 | ]; 118 | -------------------------------------------------------------------------------- /config/auth.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'guard' => env('AUTH_GUARD', 'web'), 18 | 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), 19 | ], 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Authentication Guards 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Next, you may define every authentication guard for your application. 27 | | Of course, a great default configuration has been defined for you 28 | | which utilizes session storage plus the Eloquent user provider. 29 | | 30 | | All authentication guards have a user provider, which defines how the 31 | | users are actually retrieved out of your database or other storage 32 | | system used by the application. Typically, Eloquent is utilized. 33 | | 34 | | Supported: "session" 35 | | 36 | */ 37 | 38 | 'guards' => [ 39 | 'web' => [ 40 | 'driver' => 'session', 41 | 'provider' => 'users', 42 | ], 43 | ], 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | User Providers 48 | |-------------------------------------------------------------------------- 49 | | 50 | | All authentication guards have a user provider, which defines how the 51 | | users are actually retrieved out of your database or other storage 52 | | system used by the application. Typically, Eloquent is utilized. 53 | | 54 | | If you have multiple user tables or models you may configure multiple 55 | | providers to represent the model / table. These providers may then 56 | | be assigned to any extra authentication guards you have defined. 57 | | 58 | | Supported: "database", "eloquent" 59 | | 60 | */ 61 | 62 | 'providers' => [ 63 | 'users' => [ 64 | 'driver' => 'eloquent', 65 | 'model' => env('AUTH_MODEL', App\Models\User::class), 66 | ], 67 | 68 | // 'users' => [ 69 | // 'driver' => 'database', 70 | // 'table' => 'users', 71 | // ], 72 | ], 73 | 74 | /* 75 | |-------------------------------------------------------------------------- 76 | | Resetting Passwords 77 | |-------------------------------------------------------------------------- 78 | | 79 | | These configuration options specify the behavior of Laravel's password 80 | | reset functionality, including the table utilized for token storage 81 | | and the user provider that is invoked to actually retrieve users. 82 | | 83 | | The expiry time is the number of minutes that each reset token will be 84 | | considered valid. This security feature keeps tokens short-lived so 85 | | they have less time to be guessed. You may change this as needed. 86 | | 87 | | The throttle setting is the number of seconds a user must wait before 88 | | generating more password reset tokens. This prevents the user from 89 | | quickly generating a very large amount of password reset tokens. 90 | | 91 | */ 92 | 93 | 'passwords' => [ 94 | 'users' => [ 95 | 'provider' => 'users', 96 | 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), 97 | 'expire' => 60, 98 | 'throttle' => 60, 99 | ], 100 | ], 101 | 102 | /* 103 | |-------------------------------------------------------------------------- 104 | | Password Confirmation Timeout 105 | |-------------------------------------------------------------------------- 106 | | 107 | | Here you may define the number of seconds before a password confirmation 108 | | window expires and users are asked to re-enter their password via the 109 | | confirmation screen. By default, the timeout lasts for three hours. 110 | | 111 | */ 112 | 113 | 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), 114 | 115 | ]; 116 | -------------------------------------------------------------------------------- /config/queue.php: -------------------------------------------------------------------------------- 1 | env('QUEUE_CONNECTION', 'database'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Queue Connections 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may configure the connection options for every queue backend 24 | | used by your application. An example configuration is provided for 25 | | each backend supported by Laravel. You're also free to add more. 26 | | 27 | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", 28 | | "deferred", "background", "failover", "null" 29 | | 30 | */ 31 | 32 | 'connections' => [ 33 | 34 | 'sync' => [ 35 | 'driver' => 'sync', 36 | ], 37 | 38 | 'database' => [ 39 | 'driver' => 'database', 40 | 'connection' => env('DB_QUEUE_CONNECTION'), 41 | 'table' => env('DB_QUEUE_TABLE', 'jobs'), 42 | 'queue' => env('DB_QUEUE', 'default'), 43 | 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), 44 | 'after_commit' => false, 45 | ], 46 | 47 | 'beanstalkd' => [ 48 | 'driver' => 'beanstalkd', 49 | 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), 50 | 'queue' => env('BEANSTALKD_QUEUE', 'default'), 51 | 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), 52 | 'block_for' => 0, 53 | 'after_commit' => false, 54 | ], 55 | 56 | 'sqs' => [ 57 | 'driver' => 'sqs', 58 | 'key' => env('AWS_ACCESS_KEY_ID'), 59 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 60 | 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 61 | 'queue' => env('SQS_QUEUE', 'default'), 62 | 'suffix' => env('SQS_SUFFIX'), 63 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 64 | 'after_commit' => false, 65 | ], 66 | 67 | 'redis' => [ 68 | 'driver' => 'redis', 69 | 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 70 | 'queue' => env('REDIS_QUEUE', 'default'), 71 | 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 72 | 'block_for' => null, 73 | 'after_commit' => false, 74 | ], 75 | 76 | 'deferred' => [ 77 | 'driver' => 'deferred', 78 | ], 79 | 80 | 'background' => [ 81 | 'driver' => 'background', 82 | ], 83 | 84 | 'failover' => [ 85 | 'driver' => 'failover', 86 | 'connections' => [ 87 | 'database', 88 | 'deferred', 89 | ], 90 | ], 91 | 92 | ], 93 | 94 | /* 95 | |-------------------------------------------------------------------------- 96 | | Job Batching 97 | |-------------------------------------------------------------------------- 98 | | 99 | | The following options configure the database and table that store job 100 | | batching information. These options can be updated to any database 101 | | connection and table which has been defined by your application. 102 | | 103 | */ 104 | 105 | 'batching' => [ 106 | 'database' => env('DB_CONNECTION', 'sqlite'), 107 | 'table' => 'job_batches', 108 | ], 109 | 110 | /* 111 | |-------------------------------------------------------------------------- 112 | | Failed Queue Jobs 113 | |-------------------------------------------------------------------------- 114 | | 115 | | These options configure the behavior of failed queue job logging so you 116 | | can control how and where failed jobs are stored. Laravel ships with 117 | | support for storing failed jobs in a simple file or in a database. 118 | | 119 | | Supported drivers: "database-uuids", "dynamodb", "file", "null" 120 | | 121 | */ 122 | 123 | 'failed' => [ 124 | 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 125 | 'database' => env('DB_CONNECTION', 'sqlite'), 126 | 'table' => 'failed_jobs', 127 | ], 128 | 129 | ]; 130 | -------------------------------------------------------------------------------- /tests/Unit/ArticleViewTest.php: -------------------------------------------------------------------------------- 1 | 'Test Article', 18 | 'slug' => 'test-article', 19 | 'content' => 'Content', 20 | 'is_published' => true, 21 | 'published_at' => now(), 22 | ]); 23 | 24 | $view = ArticleView::create([ 25 | 'article_id' => $article->id, 26 | 'ip_address' => '127.0.0.1', 27 | 'user_agent' => 'Test Agent', 28 | 'viewed_at' => now(), 29 | ]); 30 | 31 | $this->assertInstanceOf(Article::class, $view->article); 32 | $this->assertEquals($article->id, $view->article->id); 33 | } 34 | 35 | public function test_article_has_many_views(): void 36 | { 37 | $article = Article::create([ 38 | 'title' => 'Test Article', 39 | 'slug' => 'test-article', 40 | 'content' => 'Content', 41 | 'is_published' => true, 42 | 'published_at' => now(), 43 | ]); 44 | 45 | ArticleView::create([ 46 | 'article_id' => $article->id, 47 | 'ip_address' => '127.0.0.1', 48 | 'viewed_at' => now(), 49 | ]); 50 | 51 | ArticleView::create([ 52 | 'article_id' => $article->id, 53 | 'ip_address' => '192.168.1.1', 54 | 'viewed_at' => now(), 55 | ]); 56 | 57 | $this->assertCount(2, $article->views); 58 | } 59 | 60 | public function test_article_record_view_creates_view(): void 61 | { 62 | $article = Article::create([ 63 | 'title' => 'Test Article', 64 | 'slug' => 'test-article', 65 | 'content' => 'Content', 66 | 'is_published' => true, 67 | 'published_at' => now(), 68 | ]); 69 | 70 | $view = $article->recordView('127.0.0.1', 'Test Agent', 'https://google.com'); 71 | 72 | $this->assertInstanceOf(ArticleView::class, $view); 73 | $this->assertEquals($article->id, $view->article_id); 74 | $this->assertEquals('127.0.0.1', $view->ip_address); 75 | $this->assertEquals('Test Agent', $view->user_agent); 76 | $this->assertEquals('https://google.com', $view->referer); 77 | $this->assertNotNull($view->viewed_at); 78 | } 79 | 80 | public function test_article_view_can_have_null_optional_fields(): void 81 | { 82 | $article = Article::create([ 83 | 'title' => 'Test Article', 84 | 'slug' => 'test-article', 85 | 'content' => 'Content', 86 | 'is_published' => true, 87 | 'published_at' => now(), 88 | ]); 89 | 90 | $view = $article->recordView(null, null, null); 91 | 92 | $this->assertInstanceOf(ArticleView::class, $view); 93 | $this->assertNull($view->ip_address); 94 | $this->assertNull($view->user_agent); 95 | $this->assertNull($view->referer); 96 | } 97 | 98 | public function test_viewed_at_is_cast_to_datetime(): void 99 | { 100 | $article = Article::create([ 101 | 'title' => 'Test Article', 102 | 'slug' => 'test-article', 103 | 'content' => 'Content', 104 | 'is_published' => true, 105 | 'published_at' => now(), 106 | ]); 107 | 108 | $view = ArticleView::create([ 109 | 'article_id' => $article->id, 110 | 'viewed_at' => '2025-01-15 10:30:00', 111 | ]); 112 | 113 | $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $view->viewed_at); 114 | } 115 | 116 | public function test_views_are_deleted_when_article_is_deleted(): void 117 | { 118 | $article = Article::create([ 119 | 'title' => 'Test Article', 120 | 'slug' => 'test-article', 121 | 'content' => 'Content', 122 | 'is_published' => true, 123 | 'published_at' => now(), 124 | ]); 125 | 126 | ArticleView::create([ 127 | 'article_id' => $article->id, 128 | 'viewed_at' => now(), 129 | ]); 130 | 131 | ArticleView::create([ 132 | 'article_id' => $article->id, 133 | 'viewed_at' => now(), 134 | ]); 135 | 136 | $this->assertEquals(2, ArticleView::count()); 137 | 138 | $article->delete(); 139 | 140 | $this->assertEquals(0, ArticleView::count()); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /config/app.php: -------------------------------------------------------------------------------- 1 | env('APP_NAME', 'Laravel'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Application Environment 21 | |-------------------------------------------------------------------------- 22 | | 23 | | This value determines the "environment" your application is currently 24 | | running in. This may determine how you prefer to configure various 25 | | services the application utilizes. Set this in your ".env" file. 26 | | 27 | */ 28 | 29 | 'env' => env('APP_ENV', 'production'), 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Application Debug Mode 34 | |-------------------------------------------------------------------------- 35 | | 36 | | When your application is in debug mode, detailed error messages with 37 | | stack traces will be shown on every error that occurs within your 38 | | application. If disabled, a simple generic error page is shown. 39 | | 40 | */ 41 | 42 | 'debug' => (bool) env('APP_DEBUG', false), 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Application URL 47 | |-------------------------------------------------------------------------- 48 | | 49 | | This URL is used by the console to properly generate URLs when using 50 | | the Artisan command line tool. You should set this to the root of 51 | | the application so that it's available within Artisan commands. 52 | | 53 | */ 54 | 55 | 'url' => env('APP_URL', 'http://localhost'), 56 | 57 | /* 58 | |-------------------------------------------------------------------------- 59 | | Application Timezone 60 | |-------------------------------------------------------------------------- 61 | | 62 | | Here you may specify the default timezone for your application, which 63 | | will be used by the PHP date and date-time functions. The timezone 64 | | is set to "UTC" by default as it is suitable for most use cases. 65 | | 66 | */ 67 | 68 | 'timezone' => 'UTC', 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Application Locale Configuration 73 | |-------------------------------------------------------------------------- 74 | | 75 | | The application locale determines the default locale that will be used 76 | | by Laravel's translation / localization methods. This option can be 77 | | set to any locale for which you plan to have translation strings. 78 | | 79 | */ 80 | 81 | 'locale' => env('APP_LOCALE', 'en'), 82 | 83 | 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), 84 | 85 | 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), 86 | 87 | /* 88 | |-------------------------------------------------------------------------- 89 | | Encryption Key 90 | |-------------------------------------------------------------------------- 91 | | 92 | | This key is utilized by Laravel's encryption services and should be set 93 | | to a random, 32 character string to ensure that all encrypted values 94 | | are secure. You should do this prior to deploying the application. 95 | | 96 | */ 97 | 98 | 'cipher' => 'AES-256-CBC', 99 | 100 | 'key' => env('APP_KEY'), 101 | 102 | 'previous_keys' => [ 103 | ...array_filter( 104 | explode(',', (string) env('APP_PREVIOUS_KEYS', '')) 105 | ), 106 | ], 107 | 108 | /* 109 | |-------------------------------------------------------------------------- 110 | | Maintenance Mode Driver 111 | |-------------------------------------------------------------------------- 112 | | 113 | | These configuration options determine the driver used to determine and 114 | | manage Laravel's "maintenance mode" status. The "cache" driver will 115 | | allow maintenance mode to be controlled across multiple machines. 116 | | 117 | | Supported drivers: "file", "cache" 118 | | 119 | */ 120 | 121 | 'maintenance' => [ 122 | 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), 123 | 'store' => env('APP_MAINTENANCE_STORE', 'database'), 124 | ], 125 | 126 | ]; 127 | -------------------------------------------------------------------------------- /config/logging.php: -------------------------------------------------------------------------------- 1 | env('LOG_CHANNEL', 'stack'), 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | Deprecations Log Channel 26 | |-------------------------------------------------------------------------- 27 | | 28 | | This option controls the log channel that should be used to log warnings 29 | | regarding deprecated PHP and library features. This allows you to get 30 | | your application ready for upcoming major versions of dependencies. 31 | | 32 | */ 33 | 34 | 'deprecations' => [ 35 | 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), 36 | 'trace' => env('LOG_DEPRECATIONS_TRACE', false), 37 | ], 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Log Channels 42 | |-------------------------------------------------------------------------- 43 | | 44 | | Here you may configure the log channels for your application. Laravel 45 | | utilizes the Monolog PHP logging library, which includes a variety 46 | | of powerful log handlers and formatters that you're free to use. 47 | | 48 | | Available drivers: "single", "daily", "slack", "syslog", 49 | | "errorlog", "monolog", "custom", "stack" 50 | | 51 | */ 52 | 53 | 'channels' => [ 54 | 55 | 'stack' => [ 56 | 'driver' => 'stack', 57 | 'channels' => explode(',', (string) env('LOG_STACK', 'single')), 58 | 'ignore_exceptions' => false, 59 | ], 60 | 61 | 'single' => [ 62 | 'driver' => 'single', 63 | 'path' => storage_path('logs/laravel.log'), 64 | 'level' => env('LOG_LEVEL', 'debug'), 65 | 'replace_placeholders' => true, 66 | ], 67 | 68 | 'daily' => [ 69 | 'driver' => 'daily', 70 | 'path' => storage_path('logs/laravel.log'), 71 | 'level' => env('LOG_LEVEL', 'debug'), 72 | 'days' => env('LOG_DAILY_DAYS', 14), 73 | 'replace_placeholders' => true, 74 | ], 75 | 76 | 'slack' => [ 77 | 'driver' => 'slack', 78 | 'url' => env('LOG_SLACK_WEBHOOK_URL'), 79 | 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), 80 | 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), 81 | 'level' => env('LOG_LEVEL', 'critical'), 82 | 'replace_placeholders' => true, 83 | ], 84 | 85 | 'papertrail' => [ 86 | 'driver' => 'monolog', 87 | 'level' => env('LOG_LEVEL', 'debug'), 88 | 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 89 | 'handler_with' => [ 90 | 'host' => env('PAPERTRAIL_URL'), 91 | 'port' => env('PAPERTRAIL_PORT'), 92 | 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), 93 | ], 94 | 'processors' => [PsrLogMessageProcessor::class], 95 | ], 96 | 97 | 'stderr' => [ 98 | 'driver' => 'monolog', 99 | 'level' => env('LOG_LEVEL', 'debug'), 100 | 'handler' => StreamHandler::class, 101 | 'handler_with' => [ 102 | 'stream' => 'php://stderr', 103 | ], 104 | 'formatter' => env('LOG_STDERR_FORMATTER'), 105 | 'processors' => [PsrLogMessageProcessor::class], 106 | ], 107 | 108 | 'syslog' => [ 109 | 'driver' => 'syslog', 110 | 'level' => env('LOG_LEVEL', 'debug'), 111 | 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), 112 | 'replace_placeholders' => true, 113 | ], 114 | 115 | 'errorlog' => [ 116 | 'driver' => 'errorlog', 117 | 'level' => env('LOG_LEVEL', 'debug'), 118 | 'replace_placeholders' => true, 119 | ], 120 | 121 | 'null' => [ 122 | 'driver' => 'monolog', 123 | 'handler' => NullHandler::class, 124 | ], 125 | 126 | 'emergency' => [ 127 | 'path' => storage_path('logs/laravel.log'), 128 | ], 129 | 130 | ], 131 | 132 | ]; 133 | -------------------------------------------------------------------------------- /resources/views/components/layouts/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ $title ?? __('frontend.nav.home') }} - {{ __('frontend.site_name') }} 8 | 9 | @vite(['resources/css/app.css', 'resources/js/app.js']) 10 | @stack('styles') 11 | 12 | 13 | 14 |
15 | 45 |
46 | 47 |
48 | {{ $slot }} 49 |
50 | 51 |
52 |
53 |
54 |
55 | {{ __('frontend.site_name') }} 56 |

57 | {{ __('frontend.site_description') }} 58 |

59 |
60 |
61 |
62 |

{{ __('frontend.nav.navigate') }}

63 | 67 |
68 |
69 |

{{ __('frontend.nav.info') }}

70 | 75 |
76 |
77 |
78 |
79 |
80 |

{{ __('frontend.footer.copyright', ['year' => date('Y'), 'name' => __('frontend.site_name')]) }}

81 |

{{ __('frontend.footer.built_with') }}

82 |
83 |
84 |
85 | @stack('scripts') 86 | 87 | 88 | -------------------------------------------------------------------------------- /app/Filament/Resources/Media/Tables/MediaTable.php: -------------------------------------------------------------------------------- 1 | columns([ 24 | ImageColumn::make('preview') 25 | ->label('') 26 | ->getStateUsing(fn ($record) => $record->getUrl()) 27 | ->width(80) 28 | ->height(60), 29 | TextColumn::make('file_name') 30 | ->label('File Name') 31 | ->searchable() 32 | ->sortable() 33 | ->limit(30), 34 | TextColumn::make('model_type') 35 | ->label('Used By') 36 | ->formatStateUsing(fn (string $state) => class_basename($state)) 37 | ->badge() 38 | ->color('gray'), 39 | TextColumn::make('collection_name') 40 | ->label('Collection') 41 | ->badge() 42 | ->color('info'), 43 | TextColumn::make('mime_type') 44 | ->label('Type') 45 | ->toggleable(), 46 | TextColumn::make('size') 47 | ->label('Size') 48 | ->formatStateUsing(fn (int $state) => Number::fileSize($state)) 49 | ->sortable(), 50 | TextColumn::make('created_at') 51 | ->label('Uploaded') 52 | ->dateTime() 53 | ->sortable(), 54 | ]) 55 | ->filters([ 56 | SelectFilter::make('collection_name') 57 | ->label('Collection') 58 | ->options(fn () => \Spatie\MediaLibrary\MediaCollections\Models\Media::query() 59 | ->distinct() 60 | ->pluck('collection_name', 'collection_name') 61 | ->toArray() 62 | ), 63 | SelectFilter::make('model_type') 64 | ->label('Model') 65 | ->options(fn () => \Spatie\MediaLibrary\MediaCollections\Models\Media::query() 66 | ->distinct() 67 | ->pluck('model_type') 68 | ->mapWithKeys(fn ($type) => [$type => class_basename($type)]) 69 | ->toArray() 70 | ), 71 | ]) 72 | ->recordActions([ 73 | Action::make('view') 74 | ->label('View') 75 | ->icon('heroicon-o-eye') 76 | ->modalHeading(fn ($record) => $record->file_name) 77 | ->infolist([ 78 | ImageEntry::make('preview') 79 | ->hiddenLabel() 80 | ->state(fn ($record) => $record->getUrl()) 81 | ->height(300) 82 | ->extraImgAttributes(['class' => 'rounded-lg mx-auto']), 83 | TextEntry::make('url') 84 | ->label('URL') 85 | ->state(fn ($record) => $record->getUrl()) 86 | ->copyable() 87 | ->copyMessage('Copied!') 88 | ->copyMessageDuration(1500) 89 | ->fontFamily('mono') 90 | ->size('sm'), 91 | Section::make('Details') 92 | ->schema([ 93 | TextEntry::make('size') 94 | ->state(fn ($record) => Number::fileSize($record->size)), 95 | TextEntry::make('mime_type') 96 | ->label('Type'), 97 | TextEntry::make('created_at') 98 | ->label('Uploaded') 99 | ->dateTime(), 100 | ]) 101 | ->columns(3) 102 | ->compact(), 103 | ]) 104 | ->modalSubmitAction(false) 105 | ->modalCancelActionLabel('Close'), 106 | DeleteAction::make(), 107 | ]) 108 | ->toolbarActions([ 109 | BulkActionGroup::make([ 110 | DeleteBulkAction::make(), 111 | ]), 112 | ]) 113 | ->defaultSort('created_at', 'desc'); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/Feature/FrontendTest.php: -------------------------------------------------------------------------------- 1 | get('/'); 18 | 19 | $response->assertStatus(200); 20 | $response->assertSee('Laravel Simple CMS'); 21 | } 22 | 23 | public function test_homepage_displays_published_articles(): void 24 | { 25 | $category = Category::create([ 26 | 'title' => 'Technology', 27 | 'slug' => 'technology', 28 | 'is_active' => true, 29 | ]); 30 | 31 | // Create 3 articles to trigger hero section display 32 | foreach (range(1, 3) as $i) { 33 | Article::create([ 34 | 'title' => "Test Article $i", 35 | 'slug' => "test-article-$i", 36 | 'content' => 'Test content', 37 | 'category_id' => $category->id, 38 | 'is_published' => true, 39 | 'published_at' => now(), 40 | ]); 41 | } 42 | 43 | $response = $this->get('/'); 44 | 45 | $response->assertStatus(200); 46 | $response->assertSee('Test Article 1'); 47 | } 48 | 49 | public function test_articles_index_page_loads(): void 50 | { 51 | $response = $this->get('/articles'); 52 | 53 | $response->assertStatus(200); 54 | $response->assertSee('All Articles'); 55 | } 56 | 57 | public function test_article_show_page_loads(): void 58 | { 59 | $category = Category::create([ 60 | 'title' => 'Technology', 61 | 'slug' => 'technology', 62 | 'is_active' => true, 63 | ]); 64 | 65 | $article = Article::create([ 66 | 'title' => 'My Test Article', 67 | 'slug' => 'my-test-article', 68 | 'content' => '

Article content here

', 69 | 'category_id' => $category->id, 70 | 'is_published' => true, 71 | 'published_at' => now(), 72 | ]); 73 | 74 | $response = $this->get('/article/my-test-article'); 75 | 76 | $response->assertStatus(200); 77 | $response->assertSee('My Test Article'); 78 | $response->assertSee('Article content here'); 79 | } 80 | 81 | public function test_article_view_records_view(): void 82 | { 83 | $article = Article::create([ 84 | 'title' => 'View Test Article', 85 | 'slug' => 'view-test-article', 86 | 'content' => 'Content', 87 | 'is_published' => true, 88 | 'published_at' => now(), 89 | ]); 90 | 91 | $this->assertEquals(0, $article->views()->count()); 92 | 93 | $this->get('/article/view-test-article'); 94 | 95 | $this->assertEquals(1, $article->views()->count()); 96 | 97 | $this->get('/article/view-test-article'); 98 | 99 | $this->assertEquals(2, $article->views()->count()); 100 | } 101 | 102 | public function test_unpublished_article_returns_404(): void 103 | { 104 | $article = Article::create([ 105 | 'title' => 'Draft Article', 106 | 'slug' => 'draft-article', 107 | 'content' => 'Content', 108 | 'is_published' => false, 109 | ]); 110 | 111 | $response = $this->get('/article/draft-article'); 112 | 113 | $response->assertStatus(404); 114 | } 115 | 116 | public function test_category_page_loads(): void 117 | { 118 | $category = Category::create([ 119 | 'title' => 'Design', 120 | 'slug' => 'design', 121 | 'is_active' => true, 122 | ]); 123 | 124 | $article = Article::create([ 125 | 'title' => 'Design Article', 126 | 'slug' => 'design-article', 127 | 'content' => 'Content', 128 | 'category_id' => $category->id, 129 | 'is_published' => true, 130 | 'published_at' => now(), 131 | ]); 132 | 133 | $response = $this->get('/category/design'); 134 | 135 | $response->assertStatus(200); 136 | $response->assertSee('Design'); 137 | $response->assertSee('Design Article'); 138 | } 139 | 140 | public function test_page_show_loads(): void 141 | { 142 | $page = Page::create([ 143 | 'title' => 'About Us', 144 | 'slug' => 'about-us', 145 | 'content' => '

About us content

', 146 | 'is_published' => true, 147 | ]); 148 | 149 | $response = $this->get('/page/about-us'); 150 | 151 | $response->assertStatus(200); 152 | $response->assertSee('About Us'); 153 | $response->assertSee('About us content'); 154 | } 155 | 156 | public function test_unpublished_page_returns_404(): void 157 | { 158 | $page = Page::create([ 159 | 'title' => 'Draft Page', 160 | 'slug' => 'draft-page', 161 | 'content' => 'Content', 162 | 'is_published' => false, 163 | ]); 164 | 165 | $response = $this->get('/page/draft-page'); 166 | 167 | $response->assertStatus(404); 168 | } 169 | 170 | public function test_nonexistent_article_returns_404(): void 171 | { 172 | $response = $this->get('/article/does-not-exist'); 173 | 174 | $response->assertStatus(404); 175 | } 176 | 177 | public function test_navigation_shows_published_pages(): void 178 | { 179 | $contactPage = Page::create([ 180 | 'title' => 'Contact', 181 | 'slug' => 'contact', 182 | 'content' => 'Contact us', 183 | 'is_published' => true, 184 | ]); 185 | 186 | $response = $this->get('/'); 187 | 188 | $response->assertStatus(200); 189 | $response->assertSee('Contact'); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /resources/views/frontend/articles/index.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | {{ __('frontend.articles.archive') }} 7 |

{{ __('frontend.articles.title') }}

8 |

9 | {{ __('frontend.articles.description') }} 10 |

11 |
12 |
13 |
14 | 15 |
16 | @if($articles->count()) 17 | 18 | @php $categories = \App\Models\Category::where('is_active', true)->whereHas('articles', fn($q) => $q->published())->withCount(['articles' => fn($q) => $q->published()])->get(); @endphp 19 | @if($categories->count()) 20 |
21 | {{ __('frontend.articles.filter_by_topic') }} 22 | @foreach($categories as $category) 23 | 24 | {{ $category->title }} 25 | {{ $category->articles_count }} 26 | 27 | @endforeach 28 |
29 | @endif 30 | 31 | 32 | 67 | 68 | 69 | @if($articles->hasPages()) 70 |
71 |
72 | @if($articles->onFirstPage()) 73 | 74 | @else 75 | « 76 | @endif 77 | 78 | @foreach($articles->getUrlRange(1, $articles->lastPage()) as $page => $url) 79 | {{ $page }} 80 | @endforeach 81 | 82 | @if($articles->hasMorePages()) 83 | » 84 | @else 85 | 86 | @endif 87 |
88 |
89 | @endif 90 | @else 91 | 92 |
93 |
94 | 95 |
96 |

{{ __('frontend.articles.no_articles') }}

97 |

{{ __('frontend.empty.check_back') }}

98 |
99 | @endif 100 |
101 |
102 | -------------------------------------------------------------------------------- /resources/views/frontend/pages/show.blade.php: -------------------------------------------------------------------------------- 1 | 2 | @php 3 | $isContactPage = $page->slug === 'contact'; 4 | @endphp 5 | 6 | 7 |
8 |
9 | 10 | 21 | 22 |
23 |

24 | {{ $page->title }} 25 |

26 | @if($page->excerpt) 27 |

28 | {{ $page->excerpt }} 29 |

30 | @endif 31 |
32 |
33 |
34 | 35 | 36 |
37 | @if($isContactPage) 38 | 39 |
40 | 41 |
42 |
{!! $page->content !!}
43 |
44 | 45 | 75 |
76 | @else 77 | 78 |
79 |
80 |
{!! $page->content !!}
81 | 82 |
83 |

84 | {{ __('frontend.pages.last_updated', ['date' => $page->updated_at->format('F d, Y')]) }} 85 |

86 |
87 |
88 |
89 | @endif 90 |
91 | 92 | 93 | @if($page->children->count()) 94 |
95 |
96 |
97 |

{{ __('frontend.pages.related_pages') }}

98 | 99 | 116 |
117 |
118 |
119 | @endif 120 |
121 | -------------------------------------------------------------------------------- /public/js/filament/notifications/notifications.js: -------------------------------------------------------------------------------- 1 | (()=>{var O=Object.create;var N=Object.defineProperty;var V=Object.getOwnPropertyDescriptor;var Y=Object.getOwnPropertyNames;var H=Object.getPrototypeOf,W=Object.prototype.hasOwnProperty;var d=(i,t)=>()=>(t||i((t={exports:{}}).exports,t),t.exports);var j=(i,t,e,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of Y(t))!W.call(i,n)&&n!==e&&N(i,n,{get:()=>t[n],enumerable:!(s=V(t,n))||s.enumerable});return i};var J=(i,t,e)=>(e=i!=null?O(H(i)):{},j(t||!i||!i.__esModule?N(e,"default",{value:i,enumerable:!0}):e,i));var S=d((ut,_)=>{var v,g=typeof global<"u"&&(global.crypto||global.msCrypto);g&&g.getRandomValues&&(y=new Uint8Array(16),v=function(){return g.getRandomValues(y),y});var y;v||(T=new Array(16),v=function(){for(var i=0,t;i<16;i++)(i&3)===0&&(t=Math.random()*4294967296),T[i]=t>>>((i&3)<<3)&255;return T});var T;_.exports=v});var C=d((ct,U)=>{var P=[];for(f=0;f<256;++f)P[f]=(f+256).toString(16).substr(1);var f;function K(i,t){var e=t||0,s=P;return s[i[e++]]+s[i[e++]]+s[i[e++]]+s[i[e++]]+"-"+s[i[e++]]+s[i[e++]]+"-"+s[i[e++]]+s[i[e++]]+"-"+s[i[e++]]+s[i[e++]]+"-"+s[i[e++]]+s[i[e++]]+s[i[e++]]+s[i[e++]]+s[i[e++]]+s[i[e++]]}U.exports=K});var R=d((lt,F)=>{var Q=S(),X=C(),a=Q(),Z=[a[0]|1,a[1],a[2],a[3],a[4],a[5]],b=(a[6]<<8|a[7])&16383,D=0,A=0;function tt(i,t,e){var s=t&&e||0,n=t||[];i=i||{};var r=i.clockseq!==void 0?i.clockseq:b,o=i.msecs!==void 0?i.msecs:new Date().getTime(),h=i.nsecs!==void 0?i.nsecs:A+1,l=o-D+(h-A)/1e4;if(l<0&&i.clockseq===void 0&&(r=r+1&16383),(l<0||o>D)&&i.nsecs===void 0&&(h=0),h>=1e4)throw new Error("uuid.v1(): Can't create more than 10M uuids/sec");D=o,A=h,b=r,o+=122192928e5;var c=((o&268435455)*1e4+h)%4294967296;n[s++]=c>>>24&255,n[s++]=c>>>16&255,n[s++]=c>>>8&255,n[s++]=c&255;var u=o/4294967296*1e4&268435455;n[s++]=u>>>8&255,n[s++]=u&255,n[s++]=u>>>24&15|16,n[s++]=u>>>16&255,n[s++]=r>>>8|128,n[s++]=r&255;for(var $=i.node||Z,m=0;m<6;++m)n[s+m]=$[m];return t||X(n)}F.exports=tt});var G=d((dt,B)=>{var it=S(),et=C();function st(i,t,e){var s=t&&e||0;typeof i=="string"&&(t=i=="binary"?new Array(16):null,i=null),i=i||{};var n=i.random||(i.rng||it)();if(n[6]=n[6]&15|64,n[8]=n[8]&63|128,t)for(var r=0;r<16;++r)t[s+r]=n[r];return t||et(n)}B.exports=st});var M=d((ft,L)=>{var nt=R(),I=G(),E=I;E.v1=nt;E.v4=I;L.exports=E});function k(i,t=()=>{}){let e=!1;return function(){e?t.apply(this,arguments):(e=!0,i.apply(this,arguments))}}var q=i=>{i.data("notificationComponent",({notification:t})=>({isShown:!1,computedStyle:null,transitionDuration:null,transitionEasing:null,init(){this.computedStyle=window.getComputedStyle(this.$el),this.transitionDuration=parseFloat(this.computedStyle.transitionDuration)*1e3,this.transitionEasing=this.computedStyle.transitionTimingFunction,this.configureTransitions(),this.configureAnimations(),t.duration&&t.duration!=="persistent"&&setTimeout(()=>{if(!this.$el.matches(":hover")){this.close();return}this.$el.addEventListener("mouseleave",()=>this.close())},t.duration),this.isShown=!0},configureTransitions(){let e=this.computedStyle.display,s=()=>{i.mutateDom(()=>{this.$el.style.setProperty("display",e),this.$el.style.setProperty("visibility","visible")}),this.$el._x_isShown=!0},n=()=>{i.mutateDom(()=>{this.$el._x_isShown?this.$el.style.setProperty("visibility","hidden"):this.$el.style.setProperty("display","none")})},r=k(o=>o?s():n(),o=>{this.$el._x_toggleAndCascadeWithTransitions(this.$el,o,s,n)});i.effect(()=>r(this.isShown))},configureAnimations(){let e;Livewire.hook("commit",({component:s,commit:n,succeed:r,fail:o,respond:h})=>{s.snapshot.data.isFilamentNotificationsComponent&&requestAnimationFrame(()=>{let l=()=>this.$el.getBoundingClientRect().top,c=l();h(()=>{e=()=>{this.isShown&&this.$el.animate([{transform:`translateY(${c-l()}px)`},{transform:"translateY(0px)"}],{duration:this.transitionDuration,easing:this.transitionEasing})},this.$el.getAnimations().forEach(u=>u.finish())}),r(({snapshot:u,effect:$})=>{e()})})})},close(){this.isShown=!1,setTimeout(()=>window.dispatchEvent(new CustomEvent("notificationClosed",{detail:{id:t.id}})),this.transitionDuration)},markAsRead(){window.dispatchEvent(new CustomEvent("markedNotificationAsRead",{detail:{id:t.id}}))},markAsUnread(){window.dispatchEvent(new CustomEvent("markedNotificationAsUnread",{detail:{id:t.id}}))}}))};var z=J(M(),1),p=class{constructor(){return this.id((0,z.v4)()),this}id(t){return this.id=t,this}title(t){return this.title=t,this}body(t){return this.body=t,this}actions(t){return this.actions=t,this}status(t){return this.status=t,this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconColor(t){return this.iconColor=t,this}duration(t){return this.duration=t,this}seconds(t){return this.duration(t*1e3),this}persistent(){return this.duration("persistent"),this}danger(){return this.status("danger"),this}info(){return this.status("info"),this}success(){return this.status("success"),this}warning(){return this.status("warning"),this}view(t){return this.view=t,this}viewData(t){return this.viewData=t,this}send(){return window.dispatchEvent(new CustomEvent("notificationSent",{detail:{notification:this}})),this}},w=class{constructor(t){return this.name(t),this}name(t){return this.name=t,this}color(t){return this.color=t,this}dispatch(t,e){return this.event(t),this.eventData(e),this}dispatchSelf(t,e){return this.dispatch(t,e),this.dispatchDirection="self",this}dispatchTo(t,e,s){return this.dispatch(e,s),this.dispatchDirection="to",this.dispatchToComponent=t,this}emit(t,e){return this.dispatch(t,e),this}emitSelf(t,e){return this.dispatchSelf(t,e),this}emitTo(t,e,s){return this.dispatchTo(t,e,s),this}dispatchDirection(t){return this.dispatchDirection=t,this}dispatchToComponent(t){return this.dispatchToComponent=t,this}event(t){return this.event=t,this}eventData(t){return this.eventData=t,this}extraAttributes(t){return this.extraAttributes=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}outlined(t=!0){return this.isOutlined=t,this}disabled(t=!0){return this.isDisabled=t,this}label(t){return this.label=t,this}close(t=!0){return this.shouldClose=t,this}openUrlInNewTab(t=!0){return this.shouldOpenUrlInNewTab=t,this}size(t){return this.size=t,this}url(t){return this.url=t,this}view(t){return this.view=t,this}button(){return this.view("filament::components.button.index"),this}grouped(){return this.view("filament::components.dropdown.list.item"),this}iconButton(){return this.view("filament::components.icon-button"),this}link(){return this.view("filament::components.link"),this}},x=class{constructor(t){return this.actions(t),this}actions(t){return this.actions=t.map(e=>e.grouped()),this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}label(t){return this.label=t,this}tooltip(t){return this.tooltip=t,this}};window.FilamentNotificationAction=w;window.FilamentNotificationActionGroup=x;window.FilamentNotification=p;document.addEventListener("alpine:init",()=>{window.Alpine.plugin(q)});})(); 2 | -------------------------------------------------------------------------------- /resources/views/frontend/articles/show.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 |
6 | 7 | 18 | 19 |
20 | 21 |
22 | @if($article->category) 23 | {{ $article->category->title }} 24 | @endif 25 | {{ $article->published_at->format('F d, Y') }} 26 | · 27 | {{ __('frontend.articles.min_read', ['minutes' => ceil(str_word_count(strip_tags($article->content)) / 200)]) }} 28 |
29 | 30 | 31 |

32 | {{ $article->title }} 33 |

34 | 35 | 36 | @if($article->excerpt) 37 |

38 | {{ $article->excerpt }} 39 |

40 | @endif 41 |
42 |
43 |
44 | 45 | 46 |
47 |
48 | 49 |
50 | 51 | @if($article->featured_image) 52 |
53 |
54 | {{ $article->title }} 55 |
56 |
57 | @endif 58 | 59 |
{!! $article->content !!}
60 |
61 | 62 | 63 |
64 |
65 |
66 | 67 | {{ number_format($article->view_count) }} {{ Str::plural('view', $article->view_count) }} 68 |
69 |
70 |
71 | 72 | 73 | 109 |
110 |
111 |
112 | 113 | @push('scripts') 114 | @vite('resources/js/article.js') 115 | @endpush 116 |
117 | -------------------------------------------------------------------------------- /config/database.php: -------------------------------------------------------------------------------- 1 | env('DB_CONNECTION', 'sqlite'), 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Database Connections 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Below are all of the database connections defined for your application. 27 | | An example configuration is provided for each database system which 28 | | is supported by Laravel. You're free to add / remove connections. 29 | | 30 | */ 31 | 32 | 'connections' => [ 33 | 34 | 'sqlite' => [ 35 | 'driver' => 'sqlite', 36 | 'url' => env('DB_URL'), 37 | 'database' => env('DB_DATABASE', database_path('database.sqlite')), 38 | 'prefix' => '', 39 | 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 40 | 'busy_timeout' => null, 41 | 'journal_mode' => null, 42 | 'synchronous' => null, 43 | 'transaction_mode' => 'DEFERRED', 44 | ], 45 | 46 | 'mysql' => [ 47 | 'driver' => 'mysql', 48 | 'url' => env('DB_URL'), 49 | 'host' => env('DB_HOST', '127.0.0.1'), 50 | 'port' => env('DB_PORT', '3306'), 51 | 'database' => env('DB_DATABASE', 'laravel'), 52 | 'username' => env('DB_USERNAME', 'root'), 53 | 'password' => env('DB_PASSWORD', ''), 54 | 'unix_socket' => env('DB_SOCKET', ''), 55 | 'charset' => env('DB_CHARSET', 'utf8mb4'), 56 | 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), 57 | 'prefix' => '', 58 | 'prefix_indexes' => true, 59 | 'strict' => true, 60 | 'engine' => null, 61 | 'options' => extension_loaded('pdo_mysql') ? array_filter([ 62 | PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), 63 | ]) : [], 64 | ], 65 | 66 | 'mariadb' => [ 67 | 'driver' => 'mariadb', 68 | 'url' => env('DB_URL'), 69 | 'host' => env('DB_HOST', '127.0.0.1'), 70 | 'port' => env('DB_PORT', '3306'), 71 | 'database' => env('DB_DATABASE', 'laravel'), 72 | 'username' => env('DB_USERNAME', 'root'), 73 | 'password' => env('DB_PASSWORD', ''), 74 | 'unix_socket' => env('DB_SOCKET', ''), 75 | 'charset' => env('DB_CHARSET', 'utf8mb4'), 76 | 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), 77 | 'prefix' => '', 78 | 'prefix_indexes' => true, 79 | 'strict' => true, 80 | 'engine' => null, 81 | 'options' => extension_loaded('pdo_mysql') ? array_filter([ 82 | PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), 83 | ]) : [], 84 | ], 85 | 86 | 'pgsql' => [ 87 | 'driver' => 'pgsql', 88 | 'url' => env('DB_URL'), 89 | 'host' => env('DB_HOST', '127.0.0.1'), 90 | 'port' => env('DB_PORT', '5432'), 91 | 'database' => env('DB_DATABASE', 'laravel'), 92 | 'username' => env('DB_USERNAME', 'root'), 93 | 'password' => env('DB_PASSWORD', ''), 94 | 'charset' => env('DB_CHARSET', 'utf8'), 95 | 'prefix' => '', 96 | 'prefix_indexes' => true, 97 | 'search_path' => 'public', 98 | 'sslmode' => 'prefer', 99 | ], 100 | 101 | 'sqlsrv' => [ 102 | 'driver' => 'sqlsrv', 103 | 'url' => env('DB_URL'), 104 | 'host' => env('DB_HOST', 'localhost'), 105 | 'port' => env('DB_PORT', '1433'), 106 | 'database' => env('DB_DATABASE', 'laravel'), 107 | 'username' => env('DB_USERNAME', 'root'), 108 | 'password' => env('DB_PASSWORD', ''), 109 | 'charset' => env('DB_CHARSET', 'utf8'), 110 | 'prefix' => '', 111 | 'prefix_indexes' => true, 112 | // 'encrypt' => env('DB_ENCRYPT', 'yes'), 113 | // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), 114 | ], 115 | 116 | ], 117 | 118 | /* 119 | |-------------------------------------------------------------------------- 120 | | Migration Repository Table 121 | |-------------------------------------------------------------------------- 122 | | 123 | | This table keeps track of all the migrations that have already run for 124 | | your application. Using this information, we can determine which of 125 | | the migrations on disk haven't actually been run on the database. 126 | | 127 | */ 128 | 129 | 'migrations' => [ 130 | 'table' => 'migrations', 131 | 'update_date_on_publish' => true, 132 | ], 133 | 134 | /* 135 | |-------------------------------------------------------------------------- 136 | | Redis Databases 137 | |-------------------------------------------------------------------------- 138 | | 139 | | Redis is an open source, fast, and advanced key-value store that also 140 | | provides a richer body of commands than a typical key-value system 141 | | such as Memcached. You may define your connection settings here. 142 | | 143 | */ 144 | 145 | 'redis' => [ 146 | 147 | 'client' => env('REDIS_CLIENT', 'phpredis'), 148 | 149 | 'options' => [ 150 | 'cluster' => env('REDIS_CLUSTER', 'redis'), 151 | 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), 152 | 'persistent' => env('REDIS_PERSISTENT', false), 153 | ], 154 | 155 | 'default' => [ 156 | 'url' => env('REDIS_URL'), 157 | 'host' => env('REDIS_HOST', '127.0.0.1'), 158 | 'username' => env('REDIS_USERNAME'), 159 | 'password' => env('REDIS_PASSWORD'), 160 | 'port' => env('REDIS_PORT', '6379'), 161 | 'database' => env('REDIS_DB', '0'), 162 | 'max_retries' => env('REDIS_MAX_RETRIES', 3), 163 | 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), 164 | 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), 165 | 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), 166 | ], 167 | 168 | 'cache' => [ 169 | 'url' => env('REDIS_URL'), 170 | 'host' => env('REDIS_HOST', '127.0.0.1'), 171 | 'username' => env('REDIS_USERNAME'), 172 | 'password' => env('REDIS_PASSWORD'), 173 | 'port' => env('REDIS_PORT', '6379'), 174 | 'database' => env('REDIS_CACHE_DB', '1'), 175 | 'max_retries' => env('REDIS_MAX_RETRIES', 3), 176 | 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), 177 | 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), 178 | 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), 179 | ], 180 | 181 | ], 182 | 183 | ]; 184 | --------------------------------------------------------------------------------