Compare commits

..

3 commits

20 changed files with 4673 additions and 10 deletions

View file

@ -4,15 +4,15 @@ A **preview/staging environment** for managing KHY Furniture's product catalog w
## Features ## Features
- ✅ **Add Products**: Create new products with full details - **Add Products**: Create new products with full details
- ✅ **Edit Products**: Modify existing product information - **Edit Products**: Modify existing product information
- ✅ **Delete Products**: Remove products with confirmation - **Delete Products**: Remove products with confirmation
- ✅ **Preview Products**: See how products will look before saving - **Preview Products**: See how products will look before saving
- ✅ **Export JSON**: Download updated products.json file - **Export JSON**: Download updated products.json file
- ✅ **Live Preview**: See exactly how changes will look on the website - **Live Preview**: See exactly how changes will look on the website
- ✅ **One-Click Deploy**: Deploy changes to production with a single command - **One-Click Deploy**: Deploy changes to production with a single command
- ✅ **Automatic Backups**: Safe deployment with backup system - **Automatic Backups**: Safe deployment with backup system
- ✅ **No Technical Knowledge Required**: Simple form-based interface - **No Technical Knowledge Required**: Simple form-based interface
## Quick Start ## Quick Start
@ -71,7 +71,7 @@ A **preview/staging environment** for managing KHY Furniture's product catalog w
3. **Confirm Changes** → Review everything thoroughly 3. **Confirm Changes** → Review everything thoroughly
4. **Deploy to Production** → Run `npm run deploy` to copy updated files to the main KHY website 4. **Deploy to Production** → Run `npm run deploy` to copy updated files to the main KHY website
> 📖 **Detailed Workflow**: See [WORKFLOW.md](WORKFLOW.md) for complete instructions > **Detailed Workflow**: See [WORKFLOW.md](WORKFLOW.md) for complete instructions
## File Structure ## File Structure

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Product Detail - KHY</title>
<link rel="stylesheet" href="assets/styles/main.css" />
<style>
/* Force responsive behavior */
@media (min-width: 640px) {
.sm\:hidden {
display: none !important;
}
.sm\:flex {
display: flex !important;
}
}
</style>
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@100;200;300;400;500;600;700&family=Poppins:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-white font-sans text-gray-800">
<!-- Header -->
<header
class="fixed w-full h-20 sm:h-28 top-0 left-0 bg-white shadow-[0_8px_24px_rgba(0,0,0,0.06)] border-b border-black/10 z-50"
>
<nav class="h-full">
<div
class="max-w-7xl mx-auto h-full flex items-center justify-between px-5"
></div>
</nav>
</header>
<main class="pt-20 sm:pt-28">
<!-- Product Details Section -->
<section class="relative w-full bg-white py-6">
<div class="max-w-7xl mx-auto px-5">
<div class="flex items-center">
<h1
class="font-playfair font-normal text-base text-quick-silver"
id="product-details-title"
>
Product Details
</h1>
</div>
</div>
</section>
<!-- Main Product Section -->
<section class="relative w-full bg-white py-8">
<div class="max-w-7xl mx-auto px-5">
<div class="flex flex-col md:flex-row gap-8">
<!-- Left Section - Product Images -->
<div class="flex flex-col-reverse md:flex-row gap-4">
<!-- Thumbnails -->
<div
class="flex flex-row md:flex-col gap-3 md:gap-4 w-full md:w-32 overflow-x-auto md:overflow-visible"
>
<!-- Thumbnail images will be populated dynamically -->
</div>
<!-- Main Product Image -->
<div
class="w-full h-80 md:w-[500px] md:h-[500px] bg-floral-white rounded-lg overflow-hidden"
>
<!-- Main product image will be populated dynamically -->
</div>
</div>
<!-- Right Section - Product Details -->
<div class="w-full md:w-[500px]">
<!-- Product Title -->
<h1
class="font-playfair font-normal text-4xl text-black mb-8"
id="product-title"
>
<!-- Product title will be populated dynamically -->
</h1>
<!-- Product Description -->
<p
class="font-playfair font-normal text-sm text-black mb-8 max-w-md"
id="product-description"
>
<!-- Product description will be populated dynamically -->
</p>
<!-- Quantity and Action Buttons -->
<div class="flex flex-col md:flex-row gap-4 md:gap-6 mb-3">
<!-- Action Buttons -->
<a
href="contact.html"
class="inline-flex items-center justify-center w-full md:w-[440px] h-[64px] min-h-[64px] bg-white text-black font-playfair font-light text-[20px] leading-none rounded-[15px] border border-quick-silver hover:bg-black hover:text-white hover:shadow-lg transform hover:-translate-y-0.5 transition-all duration-200 box-border whitespace-nowrap mb-4"
>
Request a Quote
</a>
<button
id="compare-products-btn"
class="inline-flex items-center justify-center w-full md:w-[440px] h-[64px] min-h-[64px] bg-white text-black font-playfair font-light text-[20px] leading-none rounded-[15px] border border-quick-silver hover:bg-black hover:text-white hover:shadow-lg transform hover:-translate-y-0.5 transition-all duration-200 box-border whitespace-nowrap"
>
Compare Products
</button>
</div>
<!-- Divider -->
<div class="w-full h-px bg-light-silver mb-3"></div>
<!-- Product Specifications -->
<div class="mb-8">
<div class="space-y-2" id="product-specifications">
<!-- Product specifications will be populated dynamically -->
</div>
</div>
<!-- Product Metadata -->
<div class="space-y-4">
<!-- Product metadata will be populated dynamically -->
</div>
</div>
</div>
</div>
</section>
<!-- Product Detail Tabs Section -->
<section id="product-tabs" class="w-full bg-white">
<div class="border-t border-light-silver"></div>
<div class="max-w-7xl mx-auto px-5 py-10">
<!-- Tabs -->
<div class="flex justify-center gap-12 mb-8">
<button class="font-playfair text-2xl text-black">
Description
</button>
</div>
<!-- Copy -->
<div
class="max-w-5xl mx-auto space-y-6 text-center"
id="product-description-content"
>
<!-- Product description content will be populated dynamically -->
</div>
<!-- Images -->
<div class="relative mt-10">
<div
class="grid grid-cols-1 md:grid-cols-2 gap-8"
id="product-gallery-images"
>
<!-- Product gallery images will be populated dynamically -->
</div>
<!-- Back Arrow -->
<button
id="gallery-back-btn"
class="absolute top-1/2 left-0 transform -translate-y-1/2 bg-white border border-gray-300 rounded-full p-3 shadow-lg hover:shadow-xl transition-all duration-200 opacity-0 pointer-events-none"
style="display: none"
>
<svg
class="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
></path>
</svg>
</button>
<!-- Forward Arrow -->
<button
id="gallery-next-btn"
class="absolute top-1/2 right-0 transform -translate-y-1/2 bg-white border border-gray-300 rounded-full p-3 shadow-lg hover:shadow-xl transition-all duration-200 opacity-0 pointer-events-none"
style="display: none"
>
<svg
class="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
></path>
</svg>
</button>
</div>
</div>
</section>
<!-- full-width divider before related products -->
<div class="w-full border-t border-light-silver"></div>
<!-- Related Products Section -->
<section id="related-products" class="w-full bg-white py-14">
<div class="max-w-7xl mx-auto px-5">
<h2
class="font-playfair text-4xl font-medium text-center text-black mb-10"
>
Related Products
</h2>
<div
id="related-grid"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8"
>
<!-- Related items will be injected here -->
</div>
<div class="mt-10 flex justify-center">
<button
id="related-show-more"
class="w-[245px] h-12 border border-uc-gold rounded-md bg-white font-playfair font-semibold text-base text-uc-gold hover:bg-uc-gold hover:text-white transition-colors"
>
Show More
</button>
</div>
</div>
</section>
<!-- full-width divider after related products -->
<div class="w-full border-t border-light-silver"></div>
</main>
<!-- Image Enlargement Modal -->
<div
id="image-modal"
class="fixed inset-0 bg-black bg-opacity-90 z-50 hidden flex items-center justify-center p-4 transition-opacity duration-300"
>
<div
class="relative max-w-7xl max-h-full w-full h-full flex items-center justify-center"
>
<!-- Close Button -->
<button
id="modal-close-btn"
class="absolute top-4 right-4 z-10 bg-white bg-opacity-20 hover:bg-opacity-30 text-white rounded-full p-3 transition-all duration-200 backdrop-blur-sm"
aria-label="Close modal"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
<!-- Enlarged Image -->
<img
id="modal-image"
src=""
alt=""
class="w-[95vw] h-[95vh] object-contain rounded-lg shadow-2xl transform transition-transform duration-300 scale-95"
/>
</div>
</div>
<script src="scripts/main.js?v=3.5"></script>
</body>
</html>

