diff --git a/.claude/documentation/ARCHITECTURE_DESIGN.md b/.claude/documentation/ARCHITECTURE_DESIGN.md index 06f2e9a..d9c7fb8 100644 --- a/.claude/documentation/ARCHITECTURE_DESIGN.md +++ b/.claude/documentation/ARCHITECTURE_DESIGN.md @@ -36,33 +36,37 @@ Picture Frame Creator is a Vue.js-based web application that allows users to cre ``` ┌─────────────────────────────────────────────────────────────┐ │ App.vue │ -│ (Root Component - Layout Container) │ +│ (Root Component - Responsive Layout Container) │ +│ Desktop: Sidebar + Canvas | Mobile: Canvas + Bottom Panel │ └────────┬───────────────────────────────┬────────────────────┘ │ │ -┌────────▼────────┐ ┌────────▼─────────┐ -│ AppHeader.vue │ │CanvasContainer │ -│ (Header) │ │ FrameCanvas │ -└─────────────────┘ │ (Rendering) │ - └────────┬─────────┘ -┌─────────────────┐ │ -│ ConfigBar.vue │ ┌────────┴─────────┐ -│ (Row 1 & 2) │ │ │ -└────────┬────────┘ ┌───▼──────┐ ┌─────▼──────┐ - │ │ Konva │ │ Image │ - ┌─────┴──────┐ │ Stage │ │ Upload │ - │ │ └──────────┘ └────────────┘ -┌──▼───┐ ┌────▼────┐ -│Orient│ │ Ratio │ -│Toggle│ │Selector │ -└──────┘ └─────────┘ + │ ┌───────▼──────────┐ + │ │ CanvasContainer │ + │ │ FrameCanvas │ + │ │ (Rendering) │ + │ └────────┬─────────┘ + │ │ +┌────────▼────────┐ ┌───────┴─────────┐ +│ ConfigBar.vue │ │ │ +│ (Configuration) │ ┌───▼──────┐ ┌─────▼──────┐ +└────────┬────────┘ │ Konva │ │ Image │ + │ │ Stage │ │ Upload │ + ┌─────┴───────┐ └──────────┘ └────────────┘ + │ │ + │ ConfigElement Wrapper (x7) + │ │ +┌──▼───┐ ┌────▼────┐ ┌────────┐ ┌─────────┐ +│Orient│ │ Ratio │ │ Color │ │ Frame │ +│Toggle│ │Selector │ │ Picker │ │ Size │ +└──────┘ └─────────┘ └────────┘ └─────────┘ ┌──────┐ ┌────────┐ ┌────────┐ -│Color │ │ Frame │ │Spacing │ -│Picker│ │ Size │ │ Input │ +│Border│ │ Format │ │Quality │ +│Slider│ │Selector│ │ Slider │ └──────┘ └────────┘ └────────┘ ┌─────────────────┐ │ ActionBar.vue │ -│ (Below Canvas) │ +│ (Action Buttons)│ └────────┬────────┘ │ ┌─────┴──────┐ @@ -114,33 +118,34 @@ User Interaction ``` src/ ├── main.js # Application entry point -├── App.vue # Root component +├── App.vue # Root component with responsive layout ├── assets/ │ └── styles/ -│ └── main.css # Tailwind imports, global styles +│ └── main.css # Tailwind imports, shared component styles ├── components/ │ ├── layout/ -│ │ ├── AppHeader.vue # Header -│ │ ├── ConfigBar.vue # Configuration controls bar (2 rows) -│ │ ├── ActionBar.vue # Action buttons bar (below canvas) +│ │ ├── ConfigBar.vue # Configuration controls container +│ │ ├── ActionBar.vue # Action buttons container │ │ └── CanvasContainer.vue # Canvas wrapper with upload zones │ ├── canvas/ │ │ ├── FrameCanvas.vue # Main Konva canvas component │ │ └── ImageUploadZone.vue # Image upload interface -│ ├── controls/ -│ │ ├── AspectRatioSelector.vue # Button group (3:2, 4:3, 5:4, 16:9) -│ │ ├── ColorPicker.vue # Color swatch + hex input -│ │ ├── DownloadButton.vue # Download canvas button -│ │ ├── FrameSizeInput.vue # Frame size input (uses BaseInput) -│ │ ├── OrientationToggle.vue # Portrait/Landscape toggle -│ │ ├── ResetButton.vue # Reset configuration button -│ │ └── SpacingInput.vue # Spacing input (uses BaseInput) -│ └── shared/ -│ └── BaseInput.vue # Reusable input component +│ ├── shared/ +│ │ └── ConfigElement.vue # Configuration control wrapper +│ └── controls/ +│ ├── AspectRatioSelector.vue # Button group (3:2, 4:3, 5:4, 16:9) +│ ├── BorderSlider.vue # Border percentage slider (1-25%) +│ ├── ColorPicker.vue # HTML5 color picker +│ ├── DownloadButton.vue # Download canvas button +│ ├── FormatSelector.vue # Export format selector (PNG, JPEG, WebP) +│ ├── FrameSizeSelector.vue # Frame size selector (presets + native) +│ ├── OrientationToggle.vue # Portrait/Landscape toggle +│ ├── QualitySlider.vue # Export quality slider (1-100%) +│ └── ResetButton.vue # Reset configuration button ├── composables/ │ ├── useFrameConfig.js # Frame configuration state │ ├── useImageState.js # Image upload and management -│ ├── useCanvasRenderer.js # Canvas rendering logic +│ └── useCanvasRenderer.js # Canvas rendering logic └── utils/ ├── calculations.js # Dimension calculations ├── validation.js # Validation functions @@ -154,26 +159,38 @@ src/ ### 5.1 Component Responsibilities #### **App.vue** -- Root application container -- Provides main layout structure -- Manages canvas stage reference and preview width +- Root application container with responsive layout +- **Desktop**: Horizontal layout with sidebar and canvas area +- **Mobile**: Vertical reverse layout with canvas on top and bottom panel +- **Mobile Panel**: CSS-only slide-up settings panel using checkbox hack + - Chevron button toggles panel visibility + - Panel slides over canvas without JavaScript + - Settings displayed vertically when expanded +- Manages canvas stage reference and preview width calculation - Coordinates ConfigBar, CanvasContainer, and ActionBar -#### **AppHeader.vue** -- Application title ("Framed") - #### **ConfigBar.vue** -- Groups frame configuration controls in 2 rows: - - **Row 1**: OrientationToggle + AspectRatioSelector (side-by-side on desktop, stacked on mobile) - - **Row 2**: ColorPicker + FrameSizeInput + SpacingInput (responsive flex layout) +- Container for all frame configuration and export settings controls +- Vertical stack layout for both desktop and mobile +- Uses ConfigElement wrapper for consistent control presentation +- Contains 7 configuration controls: + - Orientation, Aspect Ratio, Frame Color, Frame Size, Border Size, Export Format, Quality - No props required (uses composables directly) #### **ActionBar.vue** -- Contains action buttons positioned below canvas -- **Desktop**: Buttons aligned right, side-by-side -- **Mobile**: Stacked vertically with Download on top (flex-col-reverse) +- Container for action buttons (Reset and Download) +- **Desktop**: Buttons aligned in sidebar bottom section +- **Mobile**: Fixed at bottom of screen below canvas - Receives stage and previewWidth props for download functionality +#### **ConfigElement.vue** +- Shared wrapper component for consistent configuration control layout +- Provides two slots: + - **label**: Configuration control label (e.g., "Orientation", "Border Size") + - **element**: The actual control component +- Standardizes spacing, typography, and layout across all configuration controls +- Used by all controls in ConfigBar for visual consistency + #### **CanvasContainer.vue** - Canvas wrapper and orchestration - Integrates FrameCanvas with ImageUploadZones @@ -191,47 +208,50 @@ src/ - Displays upload placeholder or preview - Validates image files on upload -#### **OrientationToggle.vue** -- Button group: Portrait and Landscape -- Uses scoped CSS for styling (.orientation-btn, .btn-active, .btn-inactive) -- Full-width responsive layout with equal button distribution +#### **Control Components** + +**OrientationToggle.vue** +- Button group for Portrait and Landscape orientations +- Uses shared selector button styles from main.css -#### **AspectRatioSelector.vue** -- Button group: 3:2, 4:3, 5:4, 16:9 ratios -- Uses scoped CSS matching OrientationToggle style -- Always horizontal layout, buttons shrink/grow equally +**AspectRatioSelector.vue** +- Button group for aspect ratio presets: 3:2, 4:3, 5:4, 16:9 +- Uses shared selector button styles from main.css -#### **ColorPicker.vue** -- Color swatch (40px square) + hex text input -- Uses scoped CSS (.color-picker-swatch, .color-text-input) -- Height matches BaseInput components +**ColorPicker.vue** +- Native HTML5 color picker for frame color selection - Auto-validation and formatting for hex colors -- Hint text: "(hex format)" -#### **FrameSizeInput.vue** -- Uses BaseInput component -- Number input with "px" unit display -- Validation for min/max frame size +**FrameSizeSelector.vue** +- Button group for frame size presets: 1024px, 2048px, 4096px, Native +- Uses shared selector button styles from main.css +- Native option uses original uploaded image dimensions + +**BorderSlider.vue** +- Range slider for border percentage (1-25%) +- Uses shared range slider styles from main.css +- Border calculated as percentage of frame size +- Fixed inner spacing between images (not configurable) -#### **SpacingInput.vue** -- Uses BaseInput component -- Number input with "px" unit display -- Validation for min/max spacing +**FormatSelector.vue** +- Button group for export formats: PNG, JPEG, WebP +- Uses shared selector button styles from main.css +- Dynamically generates filenames at download time -#### **DownloadButton.vue** +**QualitySlider.vue** +- Range slider for export quality (1-100%) +- Uses shared range slider styles from main.css +- Affects compression for JPEG and WebP formats + +**DownloadButton.vue** - Triggers high-resolution canvas export -- Disables when images not uploaded +- Disabled when images not uploaded - Shows loading state during export -#### **ResetButton.vue** +**ResetButton.vue** - Resets all configuration to defaults - Clears uploaded images -#### **BaseInput.vue** -- Shared input component with label, hint, and error display -- Supports unit display (e.g., "px") -- Consistent styling across all form inputs - --- ## 6. State Management @@ -247,9 +267,9 @@ The application uses Vue 3 Composition API composables for state management, pro { orientation: 'portrait' | 'landscape', aspectRatio: '3:2' | '4:3' | '5:4' | '16:9', - backgroundColor: string, // Hex color - frameSize: number, // pixels (default 3000) - spacing: number, // pixels (default 150) + backgroundColor: string, // Hex color + frameSize: number, // pixels (default 2048) + borderPercentage: number, // 1-25% (default 2) frameWidth: computed, // Based on orientation and aspect ratio frameHeight: computed, // Based on orientation and aspect ratio } @@ -262,15 +282,33 @@ The application uses Vue 3 Composition API composables for state management, pro { id: string, file: File, + fileName: string, dataUrl: string, width: number, height: number, + orientation: string, + aspectRatio: number, }, // ... second image ] } ``` +#### **Canvas Renderer** (`useCanvasRenderer.js`) +```javascript +{ + quality: number, // 1-100% (default 85) + format: string, // 'image/png' | 'image/jpeg' | 'image/webp' + stageConfig: computed, + backgroundConfig: computed, + image1Config: computed, + image2Config: computed, + previewScale: computed, + isReady: computed, + canExport: computed, +} +``` + --- ## 7. Konva Integration @@ -355,31 +393,43 @@ Removed, only dark UI ## 10. Testing Strategy -### 10.1 Testing Coverage +### 10.1 Testing Approach -- **Unit Tests (70%)**: Pure functions, utilities, composables -- **Component Tests (25%)**: Vue components in isolation -- **Integration Tests (5%)**: Full user workflows +The application follows a **behavior-driven testing** philosophy, focusing on user interactions, state changes, and business logic rather than implementation details. ### 10.2 Testing Tools - **Vitest**: Test framework with Vite integration - **@vue/test-utils**: Vue component testing utilities - **happy-dom**: Lightweight DOM implementation - -### 10.3 Test Coverage - -| Category | Target | Current Status | -|----------|--------|----------------| -| Overall | 60%+ | ✅ Achieved | -| Utils | 95%+ | ✅ Achieved | -| Composables | 80%+ | ✅ Achieved | -| Components | 70%+ | ✅ Achieved | - -**Total Tests**: 350 tests passing -- All components have dedicated test suites -- Vitest with @vue/test-utils for component testing -- Mock components used for integration tests to avoid dependency issues +- **Coverage**: v8 provider targeting 60%+ coverage for src/ folder only + +### 10.3 Test Focus Areas + +**What We Test:** +- User interactions and button clicks +- State changes via composables +- Business logic (disabled states, format updates, value calculations) +- Edge cases and error handling +- Integration between components +- Accessibility attributes with user interactions +- Event emissions with payloads + +**What We Don't Test:** +- CSS classes and styling +- Layout implementation details (flex, padding, margins) +- TestId attributes +- Input attribute presence (min, max, step) +- Basic DOM structure +- Simple getters/setters without logic + +### 10.4 Coverage Strategy + +- **Unit Tests**: Pure functions, utilities, composables +- **Component Tests**: Vue components focusing on behavior +- **Integration Tests**: Component interaction workflows +- All major components have dedicated test suites +- Mock components used for integration tests to isolate dependencies --- @@ -402,18 +452,14 @@ export const ORIENTATIONS = { export const IMAGE_CONSTRAINTS = { maxFileSize: 40 * 1024 * 1024, // 40MB - minDimension: 800, supportedFormats: ['image/jpeg', 'image/png', 'image/webp'], - aspectRatioTolerance: 0.05, }; export const FRAME_CONSTRAINTS = { - minSize: 2000, - maxSize: 6000, - defaultSize: 3000, - minSpacing: 50, - maxSpacing: 500, - defaultSpacing: 150, + minSize: 800, + maxSize: 10000, + minBorderPercentage: 1, + maxBorderPercentage: 25, }; ``` @@ -421,7 +467,8 @@ export const FRAME_CONSTRAINTS = { Key functions: - `calculateFrameDimensions()`: Calculate frame width/height based on orientation and aspect ratio -- `calculateImageLayout()`: Calculate positions for two images +- `calculateBorderSpacing()`: Calculate border spacing from percentage +- `calculateImageLayout()`: Calculate positions for two images with border and fixed 20px inner spacing - `calculatePreviewScale()`: Calculate scale ratio for responsive preview - `calculateScaledDimensions()`: Fit image within container maintaining aspect ratio - `calculateCenterOffset()`: Center image within slot @@ -429,10 +476,12 @@ Key functions: ### 11.3 Validation (`utils/validation.js`) Key functions: -- `validateImageFile()`: Validate file type and size +- `validateFile()`: Validate file type and size +- `validateImageDimensions()`: Validate image meets minimum dimensions - `validateFrameSize()`: Validate frame size within constraints - `validateSpacing()`: Validate spacing within constraints -- `isValidHexColor()`: Validate hex color codes +- `extractValidFilenameChars()`: Extract valid characters from filename (1-10 chars) +- `generateUuidV1Short()`: Generate 8-character time-based UUID --- @@ -459,17 +508,16 @@ Key functions: - Less boilerplate code - Better integration with Vue lifecycle -### 12.3 Color Picker: Native HTML5 + Text Input +### 12.3 Color Picker: Native HTML5 -**Decision**: Use native `input type="color"` with hex text input +**Decision**: Use native `input type="color"` **Rationale**: - No external dependencies -- Native OS color picker UI for visual selection -- Text input allows precise hex color entry +- Native OS color picker UI - Better accessibility - Smaller bundle size -- Dual input method improves UX +- Direct color selection without additional UI complexity ### 12.4 Canvas Export: pixelRatio Scaling @@ -492,27 +540,47 @@ Key functions: - Clearer visual feedback of current selection - Consistent styling with Tailwind utility classes -### 12.6 Responsive Layout: Flexbox +### 12.6 Responsive Layout: Flexbox with Mobile Slide-up Panel -**Decision**: Use flexbox for responsive layout with mobile-first approach +**Decision**: Use flexbox for responsive layout with CSS-only mobile panel **Rationale**: -- ConfigBar Row 1 & 2: `flex-col` on mobile, `md:flex-row` on desktop -- ActionBar: `flex-col-reverse` on mobile (Download on top), `sm:flex-row sm:justify-end` on desktop -- Equal distribution with `flex-1` on child containers -- Simpler than CSS Grid for this use case +- **Desktop**: Horizontal layout with sidebar (`flex-row`) +- **Mobile**: Vertical reverse layout with bottom panel (`flex-col-reverse`) +- **Mobile Panel**: CSS checkbox hack for slide-up panel without JavaScript + - No state management overhead + - Pure CSS transitions and transforms + - Better performance (no JS event handlers) + - Simpler implementation +- Flexbox simpler than CSS Grid for this use case - Better browser support and performance -### 12.7 CSS Organization: Scoped CSS with Tailwind @apply +### 12.7 CSS Organization: Shared Component Styles -**Decision**: Move repetitive Tailwind classes to scoped CSS using `@apply` +**Decision**: Centralize common component styles in main.css **Rationale**: -- Reduces template clutter -- Easier to maintain consistent styling -- Better separation of concerns (structure vs. styling) -- Improves readability of component templates -- Allows for semantic class names (.btn-active, .ratio-btn, etc.) +- DRY principle - single source of truth for shared component styling +- **Selector buttons**: All selector components share identical button group styles + - Classes: `.selector-group`, `.selector-btn`, `.selector-btn-active`, `.selector-btn-inactive` + - Used by: OrientationToggle, AspectRatioSelector, FrameSizeSelector, FormatSelector +- **Range sliders**: Shared slider styling and value label positioning + - Used by: BorderSlider, QualitySlider +- Better maintainability - style changes in one place +- Reduced code duplication +- Consistent styling guaranteed across all similar controls + +### 12.8 Component Wrapper Pattern: ConfigElement + +**Decision**: Create shared ConfigElement wrapper for all configuration controls + +**Rationale**: +- Consistent label and element layout across all controls +- Single source of truth for configuration control presentation +- Reduces code duplication in ConfigBar +- Easier to maintain and update styling +- Slot-based design allows flexibility while maintaining consistency +- Separates structure (ConfigElement) from functionality (control components) --- @@ -595,14 +663,35 @@ Key functions: --- -**Document Version**: 3.0 -**Last Updated**: 2025-10-10 +**Document Version**: 4.0 +**Last Updated**: 2025-01-24 **Status**: Current --- ## Changelog +### Version 4.0 (2025-01-24) +- **Major UI Update**: Mobile slide-up settings panel with CSS-only implementation + - Documented CSS checkbox hack pattern for mobile panel + - Added mobile UX pattern documentation (chevron toggle, slide-up animation) + - Updated App.vue to reflect responsive layout changes +- **New Component**: ConfigElement.vue wrapper for consistent control layout + - Added to project structure under `shared/` folder + - Documented component wrapper pattern (section 12.8) +- **Component Updates**: + - ConfigBar: Now uses ConfigElement wrapper, single vertical stack layout + - Removed: FileNameInput, SpacingInput, FrameSizeInput, BaseInput components + - Renamed: QualityInput → QualitySlider +- **Architecture Diagram**: Updated to reflect ConfigElement and mobile panel pattern +- **Testing Strategy**: Rewritten to focus on behavior-driven testing approach + - Removed specific test counts and coverage percentages + - Added "What We Test" vs "What We Don't Test" guidelines + - Coverage now targets src/ folder only +- **CSS Organization**: Documented shared range slider styles alongside selector styles +- **Responsive Layout**: Updated section 12.6 to document mobile panel decision +- **Technology Stack**: Updated Tailwind CSS version + ### Version 3.0 (2025-10-10) - Updated architecture diagram to reflect ActionBar component separation - Added ActionBar.vue component documentation @@ -613,7 +702,6 @@ Key functions: - Updated constants to include FRAME_CONSTRAINTS - Added validation functions for frame size and spacing - Documented UI refactoring decisions (sections 12.5-12.7) -- Updated test coverage with current statistics (350 tests) - Removed BaseSelect.vue from project structure (no longer used) ### Version 2.0 (2025-10-09) diff --git a/package.json b/package.json index 5e0a840..715eb87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "erost-framed", - "version": "1.1.0", + "version": "2.0.0", "description": "Canvas-based picture framing application", "type": "module", "scripts": { diff --git a/public/manifest.json b/public/manifest.json index 6a9dd1a..7b1bdd1 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -66,18 +66,39 @@ "categories": ["photography", "photo"], "screenshots": [ { - "src": "/screenshots/framed-wide.png", - "sizes": "2328x1164", + "src": "/screenshots/desktop-1280x800-landscape.png", + "sizes": "1280x800", "type": "image/png", "form_factor": "wide", - "label": "Framed desktop" + "label": "Desktop version, landscape frame" }, { - "src": "/screenshots/framed-portrait.png", - "sizes": "625x1056", + "src": "/screenshots/desktop-1280x800-portrait.png", + "sizes": "1280x800", + "type": "image/png", + "form_factor": "wide", + "label": "Desktop version, portrait frame" + }, + { + "src": "/screenshots/mobile-402x764-portrait.png", + "sizes": "402x764", + "type": "image/png", + "form_factor": "narrow", + "label": "Mobile version, portrait frame" + }, + { + "src": "/screenshots/mobile-402x764-portrait-settings.png", + "sizes": "402x764", + "type": "image/png", + "form_factor": "narrow", + "label": "Mobile version, open settings" + }, + { + "src": "/screenshots/mobile-402x764-landscape.png", + "sizes": "402x764", "type": "image/png", "form_factor": "narrow", - "label": "Framed mobile" + "label": "Mobile version, landscape frame" } ] } diff --git a/public/screenshots/desktop-1280x800-landscape.png b/public/screenshots/desktop-1280x800-landscape.png new file mode 100644 index 0000000..16ff56e Binary files /dev/null and b/public/screenshots/desktop-1280x800-landscape.png differ diff --git a/public/screenshots/desktop-1280x800-portrait.png b/public/screenshots/desktop-1280x800-portrait.png new file mode 100644 index 0000000..77da7a2 Binary files /dev/null and b/public/screenshots/desktop-1280x800-portrait.png differ diff --git a/public/screenshots/framed-portrait.png b/public/screenshots/framed-portrait.png deleted file mode 100644 index bbd6c01..0000000 Binary files a/public/screenshots/framed-portrait.png and /dev/null differ diff --git a/public/screenshots/framed-wide.png b/public/screenshots/framed-wide.png deleted file mode 100644 index b84b2d0..0000000 Binary files a/public/screenshots/framed-wide.png and /dev/null differ diff --git a/public/screenshots/mobile-402x764-landscape.png b/public/screenshots/mobile-402x764-landscape.png new file mode 100644 index 0000000..1b3fdc8 Binary files /dev/null and b/public/screenshots/mobile-402x764-landscape.png differ diff --git a/public/screenshots/mobile-402x764-portrait-settings.png b/public/screenshots/mobile-402x764-portrait-settings.png new file mode 100644 index 0000000..5c820ef Binary files /dev/null and b/public/screenshots/mobile-402x764-portrait-settings.png differ diff --git a/public/screenshots/mobile-402x764-portrait.png b/public/screenshots/mobile-402x764-portrait.png new file mode 100644 index 0000000..8e5ef6c Binary files /dev/null and b/public/screenshots/mobile-402x764-portrait.png differ diff --git a/src/App.vue b/src/App.vue index af8c86a..e1d86eb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,69 +3,114 @@ Picture Frame Creator - Main application layout --> + + diff --git a/src/assets/styles/main.css b/src/assets/styles/main.css index 9506d6c..4279b33 100644 --- a/src/assets/styles/main.css +++ b/src/assets/styles/main.css @@ -36,4 +36,112 @@ @apply bg-gray-50 dark:bg-gray-700; @apply border-b border-gray-200 dark:border-gray-600; } + + /** + * Shared button group selector styles + * Used by: OrientationToggle, AspectRatioSelector, FrameSizeSelector, FormatSelector + */ + + /* Container for button group */ + .selector-group { + @apply flex w-full rounded-lg border border-gray-600 p-1 bg-gray-800; + } + + /* Base button styles for selector buttons */ + .selector-btn { + @apply flex flex-1 items-center justify-center px-3 py-2 text-sm font-medium rounded-md; + @apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 whitespace-nowrap; + @apply transition-colors; + } + + /* Active button state (currently selected option) */ + .selector-btn-active { + @apply bg-gray-700 text-blue-400 shadow-sm; + } + + /* Inactive button state (non-selected options) */ + .selector-btn-inactive { + @apply text-gray-300 hover:text-gray-100 hover:bg-gray-700 cursor-pointer; + } + + /** + * Shared range slider styles + * Used by: QualitySlider, BorderSlider + */ + + /* Range input with value label in thumb */ + .range-input-with-value { + @apply w-full appearance-none cursor-pointer bg-transparent; + @apply focus:outline-none; + height: 32px; /* Increased height to accommodate squared thumb */ + } + + .range-input-with-value::-webkit-slider-thumb { + @apply appearance-none rounded cursor-pointer opacity-0; + width: 32px; + height: 32px; + } + + .range-input-with-value::-moz-range-thumb { + @apply rounded border-0 cursor-pointer opacity-0; + width: 32px; + height: 32px; + } + + .range-input-with-value::-webkit-slider-runnable-track { + @apply w-full h-2 rounded-lg; + @apply bg-gray-200 dark:bg-gray-600; + } + + .range-input-with-value::-moz-range-track { + @apply w-full h-2 rounded-lg; + @apply bg-gray-200 dark:bg-gray-600; + } + + /* Value label that appears as thumb */ + .range-value-label { + @apply absolute pointer-events-none; + @apply flex items-center justify-center; + @apply bg-blue-600 dark:bg-blue-500 text-white; + @apply rounded font-medium text-xs; + @apply transition-colors; + width: 32px; + height: 32px; + top: 0px; + } + + .range-input-with-value:hover + .range-value-label { + @apply bg-blue-700 dark:bg-blue-600; + } + + .range-input-with-value:focus + .range-value-label { + @apply ring-2 ring-blue-500 ring-offset-2; + } + + .range-input-with-value:disabled + .range-value-label { + @apply opacity-50 cursor-not-allowed; + } + + /** + * Shared action button styles + * Used by: DownloadButton, ResetButton + */ + + /* Base action button styles (common layout and interaction) */ + .action-btn { + @apply w-full inline-flex items-center justify-center; + @apply font-medium rounded-lg transition-colors; + @apply focus:outline-none focus:ring-2 focus:ring-offset-2; + @apply px-4 py-2 text-base cursor-pointer; + } + + /* Primary action button variant (blue) */ + .action-btn-primary { + @apply bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-500; + } + + /* Secondary action button variant (gray) */ + .action-btn-secondary { + @apply bg-gray-700 text-gray-100 hover:bg-gray-600 focus:ring-gray-500; + } } diff --git a/src/components/canvas/FrameCanvas.vue b/src/components/canvas/FrameCanvas.vue index ba2a883..cad9c70 100644 --- a/src/components/canvas/FrameCanvas.vue +++ b/src/components/canvas/FrameCanvas.vue @@ -87,7 +87,7 @@ />

