Improve mobile UI/UX.

This commit is contained in:
2025-07-13 10:52:20 +02:00
parent 2d18849267
commit 7140aeba41
21 changed files with 3604 additions and 47 deletions

173
static/js/date-formatter.js Normal file
View File

@@ -0,0 +1,173 @@
// Date and Time Formatting Utility
// This file provides client-side date/time formatting that matches user preferences
(function() {
'use strict';
// Get user preferences from data attributes or localStorage
function getUserPreferences() {
// Check if preferences are stored in the DOM
const prefsElement = document.getElementById('user-preferences');
if (prefsElement) {
return {
dateFormat: prefsElement.dataset.dateFormat || 'ISO',
timeFormat24h: prefsElement.dataset.timeFormat24h === 'true'
};
}
// Fallback to localStorage
return {
dateFormat: localStorage.getItem('dateFormat') || 'ISO',
timeFormat24h: localStorage.getItem('timeFormat24h') === 'true'
};
}
// Format date according to user preference
function formatDate(date) {
if (!date) return '';
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return '';
const prefs = getUserPreferences();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
switch (prefs.dateFormat) {
case 'US':
return `${month}/${day}/${year}`;
case 'EU':
case 'UK':
return `${day}/${month}/${year}`;
case 'Readable':
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
case 'Full':
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
case 'ISO':
default:
return `${year}-${month}-${day}`;
}
}
// Format time according to user preference
function formatTime(date, includeSeconds = true) {
if (!date) return '';
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return '';
const prefs = getUserPreferences();
const hours = d.getHours();
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
if (prefs.timeFormat24h) {
const h24 = String(hours).padStart(2, '0');
return includeSeconds ? `${h24}:${minutes}:${seconds}` : `${h24}:${minutes}`;
} else {
const h12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const ampm = hours >= 12 ? 'PM' : 'AM';
const timeStr = includeSeconds ?
`${h12}:${minutes}:${seconds}` :
`${h12}:${minutes}`;
return `${timeStr} ${ampm}`;
}
}
// Format datetime according to user preference
function formatDateTime(date) {
if (!date) return '';
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return '';
return `${formatDate(d)} ${formatTime(d)}`;
}
// Format duration (seconds to HH:MM:SS)
function formatDuration(seconds) {
if (seconds == null || seconds < 0) return '00:00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return [hours, minutes, secs]
.map(v => String(v).padStart(2, '0'))
.join(':');
}
// Update all elements with data-format attributes
function updateFormattedDates() {
// Update date elements
document.querySelectorAll('[data-format-date]').forEach(el => {
const dateStr = el.dataset.formatDate;
if (dateStr) {
el.textContent = formatDate(dateStr);
}
});
// Update time elements
document.querySelectorAll('[data-format-time]').forEach(el => {
const timeStr = el.dataset.formatTime;
const includeSeconds = el.dataset.includeSeconds !== 'false';
if (timeStr) {
el.textContent = formatTime(timeStr, includeSeconds);
}
});
// Update datetime elements
document.querySelectorAll('[data-format-datetime]').forEach(el => {
const datetimeStr = el.dataset.formatDatetime;
if (datetimeStr) {
el.textContent = formatDateTime(datetimeStr);
}
});
// Update duration elements
document.querySelectorAll('[data-format-duration]').forEach(el => {
const seconds = parseInt(el.dataset.formatDuration);
if (!isNaN(seconds)) {
el.textContent = formatDuration(seconds);
}
});
}
// Store preferences in localStorage when they change
function storePreferences(dateFormat, timeFormat24h) {
localStorage.setItem('dateFormat', dateFormat);
localStorage.setItem('timeFormat24h', timeFormat24h);
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
updateFormattedDates();
// Listen for preference changes
window.addEventListener('preferenceChanged', function(e) {
if (e.detail) {
storePreferences(e.detail.dateFormat, e.detail.timeFormat24h);
updateFormattedDates();
}
});
});
// Expose functions globally
window.DateFormatter = {
formatDate: formatDate,
formatTime: formatTime,
formatDateTime: formatDateTime,
formatDuration: formatDuration,
updateFormattedDates: updateFormattedDates,
getUserPreferences: getUserPreferences
};
})();

View File

