Editor Support

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Jason
2026-05-05 15:42:30 -05:00
parent 9f7c078ccc
commit 29154bd651
32 changed files with 3698 additions and 17 deletions
+105
View File
@@ -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.
+115
View File
@@ -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
+3
View File
@@ -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>
+216
View File
@@ -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'
}
})
+36 -2
View File
@@ -2,8 +2,8 @@
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)
+90
View File
@@ -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")
+240
View File
@@ -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()
+215
View File
@@ -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"
}
]
}
}
+35 -1
View File
@@ -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",
+3 -1
View File
@@ -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
+18
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+144 -11
View File
@@ -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();
});
+11 -1
View File
@@ -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>
+762
View File
@@ -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>
+7
View File
@@ -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;
}
+311
View File
@@ -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
+285
View File
@@ -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!
+197
View File
@@ -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. 🎨
+336
View File
@@ -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
+142
View File
@@ -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.
+113
View File
@@ -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
+34
View File
@@ -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
+210
View File
@@ -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
+69
View File
@@ -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/"
}
]