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
@@ -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>