finish plugin function

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