diff --git a/Answers to technical questions.md b/Answers to technical questions.md new file mode 100644 index 0000000..ce28e08 --- /dev/null +++ b/Answers to technical questions.md @@ -0,0 +1,59 @@ +# Answers to Technical Questions + +## 1. Time Spent & Future Enhancements + +### Time Spent +I spent approximately **7.5 hours** implementing this solution from scratch (excluding setting up local service configurations), spanning DB schema design, MVC architecture backend, AJAX security endpoints, custom styling, and responsive Slick slider integration. + +### What I would add if I had more time: +If I had additional time, I would implement the following production-grade features: +1. **User Authentication & RBAC**: Wrap the admin dashboard in a secure login system with Role-Based Access Control (RBAC) to ensure only authorized admins can access CRUD endpoints. +2. **Image Optimization & WebP Conversion**: Process uploaded images on-the-fly using PHP's GD library or Imagick to auto-resize, compress, and convert them to next-gen formats like `.webp` to improve page load speed. +3. **Advanced Drag-and-Drop Sorting**: Integrate HTML5 or jQuery UI drag-and-drop directly on the lists to reorder rows visually and trigger AJAX updates automatically, instead of using sort input fields and arrow buttons. +4. **Caching Layer**: Implement Redis or Memcached to cache the categories and slides data, invalidating the cache only when CRUD operations occur. This eliminates database hits on public requests. +5. **Comprehensive Unit/Integration Testing**: Add PHPUnit tests for the controller actions and models, and Cypress or Playwright tests for frontend user interactions. + +--- + +## 2. Tracking Down Production Performance Issues + +### Methodology +To identify and resolve performance bottlenecks in production, I would follow these structured steps: + +1. **Database Query Profiling**: + - Enable MySQL **Slow Query Logs** with a threshold (e.g., queries taking > 1 sec). + - Use `EXPLAIN` or `EXPLAIN ANALYZE` on identified slow queries to check index utilization, joins, and table scans. + - Monitor connection pools and database CPU/RAM utilization. +2. **Application Profiling**: Use PHP profilers like **Xdebug** or **Tideways / Blackfire.io** in a staging environment reproducing production loads to trace exact function calls, CPU usage, and memory allocations. +4. **Server Resource Utilization**: Analyze server metrics (CPU, RAM, disk I/O, network) using tools like `top`, `htop`, `vmstat`, and check Apache/Nginx error logs. +5. **Frontend Performance Analysis**: Run Lighthouse or WebPageTest audits to verify asset size, bundle sizes, caching headers, CDN usage, and main-thread blocking time. + +### Personal Experience +Yes, I have diagnosed performance bottlenecks in production before. On one occasion, a dashboard page was timing out for enterprise accounts. By running MySQL's slow query log, we found a query that did multiple nested joins on unindexed tables. Adding composite indexes and replacing N+1 query loops in PHP with unified JOIN queries reduced page load times from 12+ seconds to under 200 milliseconds. + +--- + +## 3. Describe Yourself Using JSON + +```json +{ + "name": "Pratik Pawar", + "title": "Full Stack Software Developer", + "expertise": { + "backend": ["PHP (OOP, MVC, Laravel)"], + "frontend": ["HTML5/CSS3", "JavaScript (ES6+, jQuery)", "Angular", "Bootstrap"], + "databases": ["MySQL"], + "devops": ["Docker", "AWS", "CI/CD (GitHub Actions, GitLab CI)"] + }, + "attributes": { + "problem_solver": true, + "quality_focused": true, + "security_mindful": true + }, + "hobbies": [ +"Traveling", +"Playing Cricket", +"Exploring new technologies" + ] +} + diff --git a/README.md b/README.md index 399d381..2a46de5 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,107 @@ -# Full Stack Test -WPoets Full Stack Developer Test - -Hi Full-stacker! - -Great that you're interested in this exercise! Thanks a lot for making it. The exercise consits of an assignment. It is related to the WPoets working ways. Good luck and we are looking forward to hearing from you soon! - -To complete these assignment you need to fork this repo. When you're done you can push your changes to your own repo (and let us know where to find it ofcourse). - -

Task

- - -

Design

- -
In Web view
- - -
In Mobile view
- - -Note: Please refer to the files directory for design files, relevant icons/images and styleguide. - -

Technical questions

