mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-08 20:51:50 -06:00
Merge pull request #9 from DRYTRIX/feature-MobileFriendly
feat: implement comprehensive mobile-friendly web interface
This commit is contained in:
249
MOBILE_IMPROVEMENTS.md
Normal file
249
MOBILE_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Mobile-Friendly Improvements for TimeTracker
|
||||
|
||||
This document outlines all the mobile-friendly improvements implemented in the TimeTracker application to provide an optimal experience on mobile devices.
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
The TimeTracker application has been completely redesigned with a mobile-first approach, ensuring excellent usability across all device sizes, from small mobile phones to large desktop screens.
|
||||
|
||||
## 📱 Key Mobile Improvements
|
||||
|
||||
### 1. **Enhanced Mobile Navigation**
|
||||
- **Collapsible Mobile Menu**: Responsive navigation that collapses into a hamburger menu on mobile
|
||||
- **Touch-Friendly Navigation**: Larger touch targets (44px minimum) for all navigation elements
|
||||
- **Swipe to Close**: Users can swipe left/right to close the mobile navigation menu
|
||||
- **Auto-Close**: Navigation automatically closes when clicking outside or selecting a menu item
|
||||
|
||||
### 2. **Mobile-Optimized Layouts**
|
||||
- **Responsive Grid System**: Bootstrap-based responsive grid that adapts to screen size
|
||||
- **Mobile-First Design**: Design starts with mobile and scales up to desktop
|
||||
- **Flexible Containers**: Containers and cards that stack properly on small screens
|
||||
- **Optimized Spacing**: Mobile-specific margins and padding for better visual hierarchy
|
||||
|
||||
### 3. **Touch-Friendly Interface Elements**
|
||||
- **Large Touch Targets**: All buttons, links, and form inputs meet the 44px minimum touch target requirement
|
||||
- **Touch Feedback**: Visual feedback when touching elements (scale animations)
|
||||
- **Improved Button Sizes**: Larger buttons on mobile for easier interaction
|
||||
- **Better Form Controls**: Larger form inputs that prevent accidental zoom on iOS
|
||||
|
||||
### 4. **Mobile-Responsive Tables**
|
||||
- **Card-Based Layout**: Tables transform into card layouts on mobile devices
|
||||
- **Data Labels**: Each table cell shows its column header on mobile
|
||||
- **Stacked Information**: Table data is presented in a vertical, easy-to-read format
|
||||
- **Touch-Friendly Actions**: Action buttons are properly sized and spaced for mobile
|
||||
|
||||
### 5. **Enhanced Mobile Forms**
|
||||
- **Mobile-Optimized Inputs**: Form inputs sized appropriately for mobile devices
|
||||
- **Better Validation**: Mobile-friendly error messages and validation feedback
|
||||
- **Improved Layout**: Form fields stack vertically on mobile for better usability
|
||||
- **Touch-Friendly Controls**: All form elements meet touch target requirements
|
||||
|
||||
### 6. **Mobile-Specific Features**
|
||||
- **Pull-to-Refresh**: Swipe down to refresh page content
|
||||
- **Swipe Navigation**: Swipe left/right for browser navigation
|
||||
- **Touch Gestures**: Intuitive touch interactions throughout the interface
|
||||
- **Mobile Keyboard Handling**: Automatic scrolling to focused form inputs
|
||||
|
||||
### 7. **Performance Optimizations**
|
||||
- **Lazy Loading**: Images and content load as needed
|
||||
- **Optimized Animations**: Reduced motion for users who prefer it
|
||||
- **Efficient Scrolling**: Smooth, optimized scrolling performance
|
||||
- **Mobile-Specific CSS**: Dedicated mobile stylesheets for better performance
|
||||
|
||||
## 🛠️ Technical Implementation
|
||||
|
||||
### CSS Improvements
|
||||
- **Mobile-First Media Queries**: CSS written for mobile first, then enhanced for larger screens
|
||||
- **CSS Custom Properties**: Variables for consistent mobile sizing and spacing
|
||||
- **Flexbox Layouts**: Modern CSS layouts that work well on all devices
|
||||
- **Mobile-Specific Classes**: Utility classes for mobile-specific styling
|
||||
|
||||
### JavaScript Enhancements
|
||||
- **Mobile Detection**: Automatic detection of mobile devices
|
||||
- **Touch Event Handling**: Proper touch event management
|
||||
- **Responsive Behavior**: JavaScript that adapts to screen size changes
|
||||
- **Performance Monitoring**: Mobile-specific performance optimizations
|
||||
|
||||
### HTML Structure
|
||||
- **Semantic Markup**: Proper HTML5 semantic elements
|
||||
- **Accessibility**: ARIA labels and proper accessibility attributes
|
||||
- **Mobile Meta Tags**: Proper viewport and mobile web app meta tags
|
||||
- **Responsive Images**: Images that scale appropriately for different screen sizes
|
||||
|
||||
## 📱 Device Support
|
||||
|
||||
### Mobile Phones
|
||||
- **Small Phones** (≤480px): Optimized layouts with full-width elements
|
||||
- **Standard Phones** (≤768px): Responsive layouts with mobile-specific features
|
||||
- **Large Phones** (≤991px): Enhanced mobile experience with touch gestures
|
||||
|
||||
### Tablets
|
||||
- **Small Tablets** (≤1024px): Tablet-optimized layouts
|
||||
- **Large Tablets** (≤1200px): Enhanced tablet experience
|
||||
|
||||
### Desktop
|
||||
- **Desktop** (>1200px): Full-featured desktop experience
|
||||
|
||||
## 🎨 Design Principles
|
||||
|
||||
### 1. **Accessibility First**
|
||||
- High contrast ratios for better readability
|
||||
- Proper focus states for keyboard navigation
|
||||
- Screen reader friendly markup
|
||||
- Touch target size compliance
|
||||
|
||||
### 2. **Performance Focused**
|
||||
- Minimal JavaScript execution on mobile
|
||||
- Optimized CSS delivery
|
||||
- Efficient DOM manipulation
|
||||
- Reduced network requests
|
||||
|
||||
### 3. **User Experience**
|
||||
- Intuitive touch interactions
|
||||
- Consistent visual feedback
|
||||
- Smooth animations and transitions
|
||||
- Clear visual hierarchy
|
||||
|
||||
## 🚀 Features by Page
|
||||
|
||||
### Dashboard
|
||||
- **Mobile-Optimized Cards**: Statistics and quick action cards stack properly
|
||||
- **Touch-Friendly Timer**: Large, easy-to-use timer controls
|
||||
- **Responsive Tables**: Recent entries table transforms for mobile
|
||||
- **Mobile Navigation**: Easy access to all dashboard features
|
||||
|
||||
### Projects
|
||||
- **Mobile Table Layout**: Project lists display as cards on mobile
|
||||
- **Touch-Friendly Actions**: Edit, view, and delete buttons properly sized
|
||||
- **Responsive Filters**: Filter controls stack vertically on mobile
|
||||
- **Mobile Forms**: Create and edit project forms optimized for mobile
|
||||
|
||||
### Timer/Log Time
|
||||
- **Mobile Form Layout**: Form fields stack vertically for mobile
|
||||
- **Touch-Friendly Inputs**: Date and time pickers optimized for mobile
|
||||
- **Mobile Validation**: Mobile-friendly error messages
|
||||
- **Responsive Buttons**: Full-width buttons on mobile
|
||||
|
||||
### Reports
|
||||
- **Mobile Charts**: Charts that scale appropriately for mobile
|
||||
- **Touch-Friendly Navigation**: Easy navigation between report types
|
||||
- **Mobile Data Display**: Data presented in mobile-friendly formats
|
||||
- **Responsive Filters**: Date and project filters optimized for mobile
|
||||
|
||||
## 🔧 Customization
|
||||
|
||||
### Adding Mobile-Specific Styles
|
||||
```css
|
||||
/* Use mobile-specific utility classes */
|
||||
.mobile-stack { /* Stack elements vertically on mobile */ }
|
||||
.mobile-btn { /* Full-width mobile buttons */ }
|
||||
.mobile-card { /* Mobile-optimized cards */ }
|
||||
.touch-target { /* Ensure proper touch target size */ }
|
||||
```
|
||||
|
||||
### Mobile JavaScript Features
|
||||
```javascript
|
||||
// Access mobile enhancer instance
|
||||
const mobileEnhancer = new MobileEnhancer();
|
||||
|
||||
// Check if device is mobile
|
||||
if (mobileEnhancer.isMobile) {
|
||||
// Apply mobile-specific logic
|
||||
}
|
||||
```
|
||||
|
||||
### Responsive Breakpoints
|
||||
```css
|
||||
/* Mobile first approach */
|
||||
@media (max-width: 768px) { /* Mobile styles */ }
|
||||
@media (max-width: 480px) { /* Small mobile styles */ }
|
||||
@media (min-width: 769px) { /* Desktop styles */ }
|
||||
```
|
||||
|
||||
## 📊 Performance Metrics
|
||||
|
||||
### Mobile Performance Targets
|
||||
- **First Contentful Paint**: < 1.5 seconds
|
||||
- **Largest Contentful Paint**: < 2.5 seconds
|
||||
- **Cumulative Layout Shift**: < 0.1
|
||||
- **First Input Delay**: < 100ms
|
||||
|
||||
### Optimization Techniques
|
||||
- **CSS Optimization**: Minified and optimized mobile CSS
|
||||
- **JavaScript Optimization**: Efficient mobile JavaScript execution
|
||||
- **Image Optimization**: Responsive images with appropriate sizes
|
||||
- **Font Optimization**: Web fonts optimized for mobile
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Mobile Testing Checklist
|
||||
- [ ] Test on various mobile devices and screen sizes
|
||||
- [ ] Verify touch target sizes (44px minimum)
|
||||
- [ ] Test mobile navigation functionality
|
||||
- [ ] Verify responsive table layouts
|
||||
- [ ] Test mobile form interactions
|
||||
- [ ] Verify mobile-specific features
|
||||
- [ ] Test performance on mobile networks
|
||||
- [ ] Verify accessibility on mobile devices
|
||||
|
||||
### Testing Tools
|
||||
- **Browser DevTools**: Mobile device simulation
|
||||
- **Real Devices**: Physical mobile device testing
|
||||
- **Performance Tools**: Lighthouse mobile audits
|
||||
- **Accessibility Tools**: Mobile accessibility testing
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
|
||||
### Planned Mobile Features
|
||||
- **Offline Support**: PWA capabilities for offline time tracking
|
||||
- **Mobile Notifications**: Push notifications for timer events
|
||||
- **Gesture Controls**: Advanced touch gesture support
|
||||
- **Mobile Analytics**: Mobile-specific usage analytics
|
||||
- **Dark Mode**: Mobile-optimized dark theme
|
||||
- **Haptic Feedback**: Touch feedback on supported devices
|
||||
|
||||
### Mobile App Considerations
|
||||
- **Native App**: Potential React Native or Flutter mobile app
|
||||
- **Hybrid App**: Cordova/PhoneGap wrapper for web app
|
||||
- **PWA Features**: Progressive Web App enhancements
|
||||
- **Mobile SDKs**: Integration with mobile development tools
|
||||
|
||||
## 📚 Resources
|
||||
|
||||
### Mobile Development Best Practices
|
||||
- [Google Mobile Guidelines](https://developers.google.com/web/fundamentals/design-and-ux/principles)
|
||||
- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/)
|
||||
- [Material Design Mobile](https://material.io/design/platform-guidance/platform-adaptation.html)
|
||||
|
||||
### Testing Resources
|
||||
- [Mobile-Friendly Test](https://search.google.com/test/mobile-friendly)
|
||||
- [Lighthouse Mobile](https://developers.google.com/web/tools/lighthouse)
|
||||
- [WebPageTest Mobile](https://www.webpagetest.org/mobile)
|
||||
|
||||
### Performance Tools
|
||||
- [PageSpeed Insights](https://pagespeed.web.dev/)
|
||||
- [WebPageTest](https://www.webpagetest.org/)
|
||||
- [GTmetrix](https://gtmetrix.com/)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
When contributing to mobile improvements:
|
||||
|
||||
1. **Test on Mobile**: Always test changes on mobile devices
|
||||
2. **Follow Guidelines**: Use established mobile design patterns
|
||||
3. **Performance First**: Ensure changes don't impact mobile performance
|
||||
4. **Accessibility**: Maintain accessibility standards on mobile
|
||||
5. **Documentation**: Update this document with new mobile features
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
### Version 1.0.0 - Initial Mobile Release
|
||||
- Complete mobile-first redesign
|
||||
- Touch-friendly interface elements
|
||||
- Mobile-responsive layouts
|
||||
- Mobile-specific JavaScript enhancements
|
||||
- Comprehensive mobile CSS framework
|
||||
|
||||
---
|
||||
|
||||
*This document is maintained by the TimeTracker development team. For questions or suggestions about mobile improvements, please open an issue or contact the development team.*
|
||||
558
app/static/mobile.css
Normal file
558
app/static/mobile.css
Normal file
@@ -0,0 +1,558 @@
|
||||
/* Mobile-First CSS for TimeTracker */
|
||||
|
||||
/* Mobile-specific variables */
|
||||
:root {
|
||||
--mobile-touch-target: 44px;
|
||||
--mobile-nav-height: 60px;
|
||||
--mobile-card-padding: 1rem;
|
||||
--mobile-button-height: 48px;
|
||||
--mobile-input-height: 48px;
|
||||
}
|
||||
|
||||
/* Mobile-specific improvements */
|
||||
@media (max-width: 768px) {
|
||||
/* Container improvements */
|
||||
.container, .container-fluid {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
/* Row and column improvements */
|
||||
.row {
|
||||
margin-left: -0.5rem;
|
||||
margin-right: -0.5rem;
|
||||
}
|
||||
|
||||
.col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Card improvements */
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--mobile-card-padding);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1rem var(--mobile-card-padding);
|
||||
}
|
||||
|
||||
/* Button improvements */
|
||||
.btn {
|
||||
min-height: var(--mobile-button-height);
|
||||
padding: 0.875rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
min-height: 40px;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
min-height: 56px;
|
||||
padding: 1.125rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Button group improvements */
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
border-radius: 8px !important;
|
||||
margin-bottom: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-group .btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Form improvements */
|
||||
.form-control, .form-select {
|
||||
min-height: var(--mobile-input-height);
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Table improvements for mobile */
|
||||
.table-responsive {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table tbody {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table td {
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table td:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table td:before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.table td.actions-cell {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.table td.actions-cell:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Navigation improvements */
|
||||
.navbar {
|
||||
min-height: var(--mobile-nav-height);
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
min-height: var(--mobile-touch-target);
|
||||
min-width: var(--mobile-touch-target);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.navbar-toggler:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
background: white;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
margin-top: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 0.25rem 0;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
min-height: var(--mobile-touch-target);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link i {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: static !important;
|
||||
float: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
margin: 0.25rem 0.5rem;
|
||||
min-height: var(--mobile-touch-target);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Modal improvements */
|
||||
.modal-dialog {
|
||||
margin: 0.5rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-header, .modal-body, .modal-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-footer .btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Typography improvements */
|
||||
h1 { font-size: 1.75rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
h4 { font-size: 1.125rem; }
|
||||
h5 { font-size: 1rem; }
|
||||
h6 { font-size: 0.95rem; }
|
||||
|
||||
/* Spacing improvements */
|
||||
.mb-4 { margin-bottom: 1.5rem !important; }
|
||||
.mb-3 { margin-bottom: 1rem !important; }
|
||||
.mb-2 { margin-bottom: 0.75rem !important; }
|
||||
|
||||
.py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; }
|
||||
.py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; }
|
||||
|
||||
/* Utility classes */
|
||||
.mobile-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-stack .btn {
|
||||
margin-bottom: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-stack .btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.touch-target {
|
||||
min-height: var(--mobile-touch-target);
|
||||
min-width: var(--mobile-touch-target);
|
||||
}
|
||||
|
||||
/* Mobile-specific components */
|
||||
.mobile-card {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mobile-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
min-height: var(--mobile-button-height);
|
||||
}
|
||||
|
||||
.mobile-btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Mobile table improvements */
|
||||
.mobile-table-row {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mobile-table-row .row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-table-row .col {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.mobile-table-row .col:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-table-row .col:before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Mobile form improvements */
|
||||
.mobile-form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-form-group .form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-form-group .form-control,
|
||||
.mobile-form-group .form-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile navigation improvements */
|
||||
.mobile-nav-item {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin: 0.25rem 0;
|
||||
transition: all 0.2s ease;
|
||||
min-height: var(--mobile-touch-target);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-nav-item:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.mobile-nav-item.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Mobile-specific animations */
|
||||
.mobile-fade-in {
|
||||
animation: mobileFadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes mobileFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific shadows */
|
||||
.mobile-shadow {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mobile-shadow-hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Mobile-specific borders */
|
||||
.mobile-border {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Mobile-specific backgrounds */
|
||||
.mobile-bg-light {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.mobile-bg-white {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Mobile-specific text colors */
|
||||
.mobile-text-primary {
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
.mobile-text-secondary {
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
.mobile-text-muted {
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
/* Mobile-specific spacing utilities */
|
||||
.mobile-p-0 { padding: 0 !important; }
|
||||
.mobile-p-1 { padding: 0.25rem !important; }
|
||||
.mobile-p-2 { padding: 0.5rem !important; }
|
||||
.mobile-p-3 { padding: 1rem !important; }
|
||||
.mobile-p-4 { padding: 1.5rem !important; }
|
||||
|
||||
.mobile-m-0 { margin: 0 !important; }
|
||||
.mobile-m-1 { margin: 0.25rem !important; }
|
||||
.mobile-m-2 { margin: 0.5rem !important; }
|
||||
.mobile-m-3 { margin: 1rem !important; }
|
||||
.mobile-m-4 { margin: 1.5rem !important; }
|
||||
}
|
||||
|
||||
/* Small mobile devices */
|
||||
@media (max-width: 480px) {
|
||||
.container, .container-fluid {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
padding: 0.75rem 0.875rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
margin: 0.25rem;
|
||||
max-width: calc(100% - 0.5rem);
|
||||
}
|
||||
|
||||
.modal-header, .modal-body, .modal-footer {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape mobile devices */
|
||||
@media (max-width: 768px) and (orientation: landscape) {
|
||||
.navbar-collapse {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
margin: 1rem;
|
||||
max-width: calc(100% - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* High-DPI mobile devices */
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||
.btn, .form-control, .form-select {
|
||||
border-width: 0.5px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
border-bottom-width: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific print styles */
|
||||
@media print and (max-width: 768px) {
|
||||
.navbar, .btn, .modal {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #000 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.table td {
|
||||
border: 1px solid #000 !important;
|
||||
}
|
||||
}
|
||||
531
app/static/mobile.js
Normal file
531
app/static/mobile.js
Normal file
@@ -0,0 +1,531 @@
|
||||
// Mobile-specific JavaScript for TimeTracker
|
||||
|
||||
class MobileEnhancer {
|
||||
constructor() {
|
||||
this.isMobile = window.innerWidth <= 768;
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
this.touchEndX = 0;
|
||||
this.touchEndY = 0;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.detectMobile();
|
||||
this.enhanceTouchTargets();
|
||||
this.enhanceTables();
|
||||
this.enhanceNavigation();
|
||||
this.enhanceModals();
|
||||
this.enhanceForms();
|
||||
this.addTouchGestures();
|
||||
this.handleViewportChanges();
|
||||
this.addMobileSpecificFeatures();
|
||||
}
|
||||
|
||||
detectMobile() {
|
||||
if (this.isMobile) {
|
||||
document.body.classList.add('mobile-view');
|
||||
this.addMobileMetaTags();
|
||||
}
|
||||
}
|
||||
|
||||
addMobileMetaTags() {
|
||||
// Add mobile-specific meta tags if not already present
|
||||
if (!document.querySelector('meta[name="mobile-web-app-capable"]')) {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'mobile-web-app-capable';
|
||||
meta.content = 'yes';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
|
||||
if (!document.querySelector('meta[name="apple-mobile-web-app-capable"]')) {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'apple-mobile-web-app-capable';
|
||||
meta.content = 'yes';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
}
|
||||
|
||||
enhanceTouchTargets() {
|
||||
// Improve touch targets for mobile
|
||||
const touchElements = document.querySelectorAll('.btn, .form-control, .form-select, .nav-link, .dropdown-item');
|
||||
touchElements.forEach(element => {
|
||||
element.classList.add('touch-target');
|
||||
|
||||
// Add touch feedback
|
||||
element.addEventListener('touchstart', this.handleTouchStart.bind(this));
|
||||
element.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
// Add missing touch event handlers
|
||||
handleTouchStart(event) {
|
||||
const element = event.currentTarget;
|
||||
element.style.transform = 'scale(0.98)';
|
||||
element.style.transition = 'transform 0.1s ease';
|
||||
|
||||
// Store touch coordinates for gesture detection
|
||||
this.touchStartX = event.touches[0].clientX;
|
||||
this.touchStartY = event.touches[0].clientY;
|
||||
}
|
||||
|
||||
handleTouchEnd(event) {
|
||||
const element = event.currentTarget;
|
||||
element.style.transform = 'scale(1)';
|
||||
|
||||
// Store end coordinates for gesture detection
|
||||
this.touchEndX = event.changedTouches[0].clientX;
|
||||
this.touchEndY = event.changedTouches[0].clientY;
|
||||
}
|
||||
|
||||
enhanceTables() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
const tables = document.querySelectorAll('.table');
|
||||
tables.forEach(table => {
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
rows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
cells.forEach((cell, index) => {
|
||||
// Add data-label attributes for mobile table display
|
||||
const header = table.querySelector(`thead th:nth-child(${index + 1})`);
|
||||
if (header) {
|
||||
cell.setAttribute('data-label', header.textContent.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
enhanceNavigation() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
const navbarToggler = document.querySelector('.navbar-toggler');
|
||||
const navbarCollapse = document.querySelector('.navbar-collapse');
|
||||
|
||||
if (navbarToggler && navbarCollapse) {
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
const isClickInsideNavbar = navbarToggler.contains(event.target) || navbarCollapse.contains(event.target);
|
||||
|
||||
if (!isClickInsideNavbar && navbarCollapse.classList.contains('show')) {
|
||||
const bsCollapse = new bootstrap.Collapse(navbarCollapse);
|
||||
bsCollapse.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking on a nav link
|
||||
const navLinks = navbarCollapse.querySelectorAll('.nav-link');
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 991.98) {
|
||||
const bsCollapse = new bootstrap.Collapse(navbarCollapse);
|
||||
bsCollapse.hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add swipe to close functionality
|
||||
this.addSwipeToClose(navbarCollapse);
|
||||
}
|
||||
}
|
||||
|
||||
enhanceModals() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Enhance modal behavior on mobile
|
||||
const modals = document.querySelectorAll('.modal');
|
||||
modals.forEach(modal => {
|
||||
// Add swipe to close functionality
|
||||
this.addSwipeToClose(modal);
|
||||
|
||||
// Improve modal positioning
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
this.centerModal(modal);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
enhanceForms() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Enhance form inputs for mobile
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.classList.add('mobile-form');
|
||||
|
||||
// Add mobile-specific form validation
|
||||
this.addMobileFormValidation(form);
|
||||
|
||||
// Improve form submission on mobile
|
||||
this.enhanceFormSubmission(form);
|
||||
});
|
||||
}
|
||||
|
||||
addTouchGestures() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Add swipe gestures for navigation
|
||||
this.addSwipeNavigation();
|
||||
|
||||
// Add pull-to-refresh functionality
|
||||
this.addPullToRefresh();
|
||||
|
||||
// Add touch feedback
|
||||
this.addTouchFeedback();
|
||||
}
|
||||
|
||||
addSwipeToClose(element) {
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
|
||||
element.addEventListener('touchstart', (e) => {
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
element.addEventListener('touchmove', (e) => {
|
||||
currentX = e.touches[0].clientX;
|
||||
currentY = e.touches[0].clientY;
|
||||
|
||||
const diffX = startX - currentX;
|
||||
const diffY = startY - currentY;
|
||||
|
||||
// Horizontal swipe to close
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
||||
if (element.classList.contains('navbar-collapse')) {
|
||||
const bsCollapse = new bootstrap.Collapse(element);
|
||||
bsCollapse.hide();
|
||||
} else if (element.classList.contains('modal')) {
|
||||
const modal = bootstrap.Modal.getInstance(element);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addSwipeNavigation() {
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', (e) => {
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
|
||||
const diffX = startX - endX;
|
||||
const diffY = startY - endY;
|
||||
|
||||
// Swipe left/right for navigation (if on specific pages)
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 100) {
|
||||
this.handleSwipeNavigation(diffX > 0 ? 'left' : 'right');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addPullToRefresh() {
|
||||
let startY = 0;
|
||||
let currentY = 0;
|
||||
let pullDistance = 0;
|
||||
let isPulling = false;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (window.scrollY === 0) {
|
||||
startY = e.touches[0].clientY;
|
||||
isPulling = true;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (!isPulling) return;
|
||||
|
||||
currentY = e.touches[0].clientY;
|
||||
pullDistance = currentY - startY;
|
||||
|
||||
if (pullDistance > 0 && pullDistance < 100) {
|
||||
this.showPullToRefreshIndicator(pullDistance);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
if (isPulling && pullDistance > 80) {
|
||||
this.refreshPage();
|
||||
}
|
||||
this.hidePullToRefreshIndicator();
|
||||
isPulling = false;
|
||||
});
|
||||
}
|
||||
|
||||
addTouchFeedback() {
|
||||
const touchElements = document.querySelectorAll('.btn, .card, .nav-link');
|
||||
|
||||
touchElements.forEach(element => {
|
||||
element.addEventListener('touchstart', () => {
|
||||
element.style.transform = 'scale(0.98)';
|
||||
element.style.transition = 'transform 0.1s ease';
|
||||
});
|
||||
|
||||
element.addEventListener('touchend', () => {
|
||||
element.style.transform = 'scale(1)';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleSwipeNavigation(direction) {
|
||||
// Handle swipe navigation based on current page
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
if (direction === 'left') {
|
||||
// Swipe left - go forward
|
||||
if (window.history.length > 1) {
|
||||
window.history.forward();
|
||||
}
|
||||
} else {
|
||||
// Swipe right - go back
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showPullToRefreshIndicator(distance) {
|
||||
// Create or update pull-to-refresh indicator
|
||||
let indicator = document.getElementById('pull-to-refresh-indicator');
|
||||
if (!indicator) {
|
||||
indicator = document.createElement('div');
|
||||
indicator.id = 'pull-to-refresh-indicator';
|
||||
indicator.innerHTML = `
|
||||
<div class="text-center p-3">
|
||||
<i class="fas fa-arrow-down text-primary"></i>
|
||||
<span class="ms-2">Pull to refresh</span>
|
||||
</div>
|
||||
`;
|
||||
indicator.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
z-index: 9999;
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
`;
|
||||
document.body.appendChild(indicator);
|
||||
}
|
||||
|
||||
const translateY = Math.min(distance * 0.5, 100);
|
||||
indicator.style.transform = `translateY(${translateY - 100}px)`;
|
||||
}
|
||||
|
||||
hidePullToRefreshIndicator() {
|
||||
const indicator = document.getElementById('pull-to-refresh-indicator');
|
||||
if (indicator) {
|
||||
indicator.style.transform = 'translateY(-100%)';
|
||||
setTimeout(() => {
|
||||
if (indicator.parentNode) {
|
||||
indicator.parentNode.removeChild(indicator);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
refreshPage() {
|
||||
// Show loading indicator
|
||||
this.showLoadingIndicator();
|
||||
|
||||
// Refresh the page
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
showLoadingIndicator() {
|
||||
const loading = document.createElement('div');
|
||||
loading.id = 'mobile-loading-indicator';
|
||||
loading.innerHTML = `
|
||||
<div class="text-center p-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-2">Refreshing...</div>
|
||||
</div>
|
||||
`;
|
||||
loading.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
z-index: 10000;
|
||||
`;
|
||||
document.body.appendChild(loading);
|
||||
}
|
||||
|
||||
centerModal(modal) {
|
||||
// Center modal content on mobile
|
||||
const modalContent = modal.querySelector('.modal-content');
|
||||
if (modalContent) {
|
||||
modalContent.style.margin = 'auto';
|
||||
modalContent.style.maxHeight = '90vh';
|
||||
modalContent.style.overflow = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
addMobileFormValidation(form) {
|
||||
// Add mobile-specific form validation
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('invalid', (e) => {
|
||||
e.preventDefault();
|
||||
this.showMobileValidationError(input);
|
||||
});
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
this.hideMobileValidationError(input);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showMobileValidationError(input) {
|
||||
// Show mobile-friendly validation error
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'mobile-validation-error';
|
||||
errorDiv.textContent = input.validationMessage;
|
||||
errorDiv.style.cssText = `
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #fecaca;
|
||||
`;
|
||||
|
||||
input.parentNode.appendChild(errorDiv);
|
||||
input.style.borderColor = '#dc2626';
|
||||
}
|
||||
|
||||
hideMobileValidationError(input) {
|
||||
const errorDiv = input.parentNode.querySelector('.mobile-validation-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
input.style.borderColor = '#e2e8f0';
|
||||
}
|
||||
|
||||
enhanceFormSubmission(form) {
|
||||
// Enhance form submission for mobile
|
||||
form.addEventListener('submit', (e) => {
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
if (submitBtn) {
|
||||
submitBtn.innerHTML = '<div class="spinner-border spinner-border-sm me-2"></div>Submitting...';
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleViewportChanges() {
|
||||
window.addEventListener('resize', () => {
|
||||
const wasMobile = this.isMobile;
|
||||
this.isMobile = window.innerWidth <= 768;
|
||||
|
||||
if (wasMobile !== this.isMobile) {
|
||||
if (this.isMobile) {
|
||||
document.body.classList.add('mobile-view');
|
||||
this.enhanceTouchTargets();
|
||||
this.enhanceTables();
|
||||
} else {
|
||||
document.body.classList.remove('mobile-view');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addMobileSpecificFeatures() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Add mobile-specific features
|
||||
this.addMobileKeyboardHandling();
|
||||
this.addMobileScrollOptimization();
|
||||
this.addMobilePerformanceOptimizations();
|
||||
}
|
||||
|
||||
addMobileKeyboardHandling() {
|
||||
// Handle mobile keyboard events
|
||||
const inputs = document.querySelectorAll('input, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('focus', () => {
|
||||
// Scroll to input when focused on mobile
|
||||
setTimeout(() => {
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addMobileScrollOptimization() {
|
||||
// Optimize scrolling on mobile
|
||||
let ticking = false;
|
||||
|
||||
const updateScroll = () => {
|
||||
// Add scroll-based animations or effects
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
const requestTick = () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(updateScroll);
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('scroll', requestTick, { passive: true });
|
||||
}
|
||||
|
||||
addMobilePerformanceOptimizations() {
|
||||
// Add mobile-specific performance optimizations
|
||||
|
||||
// Lazy load images
|
||||
if ('IntersectionObserver' in window) {
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('img[data-src]').forEach(img => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
// Optimize animations for mobile
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
if (prefersReducedMotion.matches) {
|
||||
document.body.style.setProperty('--transition', 'none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize mobile enhancements when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new MobileEnhancer();
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
window.MobileEnhancer = MobileEnhancer;
|
||||
@@ -2,7 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="theme-color" content="#3b82f6">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
@@ -15,6 +19,7 @@
|
||||
<!-- Google Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #3b82f6;
|
||||
@@ -45,6 +50,8 @@
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -56,14 +63,17 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.95rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 0 auto;
|
||||
display: block;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Navbar Styling */
|
||||
/* Mobile-First Navigation */
|
||||
.navbar {
|
||||
background: white !important;
|
||||
backdrop-filter: blur(10px);
|
||||
@@ -72,6 +82,7 @@
|
||||
padding: 0.75rem 0;
|
||||
z-index: 1030;
|
||||
position: relative;
|
||||
min-height: var(--mobile-nav-height);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
@@ -99,11 +110,14 @@
|
||||
.navbar-nav .nav-link {
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
margin: 0 0.25rem;
|
||||
min-height: var(--mobile-touch-target);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link:hover {
|
||||
@@ -134,6 +148,9 @@
|
||||
padding: 0.75rem 1.5rem;
|
||||
transition: var(--transition);
|
||||
color: var(--text-secondary);
|
||||
min-height: var(--mobile-touch-target);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
@@ -141,6 +158,60 @@
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Mobile Navigation Toggle */
|
||||
.navbar-toggler {
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
min-height: var(--mobile-touch-target);
|
||||
min-width: var(--mobile-touch-target);
|
||||
}
|
||||
|
||||
.navbar-toggler:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Mobile Navigation Menu */
|
||||
@media (max-width: 991.98px) {
|
||||
.navbar-collapse {
|
||||
background: white;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 0.25rem 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link i {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: static !important;
|
||||
float: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: var(--light-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card Styling */
|
||||
.card {
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -198,15 +269,27 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Button Styling */
|
||||
/* Button Styling - Mobile Optimized */
|
||||
.btn {
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-weight: 500;
|
||||
padding: 0.625rem 1.25rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
transition: var(--transition);
|
||||
border: none;
|
||||
position: relative;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
min-height: var(--mobile-touch-target);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -266,6 +349,21 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Mobile Button Sizes */
|
||||
@media (max-width: 768px) {
|
||||
.btn {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Timer Display */
|
||||
.timer-display {
|
||||
font-family: 'Inter', monospace;
|
||||
@@ -300,7 +398,7 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
/* Table Styling - Mobile Responsive */
|
||||
.table {
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
@@ -334,6 +432,69 @@
|
||||
background: var(--light-color);
|
||||
}
|
||||
|
||||
/* Mobile Table Responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.table-responsive {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table tbody {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: white;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.table td {
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table td:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table td:before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.table td.actions-cell {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.table td.actions-cell:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge Styling */
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
@@ -342,14 +503,15 @@
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
/* Form Styling */
|
||||
/* Form Styling - Mobile Optimized */
|
||||
.form-control, .form-select {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
transition: var(--transition);
|
||||
background: white;
|
||||
min-height: var(--mobile-touch-target);
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
@@ -361,8 +523,21 @@
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Mobile Form Improvements */
|
||||
@media (max-width: 768px) {
|
||||
.form-control, .form-select {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert Styling */
|
||||
@@ -399,7 +574,7 @@
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* Modal Styling */
|
||||
/* Modal Styling - Mobile Optimized */
|
||||
.modal-content {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
@@ -420,6 +595,22 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Mobile Modal Improvements */
|
||||
@media (max-width: 576px) {
|
||||
.modal-dialog {
|
||||
margin: 0.5rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.modal-header, .modal-body, .modal-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: white;
|
||||
@@ -465,7 +656,7 @@
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
/* Enhanced Mobile Responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.timer-display {
|
||||
font-size: 1.5rem;
|
||||
@@ -479,8 +670,82 @@
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-left: -0.5rem;
|
||||
margin-right: -0.5rem;
|
||||
}
|
||||
|
||||
.col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mobile-specific spacing */
|
||||
.mb-4 { margin-bottom: 1.5rem !important; }
|
||||
.mb-3 { margin-bottom: 1rem !important; }
|
||||
.mb-2 { margin-bottom: 0.75rem !important; }
|
||||
|
||||
.py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; }
|
||||
.py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; }
|
||||
|
||||
/* Mobile navigation improvements */
|
||||
.navbar-nav .nav-link {
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Mobile card improvements */
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Mobile button group improvements */
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
border-radius: var(--border-radius-sm) !important;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-group .btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small Mobile Devices */
|
||||
@media (max-width: 480px) {
|
||||
.navbar-brand {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.d-flex.justify-content-between {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.d-flex.justify-content-between .btn {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +805,16 @@
|
||||
h5 { font-size: 1.125rem; }
|
||||
h6 { font-size: 1rem; }
|
||||
|
||||
/* Mobile Typography */
|
||||
@media (max-width: 768px) {
|
||||
h1 { font-size: 1.75rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
h4 { font-size: 1.125rem; }
|
||||
h5 { font-size: 1rem; }
|
||||
h6 { font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
@@ -627,6 +902,117 @@
|
||||
|
||||
.px-4 { padding-left: 1.5rem !important; padding-right: 1.5rem !important; }
|
||||
.px-3 { padding-left: 1rem !important; padding-right: 1rem !important; }
|
||||
|
||||
/* Mobile-specific improvements */
|
||||
.mobile-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-stack .btn {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-stack .btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Touch-friendly improvements */
|
||||
.touch-target {
|
||||
min-height: var(--mobile-touch-target);
|
||||
min-width: var(--mobile-touch-target);
|
||||
}
|
||||
|
||||
/* Mobile navigation improvements */
|
||||
.mobile-nav-item {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin: 0.25rem 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.mobile-nav-item:hover {
|
||||
background: var(--light-color);
|
||||
}
|
||||
|
||||
.mobile-nav-item.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Mobile form improvements */
|
||||
.mobile-form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-form-group .form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-form-group .form-control,
|
||||
.mobile-form-group .form-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile card improvements */
|
||||
.mobile-card {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.mobile-card .card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* Mobile button improvements */
|
||||
.mobile-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.mobile-btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Mobile table improvements */
|
||||
.mobile-table-row {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.mobile-table-row .row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-table-row .col {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mobile-table-row .col:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-table-row .col:before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
@@ -640,7 +1026,7 @@
|
||||
<span class="text-dark fw-bold">Time Tracker</span>
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
@@ -678,7 +1064,7 @@
|
||||
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
|
||||
<i class="fas fa-user text-primary"></i>
|
||||
</div>
|
||||
@@ -708,7 +1094,7 @@
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show fade-in" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -743,6 +1129,7 @@
|
||||
<!-- Socket.IO -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.js"></script>
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', filename='mobile.js') }}"></script>
|
||||
<script>
|
||||
// Initialize Socket.IO
|
||||
const socket = io();
|
||||
@@ -813,6 +1200,62 @@
|
||||
card.style.animationDelay = `${index * 0.1}s`;
|
||||
card.classList.add('fade-in');
|
||||
});
|
||||
|
||||
// Mobile-specific improvements
|
||||
if (window.innerWidth <= 768) {
|
||||
// Add mobile-specific classes
|
||||
document.body.classList.add('mobile-view');
|
||||
|
||||
// Improve touch targets
|
||||
const buttons = document.querySelectorAll('.btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.classList.add('touch-target');
|
||||
});
|
||||
|
||||
// Improve form inputs
|
||||
const inputs = document.querySelectorAll('.form-control, .form-select');
|
||||
inputs.forEach(input => {
|
||||
input.classList.add('touch-target');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle mobile navigation improvements
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const navbarToggler = document.querySelector('.navbar-toggler');
|
||||
const navbarCollapse = document.querySelector('.navbar-collapse');
|
||||
|
||||
if (navbarToggler && navbarCollapse) {
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const isClickInsideNavbar = navbarToggler.contains(event.target) || navbarCollapse.contains(event.target);
|
||||
|
||||
if (!isClickInsideNavbar && navbarCollapse.classList.contains('show')) {
|
||||
const bsCollapse = new bootstrap.Collapse(navbarCollapse);
|
||||
bsCollapse.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking on a nav link
|
||||
const navLinks = navbarCollapse.querySelectorAll('.nav-link');
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
if (window.innerWidth <= 991.98) {
|
||||
const bsCollapse = new bootstrap.Collapse(navbarCollapse);
|
||||
bsCollapse.hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle mobile viewport changes
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth <= 768) {
|
||||
document.body.classList.add('mobile-view');
|
||||
} else {
|
||||
document.body.classList.remove('mobile-view');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
<!-- Welcome Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-body py-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<div class="col-lg-8 col-md-7 mb-3 mb-md-0">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="DryTrix Logo" class="me-2" width="24" height="24">
|
||||
<h2 class="mb-0">Welcome back, {{ current_user.username }}!</h2>
|
||||
</div>
|
||||
<p class="text-muted mb-0">Track your productivity and manage your time effectively with <strong>DryTrix</strong> TimeTracker</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end">
|
||||
<div class="col-lg-4 col-md-5 text-center text-md-end">
|
||||
<div class="d-flex justify-content-center justify-content-md-end">
|
||||
<div class="text-center">
|
||||
<div class="h2 text-primary mb-1">{{ today_hours|round(1) }}</div>
|
||||
@@ -36,7 +36,7 @@
|
||||
<!-- Timer Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-clock me-2 text-primary"></i>Timer Status
|
||||
@@ -44,15 +44,15 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<div class="col-lg-8 col-md-7 mb-3 mb-md-0">
|
||||
{% if active_timer %}
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-4">
|
||||
<div class="d-flex align-items-center flex-column flex-md-row">
|
||||
<div class="me-0 me-md-4 mb-3 mb-md-0">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
|
||||
<i class="fas fa-play text-success fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-center text-md-start">
|
||||
<h5 class="mb-1 text-success">Timer Running</h5>
|
||||
<p class="mb-2 text-muted">
|
||||
<i class="fas fa-project-diagram me-1"></i>{{ active_timer.project.name }}
|
||||
@@ -66,13 +66,13 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-4">
|
||||
<div class="d-flex align-items-center flex-column flex-md-row">
|
||||
<div class="me-0 me-md-4 mb-3 mb-md-0">
|
||||
<div class="bg-light rounded-circle d-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
|
||||
<i class="fas fa-stop text-muted fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-center text-md-start">
|
||||
<h5 class="mb-1 text-muted">No Active Timer</h5>
|
||||
<p class="mb-0 text-muted">Ready to start tracking your time?</p>
|
||||
<div class="mt-2">
|
||||
@@ -82,15 +82,15 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4 text-center text-md-end">
|
||||
<div class="col-lg-4 col-md-5 text-center">
|
||||
{% if active_timer %}
|
||||
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger px-4">
|
||||
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline w-100">
|
||||
<button type="submit" class="btn btn-danger px-4 w-100 w-md-auto touch-target">
|
||||
<i class="fas fa-stop me-2"></i>Stop Timer
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-success px-4" data-bs-toggle="modal" data-bs-target="#startTimerModal">
|
||||
<button type="button" class="btn btn-success px-4 w-100 w-md-auto touch-target" data-bs-toggle="modal" data-bs-target="#startTimerModal">
|
||||
<i class="fas fa-play me-2"></i>Start Timer
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -103,8 +103,8 @@
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="col-lg-4 col-md-6 mb-3">
|
||||
<div class="card h-100 mobile-card">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 60px; height: 60px;">
|
||||
<i class="fas fa-calendar-day text-primary fa-2x"></i>
|
||||
@@ -117,8 +117,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="col-lg-4 col-md-6 mb-3">
|
||||
<div class="card h-100 mobile-card">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 60px; height: 60px;">
|
||||
<i class="fas fa-calendar-week text-success fa-2x"></i>
|
||||
@@ -131,8 +131,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="col-lg-4 col-md-6 mb-3">
|
||||
<div class="card h-100 mobile-card">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 60px; height: 60px;">
|
||||
<i class="fas fa-calendar-alt text-info fa-2x"></i>
|
||||
@@ -150,7 +150,7 @@
|
||||
<!-- Quick Actions -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-bolt me-2 text-warning"></i>Quick Actions
|
||||
@@ -158,8 +158,8 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="card h-100 text-decoration-none">
|
||||
<div class="col-lg-3 col-md-6 col-sm-6">
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="card h-100 text-decoration-none mobile-card">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 50px; height: 50px;">
|
||||
<i class="fas fa-plus text-primary fa-lg"></i>
|
||||
@@ -169,8 +169,8 @@
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="card h-100 text-decoration-none">
|
||||
<div class="col-lg-3 col-md-6 col-sm-6">
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="card h-100 text-decoration-none mobile-card">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 50px; height: 50px;">
|
||||
<i class="fas fa-project-diagram text-secondary fa-lg"></i>
|
||||
@@ -180,8 +180,8 @@
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<a href="{{ url_for('reports.reports') }}" class="card h-100 text-decoration-none">
|
||||
<div class="col-lg-3 col-md-6 col-sm-6">
|
||||
<a href="{{ url_for('reports.reports') }}" class="card h-100 text-decoration-none mobile-card">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 50px; height: 50px;">
|
||||
<i class="fas fa-chart-bar text-info fa-lg"></i>
|
||||
@@ -191,8 +191,8 @@
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<a href="{{ url_for('main.search') }}" class="card h-100 text-decoration-none">
|
||||
<div class="col-lg-3 col-md-6 col-sm-6">
|
||||
<a href="{{ url_for('main.search') }}" class="card h-100 text-decoration-none mobile-card">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 50px; height: 50px;">
|
||||
<i class="fas fa-search text-warning fa-lg"></i>
|
||||
@@ -211,12 +211,12 @@
|
||||
<!-- Recent Entries -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-column flex-md-row">
|
||||
<h5 class="mb-0 mb-3 mb-md-0">
|
||||
<i class="fas fa-history me-2 text-primary"></i>Recent Entries
|
||||
</h5>
|
||||
<a href="{{ url_for('reports.reports') }}" class="btn btn-sm btn-outline-primary">
|
||||
<a href="{{ url_for('reports.reports') }}" class="btn btn-sm btn-outline-primary touch-target">
|
||||
<i class="fas fa-external-link-alt me-1"></i>View All
|
||||
</a>
|
||||
</div>
|
||||
@@ -236,7 +236,7 @@
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<td class="ps-4" data-label="Project">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 36px; height: 36px;">
|
||||
@@ -249,16 +249,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Duration">
|
||||
<span class="badge bg-primary">{{ entry.duration_formatted }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Date">
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold">{{ entry.start_time.strftime('%b %d') }}</span>
|
||||
<small class="text-muted">{{ entry.start_time.strftime('%H:%M') }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Notes">
|
||||
{% if entry.notes %}
|
||||
<div class="text-truncate" style="max-width: 200px;" title="{{ entry.notes }}">
|
||||
{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}
|
||||
@@ -267,20 +267,20 @@
|
||||
<span class="text-muted fst-italic">No notes</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Edit entry">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if current_user.is_admin or entry.user_id == current_user.id %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete entry"
|
||||
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ entry.project.name }}', '{{ entry.duration_formatted }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end pe-4 actions-cell" data-label="Actions">
|
||||
<div class="btn-group d-flex d-md-inline-flex mobile-stack" role="group">
|
||||
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary touch-target" title="Edit entry">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if current_user.is_admin or entry.user_id == current_user.id %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger touch-target" title="Delete entry"
|
||||
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ entry.project.name }}', '{{ entry.duration_formatted }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -293,7 +293,7 @@
|
||||
</div>
|
||||
<h5 class="text-muted mb-3">No recent entries</h5>
|
||||
<p class="text-muted mb-4">Start tracking your time to see entries here</p>
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="btn btn-primary">
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="btn btn-primary touch-target">
|
||||
<i class="fas fa-plus me-2"></i>Log Your First Entry
|
||||
</a>
|
||||
</div>
|
||||
@@ -311,7 +311,7 @@
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-play me-2 text-success"></i>Start Timer
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('timer.start_timer') }}">
|
||||
<div class="modal-body">
|
||||
@@ -336,8 +336,8 @@
|
||||
placeholder="What are you working on?"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<div class="modal-footer d-flex flex-column flex-md-row">
|
||||
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
@@ -357,7 +357,7 @@
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-trash me-2 text-danger"></i>Delete Time Entry
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
@@ -367,8 +367,8 @@
|
||||
<p>Are you sure you want to delete the time entry for <strong id="deleteEntryProjectName"></strong>?</p>
|
||||
<p class="text-muted mb-0">Duration: <strong id="deleteEntryDuration"></strong></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<div class="modal-footer d-flex flex-column flex-md-row">
|
||||
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</button>
|
||||
<form method="POST" id="deleteEntryForm" class="d-inline">
|
||||
@@ -454,6 +454,28 @@
|
||||
this.style.transform = 'translateY(0) scale(1)';
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile-specific improvements
|
||||
if (window.innerWidth <= 768) {
|
||||
// Improve mobile table responsiveness
|
||||
const tableRows = document.querySelectorAll('tbody tr');
|
||||
tableRows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
cells.forEach((cell, index) => {
|
||||
if (index === 0) {
|
||||
cell.setAttribute('data-label', 'Project');
|
||||
} else if (index === 1) {
|
||||
cell.setAttribute('data-label', 'Duration');
|
||||
} else if (index === 2) {
|
||||
cell.setAttribute('data-label', 'Date');
|
||||
} else if (index === 3) {
|
||||
cell.setAttribute('data-label', 'Notes');
|
||||
} else if (index === 4) {
|
||||
cell.setAttribute('data-label', 'Actions');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Function to show delete time entry modal
|
||||
@@ -475,5 +497,29 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle mobile viewport changes
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth <= 768) {
|
||||
// Re-apply mobile table improvements
|
||||
const tableRows = document.querySelectorAll('tbody tr');
|
||||
tableRows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
cells.forEach((cell, index) => {
|
||||
if (index === 0) {
|
||||
cell.setAttribute('data-label', 'Project');
|
||||
} else if (index === 1) {
|
||||
cell.setAttribute('data-label', 'Duration');
|
||||
} else if (index === 2) {
|
||||
cell.setAttribute('data-label', 'Date');
|
||||
} else if (index === 3) {
|
||||
cell.setAttribute('data-label', 'Notes');
|
||||
} else if (index === 4) {
|
||||
cell.setAttribute('data-label', 'Actions');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
76
docker-compose.yml
Normal file
76
docker-compose.yml
Normal file
@@ -0,0 +1,76 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: timetracker-app
|
||||
environment:
|
||||
- TZ=${TZ:-Europe/Brussels}
|
||||
- CURRENCY=${CURRENCY:-EUR}
|
||||
- ROUNDING_MINUTES=${ROUNDING_MINUTES:-1}
|
||||
- SINGLE_ACTIVE_TIMER=${SINGLE_ACTIVE_TIMER:-true}
|
||||
- ALLOW_SELF_REGISTER=${ALLOW_SELF_REGISTER:-true}
|
||||
- IDLE_TIMEOUT_MINUTES=${IDLE_TIMEOUT_MINUTES:-30}
|
||||
- ADMIN_USERNAMES=${ADMIN_USERNAMES:-admin}
|
||||
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
|
||||
- DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker
|
||||
- LOG_FILE=/app/logs/timetracker.log
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- app_data:/data
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/_health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: timetracker-db
|
||||
environment:
|
||||
- POSTGRES_DB=${POSTGRES_DB:-timetracker}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-timetracker}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-timetracker}
|
||||
- TZ=${TZ:-Europe/Brussels}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional reverse proxy for TLS on LAN
|
||||
proxy:
|
||||
image: caddy:2-alpine
|
||||
container_name: timetracker-proxy
|
||||
volumes:
|
||||
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- tls
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
driver: local
|
||||
db_data:
|
||||
driver: local
|
||||
caddy_data:
|
||||
driver: local
|
||||
caddy_config:
|
||||
driver: local
|
||||
@@ -6,13 +6,13 @@
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-column flex-md-row">
|
||||
<h1 class="h3 mb-0 mb-3 mb-md-0">
|
||||
<i class="fas fa-project-diagram text-primary"></i> Projects
|
||||
</h1>
|
||||
{% if current_user.is_admin %}
|
||||
<div>
|
||||
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
|
||||
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary touch-target">
|
||||
<i class="fas fa-plus"></i> New Project
|
||||
</a>
|
||||
</div>
|
||||
@@ -24,41 +24,41 @@
|
||||
<!-- Filters -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-filter me-2 text-primary"></i>Filters
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<form method="GET" class="row g-3 mobile-form">
|
||||
<div class="col-12 col-md-4 mobile-form-group">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<select class="form-select touch-target" id="status" name="status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active" {% if request.args.get('status') == 'active' %}selected{% endif %}>Active</option>
|
||||
<option value="archived" {% if request.args.get('status') == 'archived' %}selected{% endif %}>Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-12 col-md-4 mobile-form-group">
|
||||
<label for="client" class="form-label">Client</label>
|
||||
<select class="form-select" id="client" name="client">
|
||||
<select class="form-select touch-target" id="client" name="client">
|
||||
<option value="">All Clients</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client }}" {% if request.args.get('client') == client %}selected{% endif %}>{{ client }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-12 col-md-4 mobile-form-group">
|
||||
<label for="search" class="form-label">Search</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
<input type="text" class="form-control touch-target" id="search" name="search"
|
||||
value="{{ request.args.get('search', '') }}" placeholder="Project name or description">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<div class="col-12 d-flex flex-column flex-md-row gap-2 mobile-stack">
|
||||
<button type="submit" class="btn btn-primary mobile-btn">
|
||||
<i class="fas fa-search me-1"></i>Filter
|
||||
</button>
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary">
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary mobile-btn">
|
||||
<i class="fas fa-times me-1"></i>Clear
|
||||
</a>
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@
|
||||
<!-- Projects List -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-list"></i> Projects ({{ projects|length }})
|
||||
@@ -95,7 +95,7 @@
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr>
|
||||
<td>
|
||||
<td data-label="Project">
|
||||
<div>
|
||||
<strong>{{ project.name }}</strong>
|
||||
{% if project.description %}
|
||||
@@ -103,65 +103,44 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if project.client %}
|
||||
<a href="{{ url_for('projects.view_client', client_name=project.client) }}" class="text-decoration-none">
|
||||
{{ project.client }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
<td data-label="Client">
|
||||
<span class="badge bg-secondary">{{ project.client }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status">
|
||||
{% if project.status == 'active' %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Archived</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ "%.1f"|format(project.total_hours) }}h</strong>
|
||||
<td data-label="Total Hours">
|
||||
<strong>{{ "%.1f"|format(project.total_hours or 0) }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if project.billable %}
|
||||
<span class="text-success">{{ "%.1f"|format(project.total_billable_hours) }}h</span>
|
||||
<td data-label="Billable Hours">
|
||||
<span class="text-success">{{ "%.1f"|format(project.billable_hours or 0) }}</span>
|
||||
</td>
|
||||
<td data-label="Rate">
|
||||
{% if project.hourly_rate %}
|
||||
<span class="text-primary">${{ "%.2f"|format(project.hourly_rate) }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if project.billable and project.hourly_rate %}
|
||||
<span class="text-success">{{ currency }} {{ "%.2f"|format(project.hourly_rate) }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<td class="actions-cell" data-label="Actions">
|
||||
<div class="btn-group d-flex d-md-inline-flex mobile-stack" role="group">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="View">
|
||||
class="btn btn-sm btn-outline-primary touch-target" title="View project">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
class="btn btn-sm btn-outline-secondary touch-target" title="Edit project">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if project.status == 'active' %}
|
||||
<button type="button" class="btn btn-sm btn-outline-warning" title="Archive"
|
||||
onclick="showArchiveModal('{{ project.id }}', '{{ project.name }}')">
|
||||
<i class="fas fa-archive"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-sm btn-outline-success" title="Unarchive"
|
||||
onclick="showUnarchiveModal('{{ project.id }}', '{{ project.name }}')">
|
||||
<i class="fas fa-box-open"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete"
|
||||
onclick="showDeleteModal('{{ project.id }}', '{{ project.name }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger touch-target" title="Delete project"
|
||||
onclick="showDeleteProjectModal('{{ project.id }}', '{{ project.name }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
@@ -172,23 +151,14 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-project-diagram fa-3x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">No Projects Found</h4>
|
||||
<p class="text-muted">
|
||||
{% if request.args.get('status') or request.args.get('client') or request.args.get('search') %}
|
||||
Try adjusting your filters or
|
||||
<a href="{{ url_for('projects.list_projects') }}">view all projects</a>.
|
||||
{% else %}
|
||||
{% if current_user.is_admin %}
|
||||
Get started by creating your first project.
|
||||
{% else %}
|
||||
No projects have been created yet.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if current_user.is_admin and not (request.args.get('status') or request.args.get('client') or request.args.get('search')) %}
|
||||
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create First Project
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-project-diagram fa-3x text-muted opacity-50"></i>
|
||||
</div>
|
||||
<h5 class="text-muted mb-3">No projects found</h5>
|
||||
<p class="text-muted mb-4">Create your first project to get started</p>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary touch-target">
|
||||
<i class="fas fa-plus me-2"></i>Create Project
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -199,62 +169,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archive Project Modal -->
|
||||
<div class="modal fade" id="archiveProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-archive me-2 text-warning"></i>Archive Project
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to archive the project <strong id="archiveProjectName"></strong>?</p>
|
||||
<p class="text-muted mb-0">Archived projects will be hidden from the main project list but can be restored later.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</button>
|
||||
<form method="POST" id="archiveProjectForm" class="d-inline">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="fas fa-archive me-2"></i>Archive Project
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unarchive Project Modal -->
|
||||
<div class="modal fade" id="unarchiveProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-box-open me-2 text-success"></i>Restore Project
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to restore the project <strong id="unarchiveProjectName"></strong>?</p>
|
||||
<p class="text-muted mb-0">This will make the project active again and visible in the main project list.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</button>
|
||||
<form method="POST" id="unarchiveProjectForm" class="d-inline">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-box-open me-2"></i>Restore Project
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Project Modal -->
|
||||
<div class="modal fade" id="deleteProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
@@ -263,22 +177,22 @@
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-trash me-2 text-danger"></i>Delete Project
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>Warning:</strong> This action cannot be undone.
|
||||
</div>
|
||||
<p>Are you sure you want to permanently delete the project <strong id="deleteProjectName"></strong>?</p>
|
||||
<p class="text-muted mb-0">This will also delete all associated time entries and cannot be recovered.</p>
|
||||
<p>Are you sure you want to delete the project <strong id="deleteProjectName"></strong>?</p>
|
||||
<p class="text-muted mb-0">All associated time entries will also be deleted.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<div class="modal-footer d-flex flex-column flex-md-row">
|
||||
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2 touch-target" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</button>
|
||||
<form method="POST" id="deleteProjectForm" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<button type="submit" class="btn btn-danger touch-target">
|
||||
<i class="fas fa-trash me-2"></i>Delete Project
|
||||
</button>
|
||||
</form>
|
||||
@@ -288,49 +202,90 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Function to show archive project modal
|
||||
function showArchiveModal(projectId, projectName) {
|
||||
document.getElementById('archiveProjectName').textContent = projectName;
|
||||
document.getElementById('archiveProjectForm').action = "{{ url_for('projects.archive_project', project_id=0) }}".replace('0', projectId);
|
||||
new bootstrap.Modal(document.getElementById('archiveProjectModal')).show();
|
||||
}
|
||||
|
||||
// Function to show unarchive project modal
|
||||
function showUnarchiveModal(projectId, projectName) {
|
||||
document.getElementById('unarchiveProjectName').textContent = projectName;
|
||||
document.getElementById('unarchiveProjectForm').action = "{{ url_for('projects.unarchive_project', project_id=0) }}".replace('0', projectId);
|
||||
new bootstrap.Modal(document.getElementById('unarchiveProjectModal')).show();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Mobile-specific improvements
|
||||
if (window.innerWidth <= 768) {
|
||||
// Improve mobile table responsiveness
|
||||
const tableRows = document.querySelectorAll('tbody tr');
|
||||
tableRows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
cells.forEach((cell, index) => {
|
||||
if (index === 0) {
|
||||
cell.setAttribute('data-label', 'Project');
|
||||
} else if (index === 1) {
|
||||
cell.setAttribute('data-label', 'Client');
|
||||
} else if (index === 2) {
|
||||
cell.setAttribute('data-label', 'Status');
|
||||
} else if (index === 3) {
|
||||
cell.setAttribute('data-label', 'Total Hours');
|
||||
} else if (index === 4) {
|
||||
cell.setAttribute('data-label', 'Billable Hours');
|
||||
} else if (index === 5) {
|
||||
cell.setAttribute('data-label', 'Rate');
|
||||
} else if (index === 6) {
|
||||
cell.setAttribute('data-label', 'Actions');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Improve touch targets
|
||||
const inputs = document.querySelectorAll('.form-control, .form-select');
|
||||
inputs.forEach(input => {
|
||||
input.classList.add('touch-target');
|
||||
});
|
||||
|
||||
const buttons = document.querySelectorAll('.btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.classList.add('touch-target');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle mobile viewport changes
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth <= 768) {
|
||||
// Re-apply mobile table improvements
|
||||
const tableRows = document.querySelectorAll('tbody tr');
|
||||
tableRows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
cells.forEach((cell, index) => {
|
||||
if (index === 0) {
|
||||
cell.setAttribute('data-label', 'Project');
|
||||
} else if (index === 1) {
|
||||
cell.setAttribute('data-label', 'Client');
|
||||
} else if (index === 2) {
|
||||
cell.setAttribute('data-label', 'Status');
|
||||
} else if (index === 3) {
|
||||
cell.setAttribute('data-label', 'Total Hours');
|
||||
} else if (index === 4) {
|
||||
cell.setAttribute('data-label', 'Billable Hours');
|
||||
} else if (index === 5) {
|
||||
cell.setAttribute('data-label', 'Rate');
|
||||
} else if (index === 6) {
|
||||
cell.setAttribute('data-label', 'Actions');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Function to show delete project modal
|
||||
function showDeleteModal(projectId, projectName) {
|
||||
function showDeleteProjectModal(projectId, projectName) {
|
||||
document.getElementById('deleteProjectName').textContent = projectName;
|
||||
document.getElementById('deleteProjectForm').action = "{{ url_for('projects.delete_project', project_id=0) }}".replace('0', projectId);
|
||||
new bootstrap.Modal(document.getElementById('deleteProjectModal')).show();
|
||||
}
|
||||
|
||||
// Add loading states to form submissions
|
||||
// Add loading state to delete project form
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Archive form
|
||||
document.getElementById('archiveProjectForm').addEventListener('submit', function(e) {
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Archiving...';
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
|
||||
// Unarchive form
|
||||
document.getElementById('unarchiveProjectForm').addEventListener('submit', function(e) {
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Restoring...';
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
|
||||
// Delete form
|
||||
document.getElementById('deleteProjectForm').addEventListener('submit', function(e) {
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
const deleteForm = document.getElementById('deleteProjectForm');
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', function(e) {
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="col-lg-8 col-md-10">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<i class="fas fa-plus me-2 text-primary"></i>
|
||||
@@ -26,8 +26,8 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">
|
||||
<i class="fas fa-play me-1"></i>Start *
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">
|
||||
<i class="fas fa-stop me-1"></i>End *
|
||||
@@ -66,8 +66,8 @@
|
||||
<textarea class="form-control" id="notes" name="notes" rows="3" placeholder="What did you work on?">{{ request.form.get('notes','') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="mb-4">
|
||||
<label for="tags" class="form-label fw-semibold">
|
||||
<i class="fas fa-tags me-1"></i>Tags
|
||||
@@ -76,8 +76,8 @@
|
||||
<div class="form-text">Separate tags with commas</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-center">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<div class="col-12 col-md-4 d-flex align-items-center">
|
||||
<div class="form-check form-switch mt-4 w-100">
|
||||
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% else %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="billable">
|
||||
<i class="fas fa-dollar-sign me-1"></i>Billable
|
||||
@@ -86,8 +86,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">
|
||||
<div class="d-flex justify-content-between flex-column flex-md-row">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary mb-2 mb-md-0">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@@ -100,6 +100,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set default dates to today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const now = new Date().toTimeString().slice(0, 5);
|
||||
|
||||
if (!document.getElementById('start_date').value) {
|
||||
document.getElementById('start_date').value = today;
|
||||
}
|
||||
if (!document.getElementById('end_date').value) {
|
||||
document.getElementById('end_date').value = today;
|
||||
}
|
||||
if (!document.getElementById('start_time').value) {
|
||||
document.getElementById('start_time').value = now;
|
||||
}
|
||||
if (!document.getElementById('end_time').value) {
|
||||
document.getElementById('end_time').value = now;
|
||||
}
|
||||
|
||||
// Mobile-specific improvements
|
||||
if (window.innerWidth <= 768) {
|
||||
// Add mobile-specific classes
|
||||
const form = document.querySelector('form');
|
||||
form.classList.add('mobile-form');
|
||||
|
||||
// Improve touch targets
|
||||
const inputs = document.querySelectorAll('.form-control, .form-select');
|
||||
inputs.forEach(input => {
|
||||
input.classList.add('touch-target');
|
||||
});
|
||||
|
||||
// Improve buttons
|
||||
const buttons = document.querySelectorAll('.btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.classList.add('touch-target');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle mobile viewport changes
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth <= 768) {
|
||||
document.body.classList.add('mobile-view');
|
||||
} else {
|
||||
document.body.classList.remove('mobile-view');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user