@@ -0,0 +1,550 @@
// Date Picker Enhancer - Makes date/time inputs respect user preferences
(function() {
'use strict';
// Enhanced date input that shows user's preferred format
class EnhancedDateInput {
constructor(input) {
this.nativeInput = input;
this.userPrefs = DateFormatter.getUserPreferences();
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768;
// Only enhance date and datetime-local inputs
if (input.type !== 'date' && input.type !== 'datetime-local') {
return;
}
// Check if already enhanced
if (input.dataset.enhanced === 'true') {
return;
}
this.enhance();
}
enhance() {
// Skip if already in a hybrid date input structure
if (this.nativeInput.classList.contains('date-input-native') ||
this.nativeInput.closest('.hybrid-date-input')) {
return;
}
// Mark as enhanced
this.nativeInput.dataset.enhanced = 'true';
// Create wrapper
const wrapper = document.createElement('div');
wrapper.className = 'enhanced-date-wrapper';
// Create formatted text input
this.textInput = document.createElement('input');
this.textInput.type = 'text';
this.textInput.className = this.nativeInput.className + ' date-text-input';
this.textInput.placeholder = this.getPlaceholder();
this.textInput.required = this.nativeInput.required;
// Copy other attributes
if (this.nativeInput.id) {
this.textInput.id = this.nativeInput.id + '-text';
}
// Hide native input but keep it functional
this.nativeInput.style.position = 'absolute';
this.nativeInput.style.opacity = '0';
this.nativeInput.style.pointerEvents = 'none';
this.nativeInput.style.zIndex = '-1';
this.nativeInput.tabIndex = -1;
// Insert wrapper
this.nativeInput.parentNode.insertBefore(wrapper, this.nativeInput);
wrapper.appendChild(this.nativeInput);
wrapper.appendChild(this.textInput);
// Add calendar icon button
const calendarBtn = document.createElement('button');
calendarBtn.type = 'button';
calendarBtn.className = 'calendar-btn';
calendarBtn.innerHTML = '<i class="ti ti-calendar"></i>';
calendarBtn.title = 'Open date picker';
calendarBtn.setAttribute('aria-label', 'Open date picker');
wrapper.appendChild(calendarBtn);
// Add mobile hint
if (this.isMobile) {
this.textInput.placeholder = this.textInput.placeholder + ' (tap to select)';
}
// Set initial value if exists
if (this.nativeInput.value) {
this.updateTextInput();
}
// Event listeners
this.setupEventListeners(calendarBtn);
}
getPlaceholder() {
const format = this.userPrefs.dateFormat;
const isDateTime = this.nativeInput.type === 'datetime-local';
const datePlaceholder = {
'ISO': 'YYYY-MM-DD',
'US': 'MM/DD/YYYY',
'EU': 'DD/MM/YYYY',
'UK': 'DD/MM/YYYY',
'Readable': 'Jan 01, 2024',
'Full': 'January 01, 2024'
}[format] || 'YYYY-MM-DD';
if (isDateTime) {
const timePlaceholder = this.userPrefs.timeFormat24h ? 'HH:MM' : 'HH:MM AM';
return `${datePlaceholder} ${timePlaceholder}`;
}
return datePlaceholder;
}
setupEventListeners(calendarBtn) {
// On mobile, make the entire input area clickable for date picker
if (this.isMobile) {
this.textInput.addEventListener('focus', (e) => {
e.preventDefault();
// Trigger calendar button click
calendarBtn.click();
this.textInput.blur();
});
// Make text input read-only on mobile to prevent keyboard
this.textInput.readOnly = true;
this.textInput.style.cursor = 'pointer';
} else {
// Desktop behavior - allow typing
this.textInput.addEventListener('blur', () => {
this.parseUserInput();
});
// Allow Enter key to trigger parsing
this.textInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.parseUserInput();
}
});
}
// When native input changes, update text input
this.nativeInput.addEventListener('change', () => {
this.updateTextInput();
});
// Calendar button shows native picker
calendarBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// On mobile, we need different handling
if (window.innerWidth <= 768) {
// For mobile, overlay the native input over the button temporarily
this.nativeInput.style.position = 'absolute';
this.nativeInput.style.top = '0';
this.nativeInput.style.left = '0';
this.nativeInput.style.width = '100%';
this.nativeInput.style.height = '100%';
this.nativeInput.style.opacity = '0.01'; // Almost invisible but interactive
this.nativeInput.style.pointerEvents = 'auto';
this.nativeInput.style.zIndex = '100';
// Trigger the native picker
this.nativeInput.focus();
this.nativeInput.click();
// Some mobile browsers need showPicker()
if (this.nativeInput.showPicker) {
try {
this.nativeInput.showPicker();
} catch (e) {
// Fallback if showPicker is not supported
}
}
} else {
// Desktop behavior
this.nativeInput.style.opacity = '1';
this.nativeInput.style.pointerEvents = 'auto';
this.nativeInput.focus();
this.nativeInput.click();
}
// Hide native input after interaction
const hideNative = () => {
this.nativeInput.style.opacity = '0';
this.nativeInput.style.pointerEvents = 'none';
this.nativeInput.style.position = 'absolute';
this.nativeInput.style.zIndex = '-1';
this.nativeInput.removeEventListener('blur', hideNative);
this.nativeInput.removeEventListener('change', hideNative);
};
// Use timeout for mobile to ensure picker has opened
if (window.innerWidth <= 768) {
setTimeout(() => {
this.nativeInput.addEventListener('blur', hideNative);
this.nativeInput.addEventListener('change', hideNative);
}, 100);
} else {
this.nativeInput.addEventListener('blur', hideNative);
this.nativeInput.addEventListener('change', hideNative);
}
});
}
updateTextInput() {
const value = this.nativeInput.value;
if (!value) {
this.textInput.value = '';
return;
}
if (this.nativeInput.type === 'datetime-local') {
// Format datetime
const dt = new Date(value);
this.textInput.value = DateFormatter.formatDateTime(dt);
} else {
// Format date only
const dt = new Date(value + 'T00:00:00');
this.textInput.value = DateFormatter.formatDate(dt);
}
}
parseUserInput() {
const input = this.textInput.value.trim();
if (!input) {
this.nativeInput.value = '';
return;
}
try {
const parsed = this.parseDate(input);
if (parsed) {
// Convert to ISO format for native input
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
if (this.nativeInput.type === 'datetime-local') {
const hours = String(parsed.getHours()).padStart(2, '0');
const minutes = String(parsed.getMinutes()).padStart(2, '0');
this.nativeInput.value = `${year}-${month}-${day}T${hours}:${minutes}`;
} else {
this.nativeInput.value = `${year}-${month}-${day}`;
}
// Update text input with properly formatted value
this.updateTextInput();
// Trigger change event on native input
this.nativeInput.dispatchEvent(new Event('change', { bubbles: true }));
} else {
// Invalid input - restore previous value
this.updateTextInput();
this.textInput.classList.add('error');
setTimeout(() => this.textInput.classList.remove('error'), 2000);
}
} catch (e) {
console.error('Date parsing error:', e);
this.updateTextInput();
}
}
parseDate(input) {
// Remove extra spaces
input = input.replace(/\s+/g, ' ').trim();
// Try different parsing strategies based on user's format
const format = this.userPrefs.dateFormat;
let date = null;
// Extract time if present
let timeMatch = null;
let dateStr = input;
if (this.nativeInput.type === 'datetime-local') {
// Look for time patterns
const time24Match = input.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
const time12Match = input.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM|am|pm)$/i);
if (time12Match) {
timeMatch = time12Match;
dateStr = input.substring(0, input.lastIndexOf(time12Match[0])).trim();
} else if (time24Match) {
timeMatch = time24Match;
dateStr = input.substring(0, input.lastIndexOf(time24Match[0])).trim();
}
}
// Parse date part based on format
switch (format) {
case 'US': // MM/DD/YYYY
const usMatch = dateStr.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
if (usMatch) {
date = new Date(usMatch[3], usMatch[1] - 1, usMatch[2]);
}
break;
case 'EU':
case 'UK': // DD/MM/YYYY
const euMatch = dateStr.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
if (euMatch) {
date = new Date(euMatch[3], euMatch[2] - 1, euMatch[1]);
}
break;
case 'ISO': // YYYY-MM-DD
const isoMatch = dateStr.match(/^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/);
if (isoMatch) {
date = new Date(isoMatch[1], isoMatch[2] - 1, isoMatch[3]);
}
break;
case 'Readable': // Jan 01, 2024
case 'Full': // January 01, 2024
// Try to parse natural language dates
date = new Date(dateStr);
if (isNaN(date.getTime())) {
date = null;
}
break;
}
// If no specific format matched, try generic parsing
if (!date) {
date = new Date(dateStr);
if (isNaN(date.getTime())) {
return null;
}
}
// Add time if present
if (timeMatch && date) {
let hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = timeMatch[3] ? parseInt(timeMatch[3]) : 0;
// Handle 12-hour format
if (timeMatch[4]) {
const isPM = timeMatch[4].toUpperCase() === 'PM';
if (hours === 12 && !isPM) hours = 0;
else if (hours !== 12 && isPM) hours += 12;
}
date.setHours(hours, minutes, seconds);
}
return date;
}
}
// Enhanced time input for 12/24 hour format
class EnhancedTimeInput {
constructor(input) {
this.nativeInput = input;
this.userPrefs = DateFormatter.getUserPreferences();
if (input.type !== 'time' || input.dataset.enhanced === 'true') {
return;
}
// Only enhance if user prefers 12-hour format
if (this.userPrefs.timeFormat24h) {
return; // Native time input already uses 24-hour format
}
this.enhance();
}
enhance() {
// Similar enhancement for time inputs to show AM/PM
// This is simpler since time inputs are already somewhat flexible
// Add helper text
const helper = document.createElement('small');
helper.className = 'time-format-helper';
helper.textContent = '12-hour format (use AM/PM)';
this.nativeInput.parentNode.insertBefore(helper, this.nativeInput.nextSibling);
this.nativeInput.dataset.enhanced = 'true';
}
}
// Auto-enhance all date/time inputs
function enhanceAllInputs() {
// Enhance date and datetime-local inputs
document.querySelectorAll('input[type="date"], input[type="datetime-local"]').forEach(input => {
new EnhancedDateInput(input);
});
// Enhance time inputs
document.querySelectorAll('input[type="time"]').forEach(input => {
new EnhancedTimeInput(input);
});
}
// CSS injection for styling
function injectStyles() {
if (document.getElementById('date-picker-enhancer-styles')) return;
const styles = `
<style id="date-picker-enhancer-styles">
.enhanced-date-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.date-text-input {
flex: 1;
}
.date-text-input.error {
border-color: var(--danger, #dc3545);
animation: shake 0.3s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.calendar-btn {
padding: 8px 12px;
border: 1px solid var(--border-color, #dee2e6);
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.calendar-btn:hover {
background: var(--bg-light, #f8f9fa);
border-color: var(--primary-color, #667eea);
}
.calendar-btn i {
font-size: 18px;
color: var(--primary-color, #667eea);
}
.time-format-helper {
display: block;
margin-top: 4px;
color: var(--text-muted, #6c757d);
font-size: 0.875em;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.enhanced-date-wrapper {
position: relative;
display: block;
width: 100%;
}
.calendar-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
padding: 12px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background: white;
border: 1px solid var(--border-color, #dee2e6);
}
.date-text-input {
padding-right: 56px;
width: 100%;
min-height: 48px;
font-size: 16px; /* Prevent zoom on iOS */
-webkit-appearance: none;
appearance: none;
}
/* Read-only style on mobile */
.date-text-input[readonly] {
background-color: white;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0,0,0,0.1);
}
/* Larger tap target for wrapper */
.enhanced-date-wrapper {
min-height: 48px;
}
/* Better touch targets */
.enhanced-date-wrapper input[type="date"],
.enhanced-date-wrapper input[type="datetime-local"] {
min-height: 48px;
width: 100%;
}
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
// Make sure DateFormatter is loaded
if (typeof DateFormatter === 'undefined') {
console.error('DateFormatter not found. Make sure date-formatter.js is loaded first.');
return;
}
injectStyles();
enhanceAllInputs();
// Re-enhance when new content is added dynamically
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) { // Element node
if (node.matches && (node.matches('input[type="date"]') ||
node.matches('input[type="time"]') ||
node.matches('input[type="datetime-local"]'))) {
new EnhancedDateInput(node);
new EnhancedTimeInput(node);
}
// Check children
const inputs = node.querySelectorAll('input[type="date"], input[type="time"], input[type="datetime-local"]');
inputs.forEach(input => {
new EnhancedDateInput(input);
new EnhancedTimeInput(input);
});
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
});
// Expose for manual enhancement
window.DatePickerEnhancer = {
enhanceInput: function(input) {
if (input.type === 'date' || input.type === 'datetime-local') {
return new EnhancedDateInput(input);
} else if (input.type === 'time') {
return new EnhancedTimeInput(input);
}
},
enhanceAll: enhanceAllInputs
};
})();

View File

@@ -0,0 +1,403 @@
// Enhanced Mobile Gesture Support
(function() {
'use strict';
class MobileGestures {
constructor() {
this.touchStartX = 0;
this.touchStartY = 0;
this.touchEndX = 0;
this.touchEndY = 0;
this.longPressTimer = null;
this.init();
}
init() {
// Only initialize on mobile
if (!this.isMobile()) return;
// Add swipe to navigate back
this.initSwipeBack();
// Add long press for context menus
this.initLongPress();
// Add swipe actions for list items
this.initSwipeActions();
// Add pinch to zoom for images/charts
this.initPinchZoom();
}
isMobile() {
return window.innerWidth <= 768 || 'ontouchstart' in window;
}
// Swipe from left edge to go back
initSwipeBack() {
let startX = 0;
let startY = 0;
let startTime = 0;
document.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
startTime = Date.now();
// Only track if starting from left edge
if (startX > 30) return;
// Add visual indicator
this.showSwipeIndicator();
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (startX > 30) return;
const touch = e.touches[0];
const diffX = touch.clientX - startX;
const diffY = Math.abs(touch.clientY - startY);
// Horizontal swipe detection
if (diffX > 50 && diffY < 50) {
this.updateSwipeIndicator(diffX);
}
}, { passive: true });
document.addEventListener('touchend', (e) => {
if (startX > 30) return;
const endTime = Date.now();
const timeDiff = endTime - startTime;
const touch = e.changedTouches[0];
const diffX = touch.clientX - startX;
this.hideSwipeIndicator();
// Quick swipe from edge
if (timeDiff < 300 && diffX > 100) {
this.navigateBack();
}
});
}
// Long press for context actions
initLongPress() {
const longPressElements = document.querySelectorAll('[data-long-press]');
longPressElements.forEach(element => {
element.addEventListener('touchstart', (e) => {
this.longPressTimer = setTimeout(() => {
this.showContextMenu(element, e);
// Haptic feedback
if (navigator.vibrate) {
navigator.vibrate(50);
}
}, 500);
}, { passive: true });
element.addEventListener('touchend', () => {
clearTimeout(this.longPressTimer);
});
element.addEventListener('touchmove', () => {
clearTimeout(this.longPressTimer);
});
});
}
// Swipe actions on list items
initSwipeActions() {
const swipeElements = document.querySelectorAll('[data-swipe-actions]');
swipeElements.forEach(element => {
let startX = 0;
let currentX = 0;
let startTime = 0;
element.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startTime = Date.now();
element.style.transition = 'none';
}, { passive: true });
element.addEventListener('touchmove', (e) => {
currentX = e.touches[0].clientX;
const diffX = currentX - startX;
// Limit swipe distance
const maxSwipe = 100;
const swipeX = Math.max(-maxSwipe, Math.min(maxSwipe, diffX));
element.style.transform = `translateX(${swipeX}px)`;
// Show action hints
if (swipeX < -50) {
element.classList.add('swipe-left');
element.classList.remove('swipe-right');
} else if (swipeX > 50) {
element.classList.add('swipe-right');
element.classList.remove('swipe-left');
} else {
element.classList.remove('swipe-left', 'swipe-right');
}
}, { passive: true });
element.addEventListener('touchend', (e) => {
const endTime = Date.now();
const timeDiff = endTime - startTime;
const diffX = currentX - startX;
element.style.transition = 'transform 0.3s ease';
element.style.transform = '';
// Quick swipe actions
if (timeDiff < 300) {
if (diffX < -80) {
this.triggerSwipeAction(element, 'left');
} else if (diffX > 80) {
this.triggerSwipeAction(element, 'right');
}
}
element.classList.remove('swipe-left', 'swipe-right');
});
});
}
// Pinch to zoom for images
initPinchZoom() {
const zoomElements = document.querySelectorAll('[data-zoomable]');
zoomElements.forEach(element => {
let initialDistance = 0;
let currentScale = 1;
element.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
initialDistance = this.getDistance(e.touches[0], e.touches[1]);
}
}, { passive: true });
element.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
e.preventDefault();
const currentDistance = this.getDistance(e.touches[0], e.touches[1]);
currentScale = currentDistance / initialDistance;
currentScale = Math.max(0.5, Math.min(3, currentScale));
element.style.transform = `scale(${currentScale})`;
}
}, { passive: false });
element.addEventListener('touchend', () => {
if (currentScale < 0.8 || currentScale > 2.5) {
element.style.transition = 'transform 0.3s ease';
element.style.transform = 'scale(1)';
currentScale = 1;
}
});
});
}
// Helper methods
getDistance(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
showSwipeIndicator() {
if (!this.swipeIndicator) {
this.swipeIndicator = document.createElement('div');
this.swipeIndicator.className = 'swipe-back-indicator';
this.swipeIndicator.innerHTML = '<i class="ti ti-chevron-right"></i>';
document.body.appendChild(this.swipeIndicator);
}
this.swipeIndicator.classList.add('visible');
}
updateSwipeIndicator(progress) {
if (this.swipeIndicator) {
const opacity = Math.min(1, progress / 100);
const scale = 0.8 + (0.2 * opacity);
this.swipeIndicator.style.opacity = opacity;
this.swipeIndicator.style.transform = `translateX(${progress * 0.3}px) scale(${scale})`;
}
}
hideSwipeIndicator() {
if (this.swipeIndicator) {
this.swipeIndicator.classList.remove('visible');
}
}
navigateBack() {
// Check if there's a back button
const backBtn = document.querySelector('.btn-back, [href*="javascript:history.back"]');
if (backBtn) {
backBtn.click();
} else {
history.back();
}
}
showContextMenu(element, event) {
const actions = element.dataset.longPress.split(',');
// Create context menu
const menu = document.createElement('div');
menu.className = 'mobile-context-menu';
menu.style.top = `${event.touches[0].clientY}px`;
menu.style.left = `${event.touches[0].clientX}px`;
actions.forEach(action => {
const [label, handler] = action.split(':');
const item = document.createElement('button');
item.textContent = label;
item.onclick = () => {
if (window[handler]) {
window[handler](element);
}
menu.remove();
};
menu.appendChild(item);
});
document.body.appendChild(menu);
// Remove on outside click
setTimeout(() => {
document.addEventListener('touchstart', () => menu.remove(), { once: true });
}, 100);
}
triggerSwipeAction(element, direction) {
const action = element.dataset[`swipe${direction.charAt(0).toUpperCase() + direction.slice(1)}`];
if (action && window[action]) {
window[action](element);
}
}
}
// Add CSS for gestures
function addGestureStyles() {
if (document.getElementById('gesture-styles')) return;
const styles = `
<style id="gesture-styles">
/* Swipe back indicator */
.swipe-back-indicator {
position: fixed;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
background: rgba(0,0,0,0.8);
border-radius: 0 20px 20px 0;
display: flex;
align-items: center;
justify-content: center;
color: white;
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
pointer-events: none;
}
.swipe-back-indicator.visible {
opacity: 1;
}
/* Swipe actions */
[data-swipe-actions] {
position: relative;
overflow: hidden;
}
[data-swipe-actions]::before,
[data-swipe-actions]::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 100px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
[data-swipe-actions]::before {
left: 0;
background: linear-gradient(to right, #4CAF50, transparent);
}
[data-swipe-actions]::after {
right: 0;
background: linear-gradient(to left, #F44336, transparent);
}
[data-swipe-actions].swipe-right::before,
[data-swipe-actions].swipe-left::after {
opacity: 0.3;
}
/* Context menu */
.mobile-context-menu {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
padding: 8px 0;
z-index: 1000;
min-width: 150px;
transform: scale(0.8);
animation: contextMenuIn 0.2s ease forwards;
}
@keyframes contextMenuIn {
to {
transform: scale(1);
}
}
.mobile-context-menu button {
display: block;
width: 100%;
padding: 12px 16px;
border: none;
background: none;
text-align: left;
font-size: 16px;
cursor: pointer;
}
.mobile-context-menu button:hover {
background: #f5f5f5;
}
/* Pinch zoom */
[data-zoomable] {
touch-action: pinch-zoom;
transition: transform 0.3s ease;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
addGestureStyles();
new MobileGestures();
});
window.MobileGestures = MobileGestures;
})();