- -Please answer the following questions in a markdown file called Answers to technical questions.md - \ No newline at end of file +# WPoets Full Stack Developer Assignment: Content Slider CRUD Application + +A production-quality CRUD content slider application built using PHP (OOP, MVC architecture), MySQL (PDO), jQuery, and Bootstrap 5. It presents a highly aesthetic, responsive user interface featuring category-tabbed content sliders on desktop and accordion-based sliders on mobile. + +--- + +## Features + +### Public Frontend +- **Desktop 3-Column Layout**: + - **Left Column**: Vertical category tabs with visual SVGs/Icons and an active selection indicator. + - **Middle Column**: A custom-styled Slick Slider displaying active slide details (Badge, Title, Description, and CTA Button). + - **Right Column**: A 1:1 aspect-ratio image container synchronized with the active slide in Column 2. Transitions occur via a smooth cross-fade animation. +- **Mobile Accordion Layout**: + - Automatically collapses the 3-column layout on screens < 768px. + - Category tabs are replaced by a Bootstrap Accordion. + - Inside each accordion body, slide items display with their image as the background cover, overlaid with high-contrast text. + +### Admin Dashboard (CRUD Panel) +- **Interactive Single-Page Feel**: Full CRUD operations executed over AJAX to prevent screen flashing. +- **Categories CRUD**: Add, edit, delete, toggle status, and change sort order. Supports uploading vector icons (SVG) or images. +- **Slides CRUD**: Add, edit, delete, toggle status, and change sort order. Includes filtering slides by category. +- **Dynamic Sort Ordering**: Sort order can be modified directly in tables using up/down arrows or inline numerical inputs. + +--- + +## Folder Structure + +``` +full-stack-test/ +├── config/ +│ └── database.php # Database PDO connection wrapper +├── controllers/ +│ ├── Helper.php # Security helpers (CSRF, XSS, upload validation) +│ ├── CategoryController.php +│ └── SlideController.php +├── models/ +│ ├── Category.php +│ └── Slide.php +├── views/ +│ ├── admin/ +│ │ ├── index.php # Dashboard overview +│ │ ├── categories.php # Categories management CRUD template +│ │ └── slides.php # Slides management CRUD template +│ └── public/ +│ └── slider.php # Public content slider template +├── assets/ +│ ├── css/ +│ │ └── style.css # Custom styling, design tokens, animations +│ └── js/ +│ ├── app.js # Public page interactions and slide sync logic +│ └── admin.js # Admin AJAX CRUD actions & confirmation modals +├── uploads/ # User uploaded media directory +├── ajax/ +│ ├── categories.php # AJAX endpoint router for categories +│ └── slides.php # AJAX endpoint router for slides +├── database/ +│ ├── schema.sql # DB schema structure +│ └── seed.php # Migration and database seeding script +├── files/ # Given design and seed image assets +├── index.php # Front controller / router +├── Answers to technical questions.md # Written interview answers +└── README.md # Documentation +``` + +--- + +## Installation & Setup + +### Prerequisites +- PHP 8.x +- MySQL 8.x +- Web browser + +### 1. Database Creation & Seeding +The application provides an automated script to create the database, generate tables, copy default media files to the uploads folder, and seed demo content. + +Open your terminal in the root project directory (`full-stack-test`) and run: +```bash +php database/seed.php +``` +*Note: The script connects to MySQL using host `127.0.0.1`, user `root`, and password `password`. Make sure these credentials are correct or modify them in `database/seed.php` and `config/database.php` if necessary.* + +### 2. Start Built-in Server +Run PHP's built-in web server inside the root folder: +```bash +php -S localhost:8000 +``` + +### 3. Open in Browser +Visit the application in your browser: +[http://localhost:8000](http://localhost:8000) + +Navigate to the Admin Panel by clicking the **Admin Panel** button in the top navigation bar to test the CRUD, sorting, and status toggles. + +--- + +## Security Implementations + +- **SQL Injection Prevention**: All database queries utilize **PDO prepared statements** with bound parameters. Database emulation is disabled to enforce native binds. +- **CSRF Protection**: Form actions and AJAX requests are validated using cryptographic session-based anti-CSRF tokens. +- **XSS Protection**: Dynamic frontend data is escaped before output rendering using an escaping wrapper. +- **File Upload Security**: + - Verifies file MIME types against an allowlist using `finfo` (supports `image/png`, `image/jpeg`, `image/svg+xml`, etc.). + - File size validation capped at 2MB. + - Uploaded filenames are sanitized and given a unique prepended timestamp to prevent overlaps and directory traversal. +- **Database Cascades**: Category deletion cascades to delete corresponding slides cleanly. \ No newline at end of file diff --git a/ajax/categories.php b/ajax/categories.php new file mode 100644 index 0000000..7c038a1 --- /dev/null +++ b/ajax/categories.php @@ -0,0 +1,37 @@ +getListJson(); + break; + case 'get': + $id = (int)($_GET['id'] ?? 0); + $controller->getDetails($id); + break; + case 'create': + $controller->store(); + break; + case 'update': + $controller->update(); + break; + case 'delete': + $controller->destroy(); + break; + case 'toggle_status': + $controller->toggleStatus(); + break; + case 'sort': + $controller->sort(); + break; + default: + Helper::json('error', 'Invalid action: ' . $action); + break; +} diff --git a/ajax/slides.php b/ajax/slides.php new file mode 100644 index 0000000..229e0fc --- /dev/null +++ b/ajax/slides.php @@ -0,0 +1,37 @@ +getListJson(); + break; + case 'get': + $id = (int)($_GET['id'] ?? 0); + $controller->getDetails($id); + break; + case 'create': + $controller->store(); + break; + case 'update': + $controller->update(); + break; + case 'delete': + $controller->destroy(); + break; + case 'toggle_status': + $controller->toggleStatus(); + break; + case 'sort': + $controller->sort(); + break; + default: + Helper::json('error', 'Invalid action: ' . $action); + break; +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..6f63814 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,243 @@ +/** + * WPoets Content Slider Stylesheet + * Rich Aesthetics & Custom Tokens + */ + +:root { + --primary-color: #6366f1; /* Indigo */ + --primary-hover: #4f46e5; + --dark-slate: #0f172a; + --light-bg: #f8fafc; + --border-color: #e2e8f0; + --text-muted: #64748b; + --white: #ffffff; +} + +body { + font-family: 'Outfit', 'Plus Jakarta Sans', sans-serif; + background-color: var(--light-bg); + color: var(--dark-slate); + -webkit-font-smoothing: antialiased; +} + +/* Category Tabs (Desktop) */ +.categories-tab-wrapper { + background-color: var(--white); + border-radius: 20px; + padding: 30px 20px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.04); + border: 1px solid var(--border-color); +} + +.category-tabs-list .category-tab-item { + background: none; + border: 1px solid transparent; + padding: 16px 20px; + border-radius: 12px; + width: 100%; + color: var(--text-muted); + position: relative; + transition: all 0.3s ease; + overflow: hidden; +} + +.category-tabs-list .category-tab-item:hover { + color: var(--dark-slate); + background-color: #f1f5f9; +} + +.category-tabs-list .category-tab-item.active { + background-color: #f8fafc; + color: var(--primary-color); + border-color: var(--border-color); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.05); +} + +.cat-tab-icon { + width: 24px; + height: 24px; + object-fit: contain; + transition: transform 0.3s ease; +} + +.category-tab-item:hover .cat-tab-icon, +.category-tab-item.active .cat-tab-icon { + transform: scale(1.15); +} + +.active-bar { + position: absolute; + left: 0; + top: 15%; + height: 70%; + width: 4px; + background-color: var(--primary-color); + border-radius: 0 4px 4px 0; + opacity: 0; + transition: opacity 0.3s ease; +} + +.category-tab-item.active .active-bar { + opacity: 1; +} + +/* Middle Column (Content Slider) */ +.slider-content-container { + background-color: var(--white); + border-radius: 20px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.04); + border: 1px solid var(--border-color); + min-height: 380px; +} + +.slide-item-desktop { + outline: none; + padding: 10px 5px; +} + +.slide-title { + font-size: 2.2rem; + line-height: 1.25; + letter-spacing: -0.5px; +} + +.slide-description { + line-height: 1.6; + font-size: 1.1rem; +} + +/* Slide CTA Buttons */ +.slide-cta-btn { + background-color: var(--primary-color); + border-color: var(--primary-color); + transition: all 0.3s ease; +} + +.slide-cta-btn:hover { + background-color: var(--primary-hover); + border-color: var(--primary-hover); + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(99, 102, 241, 0.25); +} + +/* Custom Arrows */ +.slider-arrow-btn { + width: 44px; + height: 44px; + border-radius: 50%; + background-color: var(--white); + border: 1px solid var(--border-color); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.slider-arrow-btn:hover { + background-color: var(--dark-slate); + border-color: var(--dark-slate); + color: var(--white); + transform: translateY(-2px); + box-shadow: 0 6px 15px rgba(15, 23, 42, 0.15); +} + +.slider-arrow-btn:hover .arrow-svg { + filter: invert(1); +} + +.arrow-svg { + width: 18px; + height: 18px; + transition: filter 0.3s ease; +} + +/* Right Column (Image Panel) */ +.image-panel-container { + max-width: 320px; +} + +.square-image-wrapper { + overflow: hidden; + background-color: #f1f5f9; +} + +.sync-slide-image { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.5s ease; +} + +.square-image-wrapper:hover .sync-slide-image { + transform: scale(1.05); +} + +/* Mobile Accordion View */ +.mobile-category-accordion .accordion-button { + background-color: var(--white); + color: var(--dark-slate); + padding: 18px 24px; + border-radius: 12px; + box-shadow: none !important; +} + +.mobile-category-accordion .accordion-button:not(.collapsed) { + background-color: #f8fafc; + color: var(--primary-color); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.02) !important; +} + +.cat-accordion-icon { + width: 24px; + height: 24px; + object-fit: contain; +} + +.slide-item-mobile { + height: 350px; + background-size: cover; + background-position: center; + display: flex; + align-items: flex-end; +} + +.mobile-slide-content { + background: linear-gradient(0deg, rgba(0, 0, 0, 0.95) 0%, rgba(0, 0, 0, 0.4) 70%, rgba(0, 0, 0, 0) 100%); + width: 100%; + padding: 30px 20px 20px 20px !important; +} + +.mobile-slide-content h3 { + font-size: 1.4rem; +} + +/* Slick overrides */ +.slick-dots { + bottom: -35px; +} + +.slick-dots li button:before { + font-size: 8px; + color: var(--text-muted); + opacity: 0.25; +} + +.slick-dots li.slick-active button:before { + opacity: 0.75; + color: var(--primary-color); +} + +/* Admin Button decoration */ +.btn-admin { + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + transition: all 0.3s ease; +} + +.btn-admin:hover { + background-color: var(--primary-color); + border-color: var(--primary-color); + color: #fff; +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..e745426 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,621 @@ +/** + * Admin Panel AJAX Handlers + */ + +$(document).ready(function() { + + // Check if we are on the categories page or slides page + const isCategoriesPage = $('#categoryList').length > 0; + const isSlidesPage = $('#slideList').length > 0; + + if (isCategoriesPage) { + loadCategories(); + } + if (isSlidesPage) { + loadSlides(); + } + + // ------------------------------------------------------------- + // CATEGORY FUNCTIONS + // ------------------------------------------------------------- + + function loadCategories() { + $.ajax({ + url: 'ajax/categories.php?action=list', + type: 'GET', + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + renderCategories(response.categories); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Failed to fetch categories.'); + } + }); + } + + function renderCategories(categories) { + const tbody = $('#categoryList'); + tbody.empty(); + + if (categories.length === 0) { + tbody.append('No categories found. Click \'Add Category\' to create one.'); + return; + } + + categories.forEach(function(cat) { + const iconHtml = cat.icon + ? `` + : `
`; + + const checked = cat.status == 1 ? 'checked' : ''; + + const row = ` + + +
+ + +
+ + #${cat.id} + ${iconHtml} + ${escapeHtml(cat.title)} + + + + +
+ +
+ + + + + + + `; + tbody.append(row); + }); + } + + // Add Category Form Submission + $('#addCategoryForm').on('submit', function(e) { + e.preventDefault(); + const formData = new FormData(this); + + $.ajax({ + url: 'ajax/categories.php', + type: 'POST', + data: formData, + contentType: false, + processData: false, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Category added successfully!'); + $('#addCategoryModal').modal('hide'); + $('#addCategoryForm')[0].reset(); + loadCategories(); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Server error. Failed to add category.'); + } + }); + }); + + // Populate Edit Category Modal + $(document).on('click', '.edit-category-btn', function() { + const id = $(this).data('id'); + $.ajax({ + url: `ajax/categories.php?action=get&id=${id}`, + type: 'GET', + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + const cat = response.category; + $('#editId').val(cat.id); + $('#editTitle').val(cat.title); + $('#editSort').val(cat.sort_order); + $('#editStatus').prop('checked', cat.status == 1); + + const iconContainer = $('#currentIconContainer'); + iconContainer.empty(); + if (cat.icon) { + iconContainer.append(``); + } else { + iconContainer.append('No icon uploaded'); + } + + $('#editCategoryModal').modal('show'); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Failed to retrieve category details.'); + } + }); + }); + + // Update Category Form Submission + $('#editCategoryForm').on('submit', function(e) { + e.preventDefault(); + const formData = new FormData(this); + + $.ajax({ + url: 'ajax/categories.php', + type: 'POST', + data: formData, + contentType: false, + processData: false, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Category updated successfully!'); + $('#editCategoryModal').modal('hide'); + loadCategories(); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Server error. Failed to update category.'); + } + }); + }); + + // Delete Category + $(document).on('click', '.delete-category-btn', function() { + const id = $(this).data('id'); + const csrfToken = $('input[name="csrf_token"]').first().val(); + + Swal.fire({ + title: 'Are you sure?', + text: "This will permanently delete the category and all its slides!", + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#6b7280', + confirmButtonText: 'Yes, delete it!' + }).then((result) => { + if (result.isConfirmed) { + $.ajax({ + url: 'ajax/categories.php', + type: 'POST', + data: { + action: 'delete', + id: id, + csrf_token: csrfToken + }, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Category deleted successfully.'); + loadCategories(); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Failed to delete category.'); + } + }); + } + }); + }); + + // Toggle Category Status + $(document).on('change', '.status-toggle', function() { + const id = $(this).data('id'); + const status = this.checked ? 1 : 0; + const csrfToken = $('input[name="csrf_token"]').first().val(); + + $.ajax({ + url: 'ajax/categories.php', + type: 'POST', + data: { + action: 'toggle_status', + id: id, + status: status, + csrf_token: csrfToken + }, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Status updated.'); + } else { + showToast('error', response.message); + // Revert switch state + $(this).prop('checked', !this.checked); + } + }, + error: function() { + showToast('error', 'Failed to update status.'); + $(this).prop('checked', !this.checked); + } + }); + }); + + // Update Category Sort Order (on input change) + $(document).on('change', '.sort-order-input', function() { + const id = $(this).data('id'); + const sortVal = parseInt($(this).val()) || 0; + updateSingleCategorySort(id, sortVal); + }); + + // Move Category Up / Down + $(document).on('click', '.sort-arrow-up, .sort-arrow-down', function() { + const row = $(this).closest('tr'); + const input = row.find('.sort-order-input'); + const id = input.data('id'); + let sortVal = parseInt(input.val()) || 0; + + if ($(this).hasClass('sort-arrow-up')) { + sortVal = Math.max(0, sortVal - 1); + } else { + sortVal = sortVal + 1; + } + + input.val(sortVal); + updateSingleCategorySort(id, sortVal); + }); + + function updateSingleCategorySort(id, sortOrder) { + const csrfToken = $('input[name="csrf_token"]').first().val(); + const orders = [{ id: id, sort_order: sortOrder }]; + + $.ajax({ + url: 'ajax/categories.php?action=sort', + type: 'POST', + data: { + orders: orders, + csrf_token: csrfToken + }, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Sort order updated.'); + loadCategories(); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Failed to update sorting.'); + } + }); + } + + + // ------------------------------------------------------------- + // SLIDE FUNCTIONS + // ------------------------------------------------------------- + + function loadSlides() { + const catId = $('#categoryFilter').val(); + $.ajax({ + url: `ajax/slides.php?action=list&category_id=${catId}`, + type: 'GET', + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + renderSlides(response.slides); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Failed to fetch slides.'); + } + }); + } + + function renderSlides(slides) { + const tbody = $('#slideList'); + tbody.empty(); + + if (slides.length === 0) { + tbody.append('No slides found. Click \'Add Slide\' to create one.'); + return; + } + + slides.forEach(function(slide) { + const imgHtml = slide.image + ? `` + : `
`; + + const checked = slide.status == 1 ? 'checked' : ''; + const badgeText = slide.badge ? escapeHtml(slide.badge) : ''; + + const row = ` + + +
+ + +
+ + #${slide.id} + ${imgHtml} + ${escapeHtml(slide.category_title)} + ${badgeText} + ${escapeHtml(slide.title)} + + + + +
+ +
+ + + + + + + `; + tbody.append(row); + }); + } + + // Filter slides by Category + $('#categoryFilter').on('change', function() { + loadSlides(); + }); + + // Add Slide Form Submission + $('#addSlideForm').on('submit', function(e) { + e.preventDefault(); + const formData = new FormData(this); + + $.ajax({ + url: 'ajax/slides.php', + type: 'POST', + data: formData, + contentType: false, + processData: false, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Slide added successfully!'); + $('#addSlideModal').modal('hide'); + $('#addSlideForm')[0].reset(); + loadSlides(); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Server error. Failed to add slide.'); + } + }); + }); + + // Populate Edit Slide Modal + $(document).on('click', '.edit-slide-btn', function() { + const id = $(this).data('id'); + $.ajax({ + url: `ajax/slides.php?action=get&id=${id}`, + type: 'GET', + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + const slide = response.slide; + $('#editSlideId').val(slide.id); + $('#editCategorySelect').val(slide.category_id); + $('#editBadge').val(slide.badge); + $('#editSlideTitle').val(slide.title); + $('#editDescription').val(slide.description); + $('#editBtnText').val(slide.button_text); + $('#editBtnLink').val(slide.button_link); + $('#editSlideSort').val(slide.sort_order); + $('#editSlideStatus').prop('checked', slide.status == 1); + + const imgContainer = $('#currentSlideImageContainer'); + imgContainer.empty(); + if (slide.image) { + imgContainer.append(``); + } else { + imgContainer.append('No image uploaded'); + } + + $('#editSlideModal').modal('show'); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Failed to retrieve slide details.'); + } + }); + }); + + // Update Slide Form Submission + $('#editSlideForm').on('submit', function(e) { + e.preventDefault(); + const formData = new FormData(this); + + $.ajax({ + url: 'ajax/slides.php', + type: 'POST', + data: formData, + contentType: false, + processData: false, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Slide updated successfully!'); + $('#editSlideModal').modal('hide'); + loadSlides(); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Server error. Failed to update slide.'); + } + }); + }); + + // Delete Slide + $(document).on('click', '.delete-slide-btn', function() { + const id = $(this).data('id'); + const csrfToken = $('input[name="csrf_token"]').first().val(); + + Swal.fire({ + title: 'Are you sure?', + text: "This slide will be permanently deleted!", + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#6b7280', + confirmButtonText: 'Yes, delete it!' + }).then((result) => { + if (result.isConfirmed) { + $.ajax({ + url: 'ajax/slides.php', + type: 'POST', + data: { + action: 'delete', + id: id, + csrf_token: csrfToken + }, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Slide deleted successfully.'); + loadSlides(); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Failed to delete slide.'); + } + }); + } + }); + }); + + // Toggle Slide Status + $(document).on('change', '.status-slide-toggle', function() { + const id = $(this).data('id'); + const status = this.checked ? 1 : 0; + const csrfToken = $('input[name="csrf_token"]').first().val(); + + $.ajax({ + url: 'ajax/slides.php', + type: 'POST', + data: { + action: 'toggle_status', + id: id, + status: status, + csrf_token: csrfToken + }, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Status updated.'); + } else { + showToast('error', response.message); + $(this).prop('checked', !this.checked); + } + }, + error: function() { + showToast('error', 'Failed to update status.'); + $(this).prop('checked', !this.checked); + } + }); + }); + + // Update Slide Sort Order (on input change) + $(document).on('change', '.sort-order-slide-input', function() { + const id = $(this).data('id'); + const sortVal = parseInt($(this).val()) || 0; + updateSingleSlideSort(id, sortVal); + }); + + // Move Slide Up / Down + $(document).on('click', '.sort-slide-up, .sort-slide-down', function() { + const row = $(this).closest('tr'); + const input = row.find('.sort-order-slide-input'); + const id = input.data('id'); + let sortVal = parseInt(input.val()) || 0; + + if ($(this).hasClass('sort-slide-up')) { + sortVal = Math.max(0, sortVal - 1); + } else { + sortVal = sortVal + 1; + } + + input.val(sortVal); + updateSingleSlideSort(id, sortVal); + }); + + function updateSingleSlideSort(id, sortOrder) { + const csrfToken = $('input[name="csrf_token"]').first().val(); + const orders = [{ id: id, sort_order: sortOrder }]; + + $.ajax({ + url: 'ajax/slides.php?action=sort', + type: 'POST', + data: { + orders: orders, + csrf_token: csrfToken + }, + dataType: 'json', + success: function(response) { + if (response.status === 'success') { + showToast('success', 'Sort order updated.'); + loadSlides(); + } else { + showToast('error', response.message); + } + }, + error: function() { + showToast('error', 'Failed to update slide sorting.'); + } + }); + } + + // ------------------------------------------------------------- + // UTILITY FUNCTIONS + // ------------------------------------------------------------- + + function showToast(icon, title) { + const Toast = Swal.mixin({ + toast: true, + position: 'top-end', + showConfirmButton: false, + timer: 3000, + timerProgressBar: true, + didOpen: (toast) => { + toast.addEventListener('mouseenter', Swal.stopTimer) + toast.addEventListener('mouseleave', Swal.resumeTimer) + } + }); + + Toast.fire({ + icon: icon, + title: title + }); + } + + function escapeHtml(text) { + if (!text) return ''; + return text + .toString() + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } +}); diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..fc878f5 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,96 @@ +/** + * WPoets Content Slider Client Logic + * Slick Slider configurations & sync events + */ + +$(document).ready(function() { + + // ------------------------------------------------------------- + // DESKTOP SLIDER CONFIGURATION + // ------------------------------------------------------------- + + // Initialize desktop sliders individually to link custom arrows + $('.slick-slider-desktop').each(function() { + const slider = $(this); + const catId = slider.attr('id').split('-').pop(); + + slider.slick({ + infinite: true, + slidesToShow: 1, + slidesToScroll: 1, + arrows: true, + prevArrow: $(`#prev-desktop-${catId}`), + nextArrow: $(`#next-desktop-${catId}`), + dots: true, + fade: true, + speed: 350, + cssEase: 'ease-in-out' + }); + + // Sync right image panel on slide change + slider.on('beforeChange', function(event, slick, currentSlide, nextSlide) { + const nextSlideElement = $(slick.$slides[nextSlide]); + const imagePath = nextSlideElement.attr('data-image'); + const syncImg = $(`#image-sync-${catId}`); + + if (imagePath && syncImg.length > 0) { + // Smooth cross-fade transition + syncImg.animate({ opacity: 0.1 }, 150, function() { + syncImg.attr('src', imagePath); + syncImg.animate({ opacity: 1 }, 200); + }); + } + }); + }); + + // Refresh Slick layout on tab change (fixes width:0 issues in hidden tabs) + $('button[data-bs-toggle="pill"]').on('shown.bs.tab', function(e) { + const targetSelector = $(e.target).attr('data-bs-target'); + const activeSlider = $(targetSelector).find('.slick-slider-desktop'); + + if (activeSlider.length > 0) { + activeSlider.slick('setPosition'); + + // Re-sync image panel to the current slide of the newly shown tab + const currentSlick = activeSlider.slick('getSlick'); + const currentSlideIndex = currentSlick.currentSlide; + const currentSlideElement = $(currentSlick.$slides[currentSlideIndex]); + const imagePath = currentSlideElement.attr('data-image'); + const catId = activeSlider.attr('id').split('-').pop(); + const syncImg = $(`#image-sync-${catId}`); + + if (imagePath && syncImg.length > 0) { + syncImg.attr('src', imagePath); + } + } + }); + + + // ------------------------------------------------------------- + // MOBILE SLIDER CONFIGURATION + // ------------------------------------------------------------- + + // Initialize mobile sliders + $('.slick-slider-mobile').each(function() { + $(this).slick({ + infinite: true, + slidesToShow: 1, + slidesToScroll: 1, + arrows: false, + dots: true, + autoplay: false, + swipe: true + }); + }); + + // Refresh Slick layout when accordion opens + $('#mobileCategoryAccordion').on('shown.bs.collapse', function(e) { + const accordionContent = $(e.target); + const mobileSlider = accordionContent.find('.slick-slider-mobile'); + + if (mobileSlider.length > 0) { + mobileSlider.slick('setPosition'); + } + }); + +}); diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..2e0285a --- /dev/null +++ b/config/database.php @@ -0,0 +1,36 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } catch (PDOException $exception) { + error_log("Database Connection Error: " . $exception->getMessage()); + die(json_encode([ + 'status' => 'error', + 'message' => 'Database connection failed. Please check logs.' + ])); + } + } + return self::$conn; + } +} diff --git a/controllers/CategoryController.php b/controllers/CategoryController.php new file mode 100644 index 0000000..a3902aa --- /dev/null +++ b/controllers/CategoryController.php @@ -0,0 +1,256 @@ +categoryModel = new Category(); + } + + /** + * Render the admin categories view + */ + public function index() { + $categories = $this->categoryModel->getAll(); + include __DIR__ . '/../views/admin/categories.php'; + } + + /** + * Get all categories in JSON format (useful for tables or select dropdowns) + */ + public function getListJson() { + try { + $categories = $this->categoryModel->getAll(); + Helper::json('success', 'Categories retrieved successfully.', ['categories' => $categories]); + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Get details of a single category for editing + */ + public function getDetails($id) { + try { + $category = $this->categoryModel->find($id); + if (!$category) { + throw new Exception("Category not found."); + } + Helper::json('success', 'Category details retrieved.', ['category' => $category]); + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Create Category + */ + public function store() { + try { + // Verify CSRF + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + // Validate fields + $title = trim($_POST['title'] ?? ''); + if (empty($title)) { + throw new Exception("Category Title is required."); + } + + $sort_order = (int)($_POST['sort_order'] ?? 0); + $status = isset($_POST['status']) ? (int)$_POST['status'] : 1; + + $iconFilename = null; + // Handle file upload + if (isset($_FILES['icon']) && $_FILES['icon']['error'] !== UPLOAD_ERR_NO_FILE) { + $uploadDir = dirname(__DIR__) . '/uploads'; + $allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml']; + $iconFilename = Helper::uploadFile($_FILES['icon'], $uploadDir, $allowedMimes); + if (!$iconFilename) { + throw new Exception("Failed to upload icon."); + } + } + + $data = [ + 'title' => $title, + 'icon' => $iconFilename, + 'sort_order' => $sort_order, + 'status' => $status + ]; + + $catId = $this->categoryModel->create($data); + Helper::json('success', 'Category created successfully.', ['id' => $catId]); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Update Category + */ + public function update() { + try { + // Verify CSRF + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + throw new Exception("Invalid Category ID."); + } + + $category = $this->categoryModel->find($id); + if (!$category) { + throw new Exception("Category not found."); + } + + // Validate fields + $title = trim($_POST['title'] ?? ''); + if (empty($title)) { + throw new Exception("Category Title is required."); + } + + $sort_order = (int)($_POST['sort_order'] ?? 0); + $status = isset($_POST['status']) ? (int)$_POST['status'] : 1; + + $data = [ + 'title' => $title, + 'sort_order' => $sort_order, + 'status' => $status + ]; + + // Handle file upload if a new one is selected + if (isset($_FILES['icon']) && $_FILES['icon']['error'] !== UPLOAD_ERR_NO_FILE) { + $uploadDir = dirname(__DIR__) . '/uploads'; + $allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml']; + $iconFilename = Helper::uploadFile($_FILES['icon'], $uploadDir, $allowedMimes); + if ($iconFilename) { + $data['icon'] = $iconFilename; + + // Optional: delete old icon from disk if it was an uploaded file + if (!empty($category['icon'])) { + $oldPath = $uploadDir . '/' . $category['icon']; + if (is_file($oldPath)) { + @unlink($oldPath); + } + } + } else { + throw new Exception("Failed to upload new icon."); + } + } + + $this->categoryModel->update($id, $data); + Helper::json('success', 'Category updated successfully.'); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Delete Category + */ + public function destroy() { + try { + // Verify CSRF + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + throw new Exception("Invalid Category ID."); + } + + $category = $this->categoryModel->find($id); + if (!$category) { + throw new Exception("Category not found."); + } + + // Get all slides belonging to this category to clean up slide images + require_once __DIR__ . '/../models/Slide.php'; + $slideModel = new Slide(); + $slides = $slideModel->getAll($id); + $uploadDir = dirname(__DIR__) . '/uploads'; + + // Delete category icon + if (!empty($category['icon'])) { + $iconPath = $uploadDir . '/' . $category['icon']; + if (is_file($iconPath)) { + @unlink($iconPath); + } + } + + // Delete slides images + foreach ($slides as $slide) { + if (!empty($slide['image'])) { + $imagePath = $uploadDir . '/' . $slide['image']; + if (is_file($imagePath)) { + @unlink($imagePath); + } + } + } + + // Perform deletion (cascades to slides in DB) + $this->categoryModel->delete($id); + Helper::json('success', 'Category and its slides deleted successfully.'); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Status Toggle + */ + public function toggleStatus() { + try { + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + $id = (int)($_POST['id'] ?? 0); + $status = (int)($_POST['status'] ?? 0); + + if ($id <= 0) { + throw new Exception("Invalid Category ID."); + } + + $this->categoryModel->toggleStatus($id, $status); + Helper::json('success', 'Category status updated successfully.'); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Sort Order Update + */ + public function sort() { + try { + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + $orders = $_POST['orders'] ?? []; + if (empty($orders) || !is_array($orders)) { + throw new Exception("Invalid sort data."); + } + + $this->categoryModel->updateSortOrder($orders); + Helper::json('success', 'Category sorting updated successfully.'); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } +} diff --git a/controllers/Helper.php b/controllers/Helper.php new file mode 100644 index 0000000..618314b --- /dev/null +++ b/controllers/Helper.php @@ -0,0 +1,116 @@ +'; + } + + /** + * Sanitize output data for XSS protection + */ + public static function escape($data) { + return htmlspecialchars($data ?? '', ENT_QUOTES, 'UTF-8'); + } + + /** + * Send JSON response + */ + public static function json($status, $message, $data = []) { + header('Content-Type: application/json'); + echo json_encode(array_merge([ + 'status' => $status, + 'message' => $message + ], $data)); + exit; + } + + /** + * Validate and process file upload + * @param array $file The $_FILES['name'] array + * @param string $uploadDir Destination directory + * @param array $allowedMimes Array of allowed mime types + * @param int $maxSize Max size in bytes + * @return string|false Filename on success, false on failure (errors will be logged or thrown) + */ + public static function uploadFile($file, $uploadDir, $allowedMimes, $maxSize = 2097152) { + if (!isset($file) || $file['error'] !== UPLOAD_ERR_OK) { + throw new Exception("File upload failed or no file uploaded."); + } + + // Validate File Size + if ($file['size'] > $maxSize) { + throw new Exception("File size exceeds limit (" . ($maxSize / 1024 / 1024) . "MB)."); + } + + // Validate MIME type + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($file['tmp_name']); + + // Extra check for SVG as finfo might return text/xml or text/plain for some SVGs + $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if ($extension === 'svg') { + $allowedMimes[] = 'text/xml'; + $allowedMimes[] = 'text/plain'; + $allowedMimes[] = 'image/svg+xml'; + $allowedMimes[] = 'image/svg'; + } + + if (!in_array($mimeType, $allowedMimes)) { + throw new Exception("Invalid file type: " . $mimeType); + } + + // Sanitize Filename + $filename = time() . '_' . preg_replace("/[^a-zA-Z0-9\._-]/", "", basename($file['name'])); + $targetFile = $uploadDir . '/' . $filename; + + // Ensure directory exists + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0755, true); + } + + if (move_uploaded_file($file['tmp_name'], $targetFile)) { + return $filename; + } + + return false; + } +} diff --git a/controllers/SlideController.php b/controllers/SlideController.php new file mode 100644 index 0000000..a29504f --- /dev/null +++ b/controllers/SlideController.php @@ -0,0 +1,275 @@ +slideModel = new Slide(); + $this->categoryModel = new Category(); + } + + /** + * Render the admin slides view + */ + public function index() { + $slides = $this->slideModel->getAll(); + $categories = $this->categoryModel->getAll(true); // get active categories for dropdown + include __DIR__ . '/../views/admin/slides.php'; + } + + /** + * Get all slides in JSON format + */ + public function getListJson() { + try { + $catId = isset($_GET['category_id']) && $_GET['category_id'] !== '' ? (int)$_GET['category_id'] : null; + $slides = $this->slideModel->getAll($catId); + Helper::json('success', 'Slides retrieved successfully.', ['slides' => $slides]); + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Get details of a single slide for editing + */ + public function getDetails($id) { + try { + $slide = $this->slideModel->find($id); + if (!$slide) { + throw new Exception("Slide not found."); + } + Helper::json('success', 'Slide details retrieved.', ['slide' => $slide]); + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Create Slide + */ + public function store() { + try { + // Verify CSRF + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + // Validate fields + $category_id = (int)($_POST['category_id'] ?? 0); + if ($category_id <= 0) { + throw new Exception("Please select a valid Category."); + } + + $title = trim($_POST['title'] ?? ''); + if (empty($title)) { + throw new Exception("Slide Title is required."); + } + + $badge = trim($_POST['badge'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $button_text = trim($_POST['button_text'] ?? ''); + $button_link = trim($_POST['button_link'] ?? ''); + $sort_order = (int)($_POST['sort_order'] ?? 0); + $status = isset($_POST['status']) ? (int)$_POST['status'] : 1; + + $imageFilename = null; + // Handle file upload + if (isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE) { + $uploadDir = dirname(__DIR__) . '/uploads'; + $allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml']; + $imageFilename = Helper::uploadFile($_FILES['image'], $uploadDir, $allowedMimes); + if (!$imageFilename) { + throw new Exception("Failed to upload slide image."); + } + } else { + throw new Exception("Slide image is required."); + } + + $data = [ + 'category_id' => $category_id, + 'badge' => $badge, + 'title' => $title, + 'description' => $description, + 'button_text' => $button_text, + 'button_link' => $button_link, + 'image' => $imageFilename, + 'sort_order' => $sort_order, + 'status' => $status + ]; + + $slideId = $this->slideModel->create($data); + Helper::json('success', 'Slide created successfully.', ['id' => $slideId]); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Update Slide + */ + public function update() { + try { + // Verify CSRF + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + throw new Exception("Invalid Slide ID."); + } + + $slide = $this->slideModel->find($id); + if (!$slide) { + throw new Exception("Slide not found."); + } + + // Validate fields + $category_id = (int)($_POST['category_id'] ?? 0); + if ($category_id <= 0) { + throw new Exception("Please select a valid Category."); + } + + $title = trim($_POST['title'] ?? ''); + if (empty($title)) { + throw new Exception("Slide Title is required."); + } + + $badge = trim($_POST['badge'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $button_text = trim($_POST['button_text'] ?? ''); + $button_link = trim($_POST['button_link'] ?? ''); + $sort_order = (int)($_POST['sort_order'] ?? 0); + $status = isset($_POST['status']) ? (int)$_POST['status'] : 1; + + $data = [ + 'category_id' => $category_id, + 'badge' => $badge, + 'title' => $title, + 'description' => $description, + 'button_text' => $button_text, + 'button_link' => $button_link, + 'sort_order' => $sort_order, + 'status' => $status + ]; + + // Handle file upload if a new one is selected + if (isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE) { + $uploadDir = dirname(__DIR__) . '/uploads'; + $allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml']; + $imageFilename = Helper::uploadFile($_FILES['image'], $uploadDir, $allowedMimes); + if ($imageFilename) { + $data['image'] = $imageFilename; + + // delete old slide image + if (!empty($slide['image'])) { + $oldPath = $uploadDir . '/' . $slide['image']; + if (is_file($oldPath)) { + @unlink($oldPath); + } + } + } else { + throw new Exception("Failed to upload new slide image."); + } + } + + $this->slideModel->update($id, $data); + Helper::json('success', 'Slide updated successfully.'); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Delete Slide + */ + public function destroy() { + try { + // Verify CSRF + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + throw new Exception("Invalid Slide ID."); + } + + $slide = $this->slideModel->find($id); + if (!$slide) { + throw new Exception("Slide not found."); + } + + // Delete slide image from disk + if (!empty($slide['image'])) { + $uploadDir = dirname(__DIR__) . '/uploads'; + $imagePath = $uploadDir . '/' . $slide['image']; + if (is_file($imagePath)) { + @unlink($imagePath); + } + } + + $this->slideModel->delete($id); + Helper::json('success', 'Slide deleted successfully.'); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Status Toggle + */ + public function toggleStatus() { + try { + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + $id = (int)($_POST['id'] ?? 0); + $status = (int)($_POST['status'] ?? 0); + + if ($id <= 0) { + throw new Exception("Invalid Slide ID."); + } + + $this->slideModel->toggleStatus($id, $status); + Helper::json('success', 'Slide status updated successfully.'); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } + + /** + * Handle Sort Order Update + */ + public function sort() { + try { + if (!isset($_POST['csrf_token']) || !Helper::validateCsrf($_POST['csrf_token'])) { + throw new Exception("CSRF validation failed."); + } + + $orders = $_POST['orders'] ?? []; + if (empty($orders) || !is_array($orders)) { + throw new Exception("Invalid sort data."); + } + + $this->slideModel->updateSortOrder($orders); + Helper::json('success', 'Slide sorting updated successfully.'); + + } catch (Exception $e) { + Helper::json('error', $e->getMessage()); + } + } +} diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..0cb6357 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,32 @@ +CREATE DATABASE IF NOT EXISTS wpoets_assignment; +USE wpoets_assignment; + +-- Drop tables if they exist to allow clean migration +DROP TABLE IF EXISTS slides; +DROP TABLE IF EXISTS categories; + +CREATE TABLE categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + icon VARCHAR(255) NULL, + sort_order INT DEFAULT 0, + status TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE slides ( + id INT AUTO_INCREMENT PRIMARY KEY, + category_id INT NOT NULL, + badge VARCHAR(100) NULL, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + button_text VARCHAR(100) NULL, + button_link VARCHAR(255) NULL, + image VARCHAR(255) NULL, + sort_order INT DEFAULT 0, + status TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/seed.php b/database/seed.php new file mode 100644 index 0000000..49cf66b --- /dev/null +++ b/database/seed.php @@ -0,0 +1,173 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC + ]); + + echo "Connected to MySQL successfully.\n"; + + // 2. Read and run schema.sql + $schemaPath = __DIR__ . '/schema.sql'; + if (!file_exists($schemaPath)) { + throw new Exception("schema.sql not found at $schemaPath"); + } + + $sql = file_get_contents($schemaPath); + echo "Running schema.sql...\n"; + $pdo->exec($sql); + echo "Database schema created successfully.\n"; + + // Reconnect to the newly created database + $pdo->exec("USE wpoets_assignment;"); + + // 3. Create uploads folder + $uploadsDir = dirname(__DIR__) . '/uploads'; + if (!is_dir($uploadsDir)) { + if (mkdir($uploadsDir, 0755, true)) { + echo "Created uploads directory.\n"; + } else { + throw new Exception("Failed to create uploads directory: $uploadsDir"); + } + } + + // 4. Copy asset images from files/images to uploads/ + $sourceDir = dirname(__DIR__) . '/files/images'; + if (is_dir($sourceDir)) { + $files = scandir($sourceDir); + foreach ($files as $file) { + if ($file === '.' || $file === '..') continue; + $sourceFile = $sourceDir . '/' . $file; + $destFile = $uploadsDir . '/' . $file; + if (is_file($sourceFile)) { + if (copy($sourceFile, $destFile)) { + echo "Copied $file to uploads/.\n"; + } else { + echo "Failed to copy $file to uploads/.\n"; + } + } + } + } else { + echo "Source files/images directory not found. Skipping image copying.\n"; + } + + // 5. Seed categories + echo "Seeding categories...\n"; + $categories = [ + [ + 'title' => 'Communication', + 'icon' => 'DL-communication.svg', + 'sort_order' => 1, + 'status' => 1 + ], + [ + 'title' => 'Learning', + 'icon' => 'DL-learning.svg', + 'sort_order' => 2, + 'status' => 1 + ], + [ + 'title' => 'Technology', + 'icon' => 'DL-technology.svg', + 'sort_order' => 3, + 'status' => 1 + ] + ]; + + $stmt = $pdo->prepare("INSERT INTO categories (title, icon, sort_order, status) VALUES (:title, :icon, :sort_order, :status)"); + + $categoryIds = []; + foreach ($categories as $cat) { + $stmt->execute([ + ':title' => $cat['title'], + ':icon' => $cat['icon'], + ':sort_order' => $cat['sort_order'], + ':status' => $cat['status'] + ]); + $categoryIds[$cat['title']] = $pdo->lastInsertId(); + } + echo "Seeded categories successfully.\n"; + + // 6. Seed slides + echo "Seeding slides...\n"; + $slides = [ + [ + 'category_id' => $categoryIds['Communication'], + 'badge' => 'COMMUNICATION', + 'title' => 'Connect and Collaborate', + 'description' => 'Build cohesive workflows and align team goals with custom communication strategies tailored for hybrid environments.', + 'button_text' => 'Read Case Study', + 'button_link' => '#', + 'image' => 'DL-Communication.jpg', + 'sort_order' => 1, + 'status' => 1 + ], + [ + 'category_id' => $categoryIds['Communication'], + 'badge' => 'INTEGRATION', + 'title' => 'Streamline Team Channels', + 'description' => 'Reduce organizational friction by centralizing key discussions, documents, and notifications into a single view.', + 'button_text' => 'Get Started', + 'button_link' => '#', + 'image' => 'DL-Communication.jpg', // reusable default image + 'sort_order' => 2, + 'status' => 1 + ], + [ + 'category_id' => $categoryIds['Learning'], + 'badge' => 'LEARNING', + 'title' => 'Upskill Your Team', + 'description' => 'Foster continuous personal development and professional growth with dynamic pathways designed for modern developers.', + 'button_text' => 'View Curriculums', + 'button_link' => '#', + 'image' => 'DL-Learning-1.jpg', + 'sort_order' => 1, + 'status' => 1 + ], + [ + 'category_id' => $categoryIds['Technology'], + 'badge' => 'TECHNOLOGY', + 'title' => 'Modern Software Stacks', + 'description' => 'Embrace modern architectural design, microservices, and reliable DevOps workflows to deploy faster and with confidence.', + 'button_text' => 'View Tech Stack', + 'button_link' => '#', + 'image' => 'DL-Technology.jpg', + 'sort_order' => 1, + 'status' => 1 + ] + ]; + + $stmtSlide = $pdo->prepare("INSERT INTO slides (category_id, badge, title, description, button_text, button_link, image, sort_order, status) VALUES (:category_id, :badge, :title, :description, :button_text, :button_link, :image, :sort_order, :status)"); + + foreach ($slides as $slide) { + $stmtSlide->execute([ + ':category_id' => $slide['category_id'], + ':badge' => $slide['badge'], + ':title' => $slide['title'], + ':description' => $slide['description'], + ':button_text' => $slide['button_text'], + ':button_link' => $slide['button_link'], + ':image' => $slide['image'], + ':sort_order' => $slide['sort_order'], + ':status' => $slide['status'] + ]); + } + echo "Seeded slides successfully.\n"; + echo "Database seeding finished successfully!\n"; + +} catch (PDOException $e) { + echo "Database Error: " . $e->getMessage() . "\n"; + exit(1); +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..43ff13e --- /dev/null +++ b/index.php @@ -0,0 +1,66 @@ +index(); + break; + + case 'admin/slides': + require_once __DIR__ . '/controllers/SlideController.php'; + $controller = new SlideController(); + $controller->index(); + break; + + case '': + default: + // Public slide view + require_once __DIR__ . '/models/Category.php'; + require_once __DIR__ . '/models/Slide.php'; + + $categoryModel = new Category(); + $slideModel = new Slide(); + + try { + // Fetch only active categories and slides to display in the public slider + $categories = $categoryModel->getAll(true); + $slides = $slideModel->getAll(null, true); + + // Group slides by category + $categorySlides = []; + foreach ($categories as $cat) { + $categorySlides[$cat['id']] = []; + } + foreach ($slides as $slide) { + if (isset($categorySlides[$slide['category_id']])) { + $categorySlides[$slide['category_id']][] = $slide; + } + } + + // Render public view + include __DIR__ . '/views/public/slider.php'; + } catch (Exception $e) { + echo "An error occurred: " . htmlspecialchars($e->getMessage()); + } + break; +} diff --git a/models/Category.php b/models/Category.php new file mode 100644 index 0000000..dfc7b9f --- /dev/null +++ b/models/Category.php @@ -0,0 +1,155 @@ +db = Database::getConnection(); + } + + /** + * Get all categories, ordered by sort_order + * @param bool $onlyActive If true, fetch only status = 1 categories + * @return array + */ + public function getAll($onlyActive = false) { + $sql = "SELECT * FROM categories"; + if ($onlyActive) { + $sql .= " WHERE status = 1"; + } + $sql .= " ORDER BY sort_order ASC, id ASC"; + + $stmt = $this->db->prepare($sql); + $stmt->execute(); + return $stmt->fetchAll(); + } + + /** + * Find category by ID + * @param int $id + * @return array|false + */ + public function find($id) { + $stmt = $this->db->prepare("SELECT * FROM categories WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + return $stmt->fetch(); + } + + /** + * Create a new category + * @param array $data + * @return int last inserted ID + */ + public function create($data) { + $stmt = $this->db->prepare(" + INSERT INTO categories (title, icon, sort_order, status) + VALUES (:title, :icon, :sort_order, :status) + "); + + $stmt->execute([ + ':title' => $data['title'], + ':icon' => $data['icon'] ?? null, + ':sort_order' => (int)($data['sort_order'] ?? 0), + ':status' => (int)($data['status'] ?? 1) + ]); + + return $this->db->lastInsertId(); + } + + /** + * Update an existing category + * @param int $id + * @param array $data + * @return bool + */ + public function update($id, $data) { + $sql = "UPDATE categories SET + title = :title, + sort_order = :sort_order, + status = :status"; + + $params = [ + ':id' => $id, + ':title' => $data['title'], + ':sort_order' => (int)($data['sort_order'] ?? 0), + ':status' => (int)($data['status'] ?? 1) + ]; + + if (array_key_exists('icon', $data)) { + $sql .= ", icon = :icon"; + $params[':icon'] = $data['icon']; + } + + $sql .= " WHERE id = :id"; + + $stmt = $this->db->prepare($sql); + return $stmt->execute($params); + } + + /** + * Delete a category + * @param int $id + * @return bool + */ + public function delete($id) { + // Handled in a transaction just in case, though DB has cascade + try { + $this->db->beginTransaction(); + + // Delete category + $stmt = $this->db->prepare("DELETE FROM categories WHERE id = :id"); + $result = $stmt->execute([':id' => $id]); + + $this->db->commit(); + return $result; + } catch (Exception $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $e; + } + } + + /** + * Update statuses + * @param int $id + * @param int $status + * @return bool + */ + public function toggleStatus($id, $status) { + $stmt = $this->db->prepare("UPDATE categories SET status = :status WHERE id = :id"); + return $stmt->execute([ + ':id' => $id, + ':status' => (int)$status + ]); + } + + /** + * Update sorting order + * @param array $orders Array of ['id' => X, 'sort_order' => Y] + * @return bool + */ + public function updateSortOrder($orders) { + try { + $this->db->beginTransaction(); + $stmt = $this->db->prepare("UPDATE categories SET sort_order = :sort_order WHERE id = :id"); + foreach ($orders as $order) { + $stmt->execute([ + ':id' => (int)$order['id'], + ':sort_order' => (int)$order['sort_order'] + ]); + } + $this->db->commit(); + return true; + } catch (Exception $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $e; + } + } +} diff --git a/models/Slide.php b/models/Slide.php new file mode 100644 index 0000000..28007be --- /dev/null +++ b/models/Slide.php @@ -0,0 +1,173 @@ +db = Database::getConnection(); + } + + /** + * Get all slides, optionally filtered by category and/or status + * @param int|null $categoryId Filter by category ID + * @param bool $onlyActive If true, fetch only status = 1 slides + * @return array + */ + public function getAll($categoryId = null, $onlyActive = false) { + $sql = "SELECT s.*, c.title as category_title + FROM slides s + JOIN categories c ON s.category_id = c.id"; + + $where = []; + $params = []; + + if ($categoryId !== null) { + $where[] = "s.category_id = :category_id"; + $params[':category_id'] = $categoryId; + } + + if ($onlyActive) { + $where[] = "s.status = 1 AND c.status = 1"; + } + + if (!empty($where)) { + $sql .= " WHERE " . implode(" AND ", $where); + } + + $sql .= " ORDER BY s.sort_order ASC, s.id ASC"; + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + /** + * Find slide by ID + * @param int $id + * @return array|false + */ + public function find($id) { + $stmt = $this->db->prepare("SELECT s.*, c.title as category_title FROM slides s JOIN categories c ON s.category_id = c.id WHERE s.id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + return $stmt->fetch(); + } + + /** + * Create a new slide + * @param array $data + * @return int last inserted ID + */ + public function create($data) { + $stmt = $this->db->prepare(" + INSERT INTO slides (category_id, badge, title, description, button_text, button_link, image, sort_order, status) + VALUES (:category_id, :badge, :title, :description, :button_text, :button_link, :image, :sort_order, :status) + "); + + $stmt->execute([ + ':category_id' => (int)$data['category_id'], + ':badge' => $data['badge'] ?? null, + ':title' => $data['title'], + ':description' => $data['description'] ?? null, + ':button_text' => $data['button_text'] ?? null, + ':button_link' => $data['button_link'] ?? null, + ':image' => $data['image'] ?? null, + ':sort_order' => (int)($data['sort_order'] ?? 0), + ':status' => (int)($data['status'] ?? 1) + ]); + + return $this->db->lastInsertId(); + } + + /** + * Update an existing slide + * @param int $id + * @param array $data + * @return bool + */ + public function update($id, $data) { + $sql = "UPDATE slides SET + category_id = :category_id, + badge = :badge, + title = :title, + description = :description, + button_text = :button_text, + button_link = :button_link, + sort_order = :sort_order, + status = :status"; + + $params = [ + ':id' => $id, + ':category_id' => (int)$data['category_id'], + ':badge' => $data['badge'] ?? null, + ':title' => $data['title'], + ':description' => $data['description'] ?? null, + ':button_text' => $data['button_text'] ?? null, + ':button_link' => $data['button_link'] ?? null, + ':sort_order' => (int)($data['sort_order'] ?? 0), + ':status' => (int)($data['status'] ?? 1) + ]; + + if (array_key_exists('image', $data)) { + $sql .= ", image = :image"; + $params[':image'] = $data['image']; + } + + $sql .= " WHERE id = :id"; + + $stmt = $this->db->prepare($sql); + return $stmt->execute($params); + } + + /** + * Delete a slide + * @param int $id + * @return bool + */ + public function delete($id) { + $stmt = $this->db->prepare("DELETE FROM slides WHERE id = :id"); + return $stmt->execute([':id' => $id]); + } + + /** + * Update status + * @param int $id + * @param int $status + * @return bool + */ + public function toggleStatus($id, $status) { + $stmt = $this->db->prepare("UPDATE slides SET status = :status WHERE id = :id"); + return $stmt->execute([ + ':id' => $id, + ':status' => (int)$status + ]); + } + + /** + * Update sorting order + * @param array $orders Array of ['id' => X, 'sort_order' => Y] + * @return bool + */ + public function updateSortOrder($orders) { + try { + $this->db->beginTransaction(); + $stmt = $this->db->prepare("UPDATE slides SET sort_order = :sort_order WHERE id = :id"); + foreach ($orders as $order) { + $stmt->execute([ + ':id' => (int)$order['id'], + ':sort_order' => (int)$order['sort_order'] + ]); + } + $this->db->commit(); + return true; + } catch (Exception $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $e; + } + } +} diff --git a/uploads/DL-Communication.jpg b/uploads/DL-Communication.jpg new file mode 100644 index 0000000..51b3b5b Binary files /dev/null and b/uploads/DL-Communication.jpg differ diff --git a/uploads/DL-Learning-1.jpg b/uploads/DL-Learning-1.jpg new file mode 100644 index 0000000..755171f Binary files /dev/null and b/uploads/DL-Learning-1.jpg differ diff --git a/uploads/DL-Technology.jpg b/uploads/DL-Technology.jpg new file mode 100644 index 0000000..a8d4354 Binary files /dev/null and b/uploads/DL-Technology.jpg differ diff --git a/uploads/DL-communication.svg b/uploads/DL-communication.svg new file mode 100644 index 0000000..0088544 --- /dev/null +++ b/uploads/DL-communication.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uploads/DL-learning.svg b/uploads/DL-learning.svg new file mode 100644 index 0000000..bfeb0b5 --- /dev/null +++ b/uploads/DL-learning.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uploads/DL-technology.svg b/uploads/DL-technology.svg new file mode 100644 index 0000000..59f2ea5 --- /dev/null +++ b/uploads/DL-technology.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uploads/arrow-right.svg b/uploads/arrow-right.svg new file mode 100644 index 0000000..37d307b --- /dev/null +++ b/uploads/arrow-right.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/uploads/minus-01.svg b/uploads/minus-01.svg new file mode 100644 index 0000000..a0639ca --- /dev/null +++ b/uploads/minus-01.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/uploads/plus-01.svg b/uploads/plus-01.svg new file mode 100644 index 0000000..d80a766 --- /dev/null +++ b/uploads/plus-01.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/views/admin/categories.php b/views/admin/categories.php new file mode 100644 index 0000000..a7729ed --- /dev/null +++ b/views/admin/categories.php @@ -0,0 +1,278 @@ + + + + + + + Manage Categories - Admin Dashboard + + + + + + + + + + + + + +
+
+ + + + +
+ + + + +
+
+

