mirror of
https://github.com/sassanix/Warracker.git
synced 2026-04-27 20:11:17 -05:00
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:
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user