Email Change, PWA Support, Tag Management, Credential Security Improvements

This release introduces several key features and improvements

Added:
- Account email change support from Settings with full validation and UI update.
- Progressive Web App (PWA) capability with manifest and service worker integration.
- "Manage Tags" button on the main page to streamline tag management UX.

Changed:
- Improved responsiveness of key pages (index, status) across mobile and tablet views.
- Replaced hardcoded DB credentials with environment variable references (contribution by @humrochagf, commit 20997e9):
  • Credentials are now pulled from Docker Compose env vars.
  • Redundant superuser credential checks removed.
  • Updated migration and permission scripts for dynamic configuration.

This version enhances user flexibility, security, and cross-device compatibility.
This commit is contained in:
sassanix
2025-05-24 12:31:46 -03:00
parent 4e728dca24
commit e4a24482f7
12 changed files with 1519 additions and 110 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## [0.9.9.8] - 2025-05-24
### Added
- **Account Email Change:** Users can now change the email address associated with their account from the Settings page.
- The email field in Account Settings is now editable.
- Client-side and backend validation ensure the new email is valid and not already in use by another account.
- The backend prevents duplicate emails and returns clear error messages for conflicts or invalid formats.
- On successful change, the new email is reflected in the UI and localStorage, and users must use the new email to log in.
- _Files: `frontend/settings-new.html`, `frontend/settings-new.js`, `backend/app.py`_
- **Progressive Web App (PWA) Support:** Enabled the application to be installed on Android devices.
- Added and configured `manifest.json` with necessary properties (name, short_name, icons, start_url, display, orientation, theme_color, background_color, description).
- Implemented a basic service worker (`sw.js`) with a cache-first strategy for core assets (HTML, CSS, JS, manifest, icons) to enable offline access and faster loading.
- Registered the service worker in `script.js`.
- Ensured `index.html` links to the `manifest.json` file.
- Updated icon set in `manifest.json` and `sw.js` to use 16x16, 32x32, and 512x512 favicons.
- _Files: `frontend/manifest.json`, `frontend/sw.js`, `frontend/script.js`, `frontend/index.html`_
- **Manage Tags Button on Index Page:** Added a "Manage Tags" button to the filter controls section on the main warranties page (`index.html`).
- Clicking this button opens the tag management modal, which is now centered on the screen.
- _Files: `frontend/index.html`, `frontend/script.js`, `frontend/style.css`_
### Changed
- **Mobile/Tablet Responsiveness:** Updated `index.html` , `status.html` and associated CSS to improve the layout and usability of grid and list views on mobile and tablet devices.
- _Files: `frontend/index.html`, `frontend/status.html`, `frontend/style.css` , `frontend/mobile-header.css`_
- **Database Credential Handling (Contribution by @humrochagf):** Replaced hardcoded database credentials with environment variable references for improved security and maintainability (Commit: 20997e9).
- Removed hardcoded `db_user`, `db_admin_user`, and `db_admin_password` values.
- Updated Dockerfile to eliminate redundant superuser credential checks.
- Ensured `DB_ADMIN_PASSWORD` is configurable via Docker Compose environment variables, removing manual post-deployment steps.
- Adjusted scripts to rely on dynamic credentials set in the environment.
- _Files: `Dockerfile`, `backend/app.py`, `backend/fix_permissions.py`, `backend/fix_permissions.sql`, `backend/migrations/010_configure_admin_roles.sql`, `backend/migrations/011_ensure_admin_permissions.sql`, `backend/migrations/apply_migrations.py`, `docker-compose.yml`_
## [0.9.9.7] - 2025-05-16
+296 -62
View File
@@ -38,6 +38,10 @@ logger = logging.getLogger(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_secret_key_change_in_production')
app.config['JWT_EXPIRATION_DELTA'] = timedelta(days=7) # Token expiration time
# Email change token settings
EMAIL_CHANGE_TOKEN_EXPIRATION_HOURS = 24 # Token valid for 24 hours
EMAIL_CHANGE_VERIFICATION_ENDPOINT = '/verify-email-change.html' # Frontend page for verification
UPLOAD_FOLDER = '/data/uploads'
ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'zip', 'rar'}
@@ -734,38 +738,6 @@ def request_password_reset():
if conn:
release_db_connection(conn)
@app.route('/api/auth/password/verify-token', methods=['GET'])
def verify_reset_token():
"""Verify if a password reset token is valid and not expired."""
token = request.args.get('token')
if not token:
return jsonify({'valid': False, 'message': 'Token is missing!'}), 400
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute('SELECT expires_at FROM password_reset_tokens WHERE token = %s', (token,))
token_info = cur.fetchone()
if not token_info:
return jsonify({'valid': False, 'message': 'Token not found!'}), 404
expires_at = token_info[0]
if expires_at < datetime.utcnow():
return jsonify({'valid': False, 'message': 'Token has expired!'}), 410 # 410 Gone might be appropriate
# Token is valid and not expired
return jsonify({'valid': True, 'message': 'Token is valid.'}), 200
except Exception as e:
logger.error(f"Token verification error for token {token}: {e}")
return jsonify({'valid': False, 'message': 'Error verifying token!'}), 500
finally:
if conn:
release_db_connection(conn)
# Note: The actual reset_password function is defined earlier in the file.
# The duplicated block that caused the error has been removed.
@@ -1365,7 +1337,6 @@ def update_warranty(warranty_id):
sql_values.append(db_manual_path)
elif 'delete_manual' in request.form and request.form.get('delete_manual', 'false').lower() == 'true':
sql_fields.append("manual_path = NULL")
if db_other_document_path is not None:
sql_fields.append("other_document_path = %s")
sql_values.append(db_other_document_path)
@@ -1620,44 +1591,66 @@ def update_profile():
try:
# Get request data
data = request.get_json()
if not data:
return jsonify({'message': 'No input data provided'}), 400
# Extract fields
first_name = data.get('first_name', '').strip()
last_name = data.get('last_name', '').strip()
new_email = data.get('email', '').strip()
# Validate input
if not first_name or not last_name:
return jsonify({'message': 'First name and last name are required'}), 400
# Get database connection
conn = get_db_connection()
cursor = conn.cursor()
try:
# Update user profile
cursor.execute(
"""
update_fields = ["first_name = %s", "last_name = %s"]
update_params = [first_name, last_name]
# Handle email change
if new_email:
if not is_valid_email(new_email):
conn.rollback()
return jsonify({'message': 'Invalid email format!'}), 400
# Check if the new email is different from the current one
cursor.execute("SELECT email FROM users WHERE id = %s", (user_id,))
current_email_tuple = cursor.fetchone()
if not current_email_tuple:
conn.rollback()
return jsonify({'message': 'User not found while fetching current email.'}), 404
current_email = current_email_tuple[0]
if new_email.lower() != current_email.lower():
# Check if the new email is already in use by another user
cursor.execute("SELECT id FROM users WHERE LOWER(email) = LOWER(%s) AND id != %s", (new_email, user_id))
if cursor.fetchone():
conn.rollback()
return jsonify({'message': 'This email address is already in use by another account.'}), 409
update_fields.append("email = %s")
update_params.append(new_email)
update_query_string = ", ".join(update_fields)
final_query = f"""
UPDATE users
SET first_name = %s, last_name = %s
SET {update_query_string}
WHERE id = %s
RETURNING id, username, email, first_name, last_name, created_at
""",
(first_name, last_name, user_id)
)
"""
cursor.execute(final_query, tuple(update_params + [user_id]))
# Get updated user data
user_data = cursor.fetchone()
if not user_data:
conn.rollback()
return jsonify({'message': 'User not found'}), 404
# Commit changes
conn.commit()
# Format user data
user = {
'id': user_data[0],
'username': user_data[1],
@@ -1666,9 +1659,8 @@ def update_profile():
'last_name': user_data[4],
'created_at': user_data[5].isoformat() if user_data[5] else None
}
return jsonify(user), 200
except Exception as e:
conn.rollback()
logger.error(f"Database error in update_profile: {str(e)}")
@@ -1676,7 +1668,7 @@ def update_profile():
finally:
cursor.close()
release_db_connection(conn)
except Exception as e:
logger.error(f"Error in update_profile: {str(e)}")
return jsonify({'message': 'An error occurred while updating profile'}), 500
@@ -3289,18 +3281,17 @@ def update_tag(tag_id):
return jsonify({"error": f"Another tag with this name already exists for your account"}), 409 # Updated error message
# Update the tag
cur.execute('UPDATE tags SET name = %s, color = %s, updated_at = NOW() WHERE id = %s RETURNING id, name, color',
cur.execute('UPDATE tags SET name = %s, color = %s, updated_at = NOW() WHERE id = %s RETURNING id, name, color', \
(new_name, new_color, tag_id))
updated_tag = cur.fetchone()
conn.commit()
return jsonify({"id": updated_tag[0], "name": updated_tag[1], "color": updated_tag[2]}), 200
# conn.commit() and return statement are part of the try block, outside the 'with' block.
conn.commit()
return jsonify({"id": updated_tag[0], "name": updated_tag[1], "color": updated_tag[2]}), 200
except Exception as e:
logger.error(f"Error updating tag {tag_id}: {e}")
if conn:
conn.rollback()
conn.rollback()
return jsonify({"error": "Failed to update tag"}), 500
finally:
if conn:
@@ -3629,7 +3620,7 @@ def import_warranties():
placeholders = ', '.join(['%s'] * len(tag_names))
# Include user_id in the lookup
sql = f"SELECT id, LOWER(name) FROM tags WHERE LOWER(name) IN ({placeholders}) AND user_id = %s"
cur.execute(sql, [name.lower() for name in tag_names] + [user_id]) # Add user_id to params
cur.execute(sql, [name.lower() for name in tag_names] + [user_id]) # Add user_id
existing_tags = {name_lower: tag_id for tag_id, name_lower in cur.fetchall()}
processed_tag_ids = []
@@ -3684,7 +3675,7 @@ def import_warranties():
SELECT id FROM warranties
WHERE user_id = %s AND product_name = %s AND purchase_date = %s
""", (user_id, product_name, purchase_date))
if cur.fetchone():
if cur.fetchone(): # Correctly indented
errors.append("Duplicate warranty found (same product name and purchase date).")
# --- If errors, skip row ---
@@ -3837,3 +3828,246 @@ def reset_password():
finally:
if conn:
release_db_connection(conn)
def send_email_change_verification_email(recipient_email, verification_link_path_with_token):
"""Sends an email with a link to verify the new email address."""
try:
smtp_host = os.environ.get('SMTP_HOST', 'localhost')
smtp_port = int(os.environ.get('SMTP_PORT', 1025))
smtp_username = os.environ.get('SMTP_USERNAME')
smtp_password = os.environ.get('SMTP_PASSWORD')
smtp_use_tls = os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true'
smtp_use_ssl = os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true'
sender_email = os.environ.get('SMTP_SENDER_EMAIL', 'noreply@warracker.com')
app_name = "Warracker" # Or get from config
subject = f"Confirm Your New Email Address - {app_name}"
app_base_url = os.environ.get('APP_BASE_URL', request.url_root.rstrip('/'))
full_verification_link = f"{app_base_url}{verification_link_path_with_token}"
html_content = None
email_template_path = os.path.join(os.path.dirname(__file__), 'templates', 'email_change_verification.html')
if os.path.exists(email_template_path):
with open(email_template_path, 'r') as f:
html_content = f.read()
html_content = html_content.replace("{{verification_link}}", full_verification_link)
html_content = html_content.replace("{{app_name}}", app_name)
html_content = html_content.replace("{{expiration_hours}}", str(EMAIL_CHANGE_TOKEN_EXPIRATION_HOURS))
else:
logger.warning(f"Email template not found: {email_template_path}. Using basic HTML fallback.")
html_content = f""" <html>
<body>
<p>Hello,</p>
<p>Please click the link below to confirm your new email address for {app_name}:</p>
<p><a href="{full_verification_link}">Confirm Email Change</a></p>
<p>If you did not request this change, please ignore this email.</p>
<p>This link will expire in {EMAIL_CHANGE_TOKEN_EXPIRATION_HOURS} hours.</p>
<p>Thanks,<br>The {app_name} Team</p>
</body>
</html>
"""
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = sender_email
msg['To'] = recipient_email
msg.attach(MIMEText(html_content, 'html'))
logger.info(f"Attempting to send email change verification to {recipient_email} via {smtp_host}:{smtp_port}")
if smtp_use_ssl:
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10)
else:
server = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
if smtp_use_tls:
server.starttls()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.sendmail(sender_email, recipient_email, msg.as_string())
server.quit()
logger.info(f"Email change verification sent successfully to {recipient_email}")
return True
except smtplib.SMTPAuthenticationError as e:
logger.error(f"SMTP Authentication Error sending email change verification: {e}")
except Exception as e:
logger.error(f"Error sending email change verification: {e}", exc_info=True)
return False
def generate_secure_token(length=40):
"""Generates a cryptographically secure random string token."""
return uuid.uuid4().hex + uuid.uuid4().hex[:length-len(uuid.uuid4().hex)]
@app.route('/api/auth/change-email', methods=['POST'])
@token_required
def request_email_change():
current_user_id = request.user['id'] # Get user ID from decorator
data = request.get_json()
new_email = data.get('new_email')
password = data.get('password')
if not new_email or not password:
return jsonify({'message': 'New email and password are required'}), 400
if not is_valid_email(new_email):
return jsonify({'message': 'Invalid new email format'}), 400
conn = None
try:
conn = get_db_connection()
cur = conn.cursor()
# Fetch current user's password hash and current email
cur.execute("SELECT password_hash, email FROM users WHERE id = %s", (current_user_id,))
user_data = cur.fetchone()
if not user_data:
return jsonify({'message': 'User not found'}), 404 # Should not happen with @token_required
current_password_hash, current_email = user_data
if not bcrypt.check_password_hash(current_password_hash, password):
return jsonify({'message': 'Incorrect password'}), 401
if new_email.lower() == current_email.lower():
return jsonify({'message': 'New email cannot be the same as the current email'}), 400
# Check if the new email is already in use by another user
cur.execute("SELECT id FROM users WHERE email = %s AND id != %s", (new_email.lower(), current_user_id))
if cur.fetchone():
return jsonify({'message': 'This email address is already in use by another account.'}), 409
# Check for an existing, non-expired, non-used request for this user and new email
cur.execute("""
SELECT id, token_expires_at FROM email_change_requests
WHERE user_id = %s AND new_email = %s AND is_used = FALSE AND token_expires_at > NOW()
""", (current_user_id, new_email.lower()))
existing_request = cur.fetchone()
if existing_request:
# Potentially resend email if requested soon after, or just inform user
return jsonify({'message': 'An email change request for this address is already pending. Please check your inbox.'}), 409
# Invalidate any older, unused tokens for this user to prevent multiple active links
cur.execute("UPDATE email_change_requests SET is_used = TRUE, token_expires_at = NOW() WHERE user_id = %s AND is_used = FALSE", (current_user_id,))
verification_token = generate_secure_token()
token_expires_at = datetime.utcnow().replace(tzinfo=pytz.utc) + timedelta(hours=EMAIL_CHANGE_TOKEN_EXPIRATION_HOURS)
# Get app base URL from environment or config
app_base_url = os.environ.get('APP_BASE_URL', request.url_root.rstrip('/'))
verification_link = f"{app_base_url}{EMAIL_CHANGE_VERIFICATION_ENDPOINT}?token={verification_token}"
cur.execute("""
INSERT INTO email_change_requests (user_id, new_email, verification_token, token_expires_at)
VALUES (%s, %s, %s, %s)
""", (current_user_id, new_email.lower(), verification_token, token_expires_at))
conn.commit()
if send_email_change_verification_email(new_email, verification_link):
return jsonify({'message': f'Verification email sent to {new_email}. Please check your inbox to confirm.'}), 200
else:
# Rollback if email sending failed, to allow user to try again
# Or, consider if the request should remain, and user can request resend.
# For now, simple rollback.
conn.rollback()
return jsonify({'message': 'Failed to send verification email. Please try again later.'}), 500
except psycopg2.Error as e:
if conn:
conn.rollback()
logger.error(f"Database error during email change request: {e}")
return jsonify({'message': 'Database error processing your request.'}), 500
except Exception as e:
if conn:
conn.rollback()
logger.error(f"Error requesting email change: {e}")
return jsonify({'message': 'An unexpected error occurred.'}), 500
finally:
if conn:
cur.close()
release_db_connection(conn)
@app.route('/api/auth/verify-email-change', methods=['POST'])
# This endpoint might not need @token_required if the verification_token itself is the auth mechanism
# However, if a user is already logged in on the browser where they click the link,
# it might be good to associate, but the primary auth is the token from email.
def verify_email_change():
data = request.get_json()
verification_token = data.get('token')
if not verification_token:
return jsonify({'message': 'Verification token is required'}), 400
conn = None
try:
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT id, user_id, new_email, token_expires_at
FROM email_change_requests
WHERE verification_token = %s AND is_used = FALSE
""", (verification_token,))
request_data = cur.fetchone()
if not request_data:
return jsonify({'message': 'Invalid or expired verification token.'}), 400
req_id, user_id, new_email, token_expires_at = request_data
# Ensure token_expires_at is timezone-aware for comparison
if token_expires_at.tzinfo is None:
token_expires_at = pytz.utc.localize(token_expires_at)
if datetime.utcnow().replace(tzinfo=pytz.utc) > token_expires_at:
# Mark as used/expired anyway
cur.execute("UPDATE email_change_requests SET is_used = TRUE WHERE id = %s", (req_id,))
conn.commit()
return jsonify({'message': 'Verification token has expired.'}), 400
# Check if the new email has been taken by another user *since the request was made*
# This is a rare race condition but good to check.
cur.execute("SELECT id FROM users WHERE email = %s AND id != %s", (new_email, user_id))
if cur.fetchone():
cur.execute("UPDATE email_change_requests SET is_used = TRUE WHERE id = %s", (req_id,)) # Invalidate token
conn.commit()
return jsonify({'message': 'This email address has recently been claimed by another account. Please try a different email.'}), 409
# Update user's email
cur.execute("UPDATE users SET email = %s, updated_at = NOW() WHERE id = %s", (new_email, user_id))
# Mark token as used
cur.execute("UPDATE email_change_requests SET is_used = TRUE WHERE id = %s", (req_id,))
conn.commit()
# Optionally, log the user out of other sessions for security.
# This would require a session management mechanism (e.g., storing session IDs or using JWT blacklisting).
# For now, we will skip this, but it's a good addition for production.
# Optionally, send a notification to the OLD email address that the email has been changed.
# old_email_query = "SELECT email FROM users WHERE id = %s" # This would get new email now
# To get old email, it should have been selected before the update or passed through.
# For simplicity, this step is omitted but recommended.
return jsonify({'message': 'Email address updated successfully.'}), 200
except psycopg2.Error as e:
if conn:
conn.rollback()
logger.error(f"Database error during email verification: {e}")
return jsonify({'message': 'Database error processing your request.'}), 500
except Exception as e:
if conn:
conn.rollback()
logger.error(f"Error verifying email change: {e}")
return jsonify({'message': 'An unexpected error occurred.'}), 500
finally:
if conn:
cur.close()
release_db_connection(conn)
+1 -1
View File
@@ -106,7 +106,7 @@
<div class="about-container" style="background-color: var(--card-bg); color: var(--text-color); padding: 20px; border-radius: 8px; margin-top: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); width: 100%; box-sizing: border-box;">
<h1 style="color: var(--primary-color); margin-bottom: 20px;">About Warracker</h1>
<p><strong>Version:</strong> v0.9.9.7</p>
<p><strong>Version:</strong> v0.9.9.8</p>
<div id="versionTracker" style="margin-bottom: 20px;">
<p><strong>Update Status:</strong> <span id="updateStatus">Checking for updates...</span></p>
+19 -12
View File
@@ -166,20 +166,28 @@
</div>
<!-- Filters and Search -->
<div class="filter-controls">
<div class="search-container">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="searchWarranties" placeholder="Find by name, notes, tag, or serial number...">
<div class="search-container" style="display: flex; width: 100%; gap: 8px; align-items: center; flex-wrap: nowrap;">
<!-- Make search box much longer, buttons only as wide as needed -->
<div class="search-box" style="flex: 2 1 400px; min-width: 200px; max-width: 1000px;">
<button id="searchBtn" class="search-btn" title="Search">
<i class="fas fa-search"></i>
</button>
<input type="text" id="searchWarranties" placeholder="Find by name, vendors, notes, tag, or serial number..." style="width: 100%; min-width: 200px;">
<button id="clearSearch" class="clear-search-btn" title="Clear search" style="display: none;">
<i class="fas fa-times"></i>
</button>
</div>
<button id="exportBtn" class="export-btn" title="Export warranties">
<i class="fas fa-file-export"></i> Export
</button>
<button id="importBtn" class="import-btn" title="Import warranties from CSV">
<i class="fas fa-file-import"></i> Import
</button>
<div style="display: flex; gap: 8px; flex-wrap: nowrap; flex-shrink: 0; min-width: 0;">
<button id="exportBtn" class="export-btn" title="Export warranties" style="white-space: nowrap;">
<i class="fas fa-file-export"></i> Export
</button>
<button id="importBtn" class="import-btn" title="Import warranties from CSV" style="white-space: nowrap;">
<i class="fas fa-file-import"></i> Import
</button>
<button id="globalManageTagsBtn" class="export-btn" title="Manage all tags" style="white-space: nowrap;">
<i class="fas fa-tags"></i> Manage Tags
</button>
</div>
<input type="file" id="csvFileInput" accept=".csv" style="display: none;">
</div>
@@ -248,9 +256,8 @@
<th>Actions</th>
</tr>
</div>
<!-- Warranties will be loaded here -->
<div class="empty-state">
<div class="empty-state" style="display: none;">
<i class="fas fa-box-open"></i>
<h3>No warranties yet</h3>
<p>Add your first warranty to get started</p>
+12
View File
@@ -1,7 +1,18 @@
{
"name": "Warracker",
"short_name": "Warracker",
"description": "Warracker - Warranty Tracker",
"icons": [
{
"src": "img/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "img/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "img/favicon-512x512.png",
"sizes": "512x512",
@@ -10,6 +21,7 @@
],
"start_url": "./index.html",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#ffffff"
}
+650 -1
View File
@@ -1,11 +1,22 @@
@media (max-width: 768px) {
/* Header Responsive Styles */
/* Header Responsive Styles with increased specificity */
header .container {
flex-wrap: wrap !important; /* Allow header items to wrap */
justify-content: space-between !important; /* Push title left, right-group right */
align-items: center !important; /* Vertically align items in the top row */
gap: 10px 15px !important; /* Add space between wrapped items */
padding: 10px 15px !important; /* Adjust padding */
/* Ensure theme variables are applied */
background-color: var(--card-bg) !important;
color: var(--text-color) !important;
}
header {
/* Ensure header inherits theme variables on mobile */
background-color: var(--card-bg) !important;
box-shadow: var(--shadow) !important;
padding: 20px 0 !important;
margin-bottom: 30px !important;
}
header .app-title {
@@ -17,11 +28,13 @@
header .app-title h1 {
font-size: 1.8rem !important; /* Slightly reduce title size */
margin: 0 auto !important; /* Center title text if needed */
color: var(--primary-color) !important; /* Ensure theme color */
}
header .app-title i {
font-size: 1.6rem !important; /* Slightly reduce icon size */
margin-right: 10px !important;
color: var(--primary-color) !important; /* Ensure theme color */
/* display: none; /* Optionally hide icon on very small screens */
}
@@ -44,6 +57,37 @@
gap: 15px !important;
}
/* Navigation link styling for mobile */
header .nav-link {
color: var(--text-color) !important;
text-decoration: none !important;
padding: 8px 12px !important;
border-radius: 4px !important;
transition: background-color 0.3s !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
header .nav-link:hover {
background-color: rgba(0, 0, 0, 0.05) !important;
}
/* Dark mode hover for nav links */
:root[data-theme="dark"] header .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
header .nav-link.active {
background-color: rgba(0, 0, 0, 0.05) !important;
font-weight: 600 !important;
}
/* Dark mode active nav link */
:root[data-theme="dark"] header .nav-link.active {
background-color: rgba(255, 255, 255, 0.1) !important;
}
/* Override specific margins from header-fix.css */
header .auth-buttons,
header .user-menu,
@@ -51,4 +95,609 @@
margin: 0 5px !important; /* Override margin-left from header-fix.css */
flex-shrink: 0 !important; /* Prevent shrinking */
}
/* Auth button styling for mobile with theme support */
header .auth-btn {
padding: 5px 15px !important;
border-radius: 20px !important;
font-size: 0.9rem !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
transition: all 0.3s ease !important;
text-decoration: none !important;
}
header .login-btn {
background-color: transparent !important;
border: 1px solid var(--primary-color) !important;
color: var(--primary-color) !important;
}
header .login-btn:hover {
background-color: rgba(0, 0, 0, 0.05) !important;
}
/* Dark mode login button hover */
:root[data-theme="dark"] header .login-btn:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
header .register-btn {
background-color: var(--primary-color) !important;
border: 1px solid var(--primary-color) !important;
color: white !important;
}
header .register-btn:hover {
background-color: var(--primary-dark) !important;
border-color: var(--primary-dark) !important;
}
/* User menu styling for mobile */
header .user-btn {
background: none !important;
border: none !important;
color: var(--text-color) !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
font-size: 0.9rem !important;
padding: 5px 10px !important;
border-radius: 20px !important;
transition: background-color 0.3s !important;
}
header .user-btn:hover {
background-color: rgba(0, 0, 0, 0.05) !important;
}
/* Dark mode user button hover */
:root[data-theme="dark"] header .user-btn:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
/* User menu dropdown for mobile */
header .user-menu-dropdown {
position: absolute !important;
top: 100% !important;
right: 0 !important;
background-color: var(--card-bg) !important;
border-radius: 8px !important;
box-shadow: 0 4px 12px var(--shadow-color) !important;
width: 200px !important;
z-index: 1000 !important;
display: none !important;
padding: 10px 0 !important;
margin-top: 5px !important;
border: 1px solid var(--border-color) !important;
}
header .user-menu-dropdown.active {
display: block !important;
}
header .user-info {
padding: 10px 15px !important;
border-bottom: 1px solid var(--border-color) !important;
margin-bottom: 5px !important;
}
header .user-name {
font-weight: bold !important;
margin-bottom: 5px !important;
color: var(--text-color) !important;
}
header .user-email {
font-size: 0.8rem !important;
color: var(--dark-gray) !important;
word-break: break-all !important;
}
header .user-menu-item {
padding: 8px 15px !important;
cursor: pointer !important;
transition: background-color 0.3s !important;
display: flex !important;
align-items: center !important;
color: var(--text-color) !important;
}
header .user-menu-item:hover {
background-color: rgba(0, 0, 0, 0.05) !important;
}
/* Dark mode user menu item hover */
:root[data-theme="dark"] header .user-menu-item:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
header .user-menu-item i {
margin-right: 10px !important;
width: 16px !important;
text-align: center !important;
color: var(--text-color) !important;
}
/* Settings container for mobile */
header .settings-container {
position: relative !important;
margin-left: 10px !important;
}
header .settings-btn {
background: none !important;
border: none !important;
color: var(--text-color) !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 1.2rem !important;
padding: 8px !important;
border-radius: 50% !important;
transition: background-color 0.3s !important;
width: 40px !important;
height: 40px !important;
}
header .settings-btn:hover {
background-color: rgba(0, 0, 0, 0.05) !important;
}
/* Dark mode settings button hover */
:root[data-theme="dark"] header .settings-btn:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
/* ===== MOBILE SEARCH CONTAINER AND ACTION BUTTONS ===== */
/* Override the default mobile stacking behavior from style.css */
.search-container {
display: flex !important;
flex-direction: row !important; /* Keep horizontal layout */
width: 100% !important;
gap: 8px !important;
align-items: center !important;
flex-wrap: wrap !important; /* Allow wrapping when needed */
}
/* Search box takes most of the width */
.search-container .search-box {
flex: 1 1 250px !important; /* Flexible, min 250px */
min-width: 250px !important;
max-width: none !important;
}
/* Button container stays on the right */
.search-container > div:last-child {
display: flex !important;
gap: 6px !important;
flex-wrap: nowrap !important;
flex-shrink: 0 !important;
min-width: 0 !important;
}
/* Action buttons styling for mobile */
.search-container .export-btn,
.search-container .import-btn,
.search-container #globalManageTagsBtn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 4px !important;
padding: 8px 12px !important;
border-radius: var(--border-radius) !important;
border: none !important;
background-color: var(--primary-color) !important;
color: white !important;
font-size: 0.85rem !important;
font-weight: 500 !important;
cursor: pointer !important;
transition: var(--transition) !important;
height: 36px !important;
white-space: nowrap !important;
min-width: auto !important;
width: auto !important;
max-width: none !important;
flex-shrink: 0 !important;
}
/* Import button and manage tags button get secondary styling (green) */
.search-container .import-btn,
.search-container #globalManageTagsBtn {
background-color: var(--secondary-color) !important;
}
/* Hover states for action buttons */
.search-container .export-btn:hover {
background-color: var(--primary-dark) !important;
transform: none !important; /* Remove transform for mobile */
}
.search-container .import-btn:hover,
.search-container #globalManageTagsBtn:hover {
background-color: #27ae60 !important; /* Darker green for secondary */
}
/* Icon spacing in action buttons */
.search-container .export-btn i,
.search-container .import-btn i,
.search-container #globalManageTagsBtn i {
font-size: 0.8rem !important;
margin-right: 4px !important;
}
/* Dark mode styles for action buttons */
:root[data-theme="dark"] .search-container .export-btn {
background-color: var(--primary-color) !important;
color: white !important;
}
:root[data-theme="dark"] .search-container .import-btn,
:root[data-theme="dark"] .search-container #globalManageTagsBtn {
background-color: var(--secondary-color) !important;
color: white !important;
}
:root[data-theme="dark"] .search-container .export-btn:hover {
background-color: var(--primary-dark) !important;
}
:root[data-theme="dark"] .search-container .import-btn:hover,
:root[data-theme="dark"] .search-container #globalManageTagsBtn:hover {
background-color: #27ae60 !important;
}
/* Very small screens - stack the buttons below search */
@media (max-width: 480px) {
.search-container {
flex-direction: column !important;
align-items: stretch !important;
gap: 10px !important;
}
.search-container .search-box {
width: 100% !important;
min-width: unset !important;
flex: none !important;
}
.search-container > div:last-child {
width: 100% !important;
justify-content: space-between !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
.search-container .export-btn,
.search-container .import-btn,
.search-container #globalManageTagsBtn {
flex: 1 1 calc(33.333% - 5px) !important;
min-width: 90px !important;
padding: 10px 8px !important;
font-size: 0.8rem !important;
}
}
/* ===== MOBILE STATUS DASHBOARD ===== */
/* Status cards 2x2 grid layout for mobile */
.status-cards {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
gap: 15px !important;
margin-bottom: 25px !important;
}
/* Individual status card mobile optimization */
.status-card {
background-color: var(--card-bg) !important;
border-radius: 8px !important;
padding: 15px !important;
box-shadow: var(--shadow) !important;
display: flex !important;
align-items: center !important;
transition: transform 0.3s, box-shadow 0.3s !important;
border: 1px solid var(--border-color) !important;
min-height: 80px !important;
}
.status-card:hover {
transform: translateY(-2px) !important; /* Reduced for mobile */
box-shadow: 0 4px 12px var(--shadow-color) !important;
}
/* Card icon sizing for mobile */
.status-card .card-icon {
font-size: 2rem !important; /* Slightly smaller for mobile */
margin-right: 12px !important;
flex-shrink: 0 !important;
}
/* Card content text sizing for mobile */
.status-card .card-content h3 {
margin: 0 0 4px 0 !important;
font-size: 0.85rem !important; /* Smaller text */
color: var(--dark-gray) !important;
line-height: 1.2 !important;
}
.status-card .card-content .count {
font-size: 1.6rem !important; /* Slightly smaller number */
font-weight: 700 !important;
margin: 0 !important;
color: var(--text-color) !important;
line-height: 1 !important;
}
/* Ensure proper theme colors for status card icons */
.status-card[data-status="active"] .card-icon {
color: #4CAF50 !important;
}
.status-card[data-status="expiring"] .card-icon {
color: #FF9800 !important;
}
.status-card[data-status="expired"] .card-icon {
color: #F44336 !important;
}
.status-card[data-status="total"] .card-icon {
color: var(--primary-color) !important;
}
/* Dark mode adjustments for better visibility */
:root[data-theme="dark"] .status-card {
background-color: var(--card-bg) !important;
border-color: var(--border-color) !important;
box-shadow: var(--shadow) !important;
}
:root[data-theme="dark"] .status-card:hover {
box-shadow: 0 4px 12px var(--shadow-color) !important;
}
:root[data-theme="dark"] .status-card .card-content h3 {
color: var(--dark-gray) !important;
}
:root[data-theme="dark"] .status-card .card-content .count {
color: var(--text-color) !important;
}
}
/* ===== PHONE-SPECIFIC STATUS CARD FIXES ===== */
/* iPhone SE, iPhone 12/13 mini: 375px */
@media (max-width: 375px) and (min-width: 361px) {
.status-content .status-cards,
body .status-cards,
.container .status-cards,
.status-cards {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
gap: 10px !important;
margin-bottom: 20px !important;
}
.status-content .status-card,
body .status-card,
.container .status-card,
.status-card {
background-color: var(--card-bg) !important;
border-radius: 6px !important;
padding: 10px !important;
box-shadow: var(--shadow) !important;
display: flex !important;
align-items: center !important;
min-height: 65px !important;
}
.status-card .card-icon {
font-size: 1.6rem !important;
margin-right: 8px !important;
}
.status-card .card-content h3 {
font-size: 0.75rem !important;
margin: 0 0 2px 0 !important;
}
.status-card .card-content .count {
font-size: 1.3rem !important;
}
}
/* iPhone 12/13/14, most Android phones: 390px-414px */
@media (max-width: 414px) and (min-width: 376px) {
.status-content .status-cards,
body .status-cards,
.container .status-cards,
.status-cards {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
gap: 12px !important;
margin-bottom: 20px !important;
}
.status-content .status-card,
body .status-card,
.container .status-card,
.status-card {
background-color: var(--card-bg) !important;
border-radius: 8px !important;
padding: 12px !important;
box-shadow: var(--shadow) !important;
display: flex !important;
align-items: center !important;
min-height: 70px !important;
}
.status-card .card-icon {
font-size: 1.8rem !important;
margin-right: 10px !important;
}
.status-card .card-content h3 {
font-size: 0.8rem !important;
margin: 0 0 3px 0 !important;
}
.status-card .card-content .count {
font-size: 1.4rem !important;
}
}
/* iPhone 14 Plus and larger phones: 428px+ */
@media (max-width: 480px) and (min-width: 415px) {
.status-content .status-cards,
body .status-cards,
.container .status-cards,
.status-cards {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
gap: 15px !important;
margin-bottom: 25px !important;
}
.status-content .status-card,
body .status-card,
.container .status-card,
.status-card {
background-color: var(--card-bg) !important;
border-radius: 8px !important;
padding: 15px !important;
box-shadow: var(--shadow) !important;
display: flex !important;
align-items: center !important;
min-height: 75px !important;
}
.status-card .card-icon {
font-size: 2rem !important;
margin-right: 12px !important;
}
.status-card .card-content h3 {
font-size: 0.85rem !important;
margin: 0 0 4px 0 !important;
}
.status-card .card-content .count {
font-size: 1.5rem !important;
}
}
/* ===== SPECIFIC FIX FOR 425PX SCREENS ===== */
@media (max-width: 425px) and (min-width: 361px) {
/* Force 2x2 grid layout for 425px screens with higher specificity */
.status-content .status-cards,
body .status-cards,
.container .status-cards {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
gap: 12px !important;
margin-bottom: 20px !important;
}
/* Compact status cards for 425px */
.status-content .status-card,
body .status-card,
.container .status-card {
background-color: var(--card-bg) !important;
border-radius: 8px !important;
padding: 12px !important;
box-shadow: var(--shadow) !important;
display: flex !important;
align-items: center !important;
transition: transform 0.3s, box-shadow 0.3s !important;
border: 1px solid var(--border-color) !important;
min-height: 70px !important;
}
/* Icon and text sizing for 425px */
.status-card .card-icon {
font-size: 1.8rem !important;
margin-right: 10px !important;
flex-shrink: 0 !important;
}
.status-card .card-content h3 {
font-size: 0.8rem !important;
margin: 0 0 3px 0 !important;
color: var(--dark-gray) !important;
line-height: 1.2 !important;
}
.status-card .card-content .count {
font-size: 1.4rem !important;
font-weight: 700 !important;
margin: 0 !important;
color: var(--text-color) !important;
line-height: 1 !important;
}
}
/* ===== EXTENDED MOBILE SUPPORT FOR 425PX AND SMALLER ===== */
@media (max-width: 480px) {
/* Override the default single-column behavior from style.css for status cards */
/* Keep the 2x2 grid layout down to 375px for better organization */
.status-cards {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
gap: 12px !important; /* Slightly tighter spacing */
margin-bottom: 20px !important;
}
/* Adjust status cards for smaller screens (425px and below) */
.status-card {
padding: 12px !important; /* Reduced padding for smaller screens */
min-height: 70px !important; /* Slightly shorter cards */
}
/* Smaller icon and text for very compact cards */
.status-card .card-icon {
font-size: 1.8rem !important; /* Smaller icon */
margin-right: 10px !important; /* Tighter spacing */
}
.status-card .card-content h3 {
font-size: 0.8rem !important; /* Even smaller label text */
margin: 0 0 3px 0 !important;
}
.status-card .card-content .count {
font-size: 1.4rem !important; /* Smaller but still prominent numbers */
}
}
/* ===== VERY SMALL SCREENS (360px and below) ===== */
@media (max-width: 360px) {
/* Only at very small screens, switch to single column */
.status-cards {
grid-template-columns: 1fr !important;
gap: 10px !important;
}
/* Full-width cards for tiny screens */
.status-card {
padding: 15px !important; /* Back to more padding since we have full width */
min-height: 60px !important;
}
.status-card .card-icon {
font-size: 1.6rem !important;
margin-right: 12px !important;
}
.status-card .card-content h3 {
font-size: 0.85rem !important;
}
.status-card .card-content .count {
font-size: 1.5rem !important;
}
}
+87 -2
View File
@@ -131,6 +131,49 @@ function setTheme(isDark) {
// Initialization logic on DOMContentLoaded
document.addEventListener('DOMContentLoaded', function() {
// Register Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
.then(registration => {
console.log('Service Worker registered with scope:', registration.scope);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
});
}
// --- Search button click triggers search ---
const searchBtn = document.getElementById('searchBtn');
const searchInput = document.getElementById('searchWarranties');
if (searchBtn && searchInput) {
searchBtn.addEventListener('click', function(e) {
e.preventDefault();
currentFilters.search = searchInput.value.toLowerCase();
applyFilters();
});
}
// --- Ensure globalManageTagsBtn triggers modal and tag form is always initialized ---
// (Redundant with setupUIEventListeners, but ensures modal is always ready)
const globalManageTagsBtn = document.getElementById('globalManageTagsBtn');
if (globalManageTagsBtn) {
globalManageTagsBtn.addEventListener('click', async () => {
if (!allTags || allTags.length === 0) {
showLoadingSpinner();
try {
await loadTags();
} catch (error) {
console.error("Failed to load tags before opening modal:", error);
showToast("Could not load tags. Please try again.", "error");
hideLoadingSpinner();
return;
}
hideLoadingSpinner();
}
openTagManagementModal();
});
}
console.log('[DEBUG] Registering authStateReady event handler');
// ... other initialization ...
@@ -147,7 +190,29 @@ document.addEventListener('DOMContentLoaded', function() {
// For simplicity, assuming DOMContentLoaded runs once per page load.
globalNewTagForm.addEventListener('submit', (e) => {
e.preventDefault();
createNewTag();
// Inline implementation for creating a new tag from the modal
const tagNameInput = document.getElementById('newTagName');
const tagColorInput = document.getElementById('newTagColor');
const name = tagNameInput ? tagNameInput.value.trim() : '';
const color = tagColorInput ? tagColorInput.value : '#808080';
if (!name) {
showToast('Tag name is required', 'error');
return;
}
// Use the existing createTag function if available
if (typeof createTag === 'function') {
createTag(name, color)
.then(() => {
if (tagNameInput) tagNameInput.value = '';
if (tagColorInput) tagColorInput.value = '#808080';
renderExistingTags && renderExistingTags();
})
.catch((err) => {
showToast((err && err.message) || 'Failed to create tag', 'error');
});
} else {
showToast('Tag creation function not found', 'error');
}
});
}
@@ -2934,7 +2999,7 @@ function openTagManagementModal() {
renderExistingTags();
// Show modal
tagManagementModal.style.display = 'block';
tagManagementModal.classList.add('active');
}
// Render existing tags in the management modal
@@ -3152,6 +3217,26 @@ function deleteTag(id) {
// Set up event listeners for UI controls
function setupUIEventListeners() {
// --- Global Manage Tags Button ---
const globalManageTagsBtn = document.getElementById('globalManageTagsBtn');
if (globalManageTagsBtn) {
globalManageTagsBtn.addEventListener('click', async () => {
// Ensure allTags are loaded before opening the modal
if (!allTags || allTags.length === 0) {
showLoadingSpinner();
try {
await loadTags();
} catch (error) {
console.error("Failed to load tags before opening modal:", error);
showToast("Could not load tags. Please try again.", "error");
hideLoadingSpinner();
return;
}
hideLoadingSpinner();
}
openTagManagementModal();
});
}
// Initialize edit tabs
initEditTabs();
+1 -1
View File
@@ -165,7 +165,7 @@
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" class="form-control" disabled>
<input type="email" id="email" name="email" class="form-control">
</div>
<button type="button" id="saveProfileBtn" class="btn btn-primary">Save Changes</button>
+23 -11
View File
@@ -909,13 +909,25 @@ function resetPasswordForm() {
*/
async function saveProfile() {
// Validate form
if (!firstNameInput || !lastNameInput || !firstNameInput.value.trim() || !lastNameInput.value.trim()) { // Add null checks for inputs
if (!firstNameInput || !lastNameInput || !firstNameInput.value.trim() || !lastNameInput.value.trim()) {
showToast('Please fill in First Name and Last Name', 'error');
return;
}
// Get the new email value
const newEmail = emailInput.value.trim();
if (!newEmail) {
showToast('Email address cannot be empty.', 'error');
return;
}
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(newEmail)) {
showToast('Please enter a valid email address.', 'error');
return;
}
showLoading();
// Get the display element
const userNameDisplay = document.getElementById('currentUserNameDisplay');
try {
@@ -927,28 +939,29 @@ async function saveProfile() {
},
body: JSON.stringify({
first_name: firstNameInput.value.trim(),
last_name: lastNameInput.value.trim()
last_name: lastNameInput.value.trim(),
email: newEmail
})
});
if (response.ok) {
const userData = await response.json();
// Update localStorage
const currentUser = window.auth.getCurrentUser();
let first_name = userData.first_name;
let last_name = userData.last_name;
if (!last_name) first_name = '';
const updatedUser = {
...(currentUser || {}), // Handle case where currentUser might be null
...(currentUser || {}),
first_name,
last_name,
// Ensure email and username are preserved if they existed
email: currentUser ? currentUser.email : userData.email,
email: userData.email, // Use the email returned from the backend
username: currentUser ? currentUser.username : userData.username,
is_admin: currentUser ? currentUser.is_admin : userData.is_admin,
id: currentUser ? currentUser.id : userData.id
};
// Update the email input field with the (potentially new) email from the backend
if (emailInput) emailInput.value = userData.email || '';
localStorage.setItem('user_info', JSON.stringify(updatedUser));
// --- UPDATE DISPLAY ELEMENT IMMEDIATELY ---
@@ -963,16 +976,15 @@ async function saveProfile() {
// Update UI (Header, etc.) - Ensure auth module is loaded
if (window.auth && window.auth.checkAuthState) {
window.auth.checkAuthState(); // This should update the header menu
window.auth.checkAuthState();
} else {
console.warn("Auth module or checkAuthState not found, header might not update immediately.");
console.warn("Auth module or checkAuthState not found, header might not update immediately.");
}
showToast('Profile updated successfully', 'success');
} else {
const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response' }));
throw new Error(errorData.message || 'Failed to update profile');
throw new Error(errorData.message || 'Failed to update profile');
}
} catch (error) {
console.error('Error updating profile:', error);
+348 -19
View File
@@ -782,18 +782,49 @@ input.invalid {
display: flex; /* Shown when active */
}
.modal {
background-color: var(--modal-background, #ffffff);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 600px;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: slide-in 0.3s ease;
position: relative;
overflow: hidden; /* Changed from auto to hidden */
/* Specific override for tag management modal to ensure proper centering */
#tagManagementModal.modal-backdrop {
display: none;
justify-content: center !important;
align-items: center !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
background-color: rgba(0, 0, 0, 0.5) !important;
z-index: 1050 !important;
}
#tagManagementModal.modal-backdrop.active {
display: flex !important;
}
/* Ensure the inner modal content is properly sized */
#tagManagementModal .modal {
background-color: var(--modal-background, #ffffff) !important;
border-radius: 8px !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important;
width: 90% !important;
max-width: 600px !important;
max-height: 90vh !important;
display: flex !important;
flex-direction: column !important;
animation: slide-in 0.3s ease !important;
position: relative !important;
overflow: hidden !important;
margin: 0 !important; /* Remove any conflicting margins */
}
/* Override conflicting styles from styles.css for tag management modal */
#tagManagementModal.modal-backdrop .modal {
position: relative !important; /* Override fixed positioning */
top: auto !important; /* Remove top positioning */
left: auto !important; /* Remove left positioning */
width: 90% !important; /* Set proper width */
height: auto !important; /* Set auto height */
background-color: var(--modal-background, #ffffff) !important;
margin: 0 !important; /* Remove conflicting margins */
}
@keyframes slide-in {
@@ -1861,15 +1892,143 @@ input.invalid {
border: 1px solid var(--border-color);
}
/* --- Improved search bar and dropdown spacing --- */
.search-container {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
justify-content: flex-start;
}
.search-box {
flex: 2 1 320px;
display: flex;
align-items: center;
background-color: var(--input-background, #f5f5f5);
border: 1.5px solid var(--border-color);
border-radius: 25px;
padding: 0 16px;
height: 44px;
box-shadow: none;
transition: border-color 0.2s, box-shadow 0.2s;
min-width: 220px;
max-width: 500px;
}
.search-box:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.10);
}
.search-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.1rem;
margin-right: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 4px;
transition: color 0.2s;
}
.search-btn:hover {
color: var(--primary-color);
}
.search-box input {
border: none;
outline: none;
background: none;
width: 100%;
color: var(--text-color);
font-size: 1rem;
padding: 8px 0;
}
.clear-search-btn {
background: none;
border: none;
outline: none;
cursor: pointer;
color: var(--text-muted);
font-size: 1.1rem;
margin-left: 6px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 4px;
transition: color 0.2s;
}
.clear-search-btn:hover {
color: var(--primary-color);
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 18px 24px;
align-items: center;
margin-top: 0;
margin-bottom: 0;
}
.filter-group {
display: flex;
flex-direction: column;
flex: 1 1 160px;
min-width: 140px;
max-width: 220px;
}
@media (max-width: 900px) {
.filter-options {
gap: 12px 8px;
}
.filter-group {
min-width: 120px;
max-width: 100%;
}
}
@media (max-width: 768px) {
.search-container {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.search-box {
min-width: 0;
max-width: none;
width: 100%;
}
.filter-options {
flex-direction: column;
gap: 10px 0;
}
.filter-group {
width: 100%;
min-width: 0;
max-width: none;
}
}
.search-container {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
justify-content: flex-start;
}
.search-container .search-box {
flex: 1;
flex: 1 1 220px;
}
.search-container .export-btn {
@@ -1879,6 +2038,38 @@ input.invalid {
flex-shrink: 0;
}
/* Style for the global Manage Tags button to match Export/Import */
#globalManageTagsBtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 15px;
border-radius: var(--border-radius);
border: none;
background-color: var(--secondary-color);
color: white;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
height: 40px;
align-self: center;
margin-top: 0;
min-width: 120px;
max-width: none;
white-space: nowrap;
}
#globalManageTagsBtn:hover {
background-color: #27ae60;
transform: translateY(-1px);
}
#globalManageTagsBtn i {
font-size: 0.9rem;
}
/* Override any conflicting search box styles */
.filter-controls .search-box {
position: relative;
@@ -1905,9 +2096,20 @@ input.invalid {
outline: none;
background: none;
width: 100%;
color: var(--text-color);
font-size: 0.95rem;
padding: 2px 0;
}
/* Responsive: Make search/btns stack on mobile */
@media (max-width: 768px) {
.search-container {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
#globalManageTagsBtn, .search-container .export-btn, .search-container .import-btn {
width: 100%;
min-width: 0;
max-width: none;
}
}
.filter-options {
@@ -2038,6 +2240,8 @@ input.invalid {
.view-switcher {
display: flex;
flex-direction: column;
flex-grow: 1; /* Allow this to take available space */
min-width: 120px; /* Minimum width for the 3 buttons */
}
.view-buttons {
@@ -3098,7 +3302,7 @@ input.invalid {
header .app-title i {
font-size: 1.6rem !important; /* Slightly reduce icon size */
margin-right: 10px !important;
margin-right: 10px !important; /* Slightly reduce icon size */
}
header .nav-links {
@@ -3347,3 +3551,128 @@ input.invalid {
[data-theme="dark"] .text-success .view-document-link:focus {
color: var(--primary-light);
}
.modal {
background-color: var(--modal-background, #ffffff);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 600px;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: slide-in 0.3s ease;
position: relative;
overflow: hidden; /* Changed from auto to hidden */
}
:root[data-theme="dark"] .modal-backdrop {
background-color: rgba(0, 0, 0, 0.7);
}
/* Dark mode styles for tag management modal */
:root[data-theme="dark"] #tagManagementModal .modal {
background-color: var(--modal-background) !important;
}
:root[data-theme="dark"] #tagManagementModal .modal-header,
:root[data-theme="dark"] #tagManagementModal .modal-body,
:root[data-theme="dark"] #tagManagementModal .modal-footer {
background-color: var(--modal-background) !important;
color: var(--text-color) !important;
}
:root[data-theme="dark"] #tagManagementModal .form-control {
background-color: var(--input-background) !important;
color: var(--text-color) !important;
border-color: var(--border-color) !important;
}
:root[data-theme="dark"] #tagManagementModal .form-control:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.25) !important;
}
:root[data-theme="dark"] #tagManagementModal label,
:root[data-theme="dark"] #tagManagementModal h4 {
color: var(--text-color) !important;
}
:root[data-theme="dark"] #tagManagementModal .existing-tag {
background-color: var(--card-bg) !important;
color: var(--text-color) !important;
}
/* Mobile responsive styles for tag management modal */
@media (max-width: 768px) {
#tagManagementModal.modal-backdrop {
padding: 10px !important;
}
#tagManagementModal .modal {
width: 95% !important;
max-width: none !important;
margin: 0 !important;
}
#tagManagementModal .modal-header,
#tagManagementModal .modal-body,
#tagManagementModal .modal-footer {
padding: 15px !important;
}
#tagManagementModal .tag-form {
flex-direction: column !important;
gap: 10px !important;
}
#tagManagementModal .tag-form input[type="color"] {
width: 100% !important;
height: 40px !important;
}
}
/* Fix CSS Syntax Error - Replace orphaned properties with stronger tag management modal overrides */
/* Override styles.css modal positioning specifically for tag management modal */
#tagManagementModal.modal-backdrop {
display: none !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
background-color: rgba(0, 0, 0, 0.5) !important;
z-index: 1050 !important;
justify-content: center !important;
align-items: center !important;
overflow: auto !important;
}
#tagManagementModal.modal-backdrop.active {
display: flex !important;
}
/* Stronger override to center the tag management modal content */
#tagManagementModal .modal-content,
#tagManagementModal .modal {
position: static !important;
top: auto !important;
left: auto !important;
margin: 0 !important;
width: 90% !important;
max-width: 600px !important;
max-height: 90vh !important;
background-color: var(--modal-background, #ffffff) !important;
border-radius: 8px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
overflow-y: auto !important;
display: flex !important;
flex-direction: column !important;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
+51
View File
@@ -0,0 +1,51 @@
const CACHE_NAME = 'warracker-cache-v1';
const urlsToCache = [
'./',
'./index.html',
'./style.css',
'./script.js',
'./manifest.json',
'./img/favicon-16x16.png',
'./img/favicon-32x32.png',
'./img/favicon-512x512.png'
// Add other important assets here, especially icons declared in manifest.json
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
+1 -1
View File
@@ -1,6 +1,6 @@
// Version checker for Warracker
document.addEventListener('DOMContentLoaded', () => {
const currentVersion = '0.9.9.7'; // Current version of the application
const currentVersion = '0.9.9.8'; // Current version of the application
const updateStatus = document.getElementById('updateStatus');
const updateLink = document.getElementById('updateLink');