forked from UKSOURCE/cms.hailearning.edu.vn
feat: add service management module with CRUD operations
This commit is contained in:
440
views/admin/service/edit.ejs
Normal file
440
views/admin/service/edit.ejs
Normal file
@@ -0,0 +1,440 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark);">Edit Service: <%= service.name %></h1>
|
||||
<p class="text-muted mb-0">Update service information and settings</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/service" class="btn btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Back to Services
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/service/<%= service.slug %>/edit" method="POST" id="editServiceForm">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h5 class="mb-0" style="color: var(--primary-dark);">
|
||||
<i class="fas fa-edit me-2"></i>Service Information
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium">Service Name</label>
|
||||
<input type="text" class="form-control" id="serviceName" name="name"
|
||||
value="<%= service.name %>" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">
|
||||
Slug
|
||||
<small class="text-muted">(generated from name)</small>
|
||||
<span id="slugAutoIndicator" class="badge bg-info ms-1" style="font-size: 0.7em;">EXISTING</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="serviceSlug" name="slug"
|
||||
value="<%= service.slug %>" readonly>
|
||||
<button type="button" class="btn btn-primary" id="generateSlugBtn" title="Generate slug from name">
|
||||
<i class="fas fa-magic me-1"></i>Generate
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">URL-friendly version of the service name.</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Layout</label>
|
||||
<select class="form-control" id="serviceLayout" name="layout">
|
||||
<option value="left" <%= service.layout === 'left' ? 'selected' : '' %>>Left</option>
|
||||
<option value="right" <%= service.layout === 'right' ? 'selected' : '' %>>Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-medium">Image</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="serviceImage" name="image"
|
||||
value="<%= service.image || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="serviceImage" data-image-type="service">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div id="serviceImagePreview">
|
||||
<% if (service.image) { %>
|
||||
<img src="<%= getFullImageUrl(service.image) %>" class="img-thumbnail"
|
||||
style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Description</label>
|
||||
<textarea class="form-control" id="serviceDescription" name="description"
|
||||
rows="3" required><%= service.description %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Buttons -->
|
||||
<div class="bottom-buttons">
|
||||
<a href="/admin/service" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fas fa-save me-2"></i>Update Service
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script>
|
||||
let servicesData = []; // Will be populated for duplicate checking
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load existing services data for duplicate checking
|
||||
loadServicesData();
|
||||
|
||||
// Initialize form handlers
|
||||
initializeFormHandlers();
|
||||
});
|
||||
|
||||
async function loadServicesData() {
|
||||
try {
|
||||
const response = await fetch('/api/service');
|
||||
const data = await response.json();
|
||||
servicesData = data.services?.items || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading services data:', error);
|
||||
servicesData = [];
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFormHandlers() {
|
||||
// Form submission
|
||||
const form = document.getElementById('editServiceForm');
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Updating...';
|
||||
|
||||
try {
|
||||
// Check for duplicate slug before submitting
|
||||
const slug = document.getElementById('serviceSlug').value.trim();
|
||||
const currentSlug = '<%= service.slug %>';
|
||||
|
||||
if (slug !== currentSlug && isSlugDuplicate(slug, -1)) {
|
||||
showError('Service with this slug already exists. Please generate a new slug.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Update Service';
|
||||
return;
|
||||
}
|
||||
|
||||
this.submit();
|
||||
} catch (error) {
|
||||
console.error('Error updating service:', error);
|
||||
showError('Failed to update service. Please try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Update Service';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize image upload buttons
|
||||
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetInput = this.dataset.targetInput;
|
||||
const imageType = this.dataset.imageType;
|
||||
openImageUploader(targetInput, imageType);
|
||||
});
|
||||
});
|
||||
|
||||
// Image preview for service image
|
||||
const serviceImageInput = document.getElementById('serviceImage');
|
||||
if (serviceImageInput) {
|
||||
serviceImageInput.addEventListener('input', function() {
|
||||
updateImagePreview('serviceImagePreview', this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Generate slug from service name
|
||||
const serviceNameInput = document.getElementById('serviceName');
|
||||
const serviceSlugInput = document.getElementById('serviceSlug');
|
||||
const slugAutoIndicator = document.getElementById('slugAutoIndicator');
|
||||
const generateSlugBtn = document.getElementById('generateSlugBtn');
|
||||
|
||||
if (serviceNameInput && serviceSlugInput && generateSlugBtn) {
|
||||
// Generate slug button
|
||||
generateSlugBtn.addEventListener('click', async function() {
|
||||
const serviceName = serviceNameInput.value.trim();
|
||||
if (serviceName) {
|
||||
// Show loading state
|
||||
const originalBtnHtml = this.innerHTML;
|
||||
this.disabled = true;
|
||||
this.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Generating...';
|
||||
|
||||
try {
|
||||
const slug = await generateSlugFromText(serviceName);
|
||||
const currentSlug = '<%= service.slug %>';
|
||||
|
||||
// Check for duplicate slug (excluding current service)
|
||||
if (slug !== currentSlug && isSlugDuplicate(slug, -1)) {
|
||||
const uniqueSlug = generateUniqueSlug(slug);
|
||||
serviceSlugInput.value = uniqueSlug;
|
||||
showWarning(`Slug "${slug}" already exists. Generated unique slug: "${uniqueSlug}"`);
|
||||
} else {
|
||||
serviceSlugInput.value = slug;
|
||||
showSuccess('Slug generated successfully!');
|
||||
}
|
||||
|
||||
if (slugAutoIndicator) {
|
||||
slugAutoIndicator.textContent = 'GENERATED';
|
||||
slugAutoIndicator.className = 'badge bg-success ms-1';
|
||||
slugAutoIndicator.style.fontSize = '0.7em';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating slug:', error);
|
||||
showError('Failed to generate slug. Please try again.');
|
||||
} finally {
|
||||
// Restore button state
|
||||
this.disabled = false;
|
||||
this.innerHTML = originalBtnHtml;
|
||||
}
|
||||
} else {
|
||||
showError('Please enter a service name first.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug using backend API
|
||||
async function generateSlugFromText(text) {
|
||||
try {
|
||||
const response = await fetch('/admin/service/generate-slug', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: text })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.slug;
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to generate slug');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating slug:', error);
|
||||
// Fallback to simple slug generation if API fails
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w\-]+/g, '')
|
||||
.replace(/\-\-+/g, '-')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/-+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
function isSlugDuplicate(slug, excludeIndex = -1) {
|
||||
return servicesData.some((service, index) => {
|
||||
return service && service.slug === slug && index !== excludeIndex;
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique slug by appending number
|
||||
function generateUniqueSlug(baseSlug) {
|
||||
let counter = 1;
|
||||
let uniqueSlug = baseSlug;
|
||||
|
||||
while (isSlugDuplicate(uniqueSlug, -1)) {
|
||||
uniqueSlug = baseSlug + '-' + counter;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
function updateImagePreview(previewId, imagePath) {
|
||||
const preview = document.getElementById(previewId);
|
||||
if (imagePath) {
|
||||
const fullImageUrl = getFullImageUrlJS(imagePath);
|
||||
preview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">`;
|
||||
} else {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function để tạo full URL cho ảnh
|
||||
function getFullImageUrlJS(imagePath) {
|
||||
if (!imagePath) return '';
|
||||
|
||||
if (imagePath.startsWith('http')) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
const backendUrl = '<%= (process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "") %>';
|
||||
|
||||
let imgSrc = imagePath;
|
||||
if (!imgSrc.startsWith('/')) {
|
||||
imgSrc = '/uploads/' + imgSrc;
|
||||
}
|
||||
|
||||
return backendUrl + imgSrc;
|
||||
}
|
||||
|
||||
async function openImageUploader(targetInput, imageType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.onchange = async function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return reject(new Error('No file selected'));
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
|
||||
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : null;
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
|
||||
}
|
||||
|
||||
const response = await fetch(`/admin/upload/image?imageType=service`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const input = document.getElementById(targetInput);
|
||||
if (!input) throw new Error('Target input not found');
|
||||
|
||||
input.value = result.path;
|
||||
|
||||
// Update preview
|
||||
if (targetInput === 'serviceImage') {
|
||||
updateImagePreview('serviceImagePreview', result.path);
|
||||
}
|
||||
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
if (uploadBtn) {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
console.error('Upload error:', err);
|
||||
showError('Upload failed: ' + (err.message || 'Unknown error'));
|
||||
reject(err);
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showWarning(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-warning alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
|
||||
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bottom-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.card-header h5 {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user