diff --git a/README.md b/README.md index 0165a77..4ce0606 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,118 @@ -

Laravel Logo

+# 📰 PressKit: CMS Báo Điện Tử Hiện Đại -

-Build Status -Total Downloads -Latest Stable Version -License -

+
+ PressKit CMS Dashboard +
-## About Laravel +
-Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +**Boost Productivity, Optimize SEO, and Scale Your Online Newspaper.** -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +[![Laravel](https://img.shields.io/badge/Laravel-^12.0-F23E1A.svg?style=flat&logo=laravel&logoColor=white)](https://laravel.com/) +[![License: MIT](https://img.shields.io/badge/License-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT) +[![Build Status](https://img.shields.io/badge/Build-Passing-brightgreen.svg)](https://github.com/your-org/presskit/actions) +[![Static Cache](https://img.shields.io/badge/Static-HTML%20Cache-blue.svg)](https://github.com/your-org/presskit) +[![AI Powered](https://img.shields.io/badge/AI-SEO%20Optimized-yellow.svg)](https://github.com/your-org/presskit) -Laravel is accessible, powerful, and provides tools required for large, robust applications. +
-## Learning Laravel +--- -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. +**PressKit** là một hệ thống quản trị nội dung (CMS) chuyên dụng cho báo điện tử, được xây dựng trên nền tảng **Laravel**. Hệ thống tập trung vào hiệu suất cao, tối ưu SEO vượt trội và khả năng mở rộng linh hoạt cho các tòa soạn lớn. Với PressKit, bạn có thể nhanh chóng xây dựng, quản lý và xuất bản nội dung báo chí chuyên nghiệp. -If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. +--- -## Laravel Sponsors +## ✨ Các tính năng vượt trội -We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + + + + + + + +
+
🚀 Xuất bản siêu tốc +
+
🤖 Trợ lý AI SEO +
+
📈 Phân tích Xu hướng +
+
🔄 Workflow Linh hoạt +
-### Premium Partners +- ⚡️ **Hạ tầng tối ưu:** Kiến trúc mở rộng, sẵn sàng cho hệ thống chịu tải lớn, tối ưu tốc độ tải trang. +- 🤖 **AI SEO:** Tích hợp AI hỗ trợ biên tập, gợi ý meta description, từ khóa và tối ưu hóa cấu trúc bài viết. +- 📈 **Trending & Scoring:** Thuật toán tính toán bài viết Hot theo thời gian thực (Decay Algorithm). +- 🔄 **Quy trình chuyên nghiệp:** Hỗ trợ quy trình kiểm duyệt chuẩn tòa soạn: Editor - Reviewer - Publisher. +- 📱 **Mobile AMP Ready:** Tự động tạo phiên bản AMP tối ưu cho thiết bị di động. +- 💰 **Quản lý Quảng cáo:** Tích hợp Google Ads, Banner Ads và Native Ads. +- 📊 **User Analytics:** Theo dõi Page view, Reading time, Scroll depth và Share count. -- **[Vehikl](https://vehikl.com)** -- **[Tighten Co.](https://tighten.co)** -- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** -- **[64 Robots](https://64robots.com)** -- **[Curotec](https://www.curotec.com/services/technologies/laravel)** -- **[DevSquad](https://devsquad.com/hire-laravel-developers)** -- **[Redberry](https://redberry.international/laravel-development)** -- **[Active Logic](https://activelogic.com)** +--- -## Contributing +## 🛠 Công nghệ sử dụng -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +PressKit được xây dựng trên những công nghệ hiện đại nhất: -## Code of Conduct +| Công nghệ | Mục đích | +| :--- | :--- | +| **Laravel** | Backend Framework mạnh mẽ | +| **MySQL** | Cơ sở dữ liệu chính | +| **Redis** | Xử lý Cache, Counter và Ranking bài viết | +| **Meilisearch** | Công cụ Full-text search tốc độ cao | +| **Filament** | Giao diện quản trị (Admin Panel) hiện đại | +| **CDN** | Phân phối nội dung tĩnh toàn cầu | -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +--- -## Security Vulnerabilities +## 💻 Hướng dẫn cài đặt nhanh -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. +### Yêu cầu hệ thống +* PHP >= 8.2 +* MySQL >= 8.0 +* Node.js & NPM +* Redis & Composer -## License +### Các bước cài đặt +```bash +# Clone dự án +git clone https://github.com/trunglt706/PressKit.git +cd presskit -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +# Cài đặt thư viện +composer install + +# Cấu hình môi trường +cp .env.example .env +php artisan key:generate + +# Khởi tạo dữ liệu +php artisan migrate +php artisan db:seed + +# Cài đặt các gói phụ thuộc +npm install + +# Build assets cho môi trường production +npm run build + +# HOẶC chạy môi trường phát triển (Hot Reload) +npm run dev + +# Khởi động dịch vụ +php artisan serve + +``` +## 🤝 Liên hệ & Đóng góp + +Chúng tôi hoan nghênh mọi đóng góp của bạn để phát triển PressKit tốt hơn! Hãy tạo Issue hoặc Pull Request trên GitHub. + +- **Author:** PressKit Team + +- **License:** MIT + + +---------- + +
PressKit | Developed with ❤️ by the PressKit Team
\ No newline at end of file diff --git a/app/Console/Commands/CalculateHotArticleScoreCommand.php b/app/Console/Commands/CalculateHotArticleScoreCommand.php new file mode 100644 index 0000000..4339759 --- /dev/null +++ b/app/Console/Commands/CalculateHotArticleScoreCommand.php @@ -0,0 +1,22 @@ +info('Hot score calculation job dispatched.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/FlushHotArticleViewsCommand.php b/app/Console/Commands/FlushHotArticleViewsCommand.php new file mode 100644 index 0000000..e6f7ea3 --- /dev/null +++ b/app/Console/Commands/FlushHotArticleViewsCommand.php @@ -0,0 +1,22 @@ +flushViewCountersToDatabase(); + + $this->info(sprintf('Flushed %d views to analytics table.', $flushed)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/GenerateSitemapCommand.php b/app/Console/Commands/GenerateSitemapCommand.php new file mode 100644 index 0000000..c0c1f34 --- /dev/null +++ b/app/Console/Commands/GenerateSitemapCommand.php @@ -0,0 +1,45 @@ +add( + Url::create(url('/')) + ->setChangeFrequency(Url::CHANGE_FREQUENCY_DAILY) + ->setPriority(1.0) + ); + + Article::query() + ->where('status', ArticleStatus::PUBLISHED->value) + ->latest('updated_at') + ->get() + ->each(function (Article $article) use ($sitemap): void { + $sitemap->add( + Url::create(route('articles.show', ['article' => $article->slug])) + ->setLastModificationDate($article->updated_at ?? $article->created_at) + ->setChangeFrequency(Url::CHANGE_FREQUENCY_DAILY) + ->setPriority(0.8) + ); + }); + + $sitemap->writeToFile(public_path('sitemap.xml')); + + $this->info('Sitemap generated: '.public_path('sitemap.xml')); + + return self::SUCCESS; + } +} diff --git a/app/Enums/ArticleStatus.php b/app/Enums/ArticleStatus.php new file mode 100644 index 0000000..682b191 --- /dev/null +++ b/app/Enums/ArticleStatus.php @@ -0,0 +1,9 @@ +articleService->paginateLatest(); + + return view('admin.articles.index', compact('articles')); + } + + /** + * Show the article creation form. + */ + public function create(): View + { + $categories = $this->articleFilterCacheService->getCategories(); + $article = new Article(); + + return view('admin.articles.create', compact('categories', 'article')); + } + + /** + * Store a new article in storage. + */ + public function store(StoreArticleRequest $request): RedirectResponse + { + $data = $request->validated(); + + $data['updated_by'] = auth()->id(); + $data['author_id'] = auth()->id(); + + $this->articleService->create($data); + + return redirect()->route('admin.articles.index')->with('status', 'Article created.'); + } + + /** + * Display details of a specific article in the admin area. + */ + public function show(Article $article): View + { + $article->loadMissing(['category', 'author', 'seo', 'approver', 'publisher']); + + return view('admin.articles.show', compact('article')); + } + + /** + * Show the article edit form. + */ + public function edit(Article $article): View + { + $categories = $this->articleFilterCacheService->getCategories(); + + return view('admin.articles.edit', compact('article', 'categories')); + } + + /** + * Update an existing article. + */ + public function update(UpdateArticleRequest $request, Article $article): RedirectResponse + { + $data = $request->validated(); + + $data['updated_by'] = auth()->id(); + + $this->articleService->update($article, $data); + + return redirect()->route('admin.articles.index')->with('status', 'Article updated.'); + } + + /** + * Remove an article from storage. + */ + public function destroy(Article $article): RedirectResponse + { + $this->articleService->delete($article); + + return redirect()->route('admin.articles.index')->with('status', 'Article deleted.'); + } + + /** + * Display article activity logs for comparison and auditing. + */ + public function history(Article $article): View + { + $activities = Activity::query() + ->where('subject_type', Article::class) + ->where('subject_id', $article->id) + ->latest() + ->paginate(20); + + return view('admin.articles.history', compact('article', 'activities')); + } + + /** + * Restore article data from a selected activity log entry. + */ + public function restore(Article $article, Activity $activity): RedirectResponse + { + if ($activity->subject_type !== Article::class || (int) $activity->subject_id !== (int) $article->id) { + abort(404); + } + + try { + $this->articleService->restoreFromActivity($article, $activity); + } catch (InvalidArgumentException $exception) { + return redirect() + ->route('admin.articles.history', $article) + ->with('status', $exception->getMessage()); + } + + return redirect() + ->route('admin.articles.history', $article) + ->with('status', 'Article has been restored from selected log entry.'); + } + + /** + * Update the article slug through a dedicated admin action. + */ + public function updateSlug(UpdateArticleSlugRequest $request, Article $article): RedirectResponse + { + $data = $request->validated(); + + try { + $this->articleService->updateSlug($article, $data['slug']); + } catch (InvalidArgumentException $exception) { + return redirect() + ->route('admin.articles.show', $article) + ->with('status', $exception->getMessage()); + } + + return redirect() + ->route('admin.articles.show', $article) + ->with('status', 'Slug updated successfully.'); + } + + /** + * Move a draft article to review state. + */ + public function submitForReview(SubmitArticleForReviewRequest $request, Article $article): RedirectResponse + { + try { + $this->articleService->submitForReview($article); + } catch (InvalidArgumentException $exception) { + return redirect()->route('admin.articles.show', $article)->with('status', $exception->getMessage()); + } + + return redirect()->route('admin.articles.show', $article)->with('status', 'Article submitted for review.'); + } + + /** + * Approve an article from review state. + */ + public function approve(ApproveArticleRequest $request, Article $article): RedirectResponse + { + try { + $this->articleService->approve($article, auth()->id()); + } catch (InvalidArgumentException $exception) { + return redirect()->route('admin.articles.show', $article)->with('status', $exception->getMessage()); + } + + return redirect()->route('admin.articles.show', $article)->with('status', 'Article approved.'); + } + + /** + * Publish an approved article and dispatch publish pipeline job. + */ + public function publish(PublishArticleRequest $request, Article $article): RedirectResponse + { + try { + $this->articleService->publish($article, auth()->id()); + } catch (InvalidArgumentException $exception) { + return redirect()->route('admin.articles.show', $article)->with('status', $exception->getMessage()); + } + + return redirect()->route('admin.articles.show', $article)->with('status', 'Article published and pipeline dispatched.'); + } +} diff --git a/app/Http/Controllers/Admin/CategoryController.php b/app/Http/Controllers/Admin/CategoryController.php new file mode 100644 index 0000000..55684ed --- /dev/null +++ b/app/Http/Controllers/Admin/CategoryController.php @@ -0,0 +1,93 @@ +categoryService->paginateLatest(); + + return view('admin.categories.index', compact('categories')); + } + + /** + * Show the form for creating a new category. + */ + public function create(): View + { + $category = new Category(); + + return view('admin.categories.create', compact('category')); + } + + /** + * Store a newly created category. + */ + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255', 'unique:categories,name'], + 'description' => ['nullable', 'string'], + 'status' => ['required', Rule::enum(CategoryStatus::class)], + ]); + + $this->categoryService->create($data); + + return redirect()->route('admin.categories.index')->with('status', 'Category created.'); + } + + /** + * Show the form for editing a category. + */ + public function edit(Category $category): View + { + return view('admin.categories.edit', compact('category')); + } + + /** + * Update a category. + */ + public function update(Request $request, Category $category): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255', 'unique:categories,name,'.$category->id], + 'description' => ['nullable', 'string'], + 'status' => ['required', Rule::enum(CategoryStatus::class)], + ]); + + $this->categoryService->update($category, $data); + + return redirect()->route('admin.categories.index')->with('status', 'Category updated.'); + } + + /** + * Remove a category. + */ + public function destroy(Category $category): RedirectResponse + { + $this->categoryService->delete($category); + + return redirect()->route('admin.categories.index')->with('status', 'Category deleted.'); + } +} diff --git a/app/Http/Controllers/Admin/CommentController.php b/app/Http/Controllers/Admin/CommentController.php new file mode 100644 index 0000000..8da414d --- /dev/null +++ b/app/Http/Controllers/Admin/CommentController.php @@ -0,0 +1,113 @@ +commentService->paginateByArticle($article); + + return view('admin.comments.index', compact('article', 'comments')); + } + + /** + * Show the form for creating a new comment for an article. + */ + public function create(Article $article): View + { + $comment = new Comment(); + + return view('admin.comments.create', compact('article', 'comment')); + } + + /** + * Store a newly created comment for an article. + */ + public function store(Request $request, Article $article): RedirectResponse + { + $data = $request->validate([ + 'user_id' => ['nullable', 'exists:users,id'], + 'author_name' => ['nullable', 'string', 'max:255'], + 'author_email' => ['nullable', 'email', 'max:255'], + 'content' => ['required', 'string'], + 'is_approved' => ['nullable', 'boolean'], + ]); + + $data['is_approved'] = $request->boolean('is_approved'); + + $this->commentService->createForArticle($article, $data); + + return redirect()->route('admin.articles.comments.index', $article)->with('status', 'Comment created.'); + } + + /** + * Show the form for editing a comment. + */ + public function edit(Article $article, Comment $comment): View + { + if ((int) $comment->article_id !== (int) $article->id) { + abort(404); + } + + return view('admin.comments.edit', compact('article', 'comment')); + } + + /** + * Update an existing comment. + */ + public function update(Request $request, Article $article, Comment $comment): RedirectResponse + { + $data = $request->validate([ + 'user_id' => ['nullable', 'exists:users,id'], + 'author_name' => ['nullable', 'string', 'max:255'], + 'author_email' => ['nullable', 'email', 'max:255'], + 'content' => ['required', 'string'], + 'is_approved' => ['nullable', 'boolean'], + ]); + + $data['is_approved'] = $request->boolean('is_approved'); + + try { + $this->commentService->updateForArticle($article, $comment, $data); + } catch (InvalidArgumentException $exception) { + return redirect()->route('admin.articles.comments.index', $article)->with('status', $exception->getMessage()); + } + + return redirect()->route('admin.articles.comments.index', $article)->with('status', 'Comment updated.'); + } + + /** + * Remove a comment. + */ + public function destroy(Article $article, Comment $comment): RedirectResponse + { + try { + $this->commentService->deleteForArticle($article, $comment); + } catch (InvalidArgumentException $exception) { + return redirect()->route('admin.articles.comments.index', $article)->with('status', $exception->getMessage()); + } + + return redirect()->route('admin.articles.comments.index', $article)->with('status', 'Comment deleted.'); + } +} diff --git a/app/Http/Controllers/Admin/TagController.php b/app/Http/Controllers/Admin/TagController.php new file mode 100644 index 0000000..6260911 --- /dev/null +++ b/app/Http/Controllers/Admin/TagController.php @@ -0,0 +1,87 @@ +tagService->paginateLatest(); + + return view('admin.tags.index', compact('tags')); + } + + /** + * Show the form for creating a new tag. + */ + public function create(): View + { + $tag = new Tag(); + + return view('admin.tags.create', compact('tag')); + } + + /** + * Store a newly created tag. + */ + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255', 'unique:tags,name'], + ]); + + $this->tagService->create($data); + + return redirect()->route('admin.tags.index')->with('status', 'Tag created.'); + } + + /** + * Show the form for editing a tag. + */ + public function edit(Tag $tag): View + { + return view('admin.tags.edit', compact('tag')); + } + + /** + * Update a tag. + */ + public function update(Request $request, Tag $tag): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255', 'unique:tags,name,'.$tag->id], + ]); + + $this->tagService->update($tag, $data); + + return redirect()->route('admin.tags.index')->with('status', 'Tag updated.'); + } + + /** + * Remove a tag. + */ + public function destroy(Tag $tag): RedirectResponse + { + $this->tagService->delete($tag); + + return redirect()->route('admin.tags.index')->with('status', 'Tag deleted.'); + } +} diff --git a/app/Http/Controllers/Guest/ArticleController.php b/app/Http/Controllers/Guest/ArticleController.php new file mode 100644 index 0000000..46f39b9 --- /dev/null +++ b/app/Http/Controllers/Guest/ArticleController.php @@ -0,0 +1,272 @@ + (string) $request->validated('keyword', ''), + 'tag' => (string) $request->validated('tag', ''), + 'category' => (string) $request->validated('category', ''), + 'sort' => (string) $request->validated('sort', 'latest'), + ]; + + $isSearching = $filters['keyword'] !== ''; + $data = $this->buildListingData($filters, !$isSearching); + + if ($isSearching) { + $data['searchKeyword'] = $filters['keyword']; + + return view('guest.articles.search', $data); + } + + return view('guest.articles.index', $data); + } + + /** + * Display published article detail by slug from SEO-friendly .html URL. + */ + public function show(ShowArticleRequest $request, string $article): View + { + $article = Article::query() + ->with(['category', 'seo', 'media', 'versions.media', 'author', 'tags']) + ->withCount('comments') + ->where('slug', $article) + ->where('status', ArticleStatus::PUBLISHED->value) + ->firstOrFail(); + + $tagIds = $article->tags->pluck('id')->all(); + + $relatedArticles = Article::query() + ->with(['category', 'media']) + ->where('status', ArticleStatus::PUBLISHED->value) + ->whereKeyNot($article->id) + ->when( + $tagIds !== [], + fn (Builder $query) => $query->whereHas('tags', fn (Builder $tagQuery) => $tagQuery->whereIn('tags.id', $tagIds)), + fn (Builder $query) => $query->where('category_id', $article->category_id) + ) + ->latest('published_at') + ->limit(3) + ->get(); + + $trendingArticles = Trending::query() + ->with(['article' => fn ($query) => $query->with('category')]) + ->latest('calculated_at') + ->orderBy('rank') + ->limit(4) + ->get() + ->pluck('article') + ->filter(fn ($item) => $item && $item->id !== $article->id) + ->values(); + + $trendingTags = Tag::query() + ->withCount('articles') + ->orderByDesc('articles_count') + ->limit(6) + ->get(['id', 'name', 'slug']); + + IncrementArticleViewJob::dispatch($article->id); + $this->seoService->applyForArticle($article); + + return view('guest.articles.show', compact('article', 'relatedArticles', 'trendingArticles', 'trendingTags')); + } + + /** + * Display article listing by category slug. + */ + public function byCategory(Request $request, string $slug): View + { + $category = Category::query() + ->where('slug', $slug) + ->where('status', CategoryStatus::ACTIVE->value) + ->firstOrFail(); + + $filters = [ + 'keyword' => (string) $request->query('keyword', ''), + 'tag' => (string) $request->query('tag', ''), + 'category' => $slug, + 'sort' => (string) $request->query('sort', 'latest'), + ]; + + $data = $this->buildListingData($filters); + $data['pageTitle'] = 'Danh mục: '.$category->name; + + return view('guest.articles.index', $data); + } + + /** + * Display article listing by tag slug. + */ + public function byTag(Request $request, string $slug): View + { + $tag = Tag::query()->where('slug', $slug)->firstOrFail(); + + $filters = [ + 'keyword' => (string) $request->query('keyword', ''), + 'tag' => $slug, + 'category' => (string) $request->query('category', ''), + 'sort' => (string) $request->query('sort', 'latest'), + ]; + + $data = $this->buildListingData($filters); + $data['pageTitle'] = 'Chủ đề: '.$tag->name; + + return view('guest.articles.index', $data); + } + + private function buildListingData(array $filters, bool $useFeaturedArticle = true): array + { + $normalizedFilters = [ + 'keyword' => (string) ($filters['keyword'] ?? ''), + 'tag' => (string) ($filters['tag'] ?? ''), + 'category' => (string) ($filters['category'] ?? ''), + 'sort' => in_array((string) ($filters['sort'] ?? 'latest'), ['latest', 'popular', 'featured'], true) + ? (string) $filters['sort'] + : 'latest', + ]; + + $baseQuery = Article::query() + ->with(['category', 'tags', 'media']) + ->withCount('comments') + ->withSum('analytics as total_views', 'views') + ->where('status', ArticleStatus::PUBLISHED->value); + + $this->applySearchFilters($baseQuery, $normalizedFilters); + $this->applySort($baseQuery, $normalizedFilters['sort']); + + $featuredArticle = null; + + if ($useFeaturedArticle) { + $featuredArticle = (clone $baseQuery) + ->latest('published_at') + ->first(); + } + + $articles = (clone $baseQuery) + ->when($useFeaturedArticle && $featuredArticle, fn (Builder $query) => $query->whereKeyNot($featuredArticle->id)) + ->latest('published_at') + ->paginate(10) + ->withQueryString(); + + $popularArticles = Trending::query() + ->with(['article' => fn ($query) => $query->with('category')]) + ->latest('calculated_at') + ->orderBy('rank') + ->limit(5) + ->get() + ->pluck('article') + ->filter() + ->values(); + + if ($popularArticles->isEmpty()) { + $popularArticles = Article::query() + ->with('category') + ->where('status', ArticleStatus::PUBLISHED->value) + ->latest('published_at') + ->limit(5) + ->get(); + } + + $popularTags = Tag::query() + ->withCount('articles') + ->orderByDesc('articles_count') + ->limit(10) + ->get(['id', 'name', 'slug']); + + $categories = $this->articleFilterCacheService->getCategories(); + $tags = $this->articleFilterCacheService->getTags(); + $relatedCategories = Category::query() + ->where('status', CategoryStatus::ACTIVE->value) + ->withCount([ + 'articles' => fn (Builder $query) => $query->where('status', ArticleStatus::PUBLISHED->value), + ]) + ->orderByDesc('articles_count') + ->limit(4) + ->get(['id', 'name', 'slug']); + + return [ + 'articles' => $articles, + 'featuredArticle' => $featuredArticle, + 'popularArticles' => $popularArticles, + 'popularTags' => $popularTags, + 'categories' => $categories, + 'relatedCategories' => $relatedCategories, + 'tags' => $tags, + 'filters' => $normalizedFilters, + 'pageTitle' => 'Tin tức mới nhất', + ]; + } + + private function applySearchFilters(Builder $query, array $filters): void + { + $query + ->when( + !empty($filters['keyword'] ?? null), + fn (Builder $builder) => $builder->where(function (Builder $innerQuery) use ($filters): void { + $keyword = (string) $filters['keyword']; + $innerQuery + ->where('title', 'like', "%{$keyword}%") + ->orWhere('excerpt', 'like', "%{$keyword}%") + ->orWhere('content', 'like', "%{$keyword}%"); + }) + ) + ->when( + !empty($filters['tag'] ?? null), + fn (Builder $builder) => $builder->whereHas('tags', function (Builder $tagQuery) use ($filters): void { + $tagQuery->where('slug', $filters['tag']); + }) + ) + ->when( + !empty($filters['category'] ?? null), + fn (Builder $builder) => $builder->whereHas('category', function (Builder $categoryQuery) use ($filters): void { + $categoryQuery->where('slug', $filters['category']); + }) + ); + } + + private function applySort(Builder $query, string $sort): void + { + match ($sort) { + 'popular' => $query + ->orderByDesc('total_views') + ->latest('published_at'), + 'featured' => $query + ->orderByDesc('comments_count') + ->orderByDesc('total_views') + ->latest('published_at'), + default => $query->latest('published_at'), + }; + } + +} diff --git a/app/Http/Controllers/Guest/ContactSubmissionController.php b/app/Http/Controllers/Guest/ContactSubmissionController.php new file mode 100644 index 0000000..3ee1b94 --- /dev/null +++ b/app/Http/Controllers/Guest/ContactSubmissionController.php @@ -0,0 +1,27 @@ +contactSubmissionService->handleGuestSubmission( + $request->validated(), + (string) $request->ip() + ); + } +} diff --git a/app/Http/Controllers/Guest/HomeController.php b/app/Http/Controllers/Guest/HomeController.php new file mode 100644 index 0000000..40d3cc9 --- /dev/null +++ b/app/Http/Controllers/Guest/HomeController.php @@ -0,0 +1,119 @@ + (string) $request->query('keyword', ''), + 'tag' => (string) $request->query('tag', ''), + 'category' => (string) $request->query('category', ''), + ]; + + $baseQuery = Article::query() + ->with(['category', 'tags', 'media']) + ->where('status', ArticleStatus::PUBLISHED->value); + + $this->applySearchFilters($baseQuery, $filters); + + $featuredArticle = (clone $baseQuery) + ->latest('published_at') + ->first(); + + $articles = (clone $baseQuery) + ->when($featuredArticle, fn (Builder $query) => $query->whereKeyNot($featuredArticle->id)) + ->latest('published_at') + ->limit(5) + ->get(); + + $popularArticles = Trending::query() + ->with([ + 'article' => fn ($query) => $query + ->with('category') + ->withSum('analytics as total_views', 'views'), + ]) + ->latest('calculated_at') + ->orderBy('rank') + ->limit(5) + ->get() + ->pluck('article') + ->filter() + ->values(); + + if ($popularArticles->isEmpty()) { + $popularArticles = Article::query() + ->with('category') + ->withSum('analytics as total_views', 'views') + ->where('status', ArticleStatus::PUBLISHED->value) + ->latest('published_at') + ->limit(5) + ->get(); + } + + $popularTags = Tag::query() + ->withCount('articles') + ->orderByDesc('articles_count') + ->limit(10) + ->get(['id', 'name', 'slug']); + + $categories = $this->articleFilterCacheService->getCategories(); + $tags = $this->articleFilterCacheService->getTags(); + + return view('guest.home.index', [ + 'articles' => $articles, + 'featuredArticle' => $featuredArticle, + 'popularArticles' => $popularArticles, + 'popularTags' => $popularTags, + 'categories' => $categories, + 'tags' => $tags, + 'filters' => $filters, + ]); + } + + private function applySearchFilters(Builder $query, array $filters): void + { + $query + ->when( + $filters['keyword'] !== '', + fn (Builder $builder) => $builder->where(function (Builder $innerQuery) use ($filters): void { + $keyword = $filters['keyword']; + $innerQuery + ->where('title', 'like', "%{$keyword}%") + ->orWhere('excerpt', 'like', "%{$keyword}%") + ->orWhere('content', 'like', "%{$keyword}%"); + }) + ) + ->when( + $filters['tag'] !== '', + fn (Builder $builder) => $builder->whereHas('tags', function (Builder $tagQuery) use ($filters): void { + $tagQuery->where('slug', $filters['tag']); + }) + ) + ->when( + $filters['category'] !== '', + fn (Builder $builder) => $builder->whereHas('category', function (Builder $categoryQuery) use ($filters): void { + $categoryQuery->where('slug', $filters['category']); + }) + ); + } +} diff --git a/app/Http/Controllers/Guest/PageController.php b/app/Http/Controllers/Guest/PageController.php new file mode 100644 index 0000000..2cb7838 --- /dev/null +++ b/app/Http/Controllers/Guest/PageController.php @@ -0,0 +1,41 @@ + + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Admin/PublishArticleRequest.php b/app/Http/Requests/Admin/PublishArticleRequest.php new file mode 100644 index 0000000..5ebd884 --- /dev/null +++ b/app/Http/Requests/Admin/PublishArticleRequest.php @@ -0,0 +1,21 @@ + + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Admin/StoreArticleRequest.php b/app/Http/Requests/Admin/StoreArticleRequest.php new file mode 100644 index 0000000..b3846dd --- /dev/null +++ b/app/Http/Requests/Admin/StoreArticleRequest.php @@ -0,0 +1,46 @@ + + */ + public function rules(): array + { + return [ + 'category_id' => ['nullable', 'exists:categories,id'], + 'title' => ['required', 'string', 'max:255'], + 'excerpt' => ['nullable', 'string', 'max:500'], + 'content' => ['required', 'string'], + 'status' => ['required', Rule::enum(ArticleStatus::class)], + 'seo_title' => ['nullable', 'string', 'max:255'], + 'seo_description' => ['nullable', 'string', 'max:500'], + 'seo_keywords' => ['nullable', 'string', 'max:500'], + 'seo_og_image' => ['nullable', 'url', 'max:255'], + 'featured_image' => ['nullable', 'image', 'max:5120'], + 'gallery' => ['nullable', 'array'], + 'gallery.*' => ['image', 'max:5120'], + 'attachments' => ['nullable', 'array'], + 'attachments.*' => ['file', 'max:10240'], + 'version_files' => ['nullable', 'array'], + 'version_files.*' => ['file', 'max:10240'], + 'published_at' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/Admin/SubmitArticleForReviewRequest.php b/app/Http/Requests/Admin/SubmitArticleForReviewRequest.php new file mode 100644 index 0000000..b0ca668 --- /dev/null +++ b/app/Http/Requests/Admin/SubmitArticleForReviewRequest.php @@ -0,0 +1,21 @@ + + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Admin/UpdateArticleRequest.php b/app/Http/Requests/Admin/UpdateArticleRequest.php new file mode 100644 index 0000000..eb228e6 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateArticleRequest.php @@ -0,0 +1,46 @@ + + */ + public function rules(): array + { + return [ + 'category_id' => ['nullable', 'exists:categories,id'], + 'title' => ['required', 'string', 'max:255'], + 'excerpt' => ['nullable', 'string', 'max:500'], + 'content' => ['required', 'string'], + 'status' => ['required', Rule::enum(ArticleStatus::class)], + 'seo_title' => ['nullable', 'string', 'max:255'], + 'seo_description' => ['nullable', 'string', 'max:500'], + 'seo_keywords' => ['nullable', 'string', 'max:500'], + 'seo_og_image' => ['nullable', 'url', 'max:255'], + 'featured_image' => ['nullable', 'image', 'max:5120'], + 'gallery' => ['nullable', 'array'], + 'gallery.*' => ['image', 'max:5120'], + 'attachments' => ['nullable', 'array'], + 'attachments.*' => ['file', 'max:10240'], + 'version_files' => ['nullable', 'array'], + 'version_files.*' => ['file', 'max:10240'], + 'published_at' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdateArticleSlugRequest.php b/app/Http/Requests/Admin/UpdateArticleSlugRequest.php new file mode 100644 index 0000000..7f5aaa3 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateArticleSlugRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'slug' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Guest/SearchArticlesRequest.php b/app/Http/Requests/Guest/SearchArticlesRequest.php new file mode 100644 index 0000000..a318eb0 --- /dev/null +++ b/app/Http/Requests/Guest/SearchArticlesRequest.php @@ -0,0 +1,31 @@ + + */ + public function rules(): array + { + return [ + 'keyword' => ['nullable', 'string', 'max:255'], + 'tag' => ['nullable', 'string', 'max:255'], + 'category' => ['nullable', 'string', 'max:255'], + 'sort' => ['nullable', 'in:latest,popular,featured'], + ]; + } +} diff --git a/app/Http/Requests/Guest/ShowArticleRequest.php b/app/Http/Requests/Guest/ShowArticleRequest.php new file mode 100644 index 0000000..4327489 --- /dev/null +++ b/app/Http/Requests/Guest/ShowArticleRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Guest/StoreContactSubmissionRequest.php b/app/Http/Requests/Guest/StoreContactSubmissionRequest.php new file mode 100644 index 0000000..9431185 --- /dev/null +++ b/app/Http/Requests/Guest/StoreContactSubmissionRequest.php @@ -0,0 +1,66 @@ +input('phone', '')); + + $this->merge([ + 'name' => trim((string) $this->input('name', '')), + 'email' => mb_strtolower(trim((string) $this->input('email', ''))), + 'phone' => $normalizedPhone, + 'subject' => trim((string) $this->input('subject', '')), + 'message' => trim((string) $this->input('message', '')), + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'min:2', 'max:120'], + 'email' => ['required', 'email:rfc,dns', 'max:190'], + 'phone' => ['required', 'string', 'min:9', 'max:20'], + 'subject' => ['required', 'string', 'min:3', 'max:180'], + 'message' => ['required', 'string', 'min:10', 'max:3000'], + ]; + } + + /** + * Custom validation messages. + * + * @return array + */ + public function messages(): array + { + return [ + 'name.required' => 'Vui lòng nhập họ và tên.', + 'email.required' => 'Vui lòng nhập email.', + 'email.email' => 'Email không đúng định dạng.', + 'phone.required' => 'Vui lòng nhập số điện thoại.', + 'subject.required' => 'Vui lòng nhập tiêu đề.', + 'message.required' => 'Vui lòng nhập nội dung tin nhắn.', + 'message.min' => 'Nội dung tin nhắn cần ít nhất 10 ký tự.', + ]; + } +} diff --git a/app/Jobs/CalculateHotScoreJob.php b/app/Jobs/CalculateHotScoreJob.php new file mode 100644 index 0000000..015b1d2 --- /dev/null +++ b/app/Jobs/CalculateHotScoreJob.php @@ -0,0 +1,19 @@ +calculateHotScores(); + } +} diff --git a/app/Jobs/IncrementArticleViewJob.php b/app/Jobs/IncrementArticleViewJob.php new file mode 100644 index 0000000..7c5e059 --- /dev/null +++ b/app/Jobs/IncrementArticleViewJob.php @@ -0,0 +1,24 @@ +trackView($this->articleId); + } +} diff --git a/app/Jobs/PublishArticlePipelineJob.php b/app/Jobs/PublishArticlePipelineJob.php new file mode 100644 index 0000000..8cf0890 --- /dev/null +++ b/app/Jobs/PublishArticlePipelineJob.php @@ -0,0 +1,97 @@ +find($this->articleId); + + if (!$article || $article->status !== ArticleStatus::PUBLISHED) { + return; + } + + $staticPath = sprintf('static/articles/%s.html', $article->slug); + $ampPath = sprintf('static/articles/%s.amp.html', $article->slug); + + Storage::disk('public')->put($staticPath, $this->renderStaticHtml($article)); + Storage::disk('public')->put($ampPath, $this->renderAmpHtml($article)); + + Redis::set( + sprintf('article:published:%d', $article->id), + json_encode([ + 'id' => $article->id, + 'slug' => $article->slug, + 'title' => $article->title, + 'excerpt' => $article->excerpt, + 'published_at' => optional($article->published_at)->toDateTimeString(), + ], JSON_UNESCAPED_UNICODE) + ); + + $this->purgeCdn($article); + + Artisan::call('sitemap:generate'); + + $sitemapUrl = url('/sitemap.xml'); + Http::timeout(5)->get('https://www.google.com/ping', ['sitemap' => $sitemapUrl]); + Http::timeout(5)->get('https://www.bing.com/ping', ['sitemap' => $sitemapUrl]); + + $article->update([ + 'static_html_path' => $staticPath, + 'amp_html_path' => $ampPath, + 'last_published_job_at' => now(), + ]); + + ResponseCache::clear(); + } + + private function renderStaticHtml(Article $article): string + { + return sprintf( + "%s

