bổ sung thêm các chức năng còn thiếu của appearance
This commit is contained in:
+47
-1
@@ -15,10 +15,12 @@ import java.util.Map;
|
||||
public class GlobalControllerAdvice {
|
||||
|
||||
private final SettingService settingService;
|
||||
private final com.sisvietnamvn.web.service.MenuService menuService;
|
||||
private final PathMatchingResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
|
||||
|
||||
public GlobalControllerAdvice(SettingService settingService) {
|
||||
public GlobalControllerAdvice(SettingService settingService, com.sisvietnamvn.web.service.MenuService menuService) {
|
||||
this.settingService = settingService;
|
||||
this.menuService = menuService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,4 +43,48 @@ public class GlobalControllerAdvice {
|
||||
|
||||
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<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -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";
|
||||
}
|
||||
}
|
||||
+72
@@ -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";
|
||||
}
|
||||
}
|
||||
+68
@@ -95,4 +95,72 @@ public class ManageThemeController {
|
||||
redirectAttributes.addFlashAttribute("successMessage", "New theme activated successfully.");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+66
@@ -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";
|
||||
}
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
}
|
||||
+57
@@ -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/20260702144700_add_plugin_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-constraints-changelog - JHipster will add liquibase constraints 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">
|
||||
<h6 class="collapse-header">Design Management:</h6>
|
||||
<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>
|
||||
</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">×</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">×</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>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 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>
|
||||
|
||||
<!-- Dynamic Layout Cards -->
|
||||
<div class="col-lg-4 mb-4" th:each="theme : ${availableThemes}">
|
||||
<div class="card shadow mb-4 h-100" th:classappend="${activeTheme == theme} ? 'border-left-primary' : ''">
|
||||
<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>
|
||||
<!-- Available Themes Grid -->
|
||||
<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>
|
||||
|
||||
<div class="mt-auto d-flex justify-content-center">
|
||||
<!-- Active Badge -->
|
||||
<span th:if="${activeTheme == theme}" class="badge badge-success px-3 py-2">Active</span>
|
||||
|
||||
<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" 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>
|
||||
|
||||
<!-- 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>
|
||||
<button th:if="${activeTheme == theme}" class="btn btn-secondary btn-block mt-auto" disabled>Customize</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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">×</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>
|
||||
<!-- Add Meta Description for SEO -->
|
||||
<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>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -4,8 +4,26 @@
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<footer th:fragment="footer" style="background-color: #e9ecef; padding: 20px; text-align: center; margin-top: 40px;">
|
||||
<p>© 2026 Default Theme Footer. All rights reserved.</p>
|
||||
<footer th:fragment="footer" style="background-color: #343a40; color: white; padding: 20px; text-align: center;">
|
||||
<p>© 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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
<body>
|
||||
<header th:fragment="header" style="background-color: #e9ecef; padding: 20px; text-align: center;">
|
||||
<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="/about">About Us</a> |
|
||||
<a href="/tin-tuc">News</a> |
|
||||
|
||||
@@ -8,8 +8,19 @@
|
||||
<!-- Replace with Header Fragment -->
|
||||
<header th:replace="~{themes/__${activeTheme}__/header :: header}"></header>
|
||||
|
||||
<div style="padding: 20px; min-height: 400px;">
|
||||
<div layout:fragment="content"></div>
|
||||
<div style="display: flex; min-height: 400px; padding: 20px;">
|
||||
<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>
|
||||
|
||||
<!-- Replace with Footer Fragment -->
|
||||
|
||||
@@ -4,9 +4,23 @@
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<footer th:fragment="footer" style="background-color: #111; color: #888; padding: 40px 20px; text-align: center; margin-top: 50px;">
|
||||
<h4 style="color: #fff; margin-bottom: 10px;">Modern Theme Design</h4>
|
||||
<p style="font-size: 14px;">© 2026. Built with Spring Boot and Thymeleaf.</p>
|
||||
<footer th:fragment="footer" style="background-color: #000; color: #aaa; padding: 40px 20px; text-align: center; border-top: 2px solid #222;">
|
||||
<p style="margin: 0; font-size: 0.9em;">© 2026 MODERN INC. Crafted with care.</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
<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;">
|
||||
<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="/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>
|
||||
|
||||
@@ -8,7 +8,21 @@
|
||||
<!-- Replace with Header Fragment -->
|
||||
<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 -->
|
||||
<footer th:replace="~{themes/__${activeTheme}__/footer :: footer}"></footer>
|
||||
|
||||
Reference in New Issue
Block a user