mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 20:29:44 -05:00
b4e0b69796
Add an authenticated-only bottom bar below the md breakpoint with Heroicon-style tabs for Dashboard, Timer, Time entries, Projects, and More. More opens a slide-up sheet (backdrop, close, Escape) for Invoices, Clients, Reports, and user Settings, gated by module flags where applicable. Align shell layout to Tailwind md (768px): sidebar hidden md:flex, main md:ml-64 / md:ml-16 when collapsed, mobile hamburger md:hidden, RTL mainContent margin reset at 767px. Main column uses pb-16 on small screens so content clears the bar; bar and sheet use pb-safe (env safe-area) with a Tailwind safelist and @layer utilities rule. Remove the legacy six-slot FAB bottom nav from base.html. Docs: README UI overview, CHANGELOG [Unreleased], UI_GUIDELINES layout and file reference.
337 lines
11 KiB
JavaScript
337 lines
11 KiB
JavaScript
/* Mobile Enhancements for TimeTracker
|
|
Works with the app's sidebar navigation and Tailwind CSS UI */
|
|
|
|
const MobileUtils = {
|
|
TOUCH_TARGET_MIN: 44,
|
|
MOBILE_BREAKPOINT: 768,
|
|
SMALL_MOBILE_BREAKPOINT: 480,
|
|
|
|
isMobile() {
|
|
return window.innerWidth < this.MOBILE_BREAKPOINT;
|
|
},
|
|
isSmallMobile() {
|
|
return window.innerWidth <= this.SMALL_MOBILE_BREAKPOINT;
|
|
},
|
|
isTouchDevice() {
|
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
},
|
|
isIOS() {
|
|
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
|
}
|
|
};
|
|
|
|
class MobileSidebar {
|
|
constructor() {
|
|
this.sidebar = document.getElementById('sidebar');
|
|
this.toggleBtn = document.getElementById('mobileSidebarBtn');
|
|
this.overlay = document.getElementById('sidebarOverlay');
|
|
if (!this.sidebar) return;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
if (this.toggleBtn) {
|
|
this.toggleBtn.addEventListener('click', () => this.toggle());
|
|
}
|
|
if (this.overlay) {
|
|
this.overlay.addEventListener('click', () => this.close());
|
|
}
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') this.close();
|
|
});
|
|
|
|
this.sidebar.querySelectorAll('a').forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
if (MobileUtils.isMobile()) this.close();
|
|
});
|
|
});
|
|
}
|
|
|
|
toggle() {
|
|
this.sidebar.classList.toggle('-translate-x-full');
|
|
if (this.overlay) this.overlay.classList.toggle('hidden');
|
|
}
|
|
|
|
close() {
|
|
this.sidebar.classList.add('-translate-x-full');
|
|
if (this.overlay) this.overlay.classList.remove('hidden');
|
|
this.overlay && this.overlay.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
class MobileForms {
|
|
constructor() {
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
if (MobileUtils.isIOS()) {
|
|
document.querySelectorAll('input, select, textarea').forEach(el => {
|
|
const computed = window.getComputedStyle(el);
|
|
if (parseFloat(computed.fontSize) < 16) {
|
|
el.style.fontSize = '16px';
|
|
}
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('input, select, textarea').forEach(el => {
|
|
el.addEventListener('focus', () => {
|
|
if (MobileUtils.isMobile()) {
|
|
setTimeout(() => {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}, 300);
|
|
}
|
|
});
|
|
});
|
|
|
|
this.initFileInputs();
|
|
this.initCharCounters();
|
|
this.initSubmitButtons();
|
|
}
|
|
|
|
initFileInputs() {
|
|
document.querySelectorAll('input[type="file"]').forEach(input => {
|
|
input.addEventListener('change', () => {
|
|
const preview = document.getElementById(input.id + '-preview');
|
|
const filenameEl = document.getElementById(input.id + '-filename');
|
|
if (preview && filenameEl && input.files.length > 0) {
|
|
filenameEl.textContent = input.files[0].name;
|
|
preview.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
const dropZone = input.closest('label');
|
|
if (dropZone) {
|
|
['dragenter', 'dragover'].forEach(evt => {
|
|
dropZone.addEventListener(evt, (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.add('drag-over');
|
|
});
|
|
});
|
|
['dragleave', 'drop'].forEach(evt => {
|
|
dropZone.addEventListener(evt, (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove('drag-over');
|
|
});
|
|
});
|
|
dropZone.addEventListener('drop', (e) => {
|
|
if (e.dataTransfer.files.length) {
|
|
input.files = e.dataTransfer.files;
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
initCharCounters() {
|
|
document.querySelectorAll('.char-counter[data-for]').forEach(counter => {
|
|
const textarea = document.getElementById(counter.dataset.for);
|
|
if (textarea) {
|
|
textarea.addEventListener('input', () => {
|
|
counter.textContent = textarea.value.length;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
initSubmitButtons() {
|
|
document.querySelectorAll('button[data-loading-text]').forEach(btn => {
|
|
const form = btn.closest('form');
|
|
if (form) {
|
|
form.addEventListener('submit', () => {
|
|
if (form.checkValidity && !form.checkValidity()) return;
|
|
const original = btn.innerHTML;
|
|
btn.dataset.originalHtml = original;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>' + btn.dataset.loadingText;
|
|
btn.disabled = true;
|
|
setTimeout(() => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = original;
|
|
}, 15000);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
class MobileViewport {
|
|
constructor() {
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.handleViewportChange();
|
|
let resizeTimer;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => this.handleViewportChange(), 200);
|
|
});
|
|
window.addEventListener('orientationchange', () => {
|
|
setTimeout(() => this.handleViewportChange(), 150);
|
|
});
|
|
}
|
|
|
|
handleViewportChange() {
|
|
document.body.classList.toggle('mobile-view', MobileUtils.isMobile());
|
|
document.body.classList.toggle('small-mobile-view', MobileUtils.isSmallMobile());
|
|
}
|
|
}
|
|
|
|
class MobilePerformance {
|
|
constructor() {
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
if ('IntersectionObserver' in window) {
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const img = entry.target;
|
|
if (img.dataset.src) {
|
|
img.src = img.dataset.src;
|
|
img.removeAttribute('data-src');
|
|
}
|
|
observer.unobserve(img);
|
|
}
|
|
});
|
|
});
|
|
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
|
|
}
|
|
|
|
document.querySelectorAll('img:not([loading])').forEach(img => {
|
|
img.loading = 'lazy';
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Slide-up "More" drawer for mobile bottom navigation (see partials/_bottom_nav.html). */
|
|
class BottomNavMoreDrawer {
|
|
constructor() {
|
|
this.btn = document.getElementById('bottomNavMoreBtn');
|
|
this.backdrop = document.getElementById('bottomNavMoreBackdrop');
|
|
this.panel = document.getElementById('bottomNavMorePanel');
|
|
this.closeBtn = document.getElementById('bottomNavMoreClose');
|
|
if (!this.btn || !this.backdrop || !this.panel) return;
|
|
this._onDocKeydown = this._onDocKeydown.bind(this);
|
|
this.init();
|
|
}
|
|
|
|
isOpen() {
|
|
return !this.backdrop.classList.contains('hidden');
|
|
}
|
|
|
|
open() {
|
|
if (!MobileUtils.isMobile()) return;
|
|
this.backdrop.classList.remove('hidden');
|
|
requestAnimationFrame(() => {
|
|
this.panel.classList.remove('pointer-events-none', 'translate-y-full');
|
|
this.panel.setAttribute('aria-hidden', 'false');
|
|
this.btn.setAttribute('aria-expanded', 'true');
|
|
document.body.classList.add('overflow-hidden');
|
|
});
|
|
}
|
|
|
|
close() {
|
|
this.panel.classList.add('translate-y-full', 'pointer-events-none');
|
|
this.panel.setAttribute('aria-hidden', 'true');
|
|
this.btn.setAttribute('aria-expanded', 'false');
|
|
document.body.classList.remove('overflow-hidden');
|
|
|
|
const hideBackdrop = () => {
|
|
this.backdrop.classList.add('hidden');
|
|
};
|
|
|
|
const reduced =
|
|
typeof window.matchMedia === 'function' &&
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
if (reduced) {
|
|
hideBackdrop();
|
|
return;
|
|
}
|
|
|
|
let finished = false;
|
|
const finish = () => {
|
|
if (finished) return;
|
|
finished = true;
|
|
hideBackdrop();
|
|
};
|
|
|
|
const onEnd = (e) => {
|
|
if (e.target !== this.panel || e.propertyName !== 'transform') return;
|
|
this.panel.removeEventListener('transitionend', onEnd);
|
|
clearTimeout(fallbackTimer);
|
|
finish();
|
|
};
|
|
this.panel.addEventListener('transitionend', onEnd);
|
|
const fallbackTimer = window.setTimeout(() => {
|
|
this.panel.removeEventListener('transitionend', onEnd);
|
|
finish();
|
|
}, 400);
|
|
}
|
|
|
|
_onDocKeydown(e) {
|
|
if (e.key === 'Escape' && this.isOpen()) {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
init() {
|
|
this.btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (this.isOpen()) this.close();
|
|
else this.open();
|
|
});
|
|
this.backdrop.addEventListener('click', () => this.close());
|
|
if (this.closeBtn) {
|
|
this.closeBtn.addEventListener('click', () => this.close());
|
|
}
|
|
document.addEventListener('keydown', this._onDocKeydown);
|
|
this.panel.querySelectorAll('a.bottom-nav-more-link').forEach((a) => {
|
|
a.addEventListener('click', () => this.close());
|
|
});
|
|
}
|
|
}
|
|
|
|
class MobileOffline {
|
|
constructor() {
|
|
this.offlineToastId = null;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
window.addEventListener('offline', () => {
|
|
if (window.toastManager) {
|
|
this.offlineToastId = window.toastManager.warning(
|
|
'Some features may not work properly.',
|
|
"You're offline",
|
|
0
|
|
);
|
|
}
|
|
});
|
|
window.addEventListener('online', () => {
|
|
if (window.toastManager && this.offlineToastId) {
|
|
window.toastManager.dismiss(this.offlineToastId);
|
|
this.offlineToastId = null;
|
|
window.toastManager.success('Connection restored', "You're online", 3000);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (window._mobileInitDone) return;
|
|
window._mobileInitDone = true;
|
|
|
|
new MobileSidebar();
|
|
new MobileForms();
|
|
new MobileViewport();
|
|
new MobilePerformance();
|
|
new MobileOffline();
|
|
new BottomNavMoreDrawer();
|
|
});
|
|
|
|
window.MobileUtils = MobileUtils;
|