finish plugin function
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 + "/");
|
||||
}
|
||||
}
|
||||
|
||||
+163
@@ -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";
|
||||
}
|
||||
}
|
||||
+159
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
-35
@@ -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
|
||||
}
|
||||
+26
-6
@@ -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;
|
||||
+6
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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>
|
||||
+60
@@ -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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Reference in New Issue
Block a user