%s

%s

", + e($article->title), + e($article->title), + nl2br(e((string) $article->content)) + ); + } + + private function renderAmpHtml(Article $article): string + { + return sprintf( + "%s

%s

%s

", + e($article->title), + e($article->title), + nl2br(e((string) $article->content)) + ); + } + + private function purgeCdn(Article $article): void + { + // Placeholder for CDN purge integration. + Log::info('CDN purge requested for article.', [ + 'article_id' => $article->id, + 'slug' => $article->slug, + ]); + } +} diff --git a/app/Jobs/RegenerateSitemapJob.php b/app/Jobs/RegenerateSitemapJob.php new file mode 100644 index 0000000..46142a0 --- /dev/null +++ b/app/Jobs/RegenerateSitemapJob.php @@ -0,0 +1,22 @@ + 'boolean', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Analytics.php b/app/Models/Analytics.php new file mode 100644 index 0000000..d01710d --- /dev/null +++ b/app/Models/Analytics.php @@ -0,0 +1,29 @@ + 'date', + ]; + } + + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php new file mode 100644 index 0000000..00f4353 --- /dev/null +++ b/app/Models/Article.php @@ -0,0 +1,131 @@ +useLogName('article') + ->logOnly([ + 'category_id', + 'author_id', + 'title', + 'slug', + 'excerpt', + 'content', + 'status', + 'published_at', + ]) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + public function getSlugOptions(): SlugOptions + { + return SlugOptions::create() + ->generateSlugsFrom('title') + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); + } + + public function registerMediaCollections(): void + { + $this + ->addMediaCollection('featured_image') + ->singleFile(); + + $this->addMediaCollection('gallery'); + $this->addMediaCollection('attachments'); + } + + public function registerMediaConversions(?Media $media = null): void + { + $this + ->addMediaConversion('thumb') + ->width(320) + ->height(200) + ->performOnCollections('featured_image', 'gallery') + ->nonQueued(); + } + + protected function casts(): array + { + return [ + 'status' => ArticleStatus::class, + 'published_at' => 'datetime', + ]; + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class); + } + + public function comments(): HasMany + { + return $this->hasMany(Comment::class); + } + + public function versions(): HasMany + { + return $this->hasMany(ArticleVersion::class); + } + + public function analytics(): HasMany + { + return $this->hasMany(Analytics::class); + } + + public function trending(): HasMany + { + return $this->hasMany(Trending::class); + } + + public function seo(): HasOne + { + return $this->hasOne(ArticleSeo::class); + } +} diff --git a/app/Models/ArticleSeo.php b/app/Models/ArticleSeo.php new file mode 100644 index 0000000..4fa770f --- /dev/null +++ b/app/Models/ArticleSeo.php @@ -0,0 +1,35 @@ + 'array', + 'warnings' => 'array', + 'last_analyzed_at' => 'datetime', + ]; + } + + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } +} diff --git a/app/Models/ArticleVersion.php b/app/Models/ArticleVersion.php new file mode 100644 index 0000000..1b39127 --- /dev/null +++ b/app/Models/ArticleVersion.php @@ -0,0 +1,35 @@ +belongsTo(Article::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('version_files'); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..e6f1685 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,41 @@ + CategoryStatus::class, + ]; + } + + public function getSlugOptions(): SlugOptions + { + return SlugOptions::create() + ->generateSlugsFrom('name') + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); + } + + public function articles(): HasMany + { + return $this->hasMany(Article::class); + } +} diff --git a/app/Models/Comment.php b/app/Models/Comment.php new file mode 100644 index 0000000..f234b4b --- /dev/null +++ b/app/Models/Comment.php @@ -0,0 +1,35 @@ + 'boolean', + ]; + } + + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ContactSubmission.php b/app/Models/ContactSubmission.php new file mode 100644 index 0000000..e5b7e65 --- /dev/null +++ b/app/Models/ContactSubmission.php @@ -0,0 +1,28 @@ + ContactSubmissionStatus::class, + 'submitted_date' => 'date', + ]; + } +} diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 0000000..504e242 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,19 @@ +belongsToMany(Article::class); + } +} diff --git a/app/Models/Trending.php b/app/Models/Trending.php new file mode 100644 index 0000000..7ac66d4 --- /dev/null +++ b/app/Models/Trending.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + } + + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..ac2ca92 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasRoles; /** * The attributes that are mass assignable. diff --git a/app/Observers/ArticleObserver.php b/app/Observers/ArticleObserver.php new file mode 100644 index 0000000..8f7b689 --- /dev/null +++ b/app/Observers/ArticleObserver.php @@ -0,0 +1,49 @@ +afterCommit(); + } + + /** + * Handle the Article "deleting" event. + */ + public function deleting(Article $article): void + { + $article->tags()->detach(); + + $disk = Storage::disk('public'); + + foreach ([$article->static_html_path, $article->amp_html_path] as $path) { + if (is_string($path) && $path !== '' && $disk->exists($path)) { + $disk->delete($path); + } + } + + Redis::del(sprintf('article:published:%d', $article->id)); + Redis::del(sprintf('hot:article:%d:views', $article->id)); + Redis::zrem('hot:ranking:articles', (string) $article->id); + } + + /** + * Handle the Article "deleted" event. + */ + public function deleted(Article $article): void + { + RegenerateSitemapJob::dispatch()->afterCommit(); + ResponseCache::clear(); + } +} diff --git a/app/Observers/CategoryObserver.php b/app/Observers/CategoryObserver.php new file mode 100644 index 0000000..72fc511 --- /dev/null +++ b/app/Observers/CategoryObserver.php @@ -0,0 +1,25 @@ +status)) { + $contactSubmission->status = ContactSubmissionStatus::PENDING; + } + + if (blank($contactSubmission->ip_address)) { + $ipAddress = request()->ip(); + + if (is_string($ipAddress) && $ipAddress !== '') { + $contactSubmission->ip_address = $ipAddress; + } + } + + if (blank($contactSubmission->submitted_date)) { + $contactSubmission->submitted_date = now()->toDateString(); + } + } +} diff --git a/app/Observers/TagObserver.php b/app/Observers/TagObserver.php new file mode 100644 index 0000000..979985a --- /dev/null +++ b/app/Observers/TagObserver.php @@ -0,0 +1,33 @@ +articles()->detach(); + } + + /** + * Handle the Tag "deleted" event. + */ + public function deleted(Tag $tag): void + { + ArticleFilterCacheService::clearTags(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..d4017d8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,18 @@ namespace App\Providers; +use App\Models\Article; +use App\Models\Category; +use App\Models\ContactSubmission; +use App\Models\Tag; +use App\Observers\ArticleObserver; +use App\Observers\CategoryObserver; +use App\Observers\ContactSubmissionObserver; +use App\Observers\TagObserver; +use App\Services\ArticleFilterCacheService; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +31,24 @@ public function register(): void */ public function boot(): void { - // + // Keep compatibility for older MySQL/MariaDB index length limits. + Schema::defaultStringLength(191); + + if ($this->app->environment('production')) { + URL::forceScheme('https'); + URL::forceRootUrl(config('app.url')); + } + + Article::observe(ArticleObserver::class); + Category::observe(CategoryObserver::class); + ContactSubmission::observe(ContactSubmissionObserver::class); + Tag::observe(TagObserver::class); + + View::composer('guest.*', function ($view): void { + $categories = app(ArticleFilterCacheService::class)->getCategories(); + + $view->with('guestNavCategories', $categories->take(5)->values()); + $view->with('guestFooterCategories', $categories->take(4)->values()); + }); } } diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php new file mode 100644 index 0000000..59599dc --- /dev/null +++ b/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,36 @@ +email, [ + // + ]); + }); + } +} diff --git a/app/Repositories/ArticleRepository.php b/app/Repositories/ArticleRepository.php new file mode 100644 index 0000000..924a7c6 --- /dev/null +++ b/app/Repositories/ArticleRepository.php @@ -0,0 +1,34 @@ +with(['category', 'author']) + ->latest() + ->paginate($perPage); + } + + public function create(array $data): Article + { + return Article::query()->create($data); + } + + public function update(Article $article, array $data): Article + { + $article->update($data); + + return $article->refresh(); + } + + public function delete(Article $article): void + { + $article->delete(); + } +} diff --git a/app/Repositories/CategoryRepository.php b/app/Repositories/CategoryRepository.php new file mode 100644 index 0000000..39e1573 --- /dev/null +++ b/app/Repositories/CategoryRepository.php @@ -0,0 +1,33 @@ +latest() + ->paginate($perPage); + } + + public function create(array $data): Category + { + return Category::query()->create($data); + } + + public function update(Category $category, array $data): Category + { + $category->update($data); + + return $category->refresh(); + } + + public function delete(Category $category): void + { + $category->delete(); + } +} diff --git a/app/Repositories/CommentRepository.php b/app/Repositories/CommentRepository.php new file mode 100644 index 0000000..ccaa482 --- /dev/null +++ b/app/Repositories/CommentRepository.php @@ -0,0 +1,36 @@ +where('article_id', $article->id) + ->with('user') + ->latest() + ->paginate($perPage); + } + + public function create(array $data): Comment + { + return Comment::query()->create($data); + } + + public function update(Comment $comment, array $data): Comment + { + $comment->update($data); + + return $comment->refresh(); + } + + public function delete(Comment $comment): void + { + $comment->delete(); + } +} diff --git a/app/Repositories/ContactSubmissionRepository.php b/app/Repositories/ContactSubmissionRepository.php new file mode 100644 index 0000000..93934d2 --- /dev/null +++ b/app/Repositories/ContactSubmissionRepository.php @@ -0,0 +1,26 @@ +where('submitted_date', $submittedDate) + ->where(function ($query) use ($ipAddress, $email, $phone): void { + $query + ->where('ip_address', $ipAddress) + ->orWhere('email', $email) + ->orWhere('phone', $phone); + }) + ->first(); + } + + public function create(array $data): ContactSubmission + { + return ContactSubmission::query()->create($data); + } +} diff --git a/app/Repositories/TagRepository.php b/app/Repositories/TagRepository.php new file mode 100644 index 0000000..b10cbb2 --- /dev/null +++ b/app/Repositories/TagRepository.php @@ -0,0 +1,33 @@ +latest() + ->paginate($perPage); + } + + public function create(array $data): Tag + { + return Tag::query()->create($data); + } + + public function update(Tag $tag, array $data): Tag + { + $tag->update($data); + + return $tag->refresh(); + } + + public function delete(Tag $tag): void + { + $tag->delete(); + } +} diff --git a/app/Services/ArticleFilterCacheService.php b/app/Services/ArticleFilterCacheService.php new file mode 100644 index 0000000..56119f3 --- /dev/null +++ b/app/Services/ArticleFilterCacheService.php @@ -0,0 +1,65 @@ +where('status', CategoryStatus::ACTIVE->value) + ->orderBy('name') + ->get(['id', 'name', 'slug']); + }); + } + + /** + * Get ordered tag options from cache. + */ + public function getTags(): Collection + { + return Cache::rememberForever(self::TAGS_CACHE_KEY, function (): Collection { + return Tag::query() + ->orderBy('name') + ->get(['id', 'name', 'slug']); + }); + } + + /** + * Clear all article filter cache keys. + */ + public function clear(): void + { + self::clearCategories(); + self::clearTags(); + } + + /** + * Clear category cache key. + */ + public static function clearCategories(): void + { + Cache::forget(self::CATEGORIES_CACHE_KEY); + } + + /** + * Clear tag cache key. + */ + public static function clearTags(): void + { + Cache::forget(self::TAGS_CACHE_KEY); + } +} diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php new file mode 100644 index 0000000..b6a3358 --- /dev/null +++ b/app/Services/ArticleService.php @@ -0,0 +1,275 @@ +articleRepository->paginateLatest($perPage); + } + + public function create(array $data): Article + { + $seoData = $this->extractSeoData($data); + $mediaData = $this->extractMediaData($data); + + $article = $this->articleRepository->create($data); + $this->syncSeo($article, $seoData); + $this->syncArticleMedia($article, $mediaData); + $this->createVersionSnapshot($article, $data['updated_by'] ?? null, $mediaData['version_files']); + $this->clearResponseCache(); + + return $article; + } + + public function update(Article $article, array $data): Article + { + $seoData = $this->extractSeoData($data); + $mediaData = $this->extractMediaData($data); + $oldContent = $article->content; + + $article = $this->articleRepository->update($article, $data); + $this->syncSeo($article, $seoData); + $this->syncArticleMedia($article, $mediaData); + + $contentChanged = isset($data['content']) && $data['content'] !== $oldContent; + if ($contentChanged || $mediaData['version_files'] !== []) { + $this->createVersionSnapshot($article, $data['updated_by'] ?? null, $mediaData['version_files']); + } + + $this->clearResponseCache(); + + return $article; + } + + public function updateSlug(Article $article, string $slug): Article + { + $normalizedSlug = Str::slug($slug); + + if ($normalizedSlug === '') { + throw new InvalidArgumentException('Slug is invalid.'); + } + + $uniqueSlug = $this->resolveUniqueSlug($normalizedSlug, $article->id); + + $updated = $this->articleRepository->update($article, [ + 'slug' => $uniqueSlug, + ]); + + $this->clearResponseCache(); + + return $updated; + } + + public function submitForReview(Article $article): Article + { + if ($article->status !== ArticleStatus::DRAFT) { + throw new InvalidArgumentException('Only draft articles can be submitted for review.'); + } + + $updated = $this->articleRepository->update($article, [ + 'workflow_status' => 'review', + 'submitted_for_review_at' => now(), + ]); + + $this->clearResponseCache(); + + return $updated; + } + + public function approve(Article $article, ?int $reviewerId): Article + { + if ($article->workflow_status !== 'review') { + throw new InvalidArgumentException('Only articles in review can be approved.'); + } + + $updated = $this->articleRepository->update($article, [ + 'workflow_status' => 'approved', + 'reviewed_at' => now(), + 'approved_by' => $reviewerId, + ]); + + $this->clearResponseCache(); + + return $updated; + } + + public function publish(Article $article, ?int $publisherId): Article + { + if (!in_array($article->workflow_status, ['approved', 'published'], true)) { + throw new InvalidArgumentException('Only approved articles can be published.'); + } + + $updated = $this->articleRepository->update($article, [ + 'status' => ArticleStatus::PUBLISHED, + 'workflow_status' => 'published', + 'published_at' => $article->published_at ?: now(), + 'published_by' => $publisherId, + ]); + + PublishArticlePipelineJob::dispatch($updated->id)->afterCommit(); + $this->clearResponseCache(); + + return $updated; + } + + public function delete(Article $article): void + { + $this->articleRepository->delete($article); + $this->clearResponseCache(); + } + + public function restoreFromActivity(Article $article, Activity $activity): Article + { + $oldValues = $activity->properties['old'] ?? []; + + if (!is_array($oldValues) || $oldValues === []) { + throw new InvalidArgumentException('Selected activity does not contain restorable data.'); + } + + $allowed = [ + 'category_id', + 'title', + 'excerpt', + 'content', + 'status', + 'published_at', + ]; + + $restoredData = array_intersect_key($oldValues, array_flip($allowed)); + + if ($restoredData === []) { + throw new InvalidArgumentException('No allowed fields found to restore.'); + } + + $updated = $this->articleRepository->update($article, $restoredData); + $this->clearResponseCache(); + + return $updated; + } + + private function resolveUniqueSlug(string $baseSlug, int $ignoreId): string + { + $slug = $baseSlug; + $counter = 1; + + while ( + Article::query() + ->where('slug', $slug) + ->whereKeyNot($ignoreId) + ->exists() + ) { + $slug = sprintf('%s-%d', $baseSlug, $counter++); + } + + return $slug; + } + + private function extractSeoData(array &$data): array + { + $seoData = [ + 'title' => $data['seo_title'] ?? null, + 'description' => $data['seo_description'] ?? null, + 'keywords' => $data['seo_keywords'] ?? null, + 'og_image' => $data['seo_og_image'] ?? null, + ]; + + unset($data['seo_title'], $data['seo_description'], $data['seo_keywords'], $data['seo_og_image']); + + return $seoData; + } + + private function syncSeo(Article $article, array $seoData): void + { + $articleSeo = $article->seo()->updateOrCreate([], $seoData); + + $analysis = $this->seoAnalyzerService->analyze( + $article->fresh()->loadMissing('seo') + ); + + $articleSeo->update([ + 'score' => $analysis['score'], + 'score_breakdown' => $analysis['breakdown'], + 'warnings' => $analysis['warnings'], + 'last_analyzed_at' => now(), + ]); + } + + private function extractMediaData(array &$data): array + { + $mediaData = [ + 'featured_image' => $data['featured_image'] ?? null, + 'gallery' => $data['gallery'] ?? [], + 'attachments' => $data['attachments'] ?? [], + 'version_files' => $data['version_files'] ?? [], + ]; + + unset($data['featured_image'], $data['gallery'], $data['attachments'], $data['version_files']); + + return $mediaData; + } + + private function syncArticleMedia(Article $article, array $mediaData): void + { + if ($mediaData['featured_image'] instanceof UploadedFile) { + $article->clearMediaCollection('featured_image'); + $article->addMedia($mediaData['featured_image'])->toMediaCollection('featured_image'); + } + + foreach ($mediaData['gallery'] as $image) { + if ($image instanceof UploadedFile) { + $article->addMedia($image)->toMediaCollection('gallery'); + } + } + + foreach ($mediaData['attachments'] as $file) { + if ($file instanceof UploadedFile) { + $article->addMedia($file)->toMediaCollection('attachments'); + } + } + } + + private function createVersionSnapshot(Article $article, mixed $updatedBy, array $versionFiles): ArticleVersion + { + $nextVersionNumber = (int) $article->versions()->max('version_number') + 1; + + $version = $article->versions()->create([ + 'version_number' => $nextVersionNumber, + 'content' => $article->content, + 'updated_by' => is_numeric($updatedBy) ? (int) $updatedBy : null, + ]); + + foreach ($versionFiles as $file) { + if ($file instanceof UploadedFile) { + $version->addMedia($file)->toMediaCollection('version_files'); + } + } + + return $version; + } + + private function clearResponseCache(): void + { + ResponseCache::clear(); + } +} diff --git a/app/Services/CategoryService.php b/app/Services/CategoryService.php new file mode 100644 index 0000000..931126e --- /dev/null +++ b/app/Services/CategoryService.php @@ -0,0 +1,44 @@ +categoryRepository->paginateLatest($perPage); + } + + public function create(array $data): Category + { + return $this->categoryRepository->create([ + 'name' => trim((string) ($data['name'] ?? '')), + 'description' => $data['description'] ?? null, + 'status' => $data['status'] ?? CategoryStatus::ACTIVE->value, + ]); + } + + public function update(Category $category, array $data): Category + { + return $this->categoryRepository->update($category, [ + 'name' => trim((string) ($data['name'] ?? '')), + 'description' => $data['description'] ?? null, + 'status' => $data['status'] ?? $category->status?->value, + ]); + } + + public function delete(Category $category): void + { + $this->categoryRepository->delete($category); + } +} diff --git a/app/Services/CommentService.php b/app/Services/CommentService.php new file mode 100644 index 0000000..99cb841 --- /dev/null +++ b/app/Services/CommentService.php @@ -0,0 +1,60 @@ +commentRepository->paginateByArticle($article, $perPage); + } + + public function createForArticle(Article $article, array $data): Comment + { + return $this->commentRepository->create([ + 'article_id' => $article->id, + 'user_id' => $data['user_id'] ?? null, + 'author_name' => $data['author_name'] ?? null, + 'author_email' => $data['author_email'] ?? null, + 'content' => $data['content'], + 'is_approved' => (bool) ($data['is_approved'] ?? false), + ]); + } + + public function updateForArticle(Article $article, Comment $comment, array $data): Comment + { + $this->ensureCommentBelongsToArticle($article, $comment); + + return $this->commentRepository->update($comment, [ + 'user_id' => $data['user_id'] ?? null, + 'author_name' => $data['author_name'] ?? null, + 'author_email' => $data['author_email'] ?? null, + 'content' => $data['content'], + 'is_approved' => (bool) ($data['is_approved'] ?? false), + ]); + } + + public function deleteForArticle(Article $article, Comment $comment): void + { + $this->ensureCommentBelongsToArticle($article, $comment); + $this->commentRepository->delete($comment); + } + + private function ensureCommentBelongsToArticle(Article $article, Comment $comment): void + { + if ((int) $comment->article_id !== (int) $article->id) { + throw new InvalidArgumentException('Comment does not belong to this article.'); + } + } +} diff --git a/app/Services/ContactSubmissionService.php b/app/Services/ContactSubmissionService.php new file mode 100644 index 0000000..4f47861 --- /dev/null +++ b/app/Services/ContactSubmissionService.php @@ -0,0 +1,61 @@ +toDateString(); + + $duplicate = $this->contactSubmissionRepository->findDailyDuplicate( + $submittedDate, + $clientIp, + $validatedData['email'], + $validatedData['phone'] + ); + + if ($duplicate) { + if ($duplicate->ip_address === $clientIp) { + return back()->withErrors([ + 'daily_limit' => 'IP này đã gửi liên hệ trong hôm nay. Vui lòng thử lại vào ngày mai.', + ])->withInput(); + } + + if ($duplicate->email === $validatedData['email']) { + return back()->withErrors([ + 'email' => 'Email này đã gửi liên hệ trong hôm nay. Vui lòng thử lại vào ngày mai.', + ])->withInput(); + } + + return back()->withErrors([ + 'phone' => 'Số điện thoại này đã gửi liên hệ trong hôm nay. Vui lòng thử lại vào ngày mai.', + ])->withInput(); + } + + $this->contactSubmissionRepository->create([ + 'name' => $validatedData['name'], + 'email' => $validatedData['email'], + 'phone' => $validatedData['phone'], + 'subject' => $validatedData['subject'], + 'message' => $validatedData['message'], + 'ip_address' => $clientIp, + 'submitted_date' => $submittedDate, + ]); + + return redirect() + ->route('contact') + ->with('success', 'Cảm ơn bạn đã liên hệ. Chúng tôi sẽ phản hồi trong thời gian sớm nhất.'); + } +} diff --git a/app/Services/HotArticleService.php b/app/Services/HotArticleService.php new file mode 100644 index 0000000..1103771 --- /dev/null +++ b/app/Services/HotArticleService.php @@ -0,0 +1,203 @@ +viewCounterKey($articleId)); + Log::info("increase view"); + } catch (Throwable $exception) { + Log::warning('Skip tracking article view because Redis is unavailable.', [ + 'article_id' => $articleId, + 'error' => $exception->getMessage(), + ]); + } + } + + /** + * Flush Redis counters to analytics table. + */ + public function flushViewCountersToDatabase(?CarbonInterface $trackedDate = null): int + { + $date = ($trackedDate ?: now())->toDateString(); + + try { + $keys = Redis::keys('hot:article:*:views'); + } catch (Throwable $exception) { + Log::warning('Skip flushing hot article views because Redis is unavailable.', [ + 'error' => $exception->getMessage(), + ]); + + return 0; + } + + if ($keys === []) { + return 0; + } + + $flushedCount = 0; + + foreach ($keys as $key) { + if (!preg_match('/hot:article:(\d+):views$/', (string) $key, $matches)) { + continue; + } + + $articleId = (int) $matches[1]; + $views = (int) Redis::get($key); + + if ($views <= 0) { + Redis::del($key); + continue; + } + + $analytics = Analytics::query()->firstOrCreate( + [ + 'article_id' => $articleId, + 'tracked_date' => $date, + ], + [ + 'views' => 0, + 'likes' => 0, + 'shares' => 0, + ] + ); + + $analytics->increment('views', $views); + Redis::del($key); + $flushedCount += $views; + } + + return $flushedCount; + } + + /** + * Calculate hot scores and persist trend rankings. + */ + public function calculateHotScores(?CarbonInterface $trackedDate = null): int + { + $date = ($trackedDate ?: now())->toDateString(); + $calculatedAt = now()->startOfHour(); + + $analyticsRows = Analytics::query() + ->whereDate('tracked_date', $date) + ->with(['article' => function ($query): void { + $query->withCount('comments'); + }]) + ->get(); + + if ($analyticsRows->isEmpty()) { + try { + Redis::del('hot:ranking:articles'); + } catch (Throwable $exception) { + Log::warning('Skip clearing hot ranking cache because Redis is unavailable.', [ + 'error' => $exception->getMessage(), + ]); + } + + return 0; + } + + $scoredRows = []; + + foreach ($analyticsRows as $analytics) { + /** @var Article|null $article */ + $article = $analytics->article; + if (!$article) { + continue; + } + + $viewScore = (int) $analytics->views * 1; + $commentScore = (int) ($article->comments_count ?? 0) * 5; + $shareScore = (int) $analytics->shares * 8; + $freshScore = $this->freshScore($article, $calculatedAt); + + $score = $viewScore + $commentScore + $shareScore + $freshScore; + + $scoredRows[] = [ + 'article_id' => $article->id, + 'score' => $score, + ]; + } + + usort($scoredRows, static fn (array $a, array $b): int => $b['score'] <=> $a['score']); + + $redisAvailable = true; + try { + Redis::del('hot:ranking:articles'); + } catch (Throwable $exception) { + $redisAvailable = false; + Log::warning('Continue calculating hot scores without Redis cache update.', [ + 'error' => $exception->getMessage(), + ]); + } + + foreach ($scoredRows as $index => $item) { + $rank = $index + 1; + + Trending::query()->updateOrCreate( + [ + 'article_id' => $item['article_id'], + 'calculated_at' => $calculatedAt, + ], + [ + 'score' => $item['score'], + 'rank' => $rank, + ] + ); + + if ($redisAvailable) { + try { + Redis::zadd('hot:ranking:articles', $item['score'], (string) $item['article_id']); + } catch (Throwable $exception) { + $redisAvailable = false; + Log::warning('Stop writing hot ranking cache because Redis became unavailable.', [ + 'article_id' => $item['article_id'], + 'error' => $exception->getMessage(), + ]); + } + } + } + + return count($scoredRows); + } + + private function viewCounterKey(int $articleId): string + { + return sprintf('hot:article:%d:views', $articleId); + } + + private function freshScore(Article $article, CarbonInterface $calculatedAt): int + { + $publishedAt = $article->published_at; + if (!$publishedAt) { + return 0; + } + + $hours = Carbon::parse($publishedAt)->diffInHours($calculatedAt); + + if ($hours <= 24) { + return 20; + } + + if ($hours <= 72) { + return 10; + } + + return 0; + } +} diff --git a/app/Services/SeoAnalyzerService.php b/app/Services/SeoAnalyzerService.php new file mode 100644 index 0000000..6a550ee --- /dev/null +++ b/app/Services/SeoAnalyzerService.php @@ -0,0 +1,292 @@ +,warnings:array} + */ + public function analyze(Article $article): array + { + $seo = $article->seo; + $title = $seo?->title ?: $article->title; + $metaDescription = $seo?->description ?: ($article->excerpt ?? ''); + $keywords = $this->parseKeywords($seo?->keywords); + $primaryKeyword = $keywords[0] ?? ''; + $contentHtml = (string) $article->content; + $contentText = trim(strip_tags($contentHtml)); + + $breakdown = [ + 'title_length' => $this->scoreTitleLength($title), + 'meta_description' => $this->scoreMetaDescription($metaDescription), + 'keyword_in_title' => $this->scoreKeywordInText($primaryKeyword, $title, 10), + 'keyword_in_meta' => $this->scoreKeywordInText($primaryKeyword, $metaDescription, 10), + 'keyword_density' => $this->scoreKeywordDensity($primaryKeyword, $contentText), + 'content_length' => $this->scoreContentLength($contentText), + 'heading_structure' => $this->scoreHeadingStructure($contentHtml, $primaryKeyword), + 'internal_links' => $this->scoreInternalLinks($contentHtml), + 'image_alt' => $this->scoreImageAlt($contentHtml, $primaryKeyword), + 'url_slug' => $this->scoreUrlSlug($article->slug, $primaryKeyword), + ]; + + $warnings = $this->buildWarnings($breakdown, $primaryKeyword); + + return [ + 'score' => array_sum($breakdown), + 'breakdown' => $breakdown, + 'warnings' => $warnings, + ]; + } + + private function scoreTitleLength(string $title): int + { + $len = Str::length(trim($title)); + + if ($len >= 50 && $len <= 60) { + return 10; + } + + if ($len >= 40 && $len <= 70) { + return 5; + } + + return 0; + } + + private function scoreMetaDescription(string $metaDescription): int + { + $len = Str::length(trim($metaDescription)); + + if ($len >= 120 && $len <= 160) { + return 15; + } + + if ($len >= 90 && $len <= 180) { + return 8; + } + + return 0; + } + + private function scoreKeywordInText(string $keyword, string $text, int $max): int + { + if ($keyword === '') { + return 0; + } + + return Str::contains(Str::lower($text), Str::lower($keyword)) ? $max : 0; + } + + private function scoreKeywordDensity(string $keyword, string $contentText): int + { + if ($keyword === '') { + return 0; + } + + $words = preg_split('/\s+/u', Str::lower($contentText), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $totalWords = count($words); + + if ($totalWords === 0) { + return 0; + } + + $matches = preg_match_all('/\b'.preg_quote(Str::lower($keyword), '/').'\b/u', Str::lower($contentText)); + $density = ($matches / $totalWords) * 100; + + if ($density >= 1.0 && $density <= 2.0) { + return 10; + } + + if ($density >= 0.5 && $density <= 3.0) { + return 5; + } + + return 0; + } + + private function scoreContentLength(string $contentText): int + { + $wordCount = str_word_count(strip_tags($contentText)); + + if ($wordCount >= 800) { + return 10; + } + + if ($wordCount >= 500) { + return 5; + } + + return 0; + } + + private function scoreHeadingStructure(string $contentHtml, string $keyword): int + { + $h1Count = preg_match_all('/]*>/i', $contentHtml); + $h2Count = preg_match_all('/]*>/i', $contentHtml); + $h3Count = preg_match_all('/]*>/i', $contentHtml); + $hasKeywordInH1 = $keyword !== '' && (bool) preg_match('/]*>.*'.preg_quote($keyword, '/').'.*<\/h1>/iu', $contentHtml); + + if ($h1Count === 1 && $h2Count >= 2 && $h2Count <= 3 && $h3Count >= 3 && $h3Count <= 5 && $hasKeywordInH1) { + return 10; + } + + if ($h1Count >= 1 && $h2Count >= 1) { + return 5; + } + + return 0; + } + + private function scoreInternalLinks(string $contentHtml): int + { + preg_match_all('/]*href=["\']([^"\']+)["\'][^>]*>/i', $contentHtml, $matches); + $links = $matches[1] ?? []; + + $appHost = parse_url((string) config('app.url'), PHP_URL_HOST); + $internalCount = 0; + + foreach ($links as $link) { + if (Str::startsWith($link, ['/'])) { + $internalCount++; + continue; + } + + $host = parse_url($link, PHP_URL_HOST); + if ($host && $appHost && Str::lower($host) === Str::lower($appHost)) { + $internalCount++; + } + } + + if ($internalCount >= 2) { + return 10; + } + + if ($internalCount === 1) { + return 5; + } + + return 0; + } + + private function scoreImageAlt(string $contentHtml, string $keyword): int + { + preg_match_all('/]*>/i', $contentHtml, $images); + $imageTags = $images[0] ?? []; + + if ($imageTags === []) { + return 10; + } + + $withValidAlt = 0; + + foreach ($imageTags as $img) { + if (preg_match('/alt=["\']([^"\']+)["\']/i', $img, $altMatch)) { + $alt = Str::lower($altMatch[1]); + if ($keyword === '' || Str::contains($alt, Str::lower($keyword))) { + $withValidAlt++; + } + } + } + + if ($withValidAlt === count($imageTags)) { + return 10; + } + + if ($withValidAlt >= (int) ceil(count($imageTags) / 2)) { + return 5; + } + + return 0; + } + + private function scoreUrlSlug(string $slug, string $keyword): int + { + $parts = array_values(array_filter(explode('-', trim($slug)), fn ($part) => $part !== '')); + $stopWords = ['the', 'a', 'an', 'in', 'on', 'at', 'for', 'of', 'to']; + $containsStopWord = collect($parts)->contains(fn ($part) => in_array(Str::lower($part), $stopWords, true)); + $containsKeyword = $keyword !== '' && Str::contains(Str::lower($slug), Str::lower(Str::slug($keyword))); + + if (count($parts) >= 3 && count($parts) <= 8 && !$containsStopWord && $containsKeyword) { + return 5; + } + + if (count($parts) >= 2 && count($parts) <= 10) { + return 3; + } + + return 0; + } + + /** + * @param array $breakdown + * @return array + */ + private function buildWarnings(array $breakdown, string $primaryKeyword): array + { + $warnings = []; + + if ($primaryKeyword === '') { + $warnings[] = 'Primary keyword is missing. Add SEO keywords to improve scoring.'; + } + + if (($breakdown['title_length'] ?? 0) < 10) { + $warnings[] = 'Title should be around 50-60 characters.'; + } + + if (($breakdown['meta_description'] ?? 0) < 15) { + $warnings[] = 'Meta description should be around 120-160 characters.'; + } + + if (($breakdown['keyword_in_title'] ?? 0) < 10) { + $warnings[] = 'Primary keyword should appear in the title.'; + } + + if (($breakdown['keyword_in_meta'] ?? 0) < 10) { + $warnings[] = 'Primary keyword should appear in the meta description.'; + } + + if (($breakdown['keyword_density'] ?? 0) < 10) { + $warnings[] = 'Keyword density should be around 1%-2%.'; + } + + if (($breakdown['content_length'] ?? 0) < 10) { + $warnings[] = 'Content should be at least 800 words.'; + } + + if (($breakdown['heading_structure'] ?? 0) < 10) { + $warnings[] = 'Use proper heading structure: H1 once, H2 two to three times, H3 three to five times.'; + } + + if (($breakdown['internal_links'] ?? 0) < 10) { + $warnings[] = 'Add at least two internal links.'; + } + + if (($breakdown['image_alt'] ?? 0) < 10) { + $warnings[] = 'Add alt text with the main keyword to all images.'; + } + + if (($breakdown['url_slug'] ?? 0) < 5) { + $warnings[] = 'Slug should be concise, keyword-focused, and avoid stop words.'; + } + + return $warnings; + } + + /** + * @return array + */ + private function parseKeywords(?string $keywords): array + { + if (!$keywords) { + return []; + } + + return array_values(array_filter(array_map(static fn ($item) => trim((string) $item), explode(',', $keywords)))); + } +} diff --git a/app/Services/SeoService.php b/app/Services/SeoService.php new file mode 100644 index 0000000..eacf170 --- /dev/null +++ b/app/Services/SeoService.php @@ -0,0 +1,40 @@ +seo; + + $title = $seo?->title ?: $article->title; + $description = $seo?->description ?: ($article->excerpt ?: Str::limit(strip_tags($article->content), 160)); + + SEOTools::setTitle($title); + SEOTools::setDescription($description); + SEOTools::metatags()->setCanonical(route('articles.show', ['article' => $article->slug])); + SEOTools::metatags()->setRobots('index,follow'); + SEOTools::opengraph()->setUrl(route('articles.show', ['article' => $article->slug])); + SEOTools::opengraph()->addProperty('type', 'article'); + SEOTools::jsonLd()->setType('Article'); + SEOTools::jsonLd()->setTitle($title); + SEOTools::jsonLd()->setDescription($description); + SEOTools::jsonLd()->setUrl(route('articles.show', ['article' => $article->slug])); + + if (!empty($seo?->keywords)) { + $keywords = array_filter(array_map('trim', explode(',', $seo->keywords))); + SEOTools::metatags()->setKeywords($keywords); + } + + if (!empty($seo?->og_image)) { + SEOTools::opengraph()->addImage($seo->og_image); + SEOTools::twitter()->setImage($seo->og_image); + SEOTools::jsonLd()->addImage($seo->og_image); + } + } +} diff --git a/app/Services/TagService.php b/app/Services/TagService.php new file mode 100644 index 0000000..fa46ede --- /dev/null +++ b/app/Services/TagService.php @@ -0,0 +1,58 @@ +tagRepository->paginateLatest($perPage); + } + + public function create(array $data): Tag + { + $name = trim((string) ($data['name'] ?? '')); + $slug = $this->resolveUniqueSlug(Str::slug($name)); + + return $this->tagRepository->create([ + 'name' => $name, + 'slug' => $slug, + ]); + } + + public function update(Tag $tag, array $data): Tag + { + $name = trim((string) ($data['name'] ?? '')); + + return $this->tagRepository->update($tag, [ + 'name' => $name, + ]); + } + + public function delete(Tag $tag): void + { + $this->tagRepository->delete($tag); + } + + private function resolveUniqueSlug(string $baseSlug): string + { + $slug = $baseSlug !== '' ? $baseSlug : 'tag'; + $counter = 1; + + while (Tag::query()->where('slug', $slug)->exists()) { + $slug = sprintf('%s-%d', $baseSlug !== '' ? $baseSlug : 'tag', $counter++); + } + + return $slug; + } +} diff --git a/article_hot.md b/article_hot.md new file mode 100644 index 0000000..ca233f1 --- /dev/null +++ b/article_hot.md @@ -0,0 +1,34 @@ +# Logic xác định bài viết hot +Hot Score = ViewScore + CommentScore + ShareScore + FreshScore +Yếu tố Trọng số +View 1 +Comment 5 +Share 8 +Fresh (bài mới) 20 + +# Tracking view realtime + +# Queue job tăng view + +# Cron job flush Redis → DB + +# Job tính Hot Score + +# kiến trúc +User view + ↓ +CDN + ↓ +Nginx + ↓ +Static HTML + +Track view + ↓ +Kafka / Redis + ↓ +Analytics service + ↓ +Hot score engine + ↓ +Redis ranking \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..9b2c816 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Spatie\ResponseCache\Middlewares\CacheResponse; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -10,8 +11,13 @@ commands: __DIR__.'/../routes/console.php', health: '/up', ) + ->withCommands([ + __DIR__.'/../app/Console/Commands', + ]) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'cacheResponse' => CacheResponse::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..4e3b440 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\HorizonServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 126502d..ed560e8 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,21 @@ "license": "MIT", "require": { "php": "^8.2", + "artesaos/seotools": "^1.2", "laravel/framework": "^12.0", - "laravel/tinker": "^2.10.1" + "laravel/horizon": "^5.45", + "laravel/sanctum": "^4.0", + "laravel/scout": "^10.0", + "laravel/tinker": "^2.10.1", + "meilisearch/meilisearch-php": "^1.16", + "predis/predis": "^2.0", + "spatie/laravel-activitylog": "^4.0", + "spatie/laravel-medialibrary": "^11.0", + "spatie/laravel-permission": "^7.2", + "spatie/laravel-responsecache": "^8.3", + "spatie/laravel-sitemap": "^7.0", + "spatie/laravel-sluggable": "^3.0", + "symfony/dom-crawler": "^7.0" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -86,4 +99,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index daf4557..d3ceb63 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,79 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c514d8f7b9fc5970bdd94287905ef584", + "content-hash": "7ee432cd57ffd45ab98f4da0755cb1a6", "packages": [ + { + "name": "artesaos/seotools", + "version": "v1.3.2", + "source": { + "type": "git", + "url": "https://github.com/artesaos/seotools.git", + "reference": "f22bc0b0ff1bcb683ff72589d11fa80d10706597" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/artesaos/seotools/zipball/f22bc0b0ff1bcb683ff72589d11fa80d10706597", + "reference": "f22bc0b0ff1bcb683ff72589d11fa80d10706597", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/config": "^10.0 || ^11.0 || ^12.0", + "illuminate/support": "^10.0 || ^11.0 || ^12.0", + "php": "^8.1" + }, + "require-dev": { + "orchestra/testbench": "^8.0 || ^9.0 || ^10.0", + "phpunit/phpunit": "^9.0 || ^10.0 || ^11.5.3" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "SEO": "Artesaos\\SEOTools\\Facades\\SEOTools", + "JsonLd": "Artesaos\\SEOTools\\Facades\\JsonLd", + "SEOMeta": "Artesaos\\SEOTools\\Facades\\SEOMeta", + "Twitter": "Artesaos\\SEOTools\\Facades\\TwitterCard", + "OpenGraph": "Artesaos\\SEOTools\\Facades\\OpenGraph" + }, + "providers": [ + "Artesaos\\SEOTools\\Providers\\SEOToolsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Artesaos\\SEOTools\\": "src/SEOTools/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Vinicius", + "email": "luiz.vinicius73@gmail.com" + } + ], + "description": "SEO Tools for Laravel and Lumen", + "keywords": [ + "JSON-LD", + "laravel", + "lumen", + "metatags", + "opengraph", + "seo", + "seotools", + "webmaster" + ], + "support": { + "issues": "https://github.com/artesaos/seotools/issues", + "source": "https://github.com/artesaos/seotools" + }, + "time": "2025-03-07T14:44:43+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -135,6 +206,83 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -1274,6 +1422,86 @@ }, "time": "2026-02-24T14:35:15+00:00" }, + { + "name": "laravel/horizon", + "version": "v5.45.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/horizon.git", + "reference": "637e065ae0a704288595b896ad1c7c3c9741869b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/horizon/zipball/637e065ae0a704288595b896ad1c7c3c9741869b", + "reference": "637e065ae0a704288595b896ad1c7c3c9741869b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcntl": "*", + "ext-posix": "*", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", + "nesbot/carbon": "^2.17|^3.0", + "php": "^8.0", + "ramsey/uuid": "^4.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/error-handler": "^6.0|^7.0|^8.0", + "symfony/polyfill-php83": "^1.28", + "symfony/process": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0", + "phpstan/phpstan": "^1.10|^2.0", + "predis/predis": "^1.1|^2.0|^3.0" + }, + "suggest": { + "ext-redis": "Required to use the Redis PHP driver.", + "predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Horizon": "Laravel\\Horizon\\Horizon" + }, + "providers": [ + "Laravel\\Horizon\\HorizonServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Horizon\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Dashboard and code-driven configuration for Laravel queues.", + "keywords": [ + "laravel", + "queue" + ], + "support": { + "issues": "https://github.com/laravel/horizon/issues", + "source": "https://github.com/laravel/horizon/tree/v5.45.1" + }, + "time": "2026-03-06T15:31:27+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.13", @@ -1334,38 +1562,44 @@ "time": "2026-02-06T12:17:10+00:00" }, { - "name": "laravel/serializable-closure", - "version": "v2.0.10", + "name": "laravel/sanctum", + "version": "v4.3.1", "source": { "type": "git", - "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "url": "https://github.com/laravel/sanctum.git", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", "shasum": "" }, "require": { - "php": "^8.1" + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "nesbot/carbon": "^2.67|^3.0", - "pestphp/pest": "^2.36|^3.0|^4.0", - "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.x-dev" + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] } }, "autoload": { "psr-4": { - "Laravel\\SerializableClosure\\": "src/" + "Laravel\\Sanctum\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1376,65 +1610,76 @@ { "name": "Taylor Otwell", "email": "taylor@laravel.com" - }, - { - "name": "Nuno Maduro", - "email": "nuno@laravel.com" } ], - "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", "keywords": [ - "closure", + "auth", "laravel", - "serializable" + "sanctum" ], "support": { - "issues": "https://github.com/laravel/serializable-closure/issues", - "source": "https://github.com/laravel/serializable-closure" + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-02-07T17:19:31+00:00" }, { - "name": "laravel/tinker", - "version": "v2.11.1", + "name": "laravel/scout", + "version": "v10.24.0", "source": { "type": "git", - "url": "https://github.com/laravel/tinker.git", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + "url": "https://github.com/laravel/scout.git", + "reference": "f9864d9a727a0c0d6b95e08ed92df8c301ae6d2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "url": "https://api.github.com/repos/laravel/scout/zipball/f9864d9a727a0c0d6b95e08ed92df8c301ae6d2c", + "reference": "f9864d9a727a0c0d6b95e08ed92df8c301ae6d2c", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.2.5|^8.0", - "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + "illuminate/bus": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/pagination": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "conflict": { + "algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0" }, "require-dev": { - "mockery/mockery": "~1.3.3|^1.4.2", + "algolia/algoliasearch-client-php": "^3.2|^4.0", + "meilisearch/meilisearch-php": "^1.0", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.31|^8.36|^9.15|^10.8|^11.0", + "php-http/guzzle7-adapter": "^1.0", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + "typesense/typesense-php": "^4.9.3" }, "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", + "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", + "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." }, "type": "library", "extra": { "laravel": { "providers": [ - "Laravel\\Tinker\\TinkerServiceProvider" + "Laravel\\Scout\\ScoutServiceProvider" ] + }, + "branch-alias": { + "dev-master": "10.x-dev" } }, "autoload": { "psr-4": { - "Laravel\\Tinker\\": "src/" + "Laravel\\Scout\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1447,93 +1692,278 @@ "email": "taylor@laravel.com" } ], - "description": "Powerful REPL for the Laravel framework.", + "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", "keywords": [ - "REPL", - "Tinker", + "algolia", "laravel", - "psysh" + "search" ], "support": { - "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.1" + "issues": "https://github.com/laravel/scout/issues", + "source": "https://github.com/laravel/scout" }, - "time": "2026-02-06T14:12:35+00:00" + "time": "2026-02-10T18:44:39+00:00" }, { - "name": "league/commonmark", - "version": "2.8.0", + "name": "laravel/sentinel", + "version": "v1.0.1", "source": { "type": "git", - "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "url": "https://github.com/laravel/sentinel.git", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603", "shasum": "" }, "require": { - "ext-mbstring": "*", - "league/config": "^1.1.1", - "php": "^7.4 || ^8.0", - "psr/event-dispatcher": "^1.0", - "symfony/deprecation-contracts": "^2.1 || ^3.0", - "symfony/polyfill-php80": "^1.16" - }, - "require-dev": { - "cebe/markdown": "^1.0", - "commonmark/cmark": "0.31.1", - "commonmark/commonmark.js": "0.31.1", - "composer/package-versions-deprecated": "^1.8", - "embed/embed": "^4.4", - "erusev/parsedown": "^1.0", "ext-json": "*", - "github/gfm": "0.29.0", - "michelf/php-markdown": "^1.4 || ^2.0", - "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", - "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", - "unleashedtech/php-coding-standard": "^3.1.1", - "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + "illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" }, - "suggest": { - "symfony/yaml": "v2.3+ required if using the Front Matter extension" + "require-dev": { + "laravel/pint": "^1.27", + "orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0", + "phpstan/phpstan": "^2.1.33" }, "type": "library", "extra": { + "laravel": { + "providers": [ + "Laravel\\Sentinel\\SentinelServiceProvider" + ] + }, "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "1.x-dev" } }, "autoload": { "psr-4": { - "League\\CommonMark\\": "src" + "Laravel\\Sentinel\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Colin O'Dell", - "email": "colinodell@gmail.com", - "homepage": "https://www.colinodell.com", - "role": "Lead Developer" + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "mior@laravel.com" } ], - "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", - "homepage": "https://commonmark.thephpleague.com", - "keywords": [ - "commonmark", - "flavored", - "gfm", + "support": { + "source": "https://github.com/laravel/sentinel/tree/v1.0.1" + }, + "time": "2026-02-12T13:32:54+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.10", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-02-20T19:59:49+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", "github", "github-flavored", "markdown", @@ -2020,68 +2450,293 @@ "time": "2026-01-15T06:54:53+00:00" }, { - "name": "monolog/monolog", - "version": "3.10.0", + "name": "maennchen/zipstream-php", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/log": "^2.0 || ^3.0" - }, - "provide": { - "psr/log-implementation": "3.0.0" + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" }, "require-dev": { - "aws/aws-sdk-php": "^3.0", - "doctrine/couchdb": "~1.0@dev", - "elasticsearch/elasticsearch": "^7 || ^8", - "ext-json": "*", - "graylog2/gelf-php": "^1.4.2 || ^2.0", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8 || ^2.0", - "php-amqplib/php-amqplib": "~2.4 || ^3", - "php-console/php-console": "^3.1.8", - "phpstan/phpstan": "^2", - "phpstan/phpstan-deprecation-rules": "^2", - "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^10.5.17 || ^11.0.7", - "predis/predis": "^1.1 || ^2", - "rollbar/rollbar": "^4.0", - "ruflin/elastica": "^7 || ^8", - "symfony/mailer": "^5.4 || ^6", - "symfony/mime": "^5.4 || ^6" + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" }, "suggest": { - "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", - "doctrine/couchdb": "Allow sending log messages to a CouchDB server", - "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", - "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", - "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", - "ext-mbstring": "Allow to work properly with unicode symbols", - "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", - "ext-openssl": "Required to send log messages using SSL", - "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", - "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", - "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", - "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", - "rollbar/rollbar": "Allow sending log messages to Rollbar", - "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - } + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-12-10T09:58:31+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, + { + "name": "meilisearch/meilisearch-php", + "version": "v1.16.1", + "source": { + "type": "git", + "url": "https://github.com/meilisearch/meilisearch-php.git", + "reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6", + "reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "php-http/discovery": "^1.7", + "psr/http-client": "^1.0", + "symfony/polyfill-php81": "^1.33" + }, + "require-dev": { + "http-interop/http-factory-guzzle": "^1.2.0", + "php-cs-fixer/shim": "^3.59.3", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.5 || ^10.5", + "symfony/http-client": "^5.4|^6.0|^7.0" + }, + "suggest": { + "guzzlehttp/guzzle": "Use Guzzle ^7 as HTTP client", + "http-interop/http-factory-guzzle": "Factory for guzzlehttp/guzzle", + "symfony/http-client": "Use Symfony Http client" + }, + "type": "library", + "autoload": { + "psr-4": { + "MeiliSearch\\": "src/", + "Meilisearch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Clémentine Urquizar", + "email": "clementine@meilisearch.com" + }, + { + "name": "Bruno Casali", + "email": "bruno@meilisearch.com" + }, + { + "name": "Laurent Cazanove", + "email": "lau.cazanove@gmail.com" + }, + { + "name": "Tomas Norkūnas", + "email": "norkunas.tom@gmail.com" + } + ], + "description": "PHP wrapper for the Meilisearch API", + "keywords": [ + "api", + "client", + "instant", + "meilisearch", + "php", + "search" + ], + "support": { + "issues": "https://github.com/meilisearch/meilisearch-php/issues", + "source": "https://github.com/meilisearch/meilisearch-php/tree/v1.16.1" + }, + "time": "2025-09-18T10:15:45+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } }, "autoload": { "psr-4": { @@ -2385,6 +3040,60 @@ }, "time": "2026-02-13T03:05:33+00:00" }, + { + "name": "nicmart/tree", + "version": "0.10.1", + "source": { + "type": "git", + "url": "https://github.com/nicmart/Tree.git", + "reference": "2ef11e329d26005ef49dbacd0223bcfd2515b6cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nicmart/Tree/zipball/2ef11e329d26005ef49dbacd0223bcfd2515b6cc", + "reference": "2ef11e329d26005ef49dbacd0223bcfd2515b6cc", + "shasum": "" + }, + "require": { + "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.48.2", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.28.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "~0.26.19", + "phpunit/phpunit": "^9.6.19", + "psalm/plugin-phpunit": "~0.19.0", + "vimeo/psalm": "^5.26.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Tree\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolò Martini", + "email": "nicmartnic@gmail.com" + }, + { + "name": "Andreas Möller", + "email": "am@localheinz.com" + } + ], + "description": "A basic but flexible php tree data structure and a fluent tree builder implementation.", + "support": { + "issues": "https://github.com/nicmart/Tree/issues", + "source": "https://github.com/nicmart/Tree/tree/0.10.1" + }, + "time": "2025-11-25T08:51:01+00:00" + }, { "name": "nikic/php-parser", "version": "v5.7.0", @@ -2530,6 +3239,85 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -2606,17 +3394,79 @@ "time": "2025-12-27T19:41:33+00:00" }, { - "name": "psr/clock", - "version": "1.0.0", + "name": "predis/predis", + "version": "v2.4.1", "source": { "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + "url": "https://github.com/predis/predis.git", + "reference": "07105e050622ed80bd60808367ced9e379f31530" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "url": "https://api.github.com/repos/predis/predis/zipball/07105e050622ed80bd60808367ced9e379f31530", + "reference": "07105e050622ed80bd60808367ced9e379f31530", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ^9.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v2.4.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2025-11-12T18:00:11+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", "shasum": "" }, "require": { @@ -3141,53 +3991,1073 @@ "time": "2019-03-08T08:55:37+00:00" }, { - "name": "ramsey/collection", - "version": "2.1.1", + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "spatie/browsershot", + "version": "5.2.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/browsershot.git", + "reference": "d2e4ac7c69162999940172a674bf83ddc5ac59ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/browsershot/zipball/d2e4ac7c69162999940172a674bf83ddc5ac59ea", + "reference": "d2e4ac7c69162999940172a674bf83ddc5ac59ea", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-json": "*", + "php": "^8.2", + "spatie/temporary-directory": "^2.0", + "symfony/process": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "pestphp/pest": "^3.0|^4.0", + "spatie/image": "^3.6", + "spatie/pdf-to-text": "^1.52", + "spatie/phpunit-snapshot-assertions": "^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Browsershot\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://github.com/freekmurze", + "role": "Developer" + } + ], + "description": "Convert a webpage to an image or pdf using headless Chrome", + "homepage": "https://github.com/spatie/browsershot", + "keywords": [ + "chrome", + "convert", + "headless", + "image", + "pdf", + "puppeteer", + "screenshot", + "webpage" + ], + "support": { + "source": "https://github.com/spatie/browsershot/tree/5.2.3" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-18T16:10:58+00:00" + }, + { + "name": "spatie/crawler", + "version": "8.5.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/crawler.git", + "reference": "18198a2198adff1637c0028cb60d2c9559721556" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/crawler/zipball/18198a2198adff1637c0028cb60d2c9559721556", + "reference": "18198a2198adff1637c0028cb60d2c9559721556", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.3", + "guzzlehttp/psr7": "^2.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "nicmart/tree": "^0.10", + "php": "^8.2", + "spatie/browsershot": "^5.0.5", + "spatie/robots-txt": "^2.0", + "symfony/dom-crawler": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "pestphp/pest": "^2.0|^3.0|^4.0", + "spatie/ray": "^1.37" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Crawler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be" + } + ], + "description": "Crawl all internal links found on a website", + "homepage": "https://github.com/spatie/crawler", + "keywords": [ + "crawler", + "link", + "spatie", + "website" + ], + "support": { + "issues": "https://github.com/spatie/crawler/issues", + "source": "https://github.com/spatie/crawler/tree/8.5.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-21T22:52:37+00:00" + }, + { + "name": "spatie/image", + "version": "3.9.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/image.git", + "reference": "1ea40e429587df64b34139e6b18af34cb20ae5b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/image/zipball/1ea40e429587df64b34139e6b18af34cb20ae5b9", + "reference": "1ea40e429587df64b34139e6b18af34cb20ae5b9", + "shasum": "" + }, + "require": { + "ext-exif": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": "^8.2", + "spatie/image-optimizer": "^1.7.5", + "spatie/temporary-directory": "^2.2", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-imagick": "*", + "laravel/sail": "^1.34", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^1.10.50", + "spatie/pest-plugin-snapshots": "^2.1", + "spatie/pixelmatch-php": "^1.0", + "spatie/ray": "^1.40.1", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Manipulate images with an expressive API", + "homepage": "https://github.com/spatie/image", + "keywords": [ + "image", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/image/tree/3.9.3" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-01T20:58:11+00:00" + }, + { + "name": "spatie/image-optimizer", + "version": "1.8.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/image-optimizer.git", + "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/image-optimizer/zipball/2ad9ac7c19501739183359ae64ea6c15869c23d9", + "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.3|^8.0", + "psr/log": "^1.0 | ^2.0 | ^3.0", + "symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "pestphp/pest": "^1.21|^2.0|^3.0|^4.0", + "phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0", + "symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\ImageOptimizer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily optimize images using PHP", + "homepage": "https://github.com/spatie/image-optimizer", + "keywords": [ + "image-optimizer", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/image-optimizer/issues", + "source": "https://github.com/spatie/image-optimizer/tree/1.8.1" + }, + "time": "2025-11-26T10:57:19+00:00" + }, + { + "name": "spatie/laravel-activitylog", + "version": "4.12.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "bf66b5bbe9a946e977e876420d16b30b9aff1b2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/bf66b5bbe9a946e977e876420d16b30b9aff1b2d", + "reference": "bf66b5bbe9a946e977e876420d16b30b9aff1b2d", + "shasum": "" + }, + "require": { + "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.6 || ^10.0 || ^11.0", + "pestphp/pest": "^1.20 || ^2.0 || ^3.0 || ^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/4.12.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-22T08:37:18+00:00" + }, + { + "name": "spatie/laravel-medialibrary", + "version": "11.21.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-medialibrary.git", + "reference": "d6e2595033ffd130d4dd5d124510ab3304794c44" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/d6e2595033ffd130d4dd5d124510ab3304794c44", + "reference": "d6e2595033ffd130d4dd5d124510ab3304794c44", + "shasum": "" + }, + "require": { + "composer/semver": "^3.4", + "ext-exif": "*", + "ext-fileinfo": "*", + "ext-json": "*", + "illuminate/bus": "^10.2|^11.0|^12.0|^13.0", + "illuminate/conditionable": "^10.2|^11.0|^12.0|^13.0", + "illuminate/console": "^10.2|^11.0|^12.0|^13.0", + "illuminate/database": "^10.2|^11.0|^12.0|^13.0", + "illuminate/pipeline": "^10.2|^11.0|^12.0|^13.0", + "illuminate/support": "^10.2|^11.0|^12.0|^13.0", + "maennchen/zipstream-php": "^3.1", + "php": "^8.2", + "spatie/image": "^3.3.2", + "spatie/laravel-package-tools": "^1.16.1", + "spatie/temporary-directory": "^2.2", + "symfony/console": "^6.4.1|^7.0|^8.0" + }, + "conflict": { + "php-ffmpeg/php-ffmpeg": "<0.6.1" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.293.10", + "ext-imagick": "*", + "ext-pdo_sqlite": "*", + "ext-zip": "*", + "guzzlehttp/guzzle": "^7.8.1", + "larastan/larastan": "^2.7|^3.0", + "league/flysystem-aws-s3-v3": "^3.22", + "mockery/mockery": "^1.6.7", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/extension-installer": "^1.3.1", + "spatie/laravel-ray": "^1.33", + "spatie/pdf-to-image": "^2.2|^3.0", + "spatie/pest-expectations": "^1.13", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "suggest": { + "league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage", + "php-ffmpeg/php-ffmpeg": "Required for generating video thumbnails", + "spatie/pdf-to-image": "Required for generating thumbnails of PDFs and SVGs" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\MediaLibrary\\MediaLibraryServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\MediaLibrary\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Associate files with Eloquent models", + "homepage": "https://github.com/spatie/laravel-medialibrary", + "keywords": [ + "cms", + "conversion", + "downloads", + "images", + "laravel", + "laravel-medialibrary", + "media", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-medialibrary/issues", + "source": "https://github.com/spatie/laravel-medialibrary/tree/11.21.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-21T15:58:56+00:00" + }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-21T12:49:54+00:00" + }, + { + "name": "spatie/laravel-permission", + "version": "7.2.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "062b0cd8e3a1753fa7a53e468b918710004aa06b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/062b0cd8e3a1753fa7a53e468b918710004aa06b", + "reference": "062b0cd8e3a1753fa7a53e468b918710004aa06b", + "shasum": "" + }, + "require": { + "illuminate/auth": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "php": "^8.4", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/passport": "^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.1", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "7.x-dev", + "dev-master": "7.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 12 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/7.2.3" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-23T20:30:07+00:00" + }, + { + "name": "spatie/laravel-responsecache", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-responsecache.git", + "reference": "7f5be36713076d3164949f21768b53b236eecf03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-responsecache/zipball/7f5be36713076d3164949f21768b53b236eecf03", + "reference": "7f5be36713076d3164949f21768b53b236eecf03", + "shasum": "" + }, + "require": { + "illuminate/cache": "^12.0|^13.0", + "illuminate/console": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/http": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "nesbot/carbon": "^3.0", + "php": "^8.4", + "spatie/laravel-package-tools": "^1.9", + "spatie/php-attribute-reader": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/framework": "^12.0|^13.0", + "laravel/pint": "^1.13.7", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "ResponseCache": "Spatie\\ResponseCache\\Facades\\ResponseCache" + }, + "providers": [ + "Spatie\\ResponseCache\\ResponseCacheServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\ResponseCache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Speed up a Laravel application by caching the entire response", + "homepage": "https://github.com/spatie/laravel-responsecache", + "keywords": [ + "cache", + "laravel", + "laravel-responsecache", + "performance", + "response", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-responsecache/issues", + "source": "https://github.com/spatie/laravel-responsecache/tree/8.3.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-03T19:51:38+00:00" + }, + { + "name": "spatie/laravel-sitemap", + "version": "7.4.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-sitemap.git", + "reference": "52397c6f4219a8a0a163ddb8a8d5bc5f9bba0df4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-sitemap/zipball/52397c6f4219a8a0a163ddb8a8d5bc5f9bba0df4", + "reference": "52397c6f4219a8a0a163ddb8a8d5bc5f9bba0df4", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.8", + "illuminate/support": "^11.0|^12.0||^13.0", + "nesbot/carbon": "^2.71|^3.0", + "php": "^8.2||^8.3||^8.4||^8.5", + "spatie/crawler": "^8.0.1", + "spatie/laravel-package-tools": "^1.16.1", + "symfony/dom-crawler": "^6.3.4|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6.6", + "orchestra/testbench": "^9.0|^10.0||^11.0", + "pestphp/pest": "^3.7.4|^4.0", + "spatie/pest-plugin-snapshots": "^2.1", + "spatie/phpunit-snapshot-assertions": "^5.1.2", + "spatie/temporary-directory": "^2.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Sitemap\\SitemapServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\Sitemap\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Create and generate sitemaps with ease", + "homepage": "https://github.com/spatie/laravel-sitemap", + "keywords": [ + "laravel-sitemap", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/laravel-sitemap/tree/7.4.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2026-02-21T23:00:46+00:00" + }, + { + "name": "spatie/laravel-sluggable", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-sluggable.git", + "reference": "8050bf38a6e03ac4974e1f7bc722983242abfae0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-sluggable/zipball/8050bf38a6e03ac4974e1f7bc722983242abfae0", + "reference": "8050bf38a6e03ac4974e1f7bc722983242abfae0", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.2" + }, + "require-dev": { + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.0|^3.7|^4.0", + "spatie/laravel-translatable": "^5.0|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Sluggable\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Generate slugs when saving Eloquent models", + "homepage": "https://github.com/spatie/laravel-sluggable", + "keywords": [ + "laravel-sluggable", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/laravel-sluggable/tree/3.8.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-21T14:25:10+00:00" + }, + { + "name": "spatie/php-attribute-reader", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/php-attribute-reader.git", + "reference": "46e7484d7b51f5b22d672745c541e48c5a385404" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/php-attribute-reader/zipball/46e7484d7b51f5b22d672745c541e48c5a385404", + "reference": "46e7484d7b51f5b22d672745c541e48c5a385404", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "pestphp/pest": "^1.0|^2.0|^3.0|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Attributes\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A clean API for working with PHP attributes", + "homepage": "https://github.com/spatie/php-attribute-reader", + "keywords": [ + "attributes", + "php-attribute-reader", + "reflection", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/php-attribute-reader/issues", + "source": "https://github.com/spatie/php-attribute-reader/tree/1.1.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-23T09:01:55+00:00" + }, + { + "name": "spatie/robots-txt", + "version": "2.5.4", "source": { "type": "git", - "url": "https://github.com/ramsey/collection.git", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + "url": "https://github.com/spatie/robots-txt.git", + "reference": "a8dd35d0a94e863f52509a366a634978e9c1db03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "url": "https://api.github.com/repos/spatie/robots-txt/zipball/a8dd35d0a94e863f52509a366a634978e9c1db03", + "reference": "a8dd35d0a94e863f52509a366a634978e9c1db03", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "captainhook/plugin-composer": "^5.3", - "ergebnis/composer-normalize": "^2.45", - "fakerphp/faker": "^1.24", - "hamcrest/hamcrest-php": "^2.0", - "jangregor/phpstan-prophecy": "^2.1", - "mockery/mockery": "^1.6", - "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpspec/prophecy-phpunit": "^2.3", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5", - "ramsey/coding-standard": "^2.3", - "ramsey/conventional-commits": "^1.6", - "roave/security-advisories": "dev-latest" + "phpunit/phpunit": "^11.5.2" }, "type": "library", - "extra": { - "captainhook": { - "force-install": true - }, - "ramsey/conventional-commits": { - "configFile": "conventional-commits.json" - } - }, "autoload": { "psr-4": { - "Ramsey\\Collection\\": "src/" + "Spatie\\Robots\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -3196,103 +5066,94 @@ ], "authors": [ { - "name": "Ben Ramsey", - "email": "ben@benramsey.com", - "homepage": "https://benramsey.com" + "name": "Brent Roose", + "email": "brent@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" } ], - "description": "A PHP library for representing and manipulating collections.", + "description": "Determine if a page may be crawled from robots.txt and robots meta tags", + "homepage": "https://github.com/spatie/robots-txt", "keywords": [ - "array", - "collection", - "hash", - "map", - "queue", - "set" + "robots-txt", + "spatie" ], "support": { - "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/2.1.1" + "issues": "https://github.com/spatie/robots-txt/issues", + "source": "https://github.com/spatie/robots-txt/tree/2.5.4" }, - "time": "2025-03-22T05:38:12+00:00" + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-25T07:59:20+00:00" }, { - "name": "ramsey/uuid", - "version": "4.9.2", + "name": "spatie/temporary-directory", + "version": "2.3.1", "source": { "type": "git", - "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "662e481d6ec07ef29fd05010433428851a42cd07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/662e481d6ec07ef29fd05010433428851a42cd07", + "reference": "662e481d6ec07ef29fd05010433428851a42cd07", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", - "php": "^8.0", - "ramsey/collection": "^1.2 || ^2.0" - }, - "replace": { - "rhumsaa/uuid": "self.version" + "php": "^8.0" }, "require-dev": { - "captainhook/captainhook": "^5.25", - "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "ergebnis/composer-normalize": "^2.47", - "mockery/mockery": "^1.6", - "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.6", - "php-mock/php-mock-mockery": "^1.5", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpbench/phpbench": "^1.2.14", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6", - "slevomat/coding-standard": "^8.18", - "squizlabs/php_codesniffer": "^3.13" - }, - "suggest": { - "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", - "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", - "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", - "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", - "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + "phpunit/phpunit": "^9.5" }, "type": "library", - "extra": { - "captainhook": { - "force-install": true - } - }, "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { - "Ramsey\\Uuid\\": "src/" + "Spatie\\TemporaryDirectory\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", "keywords": [ - "guid", - "identifier", - "uuid" + "php", + "spatie", + "temporary-directory" ], "support": { - "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.3.1" }, - "time": "2025-12-14T04:43:48+00:00" + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-01-12T07:42:22+00:00" }, { "name": "symfony/clock", @@ -3605,6 +5466,78 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/dom-crawler", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/487ba8fa43da9a8e6503fe939b45ecd96875410e", + "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-17T07:53:42+00:00" + }, { "name": "symfony/error-handler", "version": "v7.4.4", @@ -4796,6 +6729,86 @@ ], "time": "2025-01-02T08:10:11+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-php83", "version": "v1.33.0", diff --git a/config/activitylog.php b/config/activitylog.php new file mode 100644 index 0000000..f1262f5 --- /dev/null +++ b/config/activitylog.php @@ -0,0 +1,52 @@ + env('ACTIVITY_LOGGER_ENABLED', true), + + /* + * When the clean-command is executed, all recording activities older than + * the number of days specified here will be deleted. + */ + 'delete_records_older_than_days' => 365, + + /* + * If no log name is passed to the activity() helper + * we use this default log name. + */ + 'default_log_name' => 'default', + + /* + * You can specify an auth driver here that gets user models. + * If this is null we'll use the current Laravel auth driver. + */ + 'default_auth_driver' => null, + + /* + * If set to true, the subject returns soft deleted models. + */ + 'subject_returns_soft_deleted_models' => false, + + /* + * This model will be used to log activity. + * It should implement the Spatie\Activitylog\Contracts\Activity interface + * and extend Illuminate\Database\Eloquent\Model. + */ + 'activity_model' => \Spatie\Activitylog\Models\Activity::class, + + /* + * This is the name of the table that will be created by the migration and + * used by the Activity model shipped with this package. + */ + 'table_name' => env('ACTIVITY_LOGGER_TABLE_NAME', 'activity_log'), + + /* + * This is the database connection that will be used by the migration and + * the Activity model shipped with this package. In case it's not set + * Laravel's database.default will be used instead. + */ + 'database_connection' => env('ACTIVITY_LOGGER_DB_CONNECTION'), +]; diff --git a/config/horizon.php b/config/horizon.php new file mode 100644 index 0000000..32f8f0d --- /dev/null +++ b/config/horizon.php @@ -0,0 +1,254 @@ + env('HORIZON_NAME'), + + /* + |-------------------------------------------------------------------------- + | Horizon Domain + |-------------------------------------------------------------------------- + | + | This is the subdomain where Horizon will be accessible from. If this + | setting is null, Horizon will reside under the same domain as the + | application. Otherwise, this value will serve as the subdomain. + | + */ + + 'domain' => env('HORIZON_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Horizon Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Horizon will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => env('HORIZON_PATH', 'horizon'), + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Connection + |-------------------------------------------------------------------------- + | + | This is the name of the Redis connection where Horizon will store the + | meta information required for it to function. It includes the list + | of supervisors, failed jobs, job metrics, and other information. + | + */ + + 'use' => 'default', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Prefix + |-------------------------------------------------------------------------- + | + | This prefix will be used when storing all Horizon data in Redis. You + | may modify the prefix when you are running multiple installations + | of Horizon on the same server so that they don't have problems. + | + */ + + 'prefix' => env( + 'HORIZON_PREFIX', + Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:' + ), + + /* + |-------------------------------------------------------------------------- + | Horizon Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will get attached onto each Horizon route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Queue Wait Time Thresholds + |-------------------------------------------------------------------------- + | + | This option allows you to configure when the LongWaitDetected event + | will be fired. Every connection / queue combination may have its + | own, unique threshold (in seconds) before this event is fired. + | + */ + + 'waits' => [ + 'redis:default' => 60, + ], + + /* + |-------------------------------------------------------------------------- + | Job Trimming Times + |-------------------------------------------------------------------------- + | + | Here you can configure for how long (in minutes) you desire Horizon to + | persist the recent and failed jobs. Typically, recent jobs are kept + | for one hour while all failed jobs are stored for an entire week. + | + */ + + 'trim' => [ + 'recent' => 60, + 'pending' => 60, + 'completed' => 60, + 'recent_failed' => 10080, + 'failed' => 10080, + 'monitored' => 10080, + ], + + /* + |-------------------------------------------------------------------------- + | Silenced Jobs + |-------------------------------------------------------------------------- + | + | Silencing a job will instruct Horizon to not place the job in the list + | of completed jobs within the Horizon dashboard. This setting may be + | used to fully remove any noisy jobs from the completed jobs list. + | + */ + + 'silenced' => [ + // App\Jobs\ExampleJob::class, + ], + + 'silenced_tags' => [ + // 'notifications', + ], + + /* + |-------------------------------------------------------------------------- + | Metrics + |-------------------------------------------------------------------------- + | + | Here you can configure how many snapshots should be kept to display in + | the metrics graph. This will get used in combination with Horizon's + | `horizon:snapshot` schedule to define how long to retain metrics. + | + */ + + 'metrics' => [ + 'trim_snapshots' => [ + 'job' => 24, + 'queue' => 24, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Fast Termination + |-------------------------------------------------------------------------- + | + | When this option is enabled, Horizon's "terminate" command will not + | wait on all of the workers to terminate unless the --wait option + | is provided. Fast termination can shorten deployment delay by + | allowing a new instance of Horizon to start while the last + | instance will continue to terminate each of its workers. + | + */ + + 'fast_termination' => false, + + /* + |-------------------------------------------------------------------------- + | Memory Limit (MB) + |-------------------------------------------------------------------------- + | + | This value describes the maximum amount of memory the Horizon master + | supervisor may consume before it is terminated and restarted. For + | configuring these limits on your workers, see the next section. + | + */ + + 'memory_limit' => 64, + + /* + |-------------------------------------------------------------------------- + | Queue Worker Configuration + |-------------------------------------------------------------------------- + | + | Here you may define the queue worker settings used by your application + | in all environments. These supervisors and settings handle all your + | queued jobs and will be provisioned by Horizon during deployment. + | + */ + + 'defaults' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'maxProcesses' => 1, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 128, + 'tries' => 1, + 'timeout' => 60, + 'nice' => 0, + ], + ], + + 'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'maxProcesses' => 10, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], + ], + + 'local' => [ + 'supervisor-1' => [ + 'maxProcesses' => 3, + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | File Watcher Configuration + |-------------------------------------------------------------------------- + | + | The following list of directories and files will be watched when using + | the `horizon:listen` command. Whenever any directories or files are + | changed, Horizon will automatically restart to apply all changes. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config/**/*.php', + 'database/**/*.php', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + 'composer.json', + '.env', + ], +]; diff --git a/config/media-library.php b/config/media-library.php new file mode 100644 index 0000000..4439c20 --- /dev/null +++ b/config/media-library.php @@ -0,0 +1,303 @@ + env('MEDIA_DISK', 'public'), + + /* + * The maximum file size of an item in bytes. + * Adding a larger file will result in an exception. + */ + 'max_file_size' => 1024 * 1024 * 10, // 10MB + + /* + * This queue connection will be used to generate derived and responsive images. + * Leave empty to use the default queue connection. + */ + 'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'), + + /* + * This queue will be used to generate derived and responsive images. + * Leave empty to use the default queue. + */ + 'queue_name' => env('MEDIA_QUEUE', ''), + + /* + * By default all conversions will be performed on a queue. + */ + 'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', true), + + /* + * Should database transactions be run after database commits? + */ + 'queue_conversions_after_database_commit' => env('QUEUE_CONVERSIONS_AFTER_DB_COMMIT', true), + + /* + * The fully qualified class name of the media model. + */ + 'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class, + + /* + * The fully qualified class name of the media observer. + */ + 'media_observer' => Spatie\MediaLibrary\MediaCollections\Models\Observers\MediaObserver::class, + + /* + * When enabled, media collections will be serialised using the default + * laravel model serialization behaviour. + * + * Keep this option disabled if using Media Library Pro components (https://medialibrary.pro) + */ + 'use_default_collection_serialization' => false, + + /* + * The fully qualified class name of the model used for temporary uploads. + * + * This model is only used in Media Library Pro (https://medialibrary.pro) + */ + 'temporary_upload_model' => Spatie\MediaLibraryPro\Models\TemporaryUpload::class, + + /* + * When enabled, Media Library Pro will only process temporary uploads that were uploaded + * in the same session. You can opt to disable this for stateless usage of + * the pro components. + */ + 'enable_temporary_uploads_session_affinity' => true, + + /* + * When enabled, Media Library pro will generate thumbnails for uploaded file. + */ + 'generate_thumbnails_for_temporary_uploads' => true, + + /* + * This is the class that is responsible for naming generated files. + */ + 'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class, + + /* + * The class that contains the strategy for determining a media file's path. + */ + 'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class, + + /* + * The class that contains the strategy for determining how to remove files. + */ + 'file_remover_class' => Spatie\MediaLibrary\Support\FileRemover\DefaultFileRemover::class, + + /* + * Here you can specify which path generator should be used for the given class. + */ + 'custom_path_generators' => [ + // Model::class => PathGenerator::class + // or + // 'model_morph_alias' => PathGenerator::class + ], + + /* + * When urls to files get generated, this class will be called. Use the default + * if your files are stored locally above the site root or on s3. + */ + 'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class, + + /* + * Moves media on updating to keep path consistent. Enable it only with a custom + * PathGenerator that uses, for example, the media UUID. + */ + 'moves_media_on_update' => false, + + /* + * Whether to activate versioning when urls to files get generated. + * When activated, this attaches a ?v=xx query string to the URL. + */ + 'version_urls' => false, + + /* + * The media library will try to optimize all converted images by removing + * metadata and applying a little bit of compression. These are + * the optimizers that will be used by default. + */ + 'image_optimizers' => [ + Spatie\ImageOptimizer\Optimizers\Jpegoptim::class => [ + '-m85', // set maximum quality to 85% + '--force', // ensure that progressive generation is always done also if a little bigger + '--strip-all', // this strips out all text information such as comments and EXIF data + '--all-progressive', // this will make sure the resulting image is a progressive one + ], + Spatie\ImageOptimizer\Optimizers\Pngquant::class => [ + '--force', // required parameter for this package + ], + Spatie\ImageOptimizer\Optimizers\Optipng::class => [ + '-i0', // this will result in a non-interlaced, progressive scanned image + '-o2', // this set the optimization level to two (multiple IDAT compression trials) + '-quiet', // required parameter for this package + ], + Spatie\ImageOptimizer\Optimizers\Svgo::class => [ + '--disable=cleanupIDs', // disabling because it is known to cause troubles + ], + Spatie\ImageOptimizer\Optimizers\Gifsicle::class => [ + '-b', // required parameter for this package + '-O3', // this produces the slowest but best results + ], + Spatie\ImageOptimizer\Optimizers\Cwebp::class => [ + '-m 6', // for the slowest compression method in order to get the best compression. + '-pass 10', // for maximizing the amount of analysis pass. + '-mt', // multithreading for some speed improvements. + '-q 90', // quality factor that brings the least noticeable changes. + ], + Spatie\ImageOptimizer\Optimizers\Avifenc::class => [ + '-a cq-level=23', // constant quality level, lower values mean better quality and greater file size (0-63). + '-j all', // number of jobs (worker threads, "all" uses all available cores). + '--min 0', // min quantizer for color (0-63). + '--max 63', // max quantizer for color (0-63). + '--minalpha 0', // min quantizer for alpha (0-63). + '--maxalpha 63', // max quantizer for alpha (0-63). + '-a end-usage=q', // rate control mode set to Constant Quality mode. + '-a tune=ssim', // SSIM as tune the encoder for distortion metric. + ], + ], + + /* + * These generators will be used to create an image of media files. + */ + 'image_generators' => [ + Spatie\MediaLibrary\Conversions\ImageGenerators\Image::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Webp::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Avif::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Pdf::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Svg::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Video::class, + ], + + /* + * The path where to store temporary files while performing image conversions. + * If set to null, storage_path('media-library/temp') will be used. + */ + 'temporary_directory_path' => null, + + /* + * The engine that should perform the image conversions. + * Should be either `gd`, `imagick` or `vips`. + */ + 'image_driver' => env('IMAGE_DRIVER', 'gd'), + + /* + * FFMPEG & FFProbe binaries paths, only used if you try to generate video + * thumbnails and have installed the php-ffmpeg/php-ffmpeg composer + * dependency. + */ + 'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'), + 'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'), + + /* + * The timeout (in seconds) that will be used when generating video + * thumbnails via FFMPEG. + */ + 'ffmpeg_timeout' => env('FFMPEG_TIMEOUT', 900), + + /* + * The number of threads that FFMPEG should use. 0 means that FFMPEG + * may decide itself. + */ + 'ffmpeg_threads' => env('FFMPEG_THREADS', 0), + + /* + * Here you can override the class names of the jobs used by this package. Make sure + * your custom jobs extend the ones provided by the package. + */ + 'jobs' => [ + 'perform_conversions' => Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob::class, + 'generate_responsive_images' => Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob::class, + ], + + /* + * When using the addMediaFromUrl method you may want to replace the default downloader. + * This is particularly useful when the url of the image is behind a firewall and + * need to add additional flags, possibly using curl. + */ + 'media_downloader' => Spatie\MediaLibrary\Downloaders\DefaultDownloader::class, + + /* + * When using the addMediaFromUrl method the SSL is verified by default. + * This is option disables SSL verification when downloading remote media. + * Please note that this is a security risk and should only be false in a local environment. + */ + 'media_downloader_ssl' => env('MEDIA_DOWNLOADER_SSL', true), + + /* + * The default lifetime in minutes for temporary urls. + * This is used when you call the `getLastTemporaryUrl` or `getLastTemporaryUrl` method on a media item. + */ + 'temporary_url_default_lifetime' => env('MEDIA_TEMPORARY_URL_DEFAULT_LIFETIME', 5), + + 'remote' => [ + /* + * Any extra headers that should be included when uploading media to + * a remote disk. Even though supported headers may vary between + * different drivers, a sensible default has been provided. + * + * Supported by S3: CacheControl, Expires, StorageClass, + * ServerSideEncryption, Metadata, ACL, ContentEncoding + */ + 'extra_headers' => [ + 'CacheControl' => 'max-age=604800', + ], + ], + + 'responsive_images' => [ + /* + * This class is responsible for calculating the target widths of the responsive + * images. By default we optimize for filesize and create variations that each are 30% + * smaller than the previous one. More info in the documentation. + * + * https://docs.spatie.be/laravel-medialibrary/v9/advanced-usage/generating-responsive-images + */ + 'width_calculator' => Spatie\MediaLibrary\ResponsiveImages\WidthCalculator\FileSizeOptimizedWidthCalculator::class, + + /* + * By default rendering media to a responsive image will add some javascript and a tiny placeholder. + * This ensures that the browser can already determine the correct layout. + * When disabled, no tiny placeholder is generated. + */ + 'use_tiny_placeholders' => true, + + /* + * This class will generate the tiny placeholder used for progressive image loading. By default + * the media library will use a tiny blurred jpg image. + */ + 'tiny_placeholder_generator' => Spatie\MediaLibrary\ResponsiveImages\TinyPlaceholderGenerator\Blurred::class, + ], + + /* + * When enabling this option, a route will be registered that will enable + * the Media Library Pro Vue and React components to move uploaded files + * in a S3 bucket to their right place. + */ + 'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false), + + /* + * When converting Media instances to response the media library will add + * a `loading` attribute to the `img` tag. Here you can set the default + * value of that attribute. + * + * Possible values: 'lazy', 'eager', 'auto' or null if you don't want to set any loading instruction. + * + * More info: https://css-tricks.com/native-lazy-loading/ + */ + 'default_loading_attribute_value' => null, + + /* + * You can specify a prefix for that is used for storing all media. + * If you set this to `/my-subdir`, all your media will be stored in a `/my-subdir` directory. + */ + 'prefix' => env('MEDIA_PREFIX', ''), + + /* + * When forcing lazy loading, media will be loaded even if you don't eager load media and you have + * disabled lazy loading globally in the service provider. + */ + 'force_lazy_loading' => env('FORCE_MEDIA_LIBRARY_LAZY_LOADING', true), +]; diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..082ca30 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,202 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Spatie\Permission\Models\Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Spatie\Permission\Models\Role::class, + + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => \DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/config/responsecache.php b/config/responsecache.php new file mode 100644 index 0000000..9d691e9 --- /dev/null +++ b/config/responsecache.php @@ -0,0 +1,120 @@ + env('RESPONSE_CACHE_ENABLED', false), + + 'cache' => [ + /* + * Here you may define the cache store that should be used + * to store requests. This can be the name of any store + * that is configured in your app's cache.php config + */ + 'store' => env('RESPONSE_CACHE_DRIVER', 'file'), + + /* + * The default number of seconds responses will be cached + * when using the default CacheProfile settings. + */ + 'lifetime_in_seconds' => (int) env('RESPONSE_CACHE_LIFETIME', 300), + + /* + * If your cache driver supports tags, you may specify a tag + * name here. All responses will be tagged. When clearing + * the responsecache only items with that tag flushed. + * + * You may use a string or an array here. + */ + 'tag' => env('RESPONSE_CACHE_TAG', ''), + ], + + 'bypass' => [ + /* + * The header name that will force a bypass of the cache. + * This is useful when you want to see the performance + * of your application without the caching enabled. + */ + 'header_name' => env('CACHE_BYPASS_HEADER_NAME'), + + /* + * The header value that will force a cache bypass. + */ + 'header_value' => env('CACHE_BYPASS_HEADER_VALUE'), + ], + + 'debug' => [ + /* + * Determines if debug headers are added to cached + * responses. This can be handy for debugging how + * response caching is performing in your app. + */ + 'enabled' => env('APP_DEBUG', false), + + /* + * The name of the http header containing the + * point at which the response was cached. + */ + 'cache_time_header_name' => 'X-Cache-Time', + + /* + * The name of the header for the cache status that + * indicates whether a response was HIT or MISS. + */ + 'cache_status_header_name' => 'X-Cache-Status', + + /* + * The header name for the cache age in seconds. + */ + 'cache_age_header_name' => 'X-Cache-Age', + + /* + * The header name used for the response cache key. + * This is only added when app.debug is enabled. + */ + 'cache_key_header_name' => 'X-Cache-Key', + ], + + /* + * These query parameters will be ignored when generating + * the cache key. This is useful for ignoring tracking + * parameters like UTM tags, gclid and also fbclid. + */ + 'ignored_query_parameters' => [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'gclid', + 'fbclid', + ], + + /* + * The given class determines if a request should be cached. + * By default all successful GET-requests will be cached. + * You can provide your own by using the CacheProfile. + */ + 'cache_profile' => Spatie\ResponseCache\CacheProfiles\CacheAllSuccessfulGetRequests::class, + + /* + * This class is responsible for generating a hash for + * a request. Used for looking up cached responses. + */ + 'hasher' => \Spatie\ResponseCache\Hasher\DefaultHasher::class, + + /* + * This class is responsible for serializing responses. + */ + 'serializer' => \Spatie\ResponseCache\Serializers\JsonSerializer::class, + + /* + * Here you may define the replacers that will replace + * dynamic content from the response. Each replacer + * must always implement the Replacer interface. + */ + 'replacers' => [ + \Spatie\ResponseCache\Replacers\CsrfTokenReplacer::class, + ], +]; diff --git a/config/seotools.php b/config/seotools.php new file mode 100644 index 0000000..a109d7c --- /dev/null +++ b/config/seotools.php @@ -0,0 +1,69 @@ + env('SEO_TOOLS_INERTIA', false), + 'meta' => [ + /* + * The default configurations to be used by the meta generator. + */ + 'defaults' => [ + 'title' => "It's Over 9000!", // set false to total remove + 'titleBefore' => false, // Put defaults.title before page title, like 'It's Over 9000! - Dashboard' + 'description' => 'For those who helped create the Genki Dama', // set false to total remove + 'separator' => ' - ', + 'keywords' => [], + 'canonical' => false, // Set to null or 'full' to use Url::full(), set to 'current' to use Url::current(), set false to total remove + 'robots' => false, // Set to 'all', 'none' or any combination of index/noindex and follow/nofollow + ], + /* + * Webmaster tags are always added. + */ + 'webmaster_tags' => [ + 'google' => null, + 'bing' => null, + 'alexa' => null, + 'pinterest' => null, + 'yandex' => null, + 'norton' => null, + ], + + 'add_notranslate_class' => false, + ], + 'opengraph' => [ + /* + * The default configurations to be used by the opengraph generator. + */ + 'defaults' => [ + 'title' => 'Over 9000 Thousand!', // set false to total remove + 'description' => 'For those who helped create the Genki Dama', // set false to total remove + 'url' => false, // Set null for using Url::current(), set false to total remove + 'type' => false, + 'site_name' => false, + 'images' => [], + ], + ], + 'twitter' => [ + /* + * The default values to be used by the twitter cards generator. + */ + 'defaults' => [ + //'card' => 'summary', + //'site' => '@LuizVinicius73', + ], + ], + 'json-ld' => [ + /* + * The default configurations to be used by the json-ld generator. + */ + 'defaults' => [ + 'title' => 'Over 9000 Thousand!', // set false to total remove + 'description' => 'For those who helped create the Genki Dama', // set false to total remove + 'url' => false, // Set to null or 'full' to use Url::full(), set to 'current' to use Url::current(), set false to total remove + 'type' => 'WebPage', + 'images' => [], + ], + ], +]; diff --git a/config/sitemap.php b/config/sitemap.php new file mode 100644 index 0000000..69be0f3 --- /dev/null +++ b/config/sitemap.php @@ -0,0 +1,57 @@ + [ + + /* + * Whether or not cookies are used in a request. + */ + RequestOptions::COOKIES => true, + + /* + * The number of seconds to wait while trying to connect to a server. + * Use 0 to wait indefinitely. + */ + RequestOptions::CONNECT_TIMEOUT => 10, + + /* + * The timeout of the request in seconds. Use 0 to wait indefinitely. + */ + RequestOptions::TIMEOUT => 10, + + /* + * Describes the redirect behavior of a request. + */ + RequestOptions::ALLOW_REDIRECTS => false, + ], + + /* + * The sitemap generator can execute JavaScript on each page so it will + * discover links that are generated by your JS scripts. This feature + * is powered by headless Chrome. + */ + 'execute_javascript' => false, + + /* + * The package will make an educated guess as to where Google Chrome is installed. + * You can also manually pass its location here. + */ + 'chrome_binary_path' => null, + + /* + * The sitemap generator uses a CrawlProfile implementation to determine + * which urls should be crawled for the sitemap. + */ + 'crawl_profile' => Profile::class, + +]; diff --git a/database/migrations/2026_03_10_020226_create_tags_table.php b/database/migrations/2026_03_10_020226_create_tags_table.php new file mode 100644 index 0000000..bdb76e2 --- /dev/null +++ b/database/migrations/2026_03_10_020226_create_tags_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->timestamps(); + + $table->unique('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tags'); + } +}; diff --git a/database/migrations/2026_03_10_020230_create_categories_table.php b/database/migrations/2026_03_10_020230_create_categories_table.php new file mode 100644 index 0000000..f47762a --- /dev/null +++ b/database/migrations/2026_03_10_020230_create_categories_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->enum('status', ['active', 'inactive'])->default('inactive'); + $table->timestamps(); + + $table->unique('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2026_03_10_020231_create_articles_table.php b/database/migrations/2026_03_10_020231_create_articles_table.php new file mode 100644 index 0000000..73ba7fe --- /dev/null +++ b/database/migrations/2026_03_10_020231_create_articles_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->string('excerpt', 500)->nullable(); + $table->longText('content'); + $table->enum('status', ['draft', 'published'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index(['status', 'published_at']); + }); + + Schema::create('article_tag', function (Blueprint $table) { + $table->foreignId('article_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); + + $table->primary(['article_id', 'tag_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('article_tag'); + Schema::dropIfExists('articles'); + } +}; diff --git a/database/migrations/2026_03_10_020236_create_article_versions_table.php b/database/migrations/2026_03_10_020236_create_article_versions_table.php new file mode 100644 index 0000000..12570d5 --- /dev/null +++ b/database/migrations/2026_03_10_020236_create_article_versions_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('article_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('version_number'); + $table->longText('content'); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['article_id', 'version_number']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('article_versions'); + } +}; diff --git a/database/migrations/2026_03_10_020342_create_comments_table.php b/database/migrations/2026_03_10_020342_create_comments_table.php new file mode 100644 index 0000000..51cdf31 --- /dev/null +++ b/database/migrations/2026_03_10_020342_create_comments_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('article_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('author_name')->nullable(); + $table->string('author_email')->nullable(); + $table->text('content'); + $table->boolean('is_approved')->default(false); + $table->timestamps(); + + $table->index(['article_id', 'is_approved']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('comments'); + } +}; diff --git a/database/migrations/2026_03_10_020344_create_analytics_table.php b/database/migrations/2026_03_10_020344_create_analytics_table.php new file mode 100644 index 0000000..f97fab7 --- /dev/null +++ b/database/migrations/2026_03_10_020344_create_analytics_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('article_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('views')->default(0); + $table->unsignedBigInteger('likes')->default(0); + $table->unsignedBigInteger('shares')->default(0); + $table->date('tracked_date'); + $table->timestamps(); + + $table->unique(['article_id', 'tracked_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics'); + } +}; diff --git a/database/migrations/2026_03_10_020346_create_trendings_table.php b/database/migrations/2026_03_10_020346_create_trendings_table.php new file mode 100644 index 0000000..5e6c44f --- /dev/null +++ b/database/migrations/2026_03_10_020346_create_trendings_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('article_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('score')->default(0); + $table->unsignedInteger('rank')->nullable(); + $table->timestamp('calculated_at'); + $table->timestamps(); + + $table->index(['score', 'calculated_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('trendings'); + } +}; diff --git a/database/migrations/2026_03_10_020432_create_ads_table.php b/database/migrations/2026_03_10_020432_create_ads_table.php new file mode 100644 index 0000000..970f8eb --- /dev/null +++ b/database/migrations/2026_03_10_020432_create_ads_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('name'); + $table->string('placement'); + $table->string('image_url')->nullable(); + $table->string('target_url'); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('priority')->default(0); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + + $table->index(['placement', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ads'); + } +}; diff --git a/database/migrations/2026_03_10_022442_create_activity_log_table.php b/database/migrations/2026_03_10_022442_create_activity_log_table.php new file mode 100644 index 0000000..7c05bc8 --- /dev/null +++ b/database/migrations/2026_03_10_022442_create_activity_log_table.php @@ -0,0 +1,27 @@ +create(config('activitylog.table_name'), function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('log_name')->nullable(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->nullableMorphs('causer', 'causer'); + $table->json('properties')->nullable(); + $table->timestamps(); + $table->index('log_name'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name')); + } +} diff --git a/database/migrations/2026_03_10_022443_add_event_column_to_activity_log_table.php b/database/migrations/2026_03_10_022443_add_event_column_to_activity_log_table.php new file mode 100644 index 0000000..7b797fd --- /dev/null +++ b/database/migrations/2026_03_10_022443_add_event_column_to_activity_log_table.php @@ -0,0 +1,22 @@ +table(config('activitylog.table_name'), function (Blueprint $table) { + $table->string('event')->nullable()->after('subject_type'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { + $table->dropColumn('event'); + }); + } +} diff --git a/database/migrations/2026_03_10_022444_add_batch_uuid_column_to_activity_log_table.php b/database/migrations/2026_03_10_022444_add_batch_uuid_column_to_activity_log_table.php new file mode 100644 index 0000000..8f7db66 --- /dev/null +++ b/database/migrations/2026_03_10_022444_add_batch_uuid_column_to_activity_log_table.php @@ -0,0 +1,22 @@ +table(config('activitylog.table_name'), function (Blueprint $table) { + $table->uuid('batch_uuid')->nullable()->after('properties'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { + $table->dropColumn('batch_uuid'); + }); + } +} diff --git a/database/migrations/2026_03_10_022729_create_media_table.php b/database/migrations/2026_03_10_022729_create_media_table.php new file mode 100644 index 0000000..47a4be9 --- /dev/null +++ b/database/migrations/2026_03_10_022729_create_media_table.php @@ -0,0 +1,32 @@ +id(); + + $table->morphs('model'); + $table->uuid()->nullable()->unique(); + $table->string('collection_name'); + $table->string('name'); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('disk'); + $table->string('conversions_disk')->nullable(); + $table->unsignedBigInteger('size'); + $table->json('manipulations'); + $table->json('custom_properties'); + $table->json('generated_conversions'); + $table->json('responsive_images'); + $table->unsignedInteger('order_column')->nullable()->index(); + + $table->nullableTimestamps(); + }); + } +}; diff --git a/database/migrations/2026_03_10_030000_create_article_seos_table.php b/database/migrations/2026_03_10_030000_create_article_seos_table.php new file mode 100644 index 0000000..d76d495 --- /dev/null +++ b/database/migrations/2026_03_10_030000_create_article_seos_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('article_id')->constrained()->cascadeOnDelete(); + $table->string('title')->nullable(); + $table->string('description', 500)->nullable(); + $table->string('keywords')->nullable(); + $table->string('og_image')->nullable(); + $table->unsignedTinyInteger('score')->default(0); + $table->json('score_breakdown')->nullable(); + $table->json('warnings')->nullable(); + $table->timestamp('last_analyzed_at')->nullable(); + $table->timestamps(); + + $table->unique('article_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('article_seos'); + } +}; diff --git a/database/migrations/2026_03_10_035016_create_permission_tables.php b/database/migrations/2026_03_10_035016_create_permission_tables.php new file mode 100644 index 0000000..8986275 --- /dev/null +++ b/database/migrations/2026_03_10_035016_create_permission_tables.php @@ -0,0 +1,137 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/database/migrations/2026_03_10_210000_create_contact_submissions_table.php b/database/migrations/2026_03_10_210000_create_contact_submissions_table.php new file mode 100644 index 0000000..5c0125d --- /dev/null +++ b/database/migrations/2026_03_10_210000_create_contact_submissions_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('name', 120); + $table->string('email', 190); + $table->string('phone', 30); + $table->string('subject', 180); + $table->text('message'); + $table->enum('status', ['pending', 'processed'])->default('pending'); + $table->ipAddress('ip_address')->nullable(); + $table->date('submitted_date')->nullable(); + $table->timestamps(); + + $table->unique(['submitted_date', 'ip_address'], 'contact_submissions_date_ip_unique'); + $table->unique(['submitted_date', 'email'], 'contact_submissions_date_email_unique'); + $table->unique(['submitted_date', 'phone'], 'contact_submissions_date_phone_unique'); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('contact_submissions'); + } +}; diff --git a/database/seeders/ArticleSeeder.php b/database/seeders/ArticleSeeder.php new file mode 100644 index 0000000..8a9257c --- /dev/null +++ b/database/seeders/ArticleSeeder.php @@ -0,0 +1,89 @@ +where('email', 'test@example.com')->value('id'); + + $categories = collect([ + ['name' => 'Technology', 'description' => 'Tech news and tutorials'], + ['name' => 'Business', 'description' => 'Business and startup stories'], + ['name' => 'Marketing', 'description' => 'Marketing insights and playbooks'], + ['name' => 'Product', 'description' => 'Product management and growth'], + ])->map(function (array $item): Category { + return Category::query()->firstOrCreate( + ['slug' => Str::slug($item['name'])], + [ + 'name' => $item['name'], + 'description' => $item['description'], + 'status' => CategoryStatus::ACTIVE, + ] + ); + }); + + $tags = collect([ + 'laravel', + 'php', + 'seo', + 'redis', + 'meilisearch', + 'architecture', + 'performance', + 'devops', + ])->map(function (string $name): Tag { + return Tag::query()->firstOrCreate( + ['slug' => Str::slug($name)], + ['name' => Str::title($name)] + ); + }); + + $baseContent = `Theo Daum, tác phẩm lấy bối cảnh sau khi vương triều Cao Câu Ly sụp đổ, xoay quanh Chilseong (Park Bo Gum), một võ sĩ mất ký ức bị đưa vào đấu trường nô lệ để giành lấy thanh kiếm huyền thoại. Trong phim, Trấn Thành vào vai In Gwi (Nhân Quý) - tổng quản phủ An Đông đô hộ của nhà Đường, nhân vật có ảnh hưởng lớn đến cục diện chính trị ở miền Bắc. Trấn Thành trong buổi đọc kịch bản phim "Kal: Thanh kiếm của Godumakhan". Ảnh: Red Ice Entertainment/Solar Partners Tại buổi họp của đoàn phim đăng tải ngày 9/3, nghệ sĩ xuất hiện với trang phục tối màu, tập trung theo dõi kịch bản và trao đổi với êkíp. Các diễn viên còn thực hiện một số động tác chiến đấu để thống nhất nhịp độ hành động.Theo Star News, vai diễn của Trấn Thành ban đầu được giao cho tài tử Cha Seung Won. Tuy nhiên, do lịch quay dự kiến tháng 8/2025 bị lùi sang năm nay, diễn viên đã rút khỏi dự án.Ngoài Trấn Thành và Park Bo Gum, tác phẩm có sự tham gia của Joo Won, Jung Jae Young và Lee Sun Bin. Joo Won hóa thân Gye Pil Hyeok, chiến binh đối đầu Chilseong, có kỹ năng sử dụng song kiếm. Jung Jae Young đảm nhận nhân vật Heuksugang - thủ lĩnh lực lượng phục hưng Cao Câu Ly, giữ vai trò trung tâm trong bối cảnh thời cuộc hỗn loạn. Lee Sun Bin đóng Maya, thành viên của lực lượng phục hưng.Dự án do đạo diễn Kim Han Min - đứng sau các tác phẩm ăn khách như Đại thủy chiến, Thủy chiến đảo Hansan: Rồng trỗi dậy và Đại hải chiến Noryang - Biển chết - thực hiện. Tác phẩm dự kiến ra mắt vào năm 2027, hướng tới phát hành tại nhiều thị trường quốc tế, trong đó có Nhật Bản và Việt Nam.     Trailer 'Đại hải chiến Noryang - Biển chết' Trailer "Đại hải chiến Noryang - Biển chết" (2023), do Kim Han Min chỉ đạo. Video: Lotte Entertainment Vietnam Trấn Thành tên đầy đủ Huỳnh Trấn Thành, 39 tuổi, là diễn viên, người dẫn chương trình, nhà làm phim. Năm 2021, nghệ sĩ gây tiếng vang khi làm phim điện ảnh đầu tay Bố già, đoạt nhiều giải thưởng trong nước như Bông Sen Vàng, Cánh Diều Vàng. Năm 2023, anh phát hành Nhà bà Nữ, tác phẩm đầu tiên tự đạo diễn, đạt doanh thu cao nhất mọi thời tại phòng vé trong nước khi đó.Phim Mai của anh - ra mắt Tết Giáp Thìn 2024, dán nhãn 18+ (không dành cho khán giả dưới 18 tuổi) - từng là tác phẩm Việt ăn khách nhất phòng vé với 520 tỷ đồng, cho đến khi bị Mưa đỏ (đạo diễn Đặng Thái Huyền) phá kỷ lục. Dịp Tết Ất Tỵ 2025, Bộ tứ báo thủ thu về hơn 300 tỷ đồng, là một trong những phim Việt doanh thu cao nhất năm.Về đời tư, anh công khai yêu đương ca sĩ Hari Won vào tháng 2/2016. Cả hai kết hôn tháng 12/2016. Vợ chồng nghệ sĩ luôn sát cánh trong công việc.Cát Tiên (theo Nate, NC Press)`; + $sharedContent = substr(str_repeat($baseContent, 12), 0, 1000); + $descriptionContent = substr(str_repeat($baseContent, 12), 0, 200); + + for ($i = 1; $i <= 20; $i++) { + $title = sprintf('Trấn Thành đóng phim điện ảnh Hàn %02d', $i); + $slug = Str::slug($title); + + /** @var Category $category */ + $category = $categories->random(); + + $article = Article::query()->updateOrCreate( + ['slug' => $slug], + [ + 'category_id' => $category->id, + 'author_id' => $defaultAuthorId, + 'title' => $title, + 'slug' => $slug, + 'excerpt' => $descriptionContent, + 'content' => $sharedContent, + 'status' => ArticleStatus::PUBLISHED, + 'published_at' => now()->subDays(21 - $i), + ] + ); + + $article->tags()->sync( + $tags->random(rand(2, 4))->pluck('id')->all() + ); + + $article->seo()->updateOrCreate([], [ + 'title' => $title, + 'description' => $descriptionContent, + 'keywords' => 'laravel,content,seo,cache,permission', + 'og_image' => null, + ]); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..63e54f7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,11 +15,17 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); + User::query()->firstOrCreate( + ['email' => 'test@example.com'], + [ + 'name' => 'Test User', + 'password' => bcrypt('password'), + ] + ); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + RolePermissionSeeder::class, + ArticleSeeder::class, ]); } } diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php new file mode 100644 index 0000000..c9dfa88 --- /dev/null +++ b/database/seeders/RolePermissionSeeder.php @@ -0,0 +1,63 @@ +forgetCachedPermissions(); + + $permissions = [ + 'write articles', + 'review content', + 'publish articles', + 'access admin', + ]; + + foreach ($permissions as $permissionName) { + Permission::query()->firstOrCreate([ + 'name' => $permissionName, + 'guard_name' => 'web', + ]); + } + + $writerRole = Role::query()->firstOrCreate([ + 'name' => 'writer', + 'guard_name' => 'web', + ]); + $reviewerRole = Role::query()->firstOrCreate([ + 'name' => 'reviewer', + 'guard_name' => 'web', + ]); + $publisherRole = Role::query()->firstOrCreate([ + 'name' => 'publisher', + 'guard_name' => 'web', + ]); + $adminRole = Role::query()->firstOrCreate([ + 'name' => 'admin', + 'guard_name' => 'web', + ]); + + $writerRole->syncPermissions(['write articles']); + $reviewerRole->syncPermissions(['review content']); + $publisherRole->syncPermissions(['publish articles']); + $adminRole->syncPermissions($permissions); + + $adminUser = User::query()->where('email', 'test@example.com')->first(); + if ($adminUser) { + $adminUser->syncRoles(['admin']); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index e83b65b..04d438f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: environment: DB_CONNECTION: mysql DB_HOST: mysql - DB_PORT: 3306 + DB_PORT: 3307 DB_DATABASE: presskit DB_USERNAME: presskit DB_PASSWORD: presskit123 @@ -36,7 +36,7 @@ services: environment: DB_CONNECTION: mysql DB_HOST: mysql - DB_PORT: 3306 + DB_PORT: 3307 DB_DATABASE: presskit DB_USERNAME: presskit DB_PASSWORD: presskit123 @@ -62,7 +62,7 @@ services: environment: DB_CONNECTION: mysql DB_HOST: mysql - DB_PORT: 3306 + DB_PORT: 3307 DB_DATABASE: presskit DB_USERNAME: presskit DB_PASSWORD: presskit123 @@ -95,8 +95,9 @@ services: mysql: image: mysql:8.4 container_name: presskit_mysql + command: ["mysqld", "--port=3307"] ports: - - "3307:3306" + - "3307:3307" environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: presskit @@ -105,7 +106,7 @@ services: volumes: - mysql_data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot"] + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-P3307", "-proot"] interval: 10s timeout: 5s retries: 10 @@ -122,6 +123,19 @@ services: networks: - presskit_net + node: + image: node:22-alpine + container_name: presskit_node + working_dir: /var/www/html + command: ["sh", "-lc", "npm install && npm run dev -- --host 0.0.0.0 --port 5173"] + ports: + - "5173:5173" + volumes: + - ./:/var/www/html + - node_modules:/var/www/html/node_modules + networks: + - presskit_net + networks: presskit_net: driver: bridge @@ -129,3 +143,4 @@ networks: volumes: mysql_data: redis_data: + node_modules: diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index 069f87b..d3396d0 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -2,7 +2,29 @@ set -e if [ -f /var/www/html/artisan ]; then + # Ensure runtime directories exist and are writable for Laravel. + mkdir -p \ + /var/www/html/storage/logs \ + /var/www/html/storage/framework/cache \ + /var/www/html/storage/framework/sessions \ + /var/www/html/storage/framework/views \ + /var/www/html/bootstrap/cache + chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache || true + chmod -R a+rwX /var/www/html/storage /var/www/html/bootstrap/cache || true + + # Keep log file writable even when bind-mounted from host. + touch /var/www/html/storage/logs/laravel.log || true + chown www-data:www-data /var/www/html/storage/logs/laravel.log || true + chmod 666 /var/www/html/storage/logs/laravel.log || true + + # Non-fatal startup helpers for local/dev convenience. + php /var/www/html/artisan storage:link --force >/dev/null 2>&1 || true + php /var/www/html/artisan optimize:clear >/dev/null 2>&1 || true + + # Re-apply ownership after artisan writes runtime files. + chown -R www-data:www-data /var/www/html/bootstrap/cache /var/www/html/storage/framework/cache || true + chmod -R a+rwX /var/www/html/bootstrap/cache /var/www/html/storage/framework/cache || true fi exec "$@" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fd57e12 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2432 @@ +{ + "name": "html", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "lightgallery": "^2.8.3", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", + "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^7.0.0" + } + }, + "node_modules/lightgallery": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/lightgallery/-/lightgallery-2.9.0.tgz", + "integrity": "sha512-58Ud1DyhD2ao58t+kPEqSZrjFxg23tGd5ZKr75erm7q31g5xhUtWUJH3sTUkhHzlyJAKHj5eTrJ37HQRXG4Wbg==", + "dev": true, + "license": "GPLv3", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json index 7686b29..fad1d81 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "axios": "^1.11.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", + "lightgallery": "^2.8.3", "tailwindcss": "^4.0.0", "vite": "^7.0.7" } diff --git a/presskit-internal.png b/presskit-internal.png new file mode 100644 index 0000000..f53a41e Binary files /dev/null and b/presskit-internal.png differ diff --git a/public/images/no-content.jpg b/public/images/no-content.jpg new file mode 100644 index 0000000..1d0a664 Binary files /dev/null and b/public/images/no-content.jpg differ diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..2097dac --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,128 @@ + + + + https://presskit.internal + daily + 1.0 + + + https://presskit.internal/articles/sample-article-11 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-20 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-19 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-18 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-17 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-16 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-15 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-14 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-13 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-12 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-10 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-09 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-08 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-07 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-06 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-05 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-04 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-03 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-02 + 2026-03-10T04:11:48+00:00 + daily + 0.8 + + + https://presskit.internal/articles/sample-article-01 + 2026-03-10T04:11:47+00:00 + daily + 0.8 + + diff --git a/publish_article.md b/publish_article.md new file mode 100644 index 0000000..3e3d75d --- /dev/null +++ b/publish_article.md @@ -0,0 +1,20 @@ +# flow publish article + +Writer create article +↓ +Draft +↓ +Submit review +↓ +Reviewer approve +↓ +Publisher publish +↓ +Queue job +↓ +Generate static HTML +Generate AMP +Update Redis cache +Purge CDN +Update sitemap +Ping search engine \ No newline at end of file diff --git a/resources/css/contact.css b/resources/css/contact.css new file mode 100644 index 0000000..eff10fb --- /dev/null +++ b/resources/css/contact.css @@ -0,0 +1,124 @@ +.contact-modal { + position: fixed; + inset: 0; + z-index: 120; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(2, 6, 23, 0.62); + backdrop-filter: blur(2px); +} + +.contact-modal.is-open { + display: flex; +} + +.contact-modal__card { + width: min(460px, 100%); + border-radius: 1rem; + background: #fff; + border: 1px solid #e2e8f0; + padding: 1.25rem; + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.28); +} + +.dark .contact-modal__card { + background: #0f172a; + border-color: #334155; + color: #e2e8f0; +} + +.contact-modal__title { + font-size: 1.125rem; + font-weight: 800; + color: #0f172a; +} + +.contact-modal__header { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.contact-modal__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 9999px; + background: #e2e8f0; + color: #334155; + flex-shrink: 0; +} + +.contact-modal__icon.is-warning { + background: #fef3c7; + color: #b45309; +} + +.contact-modal__icon.is-loading { + background: #dbeafe; + color: #1d4ed8; +} + +.contact-modal__icon.is-success { + background: #d1fae5; + color: #047857; +} + +.contact-modal__icon.is-fail { + background: #fee2e2; + color: #b91c1c; +} + +.dark .contact-modal__title { + color: #f8fafc; +} + +.contact-modal__message { + margin-top: 0.5rem; + color: #475569; + font-size: 0.95rem; + line-height: 1.6; +} + +.dark .contact-modal__message { + color: #cbd5e1; +} + +.contact-modal__actions { + margin-top: 1rem; + display: flex; + justify-content: flex-end; +} + +.contact-modal__button { + border: 0; + border-radius: 0.65rem; + padding: 0.55rem 1rem; + background: #135bec; + color: #fff; + font-size: 0.875rem; + font-weight: 700; + cursor: pointer; +} + +.contact-modal__spinner { + width: 1rem; + height: 1rem; + border-radius: 9999px; + border: 2px solid #cbd5e1; + border-top-color: #135bec; + display: inline-block; + margin-right: 0.5rem; + animation: contact-spin 0.8s linear infinite; + vertical-align: -2px; +} + +@keyframes contact-spin { + to { + transform: rotate(360deg); + } +} diff --git a/resources/css/guest.css b/resources/css/guest.css new file mode 100644 index 0000000..5ee38ce --- /dev/null +++ b/resources/css/guest.css @@ -0,0 +1,117 @@ +:root { + --guest-font-sans: 'Be Vietnam Pro', system-ui, -apple-system, 'Segoe UI', sans-serif; + --guest-font-display: 'Merriweather', Georgia, serif; + --guest-size-body: 1rem; + --guest-size-body-lg: 1.125rem; + --guest-size-title: clamp(2rem, 2.8vw, 3rem); + --guest-size-subtitle: clamp(1.5rem, 2.2vw, 2.25rem); + --guest-size-card-title: clamp(1.25rem, 1.8vw, 1.75rem); + --guest-line-body: 1.75; +} + +.guest-site { + font-family: var(--guest-font-sans); + font-size: var(--guest-size-body); + line-height: var(--guest-line-body); +} + +.guest-content { + font-size: var(--guest-size-body); +} + +.guest-title { + font-family: var(--guest-font-display); + font-size: var(--guest-size-title); + line-height: 1.18; + letter-spacing: -0.01em; +} + +.guest-subtitle { + font-family: var(--guest-font-display); + font-size: var(--guest-size-subtitle); + line-height: 1.25; +} + +.guest-card-title { + font-size: var(--guest-size-card-title); + line-height: 1.3; +} + +.guest-lead { + font-size: var(--guest-size-body-lg); + line-height: 1.75; +} + +.guest-text { + font-size: var(--guest-size-body); + line-height: var(--guest-line-body); +} + +.guest-meta { + font-size: 0.875rem; + line-height: 1.5; +} + +.guest-site input::placeholder, +.guest-site textarea::placeholder { + color: rgb(148 163 184); + font-style: italic; + opacity: 1; +} + +.dark .guest-site input::placeholder, +.dark .guest-site textarea::placeholder { + color: rgb(148 163 184 / 0.78); +} + +.article-image-shell { + position: relative; + overflow: hidden; + background: rgb(241 245 249); +} + +.dark .article-image-shell { + background: rgb(30 41 59); +} + +.article-image-shell::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(110deg, rgba(255, 255, 255, 0) 20%, rgba(255, 255, 255, 0.6) 45%, rgba(255, 255, 255, 0) 70%); + transform: translateX(-100%); + animation: article-image-shimmer 1.4s infinite; + pointer-events: none; + z-index: 1; +} + +.article-image-shell.is-loaded::before { + animation: none; + opacity: 0; +} + +.article-lazy-image { + opacity: 0; + transition: opacity 0.35s ease; +} + +.article-image-shell.is-loaded .article-lazy-image { + opacity: 1; +} + +@keyframes article-image-shimmer { + to { + transform: translateX(100%); + } +} + +@media (max-width: 768px) { + .guest-site { + font-size: 15px; + } + + .guest-lead { + font-size: 1rem; + line-height: 1.7; + } +} diff --git a/resources/css/policy.css b/resources/css/policy.css new file mode 100644 index 0000000..fcc1e79 --- /dev/null +++ b/resources/css/policy.css @@ -0,0 +1,28 @@ +.policy-toc-link { + color: #475569; +} + +.policy-toc-link:hover { + background-color: #f1f5f9; + color: #0f766e; +} + +.policy-toc-link.is-active { + background-color: #ccfbf1; + color: #0f766e; + font-weight: 600; +} + +.dark .policy-toc-link { + color: #cbd5e1; +} + +.dark .policy-toc-link:hover { + background-color: rgba(51, 65, 85, 0.6); + color: #5eead4; +} + +.dark .policy-toc-link.is-active { + background-color: rgba(45, 212, 191, 0.16); + color: #5eead4; +} diff --git a/resources/js/app.js b/resources/js/app.js index e59d6a0..404d1fe 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1 +1,15 @@ import './bootstrap'; +import lightGallery from 'lightgallery'; +import 'lightgallery/css/lightgallery.css'; + +document.addEventListener('DOMContentLoaded', function () { + const galleryContainers = document.querySelectorAll('[data-lightgallery]'); + + galleryContainers.forEach(function (container) { + lightGallery(container, { + selector: '.js-lg-item', + download: false, + counter: true, + }); + }); +}); diff --git a/resources/js/contact.js b/resources/js/contact.js new file mode 100644 index 0000000..8de5336 --- /dev/null +++ b/resources/js/contact.js @@ -0,0 +1,83 @@ +document.addEventListener('DOMContentLoaded', function () { + const form = document.getElementById('contact-form'); + const modal = document.getElementById('contact-status-modal'); + + if (!form || !modal) { + return; + } + + const modalTitle = document.getElementById('contact-modal-title'); + const modalMessage = document.getElementById('contact-modal-message'); + const modalConfirm = document.getElementById('contact-modal-confirm'); + const modalSpinner = document.getElementById('contact-modal-spinner'); + const modalIcon = document.getElementById('contact-modal-icon'); + + const openModal = function (type, title, message) { + modalTitle.textContent = title; + modalMessage.textContent = message; + + modalIcon.className = 'material-symbols-outlined contact-modal__icon'; + + if (type === 'warning') { + modalIcon.textContent = 'warning'; + modalIcon.classList.add('is-warning'); + } else if (type === 'loading') { + modalIcon.textContent = 'hourglass_top'; + modalIcon.classList.add('is-loading'); + } else if (type === 'success') { + modalIcon.textContent = 'check_circle'; + modalIcon.classList.add('is-success'); + } else { + modalIcon.textContent = 'error'; + modalIcon.classList.add('is-fail'); + } + + const isLoading = type === 'loading'; + modalSpinner.classList.toggle('hidden', !isLoading); + modalConfirm.classList.toggle('hidden', isLoading); + + modal.classList.add('is-open'); + modal.setAttribute('aria-hidden', 'false'); + document.body.style.overflow = 'hidden'; + }; + + const closeModal = function () { + modal.classList.remove('is-open'); + modal.setAttribute('aria-hidden', 'true'); + document.body.style.overflow = ''; + }; + + modalConfirm.addEventListener('click', closeModal); + + const validateRequiredFields = function () { + const requiredFields = form.querySelectorAll('[name="name"], [name="email"], [name="phone"], [name="subject"], [name="message"]'); + + for (const field of requiredFields) { + if (!String(field.value || '').trim()) { + field.focus(); + return false; + } + } + + return true; + }; + + form.addEventListener('submit', function (event) { + if (!validateRequiredFields()) { + event.preventDefault(); + openModal('warning', 'Thiếu thông tin', 'Vui lòng nhập đầy đủ nội dung trước khi gửi tin nhắn.'); + return; + } + + openModal('loading', 'Đang gửi thông tin', 'Vui lòng đợi trong giây lát.'); + }); + + const submitStatus = form.dataset.submitStatus || ''; + const submitMessage = form.dataset.submitMessage || ''; + + if (submitStatus === 'success') { + openModal('success', 'Gửi thành công', submitMessage); + } else if (submitStatus === 'fail') { + openModal('fail', 'Gửi thất bại', submitMessage); + } +}); diff --git a/resources/js/policy.js b/resources/js/policy.js new file mode 100644 index 0000000..48990d7 --- /dev/null +++ b/resources/js/policy.js @@ -0,0 +1,68 @@ +document.addEventListener('DOMContentLoaded', function () { + const nav = document.getElementById('policy-nav'); + if (!nav) { + return; + } + + const offsetTop = 80; + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const navLinks = Array.from(nav.querySelectorAll('a[href^="#"]')); + const sections = navLinks + .map((link) => document.querySelector(link.getAttribute('href'))) + .filter(Boolean); + + const setActiveLink = function (hash) { + navLinks.forEach((link) => { + const isActive = link.getAttribute('href') === hash; + link.classList.toggle('is-active', isActive); + link.setAttribute('aria-current', isActive ? 'true' : 'false'); + }); + }; + + const initialHash = window.location.hash; + if (initialHash && nav.querySelector('a[href="' + initialHash + '"]')) { + setActiveLink(initialHash); + } + + navLinks.forEach((link) => { + link.addEventListener('click', function (event) { + const hash = link.getAttribute('href'); + const target = hash ? document.querySelector(hash) : null; + if (!target) { + return; + } + + event.preventDefault(); + setActiveLink(hash); + + const targetTop = target.getBoundingClientRect().top + window.pageYOffset - offsetTop; + window.scrollTo({ + top: Math.max(targetTop, 0), + behavior: prefersReducedMotion ? 'auto' : 'smooth', + }); + + history.replaceState(null, '', hash); + }); + }); + + if ('IntersectionObserver' in window && sections.length > 0) { + const observer = new IntersectionObserver( + (entries) => { + const visibleEntry = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; + + if (visibleEntry && visibleEntry.target.id) { + setActiveLink('#' + visibleEntry.target.id); + } + }, + { + root: null, + rootMargin: '-110px 0px -55% 0px', + threshold: 0.15, + } + ); + + sections.forEach((section) => observer.observe(section)); + } +}); diff --git a/resources/views/admin/articles/_form.blade.php b/resources/views/admin/articles/_form.blade.php new file mode 100644 index 0000000..4743abb --- /dev/null +++ b/resources/views/admin/articles/_form.blade.php @@ -0,0 +1,93 @@ +@csrf + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/resources/views/admin/articles/create.blade.php b/resources/views/admin/articles/create.blade.php new file mode 100644 index 0000000..274777d --- /dev/null +++ b/resources/views/admin/articles/create.blade.php @@ -0,0 +1,20 @@ +@extends('admin.layouts.app') + +@section('title', 'Create Article') + +@section('content') +

