Bug fixes
This commit is contained in:
423
SWIPE_ANIMATIONS.md
Normal file
423
SWIPE_ANIMATIONS.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Enhanced Swipe Animation System
|
||||
|
||||
A comprehensive physics-based animation system for the image swiper app, providing realistic swipe interactions with spring effects, momentum calculations, and dynamic visual feedback.
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced swipe animation system consists of several key components:
|
||||
|
||||
- **SwipePhysics Engine**: Core physics calculations for realistic motion
|
||||
- **EnhancedSwipeCard**: Advanced swipe card component with visual effects
|
||||
- **SwipeIntegrationManager**: Seamless switching between animation modes
|
||||
- **Visual Effects System**: Dynamic feedback during swipe gestures
|
||||
|
||||
## Features
|
||||
|
||||
### 🎯 Physics-Based Animations
|
||||
- **Spring Effects**: Realistic spring-back animations when swipe threshold isn't met
|
||||
- **Momentum Calculations**: Velocity-based swipe detection and exit animations
|
||||
- **Smooth Transitions**: 60fps animations with proper GPU acceleration
|
||||
|
||||
### 🎨 Visual Feedback
|
||||
- **Dynamic Blur**: Motion blur effects based on swipe velocity
|
||||
- **Color Shifts**: Direction-based hue rotation for visual feedback
|
||||
- **3D Transforms**: Enhanced perspective and rotation effects
|
||||
- **Particle Effects**: Trailing particles during swipe gestures
|
||||
|
||||
### 📱 Device Optimization
|
||||
- **Adaptive Quality**: Automatic animation quality adjustment based on device capabilities
|
||||
- **Performance Monitoring**: Real-time FPS tracking and optimization
|
||||
- **Reduced Motion Support**: Accessibility compliance for motion-sensitive users
|
||||
|
||||
### ⚙️ Customization
|
||||
- **Multiple Modes**: Original, Enhanced Lite, and Full Enhanced modes
|
||||
- **User Settings**: Configurable physics parameters and visual effects
|
||||
- **Debug Tools**: Performance metrics and state inspection
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
#### SwipePhysics Class
|
||||
```javascript
|
||||
import { SwipePhysics } from './js/swipe-physics.js';
|
||||
|
||||
const physics = new SwipePhysics({
|
||||
springTension: 300, // Spring stiffness
|
||||
springFriction: 30, // Damping coefficient
|
||||
mass: 1, // Object mass
|
||||
velocityThreshold: 0.5, // Minimum velocity for momentum
|
||||
momentumDecay: 0.95 // Momentum decay rate
|
||||
});
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
- `updatePosition(x, y, timestamp)`: Track position and calculate velocity
|
||||
- `animateSpringTo(targetX, targetY, onUpdate, onComplete)`: Spring-back animation
|
||||
- `animateMomentumExit(direction, onUpdate, onComplete)`: Momentum-based exit
|
||||
- `getVelocityMagnitude()`: Current velocity magnitude
|
||||
- `hasSwipeMomentum(threshold)`: Check if swipe has sufficient momentum
|
||||
|
||||
#### EnhancedSwipeCard Component
|
||||
```javascript
|
||||
import EnhancedSwipeCard from './components/enhanced-swipe-card.js';
|
||||
|
||||
const swipeCard = new EnhancedSwipeCard({
|
||||
container: document.querySelector('.swipe-container'),
|
||||
onSwipe: (direction) => console.log('Swiped:', direction),
|
||||
threshold: 100,
|
||||
velocityThreshold: 2,
|
||||
springTension: 280,
|
||||
springFriction: 25
|
||||
});
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Physics-based drag interactions
|
||||
- Enhanced 3D tilt effects
|
||||
- Visual feedback during swipes
|
||||
- Smooth loading animations
|
||||
- Performance optimizations
|
||||
|
||||
#### SwipeIntegrationManager
|
||||
```javascript
|
||||
import { swipeIntegration } from './js/swipe-integration.js';
|
||||
|
||||
// Initialize with automatic mode detection
|
||||
await swipeIntegration.initialize(container, onSwipe);
|
||||
|
||||
// Switch modes dynamically
|
||||
await swipeIntegration.switchMode('enhanced');
|
||||
|
||||
// Update configuration
|
||||
swipeIntegration.updateConfig({
|
||||
enablePhysics: true,
|
||||
enableVisualEffects: true,
|
||||
animationQuality: 'high'
|
||||
});
|
||||
```
|
||||
|
||||
## Animation Modes
|
||||
|
||||
### Original Mode
|
||||
- **Use Case**: Low-end devices, reduced motion preferences
|
||||
- **Features**: Basic swipe detection, simple CSS transitions
|
||||
- **Performance**: Minimal CPU/GPU usage
|
||||
|
||||
### Enhanced Lite Mode
|
||||
- **Use Case**: Mobile devices, moderate performance
|
||||
- **Features**: Physics-based springs, reduced visual effects
|
||||
- **Performance**: Balanced performance and visual quality
|
||||
|
||||
### Enhanced Mode
|
||||
- **Use Case**: Desktop, high-performance devices
|
||||
- **Features**: Full physics simulation, all visual effects
|
||||
- **Performance**: Maximum visual quality, GPU-accelerated
|
||||
|
||||
## Physics Implementation
|
||||
|
||||
### Spring Dynamics
|
||||
The system uses Hooke's Law with damping for realistic spring behavior:
|
||||
|
||||
```
|
||||
F = -kx - cv
|
||||
```
|
||||
|
||||
Where:
|
||||
- `F` = Spring force
|
||||
- `k` = Spring tension (stiffness)
|
||||
- `x` = Displacement from rest position
|
||||
- `c` = Friction coefficient (damping)
|
||||
- `v` = Velocity
|
||||
|
||||
### Velocity Tracking
|
||||
Velocity is calculated using time-weighted sampling:
|
||||
|
||||
```javascript
|
||||
const velocity = (currentPosition - previousPosition) / deltaTime;
|
||||
```
|
||||
|
||||
Multiple samples are averaged with recent values weighted more heavily for smooth velocity calculations.
|
||||
|
||||
### Momentum-Based Actions
|
||||
Swipe actions are triggered by either:
|
||||
1. **Distance threshold**: Displacement exceeds minimum distance
|
||||
2. **Velocity threshold**: Swipe velocity exceeds minimum speed
|
||||
|
||||
## Visual Effects
|
||||
|
||||
### Dynamic Blur
|
||||
Motion blur is applied based on velocity magnitude:
|
||||
```css
|
||||
filter: blur(${velocity * 0.1}px);
|
||||
```
|
||||
|
||||
### Color Temperature Shifts
|
||||
Direction-based hue rotation provides visual feedback:
|
||||
- **Left (Discard)**: Red shift (-30°)
|
||||
- **Right (Keep)**: Green shift (120°)
|
||||
- **Up (Favorite)**: Blue shift (240°)
|
||||
- **Down (Review)**: Yellow shift (60°)
|
||||
|
||||
### 3D Transforms
|
||||
Enhanced perspective transforms create depth:
|
||||
```css
|
||||
transform:
|
||||
perspective(1200px)
|
||||
rotateX(${rotationX}deg)
|
||||
rotateY(${rotationY}deg)
|
||||
translateZ(${depth}px)
|
||||
scale3d(${scale}, ${scale}, 1);
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### GPU Acceleration
|
||||
- `transform3d()` for hardware acceleration
|
||||
- `will-change` property for optimization hints
|
||||
- `backface-visibility: hidden` to prevent flickering
|
||||
|
||||
### Frame Rate Monitoring
|
||||
```javascript
|
||||
// Automatic quality adjustment based on FPS
|
||||
if (fps < 30 && animationQuality === 'high') {
|
||||
animationQuality = 'medium';
|
||||
}
|
||||
```
|
||||
|
||||
### Memory Management
|
||||
- Particle system with limited count
|
||||
- Velocity history with bounded size
|
||||
- Automatic cleanup of animation frames
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Physics Parameters
|
||||
```javascript
|
||||
{
|
||||
springTension: 300, // Spring stiffness (50-500)
|
||||
springFriction: 30, // Damping coefficient (10-50)
|
||||
mass: 1, // Object mass (0.5-2.0)
|
||||
velocityThreshold: 0.5, // Minimum velocity for momentum
|
||||
momentumDecay: 0.95, // Momentum decay rate (0.8-0.98)
|
||||
maxVelocity: 50, // Maximum velocity clamp
|
||||
dampingRatio: 0.8, // Overall damping (0.1-1.0)
|
||||
precision: 0.01, // Animation stop precision
|
||||
maxDuration: 2000 // Maximum animation duration (ms)
|
||||
}
|
||||
```
|
||||
|
||||
### Visual Effects
|
||||
```javascript
|
||||
{
|
||||
maxBlur: 8, // Maximum blur amount (px)
|
||||
maxOpacity: 0.3, // Maximum opacity reduction
|
||||
colorShiftIntensity: 0.2, // Color shift strength
|
||||
particleCount: 20, // Maximum particles
|
||||
enable3DTilt: true, // Enable 3D tilt effects
|
||||
enableParticles: true, // Enable particle trails
|
||||
enableHaptics: true // Enable haptic feedback
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Integration
|
||||
```javascript
|
||||
import { swipeIntegration } from './js/swipe-integration.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const container = document.querySelector('.swipe-container');
|
||||
|
||||
await swipeIntegration.initialize(container, (direction) => {
|
||||
console.log('Swiped:', direction);
|
||||
// Handle swipe action
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
```javascript
|
||||
// Configure before initialization
|
||||
swipeIntegration.updateConfig({
|
||||
enablePhysics: true,
|
||||
enableVisualEffects: true,
|
||||
animationQuality: 'high',
|
||||
springTension: 320,
|
||||
springFriction: 25
|
||||
});
|
||||
|
||||
await swipeIntegration.initialize(container, onSwipe);
|
||||
```
|
||||
|
||||
### Mode Switching
|
||||
```javascript
|
||||
// Switch to enhanced mode
|
||||
await swipeIntegration.switchMode('enhanced');
|
||||
|
||||
// Switch to lite mode for performance
|
||||
await swipeIntegration.switchMode('enhanced-lite');
|
||||
|
||||
// Fallback to original mode
|
||||
await swipeIntegration.switchMode('original');
|
||||
```
|
||||
|
||||
### Settings Panel
|
||||
```javascript
|
||||
import { createSwipeSettingsPanel } from './js/swipe-integration.js';
|
||||
|
||||
// Create settings panel
|
||||
const settingsPanel = createSwipeSettingsPanel(swipeIntegration);
|
||||
document.body.appendChild(settingsPanel);
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Features
|
||||
- **Modern Browsers**: Full enhanced mode support
|
||||
- **Safari**: Full support with vendor prefixes
|
||||
- **Mobile Browsers**: Enhanced lite mode recommended
|
||||
- **Legacy Browsers**: Automatic fallback to original mode
|
||||
|
||||
### Required APIs
|
||||
- `requestAnimationFrame` (required)
|
||||
- `performance.now()` (required)
|
||||
- `CSS transforms` (required)
|
||||
- `CSS filters` (optional, for visual effects)
|
||||
- `Vibration API` (optional, for haptic feedback)
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Reduced Motion Support
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.enhanced-card {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### High Contrast Mode
|
||||
```css
|
||||
@media (prefers-contrast: high) {
|
||||
.enhanced-hint {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
- **Arrow Keys**: Trigger swipe actions
|
||||
- **WASD Keys**: Alternative swipe controls
|
||||
- **Ctrl/Cmd + ,**: Open settings panel
|
||||
- **Escape**: Close modals and panels
|
||||
|
||||
## Debugging
|
||||
|
||||
### Debug Mode
|
||||
```javascript
|
||||
swipeIntegration.updateConfig({ debugMode: true });
|
||||
|
||||
// Access debug information
|
||||
const debugInfo = swipeIntegration.getDebugInfo();
|
||||
console.log('Debug Info:', debugInfo);
|
||||
```
|
||||
|
||||
### Performance Metrics
|
||||
```javascript
|
||||
const metrics = swipeIntegration.getPerformanceMetrics();
|
||||
console.log('Performance:', metrics);
|
||||
```
|
||||
|
||||
### State Inspection
|
||||
```javascript
|
||||
// Access global debug objects
|
||||
console.log('Swipe State:', window.enhancedSwipeState);
|
||||
console.log('Swipe Card:', window.enhancedSwipeCard);
|
||||
console.log('Integration:', window.swipeIntegration);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Poor Performance
|
||||
- **Solution**: Switch to 'enhanced-lite' or 'original' mode
|
||||
- **Check**: Device memory and CPU capabilities
|
||||
- **Adjust**: Reduce particle count and visual effects
|
||||
|
||||
#### Animations Not Working
|
||||
- **Check**: Browser support for CSS transforms
|
||||
- **Verify**: JavaScript modules are loading correctly
|
||||
- **Test**: Fallback to original mode
|
||||
|
||||
#### Touch Events Not Responding
|
||||
- **Check**: Touch event listeners are properly attached
|
||||
- **Verify**: `touch-action: none` is applied to card element
|
||||
- **Test**: Mouse events as fallback
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. **Use Enhanced Lite on Mobile**: Better performance with good visual quality
|
||||
2. **Monitor Frame Rate**: Enable debug mode to track performance
|
||||
3. **Reduce Particle Count**: Lower particle count for better performance
|
||||
4. **Disable Visual Effects**: Turn off blur and color effects on low-end devices
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
- **Card Stacking**: Multiple cards with depth layering
|
||||
- **Gesture Recognition**: Advanced swipe patterns
|
||||
- **Sound Effects**: Audio feedback for actions
|
||||
- **Custom Animations**: User-defined animation curves
|
||||
|
||||
### API Improvements
|
||||
- **React Integration**: React component wrapper
|
||||
- **Vue Integration**: Vue component wrapper
|
||||
- **TypeScript Support**: Full type definitions
|
||||
- **Web Components**: Custom element implementation
|
||||
|
||||
## Contributing
|
||||
|
||||
### Development Setup
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run unit tests
|
||||
npm test
|
||||
|
||||
# Run performance tests
|
||||
npm run test:performance
|
||||
|
||||
# Run accessibility tests
|
||||
npm run test:a11y
|
||||
```
|
||||
|
||||
### Code Style
|
||||
- Use ESLint configuration
|
||||
- Follow JSDoc conventions
|
||||
- Maintain 80% test coverage
|
||||
- Use semantic versioning
|
||||
|
||||
## License
|
||||
|
||||
This enhanced swipe animation system is part of the image swiper application and follows the same licensing terms.
|
||||
|
||||
---
|
||||
|
||||
For more information, see the individual component documentation files or contact the development team.
|
||||
497
components/enhanced-swipe-card.js
Normal file
497
components/enhanced-swipe-card.js
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Enhanced Swipe Card Component with Physics-Based Animations
|
||||
* Integrates SwipePhysics engine for realistic swipe interactions
|
||||
*/
|
||||
|
||||
import { SwipePhysics, SwipeVisualEffects } from '../js/swipe-physics.js';
|
||||
|
||||
export default class EnhancedSwipeCard {
|
||||
constructor(options = {}) {
|
||||
this.container = options.container || document.querySelector('.swipe-container');
|
||||
this.onSwipe = options.onSwipe || (() => {});
|
||||
this.threshold = options.threshold || 100;
|
||||
this.velocityThreshold = options.velocityThreshold || 2;
|
||||
|
||||
// Physics engine configuration
|
||||
this.physics = new SwipePhysics({
|
||||
springTension: options.springTension || 280,
|
||||
springFriction: options.springFriction || 25,
|
||||
mass: options.mass || 1.2,
|
||||
velocityThreshold: this.velocityThreshold,
|
||||
momentumDecay: options.momentumDecay || 0.92,
|
||||
maxVelocity: options.maxVelocity || 40,
|
||||
dampingRatio: options.dampingRatio || 0.75
|
||||
});
|
||||
|
||||
// State management
|
||||
this.state = {
|
||||
isDragging: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
hasMoved: false,
|
||||
touchStartTime: 0,
|
||||
currentImageInfo: null,
|
||||
isAnimating: false
|
||||
};
|
||||
|
||||
// DOM elements
|
||||
this.card = null;
|
||||
this.actionHints = null;
|
||||
this.decisionIndicators = {};
|
||||
this.visualEffects = null;
|
||||
|
||||
// Performance optimization
|
||||
this.rafId = null;
|
||||
this.lastUpdateTime = 0;
|
||||
this.updateThrottle = 16; // ~60fps
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupCard();
|
||||
this.setupActionHints();
|
||||
this.setupDecisionIndicators();
|
||||
this.setupVisualEffects();
|
||||
this.addEventListeners();
|
||||
this.optimizePerformance();
|
||||
|
||||
console.log('Enhanced SwipeCard initialized with physics engine');
|
||||
}
|
||||
|
||||
setupCard() {
|
||||
this.card = this.container.querySelector('.image-card') || this.createCardElement();
|
||||
|
||||
// Optimize for animations
|
||||
this.card.style.willChange = 'transform, filter, opacity';
|
||||
this.card.style.backfaceVisibility = 'hidden';
|
||||
this.card.style.transformStyle = 'preserve-3d';
|
||||
}
|
||||
|
||||
createCardElement() {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'image-card enhanced-card';
|
||||
card.id = 'current-card';
|
||||
card.setAttribute('role', 'img');
|
||||
card.setAttribute('aria-label', 'Image to be swiped');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%22400%22%20height%3D%22400%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%22400%22%20height%3D%22400%22%20fill%3D%22%23e0e0e0%22%2F%3E%3Ctext%20x%3D%22200%22%20y%3D%22200%22%20font-size%3D%2220%22%20text-anchor%3D%22middle%22%20alignment-baseline%3D%22middle%22%20fill%3D%22%23999%22%3ELoading...%3C%2Ftext%3E%3C%2Fsvg%3E';
|
||||
img.alt = 'Image';
|
||||
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'loading-indicator enhanced-loading';
|
||||
loadingIndicator.innerHTML = `
|
||||
<div class="loading-spinner physics-spinner"></div>
|
||||
<div class="loading-text">Loading...</div>
|
||||
`;
|
||||
|
||||
card.appendChild(img);
|
||||
card.appendChild(loadingIndicator);
|
||||
this.container.appendChild(card);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
setupActionHints() {
|
||||
this.actionHints = {
|
||||
left: this.container.querySelector('.left-hint') || this.createActionHint('left', 'Discard'),
|
||||
right: this.container.querySelector('.right-hint') || this.createActionHint('right', 'Keep'),
|
||||
up: this.container.querySelector('.up-hint') || this.createActionHint('up', 'Favorite'),
|
||||
down: this.container.querySelector('.down-hint') || this.createActionHint('down', 'Review')
|
||||
};
|
||||
}
|
||||
|
||||
createActionHint(direction, text) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = `action-hint ${direction}-hint enhanced-hint`;
|
||||
hint.innerHTML = `
|
||||
<div class="hint-icon"></div>
|
||||
<span class="hint-text">${text}</span>
|
||||
`;
|
||||
this.container.appendChild(hint);
|
||||
return hint;
|
||||
}
|
||||
|
||||
setupDecisionIndicators() {
|
||||
// Decision indicators removed - no longer creating visual icons
|
||||
this.decisionIndicators = {
|
||||
left: null,
|
||||
right: null,
|
||||
up: null,
|
||||
down: null
|
||||
};
|
||||
}
|
||||
|
||||
setupVisualEffects() {
|
||||
this.visualEffects = new SwipeVisualEffects(this.card, {
|
||||
maxBlur: 6,
|
||||
maxOpacity: 0.25,
|
||||
colorShiftIntensity: 0.3,
|
||||
particleCount: 15
|
||||
});
|
||||
}
|
||||
|
||||
optimizePerformance() {
|
||||
// Enable GPU acceleration for container
|
||||
this.container.style.transform = 'translateZ(0)';
|
||||
this.container.style.willChange = 'transform';
|
||||
|
||||
// Optimize child elements
|
||||
const elements = this.container.querySelectorAll('.action-hint, .swipe-decision');
|
||||
elements.forEach(el => {
|
||||
el.style.willChange = 'transform, opacity';
|
||||
el.style.backfaceVisibility = 'hidden';
|
||||
});
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
// Bind methods to preserve context for removal
|
||||
this.boundHandlePointerDown = (e) => this.handlePointerDown(e.clientX, e.clientY, e.timeStamp);
|
||||
this.boundHandlePointerMove = (e) => this.handlePointerMove(e.clientX, e.clientY, e.timeStamp);
|
||||
this.boundHandlePointerUp = (e) => this.handlePointerUp(e.timeStamp);
|
||||
|
||||
// Mouse events
|
||||
this.card.addEventListener('mousedown', this.boundHandlePointerDown);
|
||||
document.addEventListener('mousemove', this.boundHandlePointerMove);
|
||||
document.addEventListener('mouseup', this.boundHandlePointerUp);
|
||||
|
||||
// Touch events
|
||||
this.card.addEventListener('touchstart', e => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.handlePointerDown(touch.clientX, touch.clientY, e.timeStamp);
|
||||
}, { passive: false });
|
||||
|
||||
this.card.addEventListener('touchmove', e => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.handlePointerMove(touch.clientX, touch.clientY, e.timeStamp);
|
||||
}, { passive: false });
|
||||
|
||||
this.card.addEventListener('touchend', e => {
|
||||
e.preventDefault();
|
||||
this.handlePointerUp(e.timeStamp);
|
||||
}, { passive: false });
|
||||
|
||||
// Enhanced 3D tilt effect
|
||||
this.setupEnhanced3DTilt();
|
||||
}
|
||||
|
||||
setupEnhanced3DTilt() {
|
||||
// 3D tilt effect disabled for cleaner UX - no hover animations on image
|
||||
}
|
||||
|
||||
handlePointerDown(x, y, timestamp) {
|
||||
if (this.state.isAnimating) return;
|
||||
|
||||
this.state.isDragging = true;
|
||||
this.state.startX = x;
|
||||
this.state.startY = y;
|
||||
this.state.hasMoved = false;
|
||||
this.state.touchStartTime = timestamp;
|
||||
|
||||
// Initialize physics
|
||||
this.physics.reset();
|
||||
this.physics.updatePosition(0, 0, timestamp);
|
||||
|
||||
// Visual feedback
|
||||
this.card.classList.add('swiping', 'enhanced-swiping');
|
||||
this.card.style.transition = '';
|
||||
this.card.style.cursor = 'grabbing';
|
||||
|
||||
// Start update loop
|
||||
this.startUpdateLoop();
|
||||
}
|
||||
|
||||
handlePointerMove(x, y, timestamp) {
|
||||
if (!this.state.isDragging) return;
|
||||
|
||||
// Throttle updates for performance
|
||||
if (timestamp - this.lastUpdateTime < this.updateThrottle) return;
|
||||
this.lastUpdateTime = timestamp;
|
||||
|
||||
const moveX = x - this.state.startX;
|
||||
const moveY = y - this.state.startY;
|
||||
|
||||
if (Math.abs(moveX) > 10 || Math.abs(moveY) > 10) {
|
||||
this.state.hasMoved = true;
|
||||
}
|
||||
|
||||
// Update physics
|
||||
this.physics.updatePosition(moveX, moveY, timestamp);
|
||||
|
||||
// Apply visual effects
|
||||
this.updateVisualFeedback(moveX, moveY);
|
||||
|
||||
// Update action hints
|
||||
this.updateActionHints(moveX, moveY);
|
||||
}
|
||||
|
||||
updateVisualFeedback(moveX, moveY) {
|
||||
const displacement = { x: moveX, y: moveY };
|
||||
const velocity = this.physics.velocity;
|
||||
const direction = this.getSwipeDirection(moveX, moveY);
|
||||
|
||||
// Apply physics-based visual effects
|
||||
this.visualEffects.updateEffects(displacement, velocity, direction);
|
||||
|
||||
// Create particle trail based on velocity
|
||||
if (this.physics.getVelocityMagnitude() > 5) {
|
||||
const rect = this.card.getBoundingClientRect();
|
||||
this.visualEffects.createParticleTrail(
|
||||
rect.left + rect.width / 2 + moveX,
|
||||
rect.top + rect.height / 2 + moveY,
|
||||
direction
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateActionHints(moveX, moveY) {
|
||||
const absX = Math.abs(moveX);
|
||||
const absY = Math.abs(moveY);
|
||||
const threshold = 60;
|
||||
|
||||
// Hide all hints first
|
||||
Object.values(this.actionHints).forEach(hint => {
|
||||
hint.classList.remove('visible', 'enhanced-visible');
|
||||
});
|
||||
|
||||
// Show appropriate hint with enhanced effects
|
||||
if (absX > threshold || absY > threshold) {
|
||||
let activeHint;
|
||||
|
||||
if (absX > absY) {
|
||||
activeHint = moveX > 0 ? this.actionHints.right : this.actionHints.left;
|
||||
} else {
|
||||
activeHint = moveY > 0 ? this.actionHints.down : this.actionHints.up;
|
||||
}
|
||||
|
||||
if (activeHint) {
|
||||
activeHint.classList.add('visible', 'enhanced-visible');
|
||||
|
||||
// Add intensity based on distance
|
||||
const intensity = Math.min((Math.max(absX, absY) - threshold) / 100, 1);
|
||||
activeHint.style.transform = `scale(${1 + intensity * 0.2})`;
|
||||
activeHint.style.opacity = 0.8 + intensity * 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePointerUp(timestamp) {
|
||||
if (!this.state.isDragging) return;
|
||||
|
||||
this.state.isDragging = false;
|
||||
this.stopUpdateLoop();
|
||||
|
||||
// Remove visual states
|
||||
this.card.classList.remove('swiping', 'enhanced-swiping');
|
||||
this.card.style.cursor = '';
|
||||
|
||||
// Hide action hints
|
||||
Object.values(this.actionHints).forEach(hint => {
|
||||
hint.classList.remove('visible', 'enhanced-visible');
|
||||
hint.style.transform = '';
|
||||
hint.style.opacity = '';
|
||||
});
|
||||
|
||||
// Determine action based on physics
|
||||
const displacement = this.physics.position;
|
||||
const velocity = this.physics.velocity;
|
||||
const velocityMagnitude = this.physics.getVelocityMagnitude();
|
||||
|
||||
const absX = Math.abs(displacement.x);
|
||||
const absY = Math.abs(displacement.y);
|
||||
|
||||
// Check if swipe meets threshold or has sufficient momentum
|
||||
const meetsThreshold = absX > this.threshold || absY > this.threshold;
|
||||
const hasMomentum = velocityMagnitude > this.velocityThreshold;
|
||||
|
||||
if (this.state.hasMoved && (meetsThreshold || hasMomentum)) {
|
||||
// Determine direction
|
||||
let direction;
|
||||
if (absX > absY) {
|
||||
direction = displacement.x > 0 ? 'right' : 'left';
|
||||
} else {
|
||||
direction = displacement.y > 0 ? 'down' : 'up';
|
||||
}
|
||||
|
||||
this.performSwipe(direction);
|
||||
} else {
|
||||
// Spring back to center
|
||||
this.animateSpringBack();
|
||||
}
|
||||
}
|
||||
|
||||
performSwipe(direction) {
|
||||
this.state.isAnimating = true;
|
||||
|
||||
// Show decision indicator
|
||||
this.showEnhancedDecisionIndicator(direction);
|
||||
|
||||
// Animate momentum-based exit
|
||||
this.physics.animateMomentumExit(
|
||||
direction,
|
||||
(x, y, velocity, progress) => {
|
||||
// Update card position during exit
|
||||
const rotation = this.getExitRotation(direction, progress);
|
||||
const scale = 1 - progress * 0.3;
|
||||
const opacity = 1 - progress;
|
||||
|
||||
this.card.style.transform = `
|
||||
translate3d(${x}px, ${y}px, 0)
|
||||
rotate(${rotation}deg)
|
||||
scale3d(${scale}, ${scale}, 1)
|
||||
`;
|
||||
this.card.style.opacity = opacity;
|
||||
},
|
||||
() => {
|
||||
// Animation complete
|
||||
this.state.isAnimating = false;
|
||||
this.onSwipe(direction);
|
||||
this.resetCard();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
animateSpringBack() {
|
||||
this.state.isAnimating = true;
|
||||
|
||||
this.physics.animateSpringTo(
|
||||
0, 0,
|
||||
(x, y, velocity) => {
|
||||
// Update card position during spring-back
|
||||
const velocityMagnitude = Math.sqrt(velocity.x * velocity.x + velocity.y * velocity.y);
|
||||
const wobble = Math.sin(performance.now() * 0.01) * velocityMagnitude * 0.1;
|
||||
|
||||
this.card.style.transform = `
|
||||
translate3d(${x}px, ${y}px, 0)
|
||||
rotate(${wobble}deg)
|
||||
scale3d(1, 1, 1)
|
||||
`;
|
||||
},
|
||||
() => {
|
||||
// Spring-back complete
|
||||
this.state.isAnimating = false;
|
||||
this.visualEffects.resetEffects();
|
||||
this.card.style.transform = '';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
showEnhancedDecisionIndicator(direction) {
|
||||
// Decision indicators removed - no visual feedback needed
|
||||
// The swipe action will be handled directly without visual indicators
|
||||
console.log(`Swipe direction: ${direction}`);
|
||||
}
|
||||
|
||||
getExitRotation(direction, progress) {
|
||||
const maxRotation = {
|
||||
left: -45,
|
||||
right: 45,
|
||||
up: 15,
|
||||
down: -15
|
||||
};
|
||||
|
||||
return (maxRotation[direction] || 0) * progress;
|
||||
}
|
||||
|
||||
getSwipeDirection(x, y) {
|
||||
const absX = Math.abs(x);
|
||||
const absY = Math.abs(y);
|
||||
|
||||
if (absX > absY) {
|
||||
return x > 0 ? 'right' : 'left';
|
||||
} else {
|
||||
return y > 0 ? 'down' : 'up';
|
||||
}
|
||||
}
|
||||
|
||||
startUpdateLoop() {
|
||||
if (this.rafId) return;
|
||||
|
||||
const update = () => {
|
||||
if (this.state.isDragging) {
|
||||
this.rafId = requestAnimationFrame(update);
|
||||
} else {
|
||||
this.rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.rafId = requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
stopUpdateLoop() {
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
setImage(imageInfo) {
|
||||
this.state.currentImageInfo = imageInfo;
|
||||
|
||||
if (!imageInfo) {
|
||||
this.card.innerHTML = '<div class="no-images-message enhanced-message">No more images available.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cardImage = this.card.querySelector('img');
|
||||
if (!cardImage) return;
|
||||
|
||||
// Enhanced image loading with fade effect
|
||||
const preloadImg = new Image();
|
||||
preloadImg.onload = () => {
|
||||
cardImage.style.opacity = 0;
|
||||
cardImage.src = imageInfo.path;
|
||||
|
||||
// Smooth fade-in with spring animation
|
||||
setTimeout(() => {
|
||||
cardImage.style.transition = 'opacity 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)';
|
||||
cardImage.style.opacity = 1;
|
||||
}, 50);
|
||||
};
|
||||
preloadImg.src = imageInfo.path;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.card.classList.add('loading', 'enhanced-loading');
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.card.classList.remove('loading', 'enhanced-loading');
|
||||
}
|
||||
|
||||
resetCard() {
|
||||
this.physics.reset();
|
||||
this.visualEffects.resetEffects();
|
||||
this.card.style.transform = '';
|
||||
this.card.style.opacity = '';
|
||||
this.card.style.filter = '';
|
||||
this.state.hasMoved = false;
|
||||
this.state.isAnimating = false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stopUpdateLoop();
|
||||
this.physics.stopAnimation();
|
||||
|
||||
// Remove event listeners
|
||||
if (this.boundHandlePointerDown) {
|
||||
this.card.removeEventListener('mousedown', this.boundHandlePointerDown);
|
||||
}
|
||||
if (this.boundHandlePointerMove) {
|
||||
document.removeEventListener('mousemove', this.boundHandlePointerMove);
|
||||
}
|
||||
if (this.boundHandlePointerUp) {
|
||||
document.removeEventListener('mouseup', this.boundHandlePointerUp);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
this.visualEffects = null;
|
||||
this.physics = null;
|
||||
this.boundHandlePointerDown = null;
|
||||
this.boundHandlePointerMove = null;
|
||||
this.boundHandlePointerUp = null;
|
||||
}
|
||||
}
|
||||
168
components/fallback-swipe-card.js
Normal file
168
components/fallback-swipe-card.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Fallback Swipe Card Component
|
||||
* Simple swipe implementation for compatibility mode
|
||||
*/
|
||||
|
||||
export default class FallbackSwipeCard {
|
||||
constructor(options = {}) {
|
||||
this.container = options.container || document.querySelector('.swipe-container');
|
||||
this.onSwipe = options.onSwipe || (() => {});
|
||||
this.threshold = options.threshold || 100;
|
||||
|
||||
this.state = {
|
||||
isDragging: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
moveX: 0,
|
||||
moveY: 0,
|
||||
hasMoved: false,
|
||||
currentImageInfo: null
|
||||
};
|
||||
|
||||
this.card = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.card = this.container.querySelector('.image-card') || this.createCardElement();
|
||||
this.addEventListeners();
|
||||
console.log('FallbackSwipeCard initialized');
|
||||
}
|
||||
|
||||
createCardElement() {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'image-card';
|
||||
card.id = 'current-card';
|
||||
card.setAttribute('role', 'img');
|
||||
card.setAttribute('aria-label', 'Image to be swiped');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%22400%22%20height%3D%22400%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%22400%22%20height%3D%22400%22%20fill%3D%22%23e0e0e0%22%2F%3E%3Ctext%20x%3D%22200%22%20y%3D%22200%22%20font-size%3D%2220%22%20text-anchor%3D%22middle%22%20alignment-baseline%3D%22middle%22%20fill%3D%22%23999%22%3ELoading...%3C%2Ftext%3E%3C%2Fsvg%3E';
|
||||
img.alt = 'Image';
|
||||
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'loading-indicator';
|
||||
loadingIndicator.textContent = 'Loading...';
|
||||
|
||||
card.appendChild(img);
|
||||
card.appendChild(loadingIndicator);
|
||||
this.container.appendChild(card);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
// Mouse events
|
||||
this.card.addEventListener('mousedown', (e) => this.handlePointerDown(e.clientX, e.clientY));
|
||||
document.addEventListener('mousemove', (e) => this.handlePointerMove(e.clientX, e.clientY));
|
||||
document.addEventListener('mouseup', () => this.handlePointerUp());
|
||||
|
||||
// Touch events
|
||||
this.card.addEventListener('touchstart', (e) => {
|
||||
const touch = e.touches[0];
|
||||
this.handlePointerDown(touch.clientX, touch.clientY);
|
||||
}, { passive: true });
|
||||
|
||||
this.card.addEventListener('touchmove', (e) => {
|
||||
const touch = e.touches[0];
|
||||
this.handlePointerMove(touch.clientX, touch.clientY);
|
||||
}, { passive: true });
|
||||
|
||||
this.card.addEventListener('touchend', () => this.handlePointerUp());
|
||||
}
|
||||
|
||||
handlePointerDown(x, y) {
|
||||
this.state.isDragging = true;
|
||||
this.state.startX = x;
|
||||
this.state.startY = y;
|
||||
this.state.hasMoved = false;
|
||||
this.card.classList.add('swiping');
|
||||
}
|
||||
|
||||
handlePointerMove(x, y) {
|
||||
if (!this.state.isDragging) return;
|
||||
|
||||
this.state.moveX = x - this.state.startX;
|
||||
this.state.moveY = y - this.state.startY;
|
||||
|
||||
if (Math.abs(this.state.moveX) > 10 || Math.abs(this.state.moveY) > 10) {
|
||||
this.state.hasMoved = true;
|
||||
}
|
||||
|
||||
// Simple transform without physics
|
||||
this.card.style.transform = `translate(${this.state.moveX}px, ${this.state.moveY}px) rotate(${this.state.moveX * 0.05}deg)`;
|
||||
}
|
||||
|
||||
handlePointerUp() {
|
||||
if (!this.state.isDragging) return;
|
||||
|
||||
this.state.isDragging = false;
|
||||
this.card.classList.remove('swiping');
|
||||
|
||||
const absX = Math.abs(this.state.moveX);
|
||||
const absY = Math.abs(this.state.moveY);
|
||||
|
||||
if (this.state.hasMoved && (absX > this.threshold || absY > this.threshold)) {
|
||||
let direction;
|
||||
if (absX > absY) {
|
||||
direction = this.state.moveX > 0 ? 'right' : 'left';
|
||||
} else {
|
||||
direction = this.state.moveY > 0 ? 'down' : 'up';
|
||||
}
|
||||
|
||||
this.performSwipe(direction);
|
||||
} else {
|
||||
// Reset position
|
||||
this.card.style.transition = 'transform 0.3s ease';
|
||||
this.card.style.transform = '';
|
||||
setTimeout(() => {
|
||||
this.card.style.transition = '';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
this.state.moveX = 0;
|
||||
this.state.moveY = 0;
|
||||
}
|
||||
|
||||
performSwipe(direction) {
|
||||
this.card.classList.add(`swipe-${direction}`);
|
||||
this.onSwipe(direction);
|
||||
|
||||
setTimeout(() => {
|
||||
this.card.classList.remove(`swipe-${direction}`);
|
||||
this.card.style.transform = '';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
setImage(imageInfo) {
|
||||
this.state.currentImageInfo = imageInfo;
|
||||
|
||||
if (!imageInfo) {
|
||||
this.card.innerHTML = '<div class="no-images-message">No more images available.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cardImage = this.card.querySelector('img');
|
||||
if (cardImage) {
|
||||
cardImage.src = imageInfo.path;
|
||||
}
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.card.classList.add('loading');
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.card.classList.remove('loading');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Remove event listeners
|
||||
this.card.removeEventListener('mousedown', this.handlePointerDown);
|
||||
document.removeEventListener('mousemove', this.handlePointerMove);
|
||||
document.removeEventListener('mouseup', this.handlePointerUp);
|
||||
|
||||
// Clean up
|
||||
this.card = null;
|
||||
}
|
||||
}
|
||||
@@ -80,22 +80,13 @@ class SwipeCard {
|
||||
}
|
||||
|
||||
createDecisionIndicators() {
|
||||
const directions = ['left', 'right', 'up', 'down'];
|
||||
const icons = ['fa-trash', 'fa-folder-plus', 'fa-star', 'fa-clock'];
|
||||
|
||||
directions.forEach((direction, index) => {
|
||||
// Check if indicator already exists
|
||||
let indicator = this.container.querySelector(`.decision-${direction}`);
|
||||
|
||||
if (!indicator) {
|
||||
indicator = document.createElement('div');
|
||||
indicator.className = `swipe-decision decision-${direction}`;
|
||||
indicator.innerHTML = `<i class="fa-solid ${icons[index]} fa-bounce"></i>`;
|
||||
this.container.appendChild(indicator);
|
||||
}
|
||||
|
||||
this.decisionIndicators[direction] = indicator;
|
||||
});
|
||||
// Decision indicators removed - no longer creating visual icons
|
||||
this.decisionIndicators = {
|
||||
left: null,
|
||||
right: null,
|
||||
up: null,
|
||||
down: null
|
||||
};
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
@@ -243,14 +234,8 @@ class SwipeCard {
|
||||
}
|
||||
|
||||
showDecisionIndicator(direction) {
|
||||
const indicator = this.decisionIndicators[direction];
|
||||
if (indicator) {
|
||||
indicator.classList.add('visible');
|
||||
|
||||
setTimeout(() => {
|
||||
indicator.classList.remove('visible');
|
||||
}, 800);
|
||||
}
|
||||
// Decision indicators removed - no visual feedback needed
|
||||
console.log(`Swipe direction: ${direction}`);
|
||||
}
|
||||
|
||||
setImage(imageInfo) {
|
||||
|
||||
23
index.html
23
index.html
@@ -20,13 +20,6 @@
|
||||
<img src="data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%22400%22%20height%3D%22400%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%22400%22%20height%3D%22400%22%20fill%3D%22%23e0e0e0%22%2F%3E%3Ctext%20x%3D%22200%22%20y%3D%22200%22%20font-size%3D%2220%22%20text-anchor%3D%22middle%22%20alignment-baseline%3D%22middle%22%20fill%3D%22%23999%22%3ELoading...%3C%2Ftext%3E%3C%2Fsvg%3E" alt="Image">
|
||||
<div class="loading-indicator">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="swipe-actions">
|
||||
<div class="action-hint left-hint">Discard</div>
|
||||
<div class="action-hint right-hint">Keep</div>
|
||||
<div class="action-hint up-hint">Favorite</div>
|
||||
<div class="action-hint down-hint">Review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="side-panel">
|
||||
@@ -123,6 +116,20 @@
|
||||
<img src="static/icons/fullscreen.svg" class="btn-icon" alt="Fullscreen">
|
||||
</button>
|
||||
|
||||
<script src="js/main.js" type="module"></script>
|
||||
<!-- Enhanced Swipe System Integration -->
|
||||
<script src="js/enhanced-main.js" type="module"></script>
|
||||
|
||||
<!-- Settings Panel for Swipe Mode Selection -->
|
||||
<div id="swipe-settings-panel" class="settings-panel" style="display: none;">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Settings Toggle Button -->
|
||||
<button id="swipe-settings-toggle" class="settings-toggle" aria-label="Swipe Settings" title="Swipe Animation Settings">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 1v6m0 6v6m11-7h-6m-6 0H1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -160,7 +160,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<input type="checkbox" class="selection-checkbox">
|
||||
</div>
|
||||
<img src="${ensureImagePath(selection.image_path)}" alt="Selected image" loading="lazy" class="${blurClass}">
|
||||
<div class="selection-action action-${actionClass(selection.action)}"><img src="/static/icons/${actionIconMap[selection.action]}" alt="${selection.action}" class="selection-action"></div>
|
||||
<div class="selection-action action-${actionClass(selection.action)}"><img src="/static/icons/${getActionIcon(selection.action)}" alt="${selection.action}" class="selection-action"></div>
|
||||
<div class="selection-info">
|
||||
<p>${selection.image_path.split('/').pop()}</p>
|
||||
<p>Resolution: ${selection.resolution}</p>
|
||||
@@ -183,8 +183,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
const actionClass = (action) => {
|
||||
// Direct mapping for full action names
|
||||
const map = { 'Discarded':'discard', 'Kept':'keep', 'Favourited':'favorite', 'Reviewing':'review' };
|
||||
return map[action] || 'discard';
|
||||
|
||||
// Check direct mapping first
|
||||
if (map[action]) {
|
||||
return map[action];
|
||||
}
|
||||
|
||||
// Backwards compatibility: check first letter for single-letter actions and legacy actions
|
||||
const firstLetter = action ? action.charAt(0).toUpperCase() : '';
|
||||
switch (firstLetter) {
|
||||
case 'K': return 'keep'; // Keep/Kept
|
||||
case 'D': return 'discard'; // Discard/Discarded
|
||||
case 'F': return 'favorite'; // Favorite/Favourited
|
||||
case 'R': return 'review'; // Review/Reviewed/Reviewing
|
||||
default: return 'discard'; // Default fallback
|
||||
}
|
||||
};
|
||||
|
||||
// Map action names to icon filenames for display
|
||||
@@ -194,6 +209,24 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
'Favourited': 'fav.svg',
|
||||
'Reviewing': 'review.svg'
|
||||
};
|
||||
|
||||
// Get appropriate icon with backwards compatibility
|
||||
const getActionIcon = (action) => {
|
||||
// Check direct mapping first
|
||||
if (actionIconMap[action]) {
|
||||
return actionIconMap[action];
|
||||
}
|
||||
|
||||
// Backwards compatibility: check first letter
|
||||
const firstLetter = action ? action.charAt(0).toUpperCase() : '';
|
||||
switch (firstLetter) {
|
||||
case 'K': return 'keep.svg'; // Keep/Kept
|
||||
case 'D': return 'discard.svg'; // Discard/Discarded
|
||||
case 'F': return 'fav.svg'; // Favorite/Favourited
|
||||
case 'R': return 'review.svg'; // Review/Reviewed/Reviewing
|
||||
default: return 'discard.svg'; // Default fallback
|
||||
}
|
||||
};
|
||||
const getActionName = (action) => action;
|
||||
|
||||
const formatDate = (ts) => {
|
||||
|
||||
471
js/swipe-integration.js
Normal file
471
js/swipe-integration.js
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Swipe Integration Manager
|
||||
* Allows seamless switching between original and enhanced swipe systems
|
||||
*/
|
||||
|
||||
import { showToast, updateImageInfo } from './utils.js';
|
||||
import EnhancedSwipeCard from '../components/enhanced-swipe-card.js';
|
||||
|
||||
export class SwipeIntegrationManager {
|
||||
constructor() {
|
||||
this.currentMode = this.detectOptimalMode();
|
||||
this.swipeCard = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Configuration options
|
||||
this.config = {
|
||||
enablePhysics: true,
|
||||
enableVisualEffects: true,
|
||||
enableHapticFeedback: true,
|
||||
animationQuality: 'auto', // 'low', 'medium', 'high', 'auto'
|
||||
performanceMode: 'auto', // 'low', 'normal', 'high', 'auto'
|
||||
debugMode: false
|
||||
};
|
||||
|
||||
// Load user preferences
|
||||
this.loadUserPreferences();
|
||||
|
||||
console.log(`SwipeIntegrationManager initialized in ${this.currentMode} mode`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect optimal swipe mode based on device capabilities
|
||||
*/
|
||||
detectOptimalMode() {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isMobile = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/.test(userAgent);
|
||||
const isLowEnd = /android.*chrome\/[1-6][0-9]/.test(userAgent);
|
||||
const hasReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// Check device memory if available
|
||||
const deviceMemory = navigator.deviceMemory || 4;
|
||||
const hasLowMemory = deviceMemory < 2;
|
||||
|
||||
// Check connection speed if available
|
||||
let hasSlowConnection = false;
|
||||
if ('connection' in navigator) {
|
||||
const connection = navigator.connection;
|
||||
hasSlowConnection = connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g';
|
||||
}
|
||||
|
||||
// Determine optimal mode
|
||||
if (hasReducedMotion || isLowEnd || hasLowMemory || hasSlowConnection) {
|
||||
return 'original';
|
||||
} else if (isMobile) {
|
||||
return 'enhanced-lite';
|
||||
} else {
|
||||
return 'enhanced';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the swipe system
|
||||
*/
|
||||
async initialize(container, onSwipe, options = {}) {
|
||||
if (this.isInitialized) {
|
||||
console.warn('SwipeIntegrationManager already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge options with config
|
||||
this.config = { ...this.config, ...options };
|
||||
|
||||
try {
|
||||
switch (this.currentMode) {
|
||||
case 'enhanced':
|
||||
await this.initializeEnhancedMode(container, onSwipe);
|
||||
break;
|
||||
case 'enhanced-lite':
|
||||
await this.initializeEnhancedLiteMode(container, onSwipe);
|
||||
break;
|
||||
case 'original':
|
||||
default:
|
||||
await this.initializeOriginalMode(container, onSwipe);
|
||||
break;
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
this.logInitialization();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize swipe system:', error);
|
||||
|
||||
// Clean up any partially initialized swipe card
|
||||
if (this.swipeCard && typeof this.swipeCard.destroy === 'function') {
|
||||
this.swipeCard.destroy();
|
||||
this.swipeCard = null;
|
||||
}
|
||||
|
||||
// Fallback to original mode
|
||||
await this.initializeOriginalMode(container, onSwipe);
|
||||
this.currentMode = 'original';
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize enhanced mode with full physics
|
||||
*/
|
||||
async initializeEnhancedMode(container, onSwipe) {
|
||||
this.swipeCard = new EnhancedSwipeCard({
|
||||
container,
|
||||
onSwipe,
|
||||
threshold: 100,
|
||||
velocityThreshold: 2.5,
|
||||
springTension: 320,
|
||||
springFriction: 25,
|
||||
mass: 1.0,
|
||||
momentumDecay: 0.90
|
||||
});
|
||||
|
||||
// Add enhanced CSS classes
|
||||
document.body.classList.add('enhanced-swipe-mode');
|
||||
container.classList.add('enhanced-container');
|
||||
|
||||
// Load enhanced styles if not already loaded
|
||||
await this.loadEnhancedStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize enhanced lite mode for mobile devices
|
||||
*/
|
||||
async initializeEnhancedLiteMode(container, onSwipe) {
|
||||
this.swipeCard = new EnhancedSwipeCard({
|
||||
container,
|
||||
onSwipe,
|
||||
threshold: 80,
|
||||
velocityThreshold: 2.0,
|
||||
springTension: 280,
|
||||
springFriction: 30,
|
||||
mass: 1.2,
|
||||
momentumDecay: 0.92
|
||||
});
|
||||
|
||||
// Add lite mode classes
|
||||
document.body.classList.add('enhanced-swipe-lite-mode');
|
||||
container.classList.add('enhanced-container-lite');
|
||||
|
||||
// Disable some heavy effects for performance
|
||||
this.config.enableVisualEffects = false;
|
||||
|
||||
await this.loadEnhancedStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize original mode as fallback
|
||||
*/
|
||||
async initializeOriginalMode(container, onSwipe) {
|
||||
// Import and initialize fallback swipe card
|
||||
const { default: FallbackSwipeCard } = await import('../components/fallback-swipe-card.js');
|
||||
|
||||
this.swipeCard = new FallbackSwipeCard({
|
||||
container,
|
||||
onSwipe,
|
||||
threshold: 100
|
||||
});
|
||||
|
||||
document.body.classList.add('original-swipe-mode');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load enhanced styles dynamically
|
||||
*/
|
||||
async loadEnhancedStyles() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if enhanced styles are already loaded
|
||||
if (document.querySelector('link[href*="enhanced-animations"]')) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'scss/enhanced-animations.css'; // Compiled CSS
|
||||
link.onload = resolve;
|
||||
link.onerror = reject;
|
||||
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch between swipe modes dynamically
|
||||
*/
|
||||
async switchMode(newMode) {
|
||||
if (newMode === this.currentMode) {
|
||||
console.log(`Already in ${newMode} mode`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Switching from ${this.currentMode} to ${newMode} mode`);
|
||||
|
||||
// Clean up current mode
|
||||
if (this.swipeCard && typeof this.swipeCard.destroy === 'function') {
|
||||
this.swipeCard.destroy();
|
||||
}
|
||||
|
||||
// Remove current mode classes
|
||||
document.body.classList.remove(
|
||||
'enhanced-swipe-mode',
|
||||
'enhanced-swipe-lite-mode',
|
||||
'original-swipe-mode'
|
||||
);
|
||||
|
||||
// Update mode and reinitialize
|
||||
this.currentMode = newMode;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Save user preference
|
||||
this.saveUserPreference('swipeMode', newMode);
|
||||
|
||||
// Show feedback to user
|
||||
showToast(`Switched to ${newMode} swipe mode`, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current swipe card instance
|
||||
*/
|
||||
getSwipeCard() {
|
||||
return this.swipeCard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mode
|
||||
*/
|
||||
getCurrentMode() {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available modes
|
||||
*/
|
||||
getAvailableModes() {
|
||||
return ['original', 'enhanced-lite', 'enhanced'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(newConfig) {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
|
||||
// Apply config changes to current swipe card if applicable
|
||||
if (this.swipeCard && typeof this.swipeCard.updateConfig === 'function') {
|
||||
this.swipeCard.updateConfig(this.config);
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
this.saveUserPreference('swipeConfig', this.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig() {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user preferences from localStorage
|
||||
*/
|
||||
loadUserPreferences() {
|
||||
try {
|
||||
const savedMode = localStorage.getItem('swipeMode');
|
||||
if (savedMode && this.getAvailableModes().includes(savedMode)) {
|
||||
this.currentMode = savedMode;
|
||||
}
|
||||
|
||||
const savedConfig = localStorage.getItem('swipeConfig');
|
||||
if (savedConfig) {
|
||||
this.config = { ...this.config, ...JSON.parse(savedConfig) };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load user preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user preference to localStorage
|
||||
*/
|
||||
saveUserPreference(key, value) {
|
||||
try {
|
||||
if (typeof value === 'object') {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to save user preference:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance monitoring
|
||||
*/
|
||||
getPerformanceMetrics() {
|
||||
if (this.swipeCard && typeof this.swipeCard.getPerformanceMetrics === 'function') {
|
||||
return this.swipeCard.getPerformanceMetrics();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug information
|
||||
*/
|
||||
getDebugInfo() {
|
||||
return {
|
||||
currentMode: this.currentMode,
|
||||
config: this.config,
|
||||
isInitialized: this.isInitialized,
|
||||
deviceInfo: {
|
||||
userAgent: navigator.userAgent,
|
||||
deviceMemory: navigator.deviceMemory,
|
||||
hardwareConcurrency: navigator.hardwareConcurrency,
|
||||
connection: navigator.connection ? {
|
||||
effectiveType: navigator.connection.effectiveType,
|
||||
downlink: navigator.connection.downlink
|
||||
} : null
|
||||
},
|
||||
performanceMetrics: this.getPerformanceMetrics()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log initialization details
|
||||
*/
|
||||
logInitialization() {
|
||||
if (this.config.debugMode) {
|
||||
console.group('SwipeIntegrationManager Initialization');
|
||||
console.log('Mode:', this.currentMode);
|
||||
console.log('Config:', this.config);
|
||||
console.log('Debug Info:', this.getDebugInfo());
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the swipe system
|
||||
*/
|
||||
destroy() {
|
||||
if (this.swipeCard && typeof this.swipeCard.destroy === 'function') {
|
||||
this.swipeCard.destroy();
|
||||
}
|
||||
|
||||
// Remove mode classes
|
||||
document.body.classList.remove(
|
||||
'enhanced-swipe-mode',
|
||||
'enhanced-swipe-lite-mode',
|
||||
'original-swipe-mode'
|
||||
);
|
||||
|
||||
this.swipeCard = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
console.log('SwipeIntegrationManager destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create settings panel for swipe mode selection
|
||||
*/
|
||||
export function createSwipeSettingsPanel(integrationManager) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'swipe-settings-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="settings-header">
|
||||
<h3>Swipe Settings</h3>
|
||||
<button class="close-settings">×</button>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="setting-group">
|
||||
<label>Swipe Mode:</label>
|
||||
<select id="swipe-mode-select">
|
||||
<option value="original">Original (Compatible)</option>
|
||||
<option value="enhanced-lite">Enhanced Lite (Mobile)</option>
|
||||
<option value="enhanced">Enhanced (Full Physics)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>
|
||||
<input type="checkbox" id="enable-physics"> Enable Physics
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>
|
||||
<input type="checkbox" id="enable-visual-effects"> Visual Effects
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>
|
||||
<input type="checkbox" id="enable-haptic"> Haptic Feedback
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>Animation Quality:</label>
|
||||
<select id="animation-quality">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button id="apply-settings" class="apply-btn">Apply</button>
|
||||
<button id="reset-settings" class="reset-btn">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set current values
|
||||
const config = integrationManager.getConfig();
|
||||
panel.querySelector('#swipe-mode-select').value = integrationManager.getCurrentMode();
|
||||
panel.querySelector('#enable-physics').checked = config.enablePhysics;
|
||||
panel.querySelector('#enable-visual-effects').checked = config.enableVisualEffects;
|
||||
panel.querySelector('#enable-haptic').checked = config.enableHapticFeedback;
|
||||
panel.querySelector('#animation-quality').value = config.animationQuality;
|
||||
|
||||
// Event listeners
|
||||
panel.querySelector('.close-settings').addEventListener('click', () => {
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
panel.querySelector('#apply-settings').addEventListener('click', () => {
|
||||
const newMode = panel.querySelector('#swipe-mode-select').value;
|
||||
const newConfig = {
|
||||
enablePhysics: panel.querySelector('#enable-physics').checked,
|
||||
enableVisualEffects: panel.querySelector('#enable-visual-effects').checked,
|
||||
enableHapticFeedback: panel.querySelector('#enable-haptic').checked,
|
||||
animationQuality: panel.querySelector('#animation-quality').value
|
||||
};
|
||||
|
||||
integrationManager.updateConfig(newConfig);
|
||||
if (newMode !== integrationManager.getCurrentMode()) {
|
||||
integrationManager.switchMode(newMode);
|
||||
}
|
||||
|
||||
showToast('Settings applied successfully', 'success');
|
||||
});
|
||||
|
||||
panel.querySelector('#reset-settings').addEventListener('click', () => {
|
||||
integrationManager.updateConfig({
|
||||
enablePhysics: true,
|
||||
enableVisualEffects: true,
|
||||
enableHapticFeedback: true,
|
||||
animationQuality: 'auto',
|
||||
performanceMode: 'auto'
|
||||
});
|
||||
|
||||
// Reset form values
|
||||
panel.querySelector('#enable-physics').checked = true;
|
||||
panel.querySelector('#enable-visual-effects').checked = true;
|
||||
panel.querySelector('#enable-haptic').checked = true;
|
||||
panel.querySelector('#animation-quality').value = 'auto';
|
||||
|
||||
showToast('Settings reset to defaults', 'info');
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const swipeIntegration = new SwipeIntegrationManager();
|
||||
427
js/swipe-physics.js
Normal file
427
js/swipe-physics.js
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* SwipePhysics - Advanced physics engine for realistic swipe animations
|
||||
* Provides spring-based animations, velocity tracking, and momentum calculations
|
||||
*/
|
||||
|
||||
export class SwipePhysics {
|
||||
constructor(options = {}) {
|
||||
// Spring physics constants
|
||||
this.springTension = options.springTension || 300;
|
||||
this.springFriction = options.springFriction || 30;
|
||||
this.mass = options.mass || 1;
|
||||
|
||||
// Velocity and momentum settings
|
||||
this.velocityThreshold = options.velocityThreshold || 0.5;
|
||||
this.momentumDecay = options.momentumDecay || 0.95;
|
||||
this.maxVelocity = options.maxVelocity || 50;
|
||||
|
||||
// Animation settings
|
||||
this.dampingRatio = options.dampingRatio || 0.8;
|
||||
this.precision = options.precision || 0.01;
|
||||
this.maxDuration = options.maxDuration || 2000;
|
||||
|
||||
// State tracking
|
||||
this.isAnimating = false;
|
||||
this.animationId = null;
|
||||
this.startTime = 0;
|
||||
|
||||
// Velocity tracking
|
||||
this.velocityHistory = [];
|
||||
this.maxHistoryLength = 5;
|
||||
this.lastPosition = { x: 0, y: 0 };
|
||||
this.lastTimestamp = 0;
|
||||
|
||||
// Current physics state
|
||||
this.position = { x: 0, y: 0 };
|
||||
this.velocity = { x: 0, y: 0 };
|
||||
this.acceleration = { x: 0, y: 0 };
|
||||
|
||||
// Bind methods - remove this line as animate method doesn't exist
|
||||
// this.animate = this.animate.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update position and calculate velocity
|
||||
*/
|
||||
updatePosition(x, y, timestamp = performance.now()) {
|
||||
if (this.lastTimestamp > 0) {
|
||||
const deltaTime = Math.max(timestamp - this.lastTimestamp, 1);
|
||||
const deltaX = x - this.lastPosition.x;
|
||||
const deltaY = y - this.lastPosition.y;
|
||||
|
||||
// Calculate instantaneous velocity
|
||||
const velocityX = (deltaX / deltaTime) * 1000; // pixels per second
|
||||
const velocityY = (deltaY / deltaTime) * 1000;
|
||||
|
||||
// Add to velocity history for smoothing
|
||||
this.velocityHistory.push({
|
||||
x: velocityX,
|
||||
y: velocityY,
|
||||
timestamp
|
||||
});
|
||||
|
||||
// Keep history within bounds
|
||||
if (this.velocityHistory.length > this.maxHistoryLength) {
|
||||
this.velocityHistory.shift();
|
||||
}
|
||||
|
||||
// Calculate smoothed velocity
|
||||
this.velocity = this.getSmoothedVelocity();
|
||||
|
||||
// Clamp velocity to maximum
|
||||
this.velocity.x = Math.max(-this.maxVelocity, Math.min(this.maxVelocity, this.velocity.x));
|
||||
this.velocity.y = Math.max(-this.maxVelocity, Math.min(this.maxVelocity, this.velocity.y));
|
||||
}
|
||||
|
||||
this.position.x = x;
|
||||
this.position.y = y;
|
||||
this.lastPosition.x = x;
|
||||
this.lastPosition.y = y;
|
||||
this.lastTimestamp = timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get smoothed velocity from history
|
||||
*/
|
||||
getSmoothedVelocity() {
|
||||
if (this.velocityHistory.length === 0) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
// Weight recent velocities more heavily
|
||||
let totalWeight = 0;
|
||||
let weightedVelocityX = 0;
|
||||
let weightedVelocityY = 0;
|
||||
|
||||
this.velocityHistory.forEach((entry, index) => {
|
||||
const weight = (index + 1) / this.velocityHistory.length;
|
||||
totalWeight += weight;
|
||||
weightedVelocityX += entry.x * weight;
|
||||
weightedVelocityY += entry.y * weight;
|
||||
});
|
||||
|
||||
return {
|
||||
x: weightedVelocityX / totalWeight,
|
||||
y: weightedVelocityY / totalWeight
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate spring force based on displacement and velocity
|
||||
*/
|
||||
calculateSpringForce(displacement, velocity) {
|
||||
// Hooke's law with damping: F = -kx - cv
|
||||
return -this.springTension * displacement - this.springFriction * velocity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current velocity magnitude
|
||||
*/
|
||||
getVelocityMagnitude() {
|
||||
return Math.sqrt(this.velocity.x * this.velocity.x + this.velocity.y * this.velocity.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if swipe has enough momentum to trigger action
|
||||
*/
|
||||
hasSwipeMomentum(threshold = this.velocityThreshold) {
|
||||
return this.getVelocityMagnitude() > threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate spring-back to target position
|
||||
*/
|
||||
animateSpringTo(targetX, targetY, onUpdate, onComplete) {
|
||||
if (this.isAnimating) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
}
|
||||
|
||||
this.isAnimating = true;
|
||||
this.startTime = performance.now();
|
||||
|
||||
const startX = this.position.x;
|
||||
const startY = this.position.y;
|
||||
const startVelX = this.velocity.x;
|
||||
const startVelY = this.velocity.y;
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - this.startTime;
|
||||
const t = elapsed / 1000; // Convert to seconds
|
||||
|
||||
// Calculate spring physics
|
||||
const displacementX = this.position.x - targetX;
|
||||
const displacementY = this.position.y - targetY;
|
||||
|
||||
// Apply spring forces
|
||||
const forceX = this.calculateSpringForce(displacementX, this.velocity.x);
|
||||
const forceY = this.calculateSpringForce(displacementY, this.velocity.y);
|
||||
|
||||
// Update acceleration (F = ma, so a = F/m)
|
||||
this.acceleration.x = forceX / this.mass;
|
||||
this.acceleration.y = forceY / this.mass;
|
||||
|
||||
// Update velocity with acceleration
|
||||
this.velocity.x += this.acceleration.x * (1/60); // Assume 60fps
|
||||
this.velocity.y += this.acceleration.y * (1/60);
|
||||
|
||||
// Update position with velocity
|
||||
this.position.x += this.velocity.x * (1/60);
|
||||
this.position.y += this.velocity.y * (1/60);
|
||||
|
||||
// Check if animation should stop
|
||||
const velocityMagnitude = this.getVelocityMagnitude();
|
||||
const displacementMagnitude = Math.sqrt(displacementX * displacementX + displacementY * displacementY);
|
||||
|
||||
const shouldStop = (
|
||||
velocityMagnitude < this.precision &&
|
||||
displacementMagnitude < this.precision
|
||||
) || elapsed > this.maxDuration;
|
||||
|
||||
if (shouldStop) {
|
||||
// Snap to target
|
||||
this.position.x = targetX;
|
||||
this.position.y = targetY;
|
||||
this.velocity.x = 0;
|
||||
this.velocity.y = 0;
|
||||
this.isAnimating = false;
|
||||
|
||||
if (onUpdate) onUpdate(this.position.x, this.position.y, this.velocity);
|
||||
if (onComplete) onComplete();
|
||||
} else {
|
||||
if (onUpdate) onUpdate(this.position.x, this.position.y, this.velocity);
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate momentum-based exit
|
||||
*/
|
||||
animateMomentumExit(direction, onUpdate, onComplete) {
|
||||
if (this.isAnimating) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
}
|
||||
|
||||
this.isAnimating = true;
|
||||
this.startTime = performance.now();
|
||||
|
||||
// Calculate exit target based on direction and momentum
|
||||
const exitDistance = 1000; // pixels
|
||||
const velocityMagnitude = this.getVelocityMagnitude();
|
||||
const momentumMultiplier = Math.max(1, velocityMagnitude / 10);
|
||||
|
||||
let targetX = this.position.x;
|
||||
let targetY = this.position.y;
|
||||
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
targetX = -exitDistance * momentumMultiplier;
|
||||
break;
|
||||
case 'right':
|
||||
targetX = exitDistance * momentumMultiplier;
|
||||
break;
|
||||
case 'up':
|
||||
targetY = -exitDistance * momentumMultiplier;
|
||||
break;
|
||||
case 'down':
|
||||
targetY = exitDistance * momentumMultiplier;
|
||||
break;
|
||||
}
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - this.startTime;
|
||||
const progress = Math.min(elapsed / 500, 1); // 500ms exit animation
|
||||
|
||||
// Use easing function for smooth exit
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
const currentX = this.position.x + (targetX - this.position.x) * easeOut;
|
||||
const currentY = this.position.y + (targetY - this.position.y) * easeOut;
|
||||
|
||||
// Apply momentum decay
|
||||
this.velocity.x *= this.momentumDecay;
|
||||
this.velocity.y *= this.momentumDecay;
|
||||
|
||||
if (progress >= 1) {
|
||||
this.isAnimating = false;
|
||||
if (onComplete) onComplete();
|
||||
} else {
|
||||
if (onUpdate) onUpdate(currentX, currentY, this.velocity, progress);
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current animation
|
||||
*/
|
||||
stopAnimation() {
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
this.isAnimating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset physics state
|
||||
*/
|
||||
reset() {
|
||||
this.stopAnimation();
|
||||
this.position = { x: 0, y: 0 };
|
||||
this.velocity = { x: 0, y: 0 };
|
||||
this.acceleration = { x: 0, y: 0 };
|
||||
this.velocityHistory = [];
|
||||
this.lastPosition = { x: 0, y: 0 };
|
||||
this.lastTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get physics state for debugging
|
||||
*/
|
||||
getState() {
|
||||
return {
|
||||
position: { ...this.position },
|
||||
velocity: { ...this.velocity },
|
||||
acceleration: { ...this.acceleration },
|
||||
velocityMagnitude: this.getVelocityMagnitude(),
|
||||
isAnimating: this.isAnimating
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual Effects Engine for dynamic feedback during swipes
|
||||
*/
|
||||
export class SwipeVisualEffects {
|
||||
constructor(element, options = {}) {
|
||||
this.element = element;
|
||||
this.options = {
|
||||
maxBlur: options.maxBlur || 8,
|
||||
maxOpacity: options.maxOpacity || 0.3,
|
||||
colorShiftIntensity: options.colorShiftIntensity || 0.2,
|
||||
particleCount: options.particleCount || 20,
|
||||
...options
|
||||
};
|
||||
|
||||
this.particles = [];
|
||||
this.setupEffects();
|
||||
}
|
||||
|
||||
setupEffects() {
|
||||
// Ensure element has proper CSS properties for effects
|
||||
this.element.style.willChange = 'transform, filter, opacity';
|
||||
this.element.style.backfaceVisibility = 'hidden';
|
||||
this.element.style.perspective = '1000px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual effects based on swipe progress
|
||||
*/
|
||||
updateEffects(displacement, velocity, direction) {
|
||||
const distance = Math.sqrt(displacement.x * displacement.x + displacement.y * displacement.y);
|
||||
const maxDistance = 200; // Maximum distance for full effect
|
||||
const intensity = Math.min(distance / maxDistance, 1);
|
||||
const velocityMagnitude = Math.sqrt(velocity.x * velocity.x + velocity.y * velocity.y);
|
||||
|
||||
// Apply blur effect based on velocity
|
||||
const blur = Math.min(velocityMagnitude * 0.1, this.options.maxBlur);
|
||||
|
||||
// Apply opacity shift based on distance
|
||||
const opacity = 1 - (intensity * this.options.maxOpacity);
|
||||
|
||||
// Apply color temperature shift based on direction
|
||||
const hueShift = this.getDirectionHueShift(direction) * intensity;
|
||||
|
||||
// Apply 3D rotation based on displacement
|
||||
const rotationX = (displacement.y / maxDistance) * 15; // Max 15 degrees
|
||||
const rotationY = (displacement.x / maxDistance) * 15;
|
||||
const rotationZ = (displacement.x / maxDistance) * 10;
|
||||
|
||||
// Apply scale effect
|
||||
const scale = 1 - (intensity * 0.1); // Slight scale down
|
||||
|
||||
// Combine all transforms
|
||||
const transform = `
|
||||
translate3d(${displacement.x}px, ${displacement.y}px, 0)
|
||||
rotateX(${rotationX}deg)
|
||||
rotateY(${rotationY}deg)
|
||||
rotateZ(${rotationZ}deg)
|
||||
scale3d(${scale}, ${scale}, 1)
|
||||
`;
|
||||
|
||||
const filter = `
|
||||
blur(${blur}px)
|
||||
hue-rotate(${hueShift}deg)
|
||||
brightness(${1 - intensity * 0.2})
|
||||
`;
|
||||
|
||||
// Apply effects
|
||||
this.element.style.transform = transform;
|
||||
this.element.style.filter = filter;
|
||||
this.element.style.opacity = opacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hue shift based on swipe direction
|
||||
*/
|
||||
getDirectionHueShift(direction) {
|
||||
const shifts = {
|
||||
left: -30, // Red shift for discard
|
||||
right: 120, // Green shift for keep
|
||||
up: 240, // Blue shift for favorite
|
||||
down: 60 // Yellow shift for review
|
||||
};
|
||||
return shifts[direction] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create particle trail effect
|
||||
*/
|
||||
createParticleTrail(x, y, direction) {
|
||||
const particle = {
|
||||
x,
|
||||
y,
|
||||
vx: (Math.random() - 0.5) * 4,
|
||||
vy: (Math.random() - 0.5) * 4,
|
||||
life: 1,
|
||||
decay: 0.02,
|
||||
color: this.getDirectionColor(direction),
|
||||
size: Math.random() * 4 + 2
|
||||
};
|
||||
|
||||
this.particles.push(particle);
|
||||
|
||||
// Limit particle count
|
||||
if (this.particles.length > this.options.particleCount) {
|
||||
this.particles.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for direction
|
||||
*/
|
||||
getDirectionColor(direction) {
|
||||
const colors = {
|
||||
left: '#e74c3c', // Red for discard
|
||||
right: '#2ecc71', // Green for keep
|
||||
up: '#3498db', // Blue for favorite
|
||||
down: '#f39c12' // Orange for review
|
||||
};
|
||||
return colors[direction] || '#ffffff';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all visual effects
|
||||
*/
|
||||
resetEffects() {
|
||||
this.element.style.transform = '';
|
||||
this.element.style.filter = '';
|
||||
this.element.style.opacity = '';
|
||||
this.particles = [];
|
||||
}
|
||||
}
|
||||
@@ -26,21 +26,52 @@ html, body {
|
||||
.fullscreen-mode .header,
|
||||
.fullscreen-mode .side-panel {
|
||||
display: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fullscreen-mode .container {
|
||||
padding: 0;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fullscreen-mode .main-section {
|
||||
height: 100%;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.fullscreen-mode .swipe-container {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
transition: all 0.3s ease;
|
||||
border-width: 0; /* Remove borders in fullscreen */
|
||||
}
|
||||
|
||||
/* Enhanced fullscreen transitions */
|
||||
.container {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.main-section {
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.swipe-container {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Ensure fullscreen toggle stays visible and accessible in fullscreen mode */
|
||||
.fullscreen-mode .fullscreen-toggle {
|
||||
z-index: 2000;
|
||||
opacity: 0.8;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.fullscreen-mode .fullscreen-toggle:hover {
|
||||
opacity: 0.95;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Ultra-wide toggle button */
|
||||
@@ -65,8 +96,8 @@ html, body {
|
||||
}
|
||||
|
||||
.fullscreen-toggle:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
opacity: 0.9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
462
scss/enhanced-animations.scss
Normal file
462
scss/enhanced-animations.scss
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Enhanced Physics-Based Animation Styles
|
||||
* Supports the SwipePhysics engine and EnhancedSwipeCard component
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Physics Animation Variables */
|
||||
--spring-gentle: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
--spring-bouncy: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
--spring-wobbly: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
--spring-stiff: cubic-bezier(0.55, 0.055, 0.675, 0.19);
|
||||
--spring-smooth: cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
|
||||
/* Enhanced Color Palette */
|
||||
--physics-primary: #667eea;
|
||||
--physics-success: #f093fb;
|
||||
--physics-danger: #ffecd2;
|
||||
--physics-warning: #fcb69f;
|
||||
|
||||
/* Gradient Definitions */
|
||||
--gradient-physics-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-physics-success: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
--gradient-physics-danger: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
|
||||
--gradient-physics-warning: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
||||
|
||||
/* Animation Timing */
|
||||
--physics-duration-fast: 0.2s;
|
||||
--physics-duration-normal: 0.4s;
|
||||
--physics-duration-slow: 0.8s;
|
||||
|
||||
/* 3D Perspective */
|
||||
--physics-perspective: 1200px;
|
||||
--physics-depth: 100px;
|
||||
}
|
||||
|
||||
/* Enhanced Card Styles */
|
||||
.enhanced-card {
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, filter, opacity;
|
||||
|
||||
/* Subtle glow effect */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: var(--gradient-physics-primary);
|
||||
border-radius: inherit;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: opacity var(--physics-duration-normal) var(--spring-smooth);
|
||||
}
|
||||
|
||||
&.enhanced-swiping::before {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Loading Animation */
|
||||
.enhanced-loading {
|
||||
.loading-indicator {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
transition: all var(--physics-duration-normal) var(--spring-wobbly);
|
||||
}
|
||||
|
||||
.physics-spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 4px solid rgba(102, 126, 234, 0.2);
|
||||
border-top-color: var(--physics-primary);
|
||||
border-radius: 50%;
|
||||
animation: physicsSpinnerRotate 1s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: rgba(102, 126, 234, 0.6);
|
||||
border-radius: 50%;
|
||||
animation: physicsSpinnerRotate 0.6s linear infinite reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--physics-primary);
|
||||
animation: loadingPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes physicsSpinnerRotate {
|
||||
0% { transform: rotate(0deg) scale(1); }
|
||||
50% { transform: rotate(180deg) scale(1.1); }
|
||||
100% { transform: rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes loadingPulse {
|
||||
0%, 100% { opacity: 0.6; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
/* Enhanced Action Hints */
|
||||
.enhanced-hint {
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
|
||||
transform: scale(0.8) translateZ(0);
|
||||
transition: all var(--physics-duration-normal) var(--spring-wobbly);
|
||||
|
||||
.hint-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-physics-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
animation: hintPulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
&.enhanced-visible {
|
||||
transform: scale(1) translateZ(var(--physics-depth));
|
||||
|
||||
.hint-icon::before {
|
||||
animation-duration: 0.8s;
|
||||
}
|
||||
}
|
||||
|
||||
/* Direction-specific styling */
|
||||
&.left-hint {
|
||||
border-left-color: #ff6b6b;
|
||||
.hint-icon { background: linear-gradient(135deg, #ff6b6b, #ee5a52); }
|
||||
}
|
||||
|
||||
&.right-hint {
|
||||
border-right-color: #51cf66;
|
||||
.hint-icon { background: linear-gradient(135deg, #51cf66, #40c057); }
|
||||
}
|
||||
|
||||
&.up-hint {
|
||||
border-top-color: #339af0;
|
||||
.hint-icon { background: linear-gradient(135deg, #339af0, #228be6); }
|
||||
}
|
||||
|
||||
&.down-hint {
|
||||
border-bottom-color: #ffb84d;
|
||||
.hint-icon { background: linear-gradient(135deg, #ffb84d, #fd7e14); }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hintPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.3); opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Enhanced Decision Indicators */
|
||||
.enhanced-decision {
|
||||
transform: translate(-50%, -50%) scale(0) rotateZ(0deg);
|
||||
transition: all var(--physics-duration-normal) var(--spring-bouncy);
|
||||
filter: drop-shadow(0 0 20px rgba(0, 0, 0, 0.5));
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
.decision-icon {
|
||||
font-size: 4rem;
|
||||
animation: decisionFloat 3s ease-in-out infinite;
|
||||
text-shadow: 0 0 30px currentColor;
|
||||
}
|
||||
|
||||
.decision-particles {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
animation: particleBurst 1s ease-out forwards;
|
||||
|
||||
@for $i from 1 through 8 {
|
||||
&:nth-child(#{$i}) {
|
||||
transform: rotate(#{$i * 45}deg) translateX(40px);
|
||||
animation-delay: #{$i * 0.05}s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.enhanced-visible {
|
||||
transform: translate(-50%, -50%) scale(1) rotateZ(360deg);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Direction-specific colors */
|
||||
&.decision-left {
|
||||
color: #ff6b6b;
|
||||
.decision-icon { animation-duration: 2s; }
|
||||
}
|
||||
|
||||
&.decision-right {
|
||||
color: #51cf66;
|
||||
.decision-icon { animation-duration: 2.5s; }
|
||||
}
|
||||
|
||||
&.decision-up {
|
||||
color: #339af0;
|
||||
.decision-icon { animation-duration: 3s; }
|
||||
}
|
||||
|
||||
&.decision-down {
|
||||
color: #ffb84d;
|
||||
.decision-icon { animation-duration: 2.2s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes decisionFloat {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-10px) scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes particleBurst {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: rotate(var(--rotation, 0deg)) translateX(0) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: rotate(var(--rotation, 0deg)) translateX(60px) scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Swipe Container */
|
||||
.swipe-container {
|
||||
perspective: var(--physics-perspective);
|
||||
transform-style: preserve-3d;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
right: -10px;
|
||||
bottom: -10px;
|
||||
background: var(--gradient-physics-primary);
|
||||
border-radius: inherit;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
filter: blur(20px);
|
||||
transition: opacity var(--physics-duration-slow) var(--spring-smooth);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Physics-based Button Enhancements */
|
||||
.action-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transform: translateZ(0);
|
||||
transition: all var(--physics-duration-normal) var(--spring-wobbly);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: all var(--physics-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px) scale(1.05) translateZ(var(--physics-depth));
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.2),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-4px) scale(0.98) translateZ(calc(var(--physics-depth) / 2));
|
||||
transition-duration: var(--physics-duration-fast);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Modal Animations */
|
||||
.modal {
|
||||
backdrop-filter: blur(15px);
|
||||
|
||||
&.show {
|
||||
animation: modalFadeIn var(--physics-duration-slow) var(--spring-wobbly);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
transform: scale(0.8) translateY(50px) rotateX(10deg);
|
||||
transition: all var(--physics-duration-slow) var(--spring-wobbly);
|
||||
}
|
||||
|
||||
&.show .modal-content {
|
||||
transform: scale(1) translateY(0) rotateX(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
backdrop-filter: blur(15px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Toast Notifications */
|
||||
.toast {
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transform: translateX(-50%) translateY(100px) scale(0.8);
|
||||
transition: all var(--physics-duration-normal) var(--spring-bouncy);
|
||||
|
||||
&.show {
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--gradient-physics-primary);
|
||||
border-radius: 50px 50px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance Optimizations */
|
||||
.enhanced-card,
|
||||
.enhanced-hint,
|
||||
.enhanced-decision,
|
||||
.action-btn {
|
||||
will-change: transform, opacity, filter;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--physics-perspective: 800px;
|
||||
--physics-depth: 50px;
|
||||
--physics-duration-normal: 0.3s;
|
||||
}
|
||||
|
||||
.enhanced-hint {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
.hint-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
&::before {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.enhanced-decision .decision-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.particle {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced Motion Support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
.enhanced-card::before,
|
||||
.swipe-container::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Mode Support */
|
||||
@media (prefers-contrast: high) {
|
||||
.enhanced-hint {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.enhanced-decision {
|
||||
filter: contrast(1.5) drop-shadow(0 0 10px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.enhanced-hint,
|
||||
.enhanced-decision,
|
||||
.physics-spinner {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.enhanced-card {
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
517
scss/main.scss
517
scss/main.scss
@@ -1,2 +1,519 @@
|
||||
@use 'variables';
|
||||
@use 'components';
|
||||
@use 'enhanced-animations';
|
||||
|
||||
/* Enhanced Ripple Effects */
|
||||
.ripple-effect {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
transform: scale(0);
|
||||
animation: ripple 0.6s linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
to {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Keyword Pills */
|
||||
.enhanced-pill {
|
||||
animation: pillSlideIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
opacity: 0;
|
||||
|
||||
.pill-text {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.remove-keyword {
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pillSlideIn {
|
||||
to {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Toast Styles */
|
||||
.enhanced-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
.toast-icon {
|
||||
font-size: 1.2rem;
|
||||
animation: toastIconBounce 0.6s ease-out;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.toast-left {
|
||||
border-left: 3px solid #ff6b6b;
|
||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.1), rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
&.toast-right {
|
||||
border-left: 3px solid #51cf66;
|
||||
background: linear-gradient(135deg, rgba(81, 207, 102, 0.1), rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
&.toast-up {
|
||||
border-left: 3px solid #339af0;
|
||||
background: linear-gradient(135deg, rgba(51, 154, 240, 0.1), rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
&.toast-down {
|
||||
border-left: 3px solid #ffb84d;
|
||||
background: linear-gradient(135deg, rgba(255, 184, 77, 0.1), rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastIconBounce {
|
||||
0%, 20%, 60%, 100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px) scale(1.1);
|
||||
}
|
||||
80% {
|
||||
transform: translateY(-5px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Error and No Images States */
|
||||
.enhanced-message,
|
||||
.enhanced-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
animation: messageSlideIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
.no-images-icon,
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
animation: iconFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.no-images-text,
|
||||
.error-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 12px 24px;
|
||||
background: var(--gradient-physics-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px) scale(1.05);
|
||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-1px) scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes messageSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes iconFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px) rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Button States */
|
||||
.action-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: all 0.3s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:active::after {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Filter Button States */
|
||||
.filter-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
&.active {
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.4);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Container Hover Effects - Removed for cleaner UX */
|
||||
|
||||
/* Performance Optimizations */
|
||||
.enhanced-card,
|
||||
.enhanced-hint,
|
||||
.enhanced-decision,
|
||||
.enhanced-pill,
|
||||
.enhanced-toast {
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Accessibility Enhancements */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.enhanced-card,
|
||||
.enhanced-hint,
|
||||
.enhanced-decision,
|
||||
.enhanced-pill,
|
||||
.enhanced-toast {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.ripple-effect {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.enhanced-hint {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
border-width: 3px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.enhanced-decision {
|
||||
filter: contrast(1.5);
|
||||
}
|
||||
|
||||
.enhanced-toast {
|
||||
background: rgba(0, 0, 0, 0.95) !important;
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background-color: #1a1a1a;
|
||||
--card-background: #2d2d2d;
|
||||
--text-color: #ffffff;
|
||||
--light-color: #3a3a3a;
|
||||
--dark-color: #ffffff;
|
||||
}
|
||||
|
||||
.enhanced-card::before {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.enhanced-hint {
|
||||
background: rgba(45, 45, 45, 0.95);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.enhanced-message,
|
||||
.enhanced-error {
|
||||
.no-images-text,
|
||||
.error-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.enhanced-hint,
|
||||
.enhanced-decision,
|
||||
.ripple-effect,
|
||||
.enhanced-toast {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.enhanced-card {
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Settings Panel Styles */
|
||||
.settings-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 20px;
|
||||
transform: translateY(-50%);
|
||||
width: 320px;
|
||||
background: var(--card-background);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 1500;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.close-settings {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: var(--light-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(30, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.apply-btn {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #1c86e3;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
&.reset-btn {
|
||||
background: var(--light-color);
|
||||
color: var(--text-color);
|
||||
|
||||
&:hover {
|
||||
background: #e0e0e0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Settings Toggle Button */
|
||||
.settings-toggle {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 20px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 1400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(30, 144, 255, 0.3);
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1) rotate(90deg);
|
||||
box-shadow: 0 6px 20px rgba(30, 144, 255, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Responsive Settings */
|
||||
@media (max-width: 768px) {
|
||||
.settings-panel {
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
width: auto;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&.show {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
bottom: 100px;
|
||||
right: 15px;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user