Bug fixes

This commit is contained in:
Aodhan Collins
2025-07-31 01:38:07 +01:00
parent ec6d40bf8f
commit 5e893a0c9d
12 changed files with 3917 additions and 212 deletions

423
SWIPE_ANIMATIONS.md Normal file
View 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.

View 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;
}
}

View 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;
}
}

View File

@@ -80,22 +80,13 @@ class SwipeCard {
} }
createDecisionIndicators() { createDecisionIndicators() {
const directions = ['left', 'right', 'up', 'down']; // Decision indicators removed - no longer creating visual icons
const icons = ['fa-trash', 'fa-folder-plus', 'fa-star', 'fa-clock']; this.decisionIndicators = {
left: null,
directions.forEach((direction, index) => { right: null,
// Check if indicator already exists up: null,
let indicator = this.container.querySelector(`.decision-${direction}`); down: null
};
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;
});
} }
addEventListeners() { addEventListeners() {
@@ -243,14 +234,8 @@ class SwipeCard {
} }
showDecisionIndicator(direction) { showDecisionIndicator(direction) {
const indicator = this.decisionIndicators[direction]; // Decision indicators removed - no visual feedback needed
if (indicator) { console.log(`Swipe direction: ${direction}`);
indicator.classList.add('visible');
setTimeout(() => {
indicator.classList.remove('visible');
}, 800);
}
} }
setImage(imageInfo) { setImage(imageInfo) {

View File

@@ -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"> <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 class="loading-indicator">Loading...</div>
</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> </div>
<aside class="side-panel"> <aside class="side-panel">
@@ -123,6 +116,20 @@
<img src="static/icons/fullscreen.svg" class="btn-icon" alt="Fullscreen"> <img src="static/icons/fullscreen.svg" class="btn-icon" alt="Fullscreen">
</button> </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> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -160,7 +160,7 @@ document.addEventListener('DOMContentLoaded', function() {
<input type="checkbox" class="selection-checkbox"> <input type="checkbox" class="selection-checkbox">
</div> </div>
<img src="${ensureImagePath(selection.image_path)}" alt="Selected image" loading="lazy" class="${blurClass}"> <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"> <div class="selection-info">
<p>${selection.image_path.split('/').pop()}</p> <p>${selection.image_path.split('/').pop()}</p>
<p>Resolution: ${selection.resolution}</p> <p>Resolution: ${selection.resolution}</p>
@@ -183,8 +183,23 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
const actionClass = (action) => { const actionClass = (action) => {
// Direct mapping for full action names
const map = { 'Discarded':'discard', 'Kept':'keep', 'Favourited':'favorite', 'Reviewing':'review' }; 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 // Map action names to icon filenames for display
@@ -194,6 +209,24 @@ document.addEventListener('DOMContentLoaded', function() {
'Favourited': 'fav.svg', 'Favourited': 'fav.svg',
'Reviewing': 'review.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 getActionName = (action) => action;
const formatDate = (ts) => { const formatDate = (ts) => {

471
js/swipe-integration.js Normal file
View 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
View 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 = [];
}
}

View File

@@ -26,21 +26,52 @@ html, body {
.fullscreen-mode .header, .fullscreen-mode .header,
.fullscreen-mode .side-panel { .fullscreen-mode .side-panel {
display: none; display: none;
transition: opacity 0.3s ease;
} }
.fullscreen-mode .container { .fullscreen-mode .container {
padding: 0; padding: 0;
max-width: 100vw; max-width: 100vw;
height: 100vh; height: 100vh;
transition: all 0.3s ease;
} }
.fullscreen-mode .main-section { .fullscreen-mode .main-section {
height: 100%; height: 100%;
transition: height 0.3s ease;
} }
.fullscreen-mode .swipe-container { .fullscreen-mode .swipe-container {
height: 100%; height: 100%;
border-radius: 0; 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 */ /* Ultra-wide toggle button */
@@ -65,8 +96,8 @@ html, body {
} }
.fullscreen-toggle:hover { .fullscreen-toggle:hover {
opacity: 1; opacity: 0.9;
transform: scale(1.1); transform: scale(1.05);
} }
.header { .header {

View 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;
}
}

View File

@@ -1,2 +1,519 @@
@use 'variables'; @use 'variables';
@use 'components'; @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;
}
}