feat: implement page/post duplication, add event layout, auto-extract featured images, and include HTML escaping for templates.

This commit is contained in:
2026-07-14 09:08:09 +07:00
parent 5513448738
commit 1b905d8c60
23 changed files with 522 additions and 123 deletions
+20
View File
@@ -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();
}
}
}
+19
View File
@@ -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();
}
}
}
+32
View File
@@ -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();
}
}
}
+40
View File
@@ -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();
}
}
}
+33
View File
@@ -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();
}
}
}
+26
View File
@@ -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();
}
}
}
+10
View File
@@ -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 =====");
}
}
@@ -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";
}
}
@@ -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.
*/
@@ -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
}
@@ -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 &amp; 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>
@@ -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)}">