diff --git a/app/Http/Controllers/Guest/ArticleController.php b/app/Http/Controllers/Guest/ArticleController.php index 46f39b9..80ac83e 100644 --- a/app/Http/Controllers/Guest/ArticleController.php +++ b/app/Http/Controllers/Guest/ArticleController.php @@ -2,30 +2,20 @@ namespace App\Http\Controllers\Guest; -use App\Enums\ArticleStatus; -use App\Enums\CategoryStatus; use App\Http\Controllers\Controller; use App\Http\Requests\Guest\SearchArticlesRequest; use App\Http\Requests\Guest\ShowArticleRequest; use App\Jobs\IncrementArticleViewJob; -use App\Models\Article; -use App\Models\Category; -use App\Models\Tag; -use App\Models\Trending; -use App\Services\ArticleFilterCacheService; +use App\Services\GuestArticleService; use App\Services\SeoService; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\View\View; class ArticleController extends Controller { - /** - * Create a new guest article controller instance. - */ public function __construct( + private readonly GuestArticleService $guestArticleService, private readonly SeoService $seoService, - private readonly ArticleFilterCacheService $articleFilterCacheService ) { } @@ -42,7 +32,7 @@ public function index(SearchArticlesRequest $request): View ]; $isSearching = $filters['keyword'] !== ''; - $data = $this->buildListingData($filters, !$isSearching); + $data = $this->guestArticleService->getListingData($filters, !$isSearching); if ($isSearching) { $data['searchKeyword'] = $filters['keyword']; @@ -58,48 +48,12 @@ public function index(SearchArticlesRequest $request): View */ 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(); + $data = $this->guestArticleService->getShowData($article); - $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(); + IncrementArticleViewJob::dispatch($data['article']->id); + $this->seoService->applyForArticle($data['article']); - $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')); + return view('guest.articles.show', $data); } /** @@ -107,20 +61,13 @@ public function show(ShowArticleRequest $request, string $article): View */ 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; + $data = $this->guestArticleService->getCategoryListingData($slug, $filters); return view('guest.articles.index', $data); } @@ -130,143 +77,14 @@ public function byCategory(Request $request, string $slug): View */ 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; + $data = $this->guestArticleService->getTagListingData($slug, $filters); 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/Repositories/ArticleRepository.php b/app/Repositories/ArticleRepository.php index 924a7c6..80c39ea 100644 --- a/app/Repositories/ArticleRepository.php +++ b/app/Repositories/ArticleRepository.php @@ -2,11 +2,17 @@ namespace App\Repositories; +use App\Enums\ArticleStatus; use App\Models\Article; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; class ArticleRepository { + /** + * Paginate articles ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 10): LengthAwarePaginator { return Article::query() @@ -15,11 +21,17 @@ public function paginateLatest(int $perPage = 10): LengthAwarePaginator ->paginate($perPage); } + /** + * Create a new article record with the given attributes. + */ public function create(array $data): Article { return Article::query()->create($data); } + /** + * Update an existing article with the given attributes and return the refreshed instance. + */ public function update(Article $article, array $data): Article { $article->update($data); @@ -27,8 +39,103 @@ public function update(Article $article, array $data): Article return $article->refresh(); } + /** + * Delete the given article from the database. + */ public function delete(Article $article): void { $article->delete(); } + + // ── Guest read methods ───────────────────────────────────────────────────── + + /** + * Find a published article by its slug or throw a 404 exception. + * Eager-loads category, SEO, media, versions, author, and tags. + */ + public function findPublishedBySlug(string $slug): Article + { + return Article::query() + ->with(['category', 'seo', 'media', 'versions.media', 'author', 'tags']) + ->withCount('comments') + ->where('slug', $slug) + ->where('status', ArticleStatus::PUBLISHED->value) + ->firstOrFail(); + } + + /** + * Retrieve articles related to the given article by shared tags. + * Falls back to same category when the article has no tags. + * + * @param int $limit Maximum number of articles to return. + */ + public function getRelatedArticles(Article $article, int $limit = 3): Collection + { + $tagIds = $article->tags->pluck('id')->all(); + + return 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($limit) + ->get(); + } + + /** + * Build a base Eloquent query for published articles applying keyword, tag, + * category, and sort filters. The returned Builder can be further decorated + * (e.g. paginate, limit) by the caller. + * + * Supported filter keys: + * - keyword (string) Full-text search across title, excerpt, content. + * - tag (string) Filter by tag slug. + * - category (string) Filter by category slug. + * - sort (string) One of: latest | popular | featured. + */ + public function buildPublishedFilteredQuery(array $filters): Builder + { + $query = Article::query() + ->with(['category', 'tags', 'media']) + ->withCount('comments') + ->withSum('analytics as total_views', 'views') + ->where('status', ArticleStatus::PUBLISHED->value); + + $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']); + }) + ); + + match ($filters['sort'] ?? 'latest') { + 'popular' => $query->orderByDesc('total_views')->latest('published_at'), + 'featured' => $query->orderByDesc('comments_count')->orderByDesc('total_views')->latest('published_at'), + default => $query->latest('published_at'), + }; + + return $query; + } } diff --git a/app/Repositories/CategoryRepository.php b/app/Repositories/CategoryRepository.php index 39e1573..cd771ae 100644 --- a/app/Repositories/CategoryRepository.php +++ b/app/Repositories/CategoryRepository.php @@ -2,11 +2,18 @@ namespace App\Repositories; +use App\Enums\ArticleStatus; +use App\Enums\CategoryStatus; use App\Models\Category; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; class CategoryRepository { + /** + * Paginate categories ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 15): LengthAwarePaginator { return Category::query() @@ -14,11 +21,17 @@ public function paginateLatest(int $perPage = 15): LengthAwarePaginator ->paginate($perPage); } + /** + * Create a new category record with the given attributes. + */ public function create(array $data): Category { return Category::query()->create($data); } + /** + * Update an existing category with the given attributes and return the refreshed instance. + */ public function update(Category $category, array $data): Category { $category->update($data); @@ -26,8 +39,40 @@ public function update(Category $category, array $data): Category return $category->refresh(); } + /** + * Delete the given category from the database. + */ public function delete(Category $category): void { $category->delete(); } + + /** + * Find an active category by its slug or throw a 404 exception. + */ + public function findActiveBySlugOrFail(string $slug): Category + { + return Category::query() + ->where('slug', $slug) + ->where('status', CategoryStatus::ACTIVE->value) + ->firstOrFail(); + } + + /** + * Retrieve active categories with their published article count, + * ordered by article count descending. + * + * @param int $limit Maximum number of categories to return. + */ + public function getActiveWithPublishedArticleCount(int $limit = 4): Collection + { + return Category::query() + ->where('status', CategoryStatus::ACTIVE->value) + ->withCount([ + 'articles' => fn (Builder $query) => $query->where('status', ArticleStatus::PUBLISHED->value), + ]) + ->orderByDesc('articles_count') + ->limit($limit) + ->get(['id', 'name', 'slug']); + } } diff --git a/app/Repositories/CommentRepository.php b/app/Repositories/CommentRepository.php index ccaa482..b1619ba 100644 --- a/app/Repositories/CommentRepository.php +++ b/app/Repositories/CommentRepository.php @@ -8,6 +8,9 @@ class CommentRepository { + /** + * Paginate comments for the given article, newest first, with the author eager-loaded. + */ public function paginateByArticle(Article $article, int $perPage = 20): LengthAwarePaginator { return Comment::query() @@ -17,11 +20,17 @@ public function paginateByArticle(Article $article, int $perPage = 20): LengthAw ->paginate($perPage); } + /** + * Create a new comment record with the given attributes. + */ public function create(array $data): Comment { return Comment::query()->create($data); } + /** + * Update an existing comment with the given attributes and return the refreshed instance. + */ public function update(Comment $comment, array $data): Comment { $comment->update($data); @@ -29,6 +38,9 @@ public function update(Comment $comment, array $data): Comment return $comment->refresh(); } + /** + * Delete the given comment from the database. + */ public function delete(Comment $comment): void { $comment->delete(); diff --git a/app/Repositories/ContactSubmissionRepository.php b/app/Repositories/ContactSubmissionRepository.php index 93934d2..f0e6330 100644 --- a/app/Repositories/ContactSubmissionRepository.php +++ b/app/Repositories/ContactSubmissionRepository.php @@ -6,6 +6,15 @@ class ContactSubmissionRepository { + /** + * Find an existing submission from the same day that shares any of: + * IP address, email, or phone — used to detect duplicate submissions. + * + * @param string $submittedDate Date string (Y-m-d) of the submission. + * @param string $ipAddress Client IP address. + * @param string $email Submitted email address. + * @param string $phone Submitted phone number. + */ public function findDailyDuplicate(string $submittedDate, string $ipAddress, string $email, string $phone): ?ContactSubmission { return ContactSubmission::query() @@ -19,6 +28,9 @@ public function findDailyDuplicate(string $submittedDate, string $ipAddress, str ->first(); } + /** + * Create a new contact submission record with the given attributes. + */ public function create(array $data): ContactSubmission { return ContactSubmission::query()->create($data); diff --git a/app/Repositories/TagRepository.php b/app/Repositories/TagRepository.php index b10cbb2..a1b8ba0 100644 --- a/app/Repositories/TagRepository.php +++ b/app/Repositories/TagRepository.php @@ -4,9 +4,13 @@ use App\Models\Tag; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; class TagRepository { + /** + * Paginate tags ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 15): LengthAwarePaginator { return Tag::query() @@ -14,11 +18,17 @@ public function paginateLatest(int $perPage = 15): LengthAwarePaginator ->paginate($perPage); } + /** + * Create a new tag record with the given attributes. + */ public function create(array $data): Tag { return Tag::query()->create($data); } + /** + * Update an existing tag with the given attributes and return the refreshed instance. + */ public function update(Tag $tag, array $data): Tag { $tag->update($data); @@ -26,8 +36,34 @@ public function update(Tag $tag, array $data): Tag return $tag->refresh(); } + /** + * Delete the given tag from the database. + */ public function delete(Tag $tag): void { $tag->delete(); } + + /** + * Find a tag by its slug or throw a 404 exception. + */ + public function findBySlugOrFail(string $slug): Tag + { + return Tag::query()->where('slug', $slug)->firstOrFail(); + } + + /** + * Retrieve the most-used tags ordered by article count descending. + * + * @param int $limit Maximum number of tags to return. + * @param array $columns Columns to select. + */ + public function getPopularByArticleCount(int $limit = 10, array $columns = ['id', 'name', 'slug']): Collection + { + return Tag::query() + ->withCount('articles') + ->orderByDesc('articles_count') + ->limit($limit) + ->get($columns); + } } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index b6a3358..cc3eb28 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -23,11 +23,18 @@ public function __construct( ) { } + /** + * Paginate articles ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 10): LengthAwarePaginator { return $this->articleRepository->paginateLatest($perPage); } + /** + * Create a new article with its SEO record, media uploads, and an initial + * version snapshot. Clears the response cache after persisting. + */ public function create(array $data): Article { $seoData = $this->extractSeoData($data); @@ -42,6 +49,11 @@ public function create(array $data): Article return $article; } + /** + * Update an existing article. A new version snapshot is created only when + * the content changes or new version files are provided. + * Clears the response cache after persisting. + */ public function update(Article $article, array $data): Article { $seoData = $this->extractSeoData($data); @@ -62,6 +74,11 @@ public function update(Article $article, array $data): Article return $article; } + /** + * Update the article slug, normalising it and appending a counter to ensure + * uniqueness. Throws InvalidArgumentException for empty slugs. + * Clears the response cache after persisting. + */ public function updateSlug(Article $article, string $slug): Article { $normalizedSlug = Str::slug($slug); @@ -81,6 +98,10 @@ public function updateSlug(Article $article, string $slug): Article return $updated; } + /** + * Transition a draft article to the "review" workflow status. + * Throws InvalidArgumentException if the article is not a draft. + */ public function submitForReview(Article $article): Article { if ($article->status !== ArticleStatus::DRAFT) { @@ -97,6 +118,12 @@ public function submitForReview(Article $article): Article return $updated; } + /** + * Approve an article that is currently in the "review" workflow status. + * Throws InvalidArgumentException if the article is not in review. + * + * @param int|null $reviewerId ID of the user performing the approval. + */ public function approve(Article $article, ?int $reviewerId): Article { if ($article->workflow_status !== 'review') { @@ -114,6 +141,13 @@ public function approve(Article $article, ?int $reviewerId): Article return $updated; } + /** + * Publish an approved article, set its published_at timestamp, and dispatch + * the publish pipeline job. Throws InvalidArgumentException if the article + * is not in "approved" or "published" workflow status. + * + * @param int|null $publisherId ID of the user performing the publish action. + */ public function publish(Article $article, ?int $publisherId): Article { if (!in_array($article->workflow_status, ['approved', 'published'], true)) { @@ -133,12 +167,19 @@ public function publish(Article $article, ?int $publisherId): Article return $updated; } + /** + * Delete the given article and clear the response cache. + */ public function delete(Article $article): void { $this->articleRepository->delete($article); $this->clearResponseCache(); } + /** + * Restore a set of allowed fields from an activity log snapshot ("old" properties). + * Throws InvalidArgumentException when the activity contains no restorable data. + */ public function restoreFromActivity(Article $article, Activity $activity): Article { $oldValues = $activity->properties['old'] ?? []; @@ -168,6 +209,10 @@ public function restoreFromActivity(Article $article, Activity $activity): Artic return $updated; } + /** + * Generate a unique slug derived from $baseSlug, skipping the given article ID. + * Appends an incrementing integer suffix until the slug is unique. + */ private function resolveUniqueSlug(string $baseSlug, int $ignoreId): string { $slug = $baseSlug; @@ -185,6 +230,10 @@ private function resolveUniqueSlug(string $baseSlug, int $ignoreId): string return $slug; } + /** + * Pop SEO-related keys (seo_title, seo_description, seo_keywords, seo_og_image) + * from the data array and return them as a separate array. + */ private function extractSeoData(array &$data): array { $seoData = [ @@ -199,6 +248,10 @@ private function extractSeoData(array &$data): array return $seoData; } + /** + * Upsert the ArticleSeo record for the article and run the SEO analyzer to + * update the score, breakdown, and warnings. + */ private function syncSeo(Article $article, array $seoData): void { $articleSeo = $article->seo()->updateOrCreate([], $seoData); @@ -215,6 +268,10 @@ private function syncSeo(Article $article, array $seoData): void ]); } + /** + * Pop media-related keys (featured_image, gallery, attachments, version_files) + * from the data array and return them as a separate array. + */ private function extractMediaData(array &$data): array { $mediaData = [ @@ -229,6 +286,11 @@ private function extractMediaData(array &$data): array return $mediaData; } + /** + * Persist featured image, gallery, and attachment files to their respective + * Spatie Media Library collections. Replaces the featured image if a new one + * is provided; appends gallery and attachment files. + */ private function syncArticleMedia(Article $article, array $mediaData): void { if ($mediaData['featured_image'] instanceof UploadedFile) { @@ -249,6 +311,12 @@ private function syncArticleMedia(Article $article, array $mediaData): void } } + /** + * Create a versioned snapshot of the current article content with an + * auto-incremented version number, attaching any provided version files. + * + * @param mixed $updatedBy User ID of the editor (cast to int) or null. + */ private function createVersionSnapshot(Article $article, mixed $updatedBy, array $versionFiles): ArticleVersion { $nextVersionNumber = (int) $article->versions()->max('version_number') + 1; @@ -268,6 +336,9 @@ private function createVersionSnapshot(Article $article, mixed $updatedBy, array return $version; } + /** + * Flush the entire Spatie ResponseCache to ensure stale pages are not served. + */ private function clearResponseCache(): void { ResponseCache::clear(); diff --git a/app/Services/CategoryService.php b/app/Services/CategoryService.php index 931126e..7416c11 100644 --- a/app/Services/CategoryService.php +++ b/app/Services/CategoryService.php @@ -14,11 +14,17 @@ public function __construct( ) { } + /** + * Paginate categories ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 15): LengthAwarePaginator { return $this->categoryRepository->paginateLatest($perPage); } + /** + * Create a new category with the given name, description, and status. + */ public function create(array $data): Category { return $this->categoryRepository->create([ @@ -28,6 +34,9 @@ public function create(array $data): Category ]); } + /** + * Update an existing category with the given name, description, and status. + */ public function update(Category $category, array $data): Category { return $this->categoryRepository->update($category, [ @@ -37,6 +46,9 @@ public function update(Category $category, array $data): Category ]); } + /** + * Delete the given category from the database. + */ public function delete(Category $category): void { $this->categoryRepository->delete($category); diff --git a/app/Services/CommentService.php b/app/Services/CommentService.php index 99cb841..a5e827f 100644 --- a/app/Services/CommentService.php +++ b/app/Services/CommentService.php @@ -15,11 +15,17 @@ public function __construct( ) { } + /** + * Paginate comments for the given article, newest first, with the author eager-loaded. + */ public function paginateByArticle(Article $article, int $perPage = 20): LengthAwarePaginator { return $this->commentRepository->paginateByArticle($article, $perPage); } + /** + * Create a comment associated with the given article. + */ public function createForArticle(Article $article, array $data): Comment { return $this->commentRepository->create([ @@ -32,6 +38,10 @@ public function createForArticle(Article $article, array $data): Comment ]); } + /** + * Update a comment that belongs to the given article. + * Throws InvalidArgumentException if the comment does not belong to the article. + */ public function updateForArticle(Article $article, Comment $comment, array $data): Comment { $this->ensureCommentBelongsToArticle($article, $comment); @@ -45,12 +55,19 @@ public function updateForArticle(Article $article, Comment $comment, array $data ]); } + /** + * Delete a comment that belongs to the given article. + * Throws InvalidArgumentException if the comment does not belong to the article. + */ public function deleteForArticle(Article $article, Comment $comment): void { $this->ensureCommentBelongsToArticle($article, $comment); $this->commentRepository->delete($comment); } + /** + * Assert that the comment belongs to the article; throws InvalidArgumentException otherwise. + */ private function ensureCommentBelongsToArticle(Article $article, Comment $comment): void { if ((int) $comment->article_id !== (int) $article->id) { diff --git a/app/Services/GuestArticleService.php b/app/Services/GuestArticleService.php new file mode 100644 index 0000000..e7254fb --- /dev/null +++ b/app/Services/GuestArticleService.php @@ -0,0 +1,162 @@ +articleRepository->findPublishedBySlug($slug); + $relatedArticles = $this->articleRepository->getRelatedArticles($article); + $trendingArticles = $this->getTrendingArticles($article->id); + $trendingTags = $this->tagRepository->getPopularByArticleCount(6); + + return compact('article', 'relatedArticles', 'trendingArticles', 'trendingTags'); + } + + /** + * Fetch all data needed for the article listing / search page. + */ + public function getListingData(array $filters, bool $useFeaturedArticle = true): array + { + $normalized = $this->normalizeFilters($filters); + $baseQuery = $this->articleRepository->buildPublishedFilteredQuery($normalized); + + $featuredArticle = null; + + if ($useFeaturedArticle) { + $featuredArticle = (clone $baseQuery)->latest('published_at')->first(); + } + + $articles = (clone $baseQuery) + ->when( + $useFeaturedArticle && $featuredArticle, + fn ($query) => $query->whereKeyNot($featuredArticle->id) + ) + ->paginate(10) + ->withQueryString(); + + $popularArticles = $this->getPopularArticles(); + $popularTags = $this->tagRepository->getPopularByArticleCount(10); + $categories = $this->articleFilterCacheService->getCategories(); + $tags = $this->articleFilterCacheService->getTags(); + $relatedCategories = $this->categoryRepository->getActiveWithPublishedArticleCount(); + + return [ + 'articles' => $articles, + 'featuredArticle' => $featuredArticle, + 'popularArticles' => $popularArticles, + 'popularTags' => $popularTags, + 'categories' => $categories, + 'relatedCategories' => $relatedCategories, + 'tags' => $tags, + 'filters' => $normalized, + 'pageTitle' => 'Tin tức mới nhất', + ]; + } + + /** + * Fetch listing data scoped to a category slug. + */ + public function getCategoryListingData(string $slug, array $filters): array + { + $category = $this->categoryRepository->findActiveBySlugOrFail($slug); + + $data = $this->getListingData(array_merge($filters, ['category' => $slug])); + $data['pageTitle'] = 'Danh mục: ' . $category->name; + + return $data; + } + + /** + * Fetch listing data scoped to a tag slug. + */ + public function getTagListingData(string $slug, array $filters): array + { + $tag = $this->tagRepository->findBySlugOrFail($slug); + + $data = $this->getListingData(array_merge($filters, ['tag' => $slug])); + $data['pageTitle'] = 'Chủ đề: ' . $tag->name; + + return $data; + } + + /** + * Validate and normalize filter inputs. + */ + public function normalizeFilters(array $filters): array + { + return [ + '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', + ]; + } + + /** + * Fetch the latest hot-score trending articles, excluding the given article ID. + */ + private function getTrendingArticles(int $excludeArticleId): Collection + { + return 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 !== $excludeArticleId) + ->values(); + } + + /** + * Fetch the most popular articles from Trending data. + * Falls back to the five most recently published articles when no trending + * records exist. + */ + private function getPopularArticles(): Collection + { + $popular = Trending::query() + ->with(['article' => fn ($query) => $query->with('category')]) + ->latest('calculated_at') + ->orderBy('rank') + ->limit(5) + ->get() + ->pluck('article') + ->filter() + ->values(); + + if ($popular->isEmpty()) { + return Article::query() + ->with('category') + ->where('status', ArticleStatus::PUBLISHED->value) + ->latest('published_at') + ->limit(5) + ->get(); + } + + return $popular; + } +} diff --git a/app/Services/HotArticleService.php b/app/Services/HotArticleService.php index 1103771..1ca0b9c 100644 --- a/app/Services/HotArticleService.php +++ b/app/Services/HotArticleService.php @@ -176,11 +176,19 @@ public function calculateHotScores(?CarbonInterface $trackedDate = null): int return count($scoredRows); } + /** + * Build the Redis key used to store the view counter for an article. + */ private function viewCounterKey(int $articleId): string { return sprintf('hot:article:%d:views', $articleId); } + /** + * Return a recency bonus score based on how many hours have elapsed since + * the article was published at the time of score calculation. + * Score: 20 (≤24 h) · 10 (≤72 h) · 0 (older). + */ private function freshScore(Article $article, CarbonInterface $calculatedAt): int { $publishedAt = $article->published_at; diff --git a/app/Services/SeoAnalyzerService.php b/app/Services/SeoAnalyzerService.php index 6a550ee..66d4bcf 100644 --- a/app/Services/SeoAnalyzerService.php +++ b/app/Services/SeoAnalyzerService.php @@ -44,6 +44,9 @@ public function analyze(Article $article): array ]; } + /** + * Score the title length. Returns 10 for 50–60 chars, 5 for 40–70 chars, 0 otherwise. + */ private function scoreTitleLength(string $title): int { $len = Str::length(trim($title)); @@ -59,6 +62,9 @@ private function scoreTitleLength(string $title): int return 0; } + /** + * Score the meta description length. Returns 15 for 120–160 chars, 8 for 90–180 chars, 0 otherwise. + */ private function scoreMetaDescription(string $metaDescription): int { $len = Str::length(trim($metaDescription)); @@ -74,6 +80,9 @@ private function scoreMetaDescription(string $metaDescription): int return 0; } + /** + * Return $max when the primary keyword appears in $text (case-insensitive), 0 otherwise. + */ private function scoreKeywordInText(string $keyword, string $text, int $max): int { if ($keyword === '') { @@ -83,6 +92,10 @@ private function scoreKeywordInText(string $keyword, string $text, int $max): in return Str::contains(Str::lower($text), Str::lower($keyword)) ? $max : 0; } + /** + * Score keyword density in plain-text content. + * Returns 10 for 1–2 %, 5 for 0.5–3 %, 0 otherwise. + */ private function scoreKeywordDensity(string $keyword, string $contentText): int { if ($keyword === '') { @@ -110,6 +123,10 @@ private function scoreKeywordDensity(string $keyword, string $contentText): int return 0; } + /** + * Score content length in words. + * Returns 10 for ≥800 words, 5 for ≥500 words, 0 otherwise. + */ private function scoreContentLength(string $contentText): int { $wordCount = str_word_count(strip_tags($contentText)); @@ -125,6 +142,11 @@ private function scoreContentLength(string $contentText): int return 0; } + /** + * Score the heading structure of the HTML content. + * Full marks (10) require: exactly one H1 containing the keyword, 2–3 H2s, and 3–5 H3s. + * Partial marks (5) require at least one H1 and one H2. + */ private function scoreHeadingStructure(string $contentHtml, string $keyword): int { $h1Count = preg_match_all('/]*>/i', $contentHtml); @@ -143,6 +165,10 @@ private function scoreHeadingStructure(string $contentHtml, string $keyword): in return 0; } + /** + * Score internal links found in the HTML content (relative paths or same host). + * Returns 10 for ≥2 internal links, 5 for 1, 0 for none. + */ private function scoreInternalLinks(string $contentHtml): int { preg_match_all('/]*href=["\']([^"\']+)["\'][^>]*>/i', $contentHtml, $matches); @@ -174,6 +200,11 @@ private function scoreInternalLinks(string $contentHtml): int return 0; } + /** + * Score image alt-text quality (keyword present in alt). + * Returns 10 when all images have valid alt text (or when there are no images), + * 5 when at least half do, 0 otherwise. + */ private function scoreImageAlt(string $contentHtml, string $keyword): int { preg_match_all('/]*>/i', $contentHtml, $images); @@ -205,6 +236,11 @@ private function scoreImageAlt(string $contentHtml, string $keyword): int return 0; } + /** + * Score the URL slug quality. + * Returns 5 for a concise (3–8 parts), keyword-containing slug without stop words; + * 3 for a reasonably sized slug (2–10 parts); 0 otherwise. + */ private function scoreUrlSlug(string $slug, string $keyword): int { $parts = array_values(array_filter(explode('-', trim($slug)), fn ($part) => $part !== '')); diff --git a/app/Services/SeoService.php b/app/Services/SeoService.php index eacf170..d574c0f 100644 --- a/app/Services/SeoService.php +++ b/app/Services/SeoService.php @@ -8,6 +8,10 @@ class SeoService { + /** + * Apply all SEO meta tags (title, description, canonical, OpenGraph, JSON-LD, + * keywords, and OG image) for the given article page using SEOTools. + */ public function applyForArticle(Article $article): void { $seo = $article->seo; diff --git a/app/Services/TagService.php b/app/Services/TagService.php index fa46ede..afc4330 100644 --- a/app/Services/TagService.php +++ b/app/Services/TagService.php @@ -14,11 +14,17 @@ public function __construct( ) { } + /** + * Paginate tags ordered by creation date descending (admin listing). + */ public function paginateLatest(int $perPage = 15): LengthAwarePaginator { return $this->tagRepository->paginateLatest($perPage); } + /** + * Create a new tag with a unique slug automatically derived from the name. + */ public function create(array $data): Tag { $name = trim((string) ($data['name'] ?? '')); @@ -30,6 +36,9 @@ public function create(array $data): Tag ]); } + /** + * Update the name of an existing tag. + */ public function update(Tag $tag, array $data): Tag { $name = trim((string) ($data['name'] ?? '')); @@ -39,11 +48,19 @@ public function update(Tag $tag, array $data): Tag ]); } + /** + * Delete the given tag from the database. + */ public function delete(Tag $tag): void { $this->tagRepository->delete($tag); } + /** + * Generate a unique tag slug from the base slug. + * Falls back to "tag" when $baseSlug is empty. + * Appends an incrementing integer suffix until the slug is unique. + */ private function resolveUniqueSlug(string $baseSlug): string { $slug = $baseSlug !== '' ? $baseSlug : 'tag'; diff --git a/database/seeders/ArticleSeeder.php b/database/seeders/ArticleSeeder.php index 8a9257c..1cd523d 100644 --- a/database/seeders/ArticleSeeder.php +++ b/database/seeders/ArticleSeeder.php @@ -49,9 +49,8 @@ public function run(): void ); }); - $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); + $baseContent = `

Trấn Thành đóng phim điện ảnh Hàn

`; + $descriptionContent = 'Trấn Thành tham gia buổi đọc kịch bản "Kal: Thanh kiếm của Godumakhan", cùng dàn diễn viên nổi tiếng như Park Bo Gum và Joo Won'; for ($i = 1; $i <= 20; $i++) { $title = sprintf('Trấn Thành đóng phim điện ảnh Hàn %02d', $i); @@ -68,7 +67,7 @@ public function run(): void 'title' => $title, 'slug' => $slug, 'excerpt' => $descriptionContent, - 'content' => $sharedContent, + 'content' => $baseContent, 'status' => ArticleStatus::PUBLISHED, 'published_at' => now()->subDays(21 - $i), ] diff --git a/resources/css/guest.css b/resources/css/guest.css index 5ee38ce..5ac5638 100644 --- a/resources/css/guest.css +++ b/resources/css/guest.css @@ -1,10 +1,10 @@ :root { --guest-font-sans: 'Be Vietnam Pro', system-ui, -apple-system, 'Segoe UI', sans-serif; - --guest-font-display: 'Merriweather', Georgia, serif; + --guest-font-display: 'Be Vietnam Pro', 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-subtitle: clamp(1.5rem, 1.8vw, 2.25rem); --guest-size-card-title: clamp(1.25rem, 1.8vw, 1.75rem); --guest-line-body: 1.75; } diff --git a/resources/js/article-show.js b/resources/js/article-show.js new file mode 100644 index 0000000..39ebb25 --- /dev/null +++ b/resources/js/article-show.js @@ -0,0 +1,122 @@ +document.addEventListener('DOMContentLoaded', function () { + // ── Share button ────────────────────────────────────────────────────────── + const btnShare = document.querySelector('.btn-share'); + + if (btnShare) { + const shareUrl = window.location.href; + const shareTitle = document.title; + + const channels = [ + { + label : 'Facebook', + icon : '', + href : `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, + }, + { + label : 'X (Twitter)', + icon : '', + href : `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(shareTitle)}`, + }, + { + label : 'LinkedIn', + icon : '', + href : `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(shareTitle)}`, + }, + { + label : 'WhatsApp', + icon : '', + href : `https://wa.me/?text=${encodeURIComponent(shareTitle + ' ' + shareUrl)}`, + }, + ]; + + // Build the dropdown panel (hidden by default) + const dropdown = document.createElement('div'); + dropdown.id = 'share-dropdown'; + dropdown.className = [ + 'absolute z-50 mt-2 w-44 rounded-xl border border-slate-200 bg-white py-1.5 shadow-lg', + 'dark:border-slate-700 dark:bg-slate-900', + 'hidden', + ].join(' '); + dropdown.setAttribute('role', 'menu'); + + channels.forEach(function (ch) { + const item = document.createElement('a'); + item.href = ch.href; + item.target = '_blank'; + item.rel = 'noopener noreferrer'; + item.className = 'flex items-center gap-3 px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800'; + item.setAttribute('role', 'menuitem'); + item.innerHTML = ch.icon + '' + ch.label + ''; + dropdown.appendChild(item); + }); + + // Position dropdown relative to the share button's parent + const shareWrapper = document.createElement('div'); + shareWrapper.className = 'relative inline-block'; + btnShare.parentNode.insertBefore(shareWrapper, btnShare); + shareWrapper.appendChild(btnShare); + shareWrapper.appendChild(dropdown); + + btnShare.addEventListener('click', function (e) { + e.stopPropagation(); + const isOpen = !dropdown.classList.contains('hidden'); + dropdown.classList.toggle('hidden', isOpen); + btnShare.setAttribute('aria-expanded', String(!isOpen)); + }); + + // Close when clicking outside + document.addEventListener('click', function () { + dropdown.classList.add('hidden'); + btnShare.setAttribute('aria-expanded', 'false'); + }); + + dropdown.addEventListener('click', function (e) { + e.stopPropagation(); + }); + } + + // ── Copy link button ────────────────────────────────────────────────────── + const btnCopy = document.querySelector('.btn-copy-link'); + + if (btnCopy) { + const iconEl = btnCopy.querySelector('.material-symbols-outlined'); + const origIcon = iconEl ? iconEl.textContent.trim() : 'link'; + + btnCopy.addEventListener('click', function () { + const url = window.location.href; + + if (!navigator.clipboard) { + // Fallback for non-secure contexts + const ta = document.createElement('textarea'); + ta.value = url; + ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + showCopyFeedback(); + return; + } + + navigator.clipboard.writeText(url).then(showCopyFeedback).catch(function () { + console.warn('Không thể sao chép liên kết.'); + }); + }); + + function showCopyFeedback() { + if (iconEl) { + iconEl.textContent = 'check'; + } + btnCopy.classList.add('bg-green-100', 'text-green-700', 'dark:bg-green-900/40', 'dark:text-green-400'); + btnCopy.title = 'Đã sao chép!'; + + setTimeout(function () { + if (iconEl) { + iconEl.textContent = origIcon; + } + btnCopy.classList.remove('bg-green-100', 'text-green-700', 'dark:bg-green-900/40', 'dark:text-green-400'); + btnCopy.title = ''; + }, 2000); + } + } +}); diff --git a/resources/views/guest/articles/show.blade.php b/resources/views/guest/articles/show.blade.php index 2b77f38..7edd154 100644 --- a/resources/views/guest/articles/show.blade.php +++ b/resources/views/guest/articles/show.blade.php @@ -4,6 +4,7 @@ @section('head') {!! \Artesaos\SEOTools\Facades\SEOTools::generate() !!} + @vite(['resources/js/article-show.js']) @endsection @section('content') @@ -31,11 +32,11 @@
- -
@@ -55,25 +56,7 @@ class="article-lazy-image aspect-video w-full rounded-xl border border-slate-200
-

- {!! 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. -

+ {!! $article->content !!}
diff --git a/resources/views/guest/home/index.blade.php b/resources/views/guest/home/index.blade.php index e71db84..cef78d1 100644 --- a/resources/views/guest/home/index.blade.php +++ b/resources/views/guest/home/index.blade.php @@ -17,8 +17,8 @@ class="article-lazy-image absolute inset-0 h-full w-full object-cover opacity-75
Nổi bật -

{{ $featuredArticle->title }}

-

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

+

{{ $featuredArticle->title }}

+

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

Đọc ngay arrow_forward @@ -52,10 +52,10 @@ class="article-lazy-image absolute inset-0 h-full w-full object-cover opacity-75 {{ $article->published_at?->diffForHumans() ?? 'Vừa xong' }}
-

+

{{ $article->title }} -

-

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

+ +

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

@empty @@ -80,7 +80,7 @@ class="article-lazy-image absolute inset-0 h-full w-full object-cover opacity-75
{{ \Illuminate\Support\Str::limit($popular->title, 78) }}
- + {{ $popular->category?->name ?? 'Tin tức' }} diff --git a/vite.config.js b/vite.config.js index caf8f16..466249c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,6 +11,7 @@ export default defineConfig({ 'resources/css/contact.css', 'resources/css/policy.css', 'resources/js/app.js', + 'resources/js/article-show.js', 'resources/js/contact.js', 'resources/js/policy.js', ],