Create Article

+

Back to list

+ + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @include('admin.articles._form') +
+@endsection diff --git a/resources/views/admin/articles/edit.blade.php b/resources/views/admin/articles/edit.blade.php new file mode 100644 index 0000000..56304b1 --- /dev/null +++ b/resources/views/admin/articles/edit.blade.php @@ -0,0 +1,21 @@ +@extends('admin.layouts.app') + +@section('title', 'Edit Article') + +@section('content') +

Edit Article

+

Back to list

+ + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @method('PUT') + @include('admin.articles._form') +
+@endsection diff --git a/resources/views/admin/articles/history.blade.php b/resources/views/admin/articles/history.blade.php new file mode 100644 index 0000000..52aa67a --- /dev/null +++ b/resources/views/admin/articles/history.blade.php @@ -0,0 +1,54 @@ +@extends('admin.layouts.app') + +@section('title', 'Article History') + +@section('content') +

Back to article

+

History: {{ $article->title }}

+ + + + + + + + + + + + + + @forelse ($activities as $activity) + @php + $old = $activity->properties['old'] ?? []; + $new = $activity->properties['attributes'] ?? []; + @endphp + + + + + + + + + @empty + + + + @endforelse + +
TimeEventCauserOldNewRestore
{{ $activity->created_at?->toDateTimeString() }}{{ $activity->event ?? 'updated' }}{{ $activity->causer?->name ?? 'system' }}
{{ json_encode($old, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
{{ json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+ @if (!empty($old)) +
+ @csrf + +
+ @else + N/A + @endif +
No activity log found for this article.
+ +
+ {{ $activities->links() }} +
+@endsection diff --git a/resources/views/admin/articles/index.blade.php b/resources/views/admin/articles/index.blade.php new file mode 100644 index 0000000..185b367 --- /dev/null +++ b/resources/views/admin/articles/index.blade.php @@ -0,0 +1,49 @@ +@extends('admin.layouts.app') + +@section('title', 'Articles') + +@section('content') +

Articles

+ +

Create new article

+ + + + + + + + + + + + + @forelse ($articles as $article) + + + + + + + + @empty + + + + @endforelse + +
TitleCategoryAuthorStatusActions
{{ $article->title }}{{ $article->category?->name ?? 'N/A' }}{{ $article->author?->name ?? 'N/A' }}{{ ucfirst($article->status?->value ?? '') }} + View + Edit + History +
+ @csrf + @method('DELETE') + +
+
No articles found.
+ +
+ {{ $articles->links() }} +
+@endsection diff --git a/resources/views/admin/articles/show.blade.php b/resources/views/admin/articles/show.blade.php new file mode 100644 index 0000000..ae02840 --- /dev/null +++ b/resources/views/admin/articles/show.blade.php @@ -0,0 +1,127 @@ +@extends('admin.layouts.app') + +@section('title', $article->title) + +@section('head') + {!! \Artesaos\SEOTools\Facades\SEOTools::generate() !!} +@endsection + +@section('content') +

Back to list

+

View change history

+

Manage comments

+ +

{{ $article->title }}

+

Category: {{ $article->category?->name ?? 'N/A' }}

+

Author: {{ $article->author?->name ?? 'N/A' }}

+

Status: {{ ucfirst($article->status?->value ?? '') }}

+

Workflow: {{ ucfirst($article->workflow_status ?? 'draft') }}

+

Slug: {{ $article->slug }}

+ +

Publish Flow

+ @if ($article->workflow_status === 'draft') +
+ @csrf + +
+ @endif + + @if ($article->workflow_status === 'review') +
+ @csrf + +
+ @endif + + @if (in_array($article->workflow_status, ['approved', 'published'], true)) +
+ @csrf + +
+ @endif + +

Submitted for review at: {{ $article->submitted_for_review_at?->toDateTimeString() ?? 'N/A' }}

+

Reviewed at: {{ $article->reviewed_at?->toDateTimeString() ?? 'N/A' }}

+

Published by: {{ $article->publisher?->name ?? 'N/A' }}

+

Static HTML path: {{ $article->static_html_path ?? 'N/A' }}

+

AMP HTML path: {{ $article->amp_html_path ?? 'N/A' }}

+

Last publish job at: {{ $article->last_published_job_at?->toDateTimeString() ?? 'N/A' }}

+ +

SEO Analysis

+

Score: {{ $article->seo?->score ?? 0 }}/100

+

Analyzed at: {{ $article->seo?->last_analyzed_at?->toDateTimeString() ?? 'N/A' }}

+ + @if (!empty($article->seo?->warnings)) +

Warnings

+
    + @foreach ($article->seo->warnings as $warning) +
  • {{ $warning }}
  • + @endforeach +
+ @endif + + @if (!empty($article->seo?->score_breakdown)) +

Breakdown

+
    + @foreach ($article->seo->score_breakdown as $criterion => $point) +
  • {{ str_replace('_', ' ', ucfirst($criterion)) }}: {{ $point }}
  • + @endforeach +
+ @endif + +

Change Slug

+
+ @csrf + @method('PATCH') + + +
+

Published at: {{ $article->published_at?->toDateTimeString() ?? 'N/A' }}

+ +

Excerpt

+

{{ $article->excerpt ?: 'N/A' }}

+ +

Featured Image

+ @if ($article->getFirstMediaUrl('featured_image')) + Featured image + @else +

N/A

+ @endif + +

Gallery

+ @if ($article->getMedia('gallery')->isNotEmpty()) +
+ @foreach ($article->getMedia('gallery') as $media) + Gallery image + @endforeach +
+ @else +

N/A

+ @endif + +

Attachments

+ @if ($article->getMedia('attachments')->isNotEmpty()) +
    + @foreach ($article->getMedia('attachments') as $media) +
  • {{ $media->name }}
  • + @endforeach +
+ @else +

N/A

+ @endif + + @php($latestVersion = $article->versions()->latest('version_number')->first()) +

Latest Version Files

+ @if ($latestVersion && $latestVersion->getMedia('version_files')->isNotEmpty()) +
    + @foreach ($latestVersion->getMedia('version_files') as $media) +
  • {{ $media->name }}
  • + @endforeach +
+ @else +

N/A

+ @endif + +

Content

+
{!! nl2br(e($article->content)) !!}
+@endsection diff --git a/resources/views/admin/categories/_form.blade.php b/resources/views/admin/categories/_form.blade.php new file mode 100644 index 0000000..491d579 --- /dev/null +++ b/resources/views/admin/categories/_form.blade.php @@ -0,0 +1,28 @@ +@csrf + +
+ + + + + + +

Slug is generated automatically and kept stable after creation.

+ + +
diff --git a/resources/views/admin/categories/create.blade.php b/resources/views/admin/categories/create.blade.php new file mode 100644 index 0000000..53dfba3 --- /dev/null +++ b/resources/views/admin/categories/create.blade.php @@ -0,0 +1,20 @@ +@extends('admin.layouts.app') + +@section('title', 'Create Category') + +@section('content') +

Create Category

+

Back to list

+ + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @include('admin.categories._form') +
+@endsection diff --git a/resources/views/admin/categories/edit.blade.php b/resources/views/admin/categories/edit.blade.php new file mode 100644 index 0000000..446d865 --- /dev/null +++ b/resources/views/admin/categories/edit.blade.php @@ -0,0 +1,21 @@ +@extends('admin.layouts.app') + +@section('title', 'Edit Category') + +@section('content') +

Edit Category

+

Back to list

+ + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @method('PUT') + @include('admin.categories._form') +
+@endsection diff --git a/resources/views/admin/categories/index.blade.php b/resources/views/admin/categories/index.blade.php new file mode 100644 index 0000000..830253b --- /dev/null +++ b/resources/views/admin/categories/index.blade.php @@ -0,0 +1,45 @@ +@extends('admin.layouts.app') + +@section('title', 'Categories') + +@section('content') +

Categories

+ +

Create new category

+ + + + + + + + + + + + @forelse ($categories as $category) + + + + + + + @empty + + + + @endforelse + +
NameSlugStatusActions
{{ $category->name }}{{ $category->slug }}{{ ucfirst($category->status?->value ?? '') }} + Edit +
+ @csrf + @method('DELETE') + +
+
No categories found.
+ +
+ {{ $categories->links() }} +
+@endsection diff --git a/resources/views/admin/comments/_form.blade.php b/resources/views/admin/comments/_form.blade.php new file mode 100644 index 0000000..0bdfd42 --- /dev/null +++ b/resources/views/admin/comments/_form.blade.php @@ -0,0 +1,30 @@ +@csrf + +
+ + + + + + + + + + + +
diff --git a/resources/views/admin/comments/create.blade.php b/resources/views/admin/comments/create.blade.php new file mode 100644 index 0000000..a8b15b2 --- /dev/null +++ b/resources/views/admin/comments/create.blade.php @@ -0,0 +1,20 @@ +@extends('admin.layouts.app') + +@section('title', 'Create Comment') + +@section('content') +

Create Comment for: {{ $article->title }}

+

Back to comments list

+ + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @include('admin.comments._form') +
+@endsection diff --git a/resources/views/admin/comments/edit.blade.php b/resources/views/admin/comments/edit.blade.php new file mode 100644 index 0000000..87b95ce --- /dev/null +++ b/resources/views/admin/comments/edit.blade.php @@ -0,0 +1,21 @@ +@extends('admin.layouts.app') + +@section('title', 'Edit Comment') + +@section('content') +

Edit Comment #{{ $comment->id }} for: {{ $article->title }}

+

Back to comments list

+ + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @method('PUT') + @include('admin.comments._form') +
+@endsection diff --git a/resources/views/admin/comments/index.blade.php b/resources/views/admin/comments/index.blade.php new file mode 100644 index 0000000..ab627ed --- /dev/null +++ b/resources/views/admin/comments/index.blade.php @@ -0,0 +1,53 @@ +@extends('admin.layouts.app') + +@section('title', 'Comments - '.$article->title) + +@section('content') +

Comments for: {{ $article->title }}

+ +

+ Back to article + | + Create new comment +

+ + + + + + + + + + + + + + @forelse ($comments as $comment) + + + + + + + + + @empty + + + + @endforelse + +
IDAuthorEmailContentApprovedActions
{{ $comment->id }}{{ $comment->author_name ?: ($comment->user?->name ?? 'N/A') }}{{ $comment->author_email ?: ($comment->user?->email ?? 'N/A') }}{{ \Illuminate\Support\Str::limit($comment->content, 120) }}{{ $comment->is_approved ? 'Yes' : 'No' }} + Edit +
+ @csrf + @method('DELETE') + +
+
No comments found.
+ +
+ {{ $comments->links() }} +
+@endsection diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php new file mode 100644 index 0000000..bca1daf --- /dev/null +++ b/resources/views/admin/layouts/app.blade.php @@ -0,0 +1,25 @@ + + + + + + @yield('title', 'Admin') + + @yield('head') + + + @if (session('status')) +

{{ session('status') }}

+ @endif + + @yield('content') + + diff --git a/resources/views/admin/tags/_form.blade.php b/resources/views/admin/tags/_form.blade.php new file mode 100644 index 0000000..8dc9d32 --- /dev/null +++ b/resources/views/admin/tags/_form.blade.php @@ -0,0 +1,12 @@ +@csrf + +
+ + +

Slug is generated automatically when creating a tag.

+ + +
diff --git a/resources/views/admin/tags/create.blade.php b/resources/views/admin/tags/create.blade.php new file mode 100644 index 0000000..1190e70 --- /dev/null +++ b/resources/views/admin/tags/create.blade.php @@ -0,0 +1,20 @@ +@extends('admin.layouts.app') + +@section('title', 'Create Tag') + +@section('content') +

Create Tag

+

Back to list

+ + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @include('admin.tags._form') +
+@endsection diff --git a/resources/views/admin/tags/edit.blade.php b/resources/views/admin/tags/edit.blade.php new file mode 100644 index 0000000..f3fb712 --- /dev/null +++ b/resources/views/admin/tags/edit.blade.php @@ -0,0 +1,21 @@ +@extends('admin.layouts.app') + +@section('title', 'Edit Tag') + +@section('content') +

Edit Tag

+

Back to list

+ + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @method('PUT') + @include('admin.tags._form') +
+@endsection diff --git a/resources/views/admin/tags/index.blade.php b/resources/views/admin/tags/index.blade.php new file mode 100644 index 0000000..d05052d --- /dev/null +++ b/resources/views/admin/tags/index.blade.php @@ -0,0 +1,43 @@ +@extends('admin.layouts.app') + +@section('title', 'Tags') + +@section('content') +

Tags

+ +

Create new tag

+ + + + + + + + + + + @forelse ($tags as $tag) + + + + + + @empty + + + + @endforelse + +
NameSlugActions
{{ $tag->name }}{{ $tag->slug }} + Edit +
+ @csrf + @method('DELETE') + +
+
No tags found.
+ +
+ {{ $tags->links() }} +
+@endsection diff --git a/resources/views/guest/about.blade.php b/resources/views/guest/about.blade.php new file mode 100644 index 0000000..64a9270 --- /dev/null +++ b/resources/views/guest/about.blade.php @@ -0,0 +1,135 @@ +@extends('guest.layouts.app') + +@section('title', 'Về chúng tôi | '.config('app.name')) + +@section('content') +
+ {{ config('app.name') }} building +
+ +
+

Câu chuyện của chúng tôi

+

Về chúng tôi - {{ config('app.name') }}

+

+ Cung cấp tin tức trung thực, khách quan và nhanh chóng cho độc giả toàn cầu. + Chúng tôi cam kết mang lại những góc nhìn đa chiều về các sự kiện quan trọng nhất thế giới. +

+ +
+
+ +
+
+
+

Sứ mệnh của chúng tôi

+

+ Tại {{ config('app.name') }}, sứ mệnh của chúng tôi là mang đến những thông tin chính xác, + kịp thời và có chiều sâu. Chúng tôi tin rằng báo chí chất lượng là nền tảng + của một xã hội thông tin minh bạch. +

+

+ Mỗi bài viết đều trải qua quy trình kiểm chứng nghiêm ngặt để đảm bảo tính xác thực cao nhất trước khi đến tay độc giả. +

+
    +
  • verifiedXác thực nguồn tin đa lớp
  • +
  • verifiedPhân tích khách quan, không định kiến
  • +
  • verifiedCập nhật thời gian thực 24/7
  • +
+
+ + +
+
+ +
+
+
+

5M+

+

Độc giả hàng ngày

+
+
+

15+

+

Năm kinh nghiệm

+
+
+

120+

+

Giải thưởng báo chí

+
+
+

50+

+

Văn phòng đại diện

+
+
+
+ +
+
+
+

Đội ngũ của chúng tôi

+

Những người đứng sau những bản tin chất lượng, làm việc không mệt mỏi để mang đến sự thật cho độc giả.

+
+ +
+
+ + Lê Minh Tuấn + +

Lê Minh Tuấn

+

Tổng biên tập

+
+
+ + Nguyễn Thu Thủy + +

Nguyễn Thu Thủy

+

Phó tổng biên tập

+
+
+ + Trần Quốc Hùng + +

Trần Quốc Hùng

+

Trưởng ban Quốc tế

+
+
+ + Phạm Minh Anh + +

Phạm Minh Anh

+

Trưởng ban Công nghệ

+
+
+
+
+ +
+
+

Đăng ký nhận tin từ chúng tôi

+

Hãy là người đầu tiên nhận được những tin tức quan trọng nhất và các phân tích chuyên sâu hàng tuần.

+
+ + +
+
+
+@endsection diff --git a/resources/views/guest/articles/index.blade.php b/resources/views/guest/articles/index.blade.php new file mode 100644 index 0000000..85f59c9 --- /dev/null +++ b/resources/views/guest/articles/index.blade.php @@ -0,0 +1,135 @@ +@extends('guest.layouts.app') + +@section('title', $pageTitle ?? 'Tin tức mới nhất') + +@section('content') +@php + $heading = str_replace(['Danh muc: ', 'Chu de: '], '', $pageTitle ?? 'Tin tức mới nhất'); + $currentSort = $filters['sort'] ?? 'latest'; +@endphp + +
+
+
+
+

{{ $heading }}

+

Cập nhật tin tức cuộc tế mới nhất, phân tích sâu sắc về chính trị, kinh tế và những biến động toàn cầu.

+
+ +
+
+ +
+
+ @if ($featuredArticle) +
+ + {{ $featuredArticle->title }} +
+ Tiêu điểm +
+ +

+ {{ $featuredArticle->title }} +

+

{{ $featuredArticle->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($featuredArticle->content), 190) }}

+
+ {{ $featuredArticle->author?->name ?? config('app.name') }} + calendar_today{{ optional($featuredArticle->published_at)->format('d/m/Y') ?? 'Mới cập nhật' }} + chat_bubble{{ $featuredArticle->comments_count ?? 24 }} +
+
+ @endif + +
+ @forelse ($articles as $article) +
+ + {{ $article->title }} + +

+ {{ $article->title }} +

+

{{ $article->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($article->content), 140) }}

+

{{ $article->author?->name ?? config('app.name') }} · {{ optional($article->published_at)->format('d/m/Y') }}

+
+ @empty +

Không có bài viết phù hợp.

+ @endforelse +
+ + @if ($articles instanceof \Illuminate\Pagination\LengthAwarePaginator) +
+ + chevron_left + + + @for ($i = 1; $i <= $articles->lastPage(); $i++) + @if ($i <= 3 || $i === $articles->lastPage() || abs($i - $articles->currentPage()) <= 1) + {{ $i }} + @elseif ($i === 4) + ... + @endif + @endfor + + + chevron_right + +
+ @endif +
+ + +
+
+@endsection diff --git a/resources/views/guest/articles/search.blade.php b/resources/views/guest/articles/search.blade.php new file mode 100644 index 0000000..70b7241 --- /dev/null +++ b/resources/views/guest/articles/search.blade.php @@ -0,0 +1,139 @@ +@extends('guest.layouts.app') + +@section('title', "Kết quả tìm kiếm: {$searchKeyword} | ".config('app.name')) + +@section('content') +
+
+
+
+
+ search + +
+
+ +
+

+ Kết quả cho: '{{ $searchKeyword }}' +

+
+ Tất cả + Bài viết + Video + Podcast +
+
+ +
+ @forelse ($articles as $article) +
+ + {{ $article->title }} + + +
+

+ {{ $article->category?->name ?? 'Tin tức' }} +

+

+ {{ $article->title }} +

+

+ {{ $article->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($article->content), 180) }} +

+
+ calendar_today + {{ optional($article->published_at)->format('d/m/Y') ?? 'Mới cập nhật' }} + + {{ max(1, (int) ceil(str_word_count(strip_tags((string) $article->content)) / 220)) }} phút đọc +
+
+
+ @empty +
+ Không tìm thấy bài viết phù hợp với từ khóa {{ $searchKeyword }}. +
+ @endforelse +
+ + @if ($articles instanceof \Illuminate\Pagination\LengthAwarePaginator) +
+ + chevron_left + + + @for ($i = 1; $i <= $articles->lastPage(); $i++) + @if ($i <= 3 || $i === $articles->lastPage() || abs($i - $articles->currentPage()) <= 1) + {{ $i }} + @elseif ($i === 4) + ... + @endif + @endfor + + + chevron_right + +
+ @endif +
+ + +
+
+@endsection diff --git a/resources/views/guest/articles/show.blade.php b/resources/views/guest/articles/show.blade.php new file mode 100644 index 0000000..2b77f38 --- /dev/null +++ b/resources/views/guest/articles/show.blade.php @@ -0,0 +1,161 @@ +@extends('guest.layouts.app') + +@section('title', $article->seo?->title ?: $article->title) + +@section('head') + {!! \Artesaos\SEOTools\Facades\SEOTools::generate() !!} +@endsection + +@section('content') +
+ + +
+
+

{{ $article->title }}

+ +
+
+ {{ $article->author?->name ?? 'Tác giả' }} +
+

{{ $article->author?->name ?? config('app.name') }}

+

{{ optional($article->published_at)->format('d/m/Y') ?? optional($article->created_at)->format('d/m/Y') }} · {{ max(3, (int) str_word_count(strip_tags((string) $article->content)) / 220) }} phút đọc

+
+
+
+ + +
+
+ +
+
+ {{ $article->title }} +
+
{{ $article->excerpt ?: 'Bài viết chuyên sâu về xu hướng và tác động trong bối cảnh hiện tại.' }}
+
+ +
+

+ {!! nl2br(e((string) \Illuminate\Support\Str::limit(strip_tags($article->content), 2200, '...'))) !!} +

+ +

Phân tích chuyên sâu

+

+ Nội dung được biên tập theo hướng cân bằng giữa tốc độ cập nhật và độ chính xác thông tin, + đồng thời đặt trọng tâm vào bối cảnh, dữ liệu và tác động dài hạn đối với xã hội. +

+ +
+ "Báo chí chất lượng không chỉ cung cấp thông tin mà còn giúp độc giả hiểu đúng bản chất của sự kiện." +
+ +

Góc nhìn minh bạch

+

+ Chúng tôi ưu tiên tiêu chuẩn xác minh nhiều lớp, nguồn trích dẫn rõ ràng và quy trình biên tập độc lập, + nhằm giảm thiểu nhiễu thông tin và tăng giá trị tham khảo cho người đọc. +

+
+ +
+ @forelse ($article->tags as $tag) + {{ $tag->name }} + @empty + Tin tức + @endforelse +
+ +
+

+ forum + Bình luận ({{ $article->comments_count ?? 0 }}) +

+ +
+ person +
+ +
+ +
+
+
+
+
+ + +
+
+@endsection diff --git a/resources/views/guest/contact.blade.php b/resources/views/guest/contact.blade.php new file mode 100644 index 0000000..a0f313b --- /dev/null +++ b/resources/views/guest/contact.blade.php @@ -0,0 +1,139 @@ +@extends('guest.layouts.app') + +@section('title', 'Liên hệ | '.config('app.name')) + +@section('head') +@vite(['resources/css/contact.css', 'resources/js/contact.js']) +@endsection + +@section('content') +
+
+
+

Liên hệ tòa soạn

+

+ Chúng tôi luôn sẵn lòng lắng nghe ý kiến đóng góp, phản hồi và tin tức từ bạn. + Hãy kết nối với {{ config('app.name') }} qua các kênh dưới đây. +

+
+ +
+
+
+
+ + location_on + +

Trụ sở chính

+

Phường Phú Định, TP. Hồ Chí Minh, Việt Nam

+
+ +
+ + call + +

Số điện thoại

+

024 1234 5678 (Tổng đài)
024 8765 4321 (Hotline)

+
+ +
+ + mail + +

Email Tòa soạn

+

support@presskit.vn

+
+ +
+ + web + +

Email Quảng cáo

+

support@presskit.vn

+
+
+ +
+
+ +
+
+
+ +
+
+ @csrf +

Gửi tin nhắn cho chúng tôi

+ +
+ + + +
+ + + + + + + +

Bằng cách nhấn gửi, bạn đồng ý với Điều khoản sử dụng và Chính sách bảo mật của chúng tôi.

+
+
+
+
+
+ + +@endsection diff --git a/resources/views/guest/home/index.blade.php b/resources/views/guest/home/index.blade.php new file mode 100644 index 0000000..e71db84 --- /dev/null +++ b/resources/views/guest/home/index.blade.php @@ -0,0 +1,121 @@ +@extends('guest.layouts.app') + +@section('title', 'Tin tuc moi nhat') + +@section('content') +
+ @if ($featuredArticle) +
+
+ {{ $featuredArticle->title }} +
+
+ Nổi bật +

{{ $featuredArticle->title }}

+

{{ $featuredArticle->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($featuredArticle->content), 170) }}

+ + Đọc ngay arrow_forward + +
+
+
+ @endif + +
+
+
+

Bài viết mới nhất

+ Xem tất cả +
+ +
+ @forelse ($articles as $article) +
+ + {{ $article->title }} + +
+
+ @if ($article->category?->slug) + {{ $article->category->name }} + @else + Tin tức + @endif + + schedule + {{ $article->published_at?->diffForHumans() ?? 'Vừa xong' }} + +
+

+ {{ $article->title }} +

+

{{ $article->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($article->content), 170) }}