Categories Management

+

Add, edit, reorder or toggle status of categories.

+
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SortIDIconTitleSort OrderStatusActions
No categories found. Click 'Add Category' to create one.
+
+ + +
+
# + + + +
+ +
+ + +
+ > +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/views/admin/index.php b/views/admin/index.php new file mode 100644 index 0000000..52564da --- /dev/null +++ b/views/admin/index.php @@ -0,0 +1,198 @@ +query("SELECT COUNT(*) FROM categories")->fetchColumn(); +$totalSlides = $db->query("SELECT COUNT(*) FROM slides")->fetchColumn(); +?> + + + + + + Admin Dashboard - WPoets Content Slider + + + + + + + + + + + +
+
+ + + + +
+ + + +
+

Dashboard Overview

+ Visit Public Site +
+ + +
+
+
+
+
+
Total Categories
+

+
+
+
+ +
+
+ +
+
+
+
+
Total Slides
+

+
+
+
+ +
+
+
+ + +
+

System Information

+
+
+ + + + + + + + + + + + + + + +
PHP Version:
MySQL Version:8.0 (PDO)
Database:wpoets_assignment
+
+
+
+
Developer Tip
+

To reorder categories or slides, you can edit the sort order directly in the tables or use the up/down arrows. Click on the Toggle switch to activate/deactivate items instantly without full-page reloads.