- {{ orientation === 'portrait' ? 'Top' : 'Left' }} Image + {{ orientation === ORIENTATIONS.PORTRAIT ? 'Top' : 'Left' }} Image

Click or drop @@ -158,7 +158,7 @@ />

- {{ orientation === 'portrait' ? 'Bottom' : 'Right' }} Image + {{ orientation === ORIENTATIONS.PORTRAIT ? 'Bottom' : 'Right' }} Image

Click or drop @@ -228,7 +228,8 @@ import { ref, computed, onMounted, watch, toRef } from 'vue'; import { useCanvasRenderer } from '@/composables/useCanvasRenderer'; import { useImageState } from '@/composables/useImageState'; import { useFrameConfig } from '@/composables/useFrameConfig'; -import { PREVIEW_CONSTRAINTS } from '@/utils/constants'; +import { PREVIEW_CONSTRAINTS, ORIENTATIONS } from '@/utils/constants'; +import { logError } from '@/utils/logger'; /** * FrameCanvas component @@ -276,28 +277,44 @@ const { const { images, addImage, removeImage: removeImageFromState } = useImageState(); const { orientation } = useFrameConfig(); +/** + * Create an image element from a data URL + * @param {string} dataUrl - The data URL to load + * @returns {Promise} Promise that resolves with the loaded image + */ +const createImageElement = (dataUrl) => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to load image')); + img.src = dataUrl; + }); +}; + /** * Load image elements from dataUrls */ -watch(() => images.value[0], (newImage) => { +watch(() => images.value[0], async (newImage) => { if (newImage && newImage.dataUrl) { - const img = new Image(); - img.onload = () => { - image1Element.value = img; - }; - img.src = newImage.dataUrl; + try { + image1Element.value = await createImageElement(newImage.dataUrl); + } catch (error) { + logError('Failed to load image 1:', error); + image1Element.value = null; + } } else { image1Element.value = null; } }, { immediate: true }); -watch(() => images.value[1], (newImage) => { +watch(() => images.value[1], async (newImage) => { if (newImage && newImage.dataUrl) { - const img = new Image(); - img.onload = () => { - image2Element.value = img; - }; - img.src = newImage.dataUrl; + try { + image2Element.value = await createImageElement(newImage.dataUrl); + } catch (error) { + logError('Failed to load image 2:', error); + image2Element.value = null; + } } else { image2Element.value = null; } @@ -406,7 +423,7 @@ const processFile = async (file, position) => { try { await addImage(file, position); } catch (error) { - console.error('Image upload error:', error); + logError('Image upload error:', error); } }; diff --git a/src/components/canvas/ImageUploadZone.vue b/src/components/canvas/ImageUploadZone.vue index 01ea6dc..3cbbd62 100644 --- a/src/components/canvas/ImageUploadZone.vue +++ b/src/components/canvas/ImageUploadZone.vue @@ -46,7 +46,7 @@ Click or drag & drop