+
+
+ @empty +

Không có bài viết mới.

+ @endforelse +
+ +
+ + +
+
+@endsection diff --git a/resources/views/guest/layouts/app.blade.php b/resources/views/guest/layouts/app.blade.php new file mode 100644 index 0000000..a7cfba8 --- /dev/null +++ b/resources/views/guest/layouts/app.blade.php @@ -0,0 +1,20 @@ + + + + + + @yield('title', config('app.name')) + @include('guest.layouts.partials.styles') + @include('guest.layouts.partials.scripts') + @yield('head') + + + @include('guest.layouts.partials.header') + +
+ @yield('content') +
+ + @include('guest.layouts.partials.footer') + + diff --git a/resources/views/guest/layouts/partials/footer.blade.php b/resources/views/guest/layouts/partials/footer.blade.php new file mode 100644 index 0000000..b0e9b8e --- /dev/null +++ b/resources/views/guest/layouts/partials/footer.blade.php @@ -0,0 +1,59 @@ + + + \ No newline at end of file diff --git a/resources/views/guest/layouts/partials/header.blade.php b/resources/views/guest/layouts/partials/header.blade.php new file mode 100644 index 0000000..8b8e634 --- /dev/null +++ b/resources/views/guest/layouts/partials/header.blade.php @@ -0,0 +1,40 @@ +
+
+
+
+ + newspaper +

