finish plugin function

This commit is contained in:
2026-07-02 15:24:07 +07:00
parent ba7a43e637
commit 4793dc9c17
29 changed files with 2369 additions and 47 deletions
+4 -4
View File
@@ -6,13 +6,13 @@ PHẦN 1: CÁC CHỨC NĂNG TRÊN GISAO DIỆN ADMIN (UI MENUS)
Menu chính Menu con Chức năng chi tiết
1. Dashboard (Bảng điều khiển) Home Xem tổng quan trạng thái website (Site Health, số lượng bài viết/trang, bình luận gần đây, hoạt động viết nháp nhanh).
Updates Cập nhật phiên bản WordPress Core, Themes, Plugins và các gói dịch vụ ngôn ngữ.
2. Posts (Bài viết) All Posts Quản lý toàn bộ bài viết tin tức, blog, lọc theo ngày/chuyên mục.
(Done) 2. Posts (Bài viết) All Posts Quản lý toàn bộ bài viết tin tức, blog, lọc theo ngày/chuyên mục.
Add New Soạn thảo bài viết mới bằng Block Editor (Gutenberg) hoặc Classic Editor.
Categories Tạo và quản lý các Chuyên mục (thư mục phân loại bài viết).
Tags Tạo và quản lý các Thẻ (nhãn từ khóa tìm kiếm nhanh).
3. Media (Thư viện) Library Quản lý toàn bộ hình ảnh, tài liệu (PDF, Docx), video, âm thanh đã tải lên.
Add New Tải tệp tin mới lên máy chủ.
4. Pages (Trang) All Pages Quản lý các trang tĩnh (ví dụ: Trang chủ, Trang liên hệ, Giới thiệu bệnh viện, Giờ làm việc...).
(Done) 3. Media (Thư viện) Library Quản lý toàn bộ hình ảnh, tài liệu (PDF, Docx), video, âm thanh đã tải lên.
Add New Tải tệp tin mới lên máy chủ.
(Done) 4. Pages (Trang) All Pages Quản lý các trang tĩnh (ví dụ: Trang chủ, Trang liên hệ, Giới thiệu bệnh viện, Giờ làm việc...).
Add New Tạo trang tĩnh mới.
5. Comments (Bình luận) (Không có) Quản lý bình luận của người đọc (Phê duyệt, Trả lời, Đánh dấu Spam, Xóa tạm/Xóa vĩnh viễn).
6. Appearance (Giao diện) Themes Cài đặt, kích hoạt hoặc xóa các giao diện hiển thị của website.
+18
View File
@@ -0,0 +1,18 @@
import java.net.URI;
import java.nio.file.Paths;
public class TestFileUri {
public static void main(String[] args) throws Exception {
String uploadPath = "/home/x79/sisvietnamvn_01/sisvietnamvn_main/uploads";
System.out.println("Using file:/ + uploadPath:");
try {
URI uri1 = new URI("file:/" + uploadPath + "/");
System.out.println(" Host: " + uri1.getHost());
System.out.println(" Path: " + uri1.getPath());
} catch(Exception e) { e.printStackTrace(); }
System.out.println("\nUsing Paths.get().toUri():");
URI uri2 = Paths.get(uploadPath).toUri();
System.out.println(" URI: " + uri2.toString());
}
}
@@ -17,13 +17,13 @@ public class MvcConfig implements WebMvcConfigurer {
private void exposeDirectory(String dirName, ResourceHandlerRegistry registry) {
Path uploadDir = Paths.get(dirName);
String uploadPath = uploadDir.toFile().getAbsolutePath();
if (dirName.startsWith("../")) {
dirName = dirName.replace("../", "");
}
String uploadPath = uploadDir.toFile().getAbsolutePath();
registry.addResourceHandler("/" + dirName + "/**")
.addResourceLocations("file:/" + uploadPath + "/");
.addResourceLocations("file:" + uploadPath + "/");
}
}
@@ -0,0 +1,163 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Media;
import com.sisvietnamvn.web.domain.MediaType;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.service.MediaService;
import java.io.IOException;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Thymeleaf MVC controller for the Media Library admin pages.
* Provides Library view, Add New upload, Detail/Edit, and Delete.
*/
@Controller
@RequestMapping("/manage/media")
@PreAuthorize("hasAnyAuthority(\"" + AuthoritiesConstants.ADMIN + "\", \"" + AuthoritiesConstants.EDITOR + "\", \"" + AuthoritiesConstants.AUTHOR + "\", \"" + AuthoritiesConstants.CONTRIBUTOR + "\")")
public class ManageMediaController {
private static final Logger LOG = LoggerFactory.getLogger(ManageMediaController.class);
private final MediaService mediaService;
private final HookManager hookManager;
public ManageMediaController(MediaService mediaService, HookManager hookManager) {
this.mediaService = mediaService;
this.hookManager = hookManager;
}
@ModelAttribute
public void adminInit() {
hookManager.doAction("admin_init");
}
/**
* GET /manage/media — Media Library page.
*/
@GetMapping
public String listMedia(@RequestParam(value = "type", required = false) String typeStr,
@RequestParam(value = "keyword", required = false) String keyword,
Model model) {
LOG.debug("Request to list all media (type={}, keyword={})", typeStr, keyword);
MediaType type = null;
if (typeStr != null && !typeStr.isBlank()) {
try {
type = MediaType.valueOf(typeStr);
} catch (IllegalArgumentException e) {
LOG.warn("Invalid media type filter: {}", typeStr);
}
}
model.addAttribute("mediaList", mediaService.findFiltered(type, keyword));
model.addAttribute("mediaTypes", MediaType.values());
model.addAttribute("selectedType", typeStr);
model.addAttribute("keyword", keyword);
return "manage/media/list";
}
/**
* GET /manage/media/new — Add New upload page.
*/
@GetMapping("/new")
public String showUploadPage() {
LOG.debug("Request to show media upload page");
return "manage/media/upload";
}
/**
* POST /manage/media/upload — Handle file upload from the form.
*/
@PostMapping("/upload")
public String uploadMedia(@RequestParam("files") MultipartFile[] files,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to upload {} file(s)", files.length);
int successCount = 0;
int failCount = 0;
for (MultipartFile file : files) {
if (file.isEmpty()) continue;
try {
mediaService.upload(file);
successCount++;
} catch (IOException e) {
LOG.error("Failed to upload file: {}", file.getOriginalFilename(), e);
failCount++;
}
}
if (successCount > 0) {
redirectAttributes.addFlashAttribute("successMessage",
successCount + " file(s) uploaded successfully!");
}
if (failCount > 0) {
redirectAttributes.addFlashAttribute("errorMessage",
failCount + " file(s) failed to upload.");
}
return "redirect:/manage/media";
}
/**
* GET /manage/media/{id} — View/edit detail page for a single media item.
*/
@GetMapping("/{id}")
public String showDetail(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
LOG.debug("Request to show media detail: {}", id);
Optional<Media> mediaOpt = mediaService.findById(id);
if (mediaOpt.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Media not found.");
return "redirect:/manage/media";
}
Media media = mediaOpt.get();
String fullUrl = org.springframework.web.servlet.support.ServletUriComponentsBuilder
.fromCurrentContextPath()
.path(media.getFileUrl())
.toUriString();
model.addAttribute("media", media);
model.addAttribute("fullUrl", fullUrl);
return "manage/media/detail";
}
/**
* POST /manage/media/{id} — Save edits (e.g. alt text) for a media item.
*/
@PostMapping("/{id}")
public String updateMedia(@PathVariable Long id,
@RequestParam(value = "altText", required = false) String altText,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update media: {}", id);
Optional<Media> mediaOpt = mediaService.findById(id);
if (mediaOpt.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Media not found.");
return "redirect:/manage/media";
}
Media media = mediaOpt.get();
media.setAltText(altText);
mediaService.save(media);
redirectAttributes.addFlashAttribute("successMessage", "Media updated successfully!");
return "redirect:/manage/media/" + id;
}
/**
* POST /manage/media/{id}/delete — Delete a media item.
*/
@PostMapping("/{id}/delete")
public String deleteMedia(@PathVariable Long id, RedirectAttributes redirectAttributes) {
LOG.debug("Request to delete media: {}", id);
mediaService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "Media deleted successfully!");
return "redirect:/manage/media";
}
}
@@ -0,0 +1,159 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Plugin;
import com.sisvietnamvn.web.service.PluginService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@Controller
@RequestMapping("/manage/plugins")
public class ManagePluginController {
private static final Logger LOG = LoggerFactory.getLogger(ManagePluginController.class);
private final PluginService pluginService;
public ManagePluginController(PluginService pluginService) {
this.pluginService = pluginService;
}
// --- Installed Plugins List ---
@GetMapping
public String listPlugins(Model model) {
LOG.debug("Request to list all plugins");
model.addAttribute("plugins", pluginService.findAll());
return "manage/plugins/list";
}
// --- Activate / Deactivate / Delete ---
@PostMapping("/{id}/activate")
public String activatePlugin(@PathVariable Long id, RedirectAttributes redirectAttributes) {
try {
Plugin plugin = pluginService.activate(id);
redirectAttributes.addFlashAttribute("successMessage", "Plugin '" + plugin.getName() + "' activated successfully. Please restart the application to apply hooks.");
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", "Failed to activate plugin: " + e.getMessage());
}
return "redirect:/manage/plugins";
}
@PostMapping("/{id}/deactivate")
public String deactivatePlugin(@PathVariable Long id, RedirectAttributes redirectAttributes) {
try {
Plugin plugin = pluginService.deactivate(id);
redirectAttributes.addFlashAttribute("successMessage", "Plugin '" + plugin.getName() + "' deactivated successfully. Please restart the application to remove hooks.");
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", "Failed to deactivate plugin: " + e.getMessage());
}
return "redirect:/manage/plugins";
}
@PostMapping("/{id}/delete")
public String deletePlugin(@PathVariable Long id, RedirectAttributes redirectAttributes) {
try {
pluginService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "Plugin deleted successfully.");
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", "Failed to delete plugin: " + e.getMessage());
}
return "redirect:/manage/plugins";
}
// --- Add New Plugin ---
@GetMapping("/new")
public String showAddPluginPage() {
return "manage/plugins/add-new";
}
@PostMapping("/upload")
public String uploadPlugin(@RequestParam("zipFile") MultipartFile zipFile, RedirectAttributes redirectAttributes) {
if (zipFile.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Please select a .zip file to upload.");
return "redirect:/manage/plugins/new";
}
try {
pluginService.installFromZip(zipFile);
redirectAttributes.addFlashAttribute("successMessage", "Plugin installed successfully. You can now activate it.");
return "redirect:/manage/plugins";
} catch (IOException e) {
LOG.error("Failed to install plugin", e);
redirectAttributes.addFlashAttribute("errorMessage", "Failed to install plugin: " + e.getMessage());
return "redirect:/manage/plugins/new";
} catch (IllegalArgumentException e) {
redirectAttributes.addFlashAttribute("errorMessage", e.getMessage());
return "redirect:/manage/plugins/new";
}
}
// --- Plugin File Editor ---
@GetMapping("/editor")
public String showEditorSelector(Model model) {
model.addAttribute("plugins", pluginService.findAll());
return "manage/plugins/editor";
}
@GetMapping("/editor/{pluginKey}")
public String showEditor(@PathVariable String pluginKey,
@RequestParam(value = "file", required = false) String file,
Model model, RedirectAttributes redirectAttributes) {
Optional<Plugin> pluginOpt = pluginService.findByKey(pluginKey);
if (pluginOpt.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Plugin not found.");
return "redirect:/manage/plugins/editor";
}
model.addAttribute("plugins", pluginService.findAll());
model.addAttribute("selectedPlugin", pluginOpt.get());
try {
List<String> files = pluginService.getPluginFiles(pluginKey);
model.addAttribute("pluginFiles", files);
if (file != null && !file.isEmpty() && files.contains(file)) {
String content = pluginService.readPluginFile(pluginKey, file);
model.addAttribute("selectedFile", file);
model.addAttribute("fileContent", content);
} else if (!files.isEmpty()) {
// Select first file by default
String firstFile = files.get(0);
String content = pluginService.readPluginFile(pluginKey, firstFile);
model.addAttribute("selectedFile", firstFile);
model.addAttribute("fileContent", content);
}
} catch (Exception e) {
LOG.error("Error reading plugin files for {}", pluginKey, e);
model.addAttribute("errorMessage", "Error reading plugin files: " + e.getMessage());
}
return "manage/plugins/editor";
}
@PostMapping("/editor/{pluginKey}/save")
public String savePluginFile(@PathVariable String pluginKey,
@RequestParam("file") String file,
@RequestParam("content") String content,
RedirectAttributes redirectAttributes) {
try {
pluginService.writePluginFile(pluginKey, file, content);
redirectAttributes.addFlashAttribute("successMessage", "File '" + file + "' saved successfully. Changes may require a rebuild or restart.");
} catch (Exception e) {
LOG.error("Error saving plugin file {}/{}", pluginKey, file, e);
redirectAttributes.addFlashAttribute("errorMessage", "Failed to save file: " + e.getMessage());
}
return "redirect:/manage/plugins/editor/" + pluginKey + "?file=" + file;
}
}
@@ -1,23 +1,22 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Media;
import com.sisvietnamvn.web.service.MediaService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.security.access.prepost.PreAuthorize;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* REST controller for media file uploads (used by Editor.js and other AJAX consumers).
*/
@RestController
@RequestMapping("/api/manage/media")
@PreAuthorize("hasAnyAuthority(\"" + AuthoritiesConstants.ADMIN + "\", \"" + AuthoritiesConstants.EDITOR + "\", \"" + AuthoritiesConstants.AUTHOR + "\", \"" + AuthoritiesConstants.CONTRIBUTOR + "\")")
@@ -25,8 +24,11 @@ public class MediaController {
private final Logger log = LoggerFactory.getLogger(MediaController.class);
// Save files in an 'uploads' directory at the project root
private final String UPLOAD_DIR = "uploads/";
private final MediaService mediaService;
public MediaController(MediaService mediaService) {
this.mediaService = mediaService;
}
@PostMapping("/upload")
public ResponseEntity<Map<String, Object>> uploadFile(@RequestParam("file") MultipartFile file) {
@@ -40,32 +42,20 @@ public class MediaController {
}
try {
// Ensure the upload directory exists
Path uploadPath = Paths.get(UPLOAD_DIR);
if (!Files.exists(uploadPath)) {
Files.createDirectories(uploadPath);
}
// Generate a unique file name to avoid conflicts
String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());
String extension = "";
int dotIndex = originalFilename.lastIndexOf('.');
if (dotIndex > 0) {
extension = originalFilename.substring(dotIndex);
}
String newFilename = UUID.randomUUID().toString() + extension;
// Save the file
Path filePath = uploadPath.resolve(newFilename);
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
Media media = mediaService.upload(file);
// Construct the Editor.js expected response
Map<String, Object> fileData = new HashMap<>();
fileData.put("url", "/uploads/" + newFilename);
fileData.put("name", originalFilename);
fileData.put("size", file.getSize());
if (extension.startsWith(".")) {
fileData.put("extension", extension.substring(1));
fileData.put("url", media.getFileUrl());
fileData.put("name", media.getOriginalFilename());
fileData.put("size", media.getFileSize());
if (media.getMimeType() != null) {
String ext = "";
int dotIndex = media.getOriginalFilename().lastIndexOf('.');
if (dotIndex > 0) {
ext = media.getOriginalFilename().substring(dotIndex + 1);
}
fileData.put("extension", ext);
}
response.put("success", 1);
@@ -0,0 +1,160 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.io.Serializable;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
/**
* A Media entity representing an uploaded file (image, document, video, audio).
*/
@Entity
@Table(name = "sis_media")
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
public class Media extends AbstractAuditingEntity<Long> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "media_sequence")
@SequenceGenerator(name = "media_sequence", sequenceName = "sis_media_seq", allocationSize = 1)
@Column(name = "id")
private Long id;
@NotNull
@Size(max = 500)
@Column(name = "original_filename", length = 500, nullable = false)
private String originalFilename;
@NotNull
@Size(max = 500)
@Column(name = "stored_filename", length = 500, nullable = false)
private String storedFilename;
@NotNull
@Size(max = 1000)
@Column(name = "file_url", length = 1000, nullable = false)
private String fileUrl;
@Size(max = 100)
@Column(name = "mime_type", length = 100)
private String mimeType;
@Column(name = "file_size")
private Long fileSize;
@Size(max = 500)
@Column(name = "alt_text", length = 500)
private String altText;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "media_type", length = 20, nullable = false)
private MediaType mediaType;
// --- Getters and Setters ---
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public String getStoredFilename() {
return storedFilename;
}
public void setStoredFilename(String storedFilename) {
this.storedFilename = storedFilename;
}
public String getFileUrl() {
return fileUrl;
}
public void setFileUrl(String fileUrl) {
this.fileUrl = fileUrl;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Long getFileSize() {
return fileSize;
}
public void setFileSize(Long fileSize) {
this.fileSize = fileSize;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
public MediaType getMediaType() {
return mediaType;
}
public void setMediaType(MediaType mediaType) {
this.mediaType = mediaType;
}
/**
* Returns a human-readable file size string.
*/
public String getFormattedFileSize() {
if (fileSize == null) return "";
if (fileSize < 1024) return fileSize + " B";
if (fileSize < 1024 * 1024) return String.format("%.1f KB", fileSize / 1024.0);
return String.format("%.1f MB", fileSize / (1024.0 * 1024.0));
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Media)) return false;
return id != null && id.equals(((Media) o).id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Media{" +
"id=" + id +
", originalFilename='" + originalFilename + '\'' +
", storedFilename='" + storedFilename + '\'' +
", fileUrl='" + fileUrl + '\'' +
", mimeType='" + mimeType + '\'' +
", fileSize=" + fileSize +
", mediaType=" + mediaType +
'}';
}
}
@@ -0,0 +1,12 @@
package com.sisvietnamvn.web.domain;
/**
* Enum representing the type of media file.
*/
public enum MediaType {
IMAGE,
DOCUMENT,
VIDEO,
AUDIO,
OTHER
}
@@ -0,0 +1,177 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import java.io.Serializable;
/**
* A Plugin.
*/
@Entity
@Table(name = "sis_plugin")
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
public class Plugin extends AbstractAuditingEntity<Long> implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator", sequenceName = "sis_plugin_seq", allocationSize = 1)
private Long id;
@NotNull
@Size(max = 100)
@Column(name = "plugin_key", length = 100, nullable = false, unique = true)
private String pluginKey;
@NotNull
@Size(max = 255)
@Column(name = "name", length = 255, nullable = false)
private String name;
@Size(max = 1000)
@Column(name = "description", length = 1000)
private String description;
@Size(max = 50)
@Column(name = "version", length = 50)
private String version;
@Size(max = 255)
@Column(name = "author", length = 255)
private String author;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private PluginStatus status;
// jhipster-needle-entity-add-field - JHipster will add fields here
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Plugin id(Long id) {
this.setId(id);
return this;
}
public String getPluginKey() {
return pluginKey;
}
public void setPluginKey(String pluginKey) {
this.pluginKey = pluginKey;
}
public Plugin pluginKey(String pluginKey) {
this.setPluginKey(pluginKey);
return this;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Plugin name(String name) {
this.setName(name);
return this;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Plugin description(String description) {
this.setDescription(description);
return this;
}
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
}
public Plugin version(String version) {
this.setVersion(version);
return this;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public Plugin author(String author) {
this.setAuthor(author);
return this;
}
public PluginStatus getStatus() {
return status;
}
public void setStatus(PluginStatus status) {
this.status = status;
}
public Plugin status(PluginStatus status) {
this.setStatus(status);
return this;
}
// jhipster-needle-entity-add-getters-setters - JHipster will add getters and setters here
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Plugin)) {
return false;
}
return id != null && id.equals(((Plugin) o).id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
// prettier-ignore
@Override
public String toString() {
return "Plugin{" +
"id=" + getId() +
", pluginKey='" + getPluginKey() + "'" +
", name='" + getName() + "'" +
", description='" + getDescription() + "'" +
", version='" + getVersion() + "'" +
", author='" + getAuthor() + "'" +
", status='" + getStatus() + "'" +
"}";
}
}
@@ -0,0 +1,8 @@
package com.sisvietnamvn.web.domain;
/**
* The PluginStatus enumeration.
*/
public enum PluginStatus {
ACTIVE, INACTIVE
}
@@ -1,7 +1,10 @@
package com.sisvietnamvn.web.hook;
package com.sisvietnamvn.web.plugins.sampleplugin;
import com.sisvietnamvn.web.domain.Page;
import jakarta.annotation.PostConstruct;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.repository.PluginRepository;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@@ -15,13 +18,32 @@ public class SamplePluginHooks {
private static final Logger LOG = LoggerFactory.getLogger(SamplePluginHooks.class);
private final HookManager hookManager;
private final PluginRepository pluginRepository;
private static final String PLUGIN_KEY = "sampleplugin";
public SamplePluginHooks(HookManager hookManager) {
public SamplePluginHooks(HookManager hookManager, PluginRepository pluginRepository) {
this.hookManager = hookManager;
this.pluginRepository = pluginRepository;
}
@PostConstruct
@EventListener(ApplicationReadyEvent.class)
public void registerHooks() {
boolean isActive = false;
try {
// Only register hooks if this plugin is ACTIVE in the database
isActive = pluginRepository.findByPluginKey(PLUGIN_KEY)
.map(p -> p.getStatus().name().equals("ACTIVE"))
.orElse(false);
} catch (Exception e) {
LOG.warn("Could not verify plugin status (table might not exist yet due to async Liquibase): {}", e.getMessage());
return;
}
if (!isActive) {
LOG.info("SamplePlugin is inactive. Skipping hook registration.");
return;
}
LOG.info("Registering SamplePlugin hooks...");
// Example Action: Log when a page is saved
@@ -53,8 +75,6 @@ public class SamplePluginHooks {
if (value instanceof Page page) {
if (page.getContent() != null && !page.getContent().contains("Auto-signature")) {
LOG.info("FILTER TRIGGERED (pre_save_post): Appending signature to page '{}'", page.getTitle());
// In a real scenario, this would modify the JSON content of Editor.js,
// but for demonstration we just log it.
}
}
return value;
@@ -0,0 +1,6 @@
{
"name": "Sample Plugin",
"version": "1.0.0",
"author": "Admin",
"description": "A sample plugin to demonstrate the HookManager."
}
@@ -0,0 +1,22 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.Media;
import com.sisvietnamvn.web.domain.MediaType;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* Spring Data JPA repository for the {@link Media} entity.
*/
@Repository
public interface MediaRepository extends JpaRepository<Media, Long> {
List<Media> findAllByOrderByCreatedDateDesc();
List<Media> findByMediaTypeOrderByCreatedDateDesc(MediaType mediaType);
List<Media> findByOriginalFilenameContainingIgnoreCaseOrderByCreatedDateDesc(String keyword);
List<Media> findByMediaTypeAndOriginalFilenameContainingIgnoreCaseOrderByCreatedDateDesc(MediaType mediaType, String keyword);
}
@@ -0,0 +1,19 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.Plugin;
import com.sisvietnamvn.web.domain.PluginStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* Spring Data JPA repository for the Plugin entity.
*/
@Repository
public interface PluginRepository extends JpaRepository<Plugin, Long> {
Optional<Plugin> findByPluginKey(String pluginKey);
List<Plugin> findByStatus(PluginStatus status);
List<Plugin> findAllByOrderByNameAsc();
}
@@ -0,0 +1,176 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Media;
import com.sisvietnamvn.web.domain.MediaType;
import com.sisvietnamvn.web.repository.MediaRepository;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
/**
* Service for managing media files (upload, list, filter, delete).
* Files are stored using a WordPress-style time-based folder structure: uploads/YYYY/MM/
*/
@Service
@Transactional
public class MediaService {
private static final Logger LOG = LoggerFactory.getLogger(MediaService.class);
private static final String BASE_UPLOAD_DIR = "uploads";
private final MediaRepository mediaRepository;
public MediaService(MediaRepository mediaRepository) {
this.mediaRepository = mediaRepository;
}
/**
* Upload a file to disk and persist a Media entity.
* Files are stored in uploads/YYYY/MM/ based on the current date.
*/
public Media upload(MultipartFile file) throws IOException {
LOG.debug("Request to upload file: {}", file.getOriginalFilename());
// Build WordPress-style path: uploads/YYYY/MM/
String subPath = getUploadSubPath();
Path uploadDir = Paths.get(BASE_UPLOAD_DIR, subPath);
if (!Files.exists(uploadDir)) {
Files.createDirectories(uploadDir);
}
// Generate unique stored filename
String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());
String extension = "";
int dotIndex = originalFilename.lastIndexOf('.');
if (dotIndex > 0) {
extension = originalFilename.substring(dotIndex);
}
String storedFilename = UUID.randomUUID().toString() + extension;
// Save file to disk
Path filePath = uploadDir.resolve(storedFilename);
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
// Build the public URL
String fileUrl = "/" + BASE_UPLOAD_DIR + "/" + subPath + "/" + storedFilename;
// Determine media type from MIME
String mimeType = file.getContentType();
MediaType mediaType = resolveMediaType(mimeType);
// Create and save entity
Media media = new Media();
media.setOriginalFilename(originalFilename);
media.setStoredFilename(storedFilename);
media.setFileUrl(fileUrl);
media.setMimeType(mimeType);
media.setFileSize(file.getSize());
media.setMediaType(mediaType);
return mediaRepository.save(media);
}
/**
* Find all media, sorted by date descending.
*/
@Transactional(readOnly = true)
public List<Media> findAll() {
LOG.debug("Request to get all Media");
return mediaRepository.findAllByOrderByCreatedDateDesc();
}
/**
* Find media filtered by type and/or keyword.
*/
@Transactional(readOnly = true)
public List<Media> findFiltered(MediaType type, String keyword) {
LOG.debug("Request to get filtered Media (type={}, keyword={})", type, keyword);
boolean hasType = type != null;
boolean hasKeyword = keyword != null && !keyword.isBlank();
if (hasType && hasKeyword) {
return mediaRepository.findByMediaTypeAndOriginalFilenameContainingIgnoreCaseOrderByCreatedDateDesc(type, keyword);
} else if (hasType) {
return mediaRepository.findByMediaTypeOrderByCreatedDateDesc(type);
} else if (hasKeyword) {
return mediaRepository.findByOriginalFilenameContainingIgnoreCaseOrderByCreatedDateDesc(keyword);
} else {
return mediaRepository.findAllByOrderByCreatedDateDesc();
}
}
/**
* Find a single media by ID.
*/
@Transactional(readOnly = true)
public Optional<Media> findById(Long id) {
LOG.debug("Request to get Media : {}", id);
return mediaRepository.findById(id);
}
/**
* Delete a media entity and its file from disk.
*/
public void delete(Long id) {
LOG.debug("Request to delete Media : {}", id);
mediaRepository.findById(id).ifPresent(media -> {
// Delete file from disk
try {
// fileUrl is like /uploads/2026/07/uuid.jpg — strip leading slash
Path filePath = Paths.get(media.getFileUrl().substring(1));
Files.deleteIfExists(filePath);
LOG.debug("Deleted file from disk: {}", filePath);
} catch (IOException e) {
LOG.warn("Failed to delete file from disk for media {}: {}", id, e.getMessage());
}
mediaRepository.delete(media);
});
}
/**
* Update a media entity (e.g. alt text).
*/
public Media save(Media media) {
LOG.debug("Request to save Media : {}", media);
return mediaRepository.save(media);
}
/**
* Returns the current year/month subdirectory path (e.g. "2026/07").
*/
public String getUploadSubPath() {
LocalDate now = LocalDate.now();
return String.format("%d/%02d", now.getYear(), now.getMonthValue());
}
/**
* Maps a MIME type string to a MediaType enum value.
*/
public MediaType resolveMediaType(String mimeType) {
if (mimeType == null) return MediaType.OTHER;
if (mimeType.startsWith("image/")) return MediaType.IMAGE;
if (mimeType.startsWith("video/")) return MediaType.VIDEO;
if (mimeType.startsWith("audio/")) return MediaType.AUDIO;
if (mimeType.startsWith("application/pdf") ||
mimeType.startsWith("application/msword") ||
mimeType.startsWith("application/vnd.openxmlformats") ||
mimeType.startsWith("application/vnd.ms-") ||
mimeType.startsWith("text/")) {
return MediaType.DOCUMENT;
}
return MediaType.OTHER;
}
}
@@ -0,0 +1,220 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Plugin;
import com.sisvietnamvn.web.domain.PluginStatus;
import com.sisvietnamvn.web.repository.PluginRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@Service
@Transactional
public class PluginService {
private static final Logger LOG = LoggerFactory.getLogger(PluginService.class);
// Using the source directory so that edited/uploaded .java files can be compiled
// and picked up by Spring Boot DevTools (or a normal build).
private static final String PLUGIN_BASE_DIR = "src/main/java/com/sisvietnamvn/web/plugins";
private final PluginRepository pluginRepository;
public PluginService(PluginRepository pluginRepository) {
this.pluginRepository = pluginRepository;
ensurePluginBaseDirExists();
}
private void ensurePluginBaseDirExists() {
try {
Path path = Paths.get(PLUGIN_BASE_DIR);
if (!Files.exists(path)) {
Files.createDirectories(path);
}
} catch (IOException e) {
LOG.error("Could not create plugin base directory", e);
}
}
public List<Plugin> findAll() {
LOG.debug("Request to get all Plugins");
return pluginRepository.findAllByOrderByNameAsc();
}
public Optional<Plugin> findById(Long id) {
LOG.debug("Request to get Plugin : {}", id);
return pluginRepository.findById(id);
}
public Optional<Plugin> findByKey(String key) {
return pluginRepository.findByPluginKey(key);
}
public boolean isPluginActive(String key) {
return pluginRepository.findByPluginKey(key)
.map(plugin -> plugin.getStatus() == PluginStatus.ACTIVE)
.orElse(false);
}
public Plugin activate(Long id) {
return pluginRepository.findById(id).map(plugin -> {
plugin.setStatus(PluginStatus.ACTIVE);
LOG.info("Activated plugin: {}", plugin.getName());
return plugin;
}).orElseThrow(() -> new IllegalArgumentException("Plugin not found"));
}
public Plugin deactivate(Long id) {
return pluginRepository.findById(id).map(plugin -> {
plugin.setStatus(PluginStatus.INACTIVE);
LOG.info("Deactivated plugin: {}", plugin.getName());
return plugin;
}).orElseThrow(() -> new IllegalArgumentException("Plugin not found"));
}
public void delete(Long id) {
pluginRepository.findById(id).ifPresent(plugin -> {
String key = plugin.getPluginKey();
pluginRepository.delete(plugin);
LOG.info("Deleted plugin from DB: {}", key);
// Also delete files
Path pluginDir = Paths.get(PLUGIN_BASE_DIR, key);
if (Files.exists(pluginDir)) {
try {
Files.walkFileTree(pluginDir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
LOG.info("Deleted plugin directory: {}", pluginDir);
} catch (IOException e) {
LOG.error("Failed to delete plugin directory: {}", pluginDir, e);
}
}
});
}
public void installFromZip(MultipartFile zipFile) throws IOException {
String originalFilename = zipFile.getOriginalFilename();
if (originalFilename == null || !originalFilename.endsWith(".zip")) {
throw new IllegalArgumentException("File must be a .zip archive");
}
String pluginKey = originalFilename.substring(0, originalFilename.length() - 4).replaceAll("[^a-zA-Z0-9-]", "-").toLowerCase();
Path pluginDir = Paths.get(PLUGIN_BASE_DIR, pluginKey);
if (Files.exists(pluginDir)) {
throw new IllegalArgumentException("Plugin directory already exists: " + pluginKey);
}
Files.createDirectories(pluginDir);
// Extract ZIP
try (ZipInputStream zis = new ZipInputStream(zipFile.getInputStream())) {
ZipEntry zipEntry = zis.getNextEntry();
while (zipEntry != null) {
Path newFile = newFile(pluginDir, zipEntry);
if (zipEntry.isDirectory()) {
Files.createDirectories(newFile);
} else {
// Create parent directories if needed
Path parent = newFile.getParent();
if (!Files.exists(parent)) {
Files.createDirectories(parent);
}
// Write file
Files.copy(zis, newFile, StandardCopyOption.REPLACE_EXISTING);
}
zipEntry = zis.getNextEntry();
}
zis.closeEntry();
}
// Create a basic DB entry
Plugin plugin = new Plugin();
plugin.setPluginKey(pluginKey);
plugin.setName(pluginKey);
plugin.setVersion("1.0");
plugin.setStatus(PluginStatus.INACTIVE);
plugin.setAuthor("Uploaded");
plugin.setDescription("Custom uploaded plugin");
pluginRepository.save(plugin);
LOG.info("Installed new plugin from zip: {}", pluginKey);
}
private Path newFile(Path destinationDir, ZipEntry zipEntry) throws IOException {
Path destFile = destinationDir.resolve(zipEntry.getName());
String destDirPath = destinationDir.toFile().getCanonicalPath();
String destFilePath = destFile.toFile().getCanonicalPath();
if (!destFilePath.startsWith(destDirPath + File.separator)) {
throw new IOException("Entry is outside of the target dir: " + zipEntry.getName());
}
return destFile;
}
// --- Plugin File Editor methods ---
public List<String> getPluginFiles(String pluginKey) throws IOException {
Path pluginDir = Paths.get(PLUGIN_BASE_DIR, pluginKey);
List<String> files = new ArrayList<>();
if (Files.exists(pluginDir)) {
Files.walkFileTree(pluginDir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
files.add(pluginDir.relativize(file).toString().replace("\\", "/"));
return FileVisitResult.CONTINUE;
}
});
}
return files;
}
public String readPluginFile(String pluginKey, String filePath) throws IOException {
Path targetPath = resolveAndCheckPath(pluginKey, filePath);
if (Files.exists(targetPath) && !Files.isDirectory(targetPath)) {
return Files.readString(targetPath);
}
throw new IllegalArgumentException("File not found or is a directory: " + filePath);
}
public void writePluginFile(String pluginKey, String filePath, String content) throws IOException {
Path targetPath = resolveAndCheckPath(pluginKey, filePath);
if (Files.exists(targetPath) && !Files.isDirectory(targetPath)) {
Files.writeString(targetPath, content, StandardOpenOption.TRUNCATE_EXISTING);
LOG.info("Saved changes to plugin file: {}/{}", pluginKey, filePath);
} else {
throw new IllegalArgumentException("File not found or is a directory: " + filePath);
}
}
private Path resolveAndCheckPath(String pluginKey, String filePath) throws IOException {
Path pluginDir = Paths.get(PLUGIN_BASE_DIR, pluginKey).toAbsolutePath().normalize();
Path targetPath = pluginDir.resolve(filePath).normalize();
if (!targetPath.startsWith(pluginDir)) {
throw new SecurityException("Path traversal attempt detected!");
}
return targetPath;
}
}
@@ -0,0 +1,40 @@
<?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="20260702143000-1" author="system">
<createSequence sequenceName="sis_media_seq" startValue="1" incrementBy="1"/>
</changeSet>
<changeSet id="20260702143000-2" author="system">
<createTable tableName="sis_media">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="original_filename" type="varchar(500)">
<constraints nullable="false"/>
</column>
<column name="stored_filename" type="varchar(500)">
<constraints nullable="false"/>
</column>
<column name="file_url" type="varchar(1000)">
<constraints nullable="false"/>
</column>
<column name="mime_type" type="varchar(100)"/>
<column name="file_size" type="bigint"/>
<column name="alt_text" type="varchar(500)"/>
<column name="media_type" type="varchar(20)">
<constraints nullable="false"/>
</column>
<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>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd
http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">
<!--
Added the entity Plugin.
-->
<changeSet id="20260702144700-1" author="antigravity">
<createTable tableName="sis_plugin">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="plugin_key" type="varchar(100)">
<constraints nullable="false" unique="true" uniqueConstraintName="ux_sis_plugin__plugin_key"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="description" type="varchar(1000)">
<constraints nullable="true"/>
</column>
<column name="version" type="varchar(50)">
<constraints nullable="true"/>
</column>
<column name="author" type="varchar(255)">
<constraints nullable="true"/>
</column>
<column name="status" type="varchar(20)">
<constraints nullable="false"/>
</column>
<!-- Audit fields -->
<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>
<createSequence sequenceName="sis_plugin_seq" startValue="1" incrementBy="1"/>
<!-- Insert sample plugin -->
<insert tableName="sis_plugin">
<column name="id" valueSequenceNext="sis_plugin_seq"/>
<column name="plugin_key" value="sampleplugin"/>
<column name="name" value="Sample Plugin"/>
<column name="description" value="A sample plugin to demonstrate the HookManager."/>
<column name="version" value="1.0.0"/>
<column name="author" value="Admin"/>
<column name="status" value="ACTIVE"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="CURRENT_TIMESTAMP"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -26,6 +26,8 @@
<include file="config/liquibase/changelog/20260629084709_add_page_layout.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260629163500_add_wordpress_roles.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260629165300_add_totp_to_user.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"/>
<!-- 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 -->
@@ -73,6 +73,22 @@
</div>
</li>
<!-- Nav Item - Media Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapseMedia"
aria-expanded="true" aria-controls="collapseMedia">
<i class="fas fa-fw fa-photo-video"></i>
<span>Media</span>
</a>
<div id="collapseMedia" class="collapse" aria-labelledby="headingMedia" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<h6 class="collapse-header">Media Management:</h6>
<a class="collapse-item" th:href="@{/manage/media}">Library</a>
<a class="collapse-item" th:href="@{/manage/media/new}">Add New</a>
</div>
</div>
</li>
<!-- Nav Item - Pages Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapsePages"
@@ -97,6 +113,23 @@
System
</div>
<!-- Nav Item - Plugins Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapsePlugins"
aria-expanded="true" aria-controls="collapsePlugins">
<i class="fas fa-fw fa-plug"></i>
<span>Plugins</span>
</a>
<div id="collapsePlugins" class="collapse" aria-labelledby="headingPlugins" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<h6 class="collapse-header">Plugin Management:</h6>
<a class="collapse-item" th:href="@{/manage/plugins}">Installed Plugins</a>
<a class="collapse-item" th:href="@{/manage/plugins/new}">Add New</a>
<a class="collapse-item" th:href="@{/manage/plugins/editor}">Plugin File Editor</a>
</div>
</div>
</li>
<!-- Nav Item - Users Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapseUsers"
@@ -0,0 +1,255 @@
<!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>Media Detail</title>
<style>
.media-preview {
background: #f8f9fc;
border: 1px solid #e3e6f0;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
overflow: hidden;
}
.media-preview img {
max-width: 100%;
max-height: 500px;
object-fit: contain;
}
.media-preview video {
max-width: 100%;
max-height: 500px;
}
.media-preview audio {
width: 100%;
margin: 2rem;
}
.media-preview .file-icon-lg {
font-size: 6rem;
color: #b7b9cc;
}
.meta-table th {
width: 140px;
font-weight: 600;
color: #5a5c69;
background: #f8f9fc;
}
.url-copy-group {
position: relative;
}
.url-copy-group .btn-copy {
position: absolute;
right: 0;
top: 0;
}
.copy-toast {
display: none;
position: fixed;
bottom: 2rem;
right: 2rem;
background: #1cc88a;
color: #fff;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 0.15rem 1.75rem rgba(0,0,0,0.2);
z-index: 9999;
font-weight: 600;
}
</style>
</head>
<body>
<div layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Media Detail</h1>
<a th:href="@{/manage/media}" 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 Library
</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">
<!-- Preview Column -->
<div class="col-lg-7 mb-4">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-eye"></i> Preview
</h6>
</div>
<div class="card-body">
<div class="media-preview">
<!-- Image -->
<img th:if="${media.mediaType.name() == 'IMAGE'}"
th:src="${media.fileUrl}"
th:alt="${media.altText ?: media.originalFilename}">
<!-- Video -->
<video th:if="${media.mediaType.name() == 'VIDEO'}" controls>
<source th:src="${media.fileUrl}" th:type="${media.mimeType}">
Your browser does not support the video tag.
</video>
<!-- Audio -->
<audio th:if="${media.mediaType.name() == 'AUDIO'}" controls>
<source th:src="${media.fileUrl}" th:type="${media.mimeType}">
Your browser does not support the audio element.
</audio>
<!-- Document / Other -->
<div th:if="${media.mediaType.name() == 'DOCUMENT'}" class="text-center p-4">
<i class="fas fa-file-alt file-icon-lg" style="color:#4e73df;"></i>
<p class="mt-3 text-muted" th:text="${media.originalFilename}"></p>
<a th:href="${media.fileUrl}" target="_blank" class="btn btn-primary btn-sm">
<i class="fas fa-external-link-alt"></i> Open File
</a>
</div>
<div th:if="${media.mediaType.name() == 'OTHER'}" class="text-center p-4">
<i class="fas fa-file file-icon-lg"></i>
<p class="mt-3 text-muted" th:text="${media.originalFilename}"></p>
</div>
</div>
</div>
</div>
</div>
<!-- Details Column -->
<div class="col-lg-5 mb-4">
<!-- Metadata Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-info-circle"></i> File Information
</h6>
</div>
<div class="card-body p-0">
<table class="table table-sm meta-table mb-0">
<tbody>
<tr>
<th>Filename</th>
<td th:text="${media.originalFilename}"></td>
</tr>
<tr>
<th>Type</th>
<td>
<span class="badge badge-pill"
th:classappend="${media.mediaType.name() == 'IMAGE'} ? 'badge-success' : (${media.mediaType.name() == 'DOCUMENT'} ? 'badge-primary' : (${media.mediaType.name() == 'VIDEO'} ? 'badge-danger' : (${media.mediaType.name() == 'AUDIO'} ? 'badge-warning' : 'badge-secondary')))"
th:text="${media.mediaType}"></span>
</td>
</tr>
<tr>
<th>MIME Type</th>
<td th:text="${media.mimeType}"></td>
</tr>
<tr>
<th>File Size</th>
<td th:text="${media.formattedFileSize}"></td>
</tr>
<tr>
<th>Uploaded By</th>
<td th:text="${media.createdBy}"></td>
</tr>
<tr>
<th>Upload Date</th>
<td th:if="${media.createdDateAsDate != null}"
th:text="${#dates.format(media.createdDateAsDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
<tr>
<th>URL</th>
<td>
<div class="input-group input-group-sm">
<input type="text" class="form-control form-control-sm" id="mediaUrl"
th:value="${fullUrl}" readonly>
<div class="input-group-append">
<button class="btn btn-outline-primary" type="button" id="copyUrlBtn"
title="Copy URL">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Edit Alt Text Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-edit"></i> Edit Details
</h6>
</div>
<div class="card-body">
<form th:action="@{/manage/media/{id}(id=${media.id})}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="form-group">
<label for="altText" class="font-weight-bold">Alt Text</label>
<textarea class="form-control" id="altText" name="altText" rows="3"
placeholder="Describe this media for accessibility..."
th:text="${media.altText}"></textarea>
<small class="form-text text-muted">
Used for image alt attributes and screen readers.
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Changes
</button>
</form>
</div>
</div>
<!-- Delete Card -->
<div class="card shadow border-left-danger">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="font-weight-bold text-danger mb-1">Delete this file</h6>
<small class="text-muted">This action cannot be undone.</small>
</div>
<form th:action="@{/manage/media/{id}/delete(id=${media.id})}" method="post"
onsubmit="return confirm('Are you sure you want to permanently delete this file?');">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash"></i> Delete
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Copy Toast -->
<div class="copy-toast" id="copyToast">
<i class="fas fa-check-circle"></i> URL copied to clipboard!
</div>
</div>
<section layout:fragment="scripts">
<script>
document.getElementById('copyUrlBtn').addEventListener('click', function() {
var urlInput = document.getElementById('mediaUrl');
urlInput.select();
urlInput.setSelectionRange(0, 99999);
navigator.clipboard.writeText(urlInput.value).then(function() {
var toast = document.getElementById('copyToast');
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 2000);
});
});
</script>
</section>
</body>
</html>
@@ -0,0 +1,205 @@
<!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>Media Library</title>
<style>
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.25rem;
}
.media-card {
border: 1px solid #e3e6f0;
border-radius: 0.5rem;
overflow: hidden;
transition: all 0.2s ease;
background: #fff;
}
.media-card:hover {
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.25);
transform: translateY(-2px);
}
.media-card .media-thumb {
width: 100%;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fc;
overflow: hidden;
}
.media-card .media-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-card .media-thumb .file-icon {
font-size: 3.5rem;
color: #b7b9cc;
}
.media-card .media-thumb .file-icon.doc { color: #4e73df; }
.media-card .media-thumb .file-icon.video { color: #e74a3b; }
.media-card .media-thumb .file-icon.audio { color: #f6c23e; }
.media-card .media-info {
padding: 0.75rem;
}
.media-card .media-info .filename {
font-size: 0.8rem;
font-weight: 600;
color: #3a3b45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-card .media-info .meta {
font-size: 0.7rem;
color: #858796;
margin-top: 0.25rem;
}
.media-card .media-actions {
padding: 0 0.75rem 0.75rem;
display: flex;
gap: 0.5rem;
}
.empty-library {
text-align: center;
padding: 4rem 2rem;
color: #858796;
}
.empty-library i {
font-size: 4rem;
margin-bottom: 1rem;
display: block;
color: #d1d3e2;
}
</style>
</head>
<body>
<div layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Media Library</h1>
<a th:href="@{/manage/media/new}" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm">
<i class="fas fa-plus fa-sm text-white-50"></i> Add New
</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>
<!-- Error Message -->
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Filter Bar -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-filter"></i> Filter Media
</h6>
</div>
<div class="card-body">
<form th:action="@{/manage/media}" method="get" class="form-inline">
<div class="form-group mr-3 mb-2">
<label for="filterType" class="mr-2 font-weight-bold">Type:</label>
<select class="form-control form-control-sm" id="filterType" name="type">
<option value="">All Types</option>
<option th:each="mt : ${mediaTypes}" th:value="${mt}" th:text="${mt}"
th:selected="${selectedType != null && selectedType == mt.name()}"></option>
</select>
</div>
<div class="form-group mr-3 mb-2">
<label for="filterKeyword" class="mr-2 font-weight-bold">Search:</label>
<input type="text" class="form-control form-control-sm" id="filterKeyword" name="keyword"
placeholder="Filename..." th:value="${keyword}">
</div>
<button type="submit" class="btn btn-sm btn-primary mb-2 mr-2">
<i class="fas fa-search"></i> Filter
</button>
<a th:href="@{/manage/media}" class="btn btn-sm btn-outline-secondary mb-2">
<i class="fas fa-times"></i> Clear
</a>
</form>
</div>
</div>
<!-- Media Grid -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
All Media
<span class="badge badge-light ml-1" th:text="${#lists.size(mediaList)}"></span>
</h6>
</div>
<div class="card-body">
<!-- Empty state -->
<div th:if="${#lists.isEmpty(mediaList)}" class="empty-library">
<i class="fas fa-photo-video"></i>
<h5>No media files found</h5>
<p>Click "Add New" to upload your first file.</p>
</div>
<!-- Grid -->
<div th:unless="${#lists.isEmpty(mediaList)}" class="media-grid">
<div th:each="media : ${mediaList}" class="media-card">
<a th:href="@{/manage/media/{id}(id=${media.id})}" style="text-decoration:none; color:inherit;">
<div class="media-thumb">
<!-- Image thumbnail -->
<img th:if="${media.mediaType.name() == 'IMAGE'}"
th:src="${media.fileUrl}"
th:alt="${media.altText ?: media.originalFilename}">
<!-- Document icon -->
<i th:if="${media.mediaType.name() == 'DOCUMENT'}" class="fas fa-file-alt file-icon doc"></i>
<!-- Video icon -->
<i th:if="${media.mediaType.name() == 'VIDEO'}" class="fas fa-file-video file-icon video"></i>
<!-- Audio icon -->
<i th:if="${media.mediaType.name() == 'AUDIO'}" class="fas fa-file-audio file-icon audio"></i>
<!-- Other icon -->
<i th:if="${media.mediaType.name() == 'OTHER'}" class="fas fa-file file-icon"></i>
</div>
</a>
<div class="media-info">
<div class="filename" th:text="${media.originalFilename}" th:title="${media.originalFilename}"></div>
<div class="meta">
<span class="badge badge-pill"
th:classappend="${media.mediaType.name() == 'IMAGE'} ? 'badge-success' : (${media.mediaType.name() == 'DOCUMENT'} ? 'badge-primary' : (${media.mediaType.name() == 'VIDEO'} ? 'badge-danger' : (${media.mediaType.name() == 'AUDIO'} ? 'badge-warning' : 'badge-secondary')))"
th:text="${media.mediaType}"></span>
<span th:text="${media.formattedFileSize}"></span>
</div>
<div class="meta" th:if="${media.createdDateAsDate != null}">
<i class="fas fa-clock"></i>
<span th:text="${#dates.format(media.createdDateAsDate, 'yyyy-MM-dd HH:mm')}"></span>
</div>
</div>
<div class="media-actions">
<a th:href="@{/manage/media/{id}(id=${media.id})}" class="btn btn-sm btn-outline-info flex-fill" title="View">
<i class="fas fa-eye"></i>
</a>
<form th:action="@{/manage/media/{id}/delete(id=${media.id})}" method="post" class="flex-fill"
onsubmit="return confirm('Are you sure you want to delete this file?');">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-danger w-100" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,225 @@
<!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>Add New Media</title>
<style>
.upload-zone {
border: 3px dashed #d1d3e2;
border-radius: 0.75rem;
padding: 3rem 2rem;
text-align: center;
background: #f8f9fc;
cursor: pointer;
transition: all 0.3s ease;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: #4e73df;
background: #eaecf4;
}
.upload-zone .upload-icon {
font-size: 4rem;
color: #b7b9cc;
margin-bottom: 1rem;
}
.upload-zone.dragover .upload-icon {
color: #4e73df;
}
.upload-zone h4 {
color: #5a5c69;
margin-bottom: 0.5rem;
}
.upload-zone p {
color: #858796;
margin-bottom: 1.5rem;
}
.file-list {
margin-top: 1.5rem;
}
.file-list-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background: #fff;
border: 1px solid #e3e6f0;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
}
.file-list-item .file-name {
flex: 1;
font-weight: 600;
color: #3a3b45;
margin-left: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-list-item .file-size {
color: #858796;
font-size: 0.8rem;
margin-right: 0.75rem;
}
.file-list-item .remove-file {
color: #e74a3b;
cursor: pointer;
border: none;
background: none;
font-size: 1.1rem;
}
.progress-bar-container {
display: none;
margin-top: 1rem;
}
</style>
</head>
<body>
<div layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Upload New Media</h1>
<a th:href="@{/manage/media}" 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 Library
</a>
</div>
<!-- Upload Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-cloud-upload-alt"></i> Upload Files
</h6>
</div>
<div class="card-body">
<form id="uploadForm" th:action="@{/manage/media/upload}" method="post" enctype="multipart/form-data">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<!-- Drop Zone -->
<div class="upload-zone" id="dropZone" onclick="document.getElementById('fileInput').click();">
<div class="upload-icon">
<i class="fas fa-cloud-upload-alt"></i>
</div>
<h4>Drop files here or click to browse</h4>
<p>Supports images (JPG, PNG, GIF, WebP), documents (PDF, DOCX), video, and audio files.</p>
<button type="button" class="btn btn-primary">
<i class="fas fa-folder-open"></i> Select Files
</button>
</div>
<input type="file" id="fileInput" name="files" multiple
accept="image/*,application/pdf,.doc,.docx,video/*,audio/*"
style="display: none;">
<!-- Selected Files List -->
<div class="file-list" id="fileList"></div>
<!-- Upload Button -->
<div class="mt-3" id="uploadButtonContainer" style="display: none;">
<button type="submit" class="btn btn-success btn-lg" id="uploadBtn">
<i class="fas fa-upload"></i> Upload All Files
</button>
</div>
</form>
</div>
</div>
</div>
<section layout:fragment="scripts">
<script>
(function() {
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const uploadBtn = document.getElementById('uploadButtonContainer');
const dataTransfer = new DataTransfer();
// Drag and drop events
['dragenter', 'dragover'].forEach(evtName => {
dropZone.addEventListener(evtName, function(e) {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(evtName => {
dropZone.addEventListener(evtName, function(e) {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('dragover');
});
});
dropZone.addEventListener('drop', function(e) {
const files = e.dataTransfer.files;
for (let i = 0; i < files.length; i++) {
dataTransfer.items.add(files[i]);
}
fileInput.files = dataTransfer.files;
renderFileList();
});
fileInput.addEventListener('change', function() {
// Add newly selected files to our accumulator
for (let i = 0; i < fileInput.files.length; i++) {
dataTransfer.items.add(fileInput.files[i]);
}
fileInput.files = dataTransfer.files;
renderFileList();
});
function renderFileList() {
fileList.innerHTML = '';
const files = dataTransfer.files;
if (files.length === 0) {
uploadBtn.style.display = 'none';
return;
}
uploadBtn.style.display = 'block';
for (let i = 0; i < files.length; i++) {
const file = files[i];
const item = document.createElement('div');
item.className = 'file-list-item';
let icon = 'fa-file';
if (file.type.startsWith('image/')) icon = 'fa-file-image';
else if (file.type.startsWith('video/')) icon = 'fa-file-video';
else if (file.type.startsWith('audio/')) icon = 'fa-file-audio';
else if (file.type.includes('pdf') || file.type.includes('word') || file.type.includes('document'))
icon = 'fa-file-alt';
const sizeStr = file.size < 1024 ? file.size + ' B'
: file.size < 1024*1024 ? (file.size/1024).toFixed(1) + ' KB'
: (file.size/(1024*1024)).toFixed(1) + ' MB';
item.innerHTML =
'<i class="fas ' + icon + ' text-primary"></i>' +
'<span class="file-name">' + file.name + '</span>' +
'<span class="file-size">' + sizeStr + '</span>' +
'<button type="button" class="remove-file" data-index="' + i + '" title="Remove">' +
'<i class="fas fa-times-circle"></i></button>';
fileList.appendChild(item);
}
// Remove file buttons
document.querySelectorAll('.remove-file').forEach(function(btn) {
btn.addEventListener('click', function() {
const idx = parseInt(this.getAttribute('data-index'));
dataTransfer.items.remove(idx);
fileInput.files = dataTransfer.files;
renderFileList();
});
});
}
})();
</script>
</section>
</body>
</html>
@@ -0,0 +1,117 @@
<!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>Add New Plugin</title>
<style>
.upload-zone {
border: 3px dashed #d1d3e2;
border-radius: 0.75rem;
padding: 3rem 2rem;
text-align: center;
background: #f8f9fc;
cursor: pointer;
transition: all 0.3s ease;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: #4e73df;
background: #eaecf4;
}
.upload-zone .upload-icon {
font-size: 4rem;
color: #b7b9cc;
margin-bottom: 1rem;
}
.upload-zone h4 {
color: #5a5c69;
margin-bottom: 0.5rem;
}
.upload-zone p {
color: #858796;
margin-bottom: 1.5rem;
}
</style>
</head>
<body>
<div layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Add New Plugin</h1>
<a th:href="@{/manage/plugins}" 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 Plugins
</a>
</div>
<!-- Messages -->
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Upload Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-file-archive"></i> Upload Plugin .zip
</h6>
</div>
<div class="card-body">
<form id="uploadForm" th:action="@{/manage/plugins/upload}" method="post" enctype="multipart/form-data">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<!-- Drop Zone -->
<div class="upload-zone" id="dropZone" onclick="document.getElementById('zipFile').click();">
<div class="upload-icon">
<i class="fas fa-file-archive"></i>
</div>
<h4 id="fileText">Select a .zip file</h4>
<p>Upload a plugin archive to install it on the system.</p>
<button type="button" class="btn btn-primary">
<i class="fas fa-folder-open"></i> Browse
</button>
</div>
<input type="file" id="zipFile" name="zipFile" accept=".zip" style="display: none;">
<!-- Upload Button -->
<div class="mt-4 text-center" id="uploadButtonContainer" style="display: none;">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-upload"></i> Install Plugin
</button>
</div>
</form>
</div>
</div>
</div>
<section layout:fragment="scripts">
<script>
const fileInput = document.getElementById('zipFile');
const fileText = document.getElementById('fileText');
const uploadBtn = document.getElementById('uploadButtonContainer');
fileInput.addEventListener('change', function() {
if (fileInput.files.length > 0) {
const file = fileInput.files[0];
if (file.name.endsWith('.zip')) {
fileText.textContent = file.name;
fileText.classList.add('text-primary');
uploadBtn.style.display = 'block';
} else {
fileText.textContent = "Please select a valid .zip file";
fileText.classList.remove('text-primary');
fileText.classList.add('text-danger');
uploadBtn.style.display = 'none';
fileInput.value = '';
}
}
});
</script>
</section>
</body>
</html>
@@ -0,0 +1,124 @@
<!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>Plugin File Editor</title>
<style>
.editor-container {
font-family: 'Courier New', Courier, monospace;
background-color: #f8f9fc;
border: 1px solid #d1d3e2;
border-radius: 0.35rem;
width: 100%;
height: 600px;
padding: 1rem;
font-size: 14px;
resize: vertical;
}
.file-list-group .list-group-item {
padding: 0.5rem 1rem;
font-size: 0.9rem;
border-left: 3px solid transparent;
}
.file-list-group .list-group-item.active {
border-left-color: #4e73df;
background-color: #eaecf4;
color: #4e73df;
font-weight: bold;
}
</style>
</head>
<body>
<div layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Plugin File Editor</h1>
</div>
<!-- Messages -->
<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 th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Plugin Selector -->
<div class="card shadow mb-4">
<div class="card-body py-3 d-flex align-items-center justify-content-end">
<span class="mr-3 font-weight-bold text-gray-800">Select plugin to edit:</span>
<form id="pluginSelectForm" th:action="@{/manage/plugins/editor}" method="get" class="form-inline">
<select class="form-control" name="pluginKey" onchange="document.getElementById('pluginSelectBtn').click();">
<option value="" disabled th:selected="${selectedPlugin == null}">-- Select a Plugin --</option>
<option th:each="p : ${plugins}"
th:value="${p.pluginKey}"
th:text="${p.name}"
th:selected="${selectedPlugin != null && selectedPlugin.pluginKey == p.pluginKey}"></option>
</select>
<button type="submit" id="pluginSelectBtn" style="display:none;">Select</button>
</form>
</div>
</div>
<div th:if="${selectedPlugin != null}" class="row">
<!-- Sidebar: File Tree -->
<div class="col-lg-3 mb-4">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Plugin Files</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush file-list-group">
<a th:each="f : ${pluginFiles}"
th:href="@{/manage/plugins/editor/{key}(key=${selectedPlugin.pluginKey}, file=${f})}"
class="list-group-item list-group-item-action"
th:classappend="${f == selectedFile} ? 'active' : ''">
<i class="fas fa-file-code fa-sm mr-2 text-gray-400"></i>
<span th:text="${f}"></span>
</a>
</div>
</div>
</div>
</div>
<!-- Editor Panel -->
<div class="col-lg-9 mb-4">
<div class="card shadow">
<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">
Editing: <span class="text-dark" th:text="${selectedPlugin.name}"></span>
<span class="text-muted font-weight-normal ml-2" th:text="${selectedFile}"></span>
</h6>
</div>
<div class="card-body">
<form th:action="@{/manage/plugins/editor/{key}/save(key=${selectedPlugin.pluginKey})}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="file" th:value="${selectedFile}" />
<textarea class="editor-container mb-3" name="content" spellcheck="false" th:text="${fileContent}"></textarea>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save File
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,111 @@
<!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>Installed Plugins</title>
</head>
<body>
<div layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Installed Plugins</h1>
<a th:href="@{/manage/plugins/new}" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm">
<i class="fas fa-plus fa-sm text-white-50"></i> Add New Plugin
</a>
</div>
<!-- Messages -->
<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 th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Plugins List Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">All Plugins</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover" width="100%" cellspacing="0">
<thead>
<tr>
<th style="width: 25%">Plugin</th>
<th style="width: 45%">Description</th>
<th style="width: 15%">Status</th>
<th style="width: 15%">Actions</th>
</tr>
</thead>
<tbody>
<tr th:if="${plugins.empty}">
<td colspan="4" class="text-center text-muted py-4">
No plugins installed.
</td>
</tr>
<tr th:each="plugin : ${plugins}" th:classappend="${plugin.status.name() == 'ACTIVE'} ? 'table-primary-light' : ''">
<td>
<strong class="text-dark" th:text="${plugin.name}"></strong><br>
<small class="text-muted">
Version <span th:text="${plugin.version}"></span> |
By <span th:text="${plugin.author}"></span><br>
<code th:text="${plugin.pluginKey}"></code>
</small>
</td>
<td th:text="${plugin.description}"></td>
<td>
<span class="badge"
th:classappend="${plugin.status.name() == 'ACTIVE'} ? 'badge-success' : 'badge-secondary'"
th:text="${plugin.status}"></span>
</td>
<td>
<div class="d-flex align-items-center">
<!-- Activate -->
<form th:if="${plugin.status.name() == 'INACTIVE'}" th:action="@{/manage/plugins/{id}/activate(id=${plugin.id})}" method="post" class="mr-2 mb-0">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-success border-0" title="Activate">
<i class="fas fa-play"></i> Activate
</button>
</form>
<!-- Deactivate -->
<form th:if="${plugin.status.name() == 'ACTIVE'}" th:action="@{/manage/plugins/{id}/deactivate(id=${plugin.id})}" method="post" class="mr-2 mb-0">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-warning border-0" title="Deactivate">
<i class="fas fa-pause"></i> Deactivate
</button>
</form>
<!-- Edit Files -->
<a th:href="@{/manage/plugins/editor/{key}(key=${plugin.pluginKey})}" class="btn btn-sm btn-outline-primary border-0 mr-2" title="Edit Files">
<i class="fas fa-code"></i> Edit
</a>
<!-- Delete -->
<form th:if="${plugin.status.name() == 'INACTIVE'}" th:action="@{/manage/plugins/{id}/delete(id=${plugin.id})}" method="post" class="mb-0" onsubmit="return confirm('Are you sure you want to permanently delete this plugin?');">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Delete">
<i class="fas fa-trash"></i> Delete
</button>
</form>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB