@@ -0,0 +1,105 @@
|
||||
# Testing Layer Transforms - Product 404R
|
||||
|
||||
## Quick Test Guide
|
||||
|
||||
I've created **product 404R** as a demonstration of the layer transform feature. It uses the same images as product 404 but flips the door and hardware layers horizontally to show a right-hinge configuration.
|
||||
|
||||
## Test URLs
|
||||
|
||||
**Left-Hinge (Original):**
|
||||
```
|
||||
http://localhost:8080/quiz/product/404
|
||||
```
|
||||
|
||||
**Right-Hinge (Flipped):**
|
||||
```
|
||||
http://localhost:8080/quiz/product/404R
|
||||
```
|
||||
|
||||
## What to Look For
|
||||
|
||||
When you compare both products:
|
||||
|
||||
1. **Base layer** - Should be identical (house/background doesn't flip)
|
||||
2. **Door layer** - Should be mirrored left-to-right
|
||||
3. **Hardware layer** - Should be mirrored to match door position
|
||||
4. **Foreground layer** - Should be identical (plants don't flip)
|
||||
|
||||
## Image Files Used
|
||||
|
||||
Both products use the same image files from:
|
||||
```
|
||||
/static/images/window/storm-window/404/
|
||||
├── door-white.png
|
||||
├── door-black.png
|
||||
└── (other color variants)
|
||||
|
||||
/static/images/window/storm-window/layers/
|
||||
├── base.jpg
|
||||
├── hardware.png
|
||||
└── foreground.png
|
||||
```
|
||||
|
||||
**Zero additional images needed!** The flip is pure CSS.
|
||||
|
||||
## Configuration Difference
|
||||
|
||||
### Product 404 (Left-Hinge)
|
||||
```json
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
}
|
||||
```
|
||||
|
||||
### Product 404R (Right-Hinge)
|
||||
```json
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true,
|
||||
"layerTransforms": {
|
||||
"door": "flip-horizontal",
|
||||
"hardware": "flip-horizontal"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Color Variations
|
||||
|
||||
Both products support all color options:
|
||||
- Black
|
||||
- White
|
||||
- Bronze
|
||||
- Sandstone
|
||||
|
||||
Select different colors in the dropdown to see the door layer change while maintaining the flip.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- **Performance**: Instant rendering, GPU-accelerated CSS transform
|
||||
- **Storage**: No duplicate images required
|
||||
- **Bandwidth**: Same image files downloaded once and reused
|
||||
- **Compatibility**: Works in all modern browsers
|
||||
|
||||
## Use Cases
|
||||
|
||||
This same technique can be applied to:
|
||||
- Storm doors with different hinge sides
|
||||
- Windows with operable panels on left vs right
|
||||
- Sliding doors/windows that open from different sides
|
||||
- Any product where a mirror variant is needed
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Restart your Flask server to load the new product data
|
||||
2. Visit both URLs to compare
|
||||
3. Try different color selections
|
||||
4. Open browser DevTools to see the CSS classes applied:
|
||||
- `.layer-flip-horizontal` on door and hardware layers for 404R
|
||||
- No transform classes on 404
|
||||
|
||||
## Production Usage
|
||||
|
||||
When ready for production:
|
||||
1. Design all "left-hinge" (or default orientation) images
|
||||
2. Add product entries for "right-hinge" variants with `layerTransforms`
|
||||
3. No need to create separate images!
|
||||
4. Consider naming convention: 404, 404R, 404L, etc.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Visual Test Images - Layer Reference
|
||||
|
||||
## Image Overview
|
||||
|
||||
All images created with **800x600px** canvas dimensions for perfect alignment.
|
||||
|
||||
## Layers Created
|
||||
|
||||
### 1. **Base Layer** (`/layers/base.jpg`)
|
||||
- **Sky blue background** (#87CEEB)
|
||||
- **Green grass** at bottom
|
||||
- **Tan house** with dark brown roof
|
||||
- **Two side windows** with dividers
|
||||
- **Door opening** in center (lighter tan rectangle)
|
||||
- **Opaque JPG** - the foundation layer
|
||||
|
||||
### 2. **Door Layer** (`/404/door-{color}.png`)
|
||||
Created in 4 colors:
|
||||
- `door-white.png` - White door (#FFFFFF)
|
||||
- `door-black.png` - Dark gray/black door (#2C2C2C)
|
||||
- `door-bronze.png` - Bronze door (#8B6914)
|
||||
- `door-tan.png` - Tan door (#D2B48C)
|
||||
|
||||
Each door has:
|
||||
- **Rectangular shape** positioned in the door opening
|
||||
- **Two decorative panels** (outlined rectangles)
|
||||
- **Small black circle** on right side marking handle position
|
||||
- **Transparent PNG** - shows house through transparent areas
|
||||
|
||||
### 3. **Hardware Layer** (`/layers/hardware.png`)
|
||||
- **Gold circle** (#FFD700) for door handle
|
||||
- **Bronze backplate** (#B8860B) behind handle
|
||||
- **Light highlight** for 3D effect
|
||||
- **Positioned on right side** (for left-hinge door)
|
||||
- **Transparent PNG** - only handle visible
|
||||
|
||||
### 4. **Foreground Layer** (`/layers/foreground.png`)
|
||||
- **Green bushes** at bottom corners (#228B22)
|
||||
- **Pink flowers** with gold centers
|
||||
- **Plants overlap bottom of door** realistically
|
||||
- **Transparent PNG** - plants appear in front
|
||||
|
||||
## How They Stack (z-index order)
|
||||
|
||||
```
|
||||
Layer 5: Foreground (plants) - On top, in front of everything
|
||||
Layer 3: Hardware (handle) - Above door
|
||||
Layer 2: Door (colored) - In door opening
|
||||
Layer 1: Base (house) - Background, always visible
|
||||
```
|
||||
|
||||
## Left-Hinge vs Right-Hinge
|
||||
|
||||
### Product 404 (Left-Hinge)
|
||||
- Door handle on **right side** of door
|
||||
- Door opens to the right
|
||||
- Uses images as created
|
||||
|
||||
### Product 404R (Right-Hinge)
|
||||
- Door and hardware layers **flipped horizontally**
|
||||
- Door handle appears on **left side** of door
|
||||
- Door opens to the left
|
||||
- Same image files, just CSS transformed!
|
||||
|
||||
## Visual Features to Notice
|
||||
|
||||
1. **Door positioning**: The door rectangle fits perfectly in the lighter opening on the house
|
||||
2. **Handle alignment**: The small black dot on the door matches the gold handle position
|
||||
3. **Plants overlap**: The foreground plants appear in front of the door bottom
|
||||
4. **Color changes**: Select different colors to see door change while everything else stays the same
|
||||
5. **Flip effect**: Compare 404 vs 404R to see the door and handle swap sides
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **Canvas size**: 800x600 pixels (all layers)
|
||||
- **Format**: JPG for base (opaque), PNG for others (transparency)
|
||||
- **Color depth**: RGB/RGBA
|
||||
- **Positioning**: Centered with `object-fit: contain`
|
||||
|
||||
## Test Checklist
|
||||
|
||||
When viewing the products, verify:
|
||||
- ✅ House background always visible
|
||||
- ✅ Door fits in opening
|
||||
- ✅ Door color changes when selected
|
||||
- ✅ Gold handle appears on door
|
||||
- ✅ Plants appear in front of door
|
||||
- ✅ 404R has flipped door and handle
|
||||
- ✅ No white gaps or misalignment
|
||||
|
||||
## File Locations
|
||||
|
||||
```
|
||||
app/static/images/
|
||||
└── window/
|
||||
└── storm-window/
|
||||
├── 404/
|
||||
│ ├── door-white.png
|
||||
│ ├── door-black.png
|
||||
│ ├── door-bronze.png
|
||||
│ └── door-tan.png
|
||||
└── layers/
|
||||
├── base.jpg
|
||||
├── hardware.png
|
||||
└── foreground.png
|
||||
```
|
||||
|
||||
## Creating Similar Images
|
||||
|
||||
Use the script `create_visual_test_images.py` as a template:
|
||||
1. Set canvas dimensions (CANVAS_WIDTH, CANVAS_HEIGHT)
|
||||
2. Design each layer with proper positioning
|
||||
3. Use transparent PNGs for layered elements
|
||||
4. Export base layer as JPG, others as PNG
|
||||
5. Keep all layers at same canvas size
|
||||
@@ -19,10 +19,12 @@ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
from blueprints.auth import auth_bp
|
||||
from blueprints.users import users_bp
|
||||
from blueprints.products import products_bp
|
||||
from blueprints.canvas import canvas_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(products_bp)
|
||||
app.register_blueprint(canvas_bp)
|
||||
|
||||
# Comment Change
|
||||
# Home route
|
||||
@@ -44,6 +46,7 @@ def home():
|
||||
<p>Current Location: {session.get('currentLocation', 'Not selected')}</p>
|
||||
<ul>
|
||||
<li><a href="{url_for('products.product_finder')}">🔍 Product Finder Quiz</a></li>
|
||||
<li><a href="{url_for('products.product_manager')}">📦 Manage Products</a></li>
|
||||
<li><a href="{url_for('users.user_manager')}">👥 Manage Users</a></li>
|
||||
<li><a href="{url_for('auth.select_location_page')}">📍 Change Location</a></li>
|
||||
<li><a href="{url_for('auth.logout')}">🚪 Logout</a></li>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Canvas Image API - Hierarchical Image Fallback System
|
||||
|
||||
This module provides intelligent image serving with fallback logic:
|
||||
1. Product-specific images
|
||||
2. Subtype-level fallbacks
|
||||
3. Type-level fallbacks
|
||||
4. Global fallbacks
|
||||
|
||||
URL Pattern: /api/canvas/<product_code>/<layer>?color=<color>&material=<material>
|
||||
"""
|
||||
|
||||
from flask import Blueprint, send_file, request, jsonify, current_app
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
canvas_bp = Blueprint('canvas', __name__)
|
||||
|
||||
# Load product data to get type/subtype information
|
||||
def get_product_data():
|
||||
"""Load product data from JSON file"""
|
||||
data_path = os.path.join(current_app.root_path, 'data', 'products.json')
|
||||
try:
|
||||
with open(data_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error loading product data: {e}")
|
||||
return []
|
||||
|
||||
def get_product_info(product_code):
|
||||
"""Get product type, subtype, and other metadata"""
|
||||
products = get_product_data()
|
||||
for product in products:
|
||||
if product.get('productCode') == product_code or product.get('id') == product_code:
|
||||
base_type = product.get('baseType', '').lower()
|
||||
sub_type = None
|
||||
|
||||
# Extract subtype
|
||||
subtype_obj = product.get('subType', {})
|
||||
if isinstance(subtype_obj, dict):
|
||||
if subtype_obj.get('door'):
|
||||
sub_type = subtype_obj['door'].lower().replace(' ', '-')
|
||||
elif subtype_obj.get('window'):
|
||||
sub_type = subtype_obj['window'].lower().replace(' ', '-')
|
||||
|
||||
return {
|
||||
'code': product_code,
|
||||
'type': base_type,
|
||||
'subtype': sub_type,
|
||||
'colors': product.get('colors', []),
|
||||
'materials': product.get('materials', [])
|
||||
}
|
||||
return None
|
||||
|
||||
def find_image_with_fallback(product_code, layer, color=None, material=None):
|
||||
"""
|
||||
Find image using hierarchical fallback system.
|
||||
|
||||
Priority order:
|
||||
1. /images/<type>/<subtype>/<code>/<layer>-<color>.<ext>
|
||||
2. /images/<type>/<subtype>/<code>/<layer>.<ext>
|
||||
3. /images/<type>/<subtype>/layers/<layer>-<color>.<ext>
|
||||
4. /images/<type>/<subtype>/layers/<layer>.<ext>
|
||||
5. /images/<type>/layers/<layer>.<ext>
|
||||
6. /images/layers/<layer>.<ext>
|
||||
|
||||
Returns: (file_path, mime_type) or (None, None)
|
||||
"""
|
||||
product_info = get_product_info(product_code)
|
||||
if not product_info:
|
||||
current_app.logger.warning(f"Product not found: {product_code}")
|
||||
return None, None
|
||||
|
||||
images_dir = os.path.join(current_app.root_path, 'static', 'images')
|
||||
base_type = product_info['type']
|
||||
sub_type = product_info['subtype']
|
||||
|
||||
# Supported extensions
|
||||
extensions = ['png', 'jpg', 'jpeg', 'webp']
|
||||
|
||||
# Build search paths in priority order
|
||||
search_paths = []
|
||||
|
||||
# 1. Product-specific with color
|
||||
if color and base_type and sub_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}-{color.lower()}.{ext}"))
|
||||
|
||||
# 2. Product-specific without color
|
||||
if base_type and sub_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}.{ext}"))
|
||||
|
||||
# 3. Subtype fallback with color
|
||||
if color and base_type and sub_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}-{color.lower()}.{ext}"))
|
||||
|
||||
# 4. Subtype fallback without color
|
||||
if base_type and sub_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}.{ext}"))
|
||||
|
||||
# 5. Type fallback
|
||||
if base_type:
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, base_type, 'layers', f"{layer}.{ext}"))
|
||||
|
||||
# 6. Global fallback
|
||||
for ext in extensions:
|
||||
search_paths.append(os.path.join(images_dir, 'layers', f"{layer}.{ext}"))
|
||||
|
||||
# Search for first existing file
|
||||
for path in search_paths:
|
||||
if os.path.exists(path):
|
||||
# Determine MIME type
|
||||
ext = path.split('.')[-1].lower()
|
||||
mime_types = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'webp': 'image/webp'
|
||||
}
|
||||
mime_type = mime_types.get(ext, 'image/png')
|
||||
|
||||
current_app.logger.info(f"Found image: {path}")
|
||||
return path, mime_type
|
||||
|
||||
current_app.logger.warning(f"No image found for {product_code}/{layer} (color: {color})")
|
||||
return None, None
|
||||
|
||||
@canvas_bp.route('/api/canvas/<product_code>/<layer>')
|
||||
def get_layer_image(product_code, layer):
|
||||
"""
|
||||
Serve a specific layer image for a product with hierarchical fallback.
|
||||
|
||||
Query parameters:
|
||||
- color: Optional color variant
|
||||
- material: Optional material (for future use)
|
||||
|
||||
Returns: Image file or 404
|
||||
"""
|
||||
color = request.args.get('color')
|
||||
material = request.args.get('material')
|
||||
|
||||
# Validate layer name (prevent directory traversal)
|
||||
valid_layers = ['base', 'door', 'hardware', 'overlay', 'foreground']
|
||||
if layer not in valid_layers:
|
||||
return jsonify({'error': 'Invalid layer name'}), 400
|
||||
|
||||
# Find image with fallback
|
||||
image_path, mime_type = find_image_with_fallback(product_code, layer, color, material)
|
||||
|
||||
if image_path:
|
||||
return send_file(image_path, mimetype=mime_type)
|
||||
else:
|
||||
# Return placeholder or 404
|
||||
return jsonify({
|
||||
'error': 'Image not found',
|
||||
'product': product_code,
|
||||
'layer': layer,
|
||||
'color': color
|
||||
}), 404
|
||||
|
||||
@canvas_bp.route('/api/canvas/<product_code>/info')
|
||||
def get_canvas_info(product_code):
|
||||
"""
|
||||
Get information about available layers for a product.
|
||||
Useful for frontend to know which layers exist.
|
||||
"""
|
||||
product_info = get_product_info(product_code)
|
||||
|
||||
if not product_info:
|
||||
return jsonify({'error': 'Product not found'}), 404
|
||||
|
||||
# Check which layers are available
|
||||
available_layers = {}
|
||||
layers_to_check = ['base', 'door', 'hardware', 'overlay', 'foreground']
|
||||
|
||||
for layer in layers_to_check:
|
||||
# Check if layer exists (with or without color)
|
||||
path, _ = find_image_with_fallback(product_code, layer)
|
||||
if path:
|
||||
available_layers[layer] = True
|
||||
|
||||
# For door layer, check which colors are available
|
||||
if layer == 'door' and product_info['colors']:
|
||||
available_colors = []
|
||||
for color in product_info['colors']:
|
||||
color_path, _ = find_image_with_fallback(product_code, layer, color)
|
||||
if color_path:
|
||||
available_colors.append(color)
|
||||
available_layers['door_colors'] = available_colors
|
||||
|
||||
return jsonify({
|
||||
'product': product_code,
|
||||
'type': product_info['type'],
|
||||
'subtype': product_info['subtype'],
|
||||
'availableLayers': available_layers,
|
||||
'colors': product_info['colors'],
|
||||
'materials': product_info['materials']
|
||||
})
|
||||
|
||||
@canvas_bp.route('/api/canvas/test')
|
||||
def test_canvas():
|
||||
"""Test endpoint to verify canvas API is working"""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'message': 'Canvas API is running',
|
||||
'endpoints': {
|
||||
'get_layer': '/api/canvas/<product_code>/<layer>?color=<color>',
|
||||
'get_info': '/api/canvas/<product_code>/info',
|
||||
'test': '/api/canvas/test'
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
"""
|
||||
Product Finder Blueprint
|
||||
Handles the product quiz and recommendations
|
||||
"""
|
||||
from flask import Blueprint, render_template, send_from_directory
|
||||
from blueprints.auth import login_required
|
||||
from flask import Blueprint, render_template, send_from_directory, session, jsonify, redirect, url_for
|
||||
from blueprints.auth import login_required, get_current_user, can_user
|
||||
import os
|
||||
|
||||
products_bp = Blueprint('products', __name__, url_prefix='/quiz')
|
||||
@@ -29,6 +29,31 @@ def advanced_search():
|
||||
"""Serve the advanced search page"""
|
||||
return render_template('advanced_search.html')
|
||||
|
||||
@products_bp.route('/manage')
|
||||
@login_required
|
||||
def product_manager():
|
||||
"""Serve the product management page"""
|
||||
# Check if user has permission to manage products
|
||||
if not can_user('manage_products'):
|
||||
return redirect(url_for('products.product_finder'))
|
||||
return render_template('product_manager.html')
|
||||
|
||||
@products_bp.route('/api/user-session')
|
||||
@login_required
|
||||
def get_user_session():
|
||||
"""Return current user's session data including accessible locations"""
|
||||
user = get_current_user()
|
||||
permissions = user.get('permissions', {}) if user else {}
|
||||
|
||||
return jsonify({
|
||||
'username': session.get('username'),
|
||||
'userId': session.get('user_id'),
|
||||
'currentLocation': session.get('currentLocation'),
|
||||
'accessibleLocations': session.get('accessibleLocations', []),
|
||||
'role': session.get('role', 'user'),
|
||||
'permissions': permissions
|
||||
})
|
||||
|
||||
@products_bp.route('/css/<path:filename>')
|
||||
def serve_css(filename):
|
||||
"""Serve CSS files"""
|
||||
@@ -48,3 +73,12 @@ def serve_images(filename):
|
||||
def serve_data(filename):
|
||||
"""Serve data files (JSON)"""
|
||||
return send_from_directory(os.path.join(basedir, 'data'), filename)
|
||||
|
||||
@products_bp.route('/product/<product_code>')
|
||||
@login_required
|
||||
def product_detail(product_code):
|
||||
"""
|
||||
Serve product detail page with URL-based navigation
|
||||
This allows direct linking to products via /quiz/product/404
|
||||
"""
|
||||
return render_template('product_finder.html', initial_product=product_code)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Create simple test placeholder images for Canvas API testing
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
def create_placeholder_image(path, text, color, size=(800, 600)):
|
||||
"""Create a simple colored placeholder image with text"""
|
||||
# Create image with solid color
|
||||
img = Image.new('RGB', size, color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Add text
|
||||
try:
|
||||
# Try to use a default font
|
||||
font = ImageFont.truetype("arial.ttf", 48)
|
||||
except:
|
||||
# Fallback to default font
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Get text bounding box for centering
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
position = ((size[0] - text_width) // 2, (size[1] - text_height) // 2)
|
||||
|
||||
# Draw text with outline for visibility
|
||||
outline_color = 'white' if sum(color) < 400 else 'black'
|
||||
for offset_x in [-2, 0, 2]:
|
||||
for offset_y in [-2, 0, 2]:
|
||||
draw.text((position[0] + offset_x, position[1] + offset_y),
|
||||
text, font=font, fill=outline_color)
|
||||
|
||||
text_color = 'black' if sum(color) > 400 else 'white'
|
||||
draw.text(position, text, font=font, fill=text_color)
|
||||
|
||||
# Save image
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
img.save(path)
|
||||
print(f"✓ Created: {path}")
|
||||
|
||||
# Base directory
|
||||
base_dir = 'static/images'
|
||||
|
||||
# Create product-specific images for 404
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/404/door-white.png',
|
||||
'404\nDoor - White',
|
||||
(240, 240, 240) # Light gray
|
||||
)
|
||||
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/404/door-black.png',
|
||||
'404\nDoor - Black',
|
||||
(40, 40, 40) # Dark gray
|
||||
)
|
||||
|
||||
# Create shared images for storm windows
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/layers/base.jpg',
|
||||
'Storm Window\nBase Layer\n(Shared)',
|
||||
(200, 220, 240) # Light blue
|
||||
)
|
||||
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/layers/hardware.png',
|
||||
'Storm Window\nHardware\n(Shared)',
|
||||
(180, 180, 180) # Silver
|
||||
)
|
||||
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/window/storm-window/layers/foreground.png',
|
||||
'Storm Window\nForeground\n(Shared Plants)',
|
||||
(100, 180, 100) # Green
|
||||
)
|
||||
|
||||
# Create global fallback images
|
||||
create_placeholder_image(
|
||||
f'{base_dir}/layers/base.jpg',
|
||||
'Global\nBase Layer\n(Fallback)',
|
||||
(220, 200, 180) # Beige
|
||||
)
|
||||
|
||||
print("\n✅ All test images created successfully!")
|
||||
print("\nNow test the Canvas API:")
|
||||
print(" http://localhost:8080/api/canvas/404/base")
|
||||
print(" http://localhost:8080/api/canvas/404/door?color=white")
|
||||
print(" http://localhost:8080/api/canvas/404/hardware")
|
||||
print(" http://localhost:8080/api/canvas/404/foreground")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Create visual test images for the Canvas API layered system.
|
||||
Demonstrates base layer, door layer (with colors), hardware layer, and foreground layer.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
# Canvas dimensions - all layers use the same size
|
||||
CANVAS_WIDTH = 800
|
||||
CANVAS_HEIGHT = 600
|
||||
|
||||
# Output directory
|
||||
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'static', 'images')
|
||||
|
||||
def create_base_layer():
|
||||
"""Create base layer - a simple house background"""
|
||||
img = Image.new('RGB', (CANVAS_WIDTH, CANVAS_HEIGHT), color='#87CEEB') # Sky blue
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw grass at bottom
|
||||
draw.rectangle([(0, 450), (CANVAS_WIDTH, CANVAS_HEIGHT)], fill='#90EE90') # Light green
|
||||
|
||||
# Draw house body (tan/beige)
|
||||
house_left = 150
|
||||
house_right = 650
|
||||
house_top = 200
|
||||
house_bottom = 500
|
||||
draw.rectangle([(house_left, house_top), (house_right, house_bottom)], fill='#D2B48C') # Tan
|
||||
|
||||
# Draw roof (dark brown triangle)
|
||||
roof_points = [
|
||||
(400, 100), # Top point
|
||||
(house_left - 30, house_top), # Left bottom
|
||||
(house_right + 30, house_top) # Right bottom
|
||||
]
|
||||
draw.polygon(roof_points, fill='#654321') # Dark brown
|
||||
|
||||
# Draw window openings (lighter rectangles where door/window will go)
|
||||
# Door opening in center
|
||||
door_left = 325
|
||||
door_right = 475
|
||||
door_top = 280
|
||||
door_bottom = 480
|
||||
draw.rectangle([(door_left, door_top), (door_right, door_bottom)], fill='#C9B896') # Lighter tan
|
||||
|
||||
# Add some house details (window on left side)
|
||||
draw.rectangle([(200, 280), (280, 360)], fill='#87CEEB') # Window
|
||||
draw.rectangle([(238, 280), (242, 360)], fill='#654321') # Window divider vertical
|
||||
draw.rectangle([(200, 318), (280, 322)], fill='#654321') # Window divider horizontal
|
||||
|
||||
# Window on right side
|
||||
draw.rectangle([(520, 280), (600, 360)], fill='#87CEEB') # Window
|
||||
draw.rectangle([(558, 280), (562, 360)], fill='#654321') # Window divider vertical
|
||||
draw.rectangle([(520, 318), (600, 322)], fill='#654321') # Window divider horizontal
|
||||
|
||||
return img
|
||||
|
||||
def create_door_layer(color_name, color_hex):
|
||||
"""Create door layer with transparency - colored rectangle positioned in door opening"""
|
||||
img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Door dimensions - positioned where the door opening is on the house
|
||||
door_left = 330
|
||||
door_right = 470
|
||||
door_top = 285
|
||||
door_bottom = 475
|
||||
|
||||
# Draw door
|
||||
draw.rectangle([(door_left, door_top), (door_right, door_bottom)], fill=color_hex)
|
||||
|
||||
# Draw door panels (decorative)
|
||||
panel_margin = 15
|
||||
panel_gap = 10
|
||||
|
||||
# Top panel
|
||||
draw.rectangle([
|
||||
(door_left + panel_margin, door_top + panel_margin),
|
||||
(door_right - panel_margin, door_top + panel_margin + 80)
|
||||
], outline='#000000', width=3)
|
||||
|
||||
# Bottom panel
|
||||
draw.rectangle([
|
||||
(door_left + panel_margin, door_top + panel_margin + 80 + panel_gap),
|
||||
(door_right - panel_margin, door_bottom - panel_margin)
|
||||
], outline='#000000', width=3)
|
||||
|
||||
# Door handle position marker (small circle to show where handle will go)
|
||||
# On the right side for left-hinge door
|
||||
handle_x = door_right - 40
|
||||
handle_y = door_top + (door_bottom - door_top) // 2
|
||||
draw.ellipse([
|
||||
(handle_x - 8, handle_y - 8),
|
||||
(handle_x + 8, handle_y + 8)
|
||||
], fill='#000000')
|
||||
|
||||
return img
|
||||
|
||||
def create_hardware_layer():
|
||||
"""Create hardware layer with transparency - gold circle for door handle"""
|
||||
img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Door handle position - matches the marker on the door
|
||||
# Right side for left-hinge door
|
||||
handle_x = 430 # door_right (470) - 40
|
||||
handle_y = 380 # middle of door
|
||||
|
||||
# Draw decorative backplate (bronze/gold rectangle)
|
||||
plate_width = 30
|
||||
plate_height = 80
|
||||
draw.rectangle([
|
||||
(handle_x - plate_width//2, handle_y - plate_height//2),
|
||||
(handle_x + plate_width//2, handle_y + plate_height//2)
|
||||
], fill='#B8860B') # Dark goldenrod
|
||||
|
||||
# Draw handle (gold circle)
|
||||
handle_radius = 20
|
||||
draw.ellipse([
|
||||
(handle_x - handle_radius, handle_y - handle_radius),
|
||||
(handle_x + handle_radius, handle_y + handle_radius)
|
||||
], fill='#FFD700') # Gold
|
||||
|
||||
# Add shine/highlight
|
||||
highlight_offset = 6
|
||||
draw.ellipse([
|
||||
(handle_x - highlight_offset, handle_y - highlight_offset),
|
||||
(handle_x - highlight_offset + 8, handle_y - highlight_offset + 8)
|
||||
], fill='#FFFFE0') # Light yellow highlight
|
||||
|
||||
return img
|
||||
|
||||
def create_foreground_layer():
|
||||
"""Create foreground layer with transparency - plants at bottom"""
|
||||
img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw plants/bushes at the bottom corners
|
||||
# Left plant
|
||||
left_plant_x = 120
|
||||
plant_y = 480
|
||||
for i in range(3):
|
||||
offset_x = i * 25
|
||||
offset_y = i * 10
|
||||
# Draw leaves (green circles)
|
||||
draw.ellipse([
|
||||
(left_plant_x + offset_x - 30, plant_y + offset_y - 30),
|
||||
(left_plant_x + offset_x + 30, plant_y + offset_y + 30)
|
||||
], fill='#228B22') # Forest green
|
||||
|
||||
# Right plant
|
||||
right_plant_x = 680
|
||||
for i in range(3):
|
||||
offset_x = -i * 25
|
||||
offset_y = i * 10
|
||||
# Draw leaves (green circles)
|
||||
draw.ellipse([
|
||||
(right_plant_x + offset_x - 30, plant_y + offset_y - 30),
|
||||
(right_plant_x + offset_x + 30, plant_y + offset_y + 30)
|
||||
], fill='#228B22') # Forest green
|
||||
|
||||
# Add some decorative flowers
|
||||
flower_positions = [
|
||||
(100, 520), (140, 510), (160, 530), # Left side
|
||||
(640, 530), (660, 510), (700, 520) # Right side
|
||||
]
|
||||
|
||||
for fx, fy in flower_positions:
|
||||
# Flower petals (pink circles)
|
||||
petal_radius = 8
|
||||
for angle in range(0, 360, 72): # 5 petals
|
||||
import math
|
||||
px = fx + int(12 * math.cos(math.radians(angle)))
|
||||
py = fy + int(12 * math.sin(math.radians(angle)))
|
||||
draw.ellipse([
|
||||
(px - petal_radius, py - petal_radius),
|
||||
(px + petal_radius, py + petal_radius)
|
||||
], fill='#FFB6C1') # Light pink
|
||||
|
||||
# Flower center (yellow circle)
|
||||
center_radius = 6
|
||||
draw.ellipse([
|
||||
(fx - center_radius, fy - center_radius),
|
||||
(fx + center_radius, fy + center_radius)
|
||||
], fill='#FFD700') # Gold
|
||||
|
||||
return img
|
||||
|
||||
def main():
|
||||
print("Creating visual test images for Canvas API...")
|
||||
|
||||
# Define paths
|
||||
base_path = os.path.join(OUTPUT_DIR, 'window', 'storm-window', '404')
|
||||
layers_path = os.path.join(OUTPUT_DIR, 'window', 'storm-window', 'layers')
|
||||
|
||||
# Create directories
|
||||
os.makedirs(base_path, exist_ok=True)
|
||||
os.makedirs(layers_path, exist_ok=True)
|
||||
|
||||
# Create base layer
|
||||
print("Creating base layer (house)...")
|
||||
base_img = create_base_layer()
|
||||
base_img.save(os.path.join(layers_path, 'base.jpg'), quality=90)
|
||||
print(f" ✓ Saved: {layers_path}/base.jpg")
|
||||
|
||||
# Create door layers in various colors
|
||||
door_colors = {
|
||||
'white': '#FFFFFF',
|
||||
'black': '#2C2C2C',
|
||||
'bronze': '#8B6914',
|
||||
'tan': '#D2B48C'
|
||||
}
|
||||
|
||||
print("\nCreating door layers...")
|
||||
for color_name, color_hex in door_colors.items():
|
||||
door_img = create_door_layer(color_name, color_hex)
|
||||
door_img.save(os.path.join(base_path, f'door-{color_name}.png'))
|
||||
print(f" ✓ Saved: {base_path}/door-{color_name}.png")
|
||||
|
||||
# Create hardware layer
|
||||
print("\nCreating hardware layer (gold handle)...")
|
||||
hardware_img = create_hardware_layer()
|
||||
hardware_img.save(os.path.join(layers_path, 'hardware.png'))
|
||||
print(f" ✓ Saved: {layers_path}/hardware.png")
|
||||
|
||||
# Create foreground layer
|
||||
print("\nCreating foreground layer (plants)...")
|
||||
foreground_img = create_foreground_layer()
|
||||
foreground_img.save(os.path.join(layers_path, 'foreground.png'))
|
||||
print(f" ✓ Saved: {layers_path}/foreground.png")
|
||||
|
||||
print("\n✅ All images created successfully!")
|
||||
print("\nTest URLs:")
|
||||
print(" Left-hinge: http://localhost:8080/quiz/product/404")
|
||||
print(" Right-hinge: http://localhost:8080/quiz/product/404R")
|
||||
print("\nThe right-hinge version will flip the door and hardware layers horizontally.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"code": "IOLA",
|
||||
"name": "Iola"
|
||||
},
|
||||
{
|
||||
"code": "LINDS",
|
||||
"name": "Lindsborg"
|
||||
},
|
||||
{
|
||||
"code": "KC",
|
||||
"name": "Kansas City"
|
||||
},
|
||||
{
|
||||
"code": "BMD",
|
||||
"name": "BMD"
|
||||
}
|
||||
],
|
||||
"statuses": [
|
||||
{
|
||||
"value": "default",
|
||||
"label": "Default"
|
||||
},
|
||||
{
|
||||
"value": "discontinued",
|
||||
"label": "Discontinued"
|
||||
},
|
||||
{
|
||||
"value": "hidden",
|
||||
"label": "Hide"
|
||||
}
|
||||
],
|
||||
"productTypes": [
|
||||
{
|
||||
"id": "storm_door",
|
||||
"type": "Storm Door",
|
||||
"category": "STORMS",
|
||||
"baseType": "Door",
|
||||
"subType": "Storm Door",
|
||||
"availableMaterials": ["Aluminum", "Steel"],
|
||||
"availableColors": ["White", "Bronze", "Black", "Sandstone"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "storm_window",
|
||||
"type": "Storm Window",
|
||||
"category": "STORMS",
|
||||
"baseType": "Window",
|
||||
"subType": "Storm Window",
|
||||
"availableMaterials": ["Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Black", "Sandstone"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "primary_window",
|
||||
"type": "Primary Window",
|
||||
"category": "SHPT",
|
||||
"baseType": "Window",
|
||||
"subType": "Primary Window",
|
||||
"availableMaterials": ["Vinyl", "Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Mill"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "sliding_window",
|
||||
"type": "Sliding Window",
|
||||
"category": "SHPW",
|
||||
"baseType": "Window",
|
||||
"subType": "Sliding Window",
|
||||
"availableMaterials": ["Vinyl", "Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Mill"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "fixed_lite",
|
||||
"type": "Fixed Lite",
|
||||
"category": "FPPW",
|
||||
"baseType": "Window",
|
||||
"subType": "Fixed Lite",
|
||||
"availableMaterials": ["Vinyl", "Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Mill"],
|
||||
"additionalOptions": []
|
||||
},
|
||||
{
|
||||
"id": "casement",
|
||||
"type": "Casement",
|
||||
"category": "CASEMNT",
|
||||
"baseType": "Window",
|
||||
"subType": "Casement",
|
||||
"availableMaterials": ["Aluminum"],
|
||||
"availableColors": ["White", "Bronze"],
|
||||
"additionalOptions": ["1-Panel", "2-Panel", "3-Panel"]
|
||||
},
|
||||
{
|
||||
"id": "double_hung",
|
||||
"type": "Double Hung",
|
||||
"category": "3000DHP",
|
||||
"baseType": "Window",
|
||||
"subType": "Double Hung",
|
||||
"availableMaterials": ["Aluminum"],
|
||||
"availableColors": ["White", "Bronze"],
|
||||
"additionalOptions": ["Thermal Break", "Standard"]
|
||||
},
|
||||
{
|
||||
"id": "screen_door",
|
||||
"type": "Screen Door",
|
||||
"category": "SCREENS",
|
||||
"baseType": "Door",
|
||||
"subType": "Screen Door",
|
||||
"availableMaterials": ["Aluminum"],
|
||||
"availableColors": ["White", "Bronze", "Black"],
|
||||
"additionalOptions": ["Self-Storing", "Fixed"]
|
||||
}
|
||||
],
|
||||
"allMaterials": [
|
||||
"Aluminum",
|
||||
"Vinyl",
|
||||
"Steel",
|
||||
"Wood",
|
||||
"Fiberglass"
|
||||
],
|
||||
"allColors": [
|
||||
"White",
|
||||
"Black",
|
||||
"Bronze",
|
||||
"Sandstone",
|
||||
"Mill",
|
||||
"Almond",
|
||||
"Clay",
|
||||
"Gray"
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"code": "STORMS",
|
||||
"label": "Storm Products"
|
||||
},
|
||||
{
|
||||
"code": "SHPW",
|
||||
"label": "Single Hung Primary Windows"
|
||||
},
|
||||
{
|
||||
"code": "SHPT",
|
||||
"label": "Single Hung Primary Tilt"
|
||||
},
|
||||
{
|
||||
"code": "FPPW",
|
||||
"label": "Fixed Picture Primary Windows"
|
||||
},
|
||||
{
|
||||
"code": "CASEMNT",
|
||||
"label": "Casement Windows"
|
||||
},
|
||||
{
|
||||
"code": "3000DHP",
|
||||
"label": "3000 Series Double Hung Primary"
|
||||
},
|
||||
{
|
||||
"code": "3000FPP",
|
||||
"label": "3000 Series Fixed Picture Primary"
|
||||
},
|
||||
{
|
||||
"code": "SCREENS",
|
||||
"label": "Screen Doors"
|
||||
}
|
||||
],
|
||||
"codeModifiers": {
|
||||
"prefixes": [
|
||||
{
|
||||
"id": "replacement",
|
||||
"label": "Replacement (R-)",
|
||||
"value": "R",
|
||||
"appliesTo": ["Window"],
|
||||
"description": "For replacement windows"
|
||||
},
|
||||
{
|
||||
"id": "new_construction",
|
||||
"label": "New Construction (N-)",
|
||||
"value": "N",
|
||||
"appliesTo": ["Window"],
|
||||
"description": "For new construction windows"
|
||||
}
|
||||
],
|
||||
"suffixes": [
|
||||
{
|
||||
"id": "kickplate",
|
||||
"label": "Kick Plate (KP)",
|
||||
"value": "KP",
|
||||
"appliesTo": ["Door"],
|
||||
"description": "Add kick plate to door"
|
||||
},
|
||||
{
|
||||
"id": "vent",
|
||||
"label": "Vent (V)",
|
||||
"value": "V",
|
||||
"appliesTo": ["Door"],
|
||||
"description": "Add vent to door"
|
||||
},
|
||||
{
|
||||
"id": "screen",
|
||||
"label": "Screen (SCR)",
|
||||
"value": "SCR",
|
||||
"appliesTo": ["Door", "Window"],
|
||||
"description": "Include screen"
|
||||
},
|
||||
{
|
||||
"id": "tempered",
|
||||
"label": "Tempered Glass (T)",
|
||||
"value": "T",
|
||||
"appliesTo": ["Door", "Window"],
|
||||
"description": "Tempered glass option"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,41 @@
|
||||
"Sandstone"
|
||||
],
|
||||
"isAccessory": false,
|
||||
"compatibleAccessories": []
|
||||
"compatibleAccessories": [],
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "404R",
|
||||
"productCode": "404R",
|
||||
"category": "STORMS",
|
||||
"description": "#404 FALCON STORM WINDOWS (RIGHT-HINGE)",
|
||||
"discontinued": false,
|
||||
"location": "Iola",
|
||||
"baseType": "Window",
|
||||
"subType": {
|
||||
"door": null,
|
||||
"window": "Storm Window"
|
||||
},
|
||||
"materials": [
|
||||
"Aluminum"
|
||||
],
|
||||
"colors": [
|
||||
"Black",
|
||||
"White",
|
||||
"Bronze",
|
||||
"Sandstone"
|
||||
],
|
||||
"isAccessory": false,
|
||||
"compatibleAccessories": [],
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true,
|
||||
"layerTransforms": {
|
||||
"door": "flip-horizontal",
|
||||
"hardware": "flip-horizontal"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "450",
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"password": "pbkdf2:sha256:1000000$tPRmseRGKLveXTxS$87eda9a20db29d12838fcb607f1f3536141f743ec9879702dc2a0dd893b4078d",
|
||||
"defaultLocation": "KC",
|
||||
"permissions": {
|
||||
"manage_users": true
|
||||
"manage_users": true,
|
||||
"manage_products": true
|
||||
},
|
||||
"locationSettings": {
|
||||
"LINDS": {
|
||||
@@ -39,6 +40,7 @@
|
||||
},
|
||||
"permissions": {
|
||||
"manage_users": true,
|
||||
"manage_products": true,
|
||||
"create_quotes": true
|
||||
},
|
||||
"active": true
|
||||
|
||||
@@ -300,6 +300,24 @@ body {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.layer-foreground {
|
||||
z-index: 5;
|
||||
pointer-events: none; /* Allow clicks to pass through to controls below */
|
||||
}
|
||||
|
||||
/* Layer transform modifiers */
|
||||
.layer-flip-horizontal {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.layer-flip-vertical {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
|
||||
.layer-flip-both {
|
||||
transform: scale(-1, -1);
|
||||
}
|
||||
|
||||
.config-preview-notice {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -106,7 +106,13 @@ let history = ['start'];
|
||||
function initFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// Priority 1: Direct product view
|
||||
// Priority 0: Direct product from Flask route (e.g., /quiz/product/404)
|
||||
if (window.INITIAL_PRODUCT && window.INITIAL_PRODUCT !== '') {
|
||||
showProductByCode(window.INITIAL_PRODUCT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 1: Direct product view from query param
|
||||
const productCode = params.get('p');
|
||||
if (productCode) {
|
||||
accumulatedBitValue = parseInt(params.get('b') || '0', 10);
|
||||
@@ -705,6 +711,7 @@ function showProductDetail(product) {
|
||||
|
||||
// Get product image configuration
|
||||
const productImage = product.image || `https://via.placeholder.com/400x400/e0e0e0/666?text=${encodeURIComponent(prodCode)}`;
|
||||
const useCanvasAPI = product.imageConfig && product.imageConfig.useCanvasAPI === true;
|
||||
const hasLayeredImages = product.imageConfig && product.imageConfig.layered === true;
|
||||
const hasFlatImages = product.flatImages === true || product.imagePattern;
|
||||
const useDynamicAPI = product.useDynamicAPI === true;
|
||||
@@ -795,20 +802,21 @@ function showProductDetail(product) {
|
||||
|
||||
const contentDiv = document.getElementById('content');
|
||||
|
||||
// Build the image display (layered, flat, or dynamic API)
|
||||
// Build the image display (Canvas API, layered, flat, or dynamic API)
|
||||
let imageDisplayHtml = '';
|
||||
|
||||
if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
|
||||
// Use layered image system
|
||||
const basePath = product.imageConfig.basePath || '';
|
||||
const layers = product.imageConfig.layers || {};
|
||||
if (useCanvasAPI || (hasLayeredImages && !hasFlatImages && !useDynamicAPI)) {
|
||||
// Use layered image system (Canvas API or static paths)
|
||||
const basePath = product.imageConfig?.basePath || '';
|
||||
const layers = product.imageConfig?.layers || {};
|
||||
|
||||
imageDisplayHtml = `
|
||||
<div class="door-configurator" id="door-configurator">
|
||||
${layers.base ? `<img src="${basePath}${layers.base}" class="layer-base" alt="Base">` : ''}
|
||||
<img src="" class="layer-base" alt="Base">
|
||||
<img src="" class="layer-door" id="door-layer" alt="Door" style="display:none;">
|
||||
${layers.hardware ? `<img src="${basePath}${layers.hardware}" class="layer-hardware" id="hardware-layer" alt="Hardware">` : ''}
|
||||
<img src="" class="layer-hardware" id="hardware-layer" alt="Hardware" style="display:none;">
|
||||
<img src="" class="layer-overlay" id="overlay-layer" alt="View" style="display:none;">
|
||||
<img src="" class="layer-foreground" alt="Foreground" style="display:none;">
|
||||
<div class="config-preview-notice" id="config-notice" style="display:none;">
|
||||
⚠️ Preview not available for this configuration
|
||||
</div>
|
||||
@@ -958,7 +966,11 @@ function showProductDetail(product) {
|
||||
updateBreadcrumb();
|
||||
|
||||
// Initialize images based on type
|
||||
if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
|
||||
if (useCanvasAPI) {
|
||||
// Use Canvas API with hierarchical fallback
|
||||
updateCanvasAPIPreview(prodCode);
|
||||
} else if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
|
||||
// Use static layered images
|
||||
updateProductPreview(prodCode);
|
||||
} else if (hasFlatImages || useDynamicAPI) {
|
||||
// Flat images or dynamic API - will update on dropdown change
|
||||
@@ -969,18 +981,138 @@ function showProductDetail(product) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Canvas API Functions - Hierarchical Image Fallback System
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build Canvas API URL for a specific layer
|
||||
* @param {string} productCode - Product code
|
||||
* @param {string} layer - Layer name (base, door, hardware, overlay, foreground)
|
||||
* @param {string} color - Optional color
|
||||
* @param {string} material - Optional material
|
||||
* @returns {string} Canvas API URL
|
||||
*/
|
||||
function buildCanvasAPIUrl(productCode, layer, color = null, material = null) {
|
||||
let url = `${BASE_URL}/api/canvas/${productCode}/${layer}`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (color) params.append('color', color.toLowerCase());
|
||||
if (material) params.append('material', material.toLowerCase());
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${url}?${queryString}` : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update layered image preview using Canvas API
|
||||
* @param {string} productCode - Product code
|
||||
*/
|
||||
function updateCanvasAPIPreview(productCode) {
|
||||
const product = productData.find(p => p.productCode === productCode || p.id === productCode);
|
||||
if (!product) return;
|
||||
|
||||
// Get selected values
|
||||
const selectedColor = document.getElementById('config-color')?.value || '';
|
||||
const selectedMaterial = document.getElementById('config-material')?.value || '';
|
||||
|
||||
// Get layer transform configuration (for flipping, etc.)
|
||||
const layerTransforms = product.imageConfig?.layerTransforms || {};
|
||||
|
||||
// Helper function to apply transform classes
|
||||
function applyTransform(element, layerName) {
|
||||
if (!element) return;
|
||||
// Remove any existing transform classes
|
||||
element.classList.remove('layer-flip-horizontal', 'layer-flip-vertical', 'layer-flip-both');
|
||||
// Apply new transform if configured
|
||||
const transform = layerTransforms[layerName];
|
||||
if (transform) {
|
||||
element.classList.add(`layer-${transform}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update each layer
|
||||
const baseLayer = document.querySelector('.layer-base');
|
||||
const doorLayer = document.getElementById('door-layer');
|
||||
const hardwareLayer = document.getElementById('hardware-layer');
|
||||
const overlayLayer = document.getElementById('overlay-layer');
|
||||
const foregroundLayer = document.querySelector('.layer-foreground');
|
||||
|
||||
// Base layer (no color variation)
|
||||
if (baseLayer && baseLayer.tagName === 'IMG') {
|
||||
baseLayer.src = buildCanvasAPIUrl(productCode, 'base');
|
||||
applyTransform(baseLayer, 'base');
|
||||
baseLayer.onerror = function() {
|
||||
console.warn('Base layer not found via Canvas API');
|
||||
};
|
||||
}
|
||||
|
||||
// Door layer (with color)
|
||||
if (doorLayer) {
|
||||
applyTransform(doorLayer, 'door');
|
||||
if (selectedColor) {
|
||||
doorLayer.src = buildCanvasAPIUrl(productCode, 'door', selectedColor);
|
||||
doorLayer.style.display = 'block';
|
||||
doorLayer.onerror = function() {
|
||||
console.warn(`Door layer not found for color: ${selectedColor}`);
|
||||
this.style.display = 'none';
|
||||
showConfigNotice(true);
|
||||
};
|
||||
doorLayer.onload = function() {
|
||||
showConfigNotice(false);
|
||||
};
|
||||
} else {
|
||||
doorLayer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Hardware layer
|
||||
if (hardwareLayer) {
|
||||
hardwareLayer.src = buildCanvasAPIUrl(productCode, 'hardware');
|
||||
applyTransform(hardwareLayer, 'hardware');
|
||||
hardwareLayer.onerror = function() {
|
||||
console.warn('Hardware layer not found via Canvas API');
|
||||
this.style.display = 'none';
|
||||
};
|
||||
hardwareLayer.onload = function() {
|
||||
this.style.display = 'block';
|
||||
};
|
||||
}
|
||||
|
||||
// Foreground layer
|
||||
if (foregroundLayer && foregroundLayer.tagName === 'IMG') {
|
||||
foregroundLayer.src = buildCanvasAPIUrl(productCode, 'foreground');
|
||||
applyTransform(foregroundLayer, 'foreground');
|
||||
foregroundLayer.onerror = function() {
|
||||
console.warn('Foreground layer not found via Canvas API');
|
||||
// This is fine - foreground is optional
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Update product preview based on configuration
|
||||
// ============================================================================
|
||||
|
||||
function updateProductPreview(productCode) {
|
||||
console.log('updateProductPreview called with:', productCode);
|
||||
|
||||
const product = productData.find(p => p.productCode === productCode || p.id === productCode);
|
||||
if (!product) return;
|
||||
|
||||
// Check which system to use
|
||||
// Check which system to use - Canvas API has priority
|
||||
const useCanvasAPI = product.imageConfig && product.imageConfig.useCanvasAPI === true;
|
||||
const hasFlatImages = product.flatImages === true || product.imagePattern;
|
||||
const useDynamicAPI = product.useDynamicAPI === true;
|
||||
|
||||
console.log('Product flags - hasFlatImages:', hasFlatImages, 'useDynamicAPI:', useDynamicAPI);
|
||||
console.log('Product flags - useCanvasAPI:', useCanvasAPI, 'hasFlatImages:', hasFlatImages, 'useDynamicAPI:', useDynamicAPI);
|
||||
|
||||
// Priority: Canvas API > Flat Images > Dynamic API > Layered Static
|
||||
if (useCanvasAPI) {
|
||||
// Use Canvas API with hierarchical fallback
|
||||
updateCanvasAPIPreview(productCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFlatImages) {
|
||||
// Use pre-rendered flat images
|
||||
@@ -1810,6 +1942,7 @@ startOver = function() {
|
||||
|
||||
// Initialize search bar when page loads
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
init(); // Load quiz data and initialize
|
||||
initSearchBar();
|
||||
toggleSearchBar();
|
||||
});
|
||||
|
||||
@@ -77,8 +77,16 @@
|
||||
locationDisplay = `📍 ${currentLoc}`;
|
||||
}
|
||||
|
||||
let manageProductsLink = '';
|
||||
if (data.permissions && data.permissions.manage_products) {
|
||||
manageProductsLink = ` | <a href="{{ url_for('products.product_manager') }}" style="color: #6a4c93; text-decoration: none; font-weight: 500; cursor: pointer;"
|
||||
onmouseover="this.style.textDecoration='underline'"
|
||||
onmouseout="this.style.textDecoration='none'"
|
||||
title="Manage Products">Manage Products</a>`;
|
||||
}
|
||||
|
||||
document.getElementById('userLocationInfo').innerHTML =
|
||||
`👤 ${data.username} | ${locationDisplay}`;
|
||||
`👤 ${data.username} | ${locationDisplay}${manageProductsLink}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user info:', error);
|
||||
@@ -93,6 +101,8 @@
|
||||
window.APP_BASE_URL = '';
|
||||
// Set data path for the quiz
|
||||
window.DATA_PATH = '{{ url_for("products.serve_data", filename="") }}';
|
||||
// Set initial product if provided via URL
|
||||
window.INITIAL_PRODUCT = '{{ initial_product or "" }}';
|
||||
</script>
|
||||
<script src="{{ url_for('products.serve_js', filename='script.js') }}"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Product Manager - CGW</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 30px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
label .optional {
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.note {
|
||||
background: #fff9e6;
|
||||
border-left: 4px solid #ffd700;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.product-codes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.type-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.radio-option input[type="radio"] {
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-option label {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.dynamic-section {
|
||||
background: #f9f9f9;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dynamic-section.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 30px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #e84052;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
margin-right: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.status-default {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.status-discontinued {
|
||||
background: #ff4757;
|
||||
}
|
||||
|
||||
.status-hidden {
|
||||
background: #ffa502;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.type-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-codes-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.product-codes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav-links">
|
||||
<a href="/">← Back to Home</a>
|
||||
<a href="/quiz/">Product Finder</a>
|
||||
</div>
|
||||
|
||||
<h1>Product Manager</h1>
|
||||
<p class="subtitle">Add or edit product information</p>
|
||||
|
||||
<form id="productForm">
|
||||
<!-- Product Location & Description -->
|
||||
<div class="form-section">
|
||||
<div class="form-group">
|
||||
<label for="location">Product Location:</label>
|
||||
<select id="location" name="location" required>
|
||||
<option value="">Select location...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" placeholder="Enter product description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="default">Default</option>
|
||||
<option value="discontinued">Discontinued</option>
|
||||
<option value="hidden">Hide</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Codes -->
|
||||
<div class="form-section">
|
||||
<div class="section-title">Product Codes</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="standardCode">Standardized Code <span class="optional">(auto-generated from selections below)</span></label>
|
||||
<input type="text" id="standardCode" name="standardCode" placeholder="Select type and modifiers to generate" readonly style="background-color: #f5f5f5;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="baseCode">Base Code <span class="optional">(optional - e.g., 2000, 3000)</span></label>
|
||||
<input type="text" id="baseCode" name="baseCode" placeholder="Enter base product code">
|
||||
</div>
|
||||
|
||||
<div class="product-codes-grid" id="locationCodesContainer">
|
||||
<!-- Dynamic location code fields will be inserted here based on user's accessible locations -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Type -->
|
||||
<div class="form-section">
|
||||
<div class="section-title">Product Type</div>
|
||||
|
||||
<div class="type-group">
|
||||
<div class="form-group">
|
||||
<label for="productType">Type</label>
|
||||
<select id="productType" name="productType">
|
||||
<option value="">Select type...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="productGroup">Group [Category]</label>
|
||||
<input type="text" id="productGroup" name="productGroup" placeholder="" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code Prefixes -->
|
||||
<div class="form-section dynamic-section hidden" id="prefixesSection">
|
||||
<div class="section-title">Code Prefixes</div>
|
||||
<div class="radio-group" id="prefixesContainer">
|
||||
<!-- Dynamic prefix options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code Suffixes -->
|
||||
<div class="form-section dynamic-section hidden" id="suffixesSection">
|
||||
<div class="section-title">Code Suffixes</div>
|
||||
<div style="margin-bottom: 10px; font-size: 13px; color: #666;">Select all that apply (multiple selections allowed)</div>
|
||||
<div class="radio-group" id="suffixesContainer">
|
||||
<!-- Dynamic suffix options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Materials -->
|
||||
<div class="form-section dynamic-section hidden" id="materialsSection">
|
||||
<div class="section-title">Materials</div>
|
||||
<div class="radio-group" id="materialsContainer">
|
||||
<!-- Dynamic material options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colors -->
|
||||
<div class="form-section dynamic-section hidden" id="colorsSection">
|
||||
<div class="section-title">Colors</div>
|
||||
<div class="radio-group" id="colorsContainer">
|
||||
<!-- Dynamic color options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Options -->
|
||||
<div class="form-section dynamic-section hidden" id="additionalOptionsSection">
|
||||
<div class="section-title">Additional Options</div>
|
||||
<div class="radio-group" id="additionalOptionsContainer">
|
||||
<!-- Dynamic additional options will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="button-group">
|
||||
<button type="submit" class="btn-primary">Save Product</button>
|
||||
<button type="button" class="btn-secondary" onclick="resetForm()">Clear Form</button>
|
||||
<button type="button" class="btn-danger" onclick="deleteProduct()">Delete Product</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let productAttributes = null;
|
||||
let userAccessibleLocations = [];
|
||||
|
||||
// Load product attributes and user locations
|
||||
async function loadProductAttributes() {
|
||||
try {
|
||||
const response = await fetch('/quiz/data/product_attributes.json');
|
||||
productAttributes = await response.json();
|
||||
|
||||
// Get user's accessible locations from session
|
||||
await fetchUserLocations();
|
||||
|
||||
initializeForm();
|
||||
} catch (error) {
|
||||
console.error('Error loading product attributes:', error);
|
||||
alert('Failed to load product attributes');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user's accessible locations from session
|
||||
async function fetchUserLocations() {
|
||||
try {
|
||||
const response = await fetch('/quiz/api/user-session');
|
||||
const userData = await response.json();
|
||||
userAccessibleLocations = userData.accessibleLocations || [];
|
||||
|
||||
// If user has no accessible locations, default to their current location
|
||||
if (userAccessibleLocations.length === 0 && userData.currentLocation) {
|
||||
userAccessibleLocations = [userData.currentLocation];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user locations:', error);
|
||||
// Fallback to all location codes if API fails
|
||||
userAccessibleLocations = productAttributes.locations.map(loc => loc.code);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize form with data
|
||||
function initializeForm() {
|
||||
// Populate locations based on user's access
|
||||
// Match user's accessible location codes with full location details
|
||||
const locationSelect = document.getElementById('location');
|
||||
|
||||
productAttributes.locations.forEach(loc => {
|
||||
// Check if user has access to this location code
|
||||
if (userAccessibleLocations.includes(loc.code)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = loc.code;
|
||||
option.textContent = loc.name;
|
||||
locationSelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize location code fields with user's accessible locations
|
||||
updateLocationCodes();
|
||||
|
||||
// Populate product types
|
||||
const typeSelect = document.getElementById('productType');
|
||||
productAttributes.productTypes.forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type.id;
|
||||
option.textContent = type.type;
|
||||
option.dataset.category = type.category;
|
||||
option.dataset.basetype = type.baseType;
|
||||
option.dataset.materials = JSON.stringify(type.availableMaterials);
|
||||
option.dataset.colors = JSON.stringify(type.availableColors);
|
||||
option.dataset.options = JSON.stringify(type.additionalOptions);
|
||||
typeSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Setup event listeners
|
||||
document.getElementById('productType').addEventListener('change', updateDynamicSections);
|
||||
document.getElementById('baseCode').addEventListener('input', updateStandardizedCode);
|
||||
}
|
||||
|
||||
// Update location code fields based on user's accessible locations
|
||||
function updateLocationCodes() {
|
||||
const container = document.getElementById('locationCodesContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Get full location details for each accessible code
|
||||
userAccessibleLocations.forEach(locCode => {
|
||||
const locationDetails = productAttributes.locations.find(loc => loc.code === locCode);
|
||||
if (locationDetails) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'form-group';
|
||||
div.innerHTML = `
|
||||
<label for="code_${locCode}">${locationDetails.name} code</label>
|
||||
<input type="text" id="code_${locCode}" name="code_${locCode}" placeholder="">
|
||||
`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update dynamic sections based on product type
|
||||
function updateDynamicSections() {
|
||||
const typeSelect = document.getElementById('productType');
|
||||
const selectedOption = typeSelect.options[typeSelect.selectedIndex];
|
||||
|
||||
if (!selectedOption.value) {
|
||||
hideAllDynamicSections();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update category
|
||||
document.getElementById('productGroup').value = selectedOption.dataset.category || '';
|
||||
|
||||
// Get the baseType from the selected option
|
||||
const baseType = selectedOption.dataset.basetype || '';
|
||||
|
||||
// Update code prefixes
|
||||
if (baseType && productAttributes.codeModifiers && productAttributes.codeModifiers.prefixes) {
|
||||
const applicablePrefixes = productAttributes.codeModifiers.prefixes.filter(
|
||||
p => p.appliesTo.includes(baseType)
|
||||
);
|
||||
if (applicablePrefixes.length > 0) {
|
||||
showPrefixesSection(applicablePrefixes);
|
||||
} else {
|
||||
hidePrefixesSection();
|
||||
}
|
||||
} else {
|
||||
hidePrefixesSection();
|
||||
}
|
||||
|
||||
// Update code suffixes
|
||||
if (baseType && productAttributes.codeModifiers && productAttributes.codeModifiers.suffixes) {
|
||||
const applicableSuffixes = productAttributes.codeModifiers.suffixes.filter(
|
||||
s => s.appliesTo.includes(baseType)
|
||||
);
|
||||
if (applicableSuffixes.length > 0) {
|
||||
showSuffixesSection(applicableSuffixes);
|
||||
} else {
|
||||
hideSuffixesSection();
|
||||
}
|
||||
} else {
|
||||
hideSuffixesSection();
|
||||
}
|
||||
|
||||
// Update materials
|
||||
const materials = JSON.parse(selectedOption.dataset.materials || '[]');
|
||||
if (materials.length > 0) {
|
||||
showMaterialsSection(materials);
|
||||
} else {
|
||||
hideMaterialsSection();
|
||||
}
|
||||
|
||||
// Update colors
|
||||
const colors = JSON.parse(selectedOption.dataset.colors || '[]');
|
||||
if (colors.length > 0) {
|
||||
showColorsSection(colors);
|
||||
} else {
|
||||
hideColorsSection();
|
||||
}
|
||||
|
||||
// Update additional options
|
||||
const options = JSON.parse(selectedOption.dataset.options || '[]');
|
||||
if (options.length > 0) {
|
||||
showAdditionalOptionsSection(options);
|
||||
} else {
|
||||
hideAdditionalOptionsSection();
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide dynamic sections
|
||||
function showMaterialsSection(materials) {
|
||||
const section = document.getElementById('materialsSection');
|
||||
const container = document.getElementById('materialsContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
materials.forEach((material, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="radio" id="material_${index}" name="material" value="${material}" ${index === 0 ? 'checked' : ''}>
|
||||
<label for="material_${index}">${material}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideMaterialsSection() {
|
||||
document.getElementById('materialsSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showColorsSection(colors) {
|
||||
const section = document.getElementById('colorsSection');
|
||||
const container = document.getElementById('colorsContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
colors.forEach((color, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="radio" id="color_${index}" name="color" value="${color}" ${index === 0 ? 'checked' : ''}>
|
||||
<label for="color_${index}">${color}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideColorsSection() {
|
||||
document.getElementById('colorsSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showAdditionalOptionsSection(options) {
|
||||
const section = document.getElementById('additionalOptionsSection');
|
||||
const container = document.getElementById('additionalOptionsContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
options.forEach((option, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="radio" id="option_${index}" name="additionalOption" value="${option}" ${index === 0 ? 'checked' : ''}>
|
||||
<label for="option_${index}">${option}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideAdditionalOptionsSection() {
|
||||
document.getElementById('additionalOptionsSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showPrefixesSection(prefixes) {
|
||||
const section = document.getElementById('prefixesSection');
|
||||
const container = document.getElementById('prefixesContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Add "None" option first
|
||||
const noneDiv = document.createElement('div');
|
||||
noneDiv.className = 'radio-option';
|
||||
noneDiv.innerHTML = `
|
||||
<input type="radio" id="prefix_none" name="codePrefix" value="" checked onchange="updateStandardizedCode()">
|
||||
<label for="prefix_none">None</label>
|
||||
`;
|
||||
container.appendChild(noneDiv);
|
||||
|
||||
prefixes.forEach((prefix, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="radio" id="prefix_${index}" name="codePrefix" value="${prefix.value}" onchange="updateStandardizedCode()" title="${prefix.description}">
|
||||
<label for="prefix_${index}">${prefix.label}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hidePrefixesSection() {
|
||||
document.getElementById('prefixesSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showSuffixesSection(suffixes) {
|
||||
const section = document.getElementById('suffixesSection');
|
||||
const container = document.getElementById('suffixesContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
suffixes.forEach((suffix, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'radio-option';
|
||||
div.innerHTML = `
|
||||
<input type="checkbox" id="suffix_${index}" name="codeSuffix" value="${suffix.value}" onchange="updateStandardizedCode()" title="${suffix.description}">
|
||||
<label for="suffix_${index}">${suffix.label}</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSuffixesSection() {
|
||||
document.getElementById('suffixesSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
function updateStandardizedCode() {
|
||||
const baseCode = document.getElementById('baseCode').value.trim();
|
||||
|
||||
// Get selected prefix
|
||||
const prefixRadio = document.querySelector('input[name="codePrefix"]:checked');
|
||||
const prefix = prefixRadio ? prefixRadio.value : '';
|
||||
|
||||
// Get selected suffixes (checkboxes)
|
||||
const suffixCheckboxes = document.querySelectorAll('input[name="codeSuffix"]:checked');
|
||||
const suffixes = Array.from(suffixCheckboxes).map(cb => cb.value);
|
||||
|
||||
// Build the standardized code
|
||||
let standardizedCode = '';
|
||||
if (baseCode) {
|
||||
standardizedCode = prefix + baseCode;
|
||||
if (suffixes.length > 0) {
|
||||
standardizedCode += suffixes.join('');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('standardCode').value = standardizedCode;
|
||||
}
|
||||
|
||||
function hideAllDynamicSections() {
|
||||
document.getElementById('prefixesSection').classList.add('hidden');
|
||||
document.getElementById('suffixesSection').classList.add('hidden');
|
||||
document.getElementById('materialsSection').classList.add('hidden');
|
||||
document.getElementById('colorsSection').classList.add('hidden');
|
||||
document.getElementById('additionalOptionsSection').classList.add('hidden');
|
||||
document.getElementById('additionalOptionsSection').classList.add('hidden');
|
||||
document.getElementById('productGroup').value = '';
|
||||
}
|
||||
|
||||
// Form submission
|
||||
document.getElementById('productForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
// Get selected prefix
|
||||
const selectedPrefix = document.querySelector('input[name="codePrefix"]:checked');
|
||||
const codePrefix = selectedPrefix ? selectedPrefix.value : '';
|
||||
|
||||
// Get selected suffixes
|
||||
const selectedSuffixes = Array.from(document.querySelectorAll('input[name="codeSuffix"]:checked'))
|
||||
.map(cb => cb.value);
|
||||
|
||||
const productData = {
|
||||
location: formData.get('location'),
|
||||
description: formData.get('description'),
|
||||
status: formData.get('status'),
|
||||
baseCode: formData.get('baseCode'),
|
||||
standardCode: formData.get('standardCode'),
|
||||
codePrefix: codePrefix,
|
||||
codeSuffixes: selectedSuffixes,
|
||||
locationCodes: {},
|
||||
productType: formData.get('productType'),
|
||||
category: formData.get('productGroup'),
|
||||
material: formData.get('material'),
|
||||
color: formData.get('color'),
|
||||
additionalOption: formData.get('additionalOption')
|
||||
};
|
||||
|
||||
// Collect location codes for user's accessible locations
|
||||
userAccessibleLocations.forEach(locCode => {
|
||||
const code = formData.get(`code_${locCode}`);
|
||||
if (code) {
|
||||
productData.locationCodes[locCode] = code;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Product Data:', productData);
|
||||
|
||||
// Here you would send the data to your backend
|
||||
// For now, just show an alert
|
||||
alert('Product saved! (This is a demo - in production, this would save to the database)');
|
||||
});
|
||||
|
||||
// Utility functions
|
||||
function resetForm() {
|
||||
document.getElementById('productForm').reset();
|
||||
hideAllDynamicSections();
|
||||
}
|
||||
|
||||
function deleteProduct() {
|
||||
if (confirm('Are you sure you want to delete this product?')) {
|
||||
alert('Product deleted! (This is a demo)');
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', loadProductAttributes);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -269,6 +269,10 @@
|
||||
<input type="checkbox" id="perm_manage_users" value="manage_users">
|
||||
<label for="perm_manage_users">Manage Users</label>
|
||||
</div>
|
||||
<div class="location-item">
|
||||
<input type="checkbox" id="perm_manage_products" value="manage_products">
|
||||
<label for="perm_manage_products">Manage Products</label>
|
||||
</div>
|
||||
<div class="location-item">
|
||||
<input type="checkbox" id="perm_create_quotes" value="create_quotes">
|
||||
<label for="perm_create_quotes">Create Quotes</label>
|
||||
@@ -389,6 +393,9 @@
|
||||
if (document.getElementById('perm_manage_users').checked) {
|
||||
permissions.manage_users = true;
|
||||
}
|
||||
if (document.getElementById('perm_manage_products').checked) {
|
||||
permissions.manage_products = true;
|
||||
}
|
||||
if (document.getElementById('perm_create_quotes').checked) {
|
||||
permissions.create_quotes = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# Canvas API - Hierarchical Image Fallback System
|
||||
|
||||
## Overview
|
||||
The Canvas API provides intelligent image serving with automatic fallback across multiple directory levels. This eliminates image duplication and makes it easy to share common elements (like foreground plants or backgrounds) across multiple products.
|
||||
|
||||
## Architecture
|
||||
|
||||
### URL Pattern
|
||||
```
|
||||
GET /api/canvas/<product_code>/<layer>?color=<color>&material=<material>
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
app/static/images/
|
||||
├── doors/
|
||||
│ ├── storm/
|
||||
│ │ ├── 404/
|
||||
│ │ │ ├── base.jpg # Product-specific base
|
||||
│ │ │ ├── door-white.png # Product-specific door color
|
||||
│ │ │ └── door-black.png
|
||||
│ │ ├── 505/
|
||||
│ │ │ ├── base.jpg
|
||||
│ │ │ └── door-white.png
|
||||
│ │ └── layers/
|
||||
│ │ ├── base.jpg # Fallback for ALL storm doors
|
||||
│ │ ├── hardware.png # Shared hardware
|
||||
│ │ └── foreground.png # Shared foreground
|
||||
│ ├── entry/
|
||||
│ │ ├── 600/
|
||||
│ │ │ ├── base.jpg
|
||||
│ │ │ └── door-bronze.png
|
||||
│ │ └── layers/
|
||||
│ │ └── foreground-plants.png # Shared for entry doors
|
||||
│ └── layers/
|
||||
│ ├── hardware-generic.png # Fallback for ALL doors
|
||||
│ └── foreground-default.png
|
||||
├── windows/
|
||||
│ ├── storm/
|
||||
│ │ └── layers/
|
||||
│ │ └── foreground-minimal.png
|
||||
│ └── layers/
|
||||
│ └── base-generic.jpg
|
||||
└── layers/
|
||||
├── foreground-default.png # Global fallback
|
||||
├── base-generic.jpg # Global fallback
|
||||
└── hardware-generic.png # Global fallback
|
||||
```
|
||||
|
||||
### Fallback Priority
|
||||
When requesting an image, the system searches in this order (most specific to least specific):
|
||||
|
||||
1. **Product-specific with variant**: `/images/<type>/<subtype>/<code>/<layer>-<color>.png`
|
||||
2. **Product-specific**: `/images/<type>/<subtype>/<code>/<layer>.png`
|
||||
3. **Subtype fallback with variant**: `/images/<type>/<subtype>/layers/<layer>-<color>.png`
|
||||
4. **Subtype fallback**: `/images/<type>/<subtype>/layers/<layer>.png`
|
||||
5. **Type fallback**: `/images/<type>/layers/<layer>.png`
|
||||
6. **Global fallback**: `/images/layers/<layer>.png`
|
||||
7. **404**: Image not found
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Get Layer Image
|
||||
```
|
||||
GET /api/canvas/<product_code>/<layer>?color=<color>&material=<material>
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `product_code` (path, required): Product code (e.g., "404", "505")
|
||||
- `layer` (path, required): Layer name - must be one of: `base`, `door`, `hardware`, `overlay`, `foreground`
|
||||
- `color` (query, optional): Color variant (e.g., "white", "black", "bronze")
|
||||
- `material` (query, optional): Material variant (for future use)
|
||||
|
||||
**Response:**
|
||||
- Success: Image file (PNG, JPG, JPEG, or WebP)
|
||||
- Error 400: Invalid layer name
|
||||
- Error 404: Image not found
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
GET /api/canvas/404/base
|
||||
GET /api/canvas/404/door?color=white
|
||||
GET /api/canvas/505/hardware
|
||||
GET /api/canvas/600/foreground
|
||||
```
|
||||
|
||||
### Get Canvas Info
|
||||
```
|
||||
GET /api/canvas/<product_code>/info
|
||||
```
|
||||
|
||||
Returns metadata about available layers for a product.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"product": "404",
|
||||
"type": "window",
|
||||
"subtype": "storm-window",
|
||||
"availableLayers": {
|
||||
"base": true,
|
||||
"door": true,
|
||||
"door_colors": ["white", "black", "bronze", "sandstone"],
|
||||
"hardware": true,
|
||||
"foreground": true
|
||||
},
|
||||
"colors": ["White", "Black", "Bronze", "Sandstone"],
|
||||
"materials": ["Aluminum"]
|
||||
}
|
||||
```
|
||||
|
||||
### Test Endpoint
|
||||
```
|
||||
GET /api/canvas/test
|
||||
```
|
||||
|
||||
Verifies the Canvas API is running.
|
||||
|
||||
## Product Configuration
|
||||
|
||||
### Enable Canvas API
|
||||
Add `"useCanvasAPI": true` to the product's `imageConfig`:
|
||||
|
||||
```json
|
||||
{
|
||||
"productCode": "404",
|
||||
"description": "#404 FALCON STORM WINDOWS",
|
||||
"colors": ["White", "Black", "Bronze", "Sandstone"],
|
||||
"materials": ["Aluminum"],
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That's it! No need to specify paths or layers - the Canvas API will automatically find them using the fallback system.
|
||||
|
||||
### Optional: Legacy Layered Configuration
|
||||
If you're migrating from the old static layered system, you can keep both configurations during transition:
|
||||
|
||||
```json
|
||||
{
|
||||
"productCode": "404",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true,
|
||||
"layered": true,
|
||||
"basePath": "images/products/404/",
|
||||
"layers": {
|
||||
"base": "base.jpg",
|
||||
"door": {
|
||||
"white": "door-white.png",
|
||||
"black": "door-black.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Organization Strategy
|
||||
|
||||
### Product-Specific Files
|
||||
Place in `/images/<type>/<subtype>/<code>/`:
|
||||
- Unique base images with specific backgrounds
|
||||
- Color variants that are product-specific
|
||||
- Custom hardware or features
|
||||
|
||||
### Subtype Shared Files
|
||||
Place in `/images/<type>/<subtype>/layers/`:
|
||||
- Common hardware for that subtype
|
||||
- Shared foreground elements (plants, railings)
|
||||
- Default base images for that subtype
|
||||
|
||||
### Type Shared Files
|
||||
Place in `/images/<type>/layers/`:
|
||||
- Generic hardware for all products of that type
|
||||
- Common decorative elements
|
||||
|
||||
### Global Shared Files
|
||||
Place in `/images/layers/`:
|
||||
- Universal fallback images
|
||||
- Default placeholder elements
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Static Layered System
|
||||
|
||||
**Before:**
|
||||
```
|
||||
app/static/images/
|
||||
└── products/
|
||||
├── 404/
|
||||
│ ├── base.jpg
|
||||
│ ├── door-white.png
|
||||
│ ├── door-black.png
|
||||
│ ├── hardware.png
|
||||
│ └── plants.png
|
||||
└── 505/
|
||||
├── base.jpg
|
||||
├── door-white.png
|
||||
├── hardware.png # Duplicate!
|
||||
└── plants.png # Duplicate!
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
app/static/images/
|
||||
└── doors/
|
||||
└── storm/
|
||||
├── 404/
|
||||
│ ├── base.jpg
|
||||
│ ├── door-white.png
|
||||
│ └── door-black.png
|
||||
├── 505/
|
||||
│ ├── base.jpg
|
||||
│ └── door-white.png
|
||||
└── layers/
|
||||
├── hardware.png # Shared by both!
|
||||
└── plants.png # Shared by both!
|
||||
```
|
||||
|
||||
**Update JSON:**
|
||||
```json
|
||||
{
|
||||
"productCode": "404",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true // Enable Canvas API
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. **Identify shared elements**: Look for duplicate files across products
|
||||
2. **Reorganize directory**: Move files to appropriate fallback levels
|
||||
3. **Update product JSON**: Add `"useCanvasAPI": true`
|
||||
4. **Test**: Verify images load correctly
|
||||
5. **Cleanup**: Remove old duplicate files
|
||||
|
||||
## Development Tips
|
||||
|
||||
### Testing Fallback Behavior
|
||||
1. Start with global fallback layer
|
||||
2. Test product without specific file - should show global fallback
|
||||
3. Add subtype-specific layer - should override global
|
||||
4. Add product-specific layer - should override subtype
|
||||
|
||||
### Debugging
|
||||
Check Flask logs to see which image path was found:
|
||||
```
|
||||
[INFO] Found image: /path/to/images/doors/storm/404/base.jpg
|
||||
```
|
||||
|
||||
If image not found, logs show what was searched:
|
||||
```
|
||||
[WARNING] No image found for 404/door (color: white)
|
||||
```
|
||||
|
||||
### Browser Testing
|
||||
Open browser console to see Canvas API requests:
|
||||
```
|
||||
GET /api/canvas/404/base → 200 OK
|
||||
GET /api/canvas/404/door?color=white → 200 OK
|
||||
GET /api/canvas/404/foreground → 200 OK (from fallback)
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Advantages
|
||||
- **Reduced file duplication**: Share common elements across products
|
||||
- **Smaller total file size**: No duplicate hardware/foreground images
|
||||
- **Easier maintenance**: Update one shared file affects all products
|
||||
- **Smart caching**: Browser caches shared layers for faster loading
|
||||
|
||||
### Best Practices
|
||||
- Optimize images before uploading (TinyPNG, ImageOptim)
|
||||
- Use appropriate formats:
|
||||
- JPG for base layers (no transparency needed)
|
||||
- PNG for layers with transparency
|
||||
- WebP for modern browsers (optional)
|
||||
- Keep file sizes reasonable:
|
||||
- Base: 200-500KB
|
||||
- Layers: 50-200KB each
|
||||
- Foreground: 50-150KB
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Image Not Showing
|
||||
1. Check product's `baseType` and `subType` in products.json
|
||||
2. Verify file exists in correct directory structure
|
||||
3. Check Flask logs for the search path
|
||||
4. Ensure product has `"useCanvasAPI": true`
|
||||
|
||||
### Wrong Image Displayed
|
||||
- Check fallback priority - more specific path should override generic
|
||||
- Verify file naming matches expected pattern
|
||||
- Check that color parameter matches filename (lowercase)
|
||||
|
||||
### 404 Errors
|
||||
- Open `/api/canvas/<product>/info` to see what's available
|
||||
- Check file permissions
|
||||
- Verify directory structure matches expected pattern
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential additions to the Canvas API system:
|
||||
- Dynamic color tinting (apply color to grayscale base)
|
||||
- Image composition/overlays
|
||||
- Real-time layer opacity/blend modes
|
||||
- A/B testing different foreground elements
|
||||
- Seasonal foreground rotation
|
||||
- Material-based texture overlays
|
||||
@@ -0,0 +1,285 @@
|
||||
# Canvas API Implementation Summary
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
Your product finder now has a **hierarchical image fallback system** (Canvas API) that automatically serves images with smart fallbacks across multiple directory levels. This eliminates duplicate files and makes it easy to share common elements (like plants, hardware, backgrounds) across products.
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Three-Layer Approach
|
||||
|
||||
1. **Backend (Python/Flask)** - Smart image resolution with fallback logic
|
||||
2. **Frontend (JavaScript)** - Dynamic image loading using Canvas API endpoints
|
||||
3. **File Structure** - Hierarchical organization with automatic fallbacks
|
||||
|
||||
```
|
||||
Product Specific → Subtype Shared → Type Shared → Global Shared
|
||||
```
|
||||
|
||||
## What Changed
|
||||
|
||||
### New Files Created
|
||||
|
||||
1. **`app/blueprints/canvas.py`** - Canvas API Flask blueprint with fallback logic
|
||||
- `/api/canvas/<product_code>/<layer>?color=<color>` - Get image with fallback
|
||||
- `/api/canvas/<product_code>/info` - Get available layers info
|
||||
- `/api/canvas/test` - Test endpoint
|
||||
|
||||
2. **`information/CANVAS_API_GUIDE.md`** - Complete technical documentation
|
||||
3. **`information/CANVAS_API_QUICKSTART.md`** - 5-minute setup guide
|
||||
4. **`information/CANVAS_DIRECTORY_SETUP.md`** - Directory setup scripts and helpers
|
||||
5. **`information/FOREGROUND_LAYER_EXAMPLE.md`** - Foreground layer guide (from earlier)
|
||||
6. **`information/FOREGROUND_QUICKSTART.md`** - Quick foreground guide (from earlier)
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. **`app/app.py`** - Registered canvas blueprint
|
||||
2. **`app/static/js/script.js`** - Added Canvas API support
|
||||
- `buildCanvasAPIUrl()` - Build API URLs
|
||||
- `updateCanvasAPIPreview()` - Update layers using API
|
||||
- Updated rendering logic to support Canvas API
|
||||
3. **`app/static/css/styles.css`** - Added `.layer-foreground` with z-index 5 (from earlier)
|
||||
4. **`information/LAYERED_IMAGES.md`** - Updated with foreground layer info
|
||||
5. **`information/products-layered-example.json`** - Added Canvas API example (product #700)
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Directory Structure
|
||||
```
|
||||
app/static/images/
|
||||
├── doors/
|
||||
│ └── storm/
|
||||
│ ├── 404/
|
||||
│ │ └── door-white.png # Product-specific
|
||||
│ └── layers/
|
||||
│ └── hardware.png # Shared by all storm doors
|
||||
└── layers/
|
||||
└── foreground.png # Global fallback
|
||||
```
|
||||
|
||||
### 2. Product Configuration
|
||||
```json
|
||||
{
|
||||
"productCode": "404",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Automatic Fallback
|
||||
Request: `/api/canvas/404/hardware`
|
||||
|
||||
Searches in order:
|
||||
1. `/images/doors/storm/404/hardware.png` ← Product-specific
|
||||
2. `/images/doors/storm/layers/hardware.png` ← Subtype shared ✓ **Found!**
|
||||
3. `/images/doors/layers/hardware.png` ← Type shared
|
||||
4. `/images/layers/hardware.png` ← Global
|
||||
|
||||
### 4. Frontend Rendering
|
||||
```javascript
|
||||
// JavaScript automatically calls Canvas API
|
||||
const url = buildCanvasAPIUrl('404', 'door', 'white');
|
||||
// Result: /api/canvas/404/door?color=white
|
||||
|
||||
doorLayer.src = url; // Image loads with automatic fallback!
|
||||
```
|
||||
|
||||
## Layering System (Updated)
|
||||
|
||||
All 5 layers are now supported:
|
||||
|
||||
1. **Base** (z-index: 1) - Background/house/frame
|
||||
2. **Door** (z-index: 2) - Door/window panel (color variants)
|
||||
3. **Hardware** (z-index: 3) - Handles, locks, hinges
|
||||
4. **Overlay** (z-index: 4) - Glass views, decorative overlays
|
||||
5. **Foreground** (z-index: 5) - Plants, decorations ⭐ **NEW from earlier request**
|
||||
|
||||
## Benefits
|
||||
|
||||
### ✅ Eliminates Duplication
|
||||
Before: Each product has own copy of hardware.png
|
||||
After: One shared hardware.png for all products of that type
|
||||
|
||||
### ✅ Smart Fallbacks
|
||||
Product can have custom image OR inherit from parent levels automatically
|
||||
|
||||
### ✅ Easier Maintenance
|
||||
Update one shared file → affects all products using it
|
||||
|
||||
### ✅ Flexible Organization
|
||||
- Product-specific overrides: `/images/<type>/<subtype>/<code>/`
|
||||
- Subtype shared: `/images/<type>/<subtype>/layers/`
|
||||
- Type shared: `/images/<type>/layers/`
|
||||
- Global fallback: `/images/layers/`
|
||||
|
||||
### ✅ Reduced File Size
|
||||
Example with 10 products sharing hardware + foreground:
|
||||
- Before: 10 × (100KB + 150KB) = 2.5MB
|
||||
- After: 1 × (100KB + 150KB) = 250KB
|
||||
- **Savings: 2.25MB (90% reduction for shared elements)**
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: All Storm Doors Share Hardware
|
||||
```
|
||||
/images/doors/storm/layers/hardware.png ← One file
|
||||
```
|
||||
Products 404, 505, 450 all use this automatically!
|
||||
|
||||
### Example 2: Product 404 Has Custom Foreground
|
||||
```
|
||||
/images/doors/storm/404/foreground.png ← Product 404
|
||||
/images/doors/storm/layers/foreground.png ← Products 505, 450
|
||||
```
|
||||
Product 404 gets custom, others get shared.
|
||||
|
||||
### Example 3: Global Plant Layer
|
||||
```
|
||||
/images/layers/foreground-plants.png ← Used by ALL products
|
||||
```
|
||||
Unless a more specific version exists.
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Keep Existing System (Low Risk)
|
||||
- Leave current products unchanged
|
||||
- New products use Canvas API: `"useCanvasAPI": true`
|
||||
- Both systems work simultaneously
|
||||
|
||||
### Phase 2: Identify Duplicates
|
||||
- Run duplicate file detection scripts
|
||||
- Find common hardware, foregrounds, bases
|
||||
- Plan shared directory structure
|
||||
|
||||
### Phase 3: Reorganize Files
|
||||
- Create type/subtype structure
|
||||
- Move shared files to appropriate `/layers/` directories
|
||||
- Update product JSON: Add `"useCanvasAPI": true`
|
||||
|
||||
### Phase 4: Cleanup
|
||||
- Remove old duplicate files
|
||||
- Verify all products load correctly
|
||||
- Measure storage savings
|
||||
|
||||
## API Endpoints Reference
|
||||
|
||||
```
|
||||
GET /api/canvas/<product_code>/<layer>?color=<color>&material=<material>
|
||||
→ Returns image file with automatic fallback
|
||||
|
||||
GET /api/canvas/<product_code>/info
|
||||
→ Returns metadata about available layers
|
||||
|
||||
GET /api/canvas/test
|
||||
→ Test endpoint to verify API is working
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Enable Canvas API
|
||||
```json
|
||||
{
|
||||
"productCode": "404",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Keep Backward Compatibility (During Migration)
|
||||
```json
|
||||
{
|
||||
"productCode": "404",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true,
|
||||
"layered": true,
|
||||
"basePath": "images/products/404/",
|
||||
"layers": { /* ... */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
If Canvas API fails, falls back to static layered system.
|
||||
|
||||
## Testing
|
||||
|
||||
### 1. Test API is Working
|
||||
```
|
||||
Visit: http://localhost:8080/api/canvas/test
|
||||
Expected: {"status": "ok"}
|
||||
```
|
||||
|
||||
### 2. Test Specific Image
|
||||
```
|
||||
Visit: http://localhost:8080/api/canvas/404/base
|
||||
Expected: Image file loads
|
||||
```
|
||||
|
||||
### 3. Test Fallback
|
||||
```
|
||||
Visit: http://localhost:8080/api/canvas/404/hardware
|
||||
Check Flask logs to see which path was used
|
||||
```
|
||||
|
||||
### 4. Test Product Info
|
||||
```
|
||||
Visit: http://localhost:8080/api/canvas/404/info
|
||||
Expected: JSON with available layers
|
||||
```
|
||||
|
||||
### 5. Test Product Page
|
||||
```
|
||||
Visit product page
|
||||
Open browser console
|
||||
Check for Canvas API requests: GET /api/canvas/404/door?color=white
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Images not loading?
|
||||
1. Check Flask logs for which path was searched
|
||||
2. Visit `/api/canvas/<product_code>/info` to see available layers
|
||||
3. Verify `"useCanvasAPI": true` in products.json
|
||||
4. Check directory structure matches type/subtype
|
||||
|
||||
### 404 errors?
|
||||
- File doesn't exist at any fallback level
|
||||
- Check file naming (lowercase for colors)
|
||||
- Verify baseType and subType in products.json
|
||||
|
||||
### Wrong image showing?
|
||||
- More specific path overrides generic
|
||||
- Check fallback priority order
|
||||
- Verify file exists where you expect
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- **[CANVAS_API_GUIDE.md](CANVAS_API_GUIDE.md)** - Complete technical guide
|
||||
- **[CANVAS_API_QUICKSTART.md](CANVAS_API_QUICKSTART.md)** - 5-minute setup
|
||||
- **[CANVAS_DIRECTORY_SETUP.md](CANVAS_DIRECTORY_SETUP.md)** - Setup scripts
|
||||
- **[FOREGROUND_LAYER_EXAMPLE.md](FOREGROUND_LAYER_EXAMPLE.md)** - Foreground guide
|
||||
- **[FOREGROUND_QUICKSTART.md](FOREGROUND_QUICKSTART.md)** - Quick foreground setup
|
||||
- **[LAYERED_IMAGES.md](LAYERED_IMAGES.md)** - Full layering system
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ **Test the API**: Visit `/api/canvas/test`
|
||||
2. ✅ **Create directory structure**: Use scripts in CANVAS_DIRECTORY_SETUP.md
|
||||
3. ✅ **Move/organize images**: Place in appropriate fallback levels
|
||||
4. ✅ **Update products.json**: Add `"useCanvasAPI": true`
|
||||
5. ✅ **Test products**: Load product pages and verify images
|
||||
6. ✅ **Optimize**: Run duplicate detection and consolidate files
|
||||
|
||||
## Summary
|
||||
|
||||
You now have a sophisticated, production-ready image serving system with:
|
||||
|
||||
✨ **Smart hierarchical fallbacks**
|
||||
✨ **Automatic image resolution**
|
||||
✨ **5-layer compositing** (including foreground from earlier)
|
||||
✨ **Massive storage savings**
|
||||
✨ **Easy maintenance**
|
||||
✨ **Flexible organization**
|
||||
✨ **Backward compatible**
|
||||
|
||||
The system is fully implemented and ready to use. Start with a few test products, verify it works, then gradually migrate your entire catalog!
|
||||
@@ -0,0 +1,197 @@
|
||||
# Quick Start: Canvas API Hierarchical Image System
|
||||
|
||||
## What Is This?
|
||||
The Canvas API lets you share images across multiple products with automatic fallback. Instead of duplicating the same plants.png file in every product folder, you can have ONE shared file that all products use.
|
||||
|
||||
## 5-Minute Setup
|
||||
|
||||
### Step 1: Organize Your Images
|
||||
|
||||
**Create this structure:**
|
||||
```
|
||||
app/static/images/
|
||||
└── doors/
|
||||
└── storm/
|
||||
├── 404/
|
||||
│ └── door-white.png # Product-specific
|
||||
├── 505/
|
||||
│ └── door-black.png # Product-specific
|
||||
└── layers/
|
||||
├── base.jpg # Shared by all storm doors
|
||||
├── hardware.png # Shared by all storm doors
|
||||
└── foreground.png # Shared by all storm doors
|
||||
```
|
||||
|
||||
### Step 2: Enable Canvas API
|
||||
|
||||
**Edit products.json:**
|
||||
```json
|
||||
{
|
||||
"productCode": "404",
|
||||
"description": "#404 FALCON STORM WINDOWS",
|
||||
"colors": ["White", "Black"],
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Test
|
||||
|
||||
1. Start your Flask server
|
||||
2. Visit: `http://localhost:8080/api/canvas/test`
|
||||
3. Should see: `{"status": "ok"}`
|
||||
4. Test specific image: `http://localhost:8080/api/canvas/404/base`
|
||||
5. Open product page - images should load automatically!
|
||||
|
||||
## How It Works
|
||||
|
||||
When you request `/api/canvas/404/door?color=white`, the system searches:
|
||||
|
||||
1. `/images/doors/storm/404/door-white.png` ← **Checks here first**
|
||||
2. `/images/doors/storm/404/door.png`
|
||||
3. `/images/doors/storm/layers/door-white.png`
|
||||
4. `/images/doors/storm/layers/door.png` ← **Uses this if 404-specific doesn't exist**
|
||||
5. `/images/doors/layers/door.png`
|
||||
6. `/images/layers/door.png`
|
||||
|
||||
## Directory Level Guide
|
||||
|
||||
| Level | Path | Use For | Example |
|
||||
|-------|------|---------|---------|
|
||||
| **Product** | `/images/doors/storm/404/` | Unique to this product | Custom door colors, unique base |
|
||||
| **Subtype** | `/images/doors/storm/layers/` | Shared across storm doors | Common hardware, shared plants |
|
||||
| **Type** | `/images/doors/layers/` | Shared across all doors | Generic handles, railings |
|
||||
| **Global** | `/images/layers/` | Shared across everything | Default fallback images |
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario 1: All Products Share Plants
|
||||
**Put foreground.png here:**
|
||||
```
|
||||
/images/doors/storm/layers/foreground.png
|
||||
```
|
||||
|
||||
Both product 404 and 505 will use this same file automatically!
|
||||
|
||||
### Scenario 2: Product 404 Needs Custom Plants
|
||||
**Add product-specific version:**
|
||||
```
|
||||
/images/doors/storm/404/foreground.png ← Product 404 uses this
|
||||
/images/doors/storm/layers/foreground.png ← Product 505 uses this
|
||||
```
|
||||
|
||||
### Scenario 3: Same Hardware for All Doors
|
||||
**Put at type level:**
|
||||
```
|
||||
/images/doors/layers/hardware.png
|
||||
```
|
||||
|
||||
Every door product (storm, entry, patio) uses the same hardware!
|
||||
|
||||
## Real-World Example
|
||||
|
||||
### Before (Static Layered System)
|
||||
```
|
||||
/images/products/404/
|
||||
base.jpg (800KB)
|
||||
door-white.png (200KB)
|
||||
hardware.png (100KB) ← Duplicate
|
||||
plants.png (150KB) ← Duplicate
|
||||
|
||||
/images/products/505/
|
||||
base.jpg (800KB)
|
||||
door-black.png (200KB)
|
||||
hardware.png (100KB) ← Duplicate!
|
||||
plants.png (150KB) ← Duplicate!
|
||||
|
||||
Total: 2.5MB
|
||||
```
|
||||
|
||||
### After (Canvas API)
|
||||
```
|
||||
/images/doors/storm/404/
|
||||
base.jpg (800KB)
|
||||
door-white.png (200KB)
|
||||
|
||||
/images/doors/storm/505/
|
||||
base.jpg (800KB)
|
||||
door-black.png (200KB)
|
||||
|
||||
/images/doors/storm/layers/
|
||||
hardware.png (100KB) ← Shared!
|
||||
plants.png (150KB) ← Shared!
|
||||
|
||||
Total: 2.25MB (saved 250KB)
|
||||
```
|
||||
|
||||
With 10 products sharing the same hardware/plants, you'd save **~2MB**!
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Enable for a Product
|
||||
```json
|
||||
{
|
||||
"productCode": "404",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
```
|
||||
/api/canvas/<product_code>/<layer>?color=<color>
|
||||
/api/canvas/<product_code>/info
|
||||
/api/canvas/test
|
||||
```
|
||||
|
||||
### Valid Layers
|
||||
- `base` - Background/house
|
||||
- `door` - Door/window panel
|
||||
- `hardware` - Handles/locks
|
||||
- `overlay` - Glass views
|
||||
- `foreground` - Plants/decorations
|
||||
|
||||
### Directory Pattern
|
||||
```
|
||||
/images/<type>/<subtype>/<product_code>/<layer>-<color>.<ext>
|
||||
/images/<type>/<subtype>/layers/<layer>.<ext>
|
||||
/images/<type>/layers/<layer>.<ext>
|
||||
/images/layers/<layer>.<ext>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Images not loading?**
|
||||
1. Check Flask logs: `[INFO] Found image: /path/to/file`
|
||||
2. Visit: `/api/canvas/YOUR-PRODUCT/info`
|
||||
3. Verify `"useCanvasAPI": true` in products.json
|
||||
4. Check baseType/subType match directory structure
|
||||
|
||||
**Wrong image showing?**
|
||||
- More specific paths override generic ones
|
||||
- Product-specific overrides subtype
|
||||
- Subtype overrides type
|
||||
- Type overrides global
|
||||
|
||||
**404 error?**
|
||||
- File doesn't exist at any fallback level
|
||||
- Check file naming (lowercase for colors)
|
||||
- Verify directory structure matches type/subtype
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Read the [full Canvas API Guide](CANVAS_API_GUIDE.md) for advanced features
|
||||
2. ✅ Review [example JSON configurations](products-layered-example.json)
|
||||
3. ✅ Check the [fallback system documentation](CANVAS_API_GUIDE.md#fallback-priority)
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Eliminate duplication** - Share common files across products
|
||||
✅ **Easier updates** - Change one file, affects all products
|
||||
✅ **Smaller total size** - Less storage and bandwidth
|
||||
✅ **Smart fallbacks** - Products automatically inherit shared elements
|
||||
✅ **Flexible organization** - Add product-specific overrides anytime
|
||||
|
||||
That's it! Start organizing your images by type/subtype and watch the magic happen. 🎨
|
||||
@@ -0,0 +1,336 @@
|
||||
# Directory Structure Helper - Canvas API
|
||||
|
||||
## Quick Directory Setup Scripts
|
||||
|
||||
### Windows PowerShell Script
|
||||
Save as `create-canvas-structure.ps1`:
|
||||
|
||||
```powershell
|
||||
# Create Canvas API directory structure
|
||||
# Run from: app/static/images/
|
||||
|
||||
param(
|
||||
[string]$BaseType = "doors",
|
||||
[string]$SubType = "storm",
|
||||
[string]$ProductCode = ""
|
||||
)
|
||||
|
||||
# Create base structure
|
||||
$basePath = "."
|
||||
|
||||
# Create type/subtype/layers directories
|
||||
New-Item -ItemType Directory -Force -Path "$basePath/$BaseType/$SubType/layers" | Out-Null
|
||||
Write-Host "✓ Created: $BaseType/$SubType/layers/"
|
||||
|
||||
# Create product-specific directory if provided
|
||||
if ($ProductCode) {
|
||||
New-Item -ItemType Directory -Force -Path "$basePath/$BaseType/$SubType/$ProductCode" | Out-Null
|
||||
Write-Host "✓ Created: $BaseType/$SubType/$ProductCode/"
|
||||
}
|
||||
|
||||
# Create type-level layers directory
|
||||
New-Item -ItemType Directory -Force -Path "$basePath/$BaseType/layers" | Out-Null
|
||||
Write-Host "✓ Created: $BaseType/layers/"
|
||||
|
||||
# Create global layers directory
|
||||
New-Item -ItemType Directory -Force -Path "$basePath/layers" | Out-Null
|
||||
Write-Host "✓ Created: layers/"
|
||||
|
||||
Write-Host "`nDirectory structure created successfully!"
|
||||
Write-Host "Now add your images to the appropriate directories."
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```powershell
|
||||
# From app/static/images/ directory
|
||||
.\create-canvas-structure.ps1 -BaseType "doors" -SubType "storm" -ProductCode "404"
|
||||
.\create-canvas-structure.ps1 -BaseType "windows" -SubType "double-hung" -ProductCode "505"
|
||||
```
|
||||
|
||||
### Linux/Mac Bash Script
|
||||
Save as `create-canvas-structure.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Create Canvas API directory structure
|
||||
# Run from: app/static/images/
|
||||
|
||||
BASE_TYPE=${1:-"doors"}
|
||||
SUB_TYPE=${2:-"storm"}
|
||||
PRODUCT_CODE=$3
|
||||
|
||||
# Create type/subtype/layers directories
|
||||
mkdir -p "$BASE_TYPE/$SUB_TYPE/layers"
|
||||
echo "✓ Created: $BASE_TYPE/$SUB_TYPE/layers/"
|
||||
|
||||
# Create product-specific directory if provided
|
||||
if [ -n "$PRODUCT_CODE" ]; then
|
||||
mkdir -p "$BASE_TYPE/$SUB_TYPE/$PRODUCT_CODE"
|
||||
echo "✓ Created: $BASE_TYPE/$SUB_TYPE/$PRODUCT_CODE/"
|
||||
fi
|
||||
|
||||
# Create type-level layers directory
|
||||
mkdir -p "$BASE_TYPE/layers"
|
||||
echo "✓ Created: $BASE_TYPE/layers/"
|
||||
|
||||
# Create global layers directory
|
||||
mkdir -p "layers"
|
||||
echo "✓ Created: layers/"
|
||||
|
||||
echo ""
|
||||
echo "Directory structure created successfully!"
|
||||
echo "Now add your images to the appropriate directories."
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# From app/static/images/ directory
|
||||
chmod +x create-canvas-structure.sh
|
||||
./create-canvas-structure.sh doors storm 404
|
||||
./create-canvas-structure.sh windows double-hung 505
|
||||
```
|
||||
|
||||
## Python Script for Bulk Setup
|
||||
Save as `setup_canvas_structure.py` in `app/`:
|
||||
|
||||
```python
|
||||
"""
|
||||
Setup Canvas API directory structure for multiple products
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
def load_products():
|
||||
"""Load products from products.json"""
|
||||
with open('data/products.json', 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def create_canvas_directories():
|
||||
"""Create directory structure based on product data"""
|
||||
products = load_products()
|
||||
base_path = 'static/images'
|
||||
|
||||
created_dirs = set()
|
||||
|
||||
for product in products:
|
||||
# Get product type info
|
||||
base_type = product.get('baseType', '').lower()
|
||||
subtype_obj = product.get('subType', {})
|
||||
product_code = product.get('productCode') or product.get('id')
|
||||
|
||||
if not base_type or not product_code:
|
||||
continue
|
||||
|
||||
# Extract subtype
|
||||
sub_type = None
|
||||
if isinstance(subtype_obj, dict):
|
||||
if subtype_obj.get('door'):
|
||||
sub_type = subtype_obj['door'].lower().replace(' ', '-')
|
||||
elif subtype_obj.get('window'):
|
||||
sub_type = subtype_obj['window'].lower().replace(' ', '-')
|
||||
|
||||
if not sub_type:
|
||||
continue
|
||||
|
||||
# Create directories
|
||||
dirs_to_create = [
|
||||
f"{base_path}/{base_type}/{sub_type}/layers",
|
||||
f"{base_path}/{base_type}/{sub_type}/{product_code}",
|
||||
f"{base_path}/{base_type}/layers",
|
||||
f"{base_path}/layers"
|
||||
]
|
||||
|
||||
for dir_path in dirs_to_create:
|
||||
if dir_path not in created_dirs:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
created_dirs.add(dir_path)
|
||||
print(f"✓ Created: {dir_path}")
|
||||
|
||||
print(f"\n✅ Created {len(created_dirs)} directories")
|
||||
print("\nNext steps:")
|
||||
print("1. Move your product images to the appropriate directories")
|
||||
print("2. Add 'useCanvasAPI': true to product imageConfig")
|
||||
print("3. Test with: /api/canvas/<product_code>/info")
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_canvas_directories()
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd app/
|
||||
python setup_canvas_structure.py
|
||||
```
|
||||
|
||||
## Manual Directory Creation Reference
|
||||
|
||||
### Complete Structure Example
|
||||
```
|
||||
app/static/images/
|
||||
├── doors/
|
||||
│ ├── storm/
|
||||
│ │ ├── 404/
|
||||
│ │ │ ├── base.jpg
|
||||
│ │ │ ├── door-white.png
|
||||
│ │ │ └── door-black.png
|
||||
│ │ ├── 505/
|
||||
│ │ │ ├── base.jpg
|
||||
│ │ │ └── door-white.png
|
||||
│ │ └── layers/
|
||||
│ │ ├── base.jpg
|
||||
│ │ ├── hardware.png
|
||||
│ │ └── foreground.png
|
||||
│ ├── entry/
|
||||
│ │ ├── 600/
|
||||
│ │ │ ├── base.jpg
|
||||
│ │ │ └── door-bronze.png
|
||||
│ │ └── layers/
|
||||
│ │ └── foreground-plants.png
|
||||
│ └── layers/
|
||||
│ └── hardware-generic.png
|
||||
├── windows/
|
||||
│ ├── storm/
|
||||
│ │ ├── 450/
|
||||
│ │ │ └── base.jpg
|
||||
│ │ └── layers/
|
||||
│ │ └── foreground-minimal.png
|
||||
│ └── layers/
|
||||
│ └── base-generic.jpg
|
||||
└── layers/
|
||||
├── foreground-default.png
|
||||
└── base-generic.jpg
|
||||
```
|
||||
|
||||
## File Naming Conventions
|
||||
|
||||
### Layer Files
|
||||
- **Base**: `base.jpg` or `base.png`
|
||||
- **Door (no color)**: `door.png`
|
||||
- **Door (with color)**: `door-white.png`, `door-black.png`, `door-bronze.png`
|
||||
- **Hardware**: `hardware.png`
|
||||
- **Foreground**: `foreground.png` or descriptive like `foreground-plants.png`
|
||||
- **Overlay**: `overlay.png` or `view-inside.png`, `view-outside.png`
|
||||
|
||||
### Tips
|
||||
- Use lowercase for color names in filenames
|
||||
- Use hyphens to separate words
|
||||
- Be descriptive for shared files: `foreground-spring-plants.png`
|
||||
- Keep product-specific files simple: `base.jpg`, `door-white.png`
|
||||
|
||||
## Migration Helper
|
||||
|
||||
### Identify Duplicate Files
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
Get-ChildItem -Path "app/static/images/products" -Recurse -File |
|
||||
Group-Object -Property Length, Name |
|
||||
Where-Object { $_.Count -gt 1 } |
|
||||
Select-Object Name, Count
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
find app/static/images/products -type f -exec md5sum {} + |
|
||||
sort |
|
||||
awk 'BEGIN{cmd="md5sum"}{if($1==prev){print $2}else{prev=$1}}'
|
||||
```
|
||||
|
||||
This will show you which files are duplicated and can be shared.
|
||||
|
||||
## Validation Script
|
||||
|
||||
Save as `validate_canvas_structure.py`:
|
||||
|
||||
```python
|
||||
"""
|
||||
Validate Canvas API directory structure
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
def validate_structure():
|
||||
"""Check if directory structure matches product data"""
|
||||
with open('app/data/products.json', 'r', encoding='utf-8') as f:
|
||||
products = json.load(f)
|
||||
|
||||
issues = []
|
||||
base_path = 'app/static/images'
|
||||
|
||||
for product in products:
|
||||
if not product.get('imageConfig', {}).get('useCanvasAPI'):
|
||||
continue
|
||||
|
||||
product_code = product.get('productCode') or product.get('id')
|
||||
base_type = product.get('baseType', '').lower()
|
||||
subtype_obj = product.get('subType', {})
|
||||
|
||||
# Extract subtype
|
||||
sub_type = None
|
||||
if isinstance(subtype_obj, dict):
|
||||
if subtype_obj.get('door'):
|
||||
sub_type = subtype_obj['door'].lower().replace(' ', '-')
|
||||
elif subtype_obj.get('window'):
|
||||
sub_type = subtype_obj['window'].lower().replace(' ', '-')
|
||||
|
||||
if not all([product_code, base_type, sub_type]):
|
||||
issues.append(f"⚠️ {product_code}: Missing type/subtype info")
|
||||
continue
|
||||
|
||||
# Check if product directory exists
|
||||
product_dir = f"{base_path}/{base_type}/{sub_type}/{product_code}"
|
||||
if not os.path.exists(product_dir):
|
||||
issues.append(f"❌ {product_code}: Directory not found: {product_dir}")
|
||||
else:
|
||||
# Check for at least one image file
|
||||
files = os.listdir(product_dir)
|
||||
image_files = [f for f in files if f.endswith(('.png', '.jpg', '.jpeg', '.webp'))]
|
||||
if not image_files:
|
||||
issues.append(f"⚠️ {product_code}: No images in {product_dir}")
|
||||
else:
|
||||
print(f"✓ {product_code}: {len(image_files)} images found")
|
||||
|
||||
if issues:
|
||||
print("\n❌ Issues found:")
|
||||
for issue in issues:
|
||||
print(f" {issue}")
|
||||
else:
|
||||
print("\n✅ All Canvas API products validated successfully!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
validate_structure()
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python validate_canvas_structure.py
|
||||
```
|
||||
|
||||
## Quick Commands
|
||||
|
||||
### Create structure for all common types
|
||||
```bash
|
||||
# Doors
|
||||
mkdir -p app/static/images/doors/{storm,entry,patio,screen}/layers
|
||||
mkdir -p app/static/images/doors/layers
|
||||
|
||||
# Windows
|
||||
mkdir -p app/static/images/windows/{storm,double-hung,casement,sliding}/layers
|
||||
mkdir -p app/static/images/windows/layers
|
||||
|
||||
# Global
|
||||
mkdir -p app/static/images/layers
|
||||
```
|
||||
|
||||
### Move images from old structure
|
||||
```bash
|
||||
# Example: Move shared hardware from products to shared layers
|
||||
mv app/static/images/products/*/hardware.png app/static/images/doors/storm/layers/
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
These scripts help you quickly set up and validate the Canvas API directory structure. Choose the approach that works best for your workflow:
|
||||
|
||||
- **Manual**: Create directories as needed
|
||||
- **Scripts**: Automate creation for multiple products
|
||||
- **Migration**: Use helper scripts to identify duplicates and reorganize
|
||||
@@ -0,0 +1,142 @@
|
||||
# Foreground Layer Example
|
||||
|
||||
## Overview
|
||||
The foreground layer adds depth and realism to product images by placing decorative elements (like plants, porch furniture, or architectural details) in front of the product.
|
||||
|
||||
## Layer Stacking Order
|
||||
From back to front:
|
||||
1. **Base Layer** (z-index: 1) - Background/house/frame
|
||||
2. **Door Layer** (z-index: 2) - Door or window panel (changes with color)
|
||||
3. **Hardware Layer** (z-index: 3) - Handles, locks, hinges
|
||||
4. **Overlay Layer** (z-index: 4) - Glass views or decorative overlays
|
||||
5. **Foreground Layer** (z-index: 5) - Plants, decorative items that appear in front
|
||||
|
||||
## Creating a Foreground Layer
|
||||
|
||||
### Step 1: Prepare Your Image
|
||||
1. Take or create an image of decorative elements (plants, porch items, etc.)
|
||||
2. Remove the background completely (use Photoshop, GIMP, or Photopea)
|
||||
3. Save as PNG with transparency
|
||||
4. Ensure dimensions match your base layer image
|
||||
|
||||
### Step 2: Position Elements
|
||||
When creating your foreground layer, position elements to:
|
||||
- Frame the product naturally
|
||||
- Not obscure critical product details
|
||||
- Add depth by having some elements extend beyond product edges
|
||||
- Consider perspective and lighting consistency
|
||||
|
||||
### Step 3: Export Settings
|
||||
- Format: PNG-24
|
||||
- Transparency: Enabled
|
||||
- Color Profile: sRGB
|
||||
- Resolution: Match your base layer (typically 800-1200px width)
|
||||
|
||||
## JSON Configuration Example
|
||||
|
||||
```json
|
||||
{
|
||||
"productCode": "600",
|
||||
"description": "#600 PREMIUM ENTRY DOOR",
|
||||
"image": "images/600.jpg",
|
||||
"colors": ["White", "Black", "Bronze", "Sandstone"],
|
||||
"materials": ["Aluminum", "Vinyl"],
|
||||
"imageConfig": {
|
||||
"layered": true,
|
||||
"basePath": "images/products/600/",
|
||||
"layers": {
|
||||
"base": "house-background.jpg",
|
||||
"door": {
|
||||
"white": "door-white.png",
|
||||
"black": "door-black.png",
|
||||
"bronze": "door-bronze.png",
|
||||
"sandstone": "door-sandstone.png"
|
||||
},
|
||||
"hardware": "handle-silver.png",
|
||||
"foreground": "front-plants.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
app/images/products/600/
|
||||
├── house-background.jpg # Background layer with house/frame
|
||||
├── door-white.png # White door (transparent background)
|
||||
├── door-black.png # Black door (transparent background)
|
||||
├── door-bronze.png # Bronze door (transparent background)
|
||||
├── door-sandstone.png # Sandstone door (transparent background)
|
||||
├── handle-silver.png # Hardware layer (transparent background)
|
||||
└── front-plants.png # Foreground layer with plants (transparent background)
|
||||
```
|
||||
|
||||
## Tips for Best Results
|
||||
|
||||
### Foreground Elements to Consider
|
||||
- **Potted plants**: Add life and color to the scene
|
||||
- **Hanging baskets**: Create visual interest at different heights
|
||||
- **Porch railings**: Partial railings in foreground add depth
|
||||
- **Architectural details**: Columns, trim, or decorative elements
|
||||
- **Seasonal decorations**: Pumpkins, wreaths, holiday items (create variants)
|
||||
|
||||
### Technical Tips
|
||||
- Keep file sizes reasonable (compress PNGs without losing quality)
|
||||
- Use soft shadows on foreground elements for realism
|
||||
- Match lighting direction across all layers
|
||||
- Consider blur/depth of field on very close foreground elements
|
||||
- Test on different screen sizes to ensure elements don't obscure product
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
- ❌ Foreground elements too large, obscuring product
|
||||
- ❌ Inconsistent lighting between layers
|
||||
- ❌ Mismatched image dimensions
|
||||
- ❌ Harsh edges on transparent elements
|
||||
- ❌ Too much foreground detail competing with product
|
||||
|
||||
## Advanced: Multiple Foreground Variants
|
||||
|
||||
You can create different foreground options for variety:
|
||||
|
||||
```json
|
||||
{
|
||||
"productCode": "600",
|
||||
"description": "#600 PREMIUM ENTRY DOOR",
|
||||
"imageConfig": {
|
||||
"layered": true,
|
||||
"basePath": "images/products/600/",
|
||||
"layers": {
|
||||
"base": "house-background.jpg",
|
||||
"door": {
|
||||
"white": "door-white.png",
|
||||
"black": "door-black.png"
|
||||
},
|
||||
"hardware": "handle-silver.png",
|
||||
"foreground": "plants-spring.png"
|
||||
// Could add logic to swap between plants-spring.png, plants-summer.png, etc.
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Your Foreground Layer
|
||||
|
||||
1. Load your product page
|
||||
2. Change door colors - foreground should stay visible on top
|
||||
3. Verify no important product details are obscured
|
||||
4. Check on mobile/tablet to ensure proper scaling
|
||||
5. Test with different browsers for compatibility
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- PNG files can be large - optimize them using tools like:
|
||||
- TinyPNG (https://tinypng.com)
|
||||
- ImageOptim (https://imageoptim.com)
|
||||
- Photoshop "Export for Web"
|
||||
- Target file size: 50-200KB for foreground layer
|
||||
- Use appropriate resolution (no need for 4K if displaying at 800px)
|
||||
|
||||
## Accessibility Note
|
||||
|
||||
The foreground layer has `pointer-events: none` CSS property, meaning clicks pass through to the controls below. This ensures the decorative layer doesn't interfere with user interaction.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Quick Start: Adding Foreground Layers to Your Products
|
||||
|
||||
## Overview
|
||||
Your product finder now supports foreground layers - perfect for adding plants, decorative elements, or any items that should appear in front of the product.
|
||||
|
||||
## Layer Order (back to front)
|
||||
1. **Base** (z-index: 1) - House/background
|
||||
2. **Door** (z-index: 2) - Door/window (changes with color)
|
||||
3. **Hardware** (z-index: 3) - Handles/locks
|
||||
4. **Overlay** (z-index: 4) - Glass views
|
||||
5. **Foreground** (z-index: 5) - Plants/decorative items ⭐ NEW!
|
||||
|
||||
## Quick Implementation Steps
|
||||
|
||||
### Step 1: Prepare Your Foreground Image
|
||||
1. Create/photograph decorative elements (plants, porch items, etc.)
|
||||
2. Remove background (make transparent)
|
||||
3. Save as PNG
|
||||
4. Match dimensions with your base layer image
|
||||
|
||||
### Step 2: Save File to Correct Location
|
||||
```
|
||||
app/images/products/YOUR-PRODUCT-CODE/foreground.png
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
app/images/products/600/
|
||||
├── house-background.jpg
|
||||
├── door-white.png
|
||||
├── door-black.png
|
||||
├── handle-silver.png
|
||||
└── plants.png ← Your new foreground layer
|
||||
```
|
||||
|
||||
### Step 3: Update Product JSON
|
||||
Edit your product in `app/data/products.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"productCode": "600",
|
||||
"description": "Your Product Name",
|
||||
"colors": ["White", "Black", "Bronze"],
|
||||
"imageConfig": {
|
||||
"layered": true,
|
||||
"basePath": "images/products/600/",
|
||||
"layers": {
|
||||
"base": "house-background.jpg",
|
||||
"door": {
|
||||
"white": "door-white.png",
|
||||
"black": "door-black.png",
|
||||
"bronze": "door-bronze.png"
|
||||
},
|
||||
"hardware": "handle-silver.png",
|
||||
"foreground": "plants.png" ← Add this line
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Test
|
||||
1. Save changes
|
||||
2. Refresh your browser
|
||||
3. Navigate to the product
|
||||
4. The foreground layer should now appear on top of everything
|
||||
|
||||
## Tips for Best Results
|
||||
|
||||
### Good Foreground Elements
|
||||
✅ Potted plants at corners of frame
|
||||
✅ Hanging baskets to the side
|
||||
✅ Partial porch railings
|
||||
✅ Seasonal decorations (wreaths, pumpkins)
|
||||
|
||||
### Avoid
|
||||
❌ Obscuring important product details
|
||||
❌ Elements too large or too busy
|
||||
❌ Mismatched lighting/shadows
|
||||
❌ Low-quality or pixelated images
|
||||
|
||||
## Example Products
|
||||
|
||||
See these files for complete examples:
|
||||
- [information/FOREGROUND_LAYER_EXAMPLE.md](FOREGROUND_LAYER_EXAMPLE.md) - Detailed guide
|
||||
- [information/products-layered-example.json](products-layered-example.json) - Product #600 example
|
||||
- [information/LAYERED_IMAGES.md](LAYERED_IMAGES.md) - Full layered system guide
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Foreground not showing?**
|
||||
- Check file path in JSON matches actual file location
|
||||
- Ensure `"layered": true` is set
|
||||
- Verify PNG has transparency
|
||||
- Check browser console for 404 errors
|
||||
|
||||
**Foreground blocking clicks?**
|
||||
- This shouldn't happen - the layer has `pointer-events: none`
|
||||
- If it does, check CSS for `.layer-foreground`
|
||||
|
||||
**Image quality issues?**
|
||||
- Use PNG-24 format
|
||||
- Match base layer dimensions
|
||||
- Optimize file size with TinyPNG or similar
|
||||
|
||||
## File Sizes & Performance
|
||||
- Target: 50-200KB for foreground PNG
|
||||
- Optimize images before uploading
|
||||
- Browser caches layers after first load
|
||||
|
||||
## Need Help?
|
||||
Check the detailed documentation:
|
||||
- `information/FOREGROUND_LAYER_EXAMPLE.md` - Complete guide with tips
|
||||
- `information/LAYERED_IMAGES.md` - Full layered system documentation
|
||||
@@ -11,6 +11,7 @@ Images are stacked in layers (like Photoshop layers):
|
||||
2. **Door Layer** - The door panel (PNG with transparency) - changes with color selection
|
||||
3. **Hardware Layer** - Handle/lock set (PNG with transparency) - can flip for left/right hinge
|
||||
4. **Overlay Layer** - Glass view/decorative elements (PNG with transparency) - optional
|
||||
5. **Foreground Layer** - Plants, decorative items that appear in front (PNG with transparency) - optional
|
||||
|
||||
### Fallback Behavior
|
||||
- **No layered config**: Shows the standard flat `image` field
|
||||
@@ -83,6 +84,32 @@ Images are stacked in layers (like Photoshop layers):
|
||||
}
|
||||
```
|
||||
|
||||
### Product with Foreground Layer (Plants/Decorative Elements)
|
||||
```json
|
||||
{
|
||||
"productCode": "600",
|
||||
"description": "#600 PREMIUM ENTRY DOOR",
|
||||
"image": "images/600.jpg",
|
||||
"colors": ["White", "Black", "Bronze"],
|
||||
"materials": ["Aluminum"],
|
||||
"imageConfig": {
|
||||
"layered": true,
|
||||
"basePath": "images/products/600/",
|
||||
"layers": {
|
||||
"base": "base.jpg",
|
||||
"door": {
|
||||
"white": "door-white.png",
|
||||
"black": "door-black.png",
|
||||
"bronze": "door-bronze.png"
|
||||
},
|
||||
"hardware": "handle.png",
|
||||
"foreground": "plants.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
**Note:** The foreground layer appears on top of all other layers and is perfect for adding plants, decorative elements, or other items that should appear in front of the product.
|
||||
|
||||
## File Structure
|
||||
|
||||
### Recommended Directory Layout
|
||||
@@ -97,6 +124,13 @@ app/images/products/
|
||||
│ ├── handle.png # Hardware (transparent BG)
|
||||
│ ├── view-inside.png # Optional inside view overlay
|
||||
│ └── view-outside.png # Optional outside view overlay
|
||||
├── 600/
|
||||
│ ├── base.jpg # Frame/house background
|
||||
│ ├── door-white.png # White door panel (transparent BG)
|
||||
│ ├── door-black.png # Black door panel (transparent BG)
|
||||
│ ├── door-bronze.png # Bronze door panel (transparent BG)
|
||||
│ ├── handle.png # Hardware (transparent BG)
|
||||
│ └── plants.png # Foreground layer - plants/decorative (transparent BG)
|
||||
├── 450/
|
||||
│ ├── base.jpg
|
||||
│ └── door-white.png
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Layer Transform System
|
||||
|
||||
## Overview
|
||||
|
||||
The Canvas API layer system supports CSS transforms for individual layers, allowing you to flip or rotate specific layers without modifying the image files. This is particularly useful for:
|
||||
|
||||
- **Door Hinge Direction**: Flip the door layer horizontally to show left-hinge vs right-hinge
|
||||
- **Mirror Effects**: Create symmetric variations of products
|
||||
- **Multi-Configuration Support**: Use the same image assets for multiple product variations
|
||||
|
||||
## Configuration
|
||||
|
||||
Add transform configuration to your product's `imageConfig` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"productCode": "404",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true,
|
||||
"layerTransforms": {
|
||||
"door": "flip-horizontal",
|
||||
"hardware": "flip-horizontal"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Available Transforms
|
||||
|
||||
### `flip-horizontal`
|
||||
Flips the layer horizontally (left-right mirror).
|
||||
|
||||
**CSS Applied**: `transform: scaleX(-1)`
|
||||
|
||||
**Use Case**: Door hinge direction (left-hinge vs right-hinge)
|
||||
|
||||
```json
|
||||
"layerTransforms": {
|
||||
"door": "flip-horizontal"
|
||||
}
|
||||
```
|
||||
|
||||
### `flip-vertical`
|
||||
Flips the layer vertically (top-bottom mirror).
|
||||
|
||||
**CSS Applied**: `transform: scaleY(-1)`
|
||||
|
||||
**Use Case**: Ceiling-mounted vs floor-mounted products
|
||||
|
||||
```json
|
||||
"layerTransforms": {
|
||||
"base": "flip-vertical"
|
||||
}
|
||||
```
|
||||
|
||||
### `flip-both`
|
||||
Flips the layer both horizontally and vertically (180° rotation).
|
||||
|
||||
**CSS Applied**: `transform: scale(-1, -1)`
|
||||
|
||||
**Use Case**: Complete inversion of a layer
|
||||
|
||||
```json
|
||||
"layerTransforms": {
|
||||
"overlay": "flip-both"
|
||||
}
|
||||
```
|
||||
|
||||
## Layer Names
|
||||
|
||||
Available layers you can transform:
|
||||
- `base` - Background/house layer
|
||||
- `door` - Door/window layer (with color variations)
|
||||
- `hardware` - Hardware layer (handles, locks, etc.)
|
||||
- `overlay` - Additional overlay layer
|
||||
- `foreground` - Foreground layer (plants, decorations)
|
||||
|
||||
## Complete Example
|
||||
|
||||
### Left-Hinge Door (Default)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "404",
|
||||
"productCode": "404",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Right-Hinge Door (Flipped)
|
||||
|
||||
Create a separate product entry with a different code:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "404R",
|
||||
"productCode": "404R",
|
||||
"description": "#404 FALCON STORM WINDOWS (RIGHT-HINGE)",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true,
|
||||
"layerTransforms": {
|
||||
"door": "flip-horizontal",
|
||||
"hardware": "flip-horizontal"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Both products can share the same image files! The transform is applied in CSS at render time.
|
||||
|
||||
## Image Preparation Tips
|
||||
|
||||
When creating images that will be flipped:
|
||||
|
||||
1. **Design for the default orientation first** (e.g., left-hinge door)
|
||||
2. **Keep text/logos off flippable layers** - they will be reversed
|
||||
3. **Test the flipped version** to ensure it looks natural
|
||||
4. **Consider asymmetric details** - handles, hinges, decorative elements
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### CSS Classes
|
||||
|
||||
The system applies these CSS classes automatically:
|
||||
|
||||
- `.layer-flip-horizontal` - Horizontal flip
|
||||
- `.layer-flip-vertical` - Vertical flip
|
||||
- `.layer-flip-both` - Both axes flip
|
||||
|
||||
### JavaScript
|
||||
|
||||
Transforms are applied in `updateCanvasAPIPreview()` function:
|
||||
|
||||
```javascript
|
||||
const layerTransforms = product.imageConfig?.layerTransforms || {};
|
||||
|
||||
function applyTransform(element, layerName) {
|
||||
element.classList.remove('layer-flip-horizontal', 'layer-flip-vertical', 'layer-flip-both');
|
||||
const transform = layerTransforms[layerName];
|
||||
if (transform) {
|
||||
element.classList.add(`layer-${transform}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced: Multiple Products, Same Images
|
||||
|
||||
You can create an entire product family from a single set of images:
|
||||
|
||||
```
|
||||
/static/images/window/storm-window/404/
|
||||
├── door-white.png (left-hinge design)
|
||||
├── door-black.png (left-hinge design)
|
||||
└── ...
|
||||
|
||||
Products using these images:
|
||||
- 404 - Left-hinge (no transform)
|
||||
- 404R - Right-hinge (door flipped)
|
||||
- 404T - Top-mount variant (base flipped)
|
||||
- 404RT - Right-hinge top-mount (both flipped)
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
CSS transforms are supported in all modern browsers:
|
||||
- Chrome/Edge: ✅
|
||||
- Firefox: ✅
|
||||
- Safari: ✅
|
||||
- Opera: ✅
|
||||
|
||||
## Performance
|
||||
|
||||
Layer transforms are GPU-accelerated CSS operations with no performance impact. Flipping layers is instant and doesn't require:
|
||||
- Additional HTTP requests
|
||||
- Image processing
|
||||
- Additional storage
|
||||
- Server-side rendering
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Transform not applying
|
||||
|
||||
1. Check that `useCanvasAPI: true` is set
|
||||
2. Verify layer name matches exactly (case-sensitive)
|
||||
3. Check browser console for JavaScript errors
|
||||
4. Ensure CSS is loaded properly
|
||||
|
||||
### Image looks distorted
|
||||
|
||||
Transforms maintain aspect ratio. If the image looks wrong:
|
||||
1. Verify the original image has correct proportions
|
||||
2. Check that all layers use the same canvas dimensions
|
||||
3. Test without transforms first to isolate the issue
|
||||
|
||||
### Text is backwards
|
||||
|
||||
This is expected! Don't place text or logos on layers that will be flipped. Instead:
|
||||
1. Keep text on non-flipped layers (usually `base` or `overlay`)
|
||||
2. Create separate images for left/right variants if text is essential
|
||||
3. Use the overlay layer for directional text
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Possible additions:
|
||||
- Rotation angles (90°, 180°, 270°)
|
||||
- Scale adjustments (zoom in/out specific layers)
|
||||
- Position offsets (shift layers left/right/up/down)
|
||||
- Animation/transition effects
|
||||
@@ -38,6 +38,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "600",
|
||||
"productCode": "600",
|
||||
"category": "DOORS",
|
||||
"description": "#600 PREMIUM ENTRY DOOR WITH PLANTS",
|
||||
"discontinued": false,
|
||||
"location": "Iola",
|
||||
"baseType": "Door",
|
||||
"subType": {
|
||||
"door": "Entry Door",
|
||||
"window": null
|
||||
},
|
||||
"materials": [
|
||||
"Aluminum"
|
||||
],
|
||||
"colors": [
|
||||
"White",
|
||||
"Black",
|
||||
"Bronze",
|
||||
"Sandstone"
|
||||
],
|
||||
"isAccessory": false,
|
||||
"compatibleAccessories": [],
|
||||
"image": "images/600.jpg",
|
||||
"imageConfig": {
|
||||
"layered": true,
|
||||
"basePath": "images/products/600/",
|
||||
"layers": {
|
||||
"base": "house-background.jpg",
|
||||
"door": {
|
||||
"white": "door-white.png",
|
||||
"black": "door-black.png",
|
||||
"bronze": "door-bronze.png",
|
||||
"sandstone": "door-sandstone.png"
|
||||
},
|
||||
"hardware": "handle-silver.png",
|
||||
"foreground": "plants.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "450",
|
||||
"productCode": "450",
|
||||
@@ -102,5 +142,34 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "700",
|
||||
"productCode": "700",
|
||||
"category": "DOORS",
|
||||
"description": "#700 MODERN ENTRY DOOR (Canvas API Example)",
|
||||
"discontinued": false,
|
||||
"location": "Iola",
|
||||
"baseType": "Door",
|
||||
"subType": {
|
||||
"door": "Entry Door",
|
||||
"window": null
|
||||
},
|
||||
"materials": [
|
||||
"Aluminum",
|
||||
"Vinyl"
|
||||
],
|
||||
"colors": [
|
||||
"White",
|
||||
"Black",
|
||||
"Bronze"
|
||||
],
|
||||
"isAccessory": false,
|
||||
"compatibleAccessories": [],
|
||||
"image": "images/700.jpg",
|
||||
"imageConfig": {
|
||||
"useCanvasAPI": true
|
||||
},
|
||||
"_comment": "Canvas API Example - Images will be automatically loaded from hierarchical fallback system. Product-specific images go in /images/doors/entry/700/, shared images go in /images/doors/entry/layers/, /images/doors/layers/, or /images/layers/"
|
||||
}
|
||||
]
|
||||
|
||||