{{ config('app.name') }}

+
+ + +
+ +
+ + + +
+
+
+
\ No newline at end of file diff --git a/resources/views/guest/layouts/partials/scripts.blade.php b/resources/views/guest/layouts/partials/scripts.blade.php new file mode 100644 index 0000000..3ca644b --- /dev/null +++ b/resources/views/guest/layouts/partials/scripts.blade.php @@ -0,0 +1,80 @@ + + \ No newline at end of file diff --git a/resources/views/guest/layouts/partials/styles.blade.php b/resources/views/guest/layouts/partials/styles.blade.php new file mode 100644 index 0000000..42345da --- /dev/null +++ b/resources/views/guest/layouts/partials/styles.blade.php @@ -0,0 +1,14 @@ + + + +@vite(['resources/css/guest.css', 'resources/js/app.js']) + \ No newline at end of file diff --git a/resources/views/guest/policy.blade.php b/resources/views/guest/policy.blade.php new file mode 100644 index 0000000..60e739b --- /dev/null +++ b/resources/views/guest/policy.blade.php @@ -0,0 +1,106 @@ +@extends('guest.layouts.app') + +@section('title', 'Chính sách bảo mật | '.config('app.name')) + +@section('head') +@vite(['resources/css/policy.css', 'resources/js/policy.js']) +@endsection + +@section('content') +
+
+
+