- JPEG, PNG, or WebP (max 10MB) + JPEG, PNG, or WebP (max {{ maxFileSizeMB }}MB)

@@ -95,6 +95,7 @@ - - diff --git a/src/components/controls/BorderSlider.vue b/src/components/controls/BorderSlider.vue new file mode 100644 index 0000000..5335aa0 --- /dev/null +++ b/src/components/controls/BorderSlider.vue @@ -0,0 +1,69 @@ + + + + diff --git a/src/components/controls/ColorPicker.vue b/src/components/controls/ColorPicker.vue index ef58afc..ee67467 100644 --- a/src/components/controls/ColorPicker.vue +++ b/src/components/controls/ColorPicker.vue @@ -1,54 +1,87 @@ diff --git a/src/components/controls/DownloadButton.vue b/src/components/controls/DownloadButton.vue index afa5fb9..1e6c6e5 100644 --- a/src/components/controls/DownloadButton.vue +++ b/src/components/controls/DownloadButton.vue @@ -4,13 +4,9 @@ --> \ No newline at end of file diff --git a/src/components/controls/FormatSelector.vue b/src/components/controls/FormatSelector.vue index 6332e8d..851e92e 100644 --- a/src/components/controls/FormatSelector.vue +++ b/src/components/controls/FormatSelector.vue @@ -1,32 +1,32 @@ @@ -34,6 +34,11 @@ import { useCanvasRenderer } from '@/composables/useCanvasRenderer'; import { IMAGE_FORMATS } from '@/utils/constants'; +/** + * FormatSelector component + * Allows users to select the export format from predefined options using a button group + */ + defineProps({ /** * Test ID for testing @@ -47,10 +52,10 @@ defineProps({ const { format, updateFormat } = useCanvasRenderer(); /** - * Handle format change - * @param {Event} event - Change event + * Handle format selection + * Updates the canvas renderer with the selected format */ -const handleFormatChange = (event) => { - updateFormat(event.target.value); +const handleSelect = (mimeType) => { + updateFormat(mimeType); }; diff --git a/src/components/controls/FrameSizeInput.vue b/src/components/controls/FrameSizeInput.vue deleted file mode 100644 index 3c66819..0000000 --- a/src/components/controls/FrameSizeInput.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - diff --git a/src/components/controls/FrameSizeSelector.vue b/src/components/controls/FrameSizeSelector.vue new file mode 100644 index 0000000..88463b4 --- /dev/null +++ b/src/components/controls/FrameSizeSelector.vue @@ -0,0 +1,68 @@ + + + + diff --git a/src/components/controls/OrientationToggle.vue b/src/components/controls/OrientationToggle.vue index 21f2fc1..e29b3b4 100644 --- a/src/components/controls/OrientationToggle.vue +++ b/src/components/controls/OrientationToggle.vue @@ -4,83 +4,80 @@ --> - - diff --git a/src/components/controls/QualityInput.vue b/src/components/controls/QualityInput.vue deleted file mode 100644 index a5911d8..0000000 --- a/src/components/controls/QualityInput.vue +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/components/controls/QualitySlider.vue b/src/components/controls/QualitySlider.vue new file mode 100644 index 0000000..6a3540c --- /dev/null +++ b/src/components/controls/QualitySlider.vue @@ -0,0 +1,52 @@ + + + + diff --git a/src/components/controls/ResetButton.vue b/src/components/controls/ResetButton.vue index 9b3d12a..e2edd2b 100644 --- a/src/components/controls/ResetButton.vue +++ b/src/components/controls/ResetButton.vue @@ -4,11 +4,7 @@ -->