View file

@ -0,0 +1,958 @@
/* Admin Dashboard Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Montserrat", sans-serif;
background-color: #f8f9fa;
color: #333;
line-height: 1.6;
}
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* Header Styles */
.admin-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-family: "Playfair Display", serif;
font-size: 2.5rem;
font-weight: 600;
margin-bottom: 5px;
}
.header-left p {
opacity: 0.9;
font-size: 1.1rem;
}
.header-actions {
display: flex;
gap: 15px;
}
/* Navigation Styles */
.admin-nav {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.nav-left {
display: flex;
gap: 10px;
}
.nav-right {
display: flex;
gap: 10px;
}
.nav-btn {
padding: 15px 25px;
border: none;
background: #f8f9fa;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: #666;
}
.nav-btn:hover {
background: #e9ecef;
transform: translateY(-2px);
}
.nav-btn-undo {
background: #fff3cd !important;
color: #856404 !important;
border: 1px solid #ffeaa7 !important;
}
.nav-btn-undo:hover {
background: #ffeaa7 !important;
color: #6c5b00 !important;
}
.nav-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.nav-icon {
font-size: 1.2rem;
}
/* Main Content */
.admin-main {
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
padding: 30px;
min-height: 600px;
}
.content-section {
display: none;
}
.content-section.active {
display: block;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #f8f9fa;
}
.section-header h2 {
font-family: "Playfair Display", serif;
font-size: 2rem;
color: #333;
}
.section-actions {
display: flex;
gap: 15px;
align-items: center;
}
.search-input {
padding: 10px 15px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 14px;
width: 250px;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #667eea;
}
/* Button Styles */
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
font-size: 14px;
transition: all 0.3s ease;
text-align: center;
justify-content: center;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-success {
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
}
.btn-danger {
background: linear-gradient(135deg, #ff416c 0%, #ff4b2b 100%);
color: white;
}
.btn-secondary {
background: linear-gradient(135deg, #6c757d 0%, #adb5bd 100%);
color: white;
}
.btn-outline {
background: transparent;
color: white;
border: 2px solid white;
}
.btn-outline:hover {
background: white;
color: #667eea;
}
.btn-sm {
padding: 8px 16px;
font-size: 12px;
}
/* Products Grid */
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 25px;
}
.product-card {
border: 1px solid #e9ecef;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
background: white;
}
.product-card:hover {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
transform: translateY(-5px);
}
.product-image {
height: 200px;
overflow: hidden;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
}
.product-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.product-card:hover .product-image img {
transform: scale(1.05);
}
.product-info {
padding: 20px;
}
.product-info h3 {
font-family: "Playfair Display", serif;
font-size: 1.3rem;
margin-bottom: 8px;
color: #333;
}
.product-category {
color: #667eea;
font-weight: 500;
font-size: 0.9rem;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.product-description {
color: #666;
font-size: 0.9rem;
line-height: 1.5;
margin-bottom: 15px;
}
.product-actions {
padding: 15px 20px;
display: flex;
gap: 10px;
border-top: 1px solid #f8f9fa;
background: #f8f9fa;
}
/* Categories List */
.categories-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.category-card {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 20px;
background: white;
transition: all 0.3s ease;
}
.category-card:hover {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.category-card h3 {
font-family: "Playfair Display", serif;
color: #333;
margin-bottom: 10px;
}
.category-id {
color: #667eea;
font-size: 0.9rem;
font-weight: 500;
margin-bottom: 10px;
}
.category-description {
color: #666;
font-size: 0.9rem;
margin-bottom: 15px;
}
.category-actions {
display: flex;
gap: 10px;
}
/* Image Manager */
.image-manager {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.image-item {
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
background: white;
}
.image-item img {
width: 100%;
height: 150px;
object-fit: cover;
}
.image-item-info {
padding: 15px;
}
.image-item-name {
font-size: 0.9rem;
color: #333;
margin-bottom: 10px;
word-break: break-all;
}
.image-item-actions {
display: flex;
gap: 5px;
}
/* Preview Container */
.preview-container {
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
height: 600px;
}
.preview-container iframe {
width: 100%;
height: 100%;
border: none;
}
.preview-controls {
display: flex;
gap: 10px;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.modal-content {
background-color: white;
margin: 2% auto;
padding: 0;
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.modal-header {
padding: 25px 30px;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px 12px 0 0;
}
.modal-header h3 {
font-family: "Playfair Display", serif;
font-size: 1.5rem;
}
.modal-body {
padding: 30px;
}
.close {
font-size: 28px;
font-weight: bold;
cursor: pointer;
color: white;
opacity: 0.8;
transition: opacity 0.3s;
}
.close:hover {
opacity: 1;
}
/* Form Styles */
form {
display: grid;
gap: 20px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
}
label {
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
input,
textarea,
select {
padding: 12px 15px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
font-family: inherit;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 100px;
}
/* Additional Information Section */
.additional-info-section {
margin-top: 25px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.additional-info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.additional-info-section h4 {
margin: 0;
color: #495057;
font-size: 1.1rem;
font-weight: 600;
}
#add-new-field-btn {
font-size: 0.85rem;
padding: 6px 12px;
}
.additional-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.additional-info-item {
display: flex;
flex-direction: column;
}
.additional-info-item label {
font-size: 0.85rem;
font-weight: 500;
color: #6c757d;
margin-bottom: 5px;
}
.additional-info-item input,
.additional-info-item textarea {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.9rem;
background-color: white;
}
.additional-info-item input:focus,
.additional-info-item textarea:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
.additional-info-item textarea {
resize: vertical;
min-height: 60px;
}
/* New field creation form */
.new-field-form {
grid-column: 1 / -1;
background-color: #e3f2fd;
border: 2px dashed #2196f3;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.new-field-form h5 {
margin: 0 0 10px 0;
color: #1976d2;
font-size: 0.95rem;
}
.new-field-inputs {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 10px;
align-items: end;
}
.new-field-inputs input {
padding: 8px 12px;
border: 1px solid #2196f3;
border-radius: 4px;
font-size: 0.9rem;
}
.new-field-inputs button {
padding: 8px 12px;
font-size: 0.85rem;
}
/* Remove field button */
.remove-field-btn {
background: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
margin-left: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.remove-field-btn:hover {
background: #c82333;
}
/* Image Delete Confirmation Dialog */
.image-delete-confirmation {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.confirmation-content {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
max-width: 400px;
text-align: center;
}
.confirmation-content h4 {
margin: 0 0 15px 0;
color: #dc3545;
font-size: 1.3rem;
}
.confirmation-content p {
margin: 10px 0;
line-height: 1.5;
}
.confirmation-content .warning {
color: #dc3545;
font-weight: 500;
background: #f8d7da;
padding: 10px;
border-radius: 6px;
border: 1px solid #f5c6cb;
}
.confirmation-buttons {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 20px;
}
.confirmation-buttons .btn {
min-width: 80px;
}
.form-actions {
display: flex;
gap: 15px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
/* Image Preview */
.image-preview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px;
margin-top: 10px;
}
.image-preview-item {
position: relative;
border-radius: 8px;
overflow: hidden;
}
.image-preview-item img {
width: 100%;
height: 100px;
object-fit: cover;
}
.image-preview-item .remove-btn {
position: absolute;
top: 5px;
right: 5px;
background: rgba(220, 53, 69, 0.9);
color: white;
border: 2px solid white;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.image-preview-item .remove-btn:hover {
background: rgba(220, 53, 69, 1);
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
/* Sync Status */
.sync-status {
display: flex;
flex-direction: column;
gap: 15px;
}
.sync-step {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
border-radius: 8px;
background: #f8f9fa;
transition: all 0.3s;
}
.sync-step.active {
background: #e3f2fd;
color: #1976d2;
}
.sync-step.completed {
background: #e8f5e8;
color: #2e7d32;
}
.sync-step.error {
background: #ffebee;
color: #c62828;
}
.step-icon {
font-size: 1.2rem;
}
.step-text {
font-weight: 500;
}
/* Responsive Design */
@media (max-width: 768px) {
.admin-container {
padding: 10px;
}
.admin-header {
flex-direction: column;
gap: 20px;
text-align: center;
}
.header-actions {
flex-wrap: wrap;
justify-content: center;
}
.admin-nav {
flex-direction: column;
}
.section-header {
flex-direction: column;
gap: 15px;
align-items: stretch;
}
.section-actions {
flex-direction: column;
}
.search-input {
width: 100%;
}
.products-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.modal-content {
width: 95%;
margin: 5% auto;
}
.form-actions {
flex-direction: column;
}
/* Additional info responsive */
.additional-info-grid {
grid-template-columns: 1fr;
}
.new-field-inputs {
grid-template-columns: 1fr;
gap: 8px;
}
.additional-info-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
/* Loading States */
.loading {
opacity: 0.6;
pointer-events: none;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Success/Error Messages */
.message {
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 20px;
font-weight: 500;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* Products Modal Styles */
.products-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #e9ecef;
}
.products-modal-header p {
margin: 0;
font-weight: 500;
color: #6c757d;
}
.products-list {
max-height: 400px;
overflow-y: auto;
}
.product-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
margin-bottom: 10px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.product-item:hover {
background: #e9ecef;
border-color: #6c757d;
}
.product-info h4 {
margin: 0 0 5px 0;
color: #333;
font-size: 16px;
}
.product-description {
margin: 0;
color: #6c757d;
font-size: 14px;
line-height: 1.4;
}
.product-actions {
display: flex;
gap: 10px;
}
.no-products {
text-align: center;
color: #6c757d;
font-style: italic;
padding: 40px 20px;
}

View file

@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KHY Admin Dashboard</title>
<link rel="stylesheet" href="css/admin.css" />
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@100;200;300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div class="admin-container">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<h1>KHY Admin Dashboard</h1>
<p>Product Management System</p>
</div>
<div class="header-actions">
<button id="preview-btn" class="btn btn-secondary">
Preview Site
</button>
<button id="sync-btn" class="btn btn-primary">
Sync to Main Site
</button>
<button id="quick-sync-btn" class="btn btn-secondary">
⚡ Quick Sync
</button>
<button id="logout-btn" class="btn btn-outline">Logout</button>
</div>
</header>
<!-- Navigation -->
<nav class="admin-nav">
<div class="nav-left">
<button class="nav-btn active" data-section="categories">
<span class="nav-icon">🏠</span>
Home
</button>
<button class="nav-btn" data-section="products">
<span class="nav-icon">📦</span>
Products
</button>
<button class="nav-btn" data-section="preview">
<span class="nav-icon">👁️</span>
Preview
</button>
</div>
<div class="nav-right">
<button class="nav-btn nav-btn-undo" id="undo-changes-btn">
<span class="nav-icon">↶</span>
Undo
</button>
</div>
</nav>
<!-- Main Content -->
<main class="admin-main">
<!-- Categories Section -->
<section id="categories-section" class="content-section active">
<div class="section-header">
<h2>Manage your products here</h2>
</div>
<div class="categories-list" id="categories-list">
<!-- Categories will be loaded here -->
</div>
</section>
<!-- Products Section -->
<section id="products-section" class="content-section">
<div class="section-header">
<h2>Product Management</h2>
<div class="section-actions">
<input
type="text"
id="product-search"
placeholder="Search products..."
class="search-input"
/>
<button id="add-product-btn" class="btn btn-success">
<span>+</span> Add Product
</button>
</div>
</div>
<div class="products-grid" id="products-grid">
<!-- Products will be loaded here -->
</div>
</section>
<!-- Preview Section -->
<section id="preview-section" class="content-section">
<div class="section-header">
<h2>Site Preview</h2>
<div class="preview-controls">
<button id="preview-catalog" class="btn btn-primary">
Product Catalog
</button>
</div>
</div>
<div class="preview-container">
<iframe
id="preview-frame"
src="../preview/catalog.html"
frameborder="0"
></iframe>
</div>
</section>
<!-- Category Products Section (Hidden by default) -->
<section
id="category-products-section"
class="content-section"
style="display: none"
>
<div class="section-header">
<h2 id="category-products-title">Category Products</h2>
<div class="section-actions">
<button class="btn btn-secondary" id="back-to-categories-btn">
← Back to Categories
</button>
<button class="btn btn-success" id="add-product-to-category-btn">
<span>+</span> Add Product to this Category
</button>
</div>
</div>
<div class="category-products-container">
<div class="category-info">
<p class="product-count" id="category-product-count">
0 products in this category
</p>
</div>
<div class="products-list" id="category-products-list">
<!-- Products will be loaded here -->
</div>
</div>
</section>
</main>
</div>
<!-- Product Modal -->
<div id="product-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Add Product</h3>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<form id="product-form">
<div class="form-row">
<div class="form-group">
<label for="product-name">Product Name *</label>
<input type="text" id="product-name" name="name" required />
</div>
<div class="form-group">
<label for="product-category">Category *</label>
<select id="product-category" name="category" required>
<option value="">Select Category</option>
</select>
</div>
</div>
<div class="form-group">
<label for="product-description">Short Description *</label>
<textarea
id="product-description"
name="description"
rows="2"
required
></textarea>
</div>
<div class="form-group">
<label for="product-description-long">Long Description</label>
<textarea
id="product-description-long"
name="descriptionLong"
rows="4"
placeholder="Enter each paragraph on a new line"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="product-material">Material</label>
<input type="text" id="product-material" name="material" />
</div>
<div class="form-group">
<label for="product-design">Design</label>
<input type="text" id="product-design" name="design" />
</div>
</div>
<div class="form-group">
<label for="product-use-cases">Use Cases</label>
<input type="text" id="product-use-cases" name="useCases" />
</div>
<div class="form-group">
<label for="product-warranty">Warranty</label>
<input
type="text"
id="product-warranty"
name="warranty"
value="5 years"
/>
</div>
<div class="form-group">
<label for="product-images">Product Images</label>
<input
type="file"
id="product-images"
name="images"
multiple
accept="image/*"
/>
<div class="image-preview" id="image-preview"></div>
</div>
<!-- Additional Information Section -->
<div
class="additional-info-section"
id="additional-info-section"
style="display: none"
>
<div class="additional-info-header">
<h4>Additional Information</h4>
<button
type="button"
class="btn btn-sm btn-secondary"
id="add-new-field-btn"
>
+ Add New Field
</button>
</div>
<div class="additional-info-grid" id="additional-info-grid">
<!-- Additional info fields will be populated here -->
</div>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
id="cancel-product-btn"
>
Cancel
</button>
<button type="submit" class="btn btn-primary">
Save Product
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Category Modal -->
<div id="category-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="category-modal-title">Add Category</h3>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<form id="category-form">
<div class="form-group">
<label for="category-id">Category ID *</label>
<input
type="text"
id="category-id"
name="id"
required
placeholder="e.g., task-chairs"
/>
</div>
<div class="form-group">
<label for="category-name">Category Name *</label>
<input type="text" id="category-name" name="name" required />
</div>
<div class="form-group">
<label for="category-description">Description</label>
<textarea
id="category-description"
name="description"
rows="3"
></textarea>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
id="cancel-category-btn"
>
Cancel
</button>
<button type="submit" class="btn btn-primary">
Save Category
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Sync Status Modal -->
<div id="sync-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Syncing to Main Site</h3>
</div>
<div class="modal-body">
<div class="sync-status">
<div class="sync-step" id="sync-step-1">
<span class="step-icon">⏳</span>
<span class="step-text">Preparing sync...</span>
</div>
<div class="sync-step" id="sync-step-2">
<span class="step-icon">⏳</span>
<span class="step-text">Cloning main repository...</span>
</div>
<div class="sync-step" id="sync-step-3">
<span class="step-icon">⏳</span>
<span class="step-text">Syncing products...</span>
</div>
<div class="sync-step" id="sync-step-4">
<span class="step-icon">⏳</span>
<span class="step-text">Syncing images...</span>
</div>
<div class="sync-step" id="sync-step-5">
<span class="step-icon">⏳</span>
<span class="step-text">Committing changes...</span>
</div>
<div class="sync-step" id="sync-step-6">
<span class="step-icon">⏳</span>
<span class="step-text">Pushing to main site...</span>
</div>
</div>
</div>
</div>
</div>
<!-- <script src="js/storageManager.js"></script> No longer needed - using direct saves -->
<script src="js/productManager.js"></script>
<script src="js/categoryManager.js"></script>
<script src="js/imageManager.js"></script>
<script src="js/admin.js"></script>
</body>
</html>

View file

@ -0,0 +1,543 @@
// Main Admin Dashboard Controller
class AdminDashboard {
constructor() {
this.currentSection = "categories";
this.productManager = null;
this.categoryManager = null;
this.init();
}
init() {
this.setupEventListeners();
this.initializeManagers();
this.loadInitialData();
// Debug: Check if modal exists
const modal = document.getElementById("product-modal");
console.log("Product modal element exists:", !!modal);
if (modal) {
console.log("Modal current display:", modal.style.display);
}
}
setupEventListeners() {
// Navigation
document.querySelectorAll(".nav-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
const button = e.currentTarget;
this.switchSection(button.dataset.section);
});
});
// Header actions
document.getElementById("preview-btn").addEventListener("click", () => {
this.switchSection("preview");
});
document.getElementById("sync-btn").addEventListener("click", () => {
this.syncToMainSite();
});
document.getElementById("quick-sync-btn").addEventListener("click", () => {
this.quickSync();
});
document.getElementById("logout-btn").addEventListener("click", () => {
this.logout();
});
document
.getElementById("undo-changes-btn")
.addEventListener("click", () => {
this.undoChanges();
});
// Preview control buttons
const catalogBtn = document.getElementById("preview-catalog");
if (catalogBtn) {
catalogBtn.addEventListener("click", () => {
this.loadPreview("catalog");
});
}
// Modal close handlers
document.querySelectorAll(".close").forEach((closeBtn) => {
closeBtn.addEventListener("click", (e) => {
const modal = e.target.closest(".modal");
modal.style.display = "none";
});
});
// Close modal when clicking outside
window.addEventListener("click", (e) => {
if (
e.target.classList.contains("modal") &&
e.target === e.currentTarget
) {
e.target.style.display = "none";
}
});
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
this.closeAllModals();
}
});
}
initializeManagers() {
this.productManager = new ProductManager(this);
this.categoryManager = new CategoryManager(this);
}
async loadInitialData() {
try {
await this.productManager.loadData();
await this.categoryManager.loadData();
// Render categories immediately since Categories is the default active section
this.categoryManager.renderCategories();
this.showMessage("Dashboard loaded successfully", "success");
} catch (error) {
this.showMessage(
"Error loading dashboard data: " + error.message,
"error"
);
}
}
switchSection(sectionName) {
// Update navigation
document.querySelectorAll(".nav-btn").forEach((btn) => {
btn.classList.remove("active");
});
// Only update nav button if it's a regular section
if (sectionName !== "category-products") {
const navBtn = document.querySelector(`[data-section="${sectionName}"]`);
if (navBtn) {
navBtn.classList.add("active");
}
}
// Update content - hide all sections first
document.querySelectorAll(".content-section").forEach((section) => {
section.classList.remove("active");
section.style.display = "none";
});
// Show the target section
const targetSection = document.getElementById(`${sectionName}-section`);
if (targetSection) {
targetSection.classList.add("active");
targetSection.style.display = "block";
}
this.currentSection = sectionName;
// Load section-specific data
this.loadSectionData(sectionName);
}
loadSectionData(sectionName) {
switch (sectionName) {
case "categories":
this.categoryManager.renderCategories();
break;
case "products":
this.productManager.renderProducts();
break;
case "preview":
this.loadPreview();
break;
}
}
loadPreview(type = "catalog") {
const iframe = document.getElementById("preview-frame");
const timestamp = new Date().getTime();
if (type === "catalog") {
iframe.src = `../preview/catalog.html?t=${timestamp}`;
} else if (type === "detail") {
iframe.src = `../preview/detail.html?t=${timestamp}`;
}
}
async syncToMainSite() {
const syncModal = document.getElementById("sync-modal");
syncModal.style.display = "block";
try {
// Step 1: Copy admin dashboard data to main website
if (this.productManager) {
await this.copyDataToMainWebsite();
}
// Step 2: Proceed with main site sync
await this.simulateSyncProcess();
this.showMessage("Successfully synced to main site!", "success");
} catch (error) {
this.showMessage("Sync failed: " + error.message, "error");
} finally {
syncModal.style.display = "none";
}
}
async quickSync() {
try {
console.log("⚡ QUICK SYNC STARTED");
// Get the current data from localStorage backup
const backupData = localStorage.getItem("khy_products_backup");
if (!backupData) {
this.showMessage(
"No changes to sync. Save a product first!",
"warning"
);
return;
}
const data = JSON.parse(backupData);
console.log("📦 Syncing data:", data.products.length, "products");
// Create a simple copy command for the user
const dataStr = JSON.stringify(data, null, 2);
const dataBlob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(dataBlob);
// Create download link
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = "products_sync.json";
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(url);
// Show instructions
this.showMessage(
"File downloaded! Copy it to data/products.json",
"info"
);
console.log("📋 QUICK SYNC INSTRUCTIONS:");
console.log("1. File 'products_sync.json' has been downloaded");
console.log("2. Copy it to replace: data/products.json");
console.log("3. Refresh the preview to see changes");
} catch (error) {
console.error("❌ Quick sync failed:", error);
this.showMessage("Quick sync failed: " + error.message, "error");
}
}
async copyDataToMainWebsite() {
try {
console.log("🔄 COPYING DATA TO MAIN WEBSITE");
// Get the current data from localStorage backup
const backupData = localStorage.getItem("khy_products_backup");
if (!backupData) {
console.log("⚠️ No backup data found in localStorage");
return;
}
const data = JSON.parse(backupData);
console.log("📦 Data to copy:", data.products.length, "products");
// Create a simple file copy using Node.js script
// This will be handled by the sync-to-main.js script
console.log("✅ Data ready for sync - will be processed by sync script");
} catch (error) {
console.error("❌ Error preparing data for sync:", error);
throw error;
}
}
async syncTemporaryChangesToLocal() {
try {
// Check if there are temporary changes to sync
const temporaryChanges =
await window.storageManager.getTemporaryChanges();
if (
temporaryChanges.products &&
Object.keys(temporaryChanges.products).length > 0
) {
console.log("🔄 Syncing temporary changes to local files...");
// Handle image changes first
if (temporaryChanges.imageChanges) {
console.log("📸 Processing image changes...");
await this.processImageChanges(temporaryChanges.imageChanges);
}
// Apply changes to actual products array
Object.values(temporaryChanges.products).forEach((tempProduct) => {
const existingIndex = this.productManager.products.findIndex(
(p) => p.id === tempProduct.id
);
if (existingIndex >= 0) {
// Update existing product
this.productManager.products[existingIndex] = {
...this.productManager.products[existingIndex],
...tempProduct,
};
} else {
// Add new product
this.productManager.products.push(tempProduct);
}
});
// Save to actual JSON file
await this.productManager.saveData();
// Clear temporary storage
await window.storageManager.clearAllTemporaryData();
console.log("✅ Temporary changes synced to local files successfully");
}
} catch (error) {
console.error("❌ Error syncing temporary changes:", error);
}
}
async processImageChanges(imageChanges) {
console.log("📸 PROCESSING IMAGE CHANGES:");
console.log("Image changes:", imageChanges);
for (const [productId, changes] of Object.entries(imageChanges)) {
console.log(`📦 Processing images for product ${productId}:`, changes);
// Handle new image uploads
if (changes.newImages && changes.newImages.length > 0) {
console.log("📁 Uploading new images:", changes.newImages);
for (const imageRef of changes.newImages) {
// Get the actual file from IndexedDB
const imageData = await window.storageManager.getImageFile(
imageRef.imageId
);
if (imageData) {
await this.uploadImageFile(imageData);
}
}
}
// Handle deleted images
if (changes.deletedImages && changes.deletedImages.length > 0) {
console.log("🗑️ Deleting images:", changes.deletedImages);
for (const deletedImage of changes.deletedImages) {
await this.deleteImageFile(deletedImage);
}
}
}
}
async uploadImageFile(imageData) {
console.log("📤 UPLOADING IMAGE FILE:", imageData.name);
// In a real implementation, you would:
// 1. Create FormData with the file
// 2. Upload to server/assets directory
// 3. Return the new file path
// 4. Handle file naming conflicts
// For now, simulate the upload process
const newPath = `assets/images/products/uploaded/${Date.now()}_${
imageData.name
}`;
console.log(`📁 File would be uploaded to: ${newPath}`);
console.log(`📁 File size: ${imageData.size} bytes`);
console.log(`📁 File type: ${imageData.type}`);
// TODO: Implement actual file upload
// The actual file is in imageData.file
return newPath;
}
async deleteImageFile(deletedImage) {
console.log("🗑️ DELETING IMAGE FILE:", deletedImage.path);
// In a real implementation, you would:
// 1. Check if file exists
// 2. Delete the file from assets directory
// 3. Handle any errors gracefully
// For now, simulate the deletion process
console.log(`🗑️ File would be deleted: ${deletedImage.path}`);
// TODO: Implement actual file deletion
return true;
}
async simulateSyncProcess() {
const steps = [
"sync-step-1",
"sync-step-2",
"sync-step-3",
"sync-step-4",
"sync-step-5",
"sync-step-6",
];
for (let i = 0; i < steps.length; i++) {
const step = document.getElementById(steps[i]);
step.classList.add("active");
// Simulate processing time
await new Promise((resolve) => setTimeout(resolve, 1000));
step.classList.remove("active");
step.classList.add("completed");
}
// In a real implementation, this would call the actual sync script
console.log("Sync process completed");
}
logout() {
if (confirm("Are you sure you want to logout?")) {
// In a real implementation, this would clear authentication
window.location.href = "login.html";
}
}
undoChanges() {
if (
confirm(
"Are you sure you want to undo all unsaved changes? This action cannot be undone."
)
) {
// Call the productManager's undo method
if (this.productManager) {
this.productManager.undoTemporaryChanges();
}
}
}
closeAllModals() {
document.querySelectorAll(".modal").forEach((modal) => {
modal.style.display = "none";
});
}
showMessage(message, type = "info") {
// Remove existing messages
const existingMessages = document.querySelectorAll(".message");
existingMessages.forEach((msg) => msg.remove());
// Create new message
const messageDiv = document.createElement("div");
messageDiv.className = `message ${type}`;
messageDiv.textContent = message;
// Insert at the top of the main content
const main = document.querySelector(".admin-main");
main.insertBefore(messageDiv, main.firstChild);
// Auto-remove after 5 seconds
setTimeout(() => {
messageDiv.remove();
}, 5000);
}
// Utility method for other managers to use
getData() {
return {
products: this.productManager.products,
categories: this.categoryManager.categories,
};
}
// Utility method to refresh current section
refreshCurrentSection() {
this.loadSectionData(this.currentSection);
}
async undoChanges() {
const undoStack = JSON.parse(
localStorage.getItem("khy_undo_stack") || "[]"
);
if (undoStack.length === 0) {
this.showMessage("No changes to undo.", "info");
return;
}
const lastChange = undoStack[undoStack.length - 1];
const confirmMessage = `Undo last change: ${lastChange.action.toUpperCase()} "${
lastChange.newData.name
}"?\n\nThis action cannot be undone.`;
if (confirm(confirmMessage)) {
try {
await this.performUndo(lastChange);
// Remove the undone change from the stack
undoStack.pop();
localStorage.setItem("khy_undo_stack", JSON.stringify(undoStack));
this.showMessage(
`Successfully undid: ${lastChange.action} "${lastChange.newData.name}"`,
"success"
);
// Refresh the current view
this.productManager.refreshCurrentView();
} catch (error) {
console.error("❌ ERROR DURING UNDO:", error);
this.showMessage("Error during undo: " + error.message, "error");
}
}
}
async performUndo(changeEntry) {
console.log("⏪ PERFORMING UNDO:", changeEntry);
if (changeEntry.action === "edit") {
// Restore the original product data
const index = this.productManager.products.findIndex(
(p) => p.id === changeEntry.productId
);
if (index !== -1) {
this.productManager.products[index] = changeEntry.originalData;
console.log("✅ Restored original product data");
}
} else if (changeEntry.action === "add") {
// Remove the added product
const index = this.productManager.products.findIndex(
(p) => p.id === changeEntry.productId
);
if (index !== -1) {
this.productManager.products.splice(index, 1);
console.log("✅ Removed added product");
}
}
// Save the changes
await this.productManager.saveData();
console.log("💾 Undo changes saved to file");
}
}
// Initialize dashboard when DOM is loaded
document.addEventListener("DOMContentLoaded", () => {
window.adminDashboard = new AdminDashboard();
});
// Global utility functions
window.showMessage = (message, type) => {
if (window.adminDashboard) {
window.adminDashboard.showMessage(message, type);
}
};
window.refreshDashboard = () => {
if (window.adminDashboard) {
window.adminDashboard.refreshCurrentSection();
}
};

View file

@ -0,0 +1,459 @@
// Category Management Class
class CategoryManager {
constructor(dashboard) {
this.dashboard = dashboard;
this.categories = [];
this.currentCategory = null;
this.setupEventListeners();
}
setupEventListeners() {
// Add category button - removed from UI
// document
// .getElementById("add-category-btn")
// .addEventListener("click", () => {
// this.showCategoryModal();
// });
// Category form submit
document.getElementById("category-form").addEventListener("submit", (e) => {
e.preventDefault();
this.saveCategory();
});
// Cancel button in category modal
document.addEventListener("click", (e) => {
if (e.target.id === "cancel-category-btn") {
e.preventDefault();
this.hideCategoryModal();
}
});
// View Products buttons - use event delegation
document.addEventListener("click", (e) => {
if (e.target.matches("[data-category-id]")) {
const categoryId = e.target.getAttribute("data-category-id");
this.viewCategoryProducts(categoryId);
}
});
// Back to Categories button
document.addEventListener("click", (e) => {
if (e.target.id === "back-to-categories-btn") {
this.backToHome();
}
});
// Add Product to Category button
document.addEventListener("click", (e) => {
if (e.target.id === "add-product-to-category-btn") {
const categoryId = e.target.getAttribute("data-category-id");
this.addProductToCategory(categoryId);
}
});
// Edit Product buttons in category view - use event delegation
document.addEventListener("click", (e) => {
console.log("Click detected on:", e.target);
// Check if the clicked element or its parent has the edit-product action
const editButton = e.target.closest("[data-action='edit-product']");
if (editButton) {
console.log("Edit product button clicked!");
e.stopPropagation();
const productId = editButton.getAttribute("data-product-id");
console.log("Product ID:", productId);
this.editProduct(productId);
}
});
}
async loadData() {
try {
const response = await fetch("../data/products.json");
const data = await response.json();
this.categories = data.categories || [];
} catch (error) {
console.error("Error loading category data:", error);
throw error;
}
}
renderCategories() {
const container = document.getElementById("categories-list");
if (this.categories.length === 0) {
container.innerHTML = `
<div class="no-categories">
<h3>No categories found</h3>
<p>Add your first category to get started!</p>
<button class="btn btn-primary" onclick="categoryManager.showCategoryModal()">Add Category</button>
</div>
`;
return;
}
container.innerHTML = "";
this.categories.forEach((category) => {
const categoryCard = this.createCategoryCard(category);
container.appendChild(categoryCard);
});
}
createCategoryCard(category) {
const card = document.createElement("div");
card.className = "category-card";
// Count products in this category
const productCount = this.dashboard.productManager.products.filter(
(p) => p.category === category.id
).length;
card.innerHTML = `
<div class="category-header">
<h3>${category.name}</h3>
<span class="category-id">${category.id}</span>
</div>
<p class="category-description">${
category.description || "No description"
}</p>
<div class="category-stats">
<span class="product-count">${productCount} product${
productCount !== 1 ? "s" : ""
}</span>
</div>
<div class="category-actions">
<button class="btn btn-sm btn-secondary" data-category-id="${
category.id
}">
View Products
</button>
</div>
`;
return card;
}
showCategoryModal(category = null) {
const modal = document.getElementById("category-modal");
const title = document.getElementById("category-modal-title");
const form = document.getElementById("category-form");
this.currentCategory = category;
if (category) {
title.textContent = "Edit Category";
this.populateForm(category);
} else {
title.textContent = "Add Category";
form.reset();
}
modal.style.display = "block";
}
hideCategoryModal() {
document.getElementById("category-modal").style.display = "none";
this.currentCategory = null;
}
populateForm(category) {
document.getElementById("category-id").value = category.id;
document.getElementById("category-name").value = category.name;
document.getElementById("category-description").value =
category.description || "";
}
async saveCategory() {
const formData = new FormData(document.getElementById("category-form"));
const categoryData = {
id: formData.get("id"),
name: formData.get("name"),
description: formData.get("description"),
};
try {
// Validate required fields
if (!categoryData.id || !categoryData.name) {
throw new Error("Category ID and name are required");
}
// Check for duplicate ID (only for new categories)
if (!this.currentCategory) {
const existingCategory = this.categories.find(
(cat) => cat.id === categoryData.id
);
if (existingCategory) {
throw new Error("A category with this ID already exists");
}
}
// Update or add category
const existingIndex = this.categories.findIndex(
(cat) => cat.id === categoryData.id
);
if (existingIndex >= 0) {
this.categories[existingIndex] = {
...this.categories[existingIndex],
...categoryData,
};
} else {
this.categories.push(categoryData);
}
await this.saveData();
this.renderCategories();
this.hideCategoryModal();
this.dashboard.showMessage("Category saved successfully!", "success");
// Update product manager's category select
if (this.dashboard.productManager) {
this.dashboard.productManager.populateCategorySelect();
}
} catch (error) {
this.dashboard.showMessage(
"Error saving category: " + error.message,
"error"
);
}
}
editCategory(id) {
const category = this.categories.find((cat) => cat.id === id);
if (category) {
this.showCategoryModal(category);
}
}
viewCategoryProducts(categoryId) {
const category = this.categories.find((cat) => cat.id === categoryId);
if (!category) return;
// Get products in this category
const productsInCategory = this.dashboard.productManager.products.filter(
(p) => p.category === categoryId
);
// Switch to the dedicated category products section
this.dashboard.switchSection("category-products");
// Render the category products
this.showCategoryProductsScreen(category, productsInCategory);
}
showCategoryProductsScreen(category, products) {
console.log("showCategoryProductsScreen called with category:", category);
console.log("Products in category:", products);
// Update the section title
const title = document.getElementById("category-products-title");
if (title) {
title.textContent = `${category.name} Products`;
}
// Update the product count
const productCount = document.getElementById("category-product-count");
if (productCount) {
productCount.textContent = `${products.length} product${
products.length !== 1 ? "s" : ""
} in this category`;
}
// Update the add product button
const addButton = document.getElementById("add-product-to-category-btn");
if (addButton) {
addButton.setAttribute("data-category-id", category.id);
}
// Update the products list
const productsList = document.getElementById("category-products-list");
if (productsList) {
productsList.innerHTML =
products.length === 0
? '<p class="no-products">No products in this category yet. Click "Add Product to this Category" to get started!</p>'
: products
.map((product) => {
console.log(
"Rendering product:",
product.name,
"with ID:",
product.id
);
return `
<div class="product-card">
<div class="product-info">
<h4>${product.name}</h4>
<p class="product-description">${
product.description || "No description"
}</p>
</div>
<div class="product-actions">
<button class="btn btn-sm btn-primary" data-action="edit-product" data-product-id="${
product.id
}">
✏️ Edit
</button>
</div>
</div>
`;
})
.join("");
}
}
backToHome() {
// Switch back to home section
this.dashboard.switchSection("categories");
}
setupModalEventListeners() {
const modal = document.getElementById("products-modal");
if (!modal) return;
// Close modal
modal.addEventListener("click", (e) => {
if (
e.target.matches("[data-action='close-modal']") ||
e.target.classList.contains("modal")
) {
modal.style.display = "none";
}
});
// Add product to category
modal.addEventListener("click", (e) => {
if (e.target.matches("[data-action='add-product']")) {
const categoryId = e.target.getAttribute("data-category-id");
this.addProductToCategory(categoryId);
}
});
// View product details
modal.addEventListener("click", (e) => {
if (e.target.matches("[data-action='view-product']")) {
const productId = e.target.getAttribute("data-product-id");
this.viewProductDetails(productId);
}
});
// Edit product
modal.addEventListener("click", (e) => {
if (e.target.matches("[data-action='edit-product']")) {
e.stopPropagation();
const productId = e.target.getAttribute("data-product-id");
this.editProduct(productId);
}
});
}
addProductToCategory(categoryId) {
// Show the product modal directly without switching sections
this.dashboard.productManager.showProductModal();
// Pre-select the category in the form after the modal is shown
setTimeout(() => {
const categorySelect = document.getElementById("product-category");
if (categorySelect) {
categorySelect.value = categoryId;
}
}, 200);
}
viewProductDetails(productId) {
// Close the products modal immediately
const modal = document.getElementById("products-modal");
if (modal) {
modal.remove();
}
// Switch to products section and show product details
this.dashboard.switchSection("products");
// You could implement a product details view here
this.dashboard.showMessage(
`Viewing product details for ID: ${productId}`,
"info"
);
}
editProduct(productId) {
console.log("editProduct called with ID:", productId);
console.log("Dashboard productManager:", this.dashboard.productManager);
// Edit the product directly (we're already in the products section)
this.dashboard.productManager.editProduct(productId);
}
async deleteCategory(id) {
const category = this.categories.find((cat) => cat.id === id);
if (!category) return;
// Check if category has products
const productsInCategory = this.dashboard.productManager.products.filter(
(p) => p.category === id
);
if (productsInCategory.length > 0) {
this.dashboard.showMessage(
`Cannot delete category "${category.name}" because it contains ${productsInCategory.length} product(s). Please move or delete the products first.`,
"error"
);
return;
}
if (
confirm(
`Are you sure you want to delete the category "${category.name}"? This action cannot be undone.`
)
) {
try {
this.categories = this.categories.filter((cat) => cat.id !== id);
await this.saveData();
this.renderCategories();
this.dashboard.showMessage("Category deleted successfully!", "success");
// Update product manager's category select
if (this.dashboard.productManager) {
this.dashboard.productManager.populateCategorySelect();
}
} catch (error) {
this.dashboard.showMessage(
"Error deleting category: " + error.message,
"error"
);
}
}
}
async saveData() {
const data = {
products: this.dashboard.productManager.products,
categories: this.categories,
};
// In a real implementation, this would save to a server
console.log("Saving category data:", data);
// Simulate API call
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 500);
});
}
// Utility method to get category by ID
getCategoryById(id) {
return this.categories.find((cat) => cat.id === id);
}
// Utility method to get category name by ID
getCategoryName(id) {
const category = this.getCategoryById(id);
return category ? category.name : id;
}
// Utility method to validate category ID format
validateCategoryId(id) {
// Category IDs should be lowercase, hyphen-separated
return /^[a-z0-9-]+$/.test(id);
}
}
// Make CategoryManager available globally
window.CategoryManager = CategoryManager;

View file

@ -0,0 +1,338 @@
// Image Management Class
class ImageManager {
constructor(dashboard) {
this.dashboard = dashboard;
this.images = [];
this.setupEventListeners();
}
setupEventListeners() {
// Upload images button
document
.getElementById("upload-images-btn")
.addEventListener("click", () => {
this.showUploadModal();
});
}
async loadData() {
try {
// In a real implementation, this would fetch from a server
// For now, we'll collect images from products
this.collectImagesFromProducts();
} catch (error) {
console.error("Error loading image data:", error);
throw error;
}
}
collectImagesFromProducts() {
this.images = [];
const products = this.dashboard.productManager.products;
products.forEach((product) => {
if (product.images && Array.isArray(product.images)) {
product.images.forEach((imagePath) => {
if (!this.images.find((img) => img.path === imagePath)) {
this.images.push({
path: imagePath,
name: this.extractFileName(imagePath),
productId: product.id,
productName: product.name,
size: "Unknown", // Would be fetched from server
uploadDate: "Unknown", // Would be fetched from server
});
}
});
}
});
}
extractFileName(path) {
return path.split("/").pop();
}
renderImages() {
const container = document.getElementById("image-manager");
if (this.images.length === 0) {
container.innerHTML = `
<div class="no-images">
<h3>No images found</h3>
<p>Upload images to get started!</p>
<button class="btn btn-primary" onclick="imageManager.showUploadModal()">Upload Images</button>
</div>
`;
return;
}
container.innerHTML = "";
this.images.forEach((image) => {
const imageItem = this.createImageItem(image);
container.appendChild(imageItem);
});
}
createImageItem(image) {
const item = document.createElement("div");
item.className = "image-item";
item.innerHTML = `
<div class="image-preview">
<img src="${image.path}" alt="${image.name}" onerror="this.src='data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"150\" viewBox=\"0 0 200 150\"><rect width=\"200\" height=\"150\" fill=\"%23f0f0f0\"/><text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" fill=\"%23999\">Image Not Found</text></svg>'">
</div>
<div class="image-item-info">
<div class="image-item-name" title="${image.name}">${image.name}</div>
<div class="image-meta">
<span class="image-product">Product: ${image.productName}</span>
<span class="image-size">Size: ${image.size}</span>
</div>
</div>
<div class="image-item-actions">
<button class="btn btn-sm btn-primary" onclick="imageManager.viewImage('${image.path}')">
👁️ View
</button>
<button class="btn btn-sm btn-secondary" onclick="imageManager.renameImage('${image.path}')">
✏️ Rename
</button>
<button class="btn btn-sm btn-danger" onclick="imageManager.deleteImage('${image.path}')">
🗑️ Delete
</button>
</div>
`;
return item;
}
showUploadModal() {
// Create upload modal if it doesn't exist
let uploadModal = document.getElementById("upload-modal");
if (!uploadModal) {
uploadModal = this.createUploadModal();
document.body.appendChild(uploadModal);
}
uploadModal.style.display = "block";
}
createUploadModal() {
const modal = document.createElement("div");
modal.id = "upload-modal";
modal.className = "modal";
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h3>Upload Images</h3>
<span class="close" onclick="this.closest('.modal').style.display='none'">&times;</span>
</div>
<div class="modal-body">
<div class="upload-area" id="upload-area">
<div class="upload-content">
<div class="upload-icon">📤</div>
<h4>Drag & Drop images here</h4>
<p>or click to select files</p>
<input type="file" id="file-input" multiple accept="image/*" style="display: none;">
</div>
</div>
<div class="upload-preview" id="upload-preview"></div>
<div class="upload-actions">
<button class="btn btn-secondary" onclick="this.closest('.modal').style.display='none'">Cancel</button>
<button class="btn btn-primary" onclick="imageManager.processUpload()">Upload Images</button>
</div>
</div>
</div>
`;
// Setup drag and drop
const uploadArea = modal.querySelector("#upload-area");
const fileInput = modal.querySelector("#file-input");
uploadArea.addEventListener("click", () => fileInput.click());
uploadArea.addEventListener("dragover", (e) => {
e.preventDefault();
uploadArea.classList.add("drag-over");
});
uploadArea.addEventListener("dragleave", () => {
uploadArea.classList.remove("drag-over");
});
uploadArea.addEventListener("drop", (e) => {
e.preventDefault();
uploadArea.classList.remove("drag-over");
this.handleFileSelect(e.dataTransfer.files);
});
fileInput.addEventListener("change", (e) => {
this.handleFileSelect(e.target.files);
});
return modal;
}
handleFileSelect(files) {
const preview = document.getElementById("upload-preview");
preview.innerHTML = "";
Array.from(files).forEach((file) => {
if (file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = (e) => {
const imageDiv = document.createElement("div");
imageDiv.className = "upload-preview-item";
imageDiv.innerHTML = `
<img src="${e.target.result}" alt="${file.name}">
<div class="upload-preview-info">
<span class="upload-filename">${file.name}</span>
<span class="upload-filesize">${this.formatFileSize(
file.size
)}</span>
</div>
<button class="remove-upload-btn" onclick="this.parentElement.remove()">×</button>
`;
preview.appendChild(imageDiv);
};
reader.readAsDataURL(file);
}
});
}
formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
async processUpload() {
const fileInput = document.getElementById("file-input");
const files = fileInput.files;
if (files.length === 0) {
this.dashboard.showMessage("Please select files to upload", "error");
return;
}
try {
this.dashboard.showMessage("Uploading images...", "info");
const uploadedImages = await this.uploadImages(files);
// Add to images list
uploadedImages.forEach((imagePath) => {
const fileName = this.extractFileName(imagePath);
this.images.push({
path: imagePath,
name: fileName,
productId: null,
productName: "Unassigned",
size: "Unknown",
uploadDate: new Date().toISOString(),
});
});
this.renderImages();
document.getElementById("upload-modal").style.display = "none";
this.dashboard.showMessage(
`Successfully uploaded ${uploadedImages.length} image(s)!`,
"success"
);
} catch (error) {
this.dashboard.showMessage(
"Error uploading images: " + error.message,
"error"
);
}
}
async uploadImages(files) {
const uploadedImages = [];
for (const file of files) {
if (file.type.startsWith("image/")) {
// In a real implementation, you'd upload to a server
const filename = `uploaded_${Date.now()}_${file.name}`;
const imagePath = `assets/images/products/${filename}`;
uploadedImages.push(imagePath);
console.log(`Would upload: ${file.name} to ${imagePath}`);
}
}
return uploadedImages;
}
viewImage(imagePath) {
// Create image viewer modal
const viewer = document.createElement("div");
viewer.className = "modal image-viewer";
viewer.innerHTML = `
<div class="modal-content image-viewer-content">
<div class="modal-header">
<h3>Image Viewer</h3>
<span class="close" onclick="this.closest('.modal').remove()">&times;</span>
</div>
<div class="modal-body">
<img src="${imagePath}" alt="Full size image" style="max-width: 100%; max-height: 70vh; object-fit: contain;">
</div>
</div>
`;
document.body.appendChild(viewer);
viewer.style.display = "block";
}
renameImage(imagePath) {
const currentName = this.extractFileName(imagePath);
const newName = prompt("Enter new name for the image:", currentName);
if (newName && newName !== currentName) {
// In a real implementation, you'd rename the file on the server
console.log(`Would rename ${imagePath} to ${newName}`);
this.dashboard.showMessage(
"Image rename functionality would be implemented here",
"info"
);
}
}
async deleteImage(imagePath) {
if (
confirm(
"Are you sure you want to delete this image? This action cannot be undone."
)
) {
try {
// Check if image is used by any products
const productsUsingImage =
this.dashboard.productManager.products.filter(
(product) => product.images && product.images.includes(imagePath)
);
if (productsUsingImage.length > 0) {
this.dashboard.showMessage(
`Cannot delete image because it's used by ${productsUsingImage.length} product(s). Please remove it from the products first.`,
"error"
);
return;
}
// Remove from images list
this.images = this.images.filter((img) => img.path !== imagePath);
// In a real implementation, you'd delete from server
console.log(`Would delete image: ${imagePath}`);
this.renderImages();
this.dashboard.showMessage("Image deleted successfully!", "success");
} catch (error) {
this.dashboard.showMessage(
"Error deleting image: " + error.message,
"error"
);
}
}
}
}
// Make ImageManager available globally
window.ImageManager = ImageManager;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,245 @@
class StorageManager {
constructor() {
this.dbName = "KHYAdminStorage";
this.dbVersion = 1;
this.db = null;
this.initDB();
}
async initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
console.error("Failed to open IndexedDB:", request.error);
reject(request.error);
};
request.onsuccess = () => {
this.db = request.result;
console.log("✅ IndexedDB initialized successfully");
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create stores for different types of data
if (!db.objectStoreNames.contains("temporaryChanges")) {
db.createObjectStore("temporaryChanges", { keyPath: "id" });
}
if (!db.objectStoreNames.contains("imageFiles")) {
db.createObjectStore("imageFiles", { keyPath: "id" });
}
console.log("✅ IndexedDB stores created");
};
});
}
async saveTemporaryChanges(changes) {
if (!this.db) {
await this.initDB();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(
["temporaryChanges"],
"readwrite"
);
const store = transaction.objectStore("temporaryChanges");
const changeData = {
id: "current",
data: changes,
timestamp: Date.now(),
};
const request = store.put(changeData);
request.onsuccess = () => {
console.log("💾 Temporary changes saved to IndexedDB");
resolve();
};
request.onerror = () => {
console.error("Failed to save temporary changes:", request.error);
reject(request.error);
};
});
}
async getTemporaryChanges() {
if (!this.db) {
await this.initDB();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(["temporaryChanges"], "readonly");
const store = transaction.objectStore("temporaryChanges");
const request = store.get("current");
request.onsuccess = () => {
const result = request.result;
if (result && result.data) {
console.log("📦 Retrieved temporary changes from IndexedDB");
resolve(result.data);
} else {
console.log("📦 No temporary changes found in IndexedDB");
resolve({});
}
};
request.onerror = () => {
console.error("Failed to get temporary changes:", request.error);
reject(request.error);
};
});
}
async saveImageFile(imageId, file) {
if (!this.db) {
await this.initDB();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(["imageFiles"], "readwrite");
const store = transaction.objectStore("imageFiles");
const imageData = {
id: imageId,
file: file,
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
timestamp: Date.now(),
};
const request = store.put(imageData);
request.onsuccess = () => {
console.log(`📁 Image file saved to IndexedDB: ${file.name}`);
resolve();
};
request.onerror = () => {
console.error("Failed to save image file:", request.error);
reject(request.error);
};
});
}
async getImageFile(imageId) {
if (!this.db) {
await this.initDB();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(["imageFiles"], "readonly");
const store = transaction.objectStore("imageFiles");
const request = store.get(imageId);
request.onsuccess = () => {
const result = request.result;
if (result) {
console.log(`📁 Retrieved image file from IndexedDB: ${result.name}`);
resolve(result);
} else {
resolve(null);
}
};
request.onerror = () => {
console.error("Failed to get image file:", request.error);
reject(request.error);
};
});
}
async deleteImageFile(imageId) {
if (!this.db) {
await this.initDB();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(["imageFiles"], "readwrite");
const store = transaction.objectStore("imageFiles");
const request = store.delete(imageId);
request.onsuccess = () => {
console.log(`🗑️ Image file deleted from IndexedDB: ${imageId}`);
resolve();
};
request.onerror = () => {
console.error("Failed to delete image file:", request.error);
reject(request.error);
};
});
}
async clearAllTemporaryData() {
if (!this.db) {
await this.initDB();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(
["temporaryChanges", "imageFiles"],
"readwrite"
);
const changeStore = transaction.objectStore("temporaryChanges");
const imageStore = transaction.objectStore("imageFiles");
const changeRequest = changeStore.clear();
const imageRequest = imageStore.clear();
let completed = 0;
const total = 2;
const checkComplete = () => {
completed++;
if (completed === total) {
console.log("🧹 All temporary data cleared from IndexedDB");
resolve();
}
};
changeRequest.onsuccess = checkComplete;
imageRequest.onsuccess = checkComplete;
changeRequest.onerror = () => reject(changeRequest.error);
imageRequest.onerror = () => reject(imageRequest.error);
});
}
async getAllImageFiles() {
if (!this.db) {
await this.initDB();
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(["imageFiles"], "readonly");
const store = transaction.objectStore("imageFiles");
const request = store.getAll();
request.onsuccess = () => {
console.log(
`📁 Retrieved ${request.result.length} image files from IndexedDB`
);
resolve(request.result);
};
request.onerror = () => {
console.error("Failed to get all image files:", request.error);
reject(request.error);
};
});
}
}
// Create global instance
window.storageManager = new StorageManager();

6
admin-dashboard/start.sh Executable file
View file

@ -0,0 +1,6 @@
#!/bin/bash
echo "🚀 Starting KHY Admin Dashboard..."
echo "📱 Dashboard will be available at: http://localhost:3000"
echo "🔧 To stop the server, press Ctrl+C"
echo ""
python3 -m http.server 3000