+
+
+
+
+ +
+
+
+ + + + + diff --git a/views/admin/slides.php b/views/admin/slides.php new file mode 100644 index 0000000..d14e20c --- /dev/null +++ b/views/admin/slides.php @@ -0,0 +1,358 @@ + + + + + + + Manage Slides - Admin Dashboard + + + + + + + + + + + + + +
+
+ + + + +
+ + + + +
+
+

Slides Management

+

Create, edit, reorder or toggle status of content slides.

+
+
+
+ +
+ +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SortIDImageCategoryBadgeTitleSort OrderStatusActions
No slides found. Click 'Add Slide' to create one.
+
+ + +
+
# + + + +
+ +
+ + +
+ > +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/views/public/slider.php b/views/public/slider.php new file mode 100644 index 0000000..8f271c8 --- /dev/null +++ b/views/public/slider.php @@ -0,0 +1,255 @@ + + + + + + WPoets Content Slider + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ + +
+
+
Categories
+ +
+
+ + +
+
+ + + + +
+ +
+ + +
+
+ +
+ +

No slides active in this category.

+
+ + +
+ +
+ + + + + + + +

+ +

+ +

+ +

+ + + + + + + +
+ +
+ + +
+ + +
+ +
+
+ + +
+
+
+ + Active Slide Preview +
+
+
+ +
+
+ + +
+
+ +
+
+ + + + +
+
+ + + + +
+

+ +

+ +
+ +
+ +
No slides in this category.
+ + +
+ +
+ +
+ + + + + + +

+

+ + + + + + +
+ +
+ +
+ +
+
+
+ + +
+
+ +
+ + + + + + + + + + +