View File

@@ -0,0 +1,323 @@
// Mobile Performance Optimizations
(function() {
'use strict';
// Lazy Loading for Images
class LazyLoader {
constructor() {
this.imageObserver = null;
this.init();
}
init() {
// Check for IntersectionObserver support
if ('IntersectionObserver' in window) {
this.imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
observer.unobserve(entry.target);
}
});
}, {
rootMargin: '50px 0px',
threshold: 0.01
});
// Start observing images
this.observeImages();
} else {
// Fallback for older browsers
this.loadAllImages();
}
}
observeImages() {
const images = document.querySelectorAll('img[data-src]');
images.forEach(img => this.imageObserver.observe(img));
}
loadImage(img) {
const src = img.dataset.src;
if (!src) return;
// Create new image to preload
const newImg = new Image();
newImg.onload = () => {
img.src = src;
img.classList.add('loaded');
delete img.dataset.src;
};
newImg.src = src;
}
loadAllImages() {
const images = document.querySelectorAll('img[data-src]');
images.forEach(img => this.loadImage(img));
}
}
// Virtual Scrolling for Long Lists
class VirtualScroller {
constructor(container, options = {}) {
this.container = container;
this.itemHeight = options.itemHeight || 80;
this.bufferSize = options.bufferSize || 5;
this.items = [];
this.visibleItems = [];
if (this.container) {
this.init();
}
}
init() {
// Set up container
this.container.style.position = 'relative';
this.container.style.overflow = 'auto';
// Create spacer for scrollbar
this.spacer = document.createElement('div');
this.spacer.style.position = 'absolute';
this.spacer.style.top = '0';
this.spacer.style.left = '0';
this.spacer.style.width = '1px';
this.container.appendChild(this.spacer);
// Set up scroll listener
this.container.addEventListener('scroll', this.onScroll.bind(this));
// Initial render
this.render();
}
setItems(items) {
this.items = items;
this.spacer.style.height = `${items.length * this.itemHeight}px`;
this.render();
}
onScroll() {
cancelAnimationFrame(this.scrollFrame);
this.scrollFrame = requestAnimationFrame(() => this.render());
}
render() {
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
// Calculate visible range
const startIndex = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.bufferSize);
const endIndex = Math.min(
this.items.length - 1,
Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.bufferSize
);
// Update visible items
this.updateVisibleItems(startIndex, endIndex);
}
updateVisibleItems(startIndex, endIndex) {
// Implementation depends on specific use case
// This is a simplified example
console.log(`Rendering items ${startIndex} to ${endIndex}`);
}
}
// Request Idle Callback Polyfill
window.requestIdleCallback = window.requestIdleCallback || function(cb) {
const start = Date.now();
return setTimeout(function() {
cb({
didTimeout: false,
timeRemaining: function() {
return Math.max(0, 50 - (Date.now() - start));
}
});
}, 1);
};
// Debounce function for performance
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Optimize form inputs
function optimizeInputs() {
// Debounce search inputs
const searchInputs = document.querySelectorAll('input[type="search"], .search-input');
searchInputs.forEach(input => {
const originalHandler = input.oninput;
if (originalHandler) {
input.oninput = debounce(originalHandler, 300);
}
});
// Lazy load select options for large dropdowns
const largeSelects = document.querySelectorAll('select[data-lazy]');
largeSelects.forEach(select => {
select.addEventListener('focus', function loadOptions() {
// Load options on first focus
if (this.dataset.loaded) return;
const endpoint = this.dataset.lazy;
fetch(endpoint)
.then(response => response.json())
.then(options => {
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
this.appendChild(option);
});
this.dataset.loaded = 'true';
});
}, { once: true });
});
}
// Reduce motion for users who prefer it
function respectReducedMotion() {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
if (prefersReducedMotion.matches) {
document.documentElement.classList.add('reduce-motion');
}
prefersReducedMotion.addEventListener('change', (e) => {
if (e.matches) {
document.documentElement.classList.add('reduce-motion');
} else {
document.documentElement.classList.remove('reduce-motion');
}
});
}
// Optimize animations
function optimizeAnimations() {
// Use CSS containment
const cards = document.querySelectorAll('.card, .entry-card');
cards.forEach(card => {
card.style.contain = 'layout style paint';
});
// Use will-change sparingly
document.addEventListener('touchstart', (e) => {
const target = e.target.closest('.btn, .card, [data-animate]');
if (target) {
target.style.willChange = 'transform';
// Remove after animation
setTimeout(() => {
target.style.willChange = 'auto';
}, 300);
}
});
}
// Memory management
function setupMemoryManagement() {
// Clean up event listeners on page hide
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Pause non-critical operations
if (window.pauseBackgroundOperations) {
window.pauseBackgroundOperations();
}
} else {
// Resume operations
if (window.resumeBackgroundOperations) {
window.resumeBackgroundOperations();
}
}
});
// Clean up on navigation
window.addEventListener('pagehide', () => {
// Cancel pending requests
if (window.pendingRequests) {
window.pendingRequests.forEach(request => request.abort());
}
});
}
// Battery-aware features
function setupBatteryAwareness() {
if ('getBattery' in navigator) {
navigator.getBattery().then(battery => {
function updateBatteryStatus() {
if (battery.level < 0.2 && !battery.charging) {
document.documentElement.classList.add('low-battery');
// Reduce animations and background operations
} else {
document.documentElement.classList.remove('low-battery');
}
}
battery.addEventListener('levelchange', updateBatteryStatus);
battery.addEventListener('chargingchange', updateBatteryStatus);
updateBatteryStatus();
});
}
}
// Network-aware loading
function setupNetworkAwareness() {
if ('connection' in navigator) {
const connection = navigator.connection;
function updateNetworkStatus() {
const effectiveType = connection.effectiveType;
document.documentElement.dataset.networkSpeed = effectiveType;
// Adjust quality based on connection
if (effectiveType === 'slow-2g' || effectiveType === '2g') {
document.documentElement.classList.add('low-quality');
} else {
document.documentElement.classList.remove('low-quality');
}
}
connection.addEventListener('change', updateNetworkStatus);
updateNetworkStatus();
}
}
// Initialize all optimizations
function init() {
// Only run on mobile
if (window.innerWidth > 768) return;
requestIdleCallback(() => {
new LazyLoader();
optimizeInputs();
respectReducedMotion();
optimizeAnimations();
setupMemoryManagement();
setupBatteryAwareness();
setupNetworkAwareness();
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Export for use in other modules
window.MobilePerformance = {
LazyLoader,
VirtualScroller,
debounce
};
})();

View File

@@ -0,0 +1,260 @@
// Pull-to-Refresh implementation for mobile
(function() {
'use strict';
class PullToRefresh {
constructor(options = {}) {
this.container = options.container || document.querySelector('.content');
this.onRefresh = options.onRefresh || (() => location.reload());
this.threshold = options.threshold || 80;
this.max = options.max || 120;
this.startY = 0;
this.currentY = 0;
this.pulling = false;
this.refreshing = false;
if (this.container && this.isMobile()) {
this.init();
}
}
isMobile() {
return window.innerWidth <= 768 ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
init() {
// Create pull-to-refresh indicator
this.createIndicator();
// Add touch event listeners
this.container.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: true });
this.container.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false });
this.container.addEventListener('touchend', this.onTouchEnd.bind(this));
}
createIndicator() {
this.indicator = document.createElement('div');
this.indicator.className = 'pull-refresh-indicator';
this.indicator.innerHTML = `
<div class="pull-refresh-spinner">
<i class="ti ti-refresh"></i>
</div>
<div class="pull-refresh-text">Pull to refresh</div>
`;
// Insert at the beginning of container
this.container.insertBefore(this.indicator, this.container.firstChild);
// Add styles
this.addStyles();
}
addStyles() {
if (document.getElementById('pull-refresh-styles')) return;
const styles = `
<style id="pull-refresh-styles">
.pull-refresh-indicator {
position: absolute;
top: -60px;
left: 0;
right: 0;
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0.8);
transition: opacity 0.2s, transform 0.2s;
}
.pull-refresh-indicator.visible {
opacity: 1;
transform: scale(1);
}
.pull-refresh-indicator.refreshing {
position: fixed;
top: 20px;
z-index: 1000;
}
.pull-refresh-spinner {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.pull-refresh-spinner i {
font-size: 24px;
color: var(--primary-color, #667eea);
transition: transform 0.3s ease;
}
.pull-refresh-indicator.pulling .pull-refresh-spinner i {
transform: rotate(180deg);
}
.pull-refresh-indicator.refreshing .pull-refresh-spinner i {
animation: spin 1s linear infinite;
}
.pull-refresh-text {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted, #999);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Container adjustments */
.content {
position: relative;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.content.pulling {
overflow-y: hidden;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
onTouchStart(e) {
if (this.refreshing) return;
// Only activate at the top of the page
if (this.container.scrollTop === 0) {
this.startY = e.touches[0].clientY;
this.pulling = true;
}
}
onTouchMove(e) {
if (!this.pulling || this.refreshing) return;
this.currentY = e.touches[0].clientY;
const diff = this.currentY - this.startY;
if (diff > 0) {
e.preventDefault();
// Calculate pull distance with resistance
const pullDistance = Math.min(diff * 0.5, this.max);
// Update container transform
this.container.style.transform = `translateY(${pullDistance}px)`;
// Update indicator
this.indicator.classList.add('visible');
if (pullDistance >= this.threshold) {
this.indicator.classList.add('pulling');
this.updateText('Release to refresh');
} else {
this.indicator.classList.remove('pulling');
this.updateText('Pull to refresh');
}
}
}
onTouchEnd() {
if (!this.pulling || this.refreshing) return;
const diff = this.currentY - this.startY;
const pullDistance = Math.min(diff * 0.5, this.max);
this.pulling = false;
this.container.style.transition = 'transform 0.3s ease';
if (pullDistance >= this.threshold) {
// Trigger refresh
this.refresh();
} else {
// Reset
this.reset();
}
}
refresh() {
this.refreshing = true;
this.container.style.transform = 'translateY(60px)';
this.indicator.classList.add('refreshing');
this.updateText('Refreshing...');
// Add haptic feedback if available
if (navigator.vibrate) {
navigator.vibrate(10);
}
// Call refresh callback
Promise.resolve(this.onRefresh()).then(() => {
setTimeout(() => {
this.reset();
}, 500);
});
}
reset() {
this.container.style.transform = '';
this.indicator.classList.remove('visible', 'pulling', 'refreshing');
setTimeout(() => {
this.container.style.transition = '';
this.refreshing = false;
this.currentY = 0;
}, 300);
}
updateText(text) {
const textEl = this.indicator.querySelector('.pull-refresh-text');
if (textEl) textEl.textContent = text;
}
}
// Auto-initialize on common containers
document.addEventListener('DOMContentLoaded', function() {
// Time tracking page
if (document.querySelector('.time-tracking-container')) {
new PullToRefresh({
container: document.querySelector('.time-tracking-container'),
onRefresh: () => {
// Refresh time entries
if (window.loadTimeEntries) {
return window.loadTimeEntries();
}
}
});
}
// Notes list
if (document.querySelector('.notes-container')) {
new PullToRefresh({
container: document.querySelector('.notes-container'),
onRefresh: () => {
// Refresh notes
if (window.refreshNotes) {
return window.refreshNotes();
}
}
});
}
});
// Expose globally
window.PullToRefresh = PullToRefresh;
})();

355
static/js/mobile-tables.js Normal file
View File

@@ -0,0 +1,355 @@
// Mobile Table Enhancements for TimeTrack
document.addEventListener('DOMContentLoaded', function() {
// Configuration
const MOBILE_BREAKPOINT = 768;
const CARD_VIEW_BREAKPOINT = 576;
// Initialize all data tables
function initMobileTables() {
const tables = document.querySelectorAll('.data-table, table');
tables.forEach(table => {
// Wrap tables in responsive container
if (!table.closest('.table-responsive')) {
const wrapper = document.createElement('div');
wrapper.className = 'table-responsive';
table.parentNode.insertBefore(wrapper, table);
wrapper.appendChild(table);
// Check if table is scrollable
checkTableScroll(wrapper);
}
// Add mobile-specific attributes
if (window.innerWidth <= CARD_VIEW_BREAKPOINT) {
convertTableToCards(table);
}
});
}
// Check if table needs horizontal scroll
function checkTableScroll(wrapper) {
const table = wrapper.querySelector('table');
if (table.scrollWidth > wrapper.clientWidth) {
wrapper.classList.add('scrollable');
addScrollIndicator(wrapper);
} else {
wrapper.classList.remove('scrollable');
}
}
// Add visual scroll indicator
function addScrollIndicator(wrapper) {
if (!wrapper.querySelector('.scroll-indicator')) {
const indicator = document.createElement('div');
indicator.className = 'scroll-indicator';
indicator.innerHTML = '<i class="ti ti-arrow-right"></i> Scroll for more';
wrapper.appendChild(indicator);
// Hide indicator when scrolled to end
wrapper.addEventListener('scroll', function() {
const maxScroll = this.scrollWidth - this.clientWidth;
if (this.scrollLeft >= maxScroll - 10) {
indicator.style.opacity = '0';
} else {
indicator.style.opacity = '1';
}
});
}
}
// Convert table to card layout for mobile
function convertTableToCards(table) {
// Skip if already converted or marked to skip
if (table.classList.contains('no-card-view') || table.dataset.mobileCards === 'true') {
return;
}
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
const rows = table.querySelectorAll('tbody tr');
// Create card container
const cardContainer = document.createElement('div');
cardContainer.className = 'mobile-card-view';
cardContainer.setAttribute('role', 'list');
rows.forEach((row, rowIndex) => {
const card = createCardFromRow(row, headers);
cardContainer.appendChild(card);
});
// Insert card view before table
table.parentNode.insertBefore(cardContainer, table);
// Add classes for toggling
table.classList.add('desktop-table-view');
table.dataset.mobileCards = 'true';
}
// Create a card element from table row
function createCardFromRow(row, headers) {
const cells = row.querySelectorAll('td');
const card = document.createElement('div');
card.className = 'table-card';
card.setAttribute('role', 'listitem');
// Check for special data attributes
const primaryField = row.dataset.primaryField || 0;
const secondaryField = row.dataset.secondaryField || 1;
// Create card header with primary info
if (cells[primaryField]) {
const cardHeader = document.createElement('div');
cardHeader.className = 'table-card-header';
cardHeader.innerHTML = cells[primaryField].innerHTML;
card.appendChild(cardHeader);
}
// Create card body with other fields
const cardBody = document.createElement('div');
cardBody.className = 'table-card-body';
cells.forEach((cell, index) => {
// Skip primary field as it's in header
if (index === primaryField) return;
const field = document.createElement('div');
field.className = 'table-card-field';
const label = document.createElement('span');
label.className = 'table-card-label';
label.textContent = headers[index] || '';
const value = document.createElement('span');
value.className = 'table-card-value';
value.innerHTML = cell.innerHTML;
field.appendChild(label);
field.appendChild(value);
cardBody.appendChild(field);
});
card.appendChild(cardBody);
// Copy any data attributes from row
Array.from(row.attributes).forEach(attr => {
if (attr.name.startsWith('data-')) {
card.setAttribute(attr.name, attr.value);
}
});
// Copy click handlers if any
if (row.onclick) {
card.onclick = row.onclick;
card.style.cursor = 'pointer';
}
return card;
}
// Time entry table specific enhancements
function enhanceTimeEntryTable() {
const timeTable = document.querySelector('.time-entries-table');
if (!timeTable) return;
if (window.innerWidth <= MOBILE_BREAKPOINT) {
// Add swipe actions for time entries
addSwipeActions(timeTable);
// Compact view for mobile
timeTable.classList.add('mobile-compact');
}
}
// Add swipe gestures to table rows
function addSwipeActions(table) {
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
let startX = 0;
let currentX = 0;
let isDragging = false;
row.addEventListener('touchstart', handleTouchStart, { passive: true });
row.addEventListener('touchmove', handleTouchMove, { passive: true });
row.addEventListener('touchend', handleTouchEnd);
function handleTouchStart(e) {
startX = e.touches[0].clientX;
isDragging = true;
row.style.transition = 'none';
}
function handleTouchMove(e) {
if (!isDragging) return;
currentX = e.touches[0].clientX;
const diffX = currentX - startX;
// Limit swipe distance
const maxSwipe = 100;
const swipeX = Math.max(-maxSwipe, Math.min(maxSwipe, diffX));
row.style.transform = `translateX(${swipeX}px)`;
// Show action indicators
if (swipeX < -50) {
row.classList.add('swipe-delete');
} else if (swipeX > 50) {
row.classList.add('swipe-edit');
} else {
row.classList.remove('swipe-delete', 'swipe-edit');
}
}
function handleTouchEnd(e) {
if (!isDragging) return;
const diffX = currentX - startX;
row.style.transition = 'transform 0.3s ease';
row.style.transform = '';
// Trigger actions based on swipe distance
if (diffX < -80) {
// Delete action
const deleteBtn = row.querySelector('.delete-btn');
if (deleteBtn) deleteBtn.click();
} else if (diffX > 80) {
// Edit action
const editBtn = row.querySelector('.edit-btn');
if (editBtn) editBtn.click();
}
row.classList.remove('swipe-delete', 'swipe-edit');
isDragging = false;
}
});
}
// Handle responsive table on window resize
function handleResize() {
const tables = document.querySelectorAll('.table-responsive');
tables.forEach(wrapper => {
checkTableScroll(wrapper);
});
// Re-initialize tables if crossing breakpoint
if (window.innerWidth <= CARD_VIEW_BREAKPOINT) {
initMobileTables();
}
// Update time entry table
enhanceTimeEntryTable();
}
// Add CSS for card view
function injectCardStyles() {
if (document.getElementById('mobile-table-styles')) return;
const styles = `
<style id="mobile-table-styles">
.table-card {
background: var(--bg-light);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.table-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.table-card-header {
font-weight: 600;
font-size: 16px;
margin-bottom: 12px;
color: var(--text-primary);
}
.table-card-body {
display: grid;
gap: 8px;
}
.table-card-field {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.table-card-label {
font-size: 13px;
color: var(--text-muted);
flex-shrink: 0;
margin-right: 12px;
}
.table-card-value {
text-align: right;
font-size: 14px;
color: var(--text-secondary);
}
.scroll-indicator {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
background: linear-gradient(to right, transparent, rgba(255,255,255,0.9));
padding: 8px 16px 8px 32px;
pointer-events: none;
transition: opacity 0.3s ease;
font-size: 13px;
color: var(--text-muted);
}
/* Swipe action styles */
.swipe-delete {
background-color: rgba(255, 59, 48, 0.1);
}
.swipe-edit {
background-color: rgba(52, 199, 89, 0.1);
}
/* Mobile compact view */
.mobile-compact td {
padding: 8px 6px;
font-size: 13px;
}
.mobile-compact .btn-sm {
padding: 4px 8px;
font-size: 12px;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
// Initialize on load
injectCardStyles();
initMobileTables();
enhanceTimeEntryTable();
// Handle window resize
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(handleResize, 250);
});
// Export functions for external use
window.MobileTables = {
init: initMobileTables,
convertToCards: convertTableToCards,
enhanceTimeEntry: enhanceTimeEntryTable
};
});

View File

@@ -3,7 +3,7 @@ document.addEventListener('DOMContentLoaded', function() {
const sidebar = document.getElementById('sidebar');
const sidebarToggle = document.getElementById('sidebar-toggle');
const mobileNavToggle = document.getElementById('mobile-nav-toggle');
const mobileOverlay = document.getElementById('mobile-overlay');
const mobileOverlay = document.getElementById('mobile-nav-overlay');
// Desktop sidebar toggle
if (sidebarToggle) {
@@ -27,18 +27,20 @@ document.addEventListener('DOMContentLoaded', function() {
// Mobile navigation toggle
if (mobileNavToggle) {
mobileNavToggle.addEventListener('click', function() {
sidebar.classList.toggle('mobile-open');
sidebar.classList.toggle('active');
mobileOverlay.classList.toggle('active');
mobileNavToggle.classList.toggle('active');
document.body.classList.toggle('mobile-nav-open');
});
}
// Close mobile sidebar when clicking overlay
if (mobileOverlay) {
mobileOverlay.addEventListener('click', function() {
sidebar.classList.remove('mobile-open');
sidebar.classList.remove('active');
mobileOverlay.classList.remove('active');
if (mobileNavToggle) mobileNavToggle.classList.remove('active');
document.body.classList.remove('mobile-nav-open');
});
}