Chính sách bảo mật

+

Cập nhật lần cuối: 24 tháng 5, 2024

+
+ +
+ + +
+
+ Tại {{ config('app.name') }}, chúng tôi cam kết bảo vệ quyền riêng tư và thông tin cá nhân của bạn. + Chính sách này giải thích cách chúng tôi thu thập, sử dụng và bảo vệ dữ liệu của bạn khi bạn truy cập trang web của chúng tôi. +
+ +
+
+

1. Thu thập thông tin cá nhân

+

Chúng tôi có thể thu thập các loại thông tin sau từ người dùng của mình:

+
    +
  • check_circleThông tin định danh: Tên, địa chỉ email, và thông tin liên hệ khi bạn đăng ký nhận bản tin hoặc gửi phản hồi.
  • +
  • check_circleDữ liệu kỹ thuật: Địa chỉ IP, loại trình duyệt, hệ điều hành và thông tin thiết bị được thu thập tự động.
  • +
  • check_circleDữ liệu sử dụng: Thông tin về cách bạn tương tác với các bài viết và chuyên mục của chúng tôi.
  • +
+
+ + + +
+

3. Quyền của người dùng

+

Bạn có các quyền sau đối với thông tin cá nhân của mình:

+
    +
  1. 1Quyền truy cập
    Yêu cầu bản sao dữ liệu cá nhân mà chúng tôi đang lưu trữ.
  2. +
  3. 2Quyền chỉnh sửa
    Yêu cầu sửa đổi bất kỳ thông tin nào không chính xác hoặc không đầy đủ.
  4. +
  5. 3Quyền xóa dữ liệu
    Yêu cầu chúng tôi xóa dữ liệu cá nhân của bạn trong một số trường hợp nhất định.
  6. +
