Improve mobile UI/UX.
This commit is contained in:
57
app.py
57
app.py
@@ -290,6 +290,63 @@ def setup():
|
||||
"""Company setup route - delegates to imported function"""
|
||||
return company_setup()
|
||||
|
||||
@app.route('/robots.txt')
|
||||
def robots_txt():
|
||||
"""Generate robots.txt for search engines"""
|
||||
lines = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
"Disallow: /admin/",
|
||||
"Disallow: /api/",
|
||||
"Disallow: /export/",
|
||||
"Disallow: /profile/",
|
||||
"Disallow: /config/",
|
||||
"Disallow: /teams/",
|
||||
"Disallow: /projects/",
|
||||
"Disallow: /logout",
|
||||
f"Sitemap: {request.host_url}sitemap.xml",
|
||||
"",
|
||||
"# TimeTrack - Open Source Time Tracking Software",
|
||||
"# https://github.com/nullmedium/TimeTrack"
|
||||
]
|
||||
return Response('\n'.join(lines), mimetype='text/plain')
|
||||
|
||||
@app.route('/sitemap.xml')
|
||||
def sitemap_xml():
|
||||
"""Generate XML sitemap for search engines"""
|
||||
pages = []
|
||||
|
||||
# Static pages accessible without login
|
||||
static_pages = [
|
||||
{'loc': '/', 'priority': '1.0', 'changefreq': 'daily'},
|
||||
{'loc': '/login', 'priority': '0.8', 'changefreq': 'monthly'},
|
||||
{'loc': '/register', 'priority': '0.9', 'changefreq': 'monthly'},
|
||||
{'loc': '/forgot_password', 'priority': '0.5', 'changefreq': 'monthly'},
|
||||
]
|
||||
|
||||
for page in static_pages:
|
||||
pages.append({
|
||||
'loc': request.host_url[:-1] + page['loc'],
|
||||
'lastmod': datetime.now().strftime('%Y-%m-%d'),
|
||||
'priority': page['priority'],
|
||||
'changefreq': page['changefreq']
|
||||
})
|
||||
|
||||
sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
sitemap_xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
|
||||
for page in pages:
|
||||
sitemap_xml += ' <url>\n'
|
||||
sitemap_xml += f' <loc>{page["loc"]}</loc>\n'
|
||||
sitemap_xml += f' <lastmod>{page["lastmod"]}</lastmod>\n'
|
||||
sitemap_xml += f' <changefreq>{page["changefreq"]}</changefreq>\n'
|
||||
sitemap_xml += f' <priority>{page["priority"]}</priority>\n'
|
||||
sitemap_xml += ' </url>\n'
|
||||
|
||||
sitemap_xml += '</urlset>'
|
||||
|
||||
return Response(sitemap_xml, mimetype='application/xml')
|
||||
|
||||
@app.route('/')
|
||||
def home():
|
||||
if g.user:
|
||||
|
||||
125
static/css/mobile-bottom-nav.css
Normal file
125
static/css/mobile-bottom-nav.css
Normal file
@@ -0,0 +1,125 @@
|
||||
/* Mobile Bottom Navigation Bar */
|
||||
@media (max-width: 768px) {
|
||||
/* Bottom Navigation Container */
|
||||
.mobile-bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
|
||||
/* Hide on desktop */
|
||||
@media (min-width: 769px) {
|
||||
.mobile-bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Navigation Items */
|
||||
.bottom-nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary, #666);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Icon styling */
|
||||
.bottom-nav-item i {
|
||||
font-size: 24px;
|
||||
margin-bottom: 2px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Label styling */
|
||||
.bottom-nav-item span {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Active state */
|
||||
.bottom-nav-item.active {
|
||||
color: var(--primary-color, #667eea);
|
||||
}
|
||||
|
||||
.bottom-nav-item.active i {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Touch feedback */
|
||||
.bottom-nav-item:active {
|
||||
opacity: 0.7;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Center FAB-style time tracking button */
|
||||
.bottom-nav-item.nav-fab {
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
.bottom-nav-item.nav-fab .fab-button {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bottom-nav-item.nav-fab .fab-button i {
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bottom-nav-item.nav-fab span {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Notification badge */
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: calc(50% - 16px);
|
||||
background: #ff3b30;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 2px 4px;
|
||||
border-radius: 10px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Adjust main content to avoid overlap */
|
||||
.has-bottom-nav .content {
|
||||
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0)) !important;
|
||||
}
|
||||
|
||||
/* Keep sidebar for hamburger menu functionality */
|
||||
/* Removed the rule that was hiding sidebar */
|
||||
}
|
||||
495
static/css/mobile-forms.css
Normal file
495
static/css/mobile-forms.css
Normal file
@@ -0,0 +1,495 @@
|
||||
/* Mobile Form Enhancements for TimeTrack */
|
||||
/* This file contains mobile-specific form optimizations */
|
||||
|
||||
/* ===== Form Container Improvements ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Form wrapper adjustments */
|
||||
.form-container,
|
||||
.auth-container,
|
||||
.profile-container {
|
||||
padding: var(--mobile-edge-padding);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Form sections with better spacing */
|
||||
.form-section {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: var(--bg-light);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Enhanced Input Fields ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Base input improvements */
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="tel"],
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
input[type="datetime-local"],
|
||||
input[type="search"],
|
||||
input[type="url"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: var(--mobile-input-height);
|
||||
padding: 14px 16px;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
border: 2px solid var(--border-color);
|
||||
transition: border-color 0.2s ease;
|
||||
-webkit-appearance: none;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
/* Focus states with better visibility */
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
/* Textarea specific */
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Select dropdown improvements */
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23667eea' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 16px center;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* Checkbox and radio improvements */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Label improvements for checkboxes/radios */
|
||||
.checkbox-wrapper,
|
||||
.radio-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--mobile-touch-target);
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.checkbox-wrapper label,
|
||||
.radio-wrapper label {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Form Labels and Helpers ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Label styling */
|
||||
label,
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Required field indicator */
|
||||
label.required::after,
|
||||
.form-label.required::after {
|
||||
content: " *";
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Help text */
|
||||
.form-help,
|
||||
.help-text {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Error messages */
|
||||
.form-error,
|
||||
.error-message {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Input with error state */
|
||||
.has-error input,
|
||||
.has-error select,
|
||||
.has-error textarea {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Form Groups and Layouts ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Form group spacing */
|
||||
.form-group,
|
||||
.mb-3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Stack form columns on mobile */
|
||||
.form-row,
|
||||
.row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-row > *,
|
||||
.row > * {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row > *:last-child,
|
||||
.row > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Inline form groups */
|
||||
.form-inline {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-inline > * {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Button Groups and Actions ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Form action buttons */
|
||||
.form-actions,
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.form-actions .btn,
|
||||
.button-group .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Primary action emphasis */
|
||||
.form-actions .btn-primary,
|
||||
.button-group .btn-primary {
|
||||
order: -1; /* Move primary button to top */
|
||||
}
|
||||
|
||||
/* Horizontal button groups on larger mobiles */
|
||||
@media (min-width: 480px) {
|
||||
.form-actions.horizontal,
|
||||
.button-group.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.form-actions.horizontal .btn,
|
||||
.button-group.horizontal .btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== File Upload Enhancements ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* File input styling */
|
||||
input[type="file"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-light);
|
||||
}
|
||||
|
||||
/* Custom file upload area */
|
||||
.file-upload-area {
|
||||
position: relative;
|
||||
padding: 32px 16px;
|
||||
border: 2px dashed var(--primary-color);
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-upload-area:hover,
|
||||
.file-upload-area.drag-over {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.file-upload-area input[type="file"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Search and Filter Forms ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Search input with icon */
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-wrapper input[type="search"] {
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.search-wrapper .search-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Filter form improvements */
|
||||
.filter-form {
|
||||
background: var(--bg-light);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.filter-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-content.show {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Keyboard and Focus Management ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Adjust layout when keyboard is visible */
|
||||
.keyboard-visible .form-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-light);
|
||||
padding: 16px;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Tab focus indicators */
|
||||
.form-tab-nav {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-tab-nav button {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-tab-nav button.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Time Entry Form Specific ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Timer display adjustments */
|
||||
.timer-form {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
font-size: clamp(2.5rem, 10vw, 4rem);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Project/task selectors */
|
||||
.time-entry-selectors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Timer controls */
|
||||
.timer-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timer-controls .btn {
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Login/Register Form Specific ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Auth forms centering */
|
||||
.auth-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Registration type selector */
|
||||
.registration-type-selector {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.registration-type-option {
|
||||
padding: 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.registration-type-option.active {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Progressive Enhancement ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Collapsible form sections */
|
||||
.collapsible-section {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapsible-header {
|
||||
padding: 16px;
|
||||
background: var(--bg-light);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.collapsible-header::after {
|
||||
content: "▼";
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.collapsible-section.collapsed .collapsible-header::after {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
padding: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.collapsible-section.collapsed .collapsible-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Accessibility Improvements ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Skip to form content link */
|
||||
.skip-to-form {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
border-radius: 0 0 8px 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.skip-to-form:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Focus visible improvements */
|
||||
*:focus-visible {
|
||||
outline: 3px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
567
static/css/mobile-optimized.css
Normal file
567
static/css/mobile-optimized.css
Normal file
@@ -0,0 +1,567 @@
|
||||
/* Mobile-Optimized CSS for TimeTrack */
|
||||
/* This file contains mobile-first responsive styles to improve the mobile experience */
|
||||
|
||||
/* ===== CSS Variables for Mobile ===== */
|
||||
:root {
|
||||
/* Touch-friendly sizes */
|
||||
--mobile-touch-target: 44px;
|
||||
--mobile-input-height: 48px;
|
||||
--mobile-button-padding: 12px 16px;
|
||||
|
||||
/* Spacing */
|
||||
--mobile-edge-padding: 16px;
|
||||
--mobile-content-padding: 20px;
|
||||
|
||||
/* Typography scaling */
|
||||
--mobile-base-font: 16px;
|
||||
--mobile-small-font: 14px;
|
||||
|
||||
/* Z-index layers */
|
||||
--z-mobile-header: 100;
|
||||
--z-mobile-nav: 300;
|
||||
--z-mobile-overlay: 290;
|
||||
--z-bottom-nav: 100;
|
||||
}
|
||||
|
||||
/* ===== Base Mobile Styles ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Prevent horizontal scroll */
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* Better viewport handling for mobile browsers */
|
||||
.sidebar {
|
||||
height: 100dvh !important; /* Use dvh for better mobile support */
|
||||
max-height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
/* Improved mobile header */
|
||||
.mobile-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--z-mobile-header);
|
||||
background: #ffffff !important; /* Override green background */
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 8px var(--mobile-edge-padding);
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* Ensure content doesn't hide behind fixed header */
|
||||
.content {
|
||||
padding-top: calc(56px + var(--mobile-content-padding)) !important;
|
||||
padding-left: var(--mobile-edge-padding) !important;
|
||||
padding-right: var(--mobile-edge-padding) !important;
|
||||
padding-bottom: env(safe-area-inset-bottom, 20px);
|
||||
}
|
||||
|
||||
/* ===== Typography Optimization ===== */
|
||||
body {
|
||||
font-size: var(--mobile-base-font);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.5rem, 5vw, 2rem);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.25rem, 4vw, 1.75rem);
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: clamp(1.125rem, 3.5vw, 1.5rem);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h4, h5, h6 {
|
||||
font-size: clamp(1rem, 3vw, 1.25rem);
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
/* ===== Mobile Navigation Improvements ===== */
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 85vw;
|
||||
max-width: 320px;
|
||||
border-radius: 0;
|
||||
box-shadow: 2px 0 20px rgba(0,0,0,0.15);
|
||||
z-index: var(--z-mobile-nav) !important;
|
||||
position: fixed !important;
|
||||
}
|
||||
|
||||
.sidebar.active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Mobile navigation overlay */
|
||||
.mobile-nav-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
z-index: var(--z-mobile-overlay);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-nav-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Mobile menu toggle button */
|
||||
.mobile-menu-toggle,
|
||||
.mobile-nav-toggle,
|
||||
#mobile-nav-toggle {
|
||||
min-width: var(--mobile-touch-target);
|
||||
min-height: var(--mobile-touch-target);
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Hamburger menu lines - override white color with higher specificity */
|
||||
.mobile-menu-toggle span,
|
||||
.mobile-nav-toggle span,
|
||||
#mobile-nav-toggle span,
|
||||
.mobile-header .mobile-nav-toggle span {
|
||||
display: block !important;
|
||||
height: 3px !important;
|
||||
width: 24px !important;
|
||||
background-color: var(--primary-color, #667eea) !important;
|
||||
border-radius: 2px !important;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* Hover state */
|
||||
.mobile-menu-toggle:hover span,
|
||||
.mobile-nav-toggle:hover span,
|
||||
#mobile-nav-toggle:hover span {
|
||||
background-color: var(--primary-gradient-end, #764ba2) !important;
|
||||
}
|
||||
|
||||
/* Active/open state animations */
|
||||
.mobile-menu-toggle.active span:nth-child(1),
|
||||
.mobile-nav-toggle.active span:nth-child(1),
|
||||
#mobile-nav-toggle.active span:nth-child(1) {
|
||||
transform: rotate(45deg) translate(6px, 6px);
|
||||
}
|
||||
|
||||
.mobile-menu-toggle.active span:nth-child(2),
|
||||
.mobile-nav-toggle.active span:nth-child(2),
|
||||
#mobile-nav-toggle.active span:nth-child(2) {
|
||||
opacity: 0 !important;
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
.mobile-menu-toggle.active span:nth-child(3),
|
||||
.mobile-nav-toggle.active span:nth-child(3),
|
||||
#mobile-nav-toggle.active span:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(6px, -6px);
|
||||
}
|
||||
|
||||
/* Fallback: If spans still not visible, add pseudo-element hamburger */
|
||||
#mobile-nav-toggle::before {
|
||||
content: "☰";
|
||||
font-size: 24px;
|
||||
color: var(--primary-color, #667eea);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show fallback if spans are hidden */
|
||||
#mobile-nav-toggle:not(.active) span:first-child:not(:visible) ~ ::before {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile brand/logo styling */
|
||||
.mobile-nav-brand {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color, #667eea);
|
||||
}
|
||||
|
||||
.mobile-nav-brand a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-logo {
|
||||
max-height: 32px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ===== Touch-Friendly Form Elements ===== */
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="tel"],
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
input[type="datetime-local"],
|
||||
select,
|
||||
textarea {
|
||||
min-height: var(--mobile-input-height);
|
||||
font-size: var(--mobile-base-font);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Prevent zoom on input focus */
|
||||
input[type="text"]:focus,
|
||||
input[type="email"]:focus,
|
||||
input[type="password"]:focus,
|
||||
input[type="number"]:focus,
|
||||
input[type="tel"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
font-size: var(--mobile-base-font);
|
||||
}
|
||||
|
||||
/* Touch-friendly buttons */
|
||||
.btn,
|
||||
button,
|
||||
.button,
|
||||
input[type="submit"],
|
||||
input[type="button"] {
|
||||
min-height: var(--mobile-touch-target);
|
||||
padding: var(--mobile-button-padding);
|
||||
font-size: var(--mobile-base-font);
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Icon buttons need proper sizing */
|
||||
.btn-icon,
|
||||
.icon-button {
|
||||
min-width: var(--mobile-touch-target);
|
||||
min-height: var(--mobile-touch-target);
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== Table Responsiveness ===== */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin-left: calc(-1 * var(--mobile-edge-padding));
|
||||
margin-right: calc(-1 * var(--mobile-edge-padding));
|
||||
padding-left: var(--mobile-edge-padding);
|
||||
padding-right: var(--mobile-edge-padding);
|
||||
}
|
||||
|
||||
/* Visual scroll indicator for tables */
|
||||
.table-responsive::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 30px;
|
||||
background: linear-gradient(to right, transparent, rgba(255,255,255,0.8));
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.table-responsive.scrollable::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Compact table styling */
|
||||
.data-table {
|
||||
font-size: var(--mobile-small-font);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Card-based layout for complex data */
|
||||
.mobile-card-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.hide-on-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.show-on-mobile {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.mobile-card-view {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktop-table-view {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Modal Improvements ===== */
|
||||
.modal {
|
||||
margin: 0;
|
||||
max-height: 100dvh;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 90dvh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Account for mobile keyboard */
|
||||
.modal-open .modal-content {
|
||||
padding-bottom: env(keyboard-inset-height, 0);
|
||||
}
|
||||
|
||||
/* ===== Card and Container Adjustments ===== */
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
padding: var(--mobile-content-padding);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin: calc(-1 * var(--mobile-content-padding));
|
||||
margin-bottom: var(--mobile-content-padding);
|
||||
padding: 16px var(--mobile-content-padding);
|
||||
}
|
||||
|
||||
/* ===== Mobile-Specific Components ===== */
|
||||
/* Bottom action bar for primary actions */
|
||||
.mobile-action-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-light);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 12px var(--mobile-edge-padding);
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0));
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.mobile-action-bar .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Content adjustment when action bar is present */
|
||||
.has-mobile-action-bar .content {
|
||||
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0)) !important;
|
||||
}
|
||||
|
||||
/* ===== Time Tracking Specific ===== */
|
||||
/* Fix page header on mobile */
|
||||
.page-header {
|
||||
padding: 1.5rem var(--mobile-edge-padding) !important;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: clamp(1.5rem, 5vw, 2rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Header action buttons */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* For slightly larger phones, show buttons side by side */
|
||||
@media (min-width: 400px) {
|
||||
.header-actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.header-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Timer display and controls */
|
||||
.timer-display,
|
||||
#timer {
|
||||
font-size: clamp(1.5rem, 5vw, 2rem) !important;
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timer-controls .btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Timer card adjustments */
|
||||
.timer-card {
|
||||
padding: 1.5rem var(--mobile-edge-padding);
|
||||
}
|
||||
|
||||
/* Stats cards in grid */
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Performance Optimizations ===== */
|
||||
/* Reduce motion for users who prefer it */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Landscape Orientation Handling ===== */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.mobile-header {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: calc(48px + 12px) !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 100dvh;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Mobile Utilities ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* Spacing utilities */
|
||||
.mobile-mt-1 { margin-top: 8px !important; }
|
||||
.mobile-mt-2 { margin-top: 16px !important; }
|
||||
.mobile-mt-3 { margin-top: 24px !important; }
|
||||
.mobile-mb-1 { margin-bottom: 8px !important; }
|
||||
.mobile-mb-2 { margin-bottom: 16px !important; }
|
||||
.mobile-mb-3 { margin-bottom: 24px !important; }
|
||||
|
||||
/* Text alignment */
|
||||
.mobile-text-center { text-align: center !important; }
|
||||
.mobile-text-left { text-align: left !important; }
|
||||
.mobile-text-right { text-align: right !important; }
|
||||
|
||||
/* Flexbox utilities */
|
||||
.mobile-flex-column { flex-direction: column !important; }
|
||||
.mobile-flex-wrap { flex-wrap: wrap !important; }
|
||||
|
||||
/* Full width on mobile */
|
||||
.mobile-full-width {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Improved Focus States for Accessibility ===== */
|
||||
@media (max-width: 768px) {
|
||||
*:focus {
|
||||
outline: 3px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline-offset: 0;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
173
static/js/date-formatter.js
Normal file
173
static/js/date-formatter.js
Normal 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
|
||||
};
|
||||
})();
|
||||
550
static/js/date-picker-enhancer.js
Normal file
550
static/js/date-picker-enhancer.js
Normal 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
|
||||
};
|
||||
})();
|
||||
403
static/js/mobile-gestures.js
Normal file
403
static/js/mobile-gestures.js
Normal 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;
|
||||
})();
|
||||
323
static/js/mobile-performance.js
Normal file
323
static/js/mobile-performance.js
Normal 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
|
||||
};
|
||||
})();
|
||||
260
static/js/mobile-pull-refresh.js
Normal file
260
static/js/mobile-pull-refresh.js
Normal 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
355
static/js/mobile-tables.js
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
101
static/manifest.json
Normal file
101
static/manifest.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "TimeTrack - Time Management",
|
||||
"short_name": "TimeTrack",
|
||||
"description": "Professional time tracking and project management application",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#667eea",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Start Timer",
|
||||
"short_name": "Timer",
|
||||
"description": "Start tracking time",
|
||||
"url": "/time-tracking",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/timer-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "View Projects",
|
||||
"short_name": "Projects",
|
||||
"description": "View all projects",
|
||||
"url": "/projects",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/projects-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Create Note",
|
||||
"short_name": "Note",
|
||||
"description": "Create a new note",
|
||||
"url": "/notes/create",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/note-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "business"],
|
||||
"prefer_related_applications": false,
|
||||
"related_applications": []
|
||||
}
|
||||
@@ -220,7 +220,7 @@
|
||||
<tr data-entry-id="{{ entry.id }}" class="entry-row">
|
||||
<td>
|
||||
<div class="date-cell">
|
||||
<span class="date-day">{{ entry.arrival_time.strftime('%d') }}</span>
|
||||
<span class="date-day">{{ entry.arrival_time.day }}</span>
|
||||
<span class="date-month">{{ entry.arrival_time.strftime('%b') }}</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -304,7 +304,7 @@
|
||||
<div class="entry-card" data-entry-id="{{ entry.id }}">
|
||||
<div class="entry-header">
|
||||
<div class="entry-date">
|
||||
{{ entry.arrival_time.strftime('%d %b %Y') }}
|
||||
{{ entry.arrival_time|format_date }}
|
||||
</div>
|
||||
<div class="entry-duration">
|
||||
{{ entry.duration|format_duration if entry.duration is not none else 'Active' }}
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<span class="info-icon"><i class="ti ti-calendar"></i></span>
|
||||
<div class="info-content">
|
||||
<label class="info-label">Created</label>
|
||||
<span class="info-value">{{ company.created_at.strftime('%B %d, %Y at %I:%M %p') }}</span>
|
||||
<span class="info-value">{{ company.created_at|format_datetime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -240,8 +240,8 @@
|
||||
</td>
|
||||
<td class="date-cell">
|
||||
{% if project.start_date %}
|
||||
{{ project.start_date.strftime('%Y-%m-%d') }}
|
||||
{% if project.end_date %}<br>to {{ project.end_date.strftime('%Y-%m-%d') }}{% endif %}
|
||||
{{ project.start_date|format_date }}
|
||||
{% if project.end_date %}<br>to {{ project.end_date|format_date }}{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<p class="section-description">
|
||||
These policies are set by your administrator and apply to all employees.
|
||||
{% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %}
|
||||
<a href="{{ url_for('admin_work_policies') }}">Click here to modify these settings</a>.
|
||||
<a href="{{ url_for('companies.admin_company') }}">Click here to modify these settings</a>.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
@@ -25,20 +25,12 @@
|
||||
</div>
|
||||
<div class="policy-item">
|
||||
<strong>Break Policy:</strong>
|
||||
{% if company_config.mandatory_break_minutes > 0 %}
|
||||
{% if company_config.require_breaks %}
|
||||
{{ company_config.break_duration_minutes }} minutes after {{ company_config.break_after_hours }} hours
|
||||
{% else %}
|
||||
No mandatory breaks
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="policy-item">
|
||||
<strong>Additional Break:</strong>
|
||||
{% if company_config.additional_break_minutes > 0 %}
|
||||
{{ company_config.additional_break_minutes }} minutes after {{ company_config.additional_break_threshold_hours }} hours
|
||||
{% else %}
|
||||
No additional breaks
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block meta_description %}{{ g.branding.app_name if g.branding else 'TimeTrack' }} - Free enterprise time tracking software with project management, team collaboration, billing & invoicing. Track time, manage projects, generate reports. Open-source & self-hosted.{% endblock %}
|
||||
|
||||
{% block meta_keywords %}time tracking software, project time tracker, team time management, billing and invoicing, enterprise time tracking, open source time tracker, self-hosted time tracking, project management software, team productivity tools, work hours tracker{% endblock %}
|
||||
|
||||
{% block og_title %}{{ g.branding.app_name if g.branding else 'TimeTrack' }} - Enterprise Time Tracking & Project Management{% endblock %}
|
||||
|
||||
{% block og_description %}Transform your team's productivity with intelligent time tracking, real-time analytics, project management, and automated billing. Free, open-source, and enterprise-ready.{% endblock %}
|
||||
|
||||
{% block structured_data %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "{{ g.branding.app_name if g.branding else 'TimeTrack' }}",
|
||||
"url": "{{ request.host_url }}",
|
||||
"description": "Free enterprise time tracking software with project management, team collaboration, billing & invoicing.",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"@context": "https://schema.org",
|
||||
"target": "{{ request.host_url }}search?q={search_term_string}",
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What is TimeTrack?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "TimeTrack is a comprehensive, open-source time tracking and project management software that helps teams track work hours, manage projects, collaborate, and handle billing/invoicing - all in one platform."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is TimeTrack really free?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes! TimeTrack is 100% free and open-source. You can use our hosted version at no cost or self-host it on your own servers. There are no hidden fees, no user limits, and all features are included."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What features does TimeTrack include?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "TimeTrack includes one-click time tracking, project management, sprint planning, team collaboration, billing & invoicing, custom reporting, multi-company support, two-factor authentication, and GDPR compliance features."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not g.user %}
|
||||
|
||||
|
||||
@@ -3,11 +3,47 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% if g.company %} - {{ g.company.name }}{% endif %}</title>
|
||||
<title>{% if title == 'Home' %}{{ g.branding.app_name if g.branding else 'TimeTrack' }} - Enterprise Time Tracking & Project Management Software{% else %}{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% endif %}{% if g.company %} - {{ g.company.name }}{% endif %}</title>
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="{% block meta_description %}{{ g.branding.app_name if g.branding else 'TimeTrack' }} is a comprehensive time tracking solution with project management, team collaboration, billing & invoicing. Free, open-source, and enterprise-ready.{% endblock %}">
|
||||
<meta name="keywords" content="{% block meta_keywords %}time tracking, project management, team collaboration, billing software, invoice management, enterprise time tracker, open source time tracking{% endblock %}">
|
||||
<meta name="author" content="{{ g.branding.app_name if g.branding else 'TimeTrack' }}">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="{{ request.url }}">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="{% block og_title %}{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% endblock %}">
|
||||
<meta property="og:description" content="{% block og_description %}Transform your productivity with intelligent time tracking, project management, and team collaboration tools. Enterprise-grade, open-source solution.{% endblock %}">
|
||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
||||
<meta property="og:url" content="{{ request.url }}">
|
||||
<meta property="og:site_name" content="{{ g.branding.app_name if g.branding else 'TimeTrack' }}">
|
||||
{% if g.branding and g.branding.logo_filename %}
|
||||
<meta property="og:image" content="{{ url_for('static', filename='uploads/branding/' + g.branding.logo_filename, _external=True) }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{% block twitter_title %}{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% endblock %}">
|
||||
<meta name="twitter:description" content="{% block twitter_description %}Transform your productivity with intelligent time tracking, project management, and team collaboration tools.{% endblock %}">
|
||||
{% if g.branding and g.branding.logo_filename %}
|
||||
<meta name="twitter:image" content="{{ url_for('static', filename='uploads/branding/' + g.branding.logo_filename, _external=True) }}">
|
||||
{% endif %}
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/hover-standards.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile-optimized.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile-forms.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile-bottom-nav.css') }}">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons@latest/iconfont/tabler-icons.min.css">
|
||||
|
||||
<!-- PWA Support -->
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<meta name="theme-color" content="#667eea">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/icon-192x192.png') }}">
|
||||
{% if g.user %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/time-tracking.css') }}">
|
||||
{% endif %}
|
||||
@@ -46,9 +82,25 @@
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
/* Fix mobile hamburger menu visibility */
|
||||
@media (max-width: 768px) {
|
||||
#mobile-nav-toggle span {
|
||||
background-color: var(--primary-color, #667eea) !important;
|
||||
display: block !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body{% if g.user %} class="has-user"{% endif %}>
|
||||
<body{% if g.user %} class="has-user has-bottom-nav"{% endif %}>
|
||||
{% if g.user and g.user.preferences %}
|
||||
<!-- User preferences for JavaScript -->
|
||||
<div id="user-preferences" style="display: none;"
|
||||
data-date-format="{{ g.user.preferences.date_format }}"
|
||||
data-time-format-24h="{{ g.user.preferences.time_format_24h|lower }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Mobile header -->
|
||||
{% if g.user %}
|
||||
<header class="mobile-header">
|
||||
@@ -69,6 +121,7 @@
|
||||
<span></span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="mobile-nav-overlay" id="mobile-nav-overlay"></div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sidebar navigation -->
|
||||
@@ -246,9 +299,43 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Mobile Bottom Navigation -->
|
||||
{% if g.user %}
|
||||
<nav class="mobile-bottom-nav">
|
||||
<a href="{{ url_for('home') }}" class="bottom-nav-item {% if request.endpoint == 'home' %}active{% endif %}">
|
||||
<i class="ti ti-home"></i>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="{{ url_for('projects.admin_projects') }}" class="bottom-nav-item {% if 'project' in request.endpoint %}active{% endif %}">
|
||||
<i class="ti ti-clipboard-list"></i>
|
||||
<span>Projects</span>
|
||||
</a>
|
||||
<a href="{{ url_for('time_tracking') }}" class="bottom-nav-item nav-fab {% if 'time' in request.endpoint %}active{% endif %}">
|
||||
<div class="fab-button">
|
||||
<i class="ti ti-clock"></i>
|
||||
</div>
|
||||
<span>Time</span>
|
||||
</a>
|
||||
<a href="{{ url_for('notes.notes_list') }}" class="bottom-nav-item {% if 'note' in request.endpoint %}active{% endif %}">
|
||||
<i class="ti ti-notes"></i>
|
||||
<span>Notes</span>
|
||||
</a>
|
||||
<a href="{{ url_for('profile') }}" class="bottom-nav-item {% if request.endpoint == 'profile' %}active{% endif %}">
|
||||
<i class="ti ti-user"></i>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/sidebar.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/mobile-tables.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/date-formatter.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/date-picker-enhancer.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/mobile-gestures.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/mobile-pull-refresh.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/mobile-performance.js') }}"></script>
|
||||
{% if g.user %}
|
||||
<script src="{{ url_for('static', filename='js/user-dropdown.js') }}"></script>
|
||||
<script>
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
Updated {{ note.updated_at|format_date }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if note.folder %}
|
||||
<span class="meta-divider">•</span>
|
||||
<span class="folder">
|
||||
<span class="icon"><i class="ti ti-folder"></i></span>
|
||||
<a href="{{ url_for('notes.notes_list', folder=note.folder) }}" class="folder-link">
|
||||
{{ note.folder }}
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@@ -89,23 +98,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Note Metadata Card -->
|
||||
{% if note.project or note.task or note.tags or note.folder %}
|
||||
{% if note.project or note.task or note.tags %}
|
||||
<div class="metadata-card">
|
||||
<div class="metadata-grid">
|
||||
{% if note.folder %}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">
|
||||
<span class="icon"><i class="ti ti-folder"></i></span>
|
||||
Folder
|
||||
</span>
|
||||
<span class="metadata-value">
|
||||
<a href="{{ url_for('notes.notes_list', folder=note.folder) }}" class="metadata-link">
|
||||
{{ note.folder }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if note.project %}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">
|
||||
@@ -401,6 +396,18 @@
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.folder-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.folder-link:hover {
|
||||
color: white;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.pin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Member Since</span>
|
||||
<span class="info-value">{{ user.created_at.strftime('%B %d, %Y') }}</span>
|
||||
<span class="info-value">{{ user.created_at|format_date }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Account Type</span>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% if active_entry %}
|
||||
<div class="active-timer">
|
||||
<h2>Currently Working</h2>
|
||||
<p>Started at: {{ active_entry.arrival_time.strftime('%Y-%m-%d %H:%M:%S') }}</p>
|
||||
<p>Started at: {{ active_entry.arrival_time|format_datetime }}</p>
|
||||
<div id="timer" data-start="{{ active_entry.arrival_time.timestamp() }}">00:00:00</div>
|
||||
<button id="leave-btn" class="leave-btn" data-id="{{ active_entry.id }}">Leave</button>
|
||||
</div>
|
||||
@@ -35,11 +35,11 @@
|
||||
</thead>
|
||||
<tbody id="time-history-body">
|
||||
{% for entry in history %}
|
||||
<tr data-entry-id="{{ entry.id }}">
|
||||
<td>{{ entry.arrival_time.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ entry.arrival_time.strftime('%H:%M:%S') }}</td>
|
||||
<td>{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }}</td>
|
||||
<td>{{ (entry.duration // 3600)|string + 'h ' + ((entry.duration % 3600) // 60)|string + 'm' if entry.duration else 'N/A' }}</td>
|
||||
<tr data-entry-id="{{ entry.id }}" data-iso-date="{{ entry.arrival_time.strftime('%Y-%m-%d') }}">
|
||||
<td>{{ entry.arrival_time|format_date }}</td>
|
||||
<td>{{ entry.arrival_time|format_time }}</td>
|
||||
<td>{{ entry.departure_time|format_time if entry.departure_time else 'Active' }}</td>
|
||||
<td>{{ entry.duration|format_duration if entry.duration else 'N/A' }}</td>
|
||||
<td>
|
||||
<button class="edit-entry-btn" data-id="{{ entry.id }}">Edit</button>
|
||||
<button class="delete-entry-btn" data-id="{{ entry.id }}">Delete</button>
|
||||
@@ -107,17 +107,18 @@
|
||||
const arrivalTimeStr = cells[1].textContent;
|
||||
const departureTimeStr = cells[2].textContent !== 'Active' ? cells[2].textContent : '';
|
||||
|
||||
// Parse date and time
|
||||
const arrivalDate = new Date(`${dateStr}T${arrivalTimeStr}`);
|
||||
// For edit form, we need ISO format (YYYY-MM-DD)
|
||||
// Parse the displayed date back to ISO format
|
||||
const entryRow = document.querySelector(`tr[data-entry-id="${entryId}"]`);
|
||||
const isoDate = entryRow.dataset.isoDate || dateStr;
|
||||
|
||||
// Set values in the form
|
||||
document.getElementById('edit-entry-id').value = entryId;
|
||||
document.getElementById('edit-arrival-date').value = dateStr;
|
||||
document.getElementById('edit-arrival-date').value = isoDate;
|
||||
document.getElementById('edit-arrival-time').value = arrivalTimeStr;
|
||||
|
||||
if (departureTimeStr) {
|
||||
const departureDate = new Date(`${dateStr}T${departureTimeStr}`);
|
||||
document.getElementById('edit-departure-date').value = dateStr;
|
||||
document.getElementById('edit-departure-date').value = isoDate;
|
||||
document.getElementById('edit-departure-time').value = departureTimeStr;
|
||||
} else {
|
||||
document.getElementById('edit-departure-date').value = '';
|
||||
|
||||
Reference in New Issue
Block a user