bổ sung thêm các chức năng còn thiếu của appearance

This commit is contained in:
2026-07-02 18:48:00 +07:00
parent e91e607d14
commit 302785b60b
28 changed files with 1265 additions and 29 deletions
@@ -15,10 +15,12 @@ import java.util.Map;
public class GlobalControllerAdvice { public class GlobalControllerAdvice {
private final SettingService settingService; private final SettingService settingService;
private final com.sisvietnamvn.web.service.MenuService menuService;
private final PathMatchingResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver(); private final PathMatchingResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
public GlobalControllerAdvice(SettingService settingService) { public GlobalControllerAdvice(SettingService settingService, com.sisvietnamvn.web.service.MenuService menuService) {
this.settingService = settingService; this.settingService = settingService;
this.menuService = menuService;
} }
/** /**
@@ -41,4 +43,48 @@ public class GlobalControllerAdvice {
return activeTheme; return activeTheme;
} }
@ModelAttribute("primaryMenu")
public com.sisvietnamvn.web.domain.Menu getPrimaryMenu() {
return menuService.findByLocation("PRIMARY").orElse(null);
}
@ModelAttribute("footerMenu")
public com.sisvietnamvn.web.domain.Menu getFooterMenu() {
return menuService.findByLocation("FOOTER").orElse(null);
}
@ModelAttribute("sidebarWidgets")
public java.util.List<com.sisvietnamvn.web.service.dto.WidgetDto> getSidebarWidgets() {
return getWidgets("sidebar");
}
@ModelAttribute("footerWidgets")
public java.util.List<com.sisvietnamvn.web.service.dto.WidgetDto> getFooterWidgets() {
return getWidgets("footer");
}
@ModelAttribute("themeModPrimaryColor")
public String getThemeModPrimaryColor() {
return settingService.getValue("theme_mod_primaryColor", "#007bff");
}
@ModelAttribute("themeModFontFamily")
public String getThemeModFontFamily() {
return settingService.getValue("theme_mod_fontFamily", "sans-serif");
}
@ModelAttribute("themeModCustomCss")
public String getThemeModCustomCss() {
return settingService.getValue("theme_mod_customCss", "");
}
private java.util.List<com.sisvietnamvn.web.service.dto.WidgetDto> getWidgets(String area) {
String json = settingService.getValue("theme_widgets_" + area, "[]");
try {
return new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, new com.fasterxml.jackson.core.type.TypeReference<java.util.List<com.sisvietnamvn.web.service.dto.WidgetDto>>() {});
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
return new java.util.ArrayList<>();
}
}
} }
@@ -0,0 +1,44 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.service.SettingService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controller for the Theme Customizer in the Admin panel.
*/
@Controller
@RequestMapping("/manage/customize")
@PreAuthorize("hasAnyAuthority(\"" + AuthoritiesConstants.ADMIN + "\", \"" + AuthoritiesConstants.EDITOR + "\")")
public class ManageCustomizeController {
private final SettingService settingService;
public ManageCustomizeController(SettingService settingService) {
this.settingService = settingService;
}
@GetMapping
public String customizerView(Model model) {
// Load current customizer settings
model.addAttribute("primaryColor", settingService.getValue("theme_mod_primaryColor", "#007bff"));
model.addAttribute("fontFamily", settingService.getValue("theme_mod_fontFamily", "sans-serif"));
model.addAttribute("customCss", settingService.getValue("theme_mod_customCss", ""));
return "manage/customize/index";
}
@PostMapping("/save")
public String saveCustomizer(@RequestParam String primaryColor, @RequestParam String fontFamily, @RequestParam String customCss, RedirectAttributes redirectAttributes) {
settingService.setValue("theme_mod_primaryColor", primaryColor);
settingService.setValue("theme_mod_fontFamily", fontFamily);
settingService.setValue("theme_mod_customCss", customCss);
redirectAttributes.addFlashAttribute("successMessage", "Customizations saved securely.");
return "redirect:/manage/customize";
}
}
@@ -0,0 +1,72 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Menu;
import com.sisvietnamvn.web.domain.MenuItem;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.service.MenuService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controller for managing Navigation Menus in the Admin panel.
*/
@Controller
@RequestMapping("/manage/menus")
@PreAuthorize("hasAnyAuthority(\"" + AuthoritiesConstants.ADMIN + "\", \"" + AuthoritiesConstants.EDITOR + "\")")
public class ManageMenuController {
private final MenuService menuService;
public ManageMenuController(MenuService menuService) {
this.menuService = menuService;
}
@GetMapping
public String listMenus(Model model) {
model.addAttribute("menus", menuService.findAll());
return "manage/menus/list";
}
@PostMapping("/create")
public String createMenu(@RequestParam String name, @RequestParam(required = false) String location, RedirectAttributes redirectAttributes) {
Menu menu = new Menu();
menu.setName(name);
menu.setLocation(location);
menuService.save(menu);
redirectAttributes.addFlashAttribute("successMessage", "Menu created successfully.");
return "redirect:/manage/menus";
}
@GetMapping("/{id}/edit")
public String editMenu(@PathVariable Long id, Model model) {
Menu menu = menuService.findById(id).orElseThrow(() -> new IllegalArgumentException("Invalid menu Id"));
model.addAttribute("menu", menu);
return "manage/menus/edit";
}
@PostMapping("/{id}/items/add")
public String addMenuItem(@PathVariable Long id, @RequestParam String label, @RequestParam String url, RedirectAttributes redirectAttributes) {
Menu menu = menuService.findById(id).orElseThrow(() -> new IllegalArgumentException("Invalid menu Id"));
MenuItem item = new MenuItem();
item.setLabel(label);
item.setUrl(url);
item.setMenu(menu);
item.setDisplayOrder(menu.getItems().size());
menu.getItems().add(item);
menuService.save(menu);
redirectAttributes.addFlashAttribute("successMessage", "Menu item added.");
return "redirect:/manage/menus/" + id + "/edit";
}
@PostMapping("/{id}/delete")
public String deleteMenu(@PathVariable Long id, RedirectAttributes redirectAttributes) {
menuService.deleteById(id);
redirectAttributes.addFlashAttribute("successMessage", "Menu deleted successfully.");
return "redirect:/manage/menus";
}
}
@@ -95,4 +95,72 @@ public class ManageThemeController {
redirectAttributes.addFlashAttribute("successMessage", "New theme activated successfully."); redirectAttributes.addFlashAttribute("successMessage", "New theme activated successfully.");
return "redirect:/manage/themes"; return "redirect:/manage/themes";
} }
@PostMapping("/delete")
public String deleteTheme(@RequestParam("themeKey") String themeKey, RedirectAttributes redirectAttributes) {
String activeTheme = settingService.getValue("active_theme", "default");
if (themeKey.equals(activeTheme)) {
redirectAttributes.addFlashAttribute("errorMessage", "Cannot delete the active theme.");
return "redirect:/manage/themes";
}
try {
java.nio.file.Path themePath = java.nio.file.Paths.get("src/main/resources/templates/themes/", themeKey);
if (java.nio.file.Files.exists(themePath)) {
// Delete directory recursively
org.springframework.util.FileSystemUtils.deleteRecursively(themePath);
redirectAttributes.addFlashAttribute("successMessage", "Theme deleted successfully.");
} else {
redirectAttributes.addFlashAttribute("errorMessage", "Theme folder not found.");
}
} catch (java.io.IOException e) {
redirectAttributes.addFlashAttribute("errorMessage", "Error deleting theme: " + e.getMessage());
}
return "redirect:/manage/themes";
}
@PostMapping("/upload")
public String uploadTheme(@RequestParam("file") org.springframework.web.multipart.MultipartFile file, RedirectAttributes redirectAttributes) {
if (file.isEmpty() || !file.getOriginalFilename().endsWith(".zip")) {
redirectAttributes.addFlashAttribute("errorMessage", "Please select a valid .zip file.");
return "redirect:/manage/themes";
}
try {
java.nio.file.Path targetDir = java.nio.file.Paths.get("src/main/resources/templates/themes/");
java.util.zip.ZipInputStream zis = new java.util.zip.ZipInputStream(file.getInputStream());
java.util.zip.ZipEntry zipEntry = zis.getNextEntry();
while (zipEntry != null) {
java.nio.file.Path newPath = zipSlipProtect(zipEntry, targetDir);
if (zipEntry.isDirectory()) {
java.nio.file.Files.createDirectories(newPath);
} else {
if (newPath.getParent() != null) {
if (java.nio.file.Files.notExists(newPath.getParent())) {
java.nio.file.Files.createDirectories(newPath.getParent());
}
}
java.nio.file.Files.copy(zis, newPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
zipEntry = zis.getNextEntry();
}
zis.closeEntry();
zis.close();
redirectAttributes.addFlashAttribute("successMessage", "Theme uploaded and extracted successfully.");
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", "Failed to upload theme: " + e.getMessage());
}
return "redirect:/manage/themes";
}
private java.nio.file.Path zipSlipProtect(java.util.zip.ZipEntry zipEntry, java.nio.file.Path targetDir) throws java.io.IOException {
java.nio.file.Path targetDirResolved = targetDir.resolve(zipEntry.getName());
java.nio.file.Path normalizePath = targetDirResolved.normalize();
if (!normalizePath.startsWith(targetDir)) {
throw new java.io.IOException("Bad zip entry: " + zipEntry.getName());
}
return normalizePath;
}
} }
@@ -0,0 +1,66 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.io.IOException;
import java.nio.file.*;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Controller for the Theme File Editor.
*/
@Controller
@RequestMapping("/manage/theme-editor")
@PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")")
public class ManageThemeEditorController {
private final Path themesBaseDir = Paths.get("src/main/resources/templates/themes").toAbsolutePath().normalize();
@GetMapping
public String editorView(@RequestParam(required = false) String file, Model model) {
try (Stream<Path> walk = Files.walk(themesBaseDir)) {
List<String> fileTree = walk.filter(Files::isRegularFile)
.map(path -> themesBaseDir.relativize(path).toString())
.collect(Collectors.toList());
model.addAttribute("files", fileTree);
if (file != null && !file.isEmpty()) {
Path targetPath = themesBaseDir.resolve(file).normalize();
if (targetPath.startsWith(themesBaseDir) && Files.exists(targetPath)) {
String content = Files.readString(targetPath);
model.addAttribute("selectedFile", file);
model.addAttribute("fileContent", content);
} else {
model.addAttribute("errorMessage", "Invalid file path.");
}
}
} catch (IOException e) {
model.addAttribute("errorMessage", "Error loading theme files: " + e.getMessage());
}
return "manage/themes/editor";
}
@PostMapping("/save")
public String saveFile(@RequestParam("selectedFile") String file, @RequestParam("fileContent") String content, RedirectAttributes redirectAttributes) {
try {
Path targetPath = themesBaseDir.resolve(file).normalize();
if (targetPath.startsWith(themesBaseDir) && Files.exists(targetPath)) {
Files.writeString(targetPath, content, StandardOpenOption.TRUNCATE_EXISTING);
redirectAttributes.addFlashAttribute("successMessage", "File saved successfully.");
return "redirect:/manage/theme-editor?file=" + file;
} else {
redirectAttributes.addFlashAttribute("errorMessage", "Security violation: Invalid file path.");
}
} catch (IOException e) {
redirectAttributes.addFlashAttribute("errorMessage", "Error saving file: " + e.getMessage());
}
return "redirect:/manage/theme-editor";
}
}
@@ -0,0 +1,85 @@
package com.sisvietnamvn.web.controller.manage;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.service.SettingService;
import com.sisvietnamvn.web.service.dto.WidgetDto;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Controller for managing Theme Widgets in the Admin panel.
*/
@Controller
@RequestMapping("/manage/widgets")
@PreAuthorize("hasAnyAuthority(\"" + AuthoritiesConstants.ADMIN + "\", \"" + AuthoritiesConstants.EDITOR + "\")")
public class ManageWidgetController {
private final SettingService settingService;
private final ObjectMapper objectMapper;
public ManageWidgetController(SettingService settingService, ObjectMapper objectMapper) {
this.settingService = settingService;
this.objectMapper = objectMapper;
}
private List<WidgetDto> getWidgets(String area) {
String json = settingService.getValue("theme_widgets_" + area, "[]");
try {
return objectMapper.readValue(json, new TypeReference<List<WidgetDto>>() {});
} catch (JsonProcessingException e) {
return new ArrayList<>();
}
}
private void saveWidgets(String area, List<WidgetDto> widgets) {
try {
String json = objectMapper.writeValueAsString(widgets);
settingService.setValue("theme_widgets_" + area, json);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
@GetMapping
public String listWidgets(Model model) {
model.addAttribute("sidebarWidgets", getWidgets("sidebar"));
model.addAttribute("footerWidgets", getWidgets("footer"));
return "manage/widgets/list";
}
@PostMapping("/add")
public String addWidget(@RequestParam String area, @RequestParam String type, @RequestParam String title, @RequestParam String content, RedirectAttributes redirectAttributes) {
List<WidgetDto> widgets = getWidgets(area);
WidgetDto widget = new WidgetDto();
widget.setId(UUID.randomUUID().toString());
widget.setType(type);
widget.setTitle(title);
widget.setContent(content);
widgets.add(widget);
saveWidgets(area, widgets);
redirectAttributes.addFlashAttribute("successMessage", "Widget added to " + area);
return "redirect:/manage/widgets";
}
@PostMapping("/delete")
public String deleteWidget(@RequestParam String area, @RequestParam String id, RedirectAttributes redirectAttributes) {
List<WidgetDto> widgets = getWidgets(area);
widgets.removeIf(w -> w.getId().equals(id));
saveWidgets(area, widgets);
redirectAttributes.addFlashAttribute("successMessage", "Widget removed.");
return "redirect:/manage/widgets";
}
}
@@ -0,0 +1,85 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* A Menu entity for managing navigation menus.
* Maps to the "sis_menu" database table.
*/
@Entity
@Table(name = "sis_menu")
public class Menu extends AbstractAuditingEntity<Long> {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@NotNull
@Size(max = 255)
@Column(name = "name", length = 255, nullable = false)
private String name;
@Size(max = 100)
@Column(name = "location", length = 100)
private String location;
@OneToMany(mappedBy = "menu", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("displayOrder ASC")
private List<MenuItem> items = new ArrayList<>();
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public List<MenuItem> getItems() {
return items;
}
public void setItems(List<MenuItem> items) {
this.items = items;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Menu menu = (Menu) o;
return Objects.equals(id, menu.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
@@ -0,0 +1,106 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.util.Objects;
/**
* A MenuItem entity representing a single link in a Menu.
* Maps to the "sis_menu_item" database table.
*/
@Entity
@Table(name = "sis_menu_item")
public class MenuItem extends AbstractAuditingEntity<Long> {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "menu_id", nullable = false)
private Menu menu;
@NotNull
@Size(max = 255)
@Column(name = "label", length = 255, nullable = false)
private String label;
@Size(max = 500)
@Column(name = "url", length = 500)
private String url;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private MenuItem parent;
@Column(name = "display_order")
private Integer displayOrder = 0;
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Menu getMenu() {
return menu;
}
public void setMenu(Menu menu) {
this.menu = menu;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public MenuItem getParent() {
return parent;
}
public void setParent(MenuItem parent) {
this.parent = parent;
}
public Integer getDisplayOrder() {
return displayOrder;
}
public void setDisplayOrder(Integer displayOrder) {
this.displayOrder = displayOrder;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MenuItem menuItem = (MenuItem) o;
return Objects.equals(id, menuItem.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
@@ -0,0 +1,12 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.MenuItem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* Spring Data JPA repository for the MenuItem entity.
*/
@Repository
public interface MenuItemRepository extends JpaRepository<MenuItem, Long> {
}
@@ -0,0 +1,15 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.Menu;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Spring Data JPA repository for the Menu entity.
*/
@Repository
public interface MenuRepository extends JpaRepository<Menu, Long> {
Optional<Menu> findByLocation(String location);
}
@@ -0,0 +1,43 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Menu;
import com.sisvietnamvn.web.repository.MenuRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
public class MenuService {
private final MenuRepository menuRepository;
public MenuService(MenuRepository menuRepository) {
this.menuRepository = menuRepository;
}
@Transactional(readOnly = true)
public List<Menu> findAll() {
return menuRepository.findAll();
}
@Transactional(readOnly = true)
public Optional<Menu> findById(Long id) {
return menuRepository.findById(id);
}
@Transactional(readOnly = true)
public Optional<Menu> findByLocation(String location) {
return menuRepository.findByLocation(location);
}
public Menu save(Menu menu) {
return menuRepository.save(menu);
}
public void deleteById(Long id) {
menuRepository.deleteById(id);
}
}
@@ -0,0 +1,40 @@
package com.sisvietnamvn.web.service.dto;
public class WidgetDto {
private String id;
private String type; // "HTML", "TEXT"
private String title;
private String content;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
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-latest.xsd">
<changeSet id="20260702184000-1" author="system">
<createTable tableName="sis_menu">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="location" type="varchar(100)"/>
<column name="created_by" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="created_date" type="timestamp"/>
<column name="last_modified_by" type="varchar(50)"/>
<column name="last_modified_date" type="timestamp"/>
</createTable>
<createTable tableName="sis_menu_item">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="menu_id" type="bigint">
<constraints nullable="false"/>
</column>
<column name="label" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="url" type="varchar(500)"/>
<column name="parent_id" type="bigint"/>
<column name="display_order" type="integer"/>
<column name="created_by" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="created_date" type="timestamp"/>
<column name="last_modified_by" type="varchar(50)"/>
<column name="last_modified_date" type="timestamp"/>
</createTable>
<addForeignKeyConstraint baseColumnNames="menu_id"
baseTableName="sis_menu_item"
constraintName="fk_menu_item_menu_id"
referencedColumnNames="id"
referencedTableName="sis_menu"/>
<addForeignKeyConstraint baseColumnNames="parent_id"
baseTableName="sis_menu_item"
constraintName="fk_menu_item_parent_id"
referencedColumnNames="id"
referencedTableName="sis_menu_item"/>
</changeSet>
</databaseChangeLog>
@@ -29,6 +29,7 @@
<include file="config/liquibase/changelog/20260702143000_add_media_entity.xml" relativeToChangelogFile="false"/> <include file="config/liquibase/changelog/20260702143000_add_media_entity.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260702144700_add_plugin_entity.xml" relativeToChangelogFile="false"/> <include file="config/liquibase/changelog/20260702144700_add_plugin_entity.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260702153000_add_setting_entity.xml" relativeToChangelogFile="false"/> <include file="config/liquibase/changelog/20260702153000_add_setting_entity.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260702184000_add_menu_entity.xml" relativeToChangelogFile="false"/>
<!-- jhipster-needle-liquibase-add-changelog - JHipster will add liquibase changelogs here --> <!-- 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-constraints-changelog - JHipster will add liquibase constraints changelogs here -->
<!-- jhipster-needle-liquibase-add-incremental-changelog - JHipster will add incremental liquibase changelogs here --> <!-- jhipster-needle-liquibase-add-incremental-changelog - JHipster will add incremental liquibase changelogs here -->
@@ -124,6 +124,10 @@
<div class="bg-white py-2 collapse-inner rounded"> <div class="bg-white py-2 collapse-inner rounded">
<h6 class="collapse-header">Design Management:</h6> <h6 class="collapse-header">Design Management:</h6>
<a class="collapse-item" th:href="@{/manage/themes}">Themes</a> <a class="collapse-item" th:href="@{/manage/themes}">Themes</a>
<a class="collapse-item" th:href="@{/manage/theme-editor}">Theme File Editor</a>
<a class="collapse-item" th:href="@{/manage/customize}">Customize</a>
<a class="collapse-item" th:href="@{/manage/menus}">Menus</a>
<a class="collapse-item" th:href="@{/manage/widgets}">Widgets</a>
</div> </div>
</div> </div>
</li> </li>
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Theme Customizer</title>
<link href="/vendor/fontawesome-free/css/all.min.css" rel="stylesheet" type="text/css">
<link href="/css/sb-admin-2.min.css" rel="stylesheet">
<style>
body, html { height: 100%; overflow: hidden; margin: 0; }
.customizer-container { display: flex; height: 100vh; }
.customizer-sidebar { width: 350px; background: #fff; box-shadow: 2px 0 5px rgba(0,0,0,0.1); z-index: 10; display: flex; flex-direction: column; }
.customizer-header { background: #343a40; color: #fff; padding: 15px; display: flex; justify-content: space-between; align-items: center; }
.customizer-body { flex: 1; overflow-y: auto; padding: 20px; }
.customizer-preview { flex: 1; background: #f8f9fc; }
.customizer-preview iframe { width: 100%; height: 100%; border: none; }
</style>
</head>
<body>
<div class="customizer-container">
<!-- Sidebar Settings -->
<div class="customizer-sidebar">
<div class="customizer-header">
<a th:href="@{/manage/themes}" class="text-white text-decoration-none"><i class="fas fa-times"></i> Close</a>
<h5 class="m-0">Customizer</h5>
</div>
<div class="customizer-body">
<div th:if="${successMessage}" class="alert alert-success" th:text="${successMessage}"></div>
<form th:action="@{/manage/customize/save}" method="post">
<div class="form-group">
<label for="primaryColor">Primary Theme Color</label>
<input type="color" class="form-control" id="primaryColor" name="primaryColor" th:value="${primaryColor}" style="height: 40px;">
</div>
<div class="form-group">
<label for="fontFamily">Base Font Family</label>
<select class="form-control" id="fontFamily" name="fontFamily">
<option value="sans-serif" th:selected="${fontFamily == 'sans-serif'}">Sans-Serif (Arial, Helvetica)</option>
<option value="serif" th:selected="${fontFamily == 'serif'}">Serif (Times New Roman)</option>
<option value="monospace" th:selected="${fontFamily == 'monospace'}">Monospace (Courier)</option>
</select>
</div>
<div class="form-group">
<label for="customCss">Custom CSS</label>
<textarea class="form-control" id="customCss" name="customCss" rows="10" th:text="${customCss}" style="font-family: monospace;"></textarea>
</div>
<button type="submit" class="btn btn-primary btn-block">Publish Changes</button>
</form>
</div>
</div>
<!-- Live Preview -->
<div class="customizer-preview">
<iframe src="/" id="previewFrame" name="previewFrame"></iframe>
</div>
</div>
</body>
</html>
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title>Edit Menu Items</title>
</head>
<body>
<div layout:fragment="content">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800" th:text="'Editing Menu: ' + ${menu.name}">Edit Menu</h1>
<a th:href="@{/manage/menus}" class="d-none d-sm-inline-block btn btn-sm btn-secondary shadow-sm"><i class="fas fa-arrow-left fa-sm text-white-50"></i> Back to Menus</a>
</div>
<!-- Success Message -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<!-- Add Item Form -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Add Custom Link</h6>
</div>
<div class="card-body">
<form th:action="@{/manage/menus/{id}/items/add(id=${menu.id})}" method="post">
<div class="form-group">
<label for="label">Link Text</label>
<input type="text" class="form-control" id="label" name="label" placeholder="e.g. Home" required>
</div>
<div class="form-group">
<label for="url">URL</label>
<input type="text" class="form-control" id="url" name="url" placeholder="e.g. /" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Add to Menu</button>
</form>
</div>
</div>
</div>
<!-- Menu Items List -->
<div class="col-lg-8">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Menu Structure</h6>
</div>
<div class="card-body">
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" th:each="item : ${menu.items}">
<div>
<strong th:text="${item.label}">Link Text</strong><br>
<small class="text-muted" th:text="${item.url}">URL</small>
</div>
<!-- Future enhancement: reorder buttons and delete button -->
<span class="badge badge-primary badge-pill" th:text="'Order: ' + ${item.displayOrder}">0</span>
</li>
</ul>
<div th:if="${menu.items.empty}" class="alert alert-info mt-3">
No items in this menu yet.
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title>Manage Menus</title>
</head>
<body>
<div layout:fragment="content">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Menus</h1>
</div>
<!-- Success Message -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Create New Menu</h6>
</div>
<div class="card-body">
<form th:action="@{/manage/menus/create}" method="post">
<div class="form-group">
<label for="name">Menu Name</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="form-group">
<label for="location">Location</label>
<select class="form-control" id="location" name="location">
<option value="">-- No Location Assigned --</option>
<option value="PRIMARY">Primary Navigation</option>
<option value="FOOTER">Footer Navigation</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-block">Create Menu</button>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Existing Menus</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>Name</th>
<th>Location</th>
<th>Items Count</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="menu : ${menus}">
<td th:text="${menu.name}">Menu Name</td>
<td th:text="${menu.location ?: 'Unassigned'}">Location</td>
<td th:text="${menu.items.size()}">0</td>
<td>
<a th:href="@{/manage/menus/{id}/edit(id=${menu.id})}" class="btn btn-sm btn-info">Edit Items</a>
<form th:action="@{/manage/menus/{id}/delete(id=${menu.id})}" method="post" style="display:inline;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to delete this menu?');">Delete</button>
</form>
</td>
</tr>
<tr th:if="${menus.empty}">
<td colspan="4" class="text-center">No menus found.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title>Theme File Editor</title>
</head>
<body>
<div layout:fragment="content">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Theme File Editor</h1>
</div>
<div th:if="${successMessage}" class="alert alert-success" th:text="${successMessage}"></div>
<div th:if="${errorMessage}" class="alert alert-danger" th:text="${errorMessage}"></div>
<div class="row">
<!-- File Tree Sidebar -->
<div class="col-lg-3">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Theme Files</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush" style="max-height: 600px; overflow-y: auto;">
<a th:each="f : ${files}"
th:href="@{/manage/theme-editor(file=${f})}"
class="list-group-item list-group-item-action"
th:classappend="${selectedFile == f} ? 'active' : ''"
style="font-size: 0.9em; padding: 10px 15px;">
<i class="fas fa-file-code fa-sm mr-2" th:classappend="${selectedFile == f} ? 'text-white' : 'text-gray-500'"></i>
<span th:text="${f}">file.html</span>
</a>
</div>
</div>
</div>
</div>
<!-- Code Editor -->
<div class="col-lg-9">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary" th:text="${selectedFile != null ? 'Editing: ' + selectedFile : 'Select a file to edit'}">Editor</h6>
</div>
<div class="card-body">
<div th:if="${selectedFile == null}" class="text-center py-5 text-muted">
<i class="fas fa-code fa-3x mb-3"></i>
<p>Select a file from the sidebar to start editing.</p>
</div>
<form th:if="${selectedFile != null}" th:action="@{/manage/theme-editor/save}" method="post">
<input type="hidden" name="selectedFile" th:value="${selectedFile}">
<div class="form-group">
<textarea class="form-control" name="fileContent" rows="25" th:text="${fileContent}" style="font-family: monospace; font-size: 14px; background-color: #272822; color: #f8f8f2;"></textarea>
</div>
<button type="submit" class="btn btn-primary"><i class="fas fa-save mr-2"></i> Update File</button>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -27,33 +27,53 @@
</button> </button>
</div> </div>
<!-- Theme Upload Form -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Upload New Theme</h6>
</div>
<div class="card-body">
<form th:action="@{/manage/themes/upload}" method="post" enctype="multipart/form-data" class="form-inline">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="form-group mb-2">
<input type="file" class="form-control-file" id="file" name="file" accept=".zip" required>
</div>
<button type="submit" class="btn btn-primary mb-2 ml-3">Upload Theme (.zip)</button>
</form>
<small class="form-text text-muted">The zip file should contain the theme folder structure (e.g., layout.html, header.html, footer.html).</small>
</div>
</div>
<!-- Available Themes Grid -->
<div class="row"> <div class="row">
<div class="col-lg-4 col-md-6 mb-4" th:each="theme : ${availableThemes}">
<div class="card h-100 shadow-sm border-0" th:classappend="${activeTheme == theme} ? 'border-primary' : ''">
<div class="card-body text-center d-flex flex-column">
<i class="fas fa-palette fa-3x mb-3" th:classappend="${activeTheme == theme} ? 'text-primary' : 'text-secondary'"></i>
<h5 class="card-title text-uppercase font-weight-bold" th:text="${theme}">Theme Name</h5>
<!-- Dynamic Layout Cards --> <div class="mt-auto d-flex justify-content-center">
<div class="col-lg-4 mb-4" th:each="theme : ${availableThemes}"> <!-- Active Badge -->
<div class="card shadow mb-4 h-100" th:classappend="${activeTheme == theme} ? 'border-left-primary' : ''"> <span th:if="${activeTheme == theme}" class="badge badge-success px-3 py-2">Active</span>
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary" th:text="${#strings.capitalizeWords(#strings.replace(theme, '-', ' '))} + ' Layout'">Theme Name</h6>
<span th:if="${activeTheme == theme}" class="badge badge-success">Active</span>
</div>
<div class="card-body d-flex flex-column">
<div class="bg-light p-5 text-center mb-3 border rounded">
<i class="fas fa-file-code fa-4x text-gray-400"></i>
</div>
<p class="card-text flex-grow-1">A custom layout file loaded from the templates/themes directory.</p>
<form th:if="${activeTheme != theme}" th:action="@{/manage/themes/activate}" method="post" class="mt-auto"> <!-- Activate Button -->
<form th:if="${activeTheme != theme}" th:action="@{/manage/themes/activate}" method="post" class="mr-2">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" /> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="themeKey" th:value="${theme}" /> <input type="hidden" name="themeKey" th:value="${theme}" />
<button type="submit" class="btn btn-primary btn-block">Activate</button> <button type="submit" class="btn btn-outline-primary btn-sm">Activate</button>
</form> </form>
<button th:if="${activeTheme == theme}" class="btn btn-secondary btn-block mt-auto" disabled>Customize</button>
</div>
</div>
</div>
<!-- Delete Button -->
<form th:if="${activeTheme != theme}" th:action="@{/manage/themes/delete}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="themeKey" th:value="${theme}" />
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Delete this theme completely?');">Delete</button>
</form>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</body> </body>
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title>Manage Widgets</title>
</head>
<body>
<div layout:fragment="content">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Widgets</h1>
</div>
<!-- Success Message -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<!-- Add Widget Form -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Add New Widget</h6>
</div>
<div class="card-body">
<form th:action="@{/manage/widgets/add}" method="post">
<div class="form-group">
<label for="area">Widget Area</label>
<select class="form-control" id="area" name="area">
<option value="sidebar">Sidebar</option>
<option value="footer">Footer</option>
</select>
</div>
<div class="form-group">
<label for="type">Widget Type</label>
<select class="form-control" id="type" name="type">
<option value="TEXT">Text</option>
<option value="HTML">Custom HTML</option>
</select>
</div>
<div class="form-group">
<label for="title">Title</label>
<input type="text" class="form-control" id="title" name="title" required>
</div>
<div class="form-group">
<label for="content">Content</label>
<textarea class="form-control" id="content" name="content" rows="4"></textarea>
</div>
<button type="submit" class="btn btn-primary btn-block">Add Widget</button>
</form>
</div>
</div>
</div>
<!-- Sidebar Widgets -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Sidebar Widgets</h6>
</div>
<div class="card-body">
<div class="list-group">
<div class="list-group-item" th:each="widget : ${sidebarWidgets}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1" th:text="${widget.title}">Title</h5>
<form th:action="@{/manage/widgets/delete}" method="post">
<input type="hidden" name="area" value="sidebar">
<input type="hidden" name="id" th:value="${widget.id}">
<button type="submit" class="btn btn-sm btn-danger"><i class="fas fa-trash"></i></button>
</form>
</div>
<small class="text-muted" th:text="${widget.type}">Type</small>
<p class="mb-1" th:text="${widget.content}">Content...</p>
</div>
<div th:if="${sidebarWidgets.empty}" class="text-center text-muted p-3">
No widgets in Sidebar.
</div>
</div>
</div>
</div>
</div>
<!-- Footer Widgets -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Footer Widgets</h6>
</div>
<div class="card-body">
<div class="list-group">
<div class="list-group-item" th:each="widget : ${footerWidgets}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1" th:text="${widget.title}">Title</h5>
<form th:action="@{/manage/widgets/delete}" method="post">
<input type="hidden" name="area" value="footer">
<input type="hidden" name="id" th:value="${widget.id}">
<button type="submit" class="btn btn-sm btn-danger"><i class="fas fa-trash"></i></button>
</form>
</div>
<small class="text-muted" th:text="${widget.type}">Type</small>
<p class="mb-1" th:text="${widget.content}">Content...</p>
</div>
<div th:if="${footerWidgets.empty}" class="text-center text-muted p-3">
No widgets in Footer.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -5,6 +5,18 @@
<title th:text="${page.title}">Page Title</title> <title th:text="${page.title}">Page Title</title>
<!-- Add Meta Description for SEO --> <!-- Add Meta Description for SEO -->
<meta name="description" th:if="${page.metaDescription != null}" th:content="${page.metaDescription}" /> <meta name="description" th:if="${page.metaDescription != null}" th:content="${page.metaDescription}" />
<!-- Customizer CSS overrides -->
<style th:inline="css">
body {
font-family: /*[[${themeModFontFamily}]]*/ sans-serif !important;
}
/* Inject Customizer Primary Color dynamically */
a, .text-primary {
color: /*[[${themeModPrimaryColor}]]*/ #007bff !important;
}
/*[[${themeModCustomCss}]]*/
</style>
</head> </head>
<body> <body>
@@ -4,8 +4,26 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
</head> </head>
<body> <body>
<footer th:fragment="footer" style="background-color: #e9ecef; padding: 20px; text-align: center; margin-top: 40px;"> <footer th:fragment="footer" style="background-color: #343a40; color: white; padding: 20px; text-align: center;">
<p>&copy; 2026 Default Theme Footer. All rights reserved.</p> <p>&copy; 2026 SIS Vietnam. All Rights Reserved.</p>
<div th:if="${footerMenu != null}">
<span th:each="item, iterStat : ${footerMenu.items}">
<a th:href="${item.url}" th:text="${item.label}" style="color: #17a2b8;">Link</a>
<span th:if="${!iterStat.last}"> | </span>
</span>
</div>
<div th:if="${footerMenu == null}">
<a href="/privacy-policy" style="color: #17a2b8;">Privacy Policy</a> |
<a href="/terms" style="color: #17a2b8;">Terms of Service</a>
</div>
<div style="margin-top: 30px; display: flex; justify-content: space-around; text-align: left; border-top: 1px solid #555; padding-top: 20px;">
<div th:each="widget : ${footerWidgets}" style="flex: 1; margin: 0 15px;">
<h5 th:text="${widget.title}" style="color: #ccc;">Widget Title</h5>
<div th:if="${widget.type == 'HTML'}" th:utext="${widget.content}" style="font-size: 0.9em; color: #aaa;"></div>
<div th:if="${widget.type == 'TEXT'}" th:text="${widget.content}" style="font-size: 0.9em; color: #aaa;"></div>
</div>
</div>
</footer> </footer>
</body> </body>
</html> </html>
@@ -6,7 +6,13 @@
<body> <body>
<header th:fragment="header" style="background-color: #e9ecef; padding: 20px; text-align: center;"> <header th:fragment="header" style="background-color: #e9ecef; padding: 20px; text-align: center;">
<h2>Default Theme Header</h2> <h2>Default Theme Header</h2>
<nav> <nav th:if="${primaryMenu != null}">
<span th:each="item, iterStat : ${primaryMenu.items}">
<a th:href="${item.url}" th:text="${item.label}">Link</a>
<span th:if="${!iterStat.last}"> | </span>
</span>
</nav>
<nav th:if="${primaryMenu == null}">
<a href="/">Home</a> | <a href="/">Home</a> |
<a href="/about">About Us</a> | <a href="/about">About Us</a> |
<a href="/tin-tuc">News</a> | <a href="/tin-tuc">News</a> |
@@ -8,8 +8,19 @@
<!-- Replace with Header Fragment --> <!-- Replace with Header Fragment -->
<header th:replace="~{themes/__${activeTheme}__/header :: header}"></header> <header th:replace="~{themes/__${activeTheme}__/header :: header}"></header>
<div style="padding: 20px; min-height: 400px;"> <div style="display: flex; min-height: 400px; padding: 20px;">
<div layout:fragment="content"></div> <div style="flex: 3; padding-right: 20px;" layout:fragment="content"></div>
<aside style="flex: 1; background-color: #f4f4f4; padding: 15px; border-radius: 5px;">
<h4>Sidebar</h4>
<div th:each="widget : ${sidebarWidgets}" style="margin-bottom: 20px;">
<h5 th:text="${widget.title}" style="border-bottom: 1px solid #ddd; padding-bottom: 5px;">Widget Title</h5>
<div th:if="${widget.type == 'HTML'}" th:utext="${widget.content}"></div>
<div th:if="${widget.type == 'TEXT'}" th:text="${widget.content}"></div>
</div>
<div th:if="${sidebarWidgets == null || sidebarWidgets.empty}">
<p>No widgets added to sidebar.</p>
</div>
</aside>
</div> </div>
<!-- Replace with Footer Fragment --> <!-- Replace with Footer Fragment -->
@@ -4,9 +4,23 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
</head> </head>
<body> <body>
<footer th:fragment="footer" style="background-color: #111; color: #888; padding: 40px 20px; text-align: center; margin-top: 50px;"> <footer th:fragment="footer" style="background-color: #000; color: #aaa; padding: 40px 20px; text-align: center; border-top: 2px solid #222;">
<h4 style="color: #fff; margin-bottom: 10px;">Modern Theme Design</h4> <p style="margin: 0; font-size: 0.9em;">&copy; 2026 MODERN INC. Crafted with care.</p>
<p style="font-size: 14px;">&copy; 2026. Built with Spring Boot and Thymeleaf.</p> <div style="margin-top: 15px;" th:if="${footerMenu != null}">
<a th:each="item : ${footerMenu.items}" th:href="${item.url}" th:text="${item.label}" style="color: #007bff; text-decoration: none; margin: 0 10px; font-weight: bold; text-transform: uppercase;">LINK</a>
</div>
<div style="margin-top: 15px;" th:if="${footerMenu == null}">
<a href="/privacy-policy" style="color: #007bff; text-decoration: none; margin: 0 10px; font-weight: bold;">PRIVACY</a>
<a href="/terms" style="color: #007bff; text-decoration: none; margin: 0 10px; font-weight: bold;">TERMS</a>
</div>
<div style="margin-top: 40px; display: flex; justify-content: center; text-align: left; max-width: 1000px; margin-left: auto; margin-right: auto; padding-top: 30px; border-top: 1px solid #333;">
<div th:each="widget : ${footerWidgets}" style="flex: 1; margin: 0 20px; min-width: 200px;">
<h4 th:text="${widget.title}" style="color: #fff; font-size: 1.1em; margin-bottom: 15px; text-transform: uppercase; letter-spacing: 1px;">Widget Title</h4>
<div th:if="${widget.type == 'HTML'}" th:utext="${widget.content}" style="font-size: 0.9em; color: #888; line-height: 1.6;"></div>
<div th:if="${widget.type == 'TEXT'}" th:text="${widget.content}" style="font-size: 0.9em; color: #888; line-height: 1.6;"></div>
</div>
</div>
</footer> </footer>
</body> </body>
</html> </html>
@@ -6,7 +6,12 @@
<body> <body>
<header th:fragment="header" style="background-color: #000; color: #fff; padding: 25px; display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #007bff;"> <header th:fragment="header" style="background-color: #000; color: #fff; padding: 25px; display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #007bff;">
<h2 style="margin: 0; font-weight: 300; letter-spacing: 2px;">MODERN<span style="color: #007bff; font-weight: bold;">THEME</span></h2> <h2 style="margin: 0; font-weight: 300; letter-spacing: 2px;">MODERN<span style="color: #007bff; font-weight: bold;">THEME</span></h2>
<nav>
<nav th:if="${primaryMenu != null}">
<a th:each="item : ${primaryMenu.items}" th:href="${item.url}" th:text="${item.label}" style="color: #fff; text-decoration: none; margin-left: 20px; font-weight: bold; text-transform: uppercase;">LINK</a>
</nav>
<nav th:if="${primaryMenu == null}">
<a href="/" style="color: #fff; text-decoration: none; margin-left: 20px; font-weight: bold;">HOME</a> <a href="/" style="color: #fff; text-decoration: none; margin-left: 20px; font-weight: bold;">HOME</a>
<a href="/about" style="color: #fff; text-decoration: none; margin-left: 20px;">ABOUT</a> <a href="/about" style="color: #fff; text-decoration: none; margin-left: 20px;">ABOUT</a>
<a href="/tin-tuc" style="color: #fff; text-decoration: none; margin-left: 20px;">NEWS</a> <a href="/tin-tuc" style="color: #fff; text-decoration: none; margin-left: 20px;">NEWS</a>
@@ -8,7 +8,21 @@
<!-- Replace with Header Fragment --> <!-- Replace with Header Fragment -->
<header th:replace="~{themes/__${activeTheme}__/header :: header}"></header> <header th:replace="~{themes/__${activeTheme}__/header :: header}"></header>
<div style="padding: 20px; min-height: 500px;" layout:fragment="content"></div> <div style="display: flex; max-width: 1200px; margin: 0 auto; padding: 40px 20px; min-height: 500px;">
<div style="flex: 3; padding-right: 40px;" layout:fragment="content"></div>
<aside style="flex: 1; border-left: 1px solid #ddd; padding-left: 20px;">
<h3 style="font-size: 1.2em; border-bottom: 2px solid #007bff; padding-bottom: 10px; margin-bottom: 20px;">SIDEBAR</h3>
<div th:each="widget : ${sidebarWidgets}" style="margin-bottom: 30px;">
<h4 th:text="${widget.title}" style="font-size: 1.1em; color: #333; margin-bottom: 10px;">Widget Title</h4>
<div th:if="${widget.type == 'HTML'}" th:utext="${widget.content}" style="font-size: 0.95em; color: #666; line-height: 1.5;"></div>
<div th:if="${widget.type == 'TEXT'}" th:text="${widget.content}" style="font-size: 0.95em; color: #666; line-height: 1.5;"></div>
</div>
<div th:if="${sidebarWidgets == null || sidebarWidgets.empty}">
<p style="color: #999; font-style: italic;">No widgets found.</p>
</div>
</aside>
</div>
<!-- Replace with Footer Fragment --> <!-- Replace with Footer Fragment -->
<footer th:replace="~{themes/__${activeTheme}__/footer :: footer}"></footer> <footer th:replace="~{themes/__${activeTheme}__/footer :: footer}"></footer>