333 lines
9.3 KiB
Markdown
333 lines
9.3 KiB
Markdown
|
|
# Product Rich Text Editor & Multi-Image Upload Feature
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This feature enhancement adds:
|
||
|
|
|
||
|
|
1. **Rich Text Editor** for product descriptions with HTML support
|
||
|
|
2. **Multiple Image Upload** system with drag-and-drop reordering
|
||
|
|
3. **Image Carousel** for frontend product display
|
||
|
|
|
||
|
|
## Backend Changes
|
||
|
|
|
||
|
|
### Database Schema
|
||
|
|
|
||
|
|
**New Table: `product_images`**
|
||
|
|
|
||
|
|
```sql
|
||
|
|
- id (UUID, Primary Key)
|
||
|
|
- product_id (UUID, Foreign Key → products.id, CASCADE DELETE)
|
||
|
|
- image_url (VARCHAR, NOT NULL)
|
||
|
|
- display_order (INTEGER, DEFAULT 0)
|
||
|
|
- is_primary (BOOLEAN, DEFAULT FALSE)
|
||
|
|
- created_at (TIMESTAMP)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Indexes:**
|
||
|
|
|
||
|
|
- `idx_product_images_product_id` on `product_id`
|
||
|
|
- `idx_product_images_display_order` on `(product_id, display_order)`
|
||
|
|
|
||
|
|
### Models (`backend/models.py`)
|
||
|
|
|
||
|
|
```python
|
||
|
|
class ProductImage(Base):
|
||
|
|
__tablename__ = "product_images"
|
||
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
|
|
product_id = Column(UUID(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"))
|
||
|
|
image_url = Column(String, nullable=False)
|
||
|
|
display_order = Column(Integer, default=0)
|
||
|
|
is_primary = Column(Boolean, default=False)
|
||
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||
|
|
|
||
|
|
product = relationship("Product", back_populates="images")
|
||
|
|
|
||
|
|
# Updated Product model:
|
||
|
|
images = relationship("ProductImage", back_populates="product",
|
||
|
|
cascade="all, delete-orphan",
|
||
|
|
order_by="ProductImage.display_order")
|
||
|
|
```
|
||
|
|
|
||
|
|
### API Endpoints (`backend/server.py`)
|
||
|
|
|
||
|
|
#### Image Upload
|
||
|
|
|
||
|
|
- **POST `/api/upload/image`** - Upload single image
|
||
|
|
- Accepts: multipart/form-data with `file` field
|
||
|
|
- Returns: `{url, filename}`
|
||
|
|
- Validates: Only image file types
|
||
|
|
- Saves to: `backend/uploads/products/`
|
||
|
|
|
||
|
|
#### Product Image Management
|
||
|
|
|
||
|
|
- **POST `/api/admin/products/{product_id}/images`** - Add multiple images
|
||
|
|
- Body: `{image_urls: List[str]}`
|
||
|
|
- Auto-sets first image as primary if none exist
|
||
|
|
|
||
|
|
- **DELETE `/api/admin/products/{product_id}/images/{image_id}`** - Delete image
|
||
|
|
- Removes from database and filesystem
|
||
|
|
|
||
|
|
- **PUT `/api/admin/products/{product_id}/images/reorder`** - Reorder images
|
||
|
|
- Body: `{image_id: display_order}` mapping
|
||
|
|
|
||
|
|
#### Updated Product CRUD
|
||
|
|
|
||
|
|
- **POST `/api/admin/products`** - Create product with images
|
||
|
|
- Accepts `images: List[str]` in request body
|
||
|
|
- Creates ProductImage records for each URL
|
||
|
|
|
||
|
|
- **PUT `/api/admin/products/{product_id}`** - Update product with images
|
||
|
|
- Accepts `images: List[str]` (optional)
|
||
|
|
- Replaces all existing images if provided
|
||
|
|
|
||
|
|
- **GET `/api/products`** - List products (includes images array)
|
||
|
|
- **GET `/api/products/{id}`** - Get product detail (includes images array)
|
||
|
|
- **GET `/api/admin/products`** - Admin product list (includes images array)
|
||
|
|
|
||
|
|
#### Static File Serving
|
||
|
|
|
||
|
|
- **GET `/uploads/**`** - Serve uploaded images
|
||
|
|
- Mounted: `/uploads` → `backend/uploads/`
|
||
|
|
|
||
|
|
### Pydantic Models
|
||
|
|
|
||
|
|
```python
|
||
|
|
class ProductCreate(BaseModel):
|
||
|
|
images: List[str] = [] # Added
|
||
|
|
description: str # Now supports HTML
|
||
|
|
|
||
|
|
class ProductUpdate(BaseModel):
|
||
|
|
images: Optional[List[str]] = None # Added
|
||
|
|
description: Optional[str] = None # Supports HTML
|
||
|
|
```
|
||
|
|
|
||
|
|
### Serializer (`product_to_dict`)
|
||
|
|
|
||
|
|
```python
|
||
|
|
"images": [
|
||
|
|
{
|
||
|
|
"id": str(img.id),
|
||
|
|
"image_url": img.image_url,
|
||
|
|
"display_order": img.display_order,
|
||
|
|
"is_primary": img.is_primary
|
||
|
|
}
|
||
|
|
for img in sorted(product.images, key=lambda x: x.display_order)
|
||
|
|
]
|
||
|
|
```
|
||
|
|
|
||
|
|
## Frontend Changes
|
||
|
|
|
||
|
|
### New Components
|
||
|
|
|
||
|
|
#### 1. RichTextEditor (`frontend/src/components/RichTextEditor.js`)
|
||
|
|
|
||
|
|
- Uses TipTap editor with StarterKit
|
||
|
|
- Features:
|
||
|
|
- Bold, Italic formatting
|
||
|
|
- Heading 2
|
||
|
|
- Bullet/Numbered lists
|
||
|
|
- Blockquotes
|
||
|
|
- Undo/Redo
|
||
|
|
- Props: `{content, onChange, placeholder}`
|
||
|
|
- Returns HTML string
|
||
|
|
|
||
|
|
#### 2. ImageUploadManager (`frontend/src/components/ImageUploadManager.js`)
|
||
|
|
|
||
|
|
- Multi-image upload with preview
|
||
|
|
- Features:
|
||
|
|
- File upload button
|
||
|
|
- Drag-and-drop reordering
|
||
|
|
- Delete individual images
|
||
|
|
- Primary image indicator (first = primary)
|
||
|
|
- Image preview grid
|
||
|
|
- Props: `{images, onChange, token}`
|
||
|
|
|
||
|
|
#### 3. ProductImageCarousel (`frontend/src/components/ProductImageCarousel.js`)
|
||
|
|
|
||
|
|
- Image slider with thumbnails
|
||
|
|
- Features:
|
||
|
|
- Navigation arrows (prev/next)
|
||
|
|
- Thumbnail strip navigation
|
||
|
|
- Image counter
|
||
|
|
- Keyboard navigation support
|
||
|
|
- Responsive design
|
||
|
|
- Props: `{images, alt}`
|
||
|
|
|
||
|
|
### Updated Pages
|
||
|
|
|
||
|
|
#### AdminDashboard.js
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// Added imports
|
||
|
|
import RichTextEditor from '../components/RichTextEditor';
|
||
|
|
import ImageUploadManager from '../components/ImageUploadManager';
|
||
|
|
|
||
|
|
// Updated product form state
|
||
|
|
productForm: {
|
||
|
|
images: [], // New field
|
||
|
|
description: "" // Now holds HTML
|
||
|
|
}
|
||
|
|
|
||
|
|
// Updated dialog
|
||
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||
|
|
<RichTextEditor
|
||
|
|
content={productForm.description}
|
||
|
|
onChange={(html) => setProductForm({...productForm, description: html})}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<ImageUploadManager
|
||
|
|
images={productForm.images}
|
||
|
|
onChange={(images) => setProductForm({...productForm, images})}
|
||
|
|
token={token}
|
||
|
|
/>
|
||
|
|
</DialogContent>
|
||
|
|
|
||
|
|
// Edit product - populate images
|
||
|
|
setProductForm({
|
||
|
|
images: product.images?.map(img => img.image_url) || []
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
#### ProductDetail.js
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
import ProductImageCarousel from '../components/ProductImageCarousel';
|
||
|
|
|
||
|
|
// Replace single image with carousel
|
||
|
|
<ProductImageCarousel
|
||
|
|
images={product.images?.length > 0 ? product.images : [product.image_url]}
|
||
|
|
alt={product.name}
|
||
|
|
/>
|
||
|
|
|
||
|
|
// Display HTML description
|
||
|
|
<div
|
||
|
|
className="prose prose-sm"
|
||
|
|
dangerouslySetInnerHTML={{ __html: product.description }}
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
#### ProductCard.js (Products listing)
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// Get primary image or first image
|
||
|
|
const getImageUrl = () => {
|
||
|
|
if (product.images?.length > 0) {
|
||
|
|
const primaryImage = product.images.find(img => img.is_primary) || product.images[0];
|
||
|
|
const url = primaryImage.image_url || primaryImage;
|
||
|
|
return url.startsWith('/uploads')
|
||
|
|
? `${process.env.REACT_APP_BACKEND_URL}${url}`
|
||
|
|
: url;
|
||
|
|
}
|
||
|
|
return product.image_url;
|
||
|
|
};
|
||
|
|
|
||
|
|
<img src={getImageUrl()} alt={product.name} />
|
||
|
|
```
|
||
|
|
|
||
|
|
### CSS Styles (`frontend/src/index.css`)
|
||
|
|
|
||
|
|
```css
|
||
|
|
/* TipTap Editor Styles */
|
||
|
|
.ProseMirror { outline: none; }
|
||
|
|
.ProseMirror h2 { font-size: 1.5em; font-weight: 600; }
|
||
|
|
.ProseMirror ul, .ProseMirror ol { padding-left: 1.5em; }
|
||
|
|
.ProseMirror blockquote { border-left: 3px solid; padding-left: 1em; }
|
||
|
|
|
||
|
|
/* Product Description Display */
|
||
|
|
.prose h2 { font-size: 1.5em; font-weight: 600; }
|
||
|
|
.prose ul, .prose ol { padding-left: 1.5em; }
|
||
|
|
.prose blockquote { border-left: 3px solid; font-style: italic; }
|
||
|
|
```
|
||
|
|
|
||
|
|
## Usage Flow
|
||
|
|
|
||
|
|
### Admin Creating Product with Multiple Images
|
||
|
|
|
||
|
|
1. Open "Add Product" dialog in Admin Dashboard
|
||
|
|
2. Fill in product name, price, category, etc.
|
||
|
|
3. Use rich text editor to write formatted description
|
||
|
|
4. Click "Add Images" button to upload multiple images
|
||
|
|
5. Drag images to reorder (first = primary)
|
||
|
|
6. Click "Create" - product saved with all images
|
||
|
|
|
||
|
|
### Admin Editing Product
|
||
|
|
|
||
|
|
1. Click Edit on existing product
|
||
|
|
2. Dialog loads with current data including images
|
||
|
|
3. Rich text editor shows formatted description
|
||
|
|
4. Images displayed in grid with reorder/delete options
|
||
|
|
5. Add more images or reorder existing ones
|
||
|
|
6. Click "Update" - changes saved
|
||
|
|
|
||
|
|
### Customer Viewing Product
|
||
|
|
|
||
|
|
1. Browse products - sees primary image on card
|
||
|
|
2. Click product for details
|
||
|
|
3. Image carousel displays all product images
|
||
|
|
4. Navigate with arrows or click thumbnails
|
||
|
|
5. Description renders with HTML formatting
|
||
|
|
|
||
|
|
## Backwards Compatibility
|
||
|
|
|
||
|
|
- `image_url` field retained for legacy support
|
||
|
|
- Products without images fallback to `image_url`
|
||
|
|
- Frontend handles both old (single URL) and new (images array)
|
||
|
|
- Database migrations non-breaking
|
||
|
|
|
||
|
|
## File Structure
|
||
|
|
|
||
|
|
```
|
||
|
|
backend/
|
||
|
|
├── models.py # Added ProductImage model
|
||
|
|
├── server.py # Added endpoints, updated CRUD
|
||
|
|
└── uploads/
|
||
|
|
└── products/ # Image storage directory
|
||
|
|
|
||
|
|
frontend/src/
|
||
|
|
├── components/
|
||
|
|
│ ├── RichTextEditor.js # NEW
|
||
|
|
│ ├── ImageUploadManager.js # NEW
|
||
|
|
│ └── ProductImageCarousel.js # NEW
|
||
|
|
├── pages/
|
||
|
|
│ ├── AdminDashboard.js # Updated
|
||
|
|
│ ├── ProductDetail.js # Updated
|
||
|
|
│ └── Products.js # Updated (via ProductCard)
|
||
|
|
└── index.css # Added prose/editor styles
|
||
|
|
```
|
||
|
|
|
||
|
|
## Dependencies
|
||
|
|
|
||
|
|
**Already Installed:**
|
||
|
|
|
||
|
|
- `@tiptap/react` - Rich text editor
|
||
|
|
- `@tiptap/starter-kit` - Editor extensions
|
||
|
|
- `@tiptap/extension-placeholder` - Placeholder text
|
||
|
|
|
||
|
|
**Backend:**
|
||
|
|
|
||
|
|
- `shutil` - File operations (built-in)
|
||
|
|
- `uuid` - Unique filenames (built-in)
|
||
|
|
|
||
|
|
## Testing Checklist
|
||
|
|
|
||
|
|
- [ ] Create product with multiple images
|
||
|
|
- [ ] Upload images via drag-and-drop
|
||
|
|
- [ ] Reorder images by dragging
|
||
|
|
- [ ] Delete individual images
|
||
|
|
- [ ] Edit product preserves existing images
|
||
|
|
- [ ] Rich text formatting saves correctly
|
||
|
|
- [ ] HTML renders properly on product detail page
|
||
|
|
- [ ] Image carousel navigation works
|
||
|
|
- [ ] Primary image displays on product cards
|
||
|
|
- [ ] Fallback to `image_url` for legacy products
|
||
|
|
- [ ] Image files serve correctly via `/uploads`
|
||
|
|
- [ ] Deleting product cascades to delete images
|
||
|
|
|
||
|
|
## Security Notes
|
||
|
|
|
||
|
|
- File upload validates image MIME types only
|
||
|
|
- Unique UUIDs prevent filename conflicts
|
||
|
|
- CASCADE DELETE ensures orphaned images cleaned up
|
||
|
|
- Static file serving restricted to `/uploads` directory
|
||
|
|
- Admin authentication required for all mutations
|