Fix admin route access and backend configuration

- Added /admin redirect to login page in nginx config
- Fixed backend server.js route ordering for proper admin handling
- Updated authentication middleware and routes
- Added user management routes
- Configured PostgreSQL integration
- Updated environment configuration
This commit is contained in:
Local Server
2025-12-13 22:34:11 -06:00
parent 8bb6430a70
commit 703ab57984
253 changed files with 29870 additions and 157 deletions

582
Views/AdminProducts/Form.cshtml Executable file
View File

@@ -0,0 +1,582 @@
@model Product
@{
ViewData["Title"] = Model?.Id == null ? "Create Product" : "Edit Product";
Layout = "_AdminLayout";
}
<style>
.form-check-input[type="checkbox"] {
width: 22px;
height: 22px;
cursor: pointer;
border: 2px solid #dee2e6;
border-radius: 4px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: white;
transition: all 0.2s ease;
position: relative;
}
.form-check-input[type="checkbox"]:checked {
background-color: #28a745;
border-color: #28a745;
}
.form-check-input[type="checkbox"]:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 16px;
font-weight: bold;
line-height: 1;
}
.form-check-input[type="checkbox"]:hover {
border-color: #28a745;
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);
}
</style>
@await Html.PartialAsync("_ImagePickerModal")
@await Html.PartialAsync("_VariantManagerModal")
<div class="card">
<div class="card-header">
<h5 class="mb-0">@ViewData["Title"]</h5>
</div>
<div class="card-body">
<form method="post" action="/admin/products/@(Model?.Id == null ? "create" : $"edit/{Model.Id}")">
<div asp-validation-summary="All" class="text-danger mb-3"></div>
<input type="hidden" name="Id" value="@Model?.Id" />
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="Name" class="form-label">Product Name *</label>
<input type="text" class="form-control" id="Name" name="Name" value="@Model?.Name" required>
</div>
<div class="mb-3">
<label for="SKU" class="form-label">SKU Code</label>
<input type="text" class="form-control" id="SKU" name="SKU" value="@Model?.SKU"
placeholder="e.g., AB-001, WASH-2024-01">
<small class="form-text text-muted">Unique product identifier (leave empty to
auto-generate)</small>
</div>
<div class="mb-3">
<label for="ShortDescription" class="form-label">Short Description</label>
<textarea class="form-control" id="ShortDescription" name="ShortDescription" rows="3"
placeholder="Brief product description (shown in listings)">@Model?.ShortDescription</textarea>
</div>
<div class="mb-3">
<label for="Description" class="form-label">Full Description</label>
<textarea class="form-control" id="Description" name="Description"
rows="10">@Model?.Description</textarea>
</div>
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="Price" class="form-label">Selling Price *</label>
<input type="number" step="0.01" class="form-control" id="Price" name="Price"
value="@Model?.Price" required>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="CostPrice" class="form-label">Cost Price</label>
<input type="number" step="0.01" class="form-control" id="CostPrice" name="CostPrice"
value="@Model?.CostPrice" placeholder="Your cost">
<small class="form-text text-muted">For profit margin calculation</small>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="StockQuantity" class="form-label">Stock Quantity</label>
<input type="number" class="form-control" id="StockQuantity" name="StockQuantity"
value="@(Model?.StockQuantity ?? 0)">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="Category" class="form-label">Category</label>
<input type="text" class="form-control" id="Category" name="Category"
value="@Model?.Category" placeholder="e.g., Washi Tape, Stickers">
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Product Color Variants</label>
<div class="border rounded p-3 bg-light">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<strong>Color Variant System (NEW)</strong>
<p class="text-muted small mb-0">Link specific images to color variants for better customer experience</p>
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="openVariantManager()">
<i class="bi bi-palette"></i> Manage Variants
</button>
</div>
<div id="variantSummary" class="mt-2">
<span class="badge bg-secondary">No variants</span>
</div>
</div>
<input type="hidden" id="productVariantsData" name="ProductVariantsJson" value="" />
</div>
<!-- Legacy color picker removed - Use "Manage Variants" button above -->
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Product Images</label>
<div class="border rounded p-3" style="min-height: 200px;">
<div id="imageGallery" class="d-flex flex-wrap gap-2" style="position: relative;">
@if (Model?.Images != null && Model.Images.Any())
{
@for (int i = 0; i < Model.Images.Count; i++)
{
<div class="image-item position-relative" draggable="true" style="width: 80px; height: 80px; cursor: move;" data-image-url="@Model.Images[i]">
<img src="@Model.Images[i]" class="img-thumbnail" style="width: 100%; height: 100%; object-fit: cover; pointer-events: none;">
<button type="button" class="btn btn-danger btn-sm position-absolute top-0 end-0"
style="padding: 2px 6px; font-size: 0.7rem; z-index: 10;"
onclick="removeImageElement(this)">
<i class="bi bi-x"></i>
</button>
@if (i == 0)
{
<span class="badge bg-primary position-absolute bottom-0 start-0 m-1" style="font-size: 0.65rem;">Main</span>
}
<input type="hidden" name="Images" value="@Model.Images[i]">
</div>
}
}
</div>
<div id="uploadPlaceholder" class="text-center"
style="display: @(Model?.Images == null || !Model.Images.Any() ? "block" : "none"); padding: 40px 0;">
<i class="bi bi-image" style="font-size: 48px; color: #ccc;"></i>
<p class="text-muted mt-2">No images uploaded</p>
</div>
</div>
<input type="hidden" id="ImageUrl" name="ImageUrl" value="@Model?.ImageUrl">
<button type="button" class="btn btn-primary btn-sm mt-2 w-100"
onclick="openImagePicker(handleImagePickerSelection, 'multiple')">
<i class="bi bi-images"></i> Select/Upload Images
</button>
<small class="text-muted d-block mt-1">Drag images to reorder. First image is the main display image.</small>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> <strong>Product Detail Page:</strong>
<ul class="mb-0 mt-2" style="font-size:0.9rem;">
<li>Main image and additional images will display in gallery</li>
<li>SKU, price, stock, and color show in product info</li>
<li>Short description appears below buttons</li>
<li>Full description displays in expandable section</li>
<li>Related products suggested based on category & views</li>
</ul>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Product Settings</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="IsActive" name="IsActive" value="true"
@(Model?.IsActive != false ? "checked" : "")>
<label class="form-check-label" for="IsActive">
<strong>Active</strong> - Product visible in shop
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="IsFeatured" name="IsFeatured" value="true"
@(Model?.IsFeatured == true ? "checked" : "")>
<label class="form-check-label" for="IsFeatured">
<strong>Featured</strong> - Show in featured products section
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="IsTopSeller" name="IsTopSeller" value="true"
@(Model?.IsTopSeller == true ? "checked" : "")>
<label class="form-check-label" for="IsTopSeller">
<strong>Top Seller</strong> - Show in top sellers section
</label>
</div>
</div>
</div>
</div>
<hr>
<div class="d-flex justify-content-between">
<a href="/admin/products" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Save Product
</button>
</div>
</form>
</div>
</div>
@section Scripts {
<script src="https://cdn.ckeditor.com/ckeditor5/40.1.0/classic/ckeditor.js"></script>
<script>
let descriptionEditor;
// Initialize CKEditor for Description
ClassicEditor
.create(document.querySelector('#Description'), {
toolbar: [
'heading', '|',
'bold', 'italic', '|',
'link', 'bulletedList', 'numberedList', '|',
'indent', 'outdent', '|',
'blockQuote', 'insertTable', '|',
'undo', 'redo'
],
heading: {
options: [
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' }
]
}
})
.then(editor => {
descriptionEditor = editor;
// Sync CKEditor data before form submission
document.querySelector('form').addEventListener('submit', function (e) {
document.querySelector('#Description').value = descriptionEditor.getData();
});
})
.catch(error => {
console.error(error);
});
let imageIndex = @(Model?.Images?.Count ?? 0);
// Handle Image Picker Selection
function handleImagePickerSelection(selectedUrls) {
const gallery = document.getElementById('imageGallery');
const placeholder = document.getElementById('uploadPlaceholder');
if (selectedUrls.length > 0) {
placeholder.style.display = 'none';
selectedUrls.forEach(imageUrl => {
const imageDiv = document.createElement('div');
imageDiv.className = 'image-item position-relative';
imageDiv.draggable = true;
imageDiv.style.width = '80px';
imageDiv.style.height = '80px';
imageDiv.style.cursor = 'move';
imageDiv.setAttribute('data-image-url', imageUrl);
const isFirstImage = gallery.querySelectorAll('.image-item').length === 0;
const mainBadge = isFirstImage ? '<span class="badge bg-primary position-absolute bottom-0 start-0 m-1" style="font-size: 0.65rem; z-index: 10;">Main</span>' : '';
imageDiv.innerHTML = `
<img src="${imageUrl}" class="img-thumbnail" style="width: 100%; height: 100%; object-fit: cover; pointer-events: none;">
<button type="button" class="btn btn-danger btn-sm position-absolute top-0 end-0"
style="padding: 2px 6px; font-size: 0.7rem; z-index: 10;"
onclick="removeImageElement(this)">
<i class="bi bi-x"></i>
</button>
${mainBadge}
<input type="hidden" name="Images" value="${imageUrl}">
`;
gallery.appendChild(imageDiv);
imageIndex++;
// Set first image as main ImageUrl
if (gallery.children.length === 1 || !document.getElementById('ImageUrl').value) {
document.getElementById('ImageUrl').value = imageUrl;
}
});
// Re-initialize drag and drop for new elements
setTimeout(() => initializeDragAndDrop(), 100);
}
}
async function handleImageUpload(event) {
const files = event.target.files;
const gallery = document.getElementById('imageGallery');
const placeholder = document.getElementById('uploadPlaceholder');
if (files.length > 0) {
placeholder.style.display = 'none';
for (let i = 0; i < files.length; i++) {
const file = files[i];
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/admin/upload/image', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
const imageUrl = result.url;
const imageDiv = document.createElement('div');
imageDiv.className = 'image-item position-relative';
imageDiv.draggable = true;
imageDiv.style.width = '80px';
imageDiv.style.height = '80px';
imageDiv.style.cursor = 'move';
imageDiv.setAttribute('data-image-url', imageUrl);
const isFirstImage = gallery.querySelectorAll('.image-item').length === 0;
const mainBadge = isFirstImage ? '<span class="badge bg-primary position-absolute bottom-0 start-0 m-1" style="font-size: 0.65rem; z-index: 10;">Main</span>' : '';
imageDiv.innerHTML = `
<img src="${imageUrl}" class="img-thumbnail" style="width: 100%; height: 100%; object-fit: cover; pointer-events: none;">
<button type="button" class="btn btn-danger btn-sm position-absolute top-0 end-0"
style="padding: 2px 6px; font-size: 0.7rem; z-index: 10;"
onclick="removeImageElement(this)">
<i class="bi bi-x"></i>
</button>
${mainBadge}
<input type="hidden" name="Images" value="${imageUrl}">
`;
gallery.appendChild(imageDiv);
imageIndex++;
// Set first image as main ImageUrl
if (gallery.children.length === 1 || !document.getElementById('ImageUrl').value) {
document.getElementById('ImageUrl').value = imageUrl;
}
} else {
alert('Error uploading image: ' + result.message);
}
} catch (error) {
alert('Error uploading image');
}
}
}
// Reset file input
event.target.value = '';
}
function removeImageElement(button) {
const imageDiv = button.closest('.position-relative');
const gallery = document.getElementById('imageGallery');
const placeholder = document.getElementById('uploadPlaceholder');
imageDiv.remove();
// Show placeholder if no images left
if (gallery.children.length === 0) {
placeholder.style.display = 'block';
document.getElementById('ImageUrl').value = '';
} else {
// Update main ImageUrl to first image if removed image was main
const firstImage = gallery.querySelector('img');
if (firstImage) {
const currentMain = document.getElementById('ImageUrl').value;
const allImages = Array.from(gallery.querySelectorAll('input[type="hidden"]')).map(input => input.value);
if (!allImages.includes(currentMain)) {
document.getElementById('ImageUrl').value = allImages[0];
}
}
}
}
function removeImage(index) {
if (confirm('Remove this image?')) {
const gallery = document.getElementById('imageGallery');
const imageDiv = gallery.children[index];
removeImageElement(imageDiv.querySelector('button'));
}
}
// Drag and Drop Functionality
let draggedElement = null;
document.addEventListener('DOMContentLoaded', function() {
initializeDragAndDrop();
});
function initializeDragAndDrop() {
const gallery = document.getElementById('imageGallery');
gallery.addEventListener('dragstart', function(e) {
if (e.target.classList.contains('image-item')) {
draggedElement = e.target;
e.target.classList.add('dragging');
e.target.style.opacity = '0.5';
}
});
gallery.addEventListener('dragend', function(e) {
if (e.target.classList.contains('image-item')) {
e.target.classList.remove('dragging');
e.target.style.opacity = '1';
updateMainBadge();
updateImageUrl();
}
});
gallery.addEventListener('dragover', function(e) {
e.preventDefault();
const afterElement = getDragAfterElement(gallery, e.clientX, e.clientY);
if (draggedElement) {
if (afterElement == null) {
gallery.appendChild(draggedElement);
} else {
gallery.insertBefore(draggedElement, afterElement);
}
}
});
gallery.addEventListener('drop', function(e) {
e.preventDefault();
});
}
function getDragAfterElement(container, x, y) {
const draggableElements = [...container.querySelectorAll('.image-item:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const centerX = box.left + box.width / 2;
const centerY = box.top + box.height / 2;
// Calculate distance from mouse to center of element
const offsetX = x - centerX;
const offsetY = y - centerY;
// For horizontal layout, primarily use X offset
if (offsetX < 0 && (closest.offset === undefined || offsetX > closest.offset)) {
return { offset: offsetX, element: child };
} else {
return closest;
}
}, { offset: undefined, element: null }).element;
}
function updateMainBadge() {
const gallery = document.getElementById('imageGallery');
const images = gallery.querySelectorAll('.image-item');
images.forEach((item, index) => {
// Remove existing main badge
const existingBadge = item.querySelector('.badge');
if (existingBadge) {
existingBadge.remove();
}
// Add main badge to first image
if (index === 0) {
const badge = document.createElement('span');
badge.className = 'badge bg-primary position-absolute bottom-0 start-0 m-1';
badge.style.fontSize = '0.65rem';
badge.textContent = 'Main';
item.appendChild(badge);
}
});
}
function updateImageUrl() {
const gallery = document.getElementById('imageGallery');
const firstImage = gallery.querySelector('.image-item img');
if (firstImage) {
document.getElementById('ImageUrl').value = firstImage.src;
}
}
// Update drag functionality when new images are added
const originalHandleImageUpload = handleImageUpload;
handleImageUpload = async function(event) {
await originalHandleImageUpload(event);
setTimeout(() => {
const newImages = document.querySelectorAll('.image-item');
newImages.forEach(item => {
if (!item.draggable) {
item.draggable = true;
item.style.cursor = 'move';
}
});
}, 100);
};
// Color selection toggle
function toggleColorSelection(label) {
const checkbox = label.previousElementSibling;
checkbox.checked = !checkbox.checked;
// Update visual state
if (checkbox.checked) {
const color = checkbox.value;
const checkIcon = color === "White" || color === "Yellow" ? "black" : "white";
label.innerHTML = `<i class="bi bi-check-lg" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: ${checkIcon}; font-size: 1.5rem; font-weight: bold;"></i>`;
label.style.transform = "scale(1.1)";
} else {
label.innerHTML = "";
label.style.transform = "scale(1)";
}
}
// Open Variant Manager
function openVariantManager() {
// Get all product images
const productImages = Array.from(document.querySelectorAll('#imageGallery .image-item img'))
.map(img => img.src);
// Load existing variants
const variantsJson = document.getElementById('productVariantsData').value;
const existingVariants = variantsJson ? JSON.parse(variantsJson) : [];
// Initialize variant manager
initVariantManager(existingVariants, productImages);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('variantManagerModal'));
modal.show();
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Load existing variants if editing
@if (Model?.Variants != null && Model.Variants.Any())
{
<text>
const existingVariants = @Html.Raw(Json.Serialize(Model.Variants));
document.getElementById('productVariantsData').value = JSON.stringify(existingVariants);
productVariants = existingVariants;
updateVariantSummary();
</text>
}
// Debug form submission
const form = document.querySelector('form');
form.addEventListener('submit', function(e) {
const variantData = document.getElementById('productVariantsData').value;
console.log('=== FORM SUBMISSION DEBUG ===');
console.log('ProductVariantsJson field value:', variantData);
console.log('Is empty?', !variantData || variantData === '');
console.log('productVariants array:', productVariants);
console.log('============================');
if (!variantData || variantData === '' || variantData === '[]') {
console.warn('WARNING: No variant data in form! Did you click "Apply Changes" in Variant Manager?');
}
});
});
</script>
}