+
+ +
+

4. Bảo mật dữ liệu

+

Chúng tôi áp dụng các biện pháp bảo mật tiêu chuẩn ngành, bao gồm mã hóa SSL và tường lửa, để bảo vệ thông tin cá nhân của bạn khỏi bị truy cập, tiết lộ hoặc phá hủy trái phép. Tuy nhiên, không có phương thức truyền tin qua Internet nào là an toàn 100%.

+
+ +
+

5. Liên hệ với chúng tôi

+
+

Nếu bạn có bất kỳ câu hỏi nào về Chính sách bảo mật này, vui lòng liên hệ với bộ phận pháp lý của chúng tôi:

+

mailsupport@presskit.vn

+

location_onPhường Phú Định, TP. Hồ Chí Minh, Việt Nam

+
+
+
+
+
+
+
+@endsection diff --git a/resources/views/guest/term.blade.php b/resources/views/guest/term.blade.php new file mode 100644 index 0000000..daf2232 --- /dev/null +++ b/resources/views/guest/term.blade.php @@ -0,0 +1,51 @@ +@extends('guest.layouts.app') + +@section('title', 'Điều khoản sử dụng | '.config('app.name')) + +@section('content') +
+
+
+

Trung tâm pháp lý

+

Quyền riêng tư và quyền lợi của bạn rất quan trọng với chúng tôi. Vui lòng đọc các chính sách được cập nhật dưới đây.

