feat: implement page/post duplication, add event layout, auto-extract featured images, and include HTML escaping for templates.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
|
||||
public class CheckNewsGrid {
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
Connection conn = DriverManager.getConnection("jdbc:oracle:thin:@localhost:1521/sisvietnam", "sisvietnam", "sisvietnam");
|
||||
Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SELECT html_template FROM sis_component_template WHERE slug = 'news-grid'");
|
||||
if (rs.next()) {
|
||||
System.out.println("TEMPLATE:");
|
||||
System.out.println(rs.getString("html_template"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
|
||||
public class CheckPosts {
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
Connection conn = DriverManager.getConnection("jdbc:oracle:thin:@localhost:1521/sisvietnam", "sisvietnam", "sisvietnam");
|
||||
Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SELECT id, slug, featured_image FROM sis_post WHERE featured_image IS NOT NULL");
|
||||
while (rs.next()) {
|
||||
System.out.println("ID: " + rs.getLong("id") + " Slug: " + rs.getString("slug") + " IMG: " + rs.getString("featured_image"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.Scanner;
|
||||
|
||||
public class CheckRender {
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
URL url = new URL("http://localhost:8080/");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.connect();
|
||||
if (conn.getResponseCode() == 200) {
|
||||
InputStream is = conn.getInputStream();
|
||||
Scanner scanner = new Scanner(is, "UTF-8").useDelimiter("\\A");
|
||||
String html = scanner.hasNext() ? scanner.next() : "";
|
||||
|
||||
int idx = html.indexOf("news-card__image");
|
||||
if (idx != -1) {
|
||||
System.out.println(html.substring(idx, Math.min(idx + 300, html.length())));
|
||||
} else {
|
||||
System.out.println("Could not find news-card__image in HTML");
|
||||
}
|
||||
} else {
|
||||
System.out.println("HTTP " + conn.getResponseCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public class FixNewsGrid {
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
Connection conn = DriverManager.getConnection("jdbc:oracle:thin:@localhost:1521/sisvietnam", "sisvietnam", "sisvietnam");
|
||||
String correctHtml = "{{#each news_posts}}\n" +
|
||||
"<div class=\"featured-grid__item\">\n" +
|
||||
" <article class=\"news-card news-card--featured\">\n" +
|
||||
" <div class=\"news-card__image\">\n" +
|
||||
" <div>\n" +
|
||||
" <img loading=\"lazy\" src=\"{{featuredImageUrl}}\" alt=\"{{title}}\">\n" +
|
||||
" </div>\n" +
|
||||
" </div>\n" +
|
||||
" <div class=\"news-card__content\">\n" +
|
||||
" <div class=\"featured-grid__category\">\n" +
|
||||
" <a href=\"#\">TIN TỨC</a>\n" +
|
||||
" </div>\n" +
|
||||
" <h3 class=\"news-card__title\">\n" +
|
||||
" <a href=\"/post/{{slug}}\" rel=\"bookmark\">\n" +
|
||||
" <span>{{title}}</span>\n" +
|
||||
" </a>\n" +
|
||||
" </h3>\n" +
|
||||
" <div class=\"news-card__date\">{{date}}</div>\n" +
|
||||
" </div>\n" +
|
||||
" </article>\n" +
|
||||
"</div>\n" +
|
||||
"{{/each}}";
|
||||
|
||||
PreparedStatement pstmt = conn.prepareStatement("UPDATE sis_component_template SET html_template = ? WHERE slug = 'news-grid'");
|
||||
pstmt.setString(1, correctHtml);
|
||||
int updated = pstmt.executeUpdate();
|
||||
System.out.println("Updated rows: " + updated);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class FixPosts {
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
Connection conn = DriverManager.getConnection("jdbc:oracle:thin:@localhost:1521/sisvietnam", "sisvietnam", "sisvietnam");
|
||||
Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SELECT id, content FROM sis_post WHERE featured_image IS NULL");
|
||||
Pattern p = Pattern.compile("<img[^>]+src\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>");
|
||||
int count = 0;
|
||||
while (rs.next()) {
|
||||
String content = rs.getString("content");
|
||||
if (content != null) {
|
||||
Matcher m = p.matcher(content);
|
||||
if (m.find()) {
|
||||
String imgUrl = m.group(1);
|
||||
Statement updateStmt = conn.createStatement();
|
||||
updateStmt.executeUpdate("UPDATE sis_post SET featured_image = '" + imgUrl + "' WHERE id = " + rs.getLong("id"));
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println("Updated " + count + " posts with featured images from content.");
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
|
||||
public class TestDb {
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
Connection conn = DriverManager.getConnection("jdbc:oracle:thin:@localhost:1521/sisvietnam", "sisvietnam", "sisvietnam");
|
||||
Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SELECT id, slug, html_template FROM sis_component_template");
|
||||
while (rs.next()) {
|
||||
System.out.println("--- ID: " + rs.getInt("id") + " SLUG: " + rs.getString("slug") + " ---");
|
||||
String html = rs.getString("html_template");
|
||||
if (html != null && html.contains("\\n")) {
|
||||
System.out.println("CONTAINS LITERAL \\n!");
|
||||
} else if (html != null && html.contains("\n")) {
|
||||
System.out.println("Contains actual newlines.");
|
||||
}
|
||||
System.out.println(html);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import java.util.regex.Matcher;
|
||||
public class TestReplace {
|
||||
public static void main(String[] args) {
|
||||
String test = "Line 1\nLine 2";
|
||||
System.out.println("Original:");
|
||||
System.out.println(test);
|
||||
System.out.println("Quote:");
|
||||
System.out.println(Matcher.quoteReplacement(test));
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.sisvietnamvn.web;
|
||||
|
||||
import com.sisvietnamvn.web.domain.ComponentTemplate;
|
||||
import com.sisvietnamvn.web.repository.ComponentTemplateRepository;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class NewsEventsSeeder implements CommandLineRunner {
|
||||
|
||||
private final ComponentTemplateRepository repository;
|
||||
|
||||
public NewsEventsSeeder(ComponentTemplateRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) throws Exception {
|
||||
seedNewsGrid();
|
||||
seedEventsList();
|
||||
}
|
||||
|
||||
private void seedNewsGrid() {
|
||||
String slug = "news-grid";
|
||||
Optional<ComponentTemplate> opt = repository.findBySlug(slug);
|
||||
ComponentTemplate t = opt.orElse(new ComponentTemplate());
|
||||
t.setSlug(slug);
|
||||
t.setName("News Grid");
|
||||
t.setDescription("Dynamic grid layout for news.");
|
||||
t.setActive(true);
|
||||
|
||||
String html =
|
||||
"<style>\n" +
|
||||
" .ne-news-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; font-family: 'Inter', sans-serif; }\n" +
|
||||
" .ne-news-card { display: flex; border: 1px solid #a91c1c; border-radius: 12px; overflow: hidden; background: #fff; align-items: stretch; }\n" +
|
||||
" .ne-news-image { flex: 0 0 40%; background-color: #f4f4f4; }\n" +
|
||||
" .ne-news-image img { width: 100%; height: 100%; object-fit: cover; }\n" +
|
||||
" .ne-news-content { flex: 1; padding: 15px; display: flex; flex-direction: column; justify-content: center; }\n" +
|
||||
" .ne-news-label { font-size: 0.7rem; color: #888; background: #eee; padding: 2px 6px; border-radius: 4px; display: inline-block; margin-bottom: 8px; width: fit-content; }\n" +
|
||||
" .ne-news-title { font-size: 0.95rem; font-weight: 700; color: #333; margin: 0 0 8px 0; line-height: 1.3; }\n" +
|
||||
" .ne-news-title a { color: inherit; text-decoration: none; }\n" +
|
||||
" .ne-news-title a:hover { color: #a91c1c; }\n" +
|
||||
" .ne-news-date { font-size: 0.8rem; color: #a91c1c; font-weight: 600; }\n" +
|
||||
" @media (max-width: 992px) { .ne-news-grid { grid-template-columns: 1fr; } }\n" +
|
||||
" @media (max-width: 768px) { .ne-news-card { flex-direction: column; } .ne-news-image { flex: none; height: 180px; } }\n" +
|
||||
"</style>\n" +
|
||||
"<div class=\"ne-news-grid\">\n" +
|
||||
" {{#each news_posts}}\n" +
|
||||
" <div class=\"ne-news-card\">\n" +
|
||||
" <div class=\"ne-news-image\"><img src=\"{{featuredImageUrl}}\" alt=\"{{title}}\"></div>\n" +
|
||||
" <div class=\"ne-news-content\">\n" +
|
||||
" <span class=\"ne-news-label\">TIN TỨC</span>\n" +
|
||||
" <h3 class=\"ne-news-title\"><a href=\"/post/{{slug}}\">{{title}}</a></h3>\n" +
|
||||
" <div class=\"ne-news-date\">{{date}}</div>\n" +
|
||||
" </div>\n" +
|
||||
" </div>\n" +
|
||||
" {{/each}}\n" +
|
||||
"</div>\n";
|
||||
|
||||
t.setHtmlTemplate(html);
|
||||
repository.save(t);
|
||||
System.out.println("====== SUCCESS: SEEDED news-grid ComponentTemplate =====");
|
||||
}
|
||||
|
||||
private void seedEventsList() {
|
||||
String slug = "events-list";
|
||||
Optional<ComponentTemplate> opt = repository.findBySlug(slug);
|
||||
ComponentTemplate t = opt.orElse(new ComponentTemplate());
|
||||
t.setSlug(slug);
|
||||
t.setName("Events List");
|
||||
t.setDescription("Vertical list layout for events with event times.");
|
||||
t.setActive(true);
|
||||
|
||||
String html =
|
||||
"<style>\n" +
|
||||
" .ne-events-list { display: flex; flex-direction: column; font-family: 'Inter', sans-serif; }\n" +
|
||||
" .ne-event-item { display: flex; gap: 15px; padding: 15px 0; border-bottom: 1px solid #e0e0e0; align-items: center; }\n" +
|
||||
" .ne-event-date-box { background: #a91c1c; color: #fff; width: 50px; height: 50px; display: flex; flex-direction: column; align-items: center; justify-content: center; font-weight: bold; line-height: 1.1; }\n" +
|
||||
" .ne-event-month { font-size: 0.7rem; text-transform: uppercase; }\n" +
|
||||
" .ne-event-day { font-size: 1.2rem; }\n" +
|
||||
" .ne-event-details { flex: 1; }\n" +
|
||||
" .ne-event-title { font-size: 0.9rem; font-weight: 700; color: #222; margin: 0 0 4px 0; }\n" +
|
||||
" .ne-event-title a { color: inherit; text-decoration: none; }\n" +
|
||||
" .ne-event-title a:hover { color: #a91c1c; }\n" +
|
||||
" .ne-event-meta { font-size: 0.8rem; color: #666; margin: 0; }\n" +
|
||||
"</style>\n" +
|
||||
"<div class=\"ne-events-list\">\n" +
|
||||
" {{#each event_posts}}\n" +
|
||||
" <div class=\"ne-event-item\">\n" +
|
||||
" <div class=\"ne-event-date-box\">\n" +
|
||||
" <span class=\"ne-event-month\">{{eventMonth}}</span>\n" +
|
||||
" <span class=\"ne-event-day\">{{eventDay}}</span>\n" +
|
||||
" </div>\n" +
|
||||
" <div class=\"ne-event-details\">\n" +
|
||||
" <h4 class=\"ne-event-title\"><a href=\"/post/{{slug}}\">{{title}}</a></h4>\n" +
|
||||
" <p class=\"ne-event-meta\">{{eventTimeStr}}<br>{{excerpt}}</p>\n" +
|
||||
" </div>\n" +
|
||||
" </div>\n" +
|
||||
" {{/each}}\n" +
|
||||
"</div>\n";
|
||||
|
||||
t.setHtmlTemplate(html);
|
||||
repository.save(t);
|
||||
System.out.println("====== SUCCESS: SEEDED events-list ComponentTemplate =====");
|
||||
}
|
||||
}
|
||||
+16
@@ -149,4 +149,20 @@ public class ManagePageController {
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Page deleted successfully!");
|
||||
return "redirect:/manage/pages";
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /manage/pages/{id}/duplicate — Duplicate a page.
|
||||
*/
|
||||
@PostMapping("/{id}/duplicate")
|
||||
public String duplicatePage(@PathVariable Long id, RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to duplicate Page : {}", id);
|
||||
try {
|
||||
pageService.duplicate(id);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Page duplicated successfully!");
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to duplicate page", e);
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Failed to duplicate page.");
|
||||
}
|
||||
return "redirect:/manage/pages";
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -170,6 +170,22 @@ public class ManagePostController {
|
||||
return "redirect:/manage/posts";
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /manage/posts/{id}/duplicate — Duplicate a post.
|
||||
*/
|
||||
@PostMapping("/{id}/duplicate")
|
||||
public String duplicatePost(@PathVariable Long id, RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to duplicate Post : {}", id);
|
||||
try {
|
||||
postService.duplicate(id);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Post duplicated successfully!");
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to duplicate post", e);
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Failed to duplicate post.");
|
||||
}
|
||||
return "redirect:/manage/posts";
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate common model attributes for the post form.
|
||||
*/
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.sisvietnamvn.web.controller.manage;
|
||||
|
||||
import com.sisvietnamvn.web.domain.ComponentTemplate;
|
||||
import com.sisvietnamvn.web.repository.ComponentTemplateRepository;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
public class TestTemplateController {
|
||||
private final ComponentTemplateRepository repo;
|
||||
|
||||
public TestTemplateController(ComponentTemplateRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@GetMapping("/api/test-templates")
|
||||
public String test() {
|
||||
return repo.findAll().stream()
|
||||
.map(t -> "SLUG: " + t.getSlug() + "\nHTML: " + t.getHtmlTemplate() + "\nHAS_BACKSLASH_N: " + (t.getHtmlTemplate().contains("\\n")))
|
||||
.collect(Collectors.joining("\n\n"));
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,10 @@ public class Post extends AbstractAuditingEntity<Long> {
|
||||
return layout;
|
||||
}
|
||||
|
||||
public void setLayout(PostLayout layout) {
|
||||
this.layout = layout;
|
||||
}
|
||||
|
||||
public Category getCategory() {
|
||||
return category;
|
||||
}
|
||||
@@ -162,6 +166,24 @@ public class Post extends AbstractAuditingEntity<Long> {
|
||||
this.eventTime = eventTime;
|
||||
}
|
||||
|
||||
@jakarta.persistence.Transient
|
||||
public String getEventTimeLocal() {
|
||||
if (this.eventTime != null) {
|
||||
return java.time.LocalDateTime.ofInstant(this.eventTime, java.time.ZoneId.systemDefault())
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void setEventTimeLocal(String eventTimeLocal) {
|
||||
if (eventTimeLocal != null && !eventTimeLocal.isEmpty()) {
|
||||
this.eventTime = java.time.LocalDateTime.parse(eventTimeLocal, java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"))
|
||||
.atZone(java.time.ZoneId.systemDefault()).toInstant();
|
||||
} else {
|
||||
this.eventTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
public Set<Tag> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ package com.sisvietnamvn.web.domain;
|
||||
public enum PostLayout {
|
||||
STANDARD,
|
||||
SIDEBAR,
|
||||
FULL_WIDTH
|
||||
FULL_WIDTH,
|
||||
EVENT
|
||||
}
|
||||
|
||||
+5
-2
@@ -215,9 +215,12 @@ public class ComponentTemplateService {
|
||||
java.time.format.DateTimeFormatter timeFormatter = java.time.format.DateTimeFormatter.ofPattern("hh:mm a").withZone(java.time.ZoneId.systemDefault());
|
||||
for (com.sisvietnamvn.web.domain.Post post : posts) {
|
||||
String itemHtml = loopBody;
|
||||
itemHtml = itemHtml.replace("{{title}}", post.getTitle() != null ? post.getTitle() : "");
|
||||
String safeTitle = post.getTitle() != null ? org.springframework.web.util.HtmlUtils.htmlEscape(post.getTitle()) : "";
|
||||
String safeExcerpt = post.getExcerpt() != null ? org.springframework.web.util.HtmlUtils.htmlEscape(post.getExcerpt()) : "";
|
||||
|
||||
itemHtml = itemHtml.replace("{{title}}", safeTitle);
|
||||
itemHtml = itemHtml.replace("{{slug}}", post.getSlug() != null ? post.getSlug() : "");
|
||||
itemHtml = itemHtml.replace("{{excerpt}}", post.getExcerpt() != null ? post.getExcerpt() : "");
|
||||
itemHtml = itemHtml.replace("{{excerpt}}", safeExcerpt);
|
||||
itemHtml = itemHtml.replace("{{featuredImageUrl}}", post.getFeaturedImage() != null ? post.getFeaturedImage() : "");
|
||||
String dateStr = post.getCreatedDate() != null ? formatter.format(post.getCreatedDate()) : "";
|
||||
itemHtml = itemHtml.replace("{{date}}", dateStr);
|
||||
|
||||
@@ -240,6 +240,15 @@ public class ImportExportService {
|
||||
post.setContent(content);
|
||||
post.setExcerpt(excerpt);
|
||||
post.setStatus(status);
|
||||
|
||||
// Extract first image from content as fallback featured image
|
||||
if (content != null) {
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile("<img[^>]+src\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>").matcher(content);
|
||||
if (m.find()) {
|
||||
post.setFeaturedImage(m.group(1));
|
||||
}
|
||||
}
|
||||
|
||||
postRepository.save(post);
|
||||
}
|
||||
} else if ("page".equalsIgnoreCase(postType)) {
|
||||
|
||||
@@ -158,4 +158,25 @@ public class PageService {
|
||||
slug = slug.replaceAll("^-|-$", "");
|
||||
return slug.toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing page.
|
||||
* Creates a new draft copy with "(Copy)" appended to the title.
|
||||
* @param id the ID of the page to duplicate
|
||||
* @return the newly created page
|
||||
*/
|
||||
public Page duplicate(Long id) {
|
||||
LOG.debug("Request to duplicate Page : {}", id);
|
||||
Page original = pageRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid page Id:" + id));
|
||||
|
||||
Page copy = new Page();
|
||||
copy.setTitle(original.getTitle() + " (Copy)");
|
||||
copy.setContent(original.getContent());
|
||||
copy.setPageType(original.getPageType());
|
||||
copy.setDisplayOrder(original.getDisplayOrder());
|
||||
copy.setStatus(PageStatus.DRAFT);
|
||||
|
||||
return save(copy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,4 +171,30 @@ public class PostService {
|
||||
}
|
||||
return postRepository.existsBySlugAndIdNot(slug, currentPostId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing post.
|
||||
* Creates a new draft copy with "(Copy)" appended to the title.
|
||||
* @param id the ID of the post to duplicate
|
||||
* @return the newly created post
|
||||
*/
|
||||
public Post duplicate(Long id) {
|
||||
LOG.debug("Request to duplicate Post : {}", id);
|
||||
Post original = postRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid post Id:" + id));
|
||||
|
||||
Post copy = new Post();
|
||||
copy.setTitle(original.getTitle() + " (Copy)");
|
||||
copy.setContent(original.getContent());
|
||||
copy.setExcerpt(original.getExcerpt());
|
||||
copy.setFeaturedImage(original.getFeaturedImage());
|
||||
copy.setLayout(original.getLayout());
|
||||
copy.setCategory(original.getCategory());
|
||||
copy.setTags(new HashSet<>(original.getTags()));
|
||||
copy.setMetaDescription(original.getMetaDescription());
|
||||
copy.setEventTime(original.getEventTime());
|
||||
copy.setStatus(PageStatus.DRAFT);
|
||||
|
||||
return save(copy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
<body>
|
||||
<div style="display: flex; gap: 40px; margin: 40px 0; padding: 0 20px;">
|
||||
<div style="flex: 2; min-width: 300px;">
|
||||
[component:news-grid data-source="posts:tag=news,limit=4,collection=news_posts"]
|
||||
<div class="cc--component-container cc--news-events">
|
||||
<div class="c--component c--news-events">
|
||||
<!-- Header -->
|
||||
<div class="news-events-section__header">
|
||||
<h2 class="news-events-section__main-title">TIN TỨC & SỰ KIỆN</h2>
|
||||
<a class="news-events-section__cta news-events-section__cta--desktop" href="/news/news-events">Xem Thêm</a>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 300px;">
|
||||
[component:events-list data-source="posts:tag=event,limit=4,collection=event_posts"]
|
||||
|
||||
<!-- News & Events Row (2fr / 1fr grid) -->
|
||||
<div class="news-events-row">
|
||||
<div class="news-column">
|
||||
<h3 class="section-title--news">TIN MỚI NHẤT</h3>
|
||||
<div class="news-list">
|
||||
[component:news-grid data-source="posts:tag=news,limit=4,collection=news_posts"]
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="events-column">
|
||||
<h3 class="events-title">Sự Kiện</h3>
|
||||
<div class="events-widget">
|
||||
[component:events-list data-source="posts:tag=event,limit=4,collection=event_posts"]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Footer CTA -->
|
||||
<div class="news-events-section__footer">
|
||||
<a class="news-events-section__cta news-events-section__cta--mobile" href="/news/news-events">Xem Thêm</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</div>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
|
||||
|
||||
<changeSet id="20260714080000-1" author="antigravity">
|
||||
<insert tableName="sis_component_template">
|
||||
<column name="id" valueComputed="(select coalesce(max(id), 0) + 1 from sis_component_template)"/>
|
||||
<column name="slug" value="news-grid"/>
|
||||
<column name="name" value="News Grid"/>
|
||||
<column name="description" value="Dynamic grid layout for news."/>
|
||||
<column name="active" valueBoolean="true"/>
|
||||
<column name="html_template"><![CDATA[{{#each news_posts}}
|
||||
<div class="featured-grid__item">
|
||||
<article class="news-card news-card--featured">
|
||||
<div class="news-card__image">
|
||||
<div>
|
||||
<img loading="lazy" src="{{featuredImageUrl}}" alt="{{title}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="news-card__content">
|
||||
<div class="featured-grid__category">
|
||||
<a href="#">TIN TỨC</a>
|
||||
</div>
|
||||
<h3 class="news-card__title">
|
||||
<a href="/post/{{slug}}" rel="bookmark">
|
||||
<span>{{title}}</span>
|
||||
</a>
|
||||
</h3>
|
||||
<div class="news-card__date">{{date}}</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{{/each}}]]></column>
|
||||
<column name="created_date" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
</insert>
|
||||
|
||||
<insert tableName="sis_component_template">
|
||||
<column name="id" valueComputed="(select coalesce(max(id), 0) + 1 from sis_component_template)"/>
|
||||
<column name="slug" value="events-list"/>
|
||||
<column name="name" value="Events List"/>
|
||||
<column name="description" value="List layout for events."/>
|
||||
<column name="active" valueBoolean="true"/>
|
||||
<column name="html_template"><![CDATA[<style>
|
||||
.campus-events-widget { background: transparent; padding: 0; font-family: inherit; }
|
||||
.campus-events-widget .events-list { list-style: none; margin: 0; padding: 0; }
|
||||
.campus-events-widget .teaser-item { padding: 20px 0; border-bottom: 1px solid #e5e5e5; }
|
||||
.campus-events-widget .teaser-item:last-child { border-bottom: none; }
|
||||
.campus-events-widget .event-wrapper { display: flex; gap: 20px; align-items: flex-start; }
|
||||
.campus-events-widget .event-date { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 0.125rem; padding: 0.25rem; background: #881C1C; width: 2.875rem; height: 3.125rem; box-sizing: border-box; }
|
||||
.campus-events-widget .event-date__month { font-size: 1rem; font-weight: 800; line-height: 1.25rem; text-transform: uppercase; text-align: center; color: #fff; }
|
||||
.campus-events-widget .event-date__day { font-size: 1rem; font-weight: 800; line-height: 1.25rem; text-transform: uppercase; text-align: center; color: #fff; }
|
||||
.campus-events-widget .event-details { flex: 1; position: relative; }
|
||||
.campus-events-widget .event-title { font-size: 1rem; font-weight: 800; line-height: 1.25rem; text-transform: uppercase; color: #000; }
|
||||
.campus-events-widget .event-title a { color: inherit; text-decoration: none; }
|
||||
.campus-events-widget .event-title a:hover { text-decoration: underline; }
|
||||
.campus-events-widget .event-time { font-size: 16px; color: #000; margin-bottom: 4px; }
|
||||
.campus-events-widget .event-location { font-size: 1rem; font-weight: 400; line-height: 1.5rem; font-style: normal; color: #000; }
|
||||
@media (max-width: 768px) {
|
||||
.campus-events-widget .event-title { font-size: 0.75rem; }
|
||||
.campus-events-widget .event-time { font-size: 0.75rem; }
|
||||
.campus-events-widget .event-location { font-size: 0.75rem; }
|
||||
.campus-events-widget .event-wrapper { display: flex; flex: 1; padding-right: 10px; align-items: stretch; gap: 12px; border: 1px solid #CCC; }
|
||||
.campus-events-widget .event-date { display: flex; padding: 4px 8px; flex-direction: column; justify-content: center; align-items: center; align-self: stretch; background: #881C1C; min-width: 48px; height: auto; }
|
||||
.campus-events-widget .event-details { padding: 8px 10px 8px 0; }
|
||||
.campus-events-widget .teaser-item { flex: 0 0 279px; display: flex; border: 0px solid #CCC; padding-bottom: 0; }
|
||||
.campus-events-widget .events-list { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 1rem; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.campus-events-widget .event-wrapper { gap: 10px; }
|
||||
.campus-events-widget .event-date__day { font-size: 18px; }
|
||||
}
|
||||
</style>
|
||||
<div class="campus-events-widget">
|
||||
<div class="events-list">
|
||||
{{#each event_posts}}
|
||||
<div class="teaser-item">
|
||||
<div class="event-wrapper">
|
||||
<div class="event-date">
|
||||
<div class="event-date__month">{{eventMonth}}</div>
|
||||
<div class="event-date__day">{{eventDay}}</div>
|
||||
</div>
|
||||
<div class="event-details">
|
||||
<div class="event-title">
|
||||
<a href="/post/{{slug}}">{{title}}</a>
|
||||
</div>
|
||||
<div class="event-time">{{eventTimeStr}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>]]></column>
|
||||
<column name="created_date" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
</insert>
|
||||
<changeSet id="20260714080000-3" author="antigravity" runAlways="true">
|
||||
<update tableName="sis_component_template">
|
||||
<column name="html_template"><![CDATA[{{#each news_posts}}
|
||||
<div class="featured-grid__item">
|
||||
<article class="news-card news-card--featured">
|
||||
<div class="news-card__image">
|
||||
<div>
|
||||
<img loading="lazy" src="{{featuredImageUrl}}" alt="{{title}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="news-card__content">
|
||||
<div class="featured-grid__category">
|
||||
<a href="#">TIN TỨC</a>
|
||||
</div>
|
||||
<h3 class="news-card__title">
|
||||
<a href="/post/{{slug}}" rel="bookmark">
|
||||
<span>{{title}}</span>
|
||||
</a>
|
||||
</h3>
|
||||
<div class="news-card__date">{{date}}</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{{/each}}]]></column>
|
||||
<where>slug = 'news-grid'</where>
|
||||
</update>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
|
||||
|
||||
<changeSet id="20260714080000-2" author="antigravity" runAlways="true">
|
||||
<update tableName="sis_component_template">
|
||||
<column name="html_template" valueComputed="REPLACE(html_template, '\n', '')"/>
|
||||
<where>slug IN ('news-grid', 'events-list')</where>
|
||||
</update>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
@@ -36,4 +36,5 @@
|
||||
<!-- jhipster-needle-liquibase-add-changelog - JHipster will add liquibase changelogs here -->
|
||||
<!-- jhipster-needle-liquibase-add-constraints-changelog - JHipster will add liquibase constraints changelogs here -->
|
||||
<!-- jhipster-needle-liquibase-add-incremental-changelog - JHipster will add incremental liquibase changelogs here -->
|
||||
<include file="config/liquibase/changelog/20260714080000_seed_news_events_components.xml" relativeToChangelogFile="false"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -67,16 +67,23 @@
|
||||
<td th:text="${page.displayOrder}"></td>
|
||||
<td th:text="${page.lastModifiedDate != null} ? ${#dates.format(page.lastModifiedDateAsDate, 'yyyy-MM-dd HH:mm')} : '-'"></td>
|
||||
<td>
|
||||
<a th:href="@{/manage/pages/{id}/edit(id=${page.id})}" class="btn btn-sm btn-info" title="Edit">
|
||||
<a th:href="@{/manage/pages/{id}/edit(id=${page.id})}" class="btn btn-sm btn-info mb-1" title="Edit">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<form th:action="@{/manage/pages/{id}/delete(id=${page.id})}" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
|
||||
<button type="submit" class="btn btn-sm btn-danger mb-1" title="Delete">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
<form th:action="@{/manage/pages/{id}/duplicate(id=${page.id})}" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Are you sure you want to duplicate this page?');">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<button type="submit" class="btn btn-sm btn-secondary mb-1" title="Duplicate">
|
||||
<i class="fas fa-copy"></i> Duplicate
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(pages)}">
|
||||
|
||||
@@ -267,6 +267,14 @@
|
||||
</select>
|
||||
<small class="form-text text-muted">Controls the visual appearance on the frontend.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-3" id="eventTimeGroup" style="display: none;">
|
||||
<label for="postEventTime" class="font-weight-bold text-danger">
|
||||
<i class="far fa-calendar-alt"></i> Event Time
|
||||
</label>
|
||||
<input type="datetime-local" class="form-control" id="postEventTime" th:field="*{eventTimeLocal}">
|
||||
<small class="form-text text-muted">Specify the date and time for this event.</small>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a th:href="@{/manage/posts}" class="btn btn-secondary btn-sm">
|
||||
@@ -469,6 +477,19 @@
|
||||
slugPreview.textContent = slug || 'auto-generated';
|
||||
}
|
||||
});
|
||||
|
||||
// --- Event Time Layout Toggle ---
|
||||
var layoutSelect = document.getElementById('postLayout');
|
||||
var eventTimeGroup = document.getElementById('eventTimeGroup');
|
||||
function toggleEventTime() {
|
||||
if (layoutSelect.value === 'EVENT') {
|
||||
eventTimeGroup.style.display = 'block';
|
||||
} else {
|
||||
eventTimeGroup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
layoutSelect.addEventListener('change', toggleEventTime);
|
||||
toggleEventTime(); // Initial check
|
||||
});
|
||||
|
||||
// --- Tag click-to-add helper ---
|
||||
|
||||
@@ -130,10 +130,17 @@
|
||||
<form th:action="@{/manage/posts/{id}/delete(id=${post.id})}" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this post?');">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger mr-1" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form th:action="@{/manage/posts/{id}/duplicate(id=${post.id})}" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Are you sure you want to duplicate this post?');">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" title="Duplicate">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(posts)}">
|
||||
|
||||
Reference in New Issue
Block a user