Spaces:
Sleeping
Sleeping
fix perf article
Browse files- src/main/java/com/darkfantasy/api/ArticleAPIController.java +40 -0
- src/main/java/com/darkfantasy/dto/article/ArticleListResponse.java +35 -0
- src/main/java/com/darkfantasy/repository/ArticleRepository.java +3 -0
- src/main/java/com/darkfantasy/repository/projection/article/ArticleList.java +16 -0
- src/main/java/com/darkfantasy/service/ArticleService.java +2 -1
- src/main/java/com/darkfantasy/service/impl/ArticleServiceImpl.java +3 -2
- src/main/resources/application.properties +5 -5
- src/main/resources/static/js/loadmore.js +61 -0
- src/main/webapp/WEB-INF/views/layout/footer.jsp +1 -0
- src/main/webapp/WEB-INF/views/news/news.jsp +20 -16
src/main/java/com/darkfantasy/api/ArticleAPIController.java
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package com.darkfantasy.api;
|
| 2 |
+
|
| 3 |
+
import java.util.HashMap;
|
| 4 |
+
import java.util.Map;
|
| 5 |
+
|
| 6 |
+
import org.springframework.data.domain.PageRequest;
|
| 7 |
+
import org.springframework.data.domain.Pageable;
|
| 8 |
+
import org.springframework.data.domain.Slice;
|
| 9 |
+
import org.springframework.web.bind.annotation.GetMapping;
|
| 10 |
+
import org.springframework.web.bind.annotation.RequestMapping;
|
| 11 |
+
import org.springframework.web.bind.annotation.RequestParam;
|
| 12 |
+
import org.springframework.web.bind.annotation.ResponseBody;
|
| 13 |
+
import org.springframework.web.bind.annotation.RestController;
|
| 14 |
+
|
| 15 |
+
import com.darkfantasy.dto.article.ArticleCardResponse;
|
| 16 |
+
import com.darkfantasy.service.ArticleService;
|
| 17 |
+
|
| 18 |
+
import lombok.RequiredArgsConstructor;
|
| 19 |
+
|
| 20 |
+
@RequestMapping("/api/article")
|
| 21 |
+
@RequiredArgsConstructor
|
| 22 |
+
@RestController
|
| 23 |
+
public class ArticleAPIController {
|
| 24 |
+
private final ArticleService articleService;
|
| 25 |
+
|
| 26 |
+
@GetMapping("/more")
|
| 27 |
+
@ResponseBody
|
| 28 |
+
public Map<String, Object> loadMoreNews(
|
| 29 |
+
@RequestParam(defaultValue = "1") int page,
|
| 30 |
+
@RequestParam(defaultValue = "6") int size) {
|
| 31 |
+
|
| 32 |
+
Pageable pageable = PageRequest.of(page, size);
|
| 33 |
+
Slice<ArticleCardResponse> slice = articleService.getArticlesDeletedFalse(pageable);
|
| 34 |
+
Map<String, Object> response = new HashMap<>();
|
| 35 |
+
response.put("articles", slice.getContent());
|
| 36 |
+
response.put("hasMore", slice.hasNext());
|
| 37 |
+
|
| 38 |
+
return response;
|
| 39 |
+
}
|
| 40 |
+
}
|
src/main/java/com/darkfantasy/dto/article/ArticleListResponse.java
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package com.darkfantasy.dto.article;
|
| 2 |
+
|
| 3 |
+
import com.darkfantasy.repository.projection.article.ArticleList;
|
| 4 |
+
import com.darkfantasy.util.TimeUtil;
|
| 5 |
+
|
| 6 |
+
import lombok.AllArgsConstructor;
|
| 7 |
+
import lombok.Builder;
|
| 8 |
+
import lombok.Data;
|
| 9 |
+
import lombok.NoArgsConstructor;
|
| 10 |
+
|
| 11 |
+
@Data
|
| 12 |
+
@NoArgsConstructor
|
| 13 |
+
@AllArgsConstructor
|
| 14 |
+
@Builder
|
| 15 |
+
public class ArticleListResponse {
|
| 16 |
+
private Long id;
|
| 17 |
+
private String thumbnailUrl;
|
| 18 |
+
private String title;
|
| 19 |
+
private String authorUsername;
|
| 20 |
+
private String type;
|
| 21 |
+
private String createdAt;
|
| 22 |
+
private boolean deleted;
|
| 23 |
+
|
| 24 |
+
public static ArticleListResponse fromProjection(ArticleList article) {
|
| 25 |
+
return ArticleListResponse.builder()
|
| 26 |
+
.id(article.getId())
|
| 27 |
+
.thumbnailUrl(article.getThumbnailUrl())
|
| 28 |
+
.title(article.getTitle())
|
| 29 |
+
.authorUsername(article.getAuthorUsername())
|
| 30 |
+
.type(article.getType())
|
| 31 |
+
.createdAt(TimeUtil.formatInstant(article.getCreatedAt()))
|
| 32 |
+
.deleted(article.isDeleted())
|
| 33 |
+
.build();
|
| 34 |
+
}
|
| 35 |
+
}
|
src/main/java/com/darkfantasy/repository/ArticleRepository.java
CHANGED
|
@@ -14,6 +14,7 @@ import org.springframework.stereotype.Repository;
|
|
| 14 |
import com.darkfantasy.entity.Article;
|
| 15 |
import com.darkfantasy.entity.enums.ArticleType;
|
| 16 |
import com.darkfantasy.repository.projection.article.ArticleDetail;
|
|
|
|
| 17 |
import com.darkfantasy.repository.projection.article.ArticleCardBar;
|
| 18 |
import com.darkfantasy.repository.projection.article.ArticleCard;
|
| 19 |
|
|
@@ -46,4 +47,6 @@ public interface ArticleRepository extends JpaRepository<Article, Long> {
|
|
| 46 |
Page<ArticleCard> findPublicArticleList(Pageable pageable);
|
| 47 |
|
| 48 |
List<ArticleCardBar> findByIdNotAndDeletedFalseOrderByCreatedAtDesc(Long id, Pageable pageable);
|
|
|
|
|
|
|
| 49 |
}
|
|
|
|
| 14 |
import com.darkfantasy.entity.Article;
|
| 15 |
import com.darkfantasy.entity.enums.ArticleType;
|
| 16 |
import com.darkfantasy.repository.projection.article.ArticleDetail;
|
| 17 |
+
import com.darkfantasy.repository.projection.article.ArticleList;
|
| 18 |
import com.darkfantasy.repository.projection.article.ArticleCardBar;
|
| 19 |
import com.darkfantasy.repository.projection.article.ArticleCard;
|
| 20 |
|
|
|
|
| 47 |
Page<ArticleCard> findPublicArticleList(Pageable pageable);
|
| 48 |
|
| 49 |
List<ArticleCardBar> findByIdNotAndDeletedFalseOrderByCreatedAtDesc(Long id, Pageable pageable);
|
| 50 |
+
|
| 51 |
+
Page<ArticleList> findAllProjectedBy(Pageable pageable);
|
| 52 |
}
|
src/main/java/com/darkfantasy/repository/projection/article/ArticleList.java
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package com.darkfantasy.repository.projection.article;
|
| 2 |
+
|
| 3 |
+
import java.time.Instant;
|
| 4 |
+
|
| 5 |
+
import org.springframework.beans.factory.annotation.Value;
|
| 6 |
+
|
| 7 |
+
public interface ArticleList {
|
| 8 |
+
Long getId();
|
| 9 |
+
String getThumbnailUrl();
|
| 10 |
+
String getTitle();
|
| 11 |
+
@Value("#{target.createdBy.username}")
|
| 12 |
+
String getAuthorUsername();
|
| 13 |
+
String getType();
|
| 14 |
+
Instant getCreatedAt();
|
| 15 |
+
boolean isDeleted();
|
| 16 |
+
}
|
src/main/java/com/darkfantasy/service/ArticleService.java
CHANGED
|
@@ -9,6 +9,7 @@ import org.springframework.data.domain.Slice;
|
|
| 9 |
import com.darkfantasy.dto.article.ArticleCardBarResponse;
|
| 10 |
import com.darkfantasy.dto.article.ArticleCardResponse;
|
| 11 |
import com.darkfantasy.dto.article.ArticleDetailResponse;
|
|
|
|
| 12 |
import com.darkfantasy.dto.article.ArticleResponse;
|
| 13 |
import com.darkfantasy.dto.article.CreateArticleRequest;
|
| 14 |
import com.darkfantasy.dto.article.UpdateArticleRequest;
|
|
@@ -26,7 +27,7 @@ public interface ArticleService {
|
|
| 26 |
|
| 27 |
void restoreArticle(Long articleId);
|
| 28 |
|
| 29 |
-
Page<
|
| 30 |
|
| 31 |
Slice<ArticleCardResponse> getArticlesDeletedFalse(Pageable pageable);
|
| 32 |
|
|
|
|
| 9 |
import com.darkfantasy.dto.article.ArticleCardBarResponse;
|
| 10 |
import com.darkfantasy.dto.article.ArticleCardResponse;
|
| 11 |
import com.darkfantasy.dto.article.ArticleDetailResponse;
|
| 12 |
+
import com.darkfantasy.dto.article.ArticleListResponse;
|
| 13 |
import com.darkfantasy.dto.article.ArticleResponse;
|
| 14 |
import com.darkfantasy.dto.article.CreateArticleRequest;
|
| 15 |
import com.darkfantasy.dto.article.UpdateArticleRequest;
|
|
|
|
| 27 |
|
| 28 |
void restoreArticle(Long articleId);
|
| 29 |
|
| 30 |
+
Page<ArticleListResponse> getArticles(Pageable pageable);
|
| 31 |
|
| 32 |
Slice<ArticleCardResponse> getArticlesDeletedFalse(Pageable pageable);
|
| 33 |
|
src/main/java/com/darkfantasy/service/impl/ArticleServiceImpl.java
CHANGED
|
@@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|
| 12 |
import com.darkfantasy.dto.article.ArticleCardBarResponse;
|
| 13 |
import com.darkfantasy.dto.article.ArticleCardResponse;
|
| 14 |
import com.darkfantasy.dto.article.ArticleDetailResponse;
|
|
|
|
| 15 |
import com.darkfantasy.dto.article.ArticleResponse;
|
| 16 |
import com.darkfantasy.dto.article.CreateArticleRequest;
|
| 17 |
import com.darkfantasy.dto.article.UpdateArticleRequest;
|
|
@@ -134,11 +135,11 @@ public class ArticleServiceImpl implements ArticleService {
|
|
| 134 |
}
|
| 135 |
|
| 136 |
@Override
|
| 137 |
-
public Page<
|
| 138 |
if (pageable == null) {
|
| 139 |
throw new IllegalArgumentException("Pageable không được null");
|
| 140 |
}
|
| 141 |
-
return articleRepository.
|
| 142 |
}
|
| 143 |
|
| 144 |
@Transactional
|
|
|
|
| 12 |
import com.darkfantasy.dto.article.ArticleCardBarResponse;
|
| 13 |
import com.darkfantasy.dto.article.ArticleCardResponse;
|
| 14 |
import com.darkfantasy.dto.article.ArticleDetailResponse;
|
| 15 |
+
import com.darkfantasy.dto.article.ArticleListResponse;
|
| 16 |
import com.darkfantasy.dto.article.ArticleResponse;
|
| 17 |
import com.darkfantasy.dto.article.CreateArticleRequest;
|
| 18 |
import com.darkfantasy.dto.article.UpdateArticleRequest;
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
@Override
|
| 138 |
+
public Page<ArticleListResponse> getArticles(Pageable pageable) {
|
| 139 |
if (pageable == null) {
|
| 140 |
throw new IllegalArgumentException("Pageable không được null");
|
| 141 |
}
|
| 142 |
+
return articleRepository.findAllProjectedBy(pageable).map(ArticleListResponse::fromProjection);
|
| 143 |
}
|
| 144 |
|
| 145 |
@Transactional
|
src/main/resources/application.properties
CHANGED
|
@@ -18,9 +18,9 @@ spring.thymeleaf.mode=HTML
|
|
| 18 |
# spring.security.oauth2.client.registration.google.scope=email,profile
|
| 19 |
|
| 20 |
# DEVTOOLS & DEVELOPMENT MODE (Dành cho Production)
|
| 21 |
-
spring.devtools.restart.enabled=false
|
| 22 |
-
spring.devtools.livereload.enabled=false
|
| 23 |
-
spring.devtools.add-properties=false
|
| 24 |
# JSP:
|
| 25 |
|
| 26 |
spring.mvc.view.cache=true
|
|
@@ -49,10 +49,10 @@ spring.jpa.hibernate.ddl-auto=update
|
|
| 49 |
|
| 50 |
# Bật tính năng in câu lệnh SQL ra console
|
| 51 |
#Nên bật khi code, khi deploy thì tắt
|
| 52 |
-
spring.jpa.show-sql=false
|
| 53 |
|
| 54 |
# Format SQL cho dễ đọc (xuống dòng, thụt lề), nếu không nó sẽ in thành 1 hàng ngang rất rối
|
| 55 |
-
spring.jpa.properties.hibernate.format_sql=
|
| 56 |
|
| 57 |
# Làm nổi bật các từ khóa SQL (SELECT, FROM, JOIN) bằng màu sắc
|
| 58 |
spring.jpa.properties.hibernate.highlight_sql=false
|
|
|
|
| 18 |
# spring.security.oauth2.client.registration.google.scope=email,profile
|
| 19 |
|
| 20 |
# DEVTOOLS & DEVELOPMENT MODE (Dành cho Production)
|
| 21 |
+
spring.devtools.restart.enabled=${DEV_TOOL:false}
|
| 22 |
+
spring.devtools.livereload.enabled=${DEV_TOOL:false}
|
| 23 |
+
spring.devtools.add-properties=${DEV_TOOL:false}
|
| 24 |
# JSP:
|
| 25 |
|
| 26 |
spring.mvc.view.cache=true
|
|
|
|
| 49 |
|
| 50 |
# Bật tính năng in câu lệnh SQL ra console
|
| 51 |
#Nên bật khi code, khi deploy thì tắt
|
| 52 |
+
spring.jpa.show-sql=${SHOW_SQL:false}
|
| 53 |
|
| 54 |
# Format SQL cho dễ đọc (xuống dòng, thụt lề), nếu không nó sẽ in thành 1 hàng ngang rất rối
|
| 55 |
+
spring.jpa.properties.hibernate.format_sql=true
|
| 56 |
|
| 57 |
# Làm nổi bật các từ khóa SQL (SELECT, FROM, JOIN) bằng màu sắc
|
| 58 |
spring.jpa.properties.hibernate.highlight_sql=false
|
src/main/resources/static/js/loadmore.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener("DOMContentLoaded", function () {
|
| 2 |
+
const loadMoreBtn = document.getElementById("load-more-btn");
|
| 3 |
+
const newsGrid = document.querySelector(".grid-3");
|
| 4 |
+
|
| 5 |
+
if (loadMoreBtn) {
|
| 6 |
+
loadMoreBtn.addEventListener("click", async function () {
|
| 7 |
+
// Lấy thông số trang hiện tại
|
| 8 |
+
let currentPage = parseInt(loadMoreBtn.getAttribute("data-page"));
|
| 9 |
+
let size = parseInt(loadMoreBtn.getAttribute("data-size"));
|
| 10 |
+
|
| 11 |
+
// Đổi text thành Đang tải để tăng UX
|
| 12 |
+
const originalText = loadMoreBtn.innerText;
|
| 13 |
+
loadMoreBtn.innerText = "Đang tải...";
|
| 14 |
+
loadMoreBtn.disabled = true;
|
| 15 |
+
|
| 16 |
+
try {
|
| 17 |
+
// Gọi API backend
|
| 18 |
+
const response = await fetch(
|
| 19 |
+
`/api/article/more?page=${currentPage}&size=${size}`,
|
| 20 |
+
);
|
| 21 |
+
if (!response.ok) throw new Error("Network response was not ok");
|
| 22 |
+
|
| 23 |
+
const data = await response.json();
|
| 24 |
+
|
| 25 |
+
const template = document.getElementById("news-card-template");
|
| 26 |
+
|
| 27 |
+
data.articles.forEach((article) => {
|
| 28 |
+
// 1. Nhân bản (clone) cái khung HTML trống
|
| 29 |
+
const clone = template.content.cloneNode(true);
|
| 30 |
+
|
| 31 |
+
// 2. Trỏ tới từng element và nhét dữ liệu (textContent tự động chống lỗi XSS)
|
| 32 |
+
const img = clone.querySelector(".card-img");
|
| 33 |
+
img.src = article.thumbnailUrl;
|
| 34 |
+
img.alt = article.title;
|
| 35 |
+
|
| 36 |
+
clone.querySelector(".card-tag").textContent = article.type;
|
| 37 |
+
clone.querySelector(".card-title").textContent = article.title;
|
| 38 |
+
clone.querySelector(".card-date").textContent = article.createdAt;
|
| 39 |
+
clone.querySelector(".card-link").href = `/news/${article.id}`;
|
| 40 |
+
|
| 41 |
+
// 3. Nhét cục DOM đã hoàn thiện vào danh sách
|
| 42 |
+
newsGrid.appendChild(clone);
|
| 43 |
+
});
|
| 44 |
+
// Xử lý trạng thái nút bấm
|
| 45 |
+
if (data.hasMore) {
|
| 46 |
+
// Tăng số trang lên 1 cho lần bấm tiếp theo
|
| 47 |
+
loadMoreBtn.setAttribute("data-page", currentPage + 1);
|
| 48 |
+
loadMoreBtn.innerText = originalText;
|
| 49 |
+
loadMoreBtn.disabled = false;
|
| 50 |
+
} else {
|
| 51 |
+
// Ẩn nút đi nếu đã hết dữ liệu
|
| 52 |
+
loadMoreBtn.style.display = "none";
|
| 53 |
+
}
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error("Lỗi khi tải thêm bài viết:", error);
|
| 56 |
+
loadMoreBtn.innerText = "Lỗi kết nối. Thử lại!";
|
| 57 |
+
loadMoreBtn.disabled = false;
|
| 58 |
+
}
|
| 59 |
+
});
|
| 60 |
+
}
|
| 61 |
+
});
|
src/main/webapp/WEB-INF/views/layout/footer.jsp
CHANGED
|
@@ -182,6 +182,7 @@
|
|
| 182 |
});
|
| 183 |
</script> -->
|
| 184 |
<script src="${pageContext.request.contextPath}/js/index2.js"></script>
|
|
|
|
| 185 |
</body>
|
| 186 |
|
| 187 |
</html>
|
|
|
|
| 182 |
});
|
| 183 |
</script> -->
|
| 184 |
<script src="${pageContext.request.contextPath}/js/index2.js"></script>
|
| 185 |
+
<script src="${pageContext.request.contextPath}/js/loadmore.js"></script>
|
| 186 |
</body>
|
| 187 |
|
| 188 |
</html>
|
src/main/webapp/WEB-INF/views/news/news.jsp
CHANGED
|
@@ -36,7 +36,7 @@
|
|
| 36 |
class="news-featured-img"
|
| 37 |
src="${featuredArticle.thumbnailUrl}"
|
| 38 |
alt="${featuredArticle.title}"
|
| 39 |
-
|
| 40 |
|
| 41 |
<div
|
| 42 |
style="
|
|
@@ -66,16 +66,6 @@
|
|
| 66 |
${featuredArticle.title}
|
| 67 |
</h2>
|
| 68 |
|
| 69 |
-
<p
|
| 70 |
-
class="font-crimson"
|
| 71 |
-
style="
|
| 72 |
-
color: var(--mid-grey);
|
| 73 |
-
font-size: 1rem;
|
| 74 |
-
line-height: 1.7;
|
| 75 |
-
margin-bottom: 1.5rem;
|
| 76 |
-
">
|
| 77 |
-
</p>
|
| 78 |
-
|
| 79 |
<div style="display: flex; gap: 1rem">
|
| 80 |
|
| 81 |
<a href="/news/${featuredArticle.id}"
|
|
@@ -183,15 +173,29 @@
|
|
| 183 |
</div>
|
| 184 |
<c:if test="${hasMore}">
|
| 185 |
<div class="text-center mt-4">
|
| 186 |
-
<
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
| 190 |
</div>
|
| 191 |
</c:if>
|
| 192 |
</div>
|
| 193 |
</section>
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
<!-- NEWSLETTER
|
| 196 |
<section
|
| 197 |
style="
|
|
|
|
| 36 |
class="news-featured-img"
|
| 37 |
src="${featuredArticle.thumbnailUrl}"
|
| 38 |
alt="${featuredArticle.title}"
|
| 39 |
+
/>
|
| 40 |
|
| 41 |
<div
|
| 42 |
style="
|
|
|
|
| 66 |
${featuredArticle.title}
|
| 67 |
</h2>
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
<div style="display: flex; gap: 1rem">
|
| 70 |
|
| 71 |
<a href="/news/${featuredArticle.id}"
|
|
|
|
| 173 |
</div>
|
| 174 |
<c:if test="${hasMore}">
|
| 175 |
<div class="text-center mt-4">
|
| 176 |
+
<button id="load-more-btn"
|
| 177 |
+
class="btn btn-secondary"
|
| 178 |
+
data-page="1"
|
| 179 |
+
data-size="6">
|
| 180 |
+
Hiện thêm
|
| 181 |
+
</button>
|
| 182 |
</div>
|
| 183 |
</c:if>
|
| 184 |
</div>
|
| 185 |
</section>
|
| 186 |
+
<template id="news-card-template">
|
| 187 |
+
<div class="card">
|
| 188 |
+
<img class="card-img" src="" alt="" loading="lazy">
|
| 189 |
+
<div class="card-body">
|
| 190 |
+
<span class="card-tag"></span>
|
| 191 |
+
<h3 class="card-title"></h3>
|
| 192 |
+
</div>
|
| 193 |
+
<div class="card-footer">
|
| 194 |
+
<span class="font-Cormorant Garamond text-grey card-date"></span>
|
| 195 |
+
<a href="" class="text-crimson font-Lora card-link">Đọc thêm →</a>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</template>
|
| 199 |
<!-- NEWSLETTER
|
| 200 |
<section
|
| 201 |
style="
|