+

Cập nhật lần cuối: 24 tháng 10, 2023

+
+ +
+
+

1. Chấp thuận điều khoản

+

Chào mừng bạn đến với {{ config('app.name') }}. Khi truy cập hoặc sử dụng dịch vụ của chúng tôi, bạn đồng ý tuân thủ các Điều khoản sử dụng này. Nếu bạn không đồng ý với toàn bộ điều khoản và điều kiện được nêu tại đây, bạn không được phép sử dụng trang web hoặc dịch vụ.

+
+ +
+

2. Giấy phép sử dụng

+

Bạn được phép tải tạm thời một bản sao tài liệu (thông tin hoặc phần mềm) trên website {{ config('app.name') }} chỉ để xem cá nhân, phi thương mại và trong thời gian ngắn.

+
    +
  1. 1Chỉnh sửa hoặc sao chép tài liệu cho mục đích thương mại.
  2. +
  3. 2Sử dụng tài liệu cho bất kỳ hình thức trình chiếu công khai nào (thương mại hoặc phi thương mại).
  4. +
  5. 3Cố gắng dịch ngược, giải mã hoặc can thiệp vào bất kỳ phần mềm nào có trên website.
  6. +
+
+ +
+

3. Độ chính xác nội dung

+

Các tài liệu xuất hiện trên website {{ config('app.name') }} có thể bao gồm lỗi kỹ thuật, lỗi đánh máy hoặc lỗi hình ảnh. {{ config('app.name') }} không đảm bảo rằng mọi nội dung trên website luôn chính xác, đầy đủ hoặc cập nhật tại mọi thời điểm. Chúng tôi có thể thay đổi nội dung trên website bất cứ lúc nào mà không cần thông báo trước.

+
+ +
+

4. Liên kết ngoài

+

{{ config('app.name') }} không xem xét toàn bộ các trang web được liên kết từ website của mình và không chịu trách nhiệm về nội dung của các trang đó. Việc xuất hiện liên kết không đồng nghĩa {{ config('app.name') }} xác nhận hoặc bảo trợ cho website liên kết. Người dùng tự chịu rủi ro khi truy cập các website bên ngoài.

+
+
+ +
+

Bạn có thắc mắc về điều khoản?

+

Nếu bạn có bất kỳ câu hỏi hoặc quan ngại nào liên quan đến tài liệu pháp lý của chúng tôi, vui lòng liên hệ bộ phận pháp chế.

+ + mail + support@presskit.vn + +
+
+
+@endsection diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index b7355d7..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,277 +0,0 @@ - - - - - - - {{ config('app.name', 'Laravel') }} - - - - - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{-- Dark Mode 12 SVG --}} - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 0000000..61b953e --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,21 @@ +name('admin.')->group(function (): void { + Route::get('articles/{article}/history', [ArticleController::class, 'history'])->name('articles.history'); + Route::post('articles/{article}/history/{activity}/restore', [ArticleController::class, 'restore'])->name('articles.history.restore'); + Route::patch('articles/{article}/slug', [ArticleController::class, 'updateSlug'])->name('articles.slug.update'); + Route::post('articles/{article}/submit-review', [ArticleController::class, 'submitForReview'])->name('articles.submit-review'); + Route::post('articles/{article}/approve', [ArticleController::class, 'approve'])->name('articles.approve'); + Route::post('articles/{article}/publish', [ArticleController::class, 'publish'])->name('articles.publish'); + + Route::resource('articles', ArticleController::class); + Route::resource('articles.comments', CommentController::class)->except(['show']); + Route::resource('categories', CategoryController::class)->except(['show']); + Route::resource('tags', TagController::class)->except(['show']); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..452cd16 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,13 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('sitemap:generate')->dailyAt('01:00'); +Schedule::command('hot:flush-views')->everyFiveMinutes(); +Schedule::command('hot:calculate-score')->hourly(); diff --git a/routes/web.php b/routes/web.php index 86a06c5..c85c069 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,45 @@ middleware('cacheResponse') + ->name('home'); + +// Static guest pages from resources/views/guest/* +Route::controller(PageController::class)->middleware('cacheResponse')->group(function (): void { + Route::get('about', 'about')->name('about'); + Route::get('contact', 'contact')->name('contact'); + Route::get('term', 'term')->name('term'); + Route::get('policy', 'policy')->name('policy'); }); + +Route::post('contact', [ContactSubmissionController::class, 'store']) + ->middleware('throttle:20,1') + ->name('contact.submit'); + +// Article pages +Route::get('articles', [ArticleController::class, 'index']) + ->middleware('cacheResponse') + ->name('articles.index'); + +Route::get('chu-de/{slug}', [ArticleController::class, 'byTag']) + ->middleware('cacheResponse') + ->name('tags.articles'); + +Route::get('{article}.html', [ArticleController::class, 'show']) + ->where('article', '[A-Za-z0-9\-]+') + ->middleware('cacheResponse') + ->name('articles.show'); + +Route::get('{slug}', [ArticleController::class, 'byCategory']) + ->where('slug', '^(?!(about|contact|term|policy|articles|chu-de)$)[A-Za-z0-9\-]+') + ->middleware('cacheResponse') + ->name('categories.articles'); + +require __DIR__.'/admin.php'; diff --git a/seo.md b/seo.md new file mode 100644 index 0000000..4cb5f71 --- /dev/null +++ b/seo.md @@ -0,0 +1,55 @@ +# Các tiêu chí chấm điểm SEO +Tiêu chí Điểm tối đa +Title length 10 +Meta description 15 +Keyword in title 10 +Keyword in meta 10 +Keyword density 10 +Content length 10 +Heading structure 10 +Internal links 10 +Image alt 10 +URL slug 5 + +=> Tổng điểm là 100 + +# Score Title +SEO tốt khi title 50–60 ký tự + +# Score Meta Description +SEO tốt khi meta description 120–160 ký tự + +# Keyword trong Title + +# Keyword trong Meta + +# Keyword Density +SEO tốt khoảng 1% – 2% + +# Content Length +SEO tốt khi content dài hơn 800 từ + +# Heading Structure +H1: 1 lần, chứa keyword chính +H2: 2-3 lần, chứa keyword phụ +H3: 3-5 lần, chứa keyword liên quan + +# Internal Links +SEO tốt khi có >= 2 internal links trong bài viết + +# Image Alt +SEO tốt khi tất cả hình ảnh đều có alt text chứa keyword + +# URL Slug +SEO tốt khi URL slug ngắn gọn, chứa keyword chính, không có stop words (the, a, an, in, on, etc.) có độ dài từ 3-8 từ + +# workflow tối ưu SEO +Editor viết bài + ↓ +Save article + ↓ +SEO Analyzer chạy + ↓ +Tính SEO Score + ↓ +Hiển thị warning \ No newline at end of file diff --git a/storage/app/.gitignore b/storage/app/.gitignore old mode 100644 new mode 100755 diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore old mode 100644 new mode 100755 diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore old mode 100644 new mode 100755 diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore old mode 100644 new mode 100755 diff --git a/tech.md b/tech.md new file mode 100644 index 0000000..bd442bd --- /dev/null +++ b/tech.md @@ -0,0 +1,46 @@ +# Kiến trúc kỹ thuật +Laravel + ↓ +MySQL (data) +Redis (cache + ranking) +Meilisearch (search) +Queue + Horizon +CDN + +# Danh sách model +articles +article_versions +tags +categories +comments +analytics +trending +ads + +# Danh sách Feature + ├ Article: Tạo, chỉnh sửa, xóa bài viết + ├ Category: Tìm kiếm bài viết theo category + ├ Tag: Tìm kiếm bài viết theo tag + ├ SEO: Tối ưu hóa bài viết cho công cụ tìm kiếm + ├ Analytics: Theo dõi lượt xem, tương tác của bài viết + ├ Trending: Hiển thị các bài viết đang hot + ├ Comment: Cho phép người dùng bình luận bài viết + ├ Media: Quản lý hình ảnh, video cho bài viết + ├ Ads: Quản lý quảng cáo hiển thị trên trang + ├ AI: Tự động gợi ý nội dung, tối ưu hóa SEO + ├ Search: Tìm kiếm bài viết theo từ khóa + + # Recommended composer packages list + "laravel/sanctum": "^4.0", + "spatie/laravel-permission": "^6.0", + "spatie/laravel-activitylog": "^4.0", + "spatie/laravel-sluggable": "^3.0", + "spatie/laravel-medialibrary": "^11.0", + "spatie/laravel-sitemap": "^7.0", + "artesaos/seotools": "^1.2", + "laravel/scout": "^10.0", + "predis/predis": "^2.0", + "symfony/dom-crawler": "^7.0" + + # command + - Clear response cache: php artisan responsecache:clear \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index f35b4e7..caf8f16 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,7 +5,15 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], + input: [ + 'resources/css/app.css', + 'resources/css/guest.css', + 'resources/css/contact.css', + 'resources/css/policy.css', + 'resources/js/app.js', + 'resources/js/contact.js', + 'resources/js/policy.js', + ], refresh: true, }), tailwindcss(),