109
Views/AdminProducts/Index.cshtml Executable file
View File

@@ -0,0 +1,109 @@
@model List<Product>
@{
ViewData["Title"] = "Manage Products";
Layout = "_AdminLayout";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">All Products (@Model.Count)</h5>
<a href="/admin/products/create" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add New Product
</a>
</div>
<div class="card">
<div class="card-body">
@if (Model.Any())
{
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Image</th>
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th>Stock</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var product in Model.OrderByDescending(p => p.CreatedAt))
{
<tr>
<td>
@if (!string.IsNullOrEmpty(product.ImageUrl))
{
<img src="@product.ImageUrl" alt="@product.Name" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;">
}
else
{
<div style="width: 50px; height: 50px; background: #e0e0e0; border-radius: 4px;"></div>
}
</td>
<td>
<strong>@product.Name</strong>
@if (product.IsFeatured)
{
<span class="badge bg-warning text-dark ms-1">Featured</span>
}
@if (product.IsTopSeller)
{
<span class="badge bg-success ms-1">Top Seller</span>
}
</td>
<td>@product.Category</td>
<td>$@product.Price.ToString("F2")</td>
<td>@product.StockQuantity</td>
<td>
@if (product.IsActive)
{
<span class="badge bg-success"><i class="bi bi-check-circle-fill"></i> Active</span>
}
else
{
<span class="badge bg-danger"><i class="bi bi-x-circle-fill"></i> Inactive</span>
}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="/admin/products/edit/@product.Id" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<button onclick="deleteProduct('@product.Id', '@product.Name')" class="btn btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<p class="text-center text-muted my-5">No products found. Create your first product!</p>
}
</div>
</div>
@section Scripts {
<script>
function deleteProduct(id, name) {
if (confirm(`Are you sure you want to delete "${name}"?`)) {
fetch(`/admin/products/delete/${id}`, {
method: 'POST'
})
.then(response => {
if (response.ok) {
location.reload();
} else {
alert('Error deleting product');
}
});
}
}
</script>
}