| """ |
| Baby Cry AI - Mobile PWA Application |
| With Continuous Learning System |
| """ |
|
|
| from flask import Flask, request, jsonify, render_template_string, send_from_directory, session, redirect, url_for |
| from flask_cors import CORS |
| from werkzeug.security import generate_password_hash, check_password_hash |
| import os |
| import sys |
| import tempfile |
| import shutil |
| import numpy as np |
| import json |
| from datetime import datetime |
| from collections import defaultdict |
| from contextlib import contextmanager |
|
|
| |
| try: |
| from dotenv import load_dotenv |
| |
| env_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env') |
| if os.path.exists(env_path): |
| load_dotenv(env_path) |
| print("โ
Loaded environment variables from .env file") |
| else: |
| |
| load_dotenv() |
| except ImportError: |
| |
| pass |
| except Exception as e: |
| |
| pass |
|
|
| |
| if os.environ.get("OPENAI_API_KEY", "").strip(): |
| print("โ
OPENAI_API_KEY is set (Cloud analysis available)") |
| else: |
| print("โ ๏ธ OPENAI_API_KEY not set (Cloud analysis will fail until you add it to .env and restart)") |
|
|
| |
| SRC_DIR = os.path.dirname(os.path.abspath(__file__)) |
| if SRC_DIR not in sys.path: |
| sys.path.insert(0, SRC_DIR) |
|
|
| from models.baseline_model import BaselineModel |
| from audio_processor import AudioProcessor |
| from feedback_manager import FeedbackManager |
| from model_manager import ModelManager |
| from supabase_storage import SupabaseStorage |
|
|
| try: |
| from openai_audio import predict_via_openai_audio |
| except ImportError: |
| predict_via_openai_audio = None |
|
|
| try: |
| from models.voc2vec_model import predict_from_audio_file as voc2vec_predict, predict_top_k_from_audio_file as voc2vec_predict_top_k, is_available as voc2vec_available |
| except ImportError: |
| try: |
| from voc2vec_model import predict_from_audio_file as voc2vec_predict, predict_top_k_from_audio_file as voc2vec_predict_top_k, is_available as voc2vec_available |
| except ImportError: |
| voc2vec_predict = None |
| voc2vec_predict_top_k = None |
| voc2vec_available = lambda **kw: False |
| |
|
|
| |
|
|
| app = Flask(__name__, static_folder='static') |
| app.secret_key = os.urandom(24) |
| |
| CORS(app, resources={ |
| r"/*": { |
| "origins": "*", |
| "methods": ["GET", "POST", "OPTIONS"], |
| "allow_headers": ["Content-Type", "Authorization"] |
| } |
| }) |
|
|
| |
| |
| |
| audio_processor = AudioProcessor(fast_mode=True) |
|
|
| |
| |
| model = None |
| model_type = "unknown" |
|
|
| |
| try: |
| baseline_model = BaselineModel() |
| if baseline_model.load_model(): |
| model = baseline_model |
| model_type = "random_forest" |
| n_feats = len(baseline_model.expected_feature_names or []) |
| print(f"โ
Loaded Random Forest Model ({n_feats} features)") |
| else: |
| raise Exception("Random Forest model not available") |
| except Exception as e: |
| print(f"โ Random Forest model not available: {e}") |
| print(" Creating untrained model as fallback") |
| model = BaselineModel() |
| model_type = "random_forest" |
|
|
| |
| DATA_DIR = os.environ.get('DATA_DIR', '../data') |
| MODELS_DIR = os.environ.get('MODELS_DIR', '../models') |
| FEEDBACK_DIR = os.environ.get('FEEDBACK_DIR', '../feedback_data') |
|
|
| feedback_manager = FeedbackManager(feedback_dir=FEEDBACK_DIR, data_dir=DATA_DIR) |
| model_manager = ModelManager(models_dir=MODELS_DIR, data_dir=DATA_DIR, feedback_dir=FEEDBACK_DIR) |
|
|
| |
| cry_gate = None |
| try: |
| import joblib as _joblib |
| _gate_path = os.path.join(MODELS_DIR, 'cry_detector.pkl') |
| if os.path.exists(_gate_path): |
| cry_gate = _joblib.load(_gate_path) |
| print(f"โ
Loaded cry/not-cry gate (reject threshold {cry_gate.get('reject_threshold')})") |
| else: |
| print("โ ๏ธ cry_detector.pkl not found โ cry gate disabled") |
| except Exception as _e: |
| print(f"โ ๏ธ Cry gate unavailable: {_e}") |
| cry_gate = None |
|
|
|
|
| def cry_gate_probability(features): |
| """Return p(cry) for a feature dict, or None if the gate can't run.""" |
| if cry_gate is None or features is None: |
| return None |
| try: |
| row = [features.get(n, 0.0) for n in cry_gate['feature_names']] |
| X = cry_gate['scaler'].transform(np.array(row).reshape(1, -1)) |
| return float(cry_gate['model'].predict_proba(X)[0][cry_gate['cry_class_index']]) |
| except Exception: |
| return None |
|
|
| |
| supabase_storage = SupabaseStorage() |
| |
| if supabase_storage.enabled: |
| supabase_storage.ensure_bucket_exists() |
| |
|
|
| |
| class _LocalStubDb: |
| @contextmanager |
| def get_connection(self): |
| class _FakeCursor: |
| def execute(self, *a, **k): pass |
| def fetchone(self): return None |
| class _FakeConn: |
| def cursor(self, cursor_factory=None): return _FakeCursor() |
| def commit(self): pass |
| def rollback(self): pass |
| def close(self): pass |
| yield _FakeConn() |
| def create_user(self, user_data): pass |
| def get_user(self, user_id): return None |
| def get_user_by_email(self, email): return None |
| def get_user_by_phone(self, phone): return None |
| def get_user_by_email_or_phone(self, identifier): return None |
| def get_all_users(self, limit=None, offset=0): return [] |
| def update_user(self, user_id, user_data): return False |
| def delete_user(self, user_id): return False |
| def count_users(self): return 0 |
| def ensure_user_exists(self, user_id): pass |
| def create_prediction(self, prediction_data): pass |
| def count_predictions(self, user_id=None): return 0 |
| def get_predictions_list(self, limit=None, offset=0, user_id=None): return [] |
| def update_prediction_what_helped(self, prediction_id, user_id, what_helped): return False |
| def create_feedback(self, feedback_data): pass |
| def count_feedback(self, is_correct=None): return 0 |
| def get_feedback_list(self, limit=None, offset=0, is_correct=None): return [] |
| def delete_feedback(self, feedback_id): return False |
| def delete_feedback_by_submission_id(self, submission_id): return False |
| def delete_feedbacks_by_date_range(self, start_date, end_date): return 0 |
| def delete_feedbacks_by_user_id(self, user_id): return 0 |
| def get_feedback_by_id(self, feedback_id): return None |
| def create_analytics_event(self, event_data): pass |
| def get_user_stats(self): return {"total_users": 0, "total_predictions": 0} |
| def get_feedback_stats(self): return {"total": 0, "correct": 0, "incorrect": 0} |
| def get_platform_distribution(self): return {} |
| def get_language_distribution(self): return {} |
| def get_user_timeline(self, days=30): return [] |
| def get_feedback_timeline(self, days=30): return [] |
|
|
| |
| SUPABASE_DB_URL = os.environ.get('SUPABASE_DB_URL') |
| db = None |
| DB_INIT_ERROR = None |
| if not SUPABASE_DB_URL: |
| db = _LocalStubDb() |
| DB_INIT_ERROR = 'SUPABASE_DB_URL not set' |
| print("โ ๏ธ Running without Supabase (SUPABASE_DB_URL not set). Predictions/feedback will not be saved.") |
| else: |
| try: |
| from database_supabase import SupabaseDatabase |
| print(f"๐ Attempting to connect to Supabase...") |
| try: |
| from urllib.parse import urlparse |
| parsed = urlparse(SUPABASE_DB_URL) |
| print(f" Host: {parsed.hostname}:{parsed.port or 5432}") |
| except Exception: |
| pass |
| db = SupabaseDatabase() |
| print("โ
Connected to Supabase PostgreSQL database") |
| except Exception as e: |
| error_details = str(e) |
| print(f"โ Failed to connect to Supabase: {error_details}") |
| print("โ ๏ธ Using local stub (predictions/feedback will not be saved). Fix connection and restart to use Supabase.") |
| DB_INIT_ERROR = str(e)[:200] |
| db = _LocalStubDb() |
|
|
| |
| pending_feedback = {} |
|
|
| |
| ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'admin123') |
|
|
| def admin_required(f): |
| """Decorator to require admin authentication""" |
| from functools import wraps |
| @wraps(f) |
| def decorated_function(*args, **kwargs): |
| if not session.get('admin_authenticated', False): |
| return jsonify({'error': 'Admin authentication required'}), 403 |
| return f(*args, **kwargs) |
| return decorated_function |
|
|
| |
|
|
| TRANSLATIONS_DIR = os.path.join(SRC_DIR, 'translations') |
| translations_cache = {} |
|
|
| def load_translations(lang='ar'): |
| """Load translations for a given language""" |
| if lang in translations_cache: |
| return translations_cache[lang] |
| |
| translation_file = os.path.join(TRANSLATIONS_DIR, f'{lang}.json') |
| |
| if not os.path.exists(translation_file): |
| |
| if lang != 'ar': |
| return load_translations('ar') |
| return {} |
| |
| try: |
| with open(translation_file, 'r', encoding='utf-8') as f: |
| translations = json.load(f) |
| translations_cache[lang] = translations |
| return translations |
| except Exception as e: |
| print(f"Error loading translations for {lang}: {e}") |
| return {} |
|
|
| def detect_language(): |
| """Detect user's preferred language""" |
| |
| |
| lang = request.args.get('lang', '').lower() |
| |
| if lang in ['ar', 'fr', 'en']: |
| session['lang'] = lang |
| return lang |
| |
| |
| if 'lang' in session and session['lang'] in ['ar', 'fr', 'en']: |
| return session['lang'] |
| |
| |
| |
| session['lang'] = 'ar' |
| return 'ar' |
|
|
| def t(key, lang=None, **kwargs): |
| """Get translation for a key, with optional formatting""" |
| if lang is None: |
| lang = detect_language() |
| |
| translations = load_translations(lang) |
| |
| |
| keys = key.split('.') |
| value = translations |
| for k in keys: |
| if isinstance(value, dict): |
| value = value.get(k, key) |
| else: |
| value = key |
| break |
| |
| |
| if value == key and lang != 'en': |
| translations_en = load_translations('en') |
| value = translations_en |
| for k in keys: |
| if isinstance(value, dict): |
| value = value.get(k, key) |
| else: |
| value = key |
| break |
| |
| |
| if isinstance(value, str) and kwargs: |
| try: |
| return value.format(**kwargs) |
| except: |
| return value |
| |
| return value if isinstance(value, str) else key |
|
|
| |
| HTML_TEMPLATE = """ |
| <!DOCTYPE html> |
| <html lang="{{LANG}}" dir="{{DIR}}"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <meta name="theme-color" content="#1a1a2e"> |
| <meta name="apple-mobile-web-app-capable" content="yes"> |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| <meta name="apple-mobile-web-app-title" content="ุฅุทู
ุฆู"> |
| <meta name="description" content="ู
ุญูู ุจูุงุก ุงูุฃุทูุงู ุจุงูุฐูุงุก ุงูุงุตุทูุงุนู - ุงููู
ู
ุง ูุญุชุงุฌู ุทููู"> |
| |
| <link rel="manifest" href="/static/manifest.json"> |
| <link rel="apple-touch-icon" href="/static/icons/icon-192.png"> |
| <link rel="icon" type="image/png" href="/static/icons/icon-192.png"> |
| |
| <title>ุฅุทู
ุฆู - Itma'In</title> |
| |
| <!-- API Configuration (must load first) --> |
| <script src="/static/config.js"></script> |
| <!-- Offline Manager (for mobile app) --> |
| <script src="/static/offline-manager.js"></script> |
| <!-- App Analytics (version tracking, error reporting) --> |
| <script src="/static/app-analytics.js"></script> |
| |
| <!-- Fonts: Arabic support + existing fonts --> |
| <link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;500;600;700&family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet"> |
| |
| <!-- intl-tel-input for phone number with country selector --> |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/intl-tel-input@19.5.6/build/css/intlTelInput.css"> |
| <script src="https://cdn.jsdelivr.net/npm/intl-tel-input@19.5.6/build/js/intlTelInput.min.js"></script> |
| |
| <style> |
| :root { |
| --bg-primary: #FFF5F8; |
| --bg-card: #FFFFFF; |
| --accent-pink: #FF6B9D; |
| --accent-pink-soft: #FFB3D1; |
| --accent-blue: #58a6ff; |
| --accent-green: #3fb950; |
| --accent-yellow: #f0c14b; |
| --accent-orange: #f78166; |
| --text-primary: #1a1a2e; |
| --text-secondary: #666666; |
| --border-color: #FFE0E8; |
| --shadow: rgba(255, 107, 157, 0.15); |
| --safe-top: env(safe-area-inset-top, 0px); |
| --safe-bottom: env(safe-area-inset-bottom, 0px); |
| } |
| |
| /* Dark Theme */ |
| [data-theme="dark"] { |
| --bg-primary: #1a1a2e; |
| --bg-card: #2d2d44; |
| --text-primary: #ffffff; |
| --text-secondary: #b0b0b0; |
| --border-color: #3d3d5c; |
| --shadow: rgba(0, 0, 0, 0.3); |
| } |
| |
| [data-theme="dark"] .header { |
| background: linear-gradient(135deg, #2d2d44 0%, #1a1a2e 100%); |
| box-shadow: 0 2px 20px rgba(0, 0, 0, 0.5); |
| } |
| |
| [data-theme="dark"] .status-card, |
| [data-theme="dark"] .result-card, |
| [data-theme="dark"] .daily-summary-card, |
| [data-theme="dark"] .welcome-card, |
| [data-theme="dark"] .login-card { |
| background: linear-gradient(135deg, #2d2d44 0%, #252540 100%); |
| color: var(--text-primary); |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); |
| border-color: #4a4a6a; |
| } |
| |
| [data-theme="dark"] .settings-sidebar, |
| [data-theme="dark"] .history-panel { |
| background: linear-gradient(135deg, #2d2d44 0%, #252540 100%); |
| box-shadow: -4px 0 30px rgba(0, 0, 0, 0.6); |
| border-color: #4a4a6a; |
| } |
| |
| [data-theme="dark"] .history-toolbar { |
| border-bottom-color: #4a4a6a; |
| background: rgba(37, 37, 64, 0.5); |
| } |
| |
| [data-theme="dark"] .wellbeing-panel { |
| background: linear-gradient(135deg, #2d2d44 0%, #252540 100%); |
| box-shadow: -4px 0 30px rgba(0, 0, 0, 0.6); |
| border-color: #4a4a6a; |
| } |
| [data-theme="dark"] .wellbeing-toolbar { |
| border-bottom-color: #4a4a6a; |
| background: rgba(37, 37, 64, 0.5); |
| } |
| [data-theme="dark"] .wellbeing-reminder { |
| background: rgba(255, 107, 157, 0.1); |
| border-color: #4a4a6a; |
| } |
| |
| [data-theme="dark"] .history-item { |
| background: rgba(37, 37, 64, 0.3); |
| border-color: #4a4a6a; |
| } |
| |
| [data-theme="dark"] .settings-toolbar { |
| border-bottom-color: #4a4a6a; |
| background: rgba(37, 37, 64, 0.5); |
| } |
| |
| [data-theme="dark"] .settings-section { |
| background: rgba(37, 37, 64, 0.3); |
| padding: 16px; |
| border-radius: 12px; |
| margin-bottom: 16px; |
| border: 1px solid #4a4a6a; |
| } |
| |
| [data-theme="dark"] .settings-select { |
| background: rgba(45, 45, 68, 0.8); |
| border-color: #4a4a6a; |
| color: var(--text-primary); |
| } |
| |
| [data-theme="dark"] .settings-select:focus { |
| border-color: #FF6B9D; |
| box-shadow: 0 0 0 3px rgba(255, 107, 157, 0.2); |
| } |
| |
| [data-theme="dark"] .slider { |
| background-color: #4a4a6a; |
| } |
| |
| [data-theme="dark"] input:checked + .slider { |
| background-color: #FF6B9D; |
| box-shadow: 0 0 10px rgba(255, 107, 157, 0.5); |
| } |
| |
| * { |
| box-sizing: border-box; |
| -webkit-tap-highlight-color: transparent; |
| } |
| |
| html, body { |
| margin: 0; |
| padding: 0; |
| font-family: 'Cairo', 'Quicksand', -apple-system, BlinkMacSystemFont, sans-serif; |
| background: var(--bg-primary); |
| color: var(--text-primary); |
| min-height: 100vh; |
| min-height: -webkit-fill-available; |
| overflow-x: hidden; |
| } |
| |
| /* RTL Support */ |
| html[dir="rtl"] { |
| direction: rtl; |
| } |
| |
| html[dir="rtl"] .app { |
| text-align: right; |
| } |
| |
| html[dir="rtl"] .status-row, |
| html[dir="rtl"] .diary-buttons, |
| html[dir="rtl"] .category-buttons { |
| flex-direction: row-reverse; |
| } |
| |
| body { |
| padding-top: var(--safe-top); |
| padding-bottom: var(--safe-bottom); |
| } |
| |
| .app { |
| max-width: 500px; |
| margin: 0 auto; |
| padding: 16px; |
| padding-bottom: calc(80px + var(--safe-bottom)); |
| } |
| |
| /* Header */ |
| .header { |
| text-align: center; |
| padding: 20px 0; |
| } |
| |
| .logo { |
| font-size: 64px; |
| margin-bottom: 8px; |
| letter-spacing: -8px; |
| } |
| |
| .app-title { |
| font-size: 28px; |
| font-weight: 700; |
| margin: 0; |
| background: linear-gradient(135deg, var(--accent-pink), #FF8FAE); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| background-clip: text; |
| } |
| |
| .app-subtitle { |
| color: var(--text-secondary); |
| font-size: 14px; |
| margin-top: 4px; |
| } |
| .app-baby-display { |
| color: var(--accent-pink); |
| font-size: 13px; |
| margin-top: 4px; |
| font-weight: 500; |
| } |
| .app-baby-display[aria-hidden="true"] { |
| display: none !important; |
| } |
| |
| /* Status Card */ |
| .status-card { |
| background: var(--bg-card); |
| border-radius: 24px; |
| padding: 16px; |
| margin-bottom: 16px; |
| border: 1px solid var(--border-color); |
| box-shadow: 0 2px 16px var(--shadow); |
| } |
| |
| .status-row { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .status-dot { |
| width: 12px; |
| height: 12px; |
| border-radius: 50%; |
| background: var(--accent-green); |
| animation: pulse 2s infinite; |
| } |
| |
| @keyframes pulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.5; } |
| } |
| |
| .status-text { |
| font-size: 14px; |
| color: var(--text-secondary); |
| } |
| |
| .status-text strong { |
| color: var(--text-primary); |
| } |
| |
| /* Main Record Section */ |
| .record-section { |
| text-align: center; |
| padding: 32px 0; |
| } |
| |
| .record-btn { |
| width: 180px; |
| height: 180px; |
| border-radius: 50%; |
| border: none; |
| background: linear-gradient(135deg, #FF6B9D, #FF8FAE); |
| color: white; |
| font-size: 20px; |
| font-weight: 600; |
| font-family: inherit; |
| cursor: pointer; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| margin: 0 auto; |
| box-shadow: 0 8px 32px rgba(255, 107, 157, 0.4); |
| transition: all 0.3s ease; |
| -webkit-user-select: none; |
| user-select: none; |
| } |
| |
| .record-btn:hover { |
| transform: scale(1.05); |
| box-shadow: 0 10px 40px rgba(255, 107, 157, 0.5); |
| } |
| |
| .record-btn:active { |
| transform: scale(0.95); |
| } |
| |
| .record-btn.recording { |
| background: linear-gradient(135deg, #ef4444, #dc2626); |
| animation: recording-pulse 1s infinite; |
| } |
| |
| @keyframes recording-pulse { |
| 0%, 100% { box-shadow: 0 8px 32px rgba(239, 68, 68, 0.4); } |
| 50% { box-shadow: 0 8px 48px rgba(239, 68, 68, 0.6); } |
| } |
| |
| .record-btn .icon { |
| font-size: 48px; |
| margin-bottom: 8px; |
| } |
| |
| .record-hint { |
| color: var(--text-secondary); |
| font-size: 14px; |
| margin-top: 16px; |
| } |
| |
| /* Recording Timer */ |
| .recording-timer { |
| font-size: 32px; |
| font-weight: 700; |
| color: var(--accent-pink); |
| margin: 12px 0; |
| font-family: 'Courier New', monospace; |
| opacity: 0; |
| transition: opacity 0.3s ease; |
| } |
| |
| .recording-timer.active { |
| opacity: 1; |
| } |
| |
| .recording-timer.warning { |
| color: var(--accent-yellow); |
| } |
| |
| .recording-timer.ready { |
| color: var(--accent-green); |
| } |
| |
| /* Audio Level Meter */ |
| .audio-level-container { |
| width: 200px; |
| height: 8px; |
| background: var(--border-color); |
| border-radius: 4px; |
| margin: 12px auto; |
| overflow: hidden; |
| opacity: 0; |
| transition: opacity 0.3s ease; |
| } |
| |
| .audio-level-container.active { |
| opacity: 1; |
| } |
| |
| .audio-level-bar { |
| height: 100%; |
| width: 0%; |
| background: linear-gradient(90deg, var(--accent-green), var(--accent-yellow), var(--accent-orange)); |
| border-radius: 4px; |
| transition: width 0.1s ease; |
| } |
| |
| .min-duration-hint { |
| font-size: 12px; |
| color: var(--text-secondary); |
| margin-top: 8px; |
| } |
| |
| .min-duration-hint.satisfied { |
| color: var(--accent-green); |
| } |
| |
| /* Calming Analysis Message */ |
| .calming-message { |
| display: none; |
| background: linear-gradient(135deg, rgba(255, 107, 157, 0.1), rgba(255, 179, 209, 0.05)); |
| border-radius: 24px; |
| padding: 32px 24px; |
| margin: 16px 0; |
| border: 2px solid rgba(255, 107, 157, 0.2); |
| text-align: center; |
| position: relative; |
| overflow: hidden; |
| } |
| |
| .calming-message.visible { |
| display: block; |
| animation: fadeInCalm 0.5s ease-in; |
| } |
| |
| @keyframes fadeInCalm { |
| from { opacity: 0; transform: translateY(10px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .calming-message::before { |
| content: ''; |
| position: absolute; |
| top: -50%; |
| left: -50%; |
| width: 200%; |
| height: 200%; |
| background: radial-gradient(circle, rgba(255, 107, 157, 0.1) 0%, transparent 70%); |
| animation: pulseCalm 3s ease-in-out infinite; |
| } |
| |
| @keyframes pulseCalm { |
| 0%, 100% { transform: scale(1); opacity: 0.3; } |
| 50% { transform: scale(1.1); opacity: 0.5; } |
| } |
| |
| .calming-icon { |
| font-size: 64px; |
| margin-bottom: 16px; |
| animation: floatCalm 2s ease-in-out infinite; |
| position: relative; |
| z-index: 1; |
| } |
| |
| @keyframes floatCalm { |
| 0%, 100% { transform: translateY(0px); } |
| 50% { transform: translateY(-10px); } |
| } |
| |
| .calming-text { |
| font-size: 18px; |
| font-weight: 500; |
| color: var(--text-primary); |
| margin-bottom: 8px; |
| position: relative; |
| z-index: 1; |
| } |
| |
| .calming-subtext { |
| font-size: 14px; |
| color: var(--text-secondary); |
| font-style: italic; |
| position: relative; |
| z-index: 1; |
| } |
| |
| /* Result Card */ |
| .result-card { |
| background: var(--bg-card); |
| border-radius: 24px; |
| padding: 24px; |
| margin: 16px 0; |
| border: 1px solid var(--border-color); |
| box-shadow: 0 2px 16px var(--shadow); |
| text-align: center; |
| min-height: 140px; |
| display: flex; |
| flex-direction: column; |
| justify-content: center; |
| } |
| |
| .result-card.success { |
| border-color: var(--accent-green); |
| background: linear-gradient(135deg, rgba(63, 185, 80, 0.05), #FFFFFF); |
| } |
| |
| .result-emoji { |
| font-size: 56px; |
| margin-bottom: 8px; |
| } |
| |
| .result-label { |
| font-size: 24px; |
| font-weight: 700; |
| color: var(--accent-yellow); |
| text-transform: uppercase; |
| letter-spacing: 1px; |
| } |
| |
| .logo-img { |
| width: 88px; |
| height: 88px; |
| border-radius: 26px; |
| object-fit: cover; |
| box-shadow: 0 6px 18px rgba(255, 107, 157, 0.25); |
| } |
| .record-btn .icon svg { display: block; margin: 0 auto; } |
| |
| .result-confidence { |
| font-size: 16px; |
| color: var(--accent-blue); |
| margin: 8px 0; |
| } |
| |
| .result-second-guess { |
| font-size: 14px; |
| color: var(--text-secondary); |
| margin: -2px 0 8px 0; |
| opacity: 0.9; |
| } |
| |
| .result-suggestion { |
| font-size: 14px; |
| color: var(--text-secondary); |
| } |
| .result-seek-help { |
| margin-top: 12px; |
| padding: 10px 12px; |
| font-size: 13px; |
| color: var(--text-secondary); |
| border-left: 3px solid var(--accent-orange); |
| background: rgba(247, 129, 102, 0.08); |
| border-radius: 0 8px 8px 0; |
| line-height: 1.5; |
| } |
| .result-tip { |
| margin-top: 10px; |
| padding: 8px 12px; |
| font-size: 13px; |
| color: var(--text-secondary); |
| border-left: 3px solid var(--accent-pink); |
| background: rgba(255, 107, 157, 0.06); |
| border-radius: 0 8px 8px 0; |
| line-height: 1.5; |
| } |
| .result-soothing { |
| margin-top: 10px; |
| } |
| .soothing-btn { |
| font-size: 13px; |
| padding: 8px 14px; |
| border-radius: 10px; |
| border: 1px solid var(--accent-pink); |
| background: transparent; |
| color: var(--accent-pink); |
| cursor: pointer; |
| } |
| .soothing-btn:hover { |
| background: rgba(255, 107, 157, 0.1); |
| } |
| |
| .result-placeholder { |
| color: var(--text-secondary); |
| font-size: 16px; |
| } |
| |
| /* Diary Flow (integrated feedback) */ |
| .diary-section { |
| display: none; |
| margin-top: 16px; |
| padding-top: 16px; |
| border-top: 1px solid var(--border-color); |
| animation: slideUp 0.3s ease; |
| } |
| .diary-section.visible { |
| display: block; |
| } |
| @keyframes slideUp { |
| from { opacity: 0; transform: translateY(20px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .diary-question { |
| font-size: 15px; |
| font-weight: 600; |
| text-align: center; |
| color: var(--text-primary); |
| margin-bottom: 12px; |
| } |
| .diary-buttons { |
| display: flex; |
| gap: 10px; |
| justify-content: center; |
| } |
| .diary-btn { |
| flex: 1; |
| padding: 14px; |
| border: none; |
| border-radius: 12px; |
| font-size: 15px; |
| font-weight: 600; |
| font-family: inherit; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 6px; |
| transition: all 0.2s ease; |
| } |
| .diary-btn.yes { |
| background: var(--accent-green); |
| color: white; |
| } |
| .diary-btn.yes:hover { |
| background: #2ea043; |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(63, 185, 80, 0.3); |
| } |
| .diary-btn.no { |
| background: var(--accent-pink); |
| color: white; |
| } |
| .diary-btn.no:hover { |
| background: #FF4D7D; |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3); |
| } |
| .diary-categories { |
| display: none; |
| } |
| .diary-categories.visible { |
| display: block; |
| animation: slideUp 0.3s ease; |
| } |
| .category-grid { |
| display: grid; |
| grid-template-columns: repeat(2, 1fr); |
| gap: 10px; |
| margin-top: 12px; |
| } |
| .category-btn { |
| padding: 14px 10px; |
| border: 2px solid var(--border-color); |
| border-radius: 14px; |
| background: transparent; |
| color: var(--text-primary); |
| font-size: 14px; |
| font-weight: 500; |
| font-family: inherit; |
| cursor: pointer; |
| transition: all 0.2s ease; |
| } |
| .category-btn:hover { |
| border-color: var(--accent-pink); |
| background: rgba(255, 107, 157, 0.1); |
| transform: translateY(-2px); |
| } |
| .category-btn:active { |
| border-color: var(--accent-pink); |
| background: rgba(255, 107, 157, 0.2); |
| } |
| |
| .category-btn .emoji { |
| font-size: 24px; |
| display: block; |
| margin-bottom: 4px; |
| } |
| |
| /* Upload Section */ |
| .upload-section { |
| margin-top: 24px; |
| } |
| |
| .upload-btn { |
| width: 100%; |
| padding: 16px; |
| border: 2px dashed var(--border-color); |
| border-radius: 16px; |
| background: transparent; |
| color: var(--text-secondary); |
| font-size: 14px; |
| font-family: inherit; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| transition: all 0.2s ease; |
| } |
| |
| .upload-btn:hover { |
| border-color: var(--accent-pink); |
| background: rgba(255, 107, 157, 0.05); |
| } |
| |
| .upload-btn:active { |
| border-color: var(--accent-pink); |
| } |
| |
| /* What helped step */ |
| .diary-helped { |
| display: none; |
| margin-top: 14px; |
| padding-top: 14px; |
| border-top: 1px solid var(--border-color); |
| animation: slideUp 0.3s ease; |
| } |
| .diary-helped.visible { |
| display: block; |
| } |
| .helped-grid { |
| display: grid; |
| grid-template-columns: repeat(3, 1fr); |
| gap: 8px; |
| margin-top: 10px; |
| } |
| .helped-btn { |
| padding: 10px 6px; |
| border: 2px solid var(--border-color); |
| border-radius: 12px; |
| background: transparent; |
| color: var(--text-primary); |
| font-size: 13px; |
| font-weight: 500; |
| font-family: inherit; |
| cursor: pointer; |
| transition: all 0.2s ease; |
| text-align: center; |
| } |
| .helped-btn:hover, .helped-btn.selected { |
| border-color: var(--accent-blue); |
| background: rgba(88, 166, 255, 0.1); |
| } |
| .helped-other-input { |
| width: 100%; |
| margin-top: 8px; |
| padding: 10px 12px; |
| border: 2px solid var(--border-color); |
| border-radius: 12px; |
| font-size: 13px; |
| font-family: inherit; |
| background: transparent; |
| color: var(--text-primary); |
| display: none; |
| } |
| .helped-other-input.visible { |
| display: block; |
| } |
| .diary-save-btn { |
| width: 100%; |
| margin-top: 12px; |
| padding: 14px; |
| border: none; |
| border-radius: 12px; |
| background: var(--accent-blue); |
| color: white; |
| font-size: 15px; |
| font-weight: 600; |
| font-family: inherit; |
| cursor: pointer; |
| transition: all 0.2s ease; |
| } |
| .diary-save-btn:hover { |
| background: #4A90D9; |
| transform: translateY(-1px); |
| } |
| .diary-thankyou { |
| text-align: center; |
| padding: 16px; |
| color: var(--text-secondary); |
| font-size: 14px; |
| animation: slideUp 0.3s ease; |
| } |
| |
| /* Daily Summary Card */ |
| .daily-summary-card { |
| background: var(--bg-card); |
| border-radius: 16px; |
| padding: 14px 16px; |
| margin: 12px 0; |
| border: 1px solid var(--border-color); |
| box-shadow: 0 1px 8px var(--shadow); |
| } |
| .daily-summary-title { |
| font-size: 13px; |
| font-weight: 600; |
| color: var(--text-secondary); |
| margin-bottom: 6px; |
| } |
| .daily-summary-counts { |
| font-size: 14px; |
| color: var(--text-primary); |
| line-height: 1.6; |
| } |
| .daily-summary-most { |
| font-size: 12px; |
| color: var(--text-secondary); |
| margin-top: 4px; |
| } |
| |
| /* Collapsible Admin - HIDDEN FROM USERS */ |
| .admin-toggle { |
| display: none !important; /* Hide from regular users */ |
| width: 100%; |
| padding: 12px; |
| border: none; |
| background: transparent; |
| color: var(--text-secondary); |
| font-size: 14px; |
| font-family: inherit; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| margin-top: 24px; |
| } |
| |
| .admin-panel { |
| display: none !important; /* Hide from regular users */ |
| display: none; |
| background: var(--bg-card); |
| border-radius: 24px; |
| padding: 16px; |
| margin-top: 8px; |
| border: 1px solid var(--border-color); |
| box-shadow: 0 2px 16px var(--shadow); |
| } |
| |
| .admin-panel.visible { |
| display: block; |
| } |
| |
| .stats-grid { |
| display: grid; |
| grid-template-columns: repeat(3, 1fr); |
| gap: 8px; |
| margin-bottom: 16px; |
| } |
| |
| .stat-box { |
| text-align: center; |
| padding: 12px 8px; |
| background: rgba(255, 255, 255, 0.03); |
| border-radius: 8px; |
| } |
| |
| .stat-value { |
| font-size: 24px; |
| font-weight: 700; |
| color: var(--accent-blue); |
| } |
| |
| .stat-label { |
| font-size: 11px; |
| color: var(--text-secondary); |
| margin-top: 4px; |
| } |
| |
| .admin-btn { |
| width: 100%; |
| padding: 12px; |
| border: 1px solid var(--border-color); |
| border-radius: 8px; |
| background: transparent; |
| color: var(--text-primary); |
| font-size: 14px; |
| font-family: inherit; |
| cursor: pointer; |
| margin-top: 8px; |
| } |
| |
| /* Install Banner */ |
| .install-banner { |
| position: fixed; |
| bottom: 0; |
| left: 0; |
| right: 0; |
| background: var(--bg-card); |
| padding: 16px; |
| padding-bottom: calc(16px + var(--safe-bottom)); |
| border-top: 1px solid var(--border-color); |
| display: none; |
| z-index: 100; |
| } |
| |
| .install-banner.visible { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| animation: slideUp 0.3s ease; |
| } |
| |
| .install-text { |
| flex: 1; |
| font-size: 14px; |
| } |
| |
| .install-text strong { |
| display: block; |
| margin-bottom: 2px; |
| } |
| |
| .install-btn { |
| padding: 10px 20px; |
| border: none; |
| border-radius: 12px; |
| background: linear-gradient(135deg, #FF6B9D, #FF8FAE); |
| color: white; |
| font-size: 14px; |
| font-weight: 600; |
| font-family: inherit; |
| cursor: pointer; |
| transition: all 0.2s ease; |
| } |
| |
| .install-btn:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3); |
| } |
| |
| .install-close { |
| padding: 8px; |
| border: none; |
| background: transparent; |
| color: var(--text-secondary); |
| font-size: 20px; |
| cursor: pointer; |
| } |
| |
| /* Toast Notification */ |
| .toast { |
| position: fixed; |
| bottom: 24px; |
| left: 50%; |
| transform: translateX(-50%) translateY(100px); |
| background: var(--accent-pink); |
| color: white; |
| padding: 16px 24px; |
| border-radius: 12px; |
| box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); |
| z-index: 10000; |
| opacity: 0; |
| transition: all 0.3s ease; |
| max-width: 90%; |
| text-align: center; |
| font-size: 14px; |
| font-weight: 500; |
| } |
| |
| .toast.show { |
| opacity: 1; |
| transform: translateX(-50%) translateY(0); |
| } |
| |
| /* Loading Spinner */ |
| .spinner { |
| width: 24px; |
| height: 24px; |
| border: 3px solid rgba(255, 107, 157, 0.3); |
| border-top-color: var(--accent-pink); |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| } |
| |
| @keyframes spin { |
| to { transform: rotate(360deg); } |
| } |
| |
| /* Hidden file input */ |
| .hidden-input { |
| display: none; |
| } |
| |
| /* Toast notifications */ |
| .toast { |
| position: fixed; |
| bottom: 100px; |
| left: 50%; |
| transform: translateX(-50%) translateY(20px); |
| background: var(--bg-card); |
| color: var(--text-primary); |
| padding: 14px 28px; |
| border-radius: 12px; |
| font-size: 15px; |
| font-weight: 500; |
| box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); |
| opacity: 0; |
| transition: all 0.3s ease; |
| z-index: 10000; |
| max-width: 90%; |
| text-align: center; |
| border: 2px solid transparent; |
| } |
| |
| .toast.visible { |
| opacity: 1; |
| transform: translateX(-50%) translateY(0); |
| } |
| |
| .toast.success { |
| background: linear-gradient(135deg, #4CAF50, #45a049); |
| color: white; |
| border-color: #4CAF50; |
| box-shadow: 0 6px 20px rgba(76, 175, 80, 0.4); |
| } |
| |
| .toast.error { |
| background: linear-gradient(135deg, #f44336, #d32f2f); |
| color: white; |
| border-color: #f44336; |
| box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4); |
| } |
| |
| .toast.info { |
| background: linear-gradient(135deg, #2196F3, #1976D2); |
| color: white; |
| border-color: #2196F3; |
| box-shadow: 0 6px 20px rgba(33, 150, 243, 0.4); |
| } |
| |
| .toast.warning { |
| background: linear-gradient(135deg, #FF9800, #F57C00); |
| color: white; |
| border-color: #FF9800; |
| box-shadow: 0 6px 20px rgba(255, 152, 0, 0.4); |
| } |
| |
| /* Welcome Screen */ |
| .welcome-screen { |
| display: none; |
| position: fixed; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background: var(--bg-primary); |
| z-index: 1000; |
| overflow-y: auto; |
| padding: 20px; |
| padding-top: calc(20px + var(--safe-top)); |
| padding-bottom: calc(20px + var(--safe-bottom)); |
| } |
| |
| .welcome-screen.visible { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .welcome-card { |
| background: var(--bg-card); |
| border-radius: 24px; |
| padding: 32px 24px; |
| max-width: 400px; |
| width: 100%; |
| box-shadow: 0 4px 24px var(--shadow); |
| border: 1px solid var(--border-color); |
| } |
| |
| .welcome-heart { |
| font-size: 80px; |
| text-align: center; |
| margin-bottom: 16px; |
| letter-spacing: -12px; |
| } |
| |
| .welcome-card h1 { |
| font-size: 28px; |
| font-weight: 700; |
| text-align: center; |
| margin: 0 0 8px 0; |
| color: var(--text-primary); |
| } |
| |
| .welcome-card p { |
| font-size: 16px; |
| text-align: center; |
| color: var(--text-secondary); |
| margin: 0 0 32px 0; |
| } |
| |
| .welcome-form { |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| } |
| |
| .welcome-form-group { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| |
| .welcome-form-label { |
| font-size: 14px; |
| font-weight: 600; |
| color: var(--text-primary); |
| } |
| |
| .welcome-form-label .optional { |
| font-weight: 400; |
| color: var(--text-secondary); |
| font-size: 12px; |
| } |
| |
| .welcome-form input, |
| .welcome-form select { |
| width: 100%; |
| padding: 14px 16px; |
| border: 2px solid var(--border-color); |
| border-radius: 12px; |
| background: var(--bg-card); |
| color: var(--text-primary); |
| font-size: 16px; |
| font-family: inherit; |
| transition: all 0.2s ease; |
| } |
| |
| .welcome-form input:focus, |
| .welcome-form select:focus { |
| outline: none; |
| border-color: var(--accent-pink); |
| } |
| |
| /* intl-tel-input styling */ |
| .iti { |
| width: 100%; |
| } |
| |
| .iti__flag-container { |
| z-index: 10; |
| } |
| |
| .welcome-form #userContact, |
| .welcome-form #loginContact { |
| padding-left: 60px !important; |
| direction: ltr !important; /* Force LTR for phone numbers to prevent inversion */ |
| text-align: left !important; /* Keep text left-aligned */ |
| } |
| |
| /* RTL: Keep country selector on left, but input text LTR */ |
| html[dir="rtl"] .welcome-form #userContact, |
| html[dir="rtl"] .welcome-form #loginContact { |
| direction: ltr !important; |
| text-align: left !important; |
| } |
| |
| .iti__selected-flag { |
| padding: 0 12px; |
| border-radius: 12px 0 0 12px; |
| } |
| |
| .iti__arrow { |
| border-top: 4px solid var(--text-secondary); |
| } |
| |
| .iti__country-list { |
| background: var(--bg-card); |
| border: 2px solid var(--border-color); |
| border-radius: 12px; |
| box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); |
| } |
| |
| .iti__country { |
| color: var(--text-primary); |
| } |
| |
| .iti__country:hover, |
| .iti__country.iti__highlight { |
| background: var(--accent-pink); |
| color: white; |
| } |
| box-shadow: 0 0 0 3px rgba(255, 107, 157, 0.1); |
| } |
| |
| .welcome-form button, |
| .welcome-card button { |
| width: 100%; |
| padding: 28px 32px; |
| border: none; |
| border-radius: 18px; |
| background: #FF6B9D !important; |
| color: white !important; |
| font-size: 20px; |
| font-weight: 700; |
| font-family: inherit; |
| cursor: pointer; |
| margin-top: 16px; |
| box-shadow: 0 8px 24px rgba(255, 107, 157, 0.5) !important; |
| transition: all 0.3s ease; |
| min-height: 72px; |
| letter-spacing: 0.8px; |
| line-height: 1.5; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .welcome-form button:hover, |
| .welcome-card button:hover { |
| transform: translateY(-4px); |
| box-shadow: 0 12px 32px rgba(255, 107, 157, 0.6) !important; |
| background: #FF5A8A !important; |
| } |
| |
| .welcome-form button:active, |
| .welcome-card button:active { |
| transform: translateY(-2px); |
| box-shadow: 0 6px 20px rgba(255, 107, 157, 0.5) !important; |
| background: #FF6B9D !important; |
| } |
| |
| .welcome-form button:disabled, |
| .welcome-card button:disabled { |
| opacity: 0.7; |
| cursor: not-allowed; |
| background: #FF6B9D !important; |
| transform: none; |
| } |
| |
| /* Ensure no other styles override button */ |
| .welcome-form button[type="submit"], |
| .welcome-card button[type="submit"] { |
| width: 100% !important; |
| padding: 28px 32px !important; |
| border: none !important; |
| border-radius: 18px !important; |
| background: #FF6B9D !important; |
| color: white !important; |
| font-size: 20px !important; |
| font-weight: 700 !important; |
| font-family: inherit !important; |
| cursor: pointer !important; |
| margin-top: 16px !important; |
| box-shadow: 0 8px 24px rgba(255, 107, 157, 0.5) !important; |
| transition: all 0.3s ease !important; |
| min-height: 72px !important; |
| letter-spacing: 0.8px !important; |
| line-height: 1.5 !important; |
| display: flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| } |
| |
| html[dir="rtl"] .welcome-card { |
| text-align: right; |
| } |
| |
| /* Settings Sidebar - Hidden by default */ |
| .settings-btn { |
| background: rgba(255, 255, 255, 0.8); |
| border: none; |
| border-radius: 12px; |
| width: 40px; |
| height: 40px; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| transition: all 0.2s ease; |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
| color: var(--text-primary); |
| } |
| |
| .settings-btn svg { |
| width: 20px; |
| height: 20px; |
| stroke: var(--text-primary); |
| } |
| |
| .settings-btn:hover { |
| background: rgba(255, 255, 255, 1); |
| transform: scale(1.1); |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
| } |
| |
| [data-theme="dark"] .settings-btn { |
| background: rgba(45, 45, 68, 0.8); |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); |
| } |
| |
| [data-theme="dark"] .settings-btn:hover { |
| background: rgba(45, 45, 68, 1); |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); |
| } |
| |
| .settings-btn-container { |
| position: absolute; |
| top: 16px; |
| left: 16px; |
| right: 16px; |
| display: flex; |
| justify-content: flex-end; |
| gap: 8px; |
| z-index: 100; |
| } |
| |
| /* RTL: Settings button on the right for Arabic */ |
| html[dir="rtl"] .settings-btn-container { |
| justify-content: flex-start; |
| } |
| |
| .settings-overlay { |
| display: none; /* Hidden by default */ |
| position: fixed; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background: rgba(0, 0, 0, 0.4); |
| backdrop-filter: blur(4px); |
| z-index: 9999; |
| animation: fadeIn 0.3s ease; |
| } |
| |
| .settings-overlay.active { |
| display: block; /* Show when settings open */ |
| } |
| |
| .settings-sidebar { |
| position: fixed; |
| top: 0; |
| left: -100%; /* Hidden on left by default (LTR) */ |
| width: 320px; |
| max-width: 85vw; |
| height: 100vh; |
| background: var(--bg-card); |
| box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15); |
| z-index: 10000; |
| transition: left 0.3s ease; |
| display: none; /* Hidden by default */ |
| flex-direction: column; |
| border-right: 1px solid var(--border-color); |
| } |
| |
| .settings-sidebar.active { |
| display: flex; /* Show when active */ |
| left: 0; /* Slide in from left (LTR) */ |
| } |
| |
| /* RTL (Arabic): Sidebar on the RIGHT side */ |
| html[dir="rtl"] .settings-sidebar { |
| left: auto; |
| right: -100%; /* Hidden on right (RTL) */ |
| border-right: none; |
| border-left: 1px solid var(--border-color); |
| box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15); |
| transition: right 0.3s ease; |
| } |
| |
| html[dir="rtl"] .settings-sidebar.active { |
| right: 0; /* Slide in from RIGHT (RTL) */ |
| } |
| |
| .history-overlay { |
| display: none; |
| position: fixed; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background: rgba(0, 0, 0, 0.4); |
| z-index: 998; |
| } |
| .history-overlay.visible { |
| display: block; |
| } |
| .history-panel { |
| position: fixed; |
| top: 0; |
| left: -100%; |
| width: 320px; |
| max-width: 85vw; |
| height: 100vh; |
| background: var(--bg-card); |
| box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15); |
| border-right: 1px solid var(--border-color); |
| z-index: 999; |
| transition: left 0.3s ease; |
| display: flex; |
| flex-direction: column; |
| } |
| .history-panel.visible { |
| left: 0; |
| } |
| html[dir="rtl"] .history-panel { |
| left: auto; |
| right: -100%; |
| border-right: none; |
| border-left: 1px solid var(--border-color); |
| transition: right 0.3s ease; |
| } |
| html[dir="rtl"] .history-panel.visible { |
| right: 0; |
| } |
| .history-toolbar { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 20px; |
| border-bottom: 1px solid var(--border-color); |
| } |
| .history-toolbar h2 { |
| margin: 0; |
| font-size: 20px; |
| font-weight: 600; |
| color: var(--text-primary); |
| } |
| .history-close { |
| background: transparent; |
| border: none; |
| font-size: 28px; |
| color: var(--text-secondary); |
| cursor: pointer; |
| width: 32px; |
| height: 32px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| border-radius: 50%; |
| } |
| .history-body { |
| flex: 1; |
| overflow-y: auto; |
| padding: 16px; |
| } |
| .history-list { |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| .history-item { |
| background: rgba(0, 0, 0, 0.04); |
| border-radius: 12px; |
| padding: 12px; |
| border: 1px solid var(--border-color); |
| } |
| .history-item-date { |
| font-size: 12px; |
| color: var(--text-secondary); |
| margin-bottom: 4px; |
| } |
| .history-item-category { |
| font-size: 15px; |
| font-weight: 600; |
| color: var(--text-primary); |
| } |
| .history-item-what-helped { |
| font-size: 13px; |
| color: var(--text-secondary); |
| margin-top: 8px; |
| } |
| .history-item-what-helped input { |
| width: 100%; |
| padding: 8px; |
| border-radius: 8px; |
| border: 1px solid var(--border-color); |
| font-size: 13px; |
| margin-top: 4px; |
| } |
| .history-item-actions { |
| margin-top: 8px; |
| } |
| .history-item-actions button { |
| font-size: 12px; |
| padding: 6px 10px; |
| border-radius: 8px; |
| border: 1px solid var(--accent-pink); |
| background: transparent; |
| color: var(--accent-pink); |
| cursor: pointer; |
| } |
| |
| .wellbeing-overlay { |
| display: none; |
| position: fixed; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background: rgba(0, 0, 0, 0.4); |
| z-index: 998; |
| } |
| .wellbeing-overlay.visible { |
| display: block; |
| } |
| .wellbeing-panel { |
| position: fixed; |
| top: 0; |
| left: -100%; |
| width: 320px; |
| max-width: 85vw; |
| height: 100vh; |
| background: var(--bg-card); |
| box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15); |
| border-right: 1px solid var(--border-color); |
| z-index: 999; |
| transition: left 0.3s ease; |
| display: flex; |
| flex-direction: column; |
| } |
| .wellbeing-panel.visible { |
| left: 0; |
| } |
| html[dir="rtl"] .wellbeing-panel { |
| left: auto; |
| right: -100%; |
| border-right: none; |
| border-left: 1px solid var(--border-color); |
| transition: right 0.3s ease; |
| } |
| html[dir="rtl"] .wellbeing-panel.visible { |
| right: 0; |
| } |
| .wellbeing-toolbar { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 20px; |
| border-bottom: 1px solid var(--border-color); |
| } |
| .wellbeing-toolbar h2 { |
| margin: 0; |
| font-size: 20px; |
| font-weight: 600; |
| color: var(--text-primary); |
| } |
| .wellbeing-close { |
| background: transparent; |
| border: none; |
| font-size: 28px; |
| color: var(--text-secondary); |
| cursor: pointer; |
| width: 32px; |
| height: 32px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| border-radius: 50%; |
| } |
| .wellbeing-body { |
| flex: 1; |
| overflow-y: auto; |
| padding: 16px; |
| } |
| .wellbeing-heading { |
| font-size: 16px; |
| font-weight: 600; |
| color: var(--text-primary); |
| margin: 0 0 12px 0; |
| } |
| .wellbeing-links { |
| list-style: none; |
| padding: 0; |
| margin: 0 0 24px 0; |
| } |
| .wellbeing-links li { |
| margin-bottom: 8px; |
| } |
| .wellbeing-link { |
| color: var(--accent-pink); |
| text-decoration: none; |
| font-size: 14px; |
| } |
| .wellbeing-link:hover { |
| text-decoration: underline; |
| } |
| .wellbeing-checkin { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| margin-bottom: 12px; |
| } |
| .wellbeing-option { |
| padding: 8px 14px; |
| border-radius: 10px; |
| border: 1px solid var(--border-color); |
| background: var(--bg-card); |
| color: var(--text-primary); |
| font-size: 13px; |
| cursor: pointer; |
| } |
| .wellbeing-option:hover { |
| border-color: var(--accent-pink); |
| background: rgba(255, 107, 157, 0.08); |
| } |
| .wellbeing-last-checkin { |
| font-size: 12px; |
| color: var(--text-secondary); |
| margin: 0 0 16px 0; |
| } |
| .wellbeing-reminder { |
| padding: 12px; |
| border-radius: 12px; |
| border: 1px solid var(--border-color); |
| background: rgba(255, 107, 157, 0.06); |
| } |
| .wellbeing-reminder-text { |
| margin: 0 0 10px 0; |
| font-size: 14px; |
| color: var(--text-primary); |
| } |
| .wellbeing-reminder-dismiss { |
| padding: 6px 12px; |
| border-radius: 8px; |
| border: 1px solid var(--accent-pink); |
| background: transparent; |
| color: var(--accent-pink); |
| font-size: 13px; |
| cursor: pointer; |
| } |
| |
| .settings-toolbar { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 20px; |
| border-bottom: 1px solid var(--border-color); |
| } |
| |
| .settings-toolbar h2 { |
| margin: 0; |
| font-size: 20px; |
| font-weight: 600; |
| color: var(--text-primary); |
| } |
| |
| .settings-close { |
| background: transparent; |
| border: none; |
| font-size: 28px; |
| color: var(--text-secondary); |
| cursor: pointer; |
| width: 32px; |
| height: 32px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| border-radius: 50%; |
| transition: all 0.2s ease; |
| } |
| |
| .settings-close:hover { |
| background: rgba(0, 0, 0, 0.05); |
| color: var(--text-primary); |
| } |
| |
| .settings-body { |
| flex: 1; |
| overflow-y: auto; |
| padding: 20px; |
| } |
| |
| .settings-section { |
| margin-bottom: 24px; |
| } |
| |
| .settings-label { |
| display: block; |
| font-size: 14px; |
| font-weight: 600; |
| color: var(--text-primary); |
| margin-bottom: 8px; |
| } |
| |
| .settings-select { |
| width: 100%; |
| padding: 12px 16px; |
| border: 2px solid var(--border-color); |
| border-radius: 12px; |
| background: var(--bg-card); |
| color: var(--text-primary); |
| font-size: 16px; |
| font-family: inherit; |
| cursor: pointer; |
| transition: all 0.2s ease; |
| } |
| |
| .settings-select:focus { |
| outline: none; |
| border-color: var(--accent-pink); |
| box-shadow: 0 0 0 3px rgba(255, 107, 157, 0.1); |
| } |
| |
| .theme-toggle { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .switch { |
| position: relative; |
| display: inline-block; |
| width: 50px; |
| height: 26px; |
| } |
| |
| .switch input { |
| opacity: 0; |
| width: 0; |
| height: 0; |
| } |
| |
| .slider { |
| position: absolute; |
| cursor: pointer; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background-color: var(--border-color); |
| transition: 0.3s; |
| border-radius: 26px; |
| } |
| |
| .slider:before { |
| position: absolute; |
| content: ""; |
| height: 20px; |
| width: 20px; |
| left: 3px; |
| bottom: 3px; |
| background-color: white; |
| transition: 0.3s; |
| border-radius: 50%; |
| } |
| |
| input:checked + .slider { |
| background-color: var(--accent-pink); |
| } |
| |
| input:checked + .slider:before { |
| transform: translateX(24px); |
| } |
| |
| .logout-btn { |
| width: 100%; |
| padding: 14px; |
| border: 2px solid #FF4444; |
| border-radius: 12px; |
| background: linear-gradient(135deg, #FF4444, #FF6B9D); |
| color: white; |
| font-size: 16px; |
| font-weight: 600; |
| font-family: inherit; |
| cursor: pointer; |
| transition: all 0.2s ease; |
| box-shadow: 0 2px 8px rgba(255, 68, 68, 0.3); |
| } |
| |
| .logout-btn:hover { |
| border-color: #FF3333; |
| background: linear-gradient(135deg, #FF3333, #FF5A8A); |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(255, 68, 68, 0.4); |
| } |
| |
| .logout-btn:active { |
| transform: translateY(0); |
| box-shadow: 0 2px 8px rgba(255, 68, 68, 0.3); |
| } |
| |
| @keyframes fadeIn { |
| from { opacity: 0; } |
| to { opacity: 1; } |
| } |
| </style> |
| </head> |
| <body> |
| <!-- Login Screen --> |
| <div class="welcome-screen" id="loginScreen" style="display: none;"> |
| <div class="welcome-card"> |
| <div class="welcome-heart"><img src="/static/icons/icon-192.png" alt="" class="logo-img"></div> |
| <h1 data-translate="login_title">ุชุณุฌูู ุงูุฏุฎูู</h1> |
| <p data-translate="login_subtitle">ุฃุฏุฎู ุจุฑูุฏู ุงูุฅููุชุฑููู ูููู
ุฉ ุงูู
ุฑูุฑ</p> |
| |
| <form class="welcome-form" id="loginForm" onsubmit="handleLoginSubmit(event)"> |
| <div class="welcome-form-group"> |
| <label class="welcome-form-label" for="loginContact" data-translate="contact_label"> |
| ุงูุจุฑูุฏ ุงูุฅููุชุฑููู ุฃู ุฑูู
ุงููุงุชู <span style="color: var(--accent-pink);">*</span> |
| </label> |
| <div style="position: relative;"> |
| <input type="text" id="loginContact" placeholder="" data-translate-placeholder="contact_placeholder_single" style="padding-left: 60px;" /> |
| </div> |
| <small style="display: block; margin-top: 4px; color: var(--text-secondary); font-size: 12px;" data-translate="contact_hint">ุฃุฏุฎู ุจุฑูุฏู ุงูุฅููุชุฑููู ุฃู ุฑูู
ูุงุชูู</small> |
| </div> |
| |
| <div class="welcome-form-group"> |
| <label class="welcome-form-label" for="loginPassword" data-translate="password_label"> |
| ููู
ุฉ ุงูู
ุฑูุฑ <span style="color: var(--accent-pink);">*</span> |
| </label> |
| <input type="password" id="loginPassword" placeholder="" data-translate-placeholder="password_placeholder" required /> |
| </div> |
| |
| <button type="submit" data-translate="login_btn">ุชุณุฌูู ุงูุฏุฎูู</button> |
| |
| <p style="text-align: center; margin-top: 16px; font-size: 14px; color: var(--text-secondary);"> |
| <span data-translate="no_account">ููุณ ูุฏูู ุญุณุงุจุ</span> |
| <a href="#" onclick="showRegister(); return false;" style="color: var(--accent-pink); text-decoration: none; font-weight: 600;" data-translate="register_link">ุณุฌู ุงูุขู</a> |
| </p> |
| </form> |
| </div> |
| </div> |
| |
| <!-- Welcome Screen --> |
| <div class="welcome-screen" id="welcomeScreen"> |
| <div class="welcome-card"> |
| <div class="welcome-heart"><img src="/static/icons/icon-192.png" alt="" class="logo-img"></div> |
| <h1 data-translate="welcome_title">ู
ุฑุญุจุงู ุจู ูู ุฅุทู
ุฆู</h1> |
| <p data-translate="welcome_subtitle">ุฏุนูุง ูุนุฏ ู
ูู ุทููู</p> |
| |
| <form class="welcome-form" id="welcomeForm" onsubmit="handleWelcomeSubmit(event)"> |
| <div class="welcome-form-group"> |
| <label class="welcome-form-label" for="userContact" data-translate="contact_label"> |
| ุงูุจุฑูุฏ ุงูุฅููุชุฑููู ุฃู ุฑูู
ุงููุงุชู <span style="color: var(--accent-pink);">*</span> |
| </label> |
| <div style="position: relative;"> |
| <input type="text" id="userContact" placeholder="" data-translate-placeholder="contact_placeholder_single" style="padding-left: 60px;" /> |
| </div> |
| <small style="display: block; margin-top: 4px; color: var(--text-secondary); font-size: 12px;" data-translate="contact_hint">ุฃุฏุฎู ุจุฑูุฏู ุงูุฅููุชุฑููู ุฃู ุฑูู
ูุงุชูู</small> |
| </div> |
| |
| <div class="welcome-form-group"> |
| <label class="welcome-form-label" for="registerPassword" data-translate="password_label"> |
| ููู
ุฉ ุงูู
ุฑูุฑ <span style="color: var(--accent-pink);">*</span> |
| </label> |
| <input type="password" id="registerPassword" placeholder="" data-translate-placeholder="password_placeholder" required minlength="6" /> |
| </div> |
| |
| <div class="welcome-form-group"> |
| <label class="welcome-form-label" for="babyName" data-translate="baby_name_label"> |
| ุงุณู
ุงูุทูู <span style="color: var(--accent-pink);">*</span> |
| </label> |
| <input type="text" id="babyName" placeholder="" data-translate-placeholder="baby_name_placeholder" required /> |
| </div> |
| |
| <div class="welcome-form-group"> |
| <label class="welcome-form-label" for="babyGender" data-translate="gender_label"> |
| ุงูุฌูุณ <span class="optional" data-translate="optional_label">(ุงุฎุชูุงุฑู)</span> |
| </label> |
| <select id="babyGender"> |
| <option value="" data-translate="gender_optional">ุงุฎุชุฑ ุงูุฌูุณ</option> |
| <option value="male" data-translate="gender_male">ุฐูุฑ</option> |
| <option value="female" data-translate="gender_female">ุฃูุซู</option> |
| </select> |
| </div> |
| |
| <div class="welcome-form-group"> |
| <label class="welcome-form-label" for="babyBirthday" data-translate="birthday_label"> |
| ุชุงุฑูุฎ ุงูู
ููุงุฏ <span style="color: var(--accent-pink);">*</span> |
| </label> |
| <input type="date" id="babyBirthday" required /> |
| </div> |
| |
| <button type="submit" data-translate="continue_btn">ู
ุชุงุจุนุฉ</button> |
| </form> |
| </div> |
| </div> |
| |
| <div class="app"> |
| <!-- Header --> |
| <header class="header"> |
| <div class="settings-btn-container"> |
| <button class="wellbeing-btn settings-btn" id="wellbeingBtn" onclick="openWellbeingPanel()" aria-label="Mom wellbeing" style="display: none;"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> |
| </svg> |
| </button> |
| <button class="history-btn settings-btn" id="historyBtn" onclick="openHistoryPanel()" aria-label="History" style="display: none;"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <circle cx="12" cy="12" r="10"></circle> |
| <polyline points="12 6 12 12 16 14"></polyline> |
| </svg> |
| </button> |
| <button class="settings-btn" onclick="openSettings()" aria-label="Settings"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> |
| <line x1="3" y1="6" x2="21" y2="6"></line> |
| <line x1="3" y1="12" x2="21" y2="12"></line> |
| <line x1="3" y1="18" x2="21" y2="18"></line> |
| </svg> |
| </button> |
| </div> |
| <div class="logo"><img src="/static/icons/icon-192.png" alt="" class="logo-img"></div> |
| <h1 class="app-title" data-translate="app_name">ุฅุทู
ุฆู</h1> |
| <p class="app-subtitle" data-translate="app_subtitle">ุงููู
ู
ุง ูุญุชุงุฌู ุทููู</p> |
| <p class="app-baby-display" id="babyDisplay" aria-hidden="true"></p> |
| </header> |
| |
| <!-- Settings Sidebar --> |
| <div class="settings-overlay" id="settingsOverlay" onclick="closeSettings()"></div> |
| <div class="settings-sidebar" id="settingsSidebar"> |
| <div class="settings-toolbar"> |
| <h2 data-translate="settings_title">ุงูุฅุนุฏุงุฏุงุช</h2> |
| <button class="settings-close" onclick="closeSettings()" aria-label="Close">ร</button> |
| </div> |
| <div class="settings-body"> |
| <div class="settings-section"> |
| <label class="settings-label" data-translate="language_label">ุงููุบุฉ</label> |
| <select id="settingsLanguage" class="settings-select" onchange="changeLanguageFromSettings(this.value)"> |
| <option value="ar">ุงูุนุฑุจูุฉ</option> |
| <option value="fr">Franรงais</option> |
| <option value="en">English</option> |
| </select> |
| </div> |
| <div class="settings-section"> |
| <label class="settings-label" data-translate="theme_label">ุงูู
ุธูุฑ</label> |
| <div class="theme-toggle"> |
| <span data-translate="theme_dark">๐ ุฏุงูู</span> |
| <label class="switch"> |
| <input type="checkbox" id="darkThemeToggle" onchange="toggleDarkTheme(this.checked)"> |
| <span class="slider"></span> |
| </label> |
| </div> |
| </div> |
| <div class="settings-section"> |
| <label class="settings-label" data-translate="analysis_mode_label">ุทุฑููุฉ ุงูุชุญููู</label> |
| <select id="settingsAnalysisMode" class="settings-select" onchange="changeAnalysisModeFromSettings(this.value)"> |
| <option value="local" data-translate="analysis_local">ููุงุณู</option> |
| <option value="cloud" data-translate="analysis_cloud">ุณุญุงุจู (ุชุฌุฑูุจู)</option> |
| </select> |
| </div> |
| <div class="settings-section"> |
| <button class="logout-btn" onclick="handleLogout()" data-translate="logout">ุชุณุฌูู ุงูุฎุฑูุฌ</button> |
| </div> |
| </div> |
| </div> |
| |
| <!-- History Panel --> |
| <div class="history-overlay" id="historyOverlay" onclick="closeHistoryPanel()"></div> |
| <div class="history-panel" id="historyPanel"> |
| <div class="history-toolbar"> |
| <h2 id="historyTitle" data-translate="history_title">My recordings</h2> |
| <button class="history-close" onclick="closeHistoryPanel()" aria-label="Close">ร</button> |
| </div> |
| <div class="history-body"> |
| <div class="history-list" id="historyList"></div> |
| <p class="history-empty" id="historyEmpty" style="display: none;" data-translate="no_recordings_yet">No recordings yet</p> |
| </div> |
| </div> |
| |
| <!-- Wellbeing Panel --> |
| <div class="wellbeing-overlay" id="wellbeingOverlay" onclick="closeWellbeingPanel()"></div> |
| <div class="wellbeing-panel" id="wellbeingPanel"> |
| <div class="wellbeing-toolbar"> |
| <h2 id="wellbeingTitle" data-translate="wellbeing_title">Mom wellbeing</h2> |
| <button class="wellbeing-close" onclick="closeWellbeingPanel()" aria-label="Close">ร</button> |
| </div> |
| <div class="wellbeing-body"> |
| <h3 class="wellbeing-heading" data-translate="wellbeing_resources_heading">Resources</h3> |
| <ul class="wellbeing-links" id="wellbeingLinks"></ul> |
| <h3 class="wellbeing-heading" data-translate="wellbeing_checkin_question">How are you feeling today?</h3> |
| <div class="wellbeing-checkin" id="wellbeingCheckin"> |
| <button type="button" class="wellbeing-option" data-feeling="good" onclick="saveWellbeingCheckin('good')"></button> |
| <button type="button" class="wellbeing-option" data-feeling="okay" onclick="saveWellbeingCheckin('okay')"></button> |
| <button type="button" class="wellbeing-option" data-feeling="struggling" onclick="saveWellbeingCheckin('struggling')"></button> |
| <button type="button" class="wellbeing-option" data-feeling="skip" onclick="saveWellbeingCheckin('skip')"></button> |
| </div> |
| <p class="wellbeing-last-checkin" id="wellbeingLastCheckin" style="display: none;"></p> |
| <div class="wellbeing-reminder" id="wellbeingReminder"> |
| <p class="wellbeing-reminder-text" data-translate="wellbeing_reminder_text">Take 5 minutes for yourself today.</p> |
| <button type="button" class="wellbeing-reminder-dismiss" onclick="dismissWellbeingReminder()" data-translate="wellbeing_reminder_dismiss">Done</button> |
| </div> |
| </div> |
| </div> |
| |
| <!-- Status Card --> |
| <div class="status-card" id="statusCard" style="display:none"> |
| <div class="status-row"> |
| <div class="status-dot" id="statusDot"></div> |
| <div class="status-text" id="statusText"> |
| <strong data-translate="status_connecting">Connecting...</strong> |
| </div> |
| </div> |
| </div> |
| |
| <!-- Main Record Button --> |
| <section class="record-section"> |
| <button class="record-btn" id="recordBtn" onclick="toggleRecording()"> |
| <span class="icon"><svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v1a7 7 0 0 0 14 0v-1"/><line x1="12" y1="19" x2="12" y2="22"/></svg></span> |
| <span id="recordText" data-translate="record_tap">Tap to Record</span> |
| </button> |
| |
| <!-- Recording Timer --> |
| <div class="recording-timer" id="recordingTimer">0:00</div> |
| |
| <!-- Audio Level Meter --> |
| <div class="audio-level-container" id="audioLevelContainer"> |
| <div class="audio-level-bar" id="audioLevelBar"></div> |
| </div> |
| |
| <p class="record-hint" id="recordHint" data-translate="record_hint">Hold your phone near the baby</p> |
| <p class="min-duration-hint" id="minDurationHint" data-translate="record_min_duration">Minimum 5 seconds required</p> |
| </section> |
| |
| <!-- Calming Analysis Message --> |
| <div class="calming-message" id="calmingMessage"> |
| <div class="calming-icon">๐</div> |
| <div class="calming-text" data-translate="analyzing_carefully">Analyzing with care...</div> |
| <div class="calming-subtext" data-translate="understanding_needs">Understanding your baby's needs</div> |
| </div> |
| |
| <!-- Result Card --> |
| <div class="result-card" id="resultCard"> |
| <div class="result-placeholder" data-translate="result_placeholder"> |
| Record a baby cry to analyze |
| </div> |
| </div> |
| |
| <!-- Daily Summary Card --> |
| <div class="daily-summary-card" id="dailySummaryCard" style="display:none;"> |
| <div class="daily-summary-title" id="dailySummaryTitle"></div> |
| <div class="daily-summary-counts" id="dailySummaryCounts"></div> |
| <div class="daily-summary-most" id="dailySummaryMost"></div> |
| </div> |
| |
| <!-- Upload Section --> |
| <section class="upload-section"> |
| <input type="file" id="audioFile" accept="audio/*" class="hidden-input" onchange="analyzeFile()"> |
| <button class="upload-btn" onclick="document.getElementById('audioFile').click()"> |
| <span data-translate="upload_file">๐ Or upload an audio file</span> |
| </button> |
| </section> |
| |
| <!-- Admin Toggle --> |
| <button class="admin-toggle" onclick="toggleAdmin()" data-translate="learning_dashboard"> |
| โ๏ธ <span data-translate="learning_dashboard">Learning Dashboard</span> |
| </button> |
| |
| <!-- Admin Panel --> |
| <div class="admin-panel" id="adminPanel"> |
| <div class="stats-grid" id="statsGrid"> |
| <div class="stat-box"> |
| <div class="stat-value" id="statTotal">-</div> |
| <div class="stat-label">Submissions</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-value" id="statVerified">-</div> |
| <div class="stat-label">Verified</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-value" id="statProgress">-</div> |
| <div class="stat-label">To Retrain</div> |
| </div> |
| </div> |
| <button class="admin-btn" onclick="manualRetrain()" id="retrainBtn" data-translate="retrain_model"> |
| ๐ <span data-translate="retrain_model">Retrain Model</span> |
| </button> |
| </div> |
| </div> |
| |
| <!-- Install Banner --> |
| <div class="install-banner" id="installBanner"> |
| <div class="install-text"> |
| <strong data-translate="install_prompt">Install App</strong> |
| <span>Add to home screen for quick access</span> |
| </div> |
| <button class="install-btn" onclick="installApp()" data-translate="install_prompt">Install</button> |
| <button class="install-close" onclick="dismissInstall()" data-translate="install_close">ร</button> |
| </div> |
| |
| <!-- Toast --> |
| <div class="toast" id="toast"></div> |
| |
| <script> |
| // Translation system |
| let translations = {}; |
| // Always default to Arabic, ignore browser language |
| // Only use localStorage if user explicitly changed language |
| let storedLang = localStorage.getItem('preferred_lang'); |
| let currentLang = (storedLang && ['ar', 'fr', 'en'].includes(storedLang)) ? storedLang : 'ar'; |
| let isRTL = currentLang === 'ar'; |
| |
| // Load translations on page load |
| async function loadTranslations(lang = null) { |
| try { |
| // Use provided lang, or localStorage preference, or default to Arabic |
| // Always default to Arabic if no preference is set |
| const langToUse = lang || currentLang || localStorage.getItem('preferred_lang') || 'ar'; |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) |
| ? window.APP_CONFIG.getApiUrl(`/api/translations?lang=${langToUse}`) |
| : `/api/translations?lang=${langToUse}`; |
| |
| console.log('Loading translations for language:', langToUse); |
| const response = await fetch(url); |
| const data = await response.json(); |
| |
| translations = data.translations; |
| currentLang = data.lang; |
| isRTL = data.rtl; |
| |
| // Save to localStorage |
| localStorage.setItem('preferred_lang', currentLang); |
| |
| // Update HTML dir attribute |
| document.documentElement.setAttribute('dir', isRTL ? 'rtl' : 'ltr'); |
| document.documentElement.setAttribute('lang', currentLang); |
| |
| // Update language switcher |
| const langSwitcher = document.getElementById('languageSwitcher'); |
| if (langSwitcher) { |
| langSwitcher.value = currentLang; |
| } |
| |
| // Apply translations |
| applyTranslations(); |
| |
| console.log('Translations loaded and applied for:', currentLang); |
| } catch (e) { |
| console.error('Failed to load translations:', e); |
| } |
| } |
| |
| // Toast notification function with type support |
| function showToast(message, type = 'info', duration = 4000) { |
| const toast = document.getElementById('toast'); |
| if (!toast) { |
| console.warn('Toast element not found'); |
| alert(message); // Fallback to alert |
| return; |
| } |
| |
| // Remove all type classes |
| toast.classList.remove('success', 'error', 'info', 'warning'); |
| |
| // Add the appropriate type class |
| if (type === 'success' || type === 'error' || type === 'info' || type === 'warning') { |
| toast.classList.add(type); |
| } else { |
| toast.classList.add('info'); // Default to info |
| } |
| |
| toast.textContent = message; |
| toast.classList.add('visible'); |
| |
| setTimeout(() => { |
| toast.classList.remove('visible'); |
| // Clean up type class after animation |
| setTimeout(() => { |
| toast.classList.remove('success', 'error', 'info', 'warning'); |
| }, 300); |
| }, duration); |
| } |
| |
| // Apply translations to all elements with data-translate attribute |
| function applyTranslations() { |
| document.querySelectorAll('[data-translate]').forEach(el => { |
| const key = el.getAttribute('data-translate'); |
| const value = getTranslation(key); |
| if (value && value !== key) { |
| // Handle different element types |
| if (el.tagName === 'INPUT') { |
| // For input elements, set placeholder or value |
| if (el.type === 'text' || el.type === 'search' || el.type === 'email') { |
| el.placeholder = value; |
| } else { |
| el.value = value; |
| } |
| } else if (el.tagName === 'BUTTON') { |
| // For buttons, check for nested span or set text directly |
| const span = el.querySelector('span'); |
| if (span) { |
| span.textContent = value; |
| } else { |
| el.textContent = value; |
| } |
| } else if (el.tagName === 'LABEL') { |
| // For labels, preserve nested elements like optional spans |
| const optionalSpan = el.querySelector('span.optional'); |
| const requiredSpan = el.querySelector('span[style*="color"]'); |
| if (optionalSpan || requiredSpan) { |
| // Get optional label text |
| const optionalKey = optionalSpan ? optionalSpan.getAttribute('data-translate') : null; |
| const optionalValue = optionalKey ? getTranslation(optionalKey) : ''; |
| |
| // Build label content preserving structure |
| let labelContent = value; |
| if (optionalSpan && optionalValue && optionalValue !== optionalKey) { |
| labelContent += ' <span class="optional" data-translate="' + (optionalKey || 'optional_label') + '">' + optionalValue + '</span>'; |
| } else if (optionalSpan) { |
| labelContent += ' <span class="optional" data-translate="optional_label">(optional)</span>'; |
| } |
| if (requiredSpan) { |
| labelContent += requiredSpan.outerHTML; |
| } |
| el.innerHTML = labelContent; |
| } else { |
| el.textContent = value; |
| } |
| } else { |
| // For other elements, set text content |
| el.textContent = value; |
| } |
| } |
| }); |
| |
| // Handle placeholder translations |
| document.querySelectorAll('[data-translate-placeholder]').forEach(el => { |
| const key = el.getAttribute('data-translate-placeholder'); |
| const value = getTranslation(key); |
| if (value && value !== key) { |
| el.placeholder = value; |
| } |
| }); |
| |
| // Also update dynamic content that might have been set by JavaScript |
| // This includes result displays, status messages, etc. |
| updateDynamicContent(); |
| } |
| |
| // Update dynamic content that might need translation |
| function updateDynamicContent() { |
| // Update status messages if they exist |
| const statusEl = document.getElementById('statusText'); |
| if (statusEl) { |
| const status = statusEl.textContent.trim(); |
| if (status.includes('Ready') || status.includes('ุฌุงูุฒ') || status.includes('Prรชt')) { |
| statusEl.textContent = getTranslation('status_ready'); |
| } else if (status.includes('Connecting') || status.includes('ุฌุงุฑู ุงูุงุชุตุงู') || status.includes('Connexion')) { |
| statusEl.textContent = getTranslation('status_connecting'); |
| } |
| } |
| } |
| |
| // Get translation by key (supports nested keys like "categories.hunger") |
| function getTranslation(key) { |
| const keys = key.split('.'); |
| let value = translations; |
| for (const k of keys) { |
| if (value && typeof value === 'object') { |
| value = value[k]; |
| } else { |
| return key; |
| } |
| } |
| return value || key; |
| } |
| |
| // Settings Functions |
| function openSettings() { |
| const sidebar = document.getElementById('settingsSidebar'); |
| const overlay = document.getElementById('settingsOverlay'); |
| if (!sidebar || !overlay) return; |
| |
| // Load current settings |
| const currentLang = localStorage.getItem('itmain_language') || localStorage.getItem('preferred_lang') || 'ar'; |
| const langSelect = document.getElementById('settingsLanguage'); |
| if (langSelect) { |
| langSelect.value = currentLang; |
| } |
| |
| const isDark = localStorage.getItem('itmain_dark_theme') === 'true'; |
| const themeToggle = document.getElementById('darkThemeToggle'); |
| if (themeToggle) { |
| themeToggle.checked = isDark; |
| } |
| |
| const analysisMode = localStorage.getItem('bbplease_analysis_mode') || 'local'; |
| const analysisModeSelect = document.getElementById('settingsAnalysisMode'); |
| if (analysisModeSelect) { |
| analysisModeSelect.value = (analysisMode === 'cloud') ? 'cloud' : 'local'; |
| } |
| |
| // Show sidebar and overlay |
| sidebar.classList.add('active'); |
| overlay.classList.add('active'); |
| // Prevent body scroll when sidebar is open |
| document.body.style.overflow = 'hidden'; |
| } |
| |
| function closeSettings() { |
| const sidebar = document.getElementById('settingsSidebar'); |
| const overlay = document.getElementById('settingsOverlay'); |
| if (sidebar) { |
| sidebar.classList.remove('active'); |
| } |
| if (overlay) { |
| overlay.classList.remove('active'); |
| } |
| // Restore body scroll |
| document.body.style.overflow = ''; |
| } |
| |
| // Close sidebar on Escape key |
| document.addEventListener('keydown', function(e) { |
| if (e.key === 'Escape') { |
| closeSettings(); |
| } |
| }); |
| |
| function changeLanguageFromSettings(lang) { |
| changeLanguage(lang); |
| closeSettings(); |
| } |
| |
| function changeAnalysisModeFromSettings(mode) { |
| if (mode === 'cloud' || mode === 'local') { |
| localStorage.setItem('bbplease_analysis_mode', mode); |
| } |
| } |
| |
| function toggleDarkTheme(enabled) { |
| if (enabled) { |
| document.documentElement.setAttribute('data-theme', 'dark'); |
| localStorage.setItem('itmain_dark_theme', 'true'); |
| } else { |
| document.documentElement.removeAttribute('data-theme'); |
| localStorage.setItem('itmain_dark_theme', 'false'); |
| } |
| } |
| |
| function handleLogout() { |
| const confirmMessage = getTranslation('logout_confirm_message') || getTranslation('logout_confirm') || 'ูู ุฃูุช ู
ุชุฃูุฏ ุฃูู ุชุฑูุฏ ุชุณุฌูู ุงูุฎุฑูุฌุ'; |
| |
| // Use confirmation dialog with Arabic support |
| if (confirm(confirmMessage)) { |
| localStorage.removeItem(USER_DATA_KEY); |
| localStorage.removeItem('itmain_language'); |
| localStorage.removeItem('itmain_dark_theme'); |
| window.location.href = '/login'; |
| } |
| } |
| |
| // Load saved theme on page load |
| function loadTheme() { |
| const isDark = localStorage.getItem('itmain_dark_theme') === 'true'; |
| if (isDark) { |
| document.documentElement.setAttribute('data-theme', 'dark'); |
| } |
| } |
| |
| async function changeLanguage(lang) { |
| try { |
| console.log('Changing language to:', lang); |
| |
| // Save to localStorage immediately for instant feedback |
| localStorage.setItem('preferred_lang', lang); |
| |
| // Set language in session |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) |
| ? window.APP_CONFIG.getApiUrl('/api/set-language') |
| : '/api/set-language'; |
| const response = await fetch(url, { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({lang: lang}) |
| }); |
| |
| if (response.ok) { |
| console.log('Language set in session, loading translations...'); |
| // Reload translations with the new language |
| await loadTranslations(lang); |
| console.log('Translations loaded, applying to UI...'); |
| |
| // Show success message |
| const langNames = { |
| 'ar': 'ุงูุนุฑุจูุฉ', |
| 'fr': 'Franรงais', |
| 'en': 'English' |
| }; |
| showToast(`Language changed to ${langNames[lang] || lang}`); |
| } else { |
| const error = await response.json(); |
| console.error('Failed to set language:', error); |
| // Still try to load translations from localStorage |
| await loadTranslations(lang); |
| showToast('Language changed (session update failed)'); |
| } |
| } catch (e) { |
| console.error('Failed to change language:', e); |
| // Still try to load translations from localStorage |
| await loadTranslations(lang); |
| showToast('Language changed (using local storage)'); |
| } |
| } |
| |
| // State |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let isRecording = false; |
| let currentPrediction = null; |
| let currentAudioId = null; |
| let deferredPrompt = null; |
| |
| // Recording timer and audio level |
| let recordingStartTime = null; |
| let timerInterval = null; |
| let audioContext = null; |
| let analyser = null; |
| let audioLevelInterval = null; |
| const MIN_RECORDING_DURATION = 5; // seconds |
| |
| // Load translations on page load - force Arabic as default |
| // Load saved theme on page load |
| function loadTheme() { |
| const isDark = localStorage.getItem('itmain_dark_theme') === 'true'; |
| if (isDark) { |
| document.documentElement.setAttribute('data-theme', 'dark'); |
| } |
| } |
| |
| window.addEventListener('DOMContentLoaded', async function() { |
| loadTheme(); // Load saved theme |
| // Force Arabic as default if no preference set |
| const storedLang = localStorage.getItem('preferred_lang'); |
| if (!storedLang || !['ar', 'fr', 'en'].includes(storedLang)) { |
| currentLang = 'ar'; |
| isRTL = true; |
| // Set Arabic in localStorage to ensure consistency |
| localStorage.setItem('preferred_lang', 'ar'); |
| // Also set HTML attributes immediately for RTL |
| document.documentElement.setAttribute('dir', 'rtl'); |
| document.documentElement.setAttribute('lang', 'ar'); |
| } else { |
| // Use stored language but ensure currentLang is set |
| currentLang = storedLang; |
| isRTL = (storedLang === 'ar'); |
| } |
| |
| // Load translations (will default to Arabic if none set) |
| await loadTranslations(currentLang); |
| |
| // Apply translations immediately (this should translate all data-translate elements) |
| applyTranslations(); |
| |
| // Initialize intl-tel-input for phone number with Tunisia (+216) as default |
| // This will be initialized when welcome screen is shown |
| initializePhoneInput(); |
| |
| // After translations load, check auth (which will show welcome screen if needed) |
| // Apply translations again when welcome screen is shown |
| await checkAuth(); |
| |
| // Initialize platform-specific features and app features |
| initPlatformSpecificFeatures(); |
| loadStatus(); |
| loadStats(); |
| refreshDailySummary(); |
| registerServiceWorker(); |
| }); |
| |
| // PWA Install |
| window.addEventListener('beforeinstallprompt', (e) => { |
| e.preventDefault(); |
| deferredPrompt = e; |
| // Show install banner after a delay |
| setTimeout(() => { |
| if (deferredPrompt && !localStorage.getItem('installDismissed')) { |
| document.getElementById('installBanner').classList.add('visible'); |
| } |
| }, 3000); |
| }); |
| |
| async function installApp() { |
| if (deferredPrompt) { |
| deferredPrompt.prompt(); |
| const { outcome } = await deferredPrompt.userChoice; |
| if (outcome === 'accepted') { |
| showToast('App installed!'); |
| } |
| deferredPrompt = null; |
| document.getElementById('installBanner').classList.remove('visible'); |
| } |
| } |
| |
| function dismissInstall() { |
| document.getElementById('installBanner').classList.remove('visible'); |
| localStorage.setItem('installDismissed', 'true'); |
| } |
| |
| // Platform detection |
| function initPlatformSpecificFeatures() { |
| const isMobile = window.Capacitor && |
| (window.Capacitor.getPlatform() === 'android' || |
| window.Capacitor.getPlatform() === 'ios'); |
| |
| if (isMobile) { |
| const uploadSection = document.querySelector('.upload-section'); |
| if (uploadSection) { |
| uploadSection.style.display = 'none'; |
| } |
| } |
| } |
| |
| // Authentication |
| const USER_DATA_KEY = 'itmain_user_data'; |
| |
| async function checkAuth() { |
| const userData = localStorage.getItem(USER_DATA_KEY); |
| // Check if we're on login page |
| if (window.location.pathname === '/login') { |
| showLoginScreen(); |
| return false; |
| } |
| if (!userData) { |
| // Show login screen instead of welcome screen if no user data |
| showLoginScreen(); |
| return false; |
| } |
| // User is logged in - hide welcome/login screens and show main app directly |
| hideWelcomeScreen(); |
| hideLoginScreen(); |
| const app = document.querySelector('.app'); |
| if (app) { |
| app.style.display = 'block'; |
| } |
| updateBabyDisplay(); |
| showOrHideHistoryButton(); |
| return true; |
| } |
| |
| function getBabyAgeText(birthday) { |
| if (!birthday || typeof birthday !== 'string') return ''; |
| const parts = birthday.trim().split('-'); |
| if (parts.length !== 3) return ''; |
| const y = parseInt(parts[0], 10), m = parseInt(parts[1], 10), d = parseInt(parts[2], 10); |
| if (isNaN(y) || isNaN(m) || isNaN(d)) return ''; |
| const birth = new Date(y, m - 1, d); |
| if (isNaN(birth.getTime())) return ''; |
| const now = new Date(); |
| const diffMs = now - birth; |
| const diffWeeks = Math.floor(diffMs / (7 * 24 * 60 * 60 * 1000)); |
| const diffMonths = (now.getFullYear() - birth.getFullYear()) * 12 + (now.getMonth() - birth.getMonth()); |
| if (diffWeeks < 0 || diffMonths < 0) return ''; |
| if (diffWeeks < 8) return diffWeeks === 1 ? '1 week' : diffWeeks + ' weeks'; |
| return diffMonths === 1 ? '1 month' : diffMonths + ' months'; |
| } |
| |
| function updateBabyDisplay() { |
| const el = document.getElementById('babyDisplay'); |
| if (!el) return; |
| try { |
| const raw = localStorage.getItem(USER_DATA_KEY); |
| if (!raw) { el.setAttribute('aria-hidden', 'true'); el.textContent = ''; return; } |
| const data = JSON.parse(raw); |
| const name = data.baby_profile && data.baby_profile.name ? data.baby_profile.name : (data.baby_name || null); |
| if (!name) { el.setAttribute('aria-hidden', 'true'); el.textContent = ''; return; } |
| const birthday = data.baby_profile && data.baby_profile.birthday ? data.baby_profile.birthday : null; |
| const ageText = getBabyAgeText(birthday); |
| el.textContent = ageText ? (name + ' ยท ' + ageText) : name; |
| el.setAttribute('aria-hidden', 'false'); |
| } catch (e) { |
| el.setAttribute('aria-hidden', 'true'); |
| el.textContent = ''; |
| } |
| } |
| |
| function showOrHideHistoryButton() { |
| let hasUser = false; |
| try { |
| const raw = localStorage.getItem(USER_DATA_KEY); |
| hasUser = !!(raw && JSON.parse(raw).user_id); |
| } catch (e) {} |
| const historyBtn = document.getElementById('historyBtn'); |
| const wellbeingBtn = document.getElementById('wellbeingBtn'); |
| if (historyBtn) historyBtn.style.display = hasUser ? '' : 'none'; |
| if (wellbeingBtn) wellbeingBtn.style.display = hasUser ? '' : 'none'; |
| } |
| |
| function openWellbeingPanel() { |
| document.getElementById('wellbeingOverlay').classList.add('visible'); |
| document.getElementById('wellbeingPanel').classList.add('visible'); |
| renderWellbeingLinks(); |
| updateWellbeingCheckinDisplay(); |
| updateWellbeingReminderDisplay(); |
| } |
| |
| function closeWellbeingPanel() { |
| document.getElementById('wellbeingOverlay').classList.remove('visible'); |
| document.getElementById('wellbeingPanel').classList.remove('visible'); |
| } |
| |
| const WELLBEING_LINKS = [ |
| { labelKey: 'wellbeing_link_1_label', url: 'https://www.postpartum.net/' }, |
| { labelKey: 'wellbeing_link_2_label', url: 'https://findahelpline.com/' }, |
| { labelKey: 'wellbeing_link_3_label', url: 'https://www.unicef.org/parenting/' } |
| ]; |
| |
| function renderWellbeingLinks() { |
| const ul = document.getElementById('wellbeingLinks'); |
| if (!ul) return; |
| ul.innerHTML = ''; |
| WELLBEING_LINKS.forEach(function(link) { |
| const li = document.createElement('li'); |
| const a = document.createElement('a'); |
| a.href = link.url; |
| a.target = '_blank'; |
| a.rel = 'noopener'; |
| a.textContent = getTranslation(link.labelKey) || link.labelKey; |
| a.className = 'wellbeing-link'; |
| li.appendChild(a); |
| ul.appendChild(li); |
| }); |
| } |
| |
| function updateWellbeingCheckinDisplay() { |
| const container = document.getElementById('wellbeingCheckin'); |
| const lastEl = document.getElementById('wellbeingLastCheckin'); |
| if (!container || !lastEl) return; |
| const opts = container.querySelectorAll('.wellbeing-option'); |
| opts.forEach(function(btn) { |
| const f = btn.getAttribute('data-feeling'); |
| const key = 'wellbeing_checkin_' + f; |
| btn.textContent = getTranslation(key) || f; |
| }); |
| try { |
| const raw = localStorage.getItem('itmain_wellbeing_checkin'); |
| if (raw) { |
| const data = JSON.parse(raw); |
| const d = data.timestamp ? new Date(data.timestamp) : null; |
| if (d && !isNaN(d.getTime())) { |
| lastEl.textContent = (getTranslation('wellbeing_last_checkin') || 'Last check-in: {date}').replace('{date}', d.toLocaleDateString()); |
| lastEl.style.display = 'block'; |
| } |
| } |
| } catch (e) {} |
| } |
| |
| function saveWellbeingCheckin(feeling) { |
| try { |
| localStorage.setItem('itmain_wellbeing_checkin', JSON.stringify({ feeling: feeling, timestamp: new Date().toISOString() })); |
| updateWellbeingCheckinDisplay(); |
| } catch (e) {} |
| } |
| |
| function updateWellbeingReminderDisplay() { |
| const reminderEl = document.getElementById('wellbeingReminder'); |
| if (!reminderEl) return; |
| const today = new Date().toDateString(); |
| try { |
| const dismissed = localStorage.getItem('itmain_wellbeing_reminder_dismissed'); |
| reminderEl.style.display = (dismissed === today) ? 'none' : 'block'; |
| } catch (e) { |
| reminderEl.style.display = 'block'; |
| } |
| } |
| |
| function dismissWellbeingReminder() { |
| try { |
| localStorage.setItem('itmain_wellbeing_reminder_dismissed', new Date().toDateString()); |
| const reminderEl = document.getElementById('wellbeingReminder'); |
| if (reminderEl) reminderEl.style.display = 'none'; |
| } catch (e) {} |
| } |
| |
| function openHistoryPanel() { |
| document.getElementById('historyOverlay').classList.add('visible'); |
| document.getElementById('historyPanel').classList.add('visible'); |
| loadAndRenderHistory(); |
| } |
| |
| function closeHistoryPanel() { |
| document.getElementById('historyOverlay').classList.remove('visible'); |
| document.getElementById('historyPanel').classList.remove('visible'); |
| } |
| |
| async function loadAndRenderHistory() { |
| const listEl = document.getElementById('historyList'); |
| const emptyEl = document.getElementById('historyEmpty'); |
| if (!listEl || !emptyEl) return; |
| listEl.innerHTML = '<div class="spinner" style="margin: 16px auto;"></div>'; |
| emptyEl.style.display = 'none'; |
| let user_id = null; |
| try { |
| const raw = localStorage.getItem(USER_DATA_KEY); |
| if (raw) user_id = JSON.parse(raw).user_id; |
| } catch (e) {} |
| if (!user_id) { |
| listEl.innerHTML = ''; |
| emptyEl.style.display = 'block'; |
| return; |
| } |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) ? window.APP_CONFIG.getApiUrl('/api/me/predictions') : '/api/me/predictions'; |
| try { |
| const res = await fetch(url, { headers: { 'X-User-ID': user_id } }); |
| const data = await res.json(); |
| if (!res.ok) { |
| listEl.innerHTML = '<p style="color: var(--accent-orange);">' + (data.error || 'Error loading history') + '</p>'; |
| return; |
| } |
| const predictions = data.predictions || []; |
| listEl.innerHTML = ''; |
| if (predictions.length === 0) { |
| emptyEl.style.display = 'block'; |
| return; |
| } |
| emptyEl.style.display = 'none'; |
| predictions.forEach(function(p) { |
| const emoji = getEmoji(p.prediction); |
| const categoryLabel = getTranslation('categories.' + (p.prediction || '')); |
| const ts = p.timestamp || ''; |
| const dateStr = ts ? (new Date(ts).toLocaleDateString() + ' ' + new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })) : ''; |
| const confidence = p.confidence != null ? Math.round(p.confidence) + '%' : ''; |
| const whatHelped = (p.what_helped && p.what_helped.trim()) ? p.what_helped.trim() : ''; |
| const div = document.createElement('div'); |
| div.className = 'history-item'; |
| div.setAttribute('data-id', p.id); |
| div.innerHTML = '<div class="history-item-date">' + dateStr + (confidence ? ' ยท ' + confidence : '') + '</div>' + |
| '<div class="history-item-category">' + emoji + ' ' + categoryLabel + '</div>' + |
| '<div class="history-item-what-helped" id="wh-' + p.id + '">' + |
| (whatHelped ? ('<span>' + escapeHtml(whatHelped) + '</span>') : '') + |
| '<input type="text" id="wh-input-' + p.id + '" placeholder="' + (getTranslation('what_helped_placeholder') || '') + '" value="' + escapeHtml(whatHelped) + '" style="' + (whatHelped ? 'display:none;' : '') + '">' + |
| '<div class="history-item-actions">' + |
| (whatHelped ? '<button type="button">' + (getTranslation('add_what_helped') || 'Edit') + '</button>' : |
| '<button type="button">' + (getTranslation('add_what_helped') || 'Add what helped') + '</button>') + |
| '</div></div>'; |
| listEl.appendChild(div); |
| const whWrap = div.querySelector('#wh-' + p.id); |
| const span = whWrap ? whWrap.querySelector('span') : null; |
| const inp = div.querySelector('#wh-input-' + p.id); |
| const btn = div.querySelector('.history-item-actions button'); |
| if (btn) { |
| if (whatHelped) { |
| btn.onclick = function() { |
| if (span) span.style.display = 'none'; |
| if (inp) { inp.style.display = 'block'; inp.focus(); } |
| btn.textContent = getTranslation('save') || 'Save'; |
| btn.onclick = function() { saveWhatHelped(p.id); }; |
| }; |
| } else { |
| btn.onclick = function() { saveWhatHelped(p.id); }; |
| } |
| } |
| }); |
| } catch (e) { |
| listEl.innerHTML = '<p style="color: var(--accent-orange);">' + (getTranslation('errors.network_error') || 'Network error') + '</p>'; |
| } |
| } |
| |
| function escapeHtml(s) { |
| if (!s) return ''; |
| const div = document.createElement('div'); |
| div.textContent = s; |
| return div.innerHTML; |
| } |
| |
| async function saveWhatHelped(id) { |
| const inp = document.getElementById('wh-input-' + id); |
| const value = inp ? inp.value.trim() : ''; |
| let user_id = null; |
| try { |
| const raw = localStorage.getItem(USER_DATA_KEY); |
| if (raw) user_id = JSON.parse(raw).user_id; |
| } catch (e) {} |
| if (!user_id) return; |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) ? window.APP_CONFIG.getApiUrl('/api/me/predictions/' + id) : '/api/me/predictions/' + id; |
| try { |
| const res = await fetch(url, { |
| method: 'PATCH', |
| headers: { 'Content-Type': 'application/json', 'X-User-ID': user_id }, |
| body: JSON.stringify({ what_helped: value || null }) |
| }); |
| if (res.ok) { |
| const wrap = document.getElementById('wh-' + id); |
| if (wrap) { |
| const span = wrap.querySelector('span'); |
| const inpEl = wrap.querySelector('input'); |
| const btn = wrap.parentElement.querySelector('.history-item-actions button'); |
| if (!span && value) { |
| const newSpan = document.createElement('span'); |
| newSpan.textContent = value; |
| wrap.insertBefore(newSpan, wrap.querySelector('input')); |
| } |
| const spanEl = wrap.querySelector('span'); |
| if (spanEl) { spanEl.textContent = value || ''; spanEl.style.display = value ? 'inline' : 'none'; } |
| if (inpEl) { inpEl.value = value; inpEl.style.display = 'none'; } |
| if (btn) { btn.textContent = getTranslation('add_what_helped') || 'Add what helped'; btn.onclick = function() { |
| if (spanEl) spanEl.style.display = 'none'; |
| if (inpEl) { inpEl.style.display = 'block'; inpEl.focus(); } |
| btn.textContent = getTranslation('save') || 'Save'; |
| btn.onclick = function() { saveWhatHelped(id); }; |
| }; } |
| } |
| } |
| } catch (e) {} |
| } |
| |
| function showLoginScreen() { |
| const loginScreen = document.getElementById('loginScreen'); |
| const welcomeScreen = document.getElementById('welcomeScreen'); |
| const app = document.querySelector('.app'); |
| if (loginScreen) { |
| loginScreen.style.display = 'flex'; |
| loginScreen.classList.add('visible'); |
| } |
| if (welcomeScreen) { |
| welcomeScreen.classList.remove('visible'); |
| } |
| if (app) { |
| app.style.display = 'none'; |
| } |
| applyTranslations(); |
| // Initialize phone input when login screen is shown |
| setTimeout(() => { |
| initializePhoneInput(); |
| }, 100); |
| } |
| |
| function hideLoginScreen() { |
| const loginScreen = document.getElementById('loginScreen'); |
| const app = document.querySelector('.app'); |
| if (loginScreen) { |
| loginScreen.style.display = 'none'; |
| loginScreen.classList.remove('visible'); |
| } |
| if (app) { |
| app.style.display = 'block'; |
| } |
| } |
| |
| function initializePhoneInput() { |
| // Initialize intl-tel-input for both login and registration contact fields |
| const contactFields = ['userContact', 'loginContact']; |
| |
| contactFields.forEach(fieldId => { |
| const field = document.getElementById(fieldId); |
| if (window.intlTelInput && field) { |
| // Destroy existing instance if any |
| if (window[fieldId + 'Input']) { |
| window[fieldId + 'Input'].destroy(); |
| } |
| // Initialize with Tunisia (+216) as default |
| window[fieldId + 'Input'] = intlTelInput(field, { |
| initialCountry: 'tn', // Tunisia |
| preferredCountries: ['tn', 'fr', 'dz', 'ma', 'sa', 'ae'], |
| utilsScript: 'https://cdn.jsdelivr.net/npm/intl-tel-input@19.5.6/build/js/utils.js', |
| separateDialCode: false, |
| nationalMode: false, |
| allowDropdown: true, |
| autoHideDialCode: false |
| }); |
| |
| // Force LTR direction for phone input to prevent number inversion |
| field.style.direction = 'ltr'; |
| field.style.textAlign = 'left'; |
| |
| // Add event listener to detect if user is typing email or phone |
| field.addEventListener('input', function(e) { |
| const value = e.target.value.trim(); |
| // Ensure LTR direction is maintained |
| e.target.style.direction = 'ltr'; |
| e.target.style.textAlign = 'left'; |
| }); |
| } |
| }); |
| } |
| |
| function showWelcomeScreen() { |
| const welcomeScreen = document.getElementById('welcomeScreen'); |
| const loginScreen = document.getElementById('loginScreen'); |
| const app = document.querySelector('.app'); |
| if (welcomeScreen) { |
| welcomeScreen.classList.add('visible'); |
| } |
| if (loginScreen) { |
| loginScreen.style.display = 'none'; |
| loginScreen.classList.remove('visible'); |
| } |
| if (app) { |
| app.style.display = 'none'; |
| } |
| // Ensure translations are applied to welcome screen |
| applyTranslations(); |
| // Initialize phone input when welcome screen is shown |
| setTimeout(() => { |
| initializePhoneInput(); |
| }, 100); |
| } |
| |
| function hideWelcomeScreen() { |
| const welcomeScreen = document.getElementById('welcomeScreen'); |
| const app = document.querySelector('.app'); |
| if (welcomeScreen) { |
| welcomeScreen.classList.remove('visible'); |
| } |
| if (app) { |
| app.style.display = 'block'; |
| } |
| } |
| |
| function showRegister() { |
| hideLoginScreen(); |
| showWelcomeScreen(); |
| } |
| |
| async function handleLoginSubmit(event) { |
| event.preventDefault(); |
| const identifier = document.getElementById('loginContact').value.trim(); // Can be email or phone |
| const password = document.getElementById('loginPassword').value; |
| |
| if (!identifier || !password) { |
| showToast(getTranslation('errors.fill_all_fields') || 'Please fill all fields', 'error'); |
| return; |
| } |
| |
| // Show loader |
| const submitBtn = event.target.querySelector('button[type="submit"]'); |
| const originalText = submitBtn.textContent; |
| submitBtn.disabled = true; |
| submitBtn.innerHTML = '<div class="spinner" style="margin: 0 auto; width: 20px; height: 20px;"></div>'; |
| |
| try { |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) |
| ? window.APP_CONFIG.getApiUrl('/api/users/login') |
| : '/api/users/login'; |
| |
| // Detect if identifier is email or phone for login |
| let email = null; |
| let phone = null; |
| |
| if (identifier.includes('@') && identifier.includes('.')) { |
| email = identifier.toLowerCase().trim(); |
| } else { |
| // It's a phone number - get formatted number with country code |
| const loginPhoneInput = window.loginContactInput; |
| if (loginPhoneInput) { |
| try { |
| const phoneNumber = loginPhoneInput.getNumber(intlTelInputUtils.numberFormat.E164); |
| if (phoneNumber) { |
| phone = phoneNumber; |
| } else { |
| phone = identifier; |
| } |
| } catch (e) { |
| phone = identifier; |
| } |
| } else { |
| phone = identifier; |
| } |
| } |
| |
| const response = await fetch(url, { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ email: email || phone, password }) // Backend accepts 'email' field but can be email or phone |
| }); |
| |
| const result = await response.json(); |
| |
| if (!response.ok || !result.success) { |
| const errorMsg = result.error || getTranslation('errors.login_failed') || 'Login failed'; |
| showToast(errorMsg, 'error', 5000); |
| submitBtn.disabled = false; |
| submitBtn.textContent = originalText; |
| return; |
| } |
| |
| // Save user data to localStorage (include baby_profile for personalization) |
| const userData = { |
| user_id: result.user_id, |
| email: result.email, |
| phone: result.phone || null, |
| baby_name: result.baby_name, |
| baby_profile: { |
| name: result.baby_name || null, |
| gender: result.baby_gender || null, |
| birthday: result.baby_birthday || null |
| }, |
| logged_in: true |
| }; |
| localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData)); |
| updateBabyDisplay(); |
| showOrHideHistoryButton(); |
| |
| // Show success message |
| showToast(getTranslation('login_success') || 'โ
Login successful!', 'success', 3000); |
| |
| // Hide login screen and welcome screen, show main app directly |
| hideLoginScreen(); |
| hideWelcomeScreen(); // Ensure welcome screen is hidden |
| |
| // Show main app directly (skip welcome/register) - no page reload needed |
| const app = document.querySelector('.app'); |
| if (app) { |
| app.style.display = 'block'; |
| } |
| |
| // No redirect needed - we've already updated the UI state |
| } catch (e) { |
| console.error('โ Login error:', e); |
| showToast(getTranslation('errors.network_error') || 'Network error. Please try again.', 'error', 5000); |
| submitBtn.disabled = false; |
| submitBtn.textContent = originalText; |
| } |
| } |
| |
| async function handleWelcomeSubmit(event) { |
| event.preventDefault(); |
| |
| // Show loader |
| const form = event.target; |
| const submitBtn = form.querySelector('button[type="submit"]'); |
| const originalText = submitBtn.textContent; |
| submitBtn.disabled = true; |
| submitBtn.innerHTML = '<div class="spinner" style="margin: 0 auto; width: 20px; height: 20px;"></div> ' + (getTranslation('registering') || 'Registering...'); |
| |
| // Disable all form inputs |
| const inputs = form.querySelectorAll('input, select, button'); |
| inputs.forEach(input => { |
| if (input !== submitBtn) input.disabled = true; |
| }); |
| |
| const contactValue = document.getElementById('userContact').value.trim(); |
| const password = document.getElementById('registerPassword').value; |
| |
| // Validate: Contact field must be provided |
| if (!contactValue) { |
| showToast(getTranslation('errors.email_or_phone_required') || 'Email or phone number is required', 'error', 4000); |
| submitBtn.disabled = false; |
| submitBtn.textContent = originalText; |
| inputs.forEach(input => { |
| if (input !== submitBtn) input.disabled = false; |
| }); |
| return; |
| } |
| |
| // Detect if input is email or phone |
| let email = null; |
| let phone = null; |
| |
| if (contactValue.includes('@') && contactValue.includes('.')) { |
| // It's an email |
| email = contactValue.toLowerCase().trim(); |
| // Validate email format |
| if (!email.includes('@') || !email.split('@')[1].includes('.')) { |
| showToast(getTranslation('errors.invalid_email') || 'Please enter a valid email address', 'error', 4000); |
| submitBtn.disabled = false; |
| submitBtn.textContent = originalText; |
| inputs.forEach(input => { |
| if (input !== submitBtn) input.disabled = false; |
| }); |
| return; |
| } |
| } else { |
| // It's a phone number - get formatted number with country code |
| const phoneInputInstance = window.userContactInput; |
| if (phoneInputInstance) { |
| try { |
| const phoneNumber = phoneInputInstance.getNumber(intlTelInputUtils.numberFormat.E164); |
| if (phoneNumber) { |
| phone = phoneNumber; |
| // Validate phone number |
| if (!phoneInputInstance.isValidNumber()) { |
| showToast(getTranslation('errors.invalid_phone') || 'Please enter a valid phone number', 'error', 4000); |
| submitBtn.disabled = false; |
| submitBtn.textContent = originalText; |
| inputs.forEach(input => { |
| if (input !== submitBtn) input.disabled = false; |
| }); |
| return; |
| } |
| } else { |
| // Fallback: use raw value if intl-tel-input fails |
| phone = contactValue; |
| } |
| } catch (e) { |
| // If utils not loaded, use raw value |
| phone = contactValue; |
| } |
| } else { |
| // Fallback if intl-tel-input not loaded |
| phone = contactValue; |
| } |
| } |
| |
| if (!password || password.length < 6) { |
| showToast(getTranslation('errors.password_too_short') || 'Password must be at least 6 characters', 'error', 4000); |
| submitBtn.disabled = false; |
| submitBtn.textContent = originalText; |
| inputs.forEach(input => { |
| if (input !== submitBtn) input.disabled = false; |
| }); |
| return; |
| } |
| |
| // Normalize email (already done above) |
| const normalizedEmail = email; |
| |
| const userData = { |
| user_id: 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9), |
| email: normalizedEmail, |
| phone: phone, |
| created_at: new Date().toISOString(), |
| baby_profile: { |
| name: document.getElementById('babyName').value, |
| gender: document.getElementById('babyGender').value || null, |
| birthday: document.getElementById('babyBirthday').value |
| } |
| }; |
| |
| // Detect platform |
| const isCapacitor = window.Capacitor !== undefined; |
| const platform = isCapacitor ? (window.Capacitor.getPlatform() || 'web') : 'web'; |
| |
| // Send registration to server and wait for response |
| try { |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) |
| ? window.APP_CONFIG.getApiUrl('/api/users/register') |
| : '/api/users/register'; |
| |
| const response = await fetch(url, { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ |
| user_id: userData.user_id, |
| email: normalizedEmail, |
| phone: phone, |
| password: password, |
| baby_name: userData.baby_profile.name, |
| baby_gender: userData.baby_profile.gender, |
| baby_birthday: userData.baby_profile.birthday, |
| platform: platform, |
| language: currentLang || 'ar' |
| }) |
| }); |
| |
| const result = await response.json(); |
| |
| if (!response.ok || !result.success) { |
| const errorMsg = result.error || getTranslation('errors.registration_failed') || 'Registration failed'; |
| console.error('โ Registration failed:', errorMsg); |
| showToast(errorMsg, 'error', 5000); |
| submitBtn.disabled = false; |
| submitBtn.textContent = originalText; |
| inputs.forEach(input => { |
| if (input !== submitBtn) input.disabled = false; |
| }); |
| return; |
| } |
| |
| // Registration successful - update user_id if server returned a different one |
| if (result.user_id && result.user_id !== userData.user_id) { |
| userData.user_id = result.user_id; |
| } |
| |
| console.log('โ
User registered successfully:', userData.user_id); |
| |
| // Show success message |
| showToast(getTranslation('registration_success') || 'โ
Registration successful! Welcome!', 'success', 4000); |
| |
| // Only save to localStorage and proceed if registration was successful |
| localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData)); |
| updateBabyDisplay(); |
| showOrHideHistoryButton(); |
| |
| // Hide welcome screen after showing success message |
| setTimeout(() => { |
| hideWelcomeScreen(); |
| }, 2000); |
| |
| // Track user signup |
| if (window.AppAnalytics) { |
| window.AppAnalytics.trackEvent('user_registered', { |
| has_email: !!userData.email, |
| has_phone: !!userData.phone, |
| has_gender: !!userData.baby_profile.gender |
| }); |
| } |
| } catch (e) { |
| console.error('โ Failed to register user on server:', e); |
| showToast(getTranslation('errors.network_error') || 'Network error. Please try again.', 'error', 5000); |
| submitBtn.disabled = false; |
| submitBtn.textContent = originalText; |
| inputs.forEach(input => { |
| if (input !== submitBtn) input.disabled = false; |
| }); |
| } |
| } |
| |
| // Initialize |
| window.onload = function() { |
| // Check authentication first |
| checkAuth(); |
| // Initialize platform-specific features |
| initPlatformSpecificFeatures(); |
| // Load app features |
| loadStatus(); |
| loadStats(); |
| registerServiceWorker(); |
| }; |
| |
| async function registerServiceWorker() { |
| if ('serviceWorker' in navigator) { |
| try { |
| await navigator.serviceWorker.register('/static/sw.js'); |
| console.log('Service Worker registered'); |
| } catch (e) { |
| console.log('Service Worker registration failed:', e); |
| } |
| } |
| } |
| |
| window.ICON_MIC = '<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v1a7 7 0 0 0 14 0v-1"/><line x1="12" y1="19" x2="12" y2="22"/></svg>'; |
| window.ICON_STOP = '<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="3"/></svg>'; |
| |
| async function loadStatus() { |
| try { |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) |
| ? window.APP_CONFIG.getApiUrl('/model-info') |
| : '/model-info'; |
| const response = await fetch(url); |
| const data = await response.json(); |
| |
| const dot = document.getElementById('statusDot'); |
| const text = document.getElementById('statusText'); |
| |
| const card = document.getElementById('statusCard'); |
| if (data.status === 'Trained') { |
| // All good: no technical status shown to users |
| if (card) card.style.display = 'none'; |
| } else { |
| if (card) card.style.display = ''; |
| dot.style.background = 'var(--accent-yellow)'; |
| text.innerHTML = '<strong>' + (getTranslation('status_connecting') || 'Connecting...') + '</strong>'; |
| } |
| } catch (e) { |
| const card = document.getElementById('statusCard'); |
| if (card) card.style.display = ''; |
| document.getElementById('statusDot').style.background = 'var(--accent-orange)'; |
| document.getElementById('statusText').innerHTML = '<strong>Offline</strong>'; |
| } |
| } |
| |
| async function loadStats() { |
| try { |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) |
| ? window.APP_CONFIG.getApiUrl('/feedback/stats') |
| : '/feedback/stats'; |
| const response = await fetch(url); |
| const stats = await response.json(); |
| |
| document.getElementById('statTotal').textContent = stats.total_submissions || 0; |
| document.getElementById('statVerified').textContent = stats.verified_count || 0; |
| document.getElementById('statProgress').textContent = |
| `${stats.samples_since_retrain || 0}/${stats.retrain_threshold || 50}`; |
| } catch (e) { |
| console.log('Stats load failed'); |
| } |
| } |
| |
| // Recording |
| async function toggleRecording() { |
| if (isRecording) { |
| stopRecording(); |
| } else { |
| await startRecording(); |
| } |
| } |
| |
| async function startRecording() { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| |
| // Get supported MIME types |
| const options = { mimeType: 'audio/webm' }; |
| if (!MediaRecorder.isTypeSupported('audio/webm')) { |
| if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) { |
| options.mimeType = 'audio/webm;codecs=opus'; |
| } else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) { |
| options.mimeType = 'audio/ogg;codecs=opus'; |
| } else { |
| // Fallback to default |
| delete options.mimeType; |
| } |
| } |
| |
| console.log('[BBPlease] MediaRecorder options:', options); |
| mediaRecorder = new MediaRecorder(stream, options); |
| audioChunks = []; |
| |
| mediaRecorder.ondataavailable = (e) => { |
| if (e.data && e.data.size > 0) { |
| console.log('[BBPlease] Audio chunk received:', e.data.size, 'bytes'); |
| audioChunks.push(e.data); |
| } else { |
| console.warn('[BBPlease] Empty chunk received'); |
| } |
| }; |
| |
| mediaRecorder.onerror = (e) => { |
| console.error('[BBPlease] MediaRecorder error:', e); |
| }; |
| |
| mediaRecorder.onstop = () => { |
| console.log('[BBPlease] MediaRecorder stopped:', { |
| chunksCount: audioChunks.length, |
| totalSize: audioChunks.reduce((sum, chunk) => sum + (chunk.size || 0), 0), |
| state: mediaRecorder.state |
| }); |
| |
| // Request final data chunk if available |
| if (mediaRecorder.state !== 'inactive') { |
| try { |
| mediaRecorder.requestData(); |
| } catch (e) { |
| console.warn('[BBPlease] Could not request data:', e); |
| } |
| } |
| |
| // Small delay to ensure all chunks are processed |
| setTimeout(() => { |
| analyzeRecording(); |
| }, 200); |
| }; |
| |
| // Start with timeslice to ensure chunks are emitted regularly |
| mediaRecorder.start(1000); // Emit chunk every 1 second |
| isRecording = true; |
| recordingStartTime = Date.now(); |
| |
| // Start timer |
| startTimer(); |
| |
| // Start audio level meter |
| startAudioLevel(stream); |
| |
| const btn = document.getElementById('recordBtn'); |
| btn.classList.add('recording'); |
| document.getElementById('recordText').textContent = getTranslation('record_recording'); |
| document.querySelector('#recordBtn .icon').innerHTML = window.ICON_STOP; |
| document.getElementById('recordHint').textContent = getTranslation('record_stop'); |
| |
| // Show timer and level meter |
| document.getElementById('recordingTimer').classList.add('active'); |
| document.getElementById('audioLevelContainer').classList.add('active'); |
| document.getElementById('minDurationHint').style.display = 'block'; |
| |
| showResult('status', getTranslation('status_listening') || 'Listening to baby cry...'); |
| |
| } catch (e) { |
| showToast('Microphone access denied'); |
| } |
| } |
| |
| function startTimer() { |
| const timerEl = document.getElementById('recordingTimer'); |
| const hintEl = document.getElementById('minDurationHint'); |
| |
| // #region agent log |
| // #endregion |
| |
| // Clear any existing timer first |
| if (timerInterval) { |
| clearInterval(timerInterval); |
| } |
| |
| timerInterval = setInterval(() => { |
| // CRITICAL: Check both isRecording flag AND if timerInterval still exists |
| // This prevents timer from continuing after stopTimer() is called |
| if (!isRecording || !timerInterval) { |
| console.log('[BBPlease] Timer stopping:', { isRecording, hasInterval: !!timerInterval }); |
| if (timerInterval) { |
| clearInterval(timerInterval); |
| timerInterval = null; |
| } |
| return; |
| } |
| const elapsed = (Date.now() - recordingStartTime) / 1000; |
| const minutes = Math.floor(elapsed / 60); |
| const seconds = Math.floor(elapsed % 60); |
| timerEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; |
| |
| // Update color based on duration |
| if (elapsed < MIN_RECORDING_DURATION) { |
| timerEl.className = 'recording-timer active warning'; |
| hintEl.className = 'min-duration-hint'; |
| const moreNeeded = Math.ceil(MIN_RECORDING_DURATION - elapsed); |
| hintEl.textContent = `${moreNeeded} ${getTranslation('record_more_needed')}`; |
| } else { |
| timerEl.className = 'recording-timer active ready'; |
| hintEl.className = 'min-duration-hint satisfied'; |
| hintEl.textContent = getTranslation('record_ready'); |
| } |
| }, 100); |
| } |
| |
| function stopTimer() { |
| console.log('[BBPlease] stopTimer called:', { timerInterval: !!timerInterval, isRecording: isRecording }); |
| |
| // #region agent log |
| // #endregion |
| |
| // Force stop timer immediately |
| if (timerInterval) { |
| clearInterval(timerInterval); |
| timerInterval = null; |
| console.log('[BBPlease] Timer interval cleared'); |
| // #region agent log |
| // #endregion |
| } |
| |
| // Also set isRecording to false as a safety measure |
| isRecording = false; |
| |
| // Update UI |
| const timerEl = document.getElementById('recordingTimer'); |
| if (timerEl) { |
| timerEl.classList.remove('active'); |
| timerEl.textContent = '0:00'; |
| } |
| |
| const hintEl = document.getElementById('minDurationHint'); |
| if (hintEl) { |
| hintEl.textContent = getTranslation('record_min_duration'); |
| hintEl.className = 'min-duration-hint'; |
| } |
| } |
| |
| function startAudioLevel(stream) { |
| try { |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| analyser = audioContext.createAnalyser(); |
| const source = audioContext.createMediaStreamSource(stream); |
| source.connect(analyser); |
| analyser.fftSize = 256; |
| |
| const bufferLength = analyser.frequencyBinCount; |
| const dataArray = new Uint8Array(bufferLength); |
| const levelBar = document.getElementById('audioLevelBar'); |
| |
| audioLevelInterval = setInterval(() => { |
| analyser.getByteFrequencyData(dataArray); |
| const average = dataArray.reduce((a, b) => a + b) / bufferLength; |
| const level = Math.min(100, (average / 128) * 100); |
| levelBar.style.width = level + '%'; |
| }, 50); |
| } catch (e) { |
| console.log('Audio level meter not available'); |
| } |
| } |
| |
| function stopAudioLevel() { |
| if (audioLevelInterval) { |
| clearInterval(audioLevelInterval); |
| audioLevelInterval = null; |
| } |
| if (audioContext) { |
| audioContext.close(); |
| audioContext = null; |
| } |
| document.getElementById('audioLevelContainer').classList.remove('active'); |
| document.getElementById('audioLevelBar').style.width = '0%'; |
| } |
| |
| function stopRecording() { |
| console.log('[BBPlease] stopRecording called:', { |
| hasMediaRecorder: !!mediaRecorder, |
| isRecording: isRecording, |
| timerInterval: !!timerInterval, |
| mediaRecorderState: mediaRecorder ? mediaRecorder.state : 'none' |
| }); |
| |
| // CRITICAL: Set isRecording to false FIRST to stop timer interval checks |
| isRecording = false; |
| |
| // CRITICAL: Stop timer IMMEDIATELY, before any other operations |
| // This prevents the timer from continuing to increment |
| stopTimer(); |
| stopAudioLevel(); |
| |
| if (mediaRecorder && mediaRecorder.state !== 'inactive') { |
| const elapsed = (Date.now() - recordingStartTime) / 1000; |
| |
| // Check minimum duration |
| if (elapsed < MIN_RECORDING_DURATION) { |
| showToast(getTranslation('errors.too_short').replace('{duration}', elapsed.toFixed(1)).replace('{min}', MIN_RECORDING_DURATION)); |
| // Clear chunks if too short |
| audioChunks = []; |
| } else { |
| // Only stop recording if duration is sufficient |
| // Request final data before stopping |
| try { |
| if (mediaRecorder.state === 'recording') { |
| mediaRecorder.requestData(); |
| } |
| } catch (e) { |
| console.warn('[BBPlease] Could not request final data:', e); |
| } |
| mediaRecorder.stop(); |
| } |
| |
| const btn = document.getElementById('recordBtn'); |
| btn.classList.remove('recording'); |
| document.getElementById('recordText').textContent = getTranslation('record_tap'); |
| document.querySelector('#recordBtn .icon').innerHTML = window.ICON_MIC; |
| document.getElementById('recordHint').textContent = getTranslation('record_hint'); |
| document.getElementById('recordingTimer').textContent = '0:00'; |
| |
| // Hide timer and audio level meter |
| document.getElementById('recordingTimer').classList.remove('active'); |
| document.getElementById('audioLevelContainer').classList.remove('active'); |
| document.getElementById('minDurationHint').style.display = 'none'; |
| |
| // Stop all audio tracks to release microphone |
| if (mediaRecorder && mediaRecorder.stream) { |
| mediaRecorder.stream.getTracks().forEach(track => { |
| track.stop(); |
| console.log('[BBPlease] Stopped audio track:', track.kind); |
| }); |
| } |
| |
| // Only analyze if recording was stopped (not if too short) |
| if (elapsed >= MIN_RECORDING_DURATION) { |
| showResult('status', getTranslation('status_analyzing')); |
| } else { |
| // Reset for new recording |
| audioChunks = []; |
| if (mediaRecorder && mediaRecorder.state !== 'inactive') { |
| mediaRecorder.stop(); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Convert audio blob (webm/ogg) to WAV format using Web Audio API |
| * This ensures compatibility with the backend which expects WAV format |
| */ |
| async function convertBlobToWav(blob) { |
| console.log('[BBPlease] Converting blob to WAV:', { |
| originalType: blob.type, |
| originalSize: blob.size |
| }); |
| |
| try { |
| // Create AudioContext if not already exists |
| const audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| |
| // Convert blob to ArrayBuffer |
| const arrayBuffer = await blob.arrayBuffer(); |
| |
| // Decode audio data |
| const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); |
| |
| console.log('[BBPlease] Audio decoded:', { |
| sampleRate: audioBuffer.sampleRate, |
| duration: audioBuffer.duration, |
| numberOfChannels: audioBuffer.numberOfChannels, |
| length: audioBuffer.length |
| }); |
| |
| // Convert AudioBuffer to WAV format |
| const wavBlob = audioBufferToWav(audioBuffer); |
| |
| console.log('[BBPlease] WAV conversion successful:', { |
| wavSize: wavBlob.size, |
| wavType: wavBlob.type |
| }); |
| |
| return wavBlob; |
| |
| } catch (error) { |
| console.error('[BBPlease] WAV conversion failed:', error); |
| throw new Error(`Failed to convert audio to WAV: ${error.message}`); |
| } |
| } |
| |
| /** |
| * Convert AudioBuffer to WAV Blob |
| * Based on: https://stackoverflow.com/questions/24124822/convert-audiobuffer-to-wav |
| */ |
| function audioBufferToWav(buffer) { |
| const numChannels = buffer.numberOfChannels; |
| const sampleRate = buffer.sampleRate; |
| const format = 1; // PCM |
| const bitDepth = 16; |
| |
| const bytesPerSample = bitDepth / 8; |
| const blockAlign = numChannels * bytesPerSample; |
| |
| // Get audio data from all channels |
| const length = buffer.length; |
| const arrayBuffer = new ArrayBuffer(44 + length * numChannels * bytesPerSample); |
| const view = new DataView(arrayBuffer); |
| |
| // Write WAV header |
| const writeString = (offset, string) => { |
| for (let i = 0; i < string.length; i++) { |
| view.setUint8(offset + i, string.charCodeAt(i)); |
| } |
| }; |
| |
| writeString(0, 'RIFF'); |
| view.setUint32(4, 36 + length * numChannels * bytesPerSample, true); |
| writeString(8, 'WAVE'); |
| writeString(12, 'fmt '); |
| view.setUint32(16, 16, true); // fmt chunk size |
| view.setUint16(20, format, true); |
| view.setUint16(22, numChannels, true); |
| view.setUint32(24, sampleRate, true); |
| view.setUint32(28, sampleRate * blockAlign, true); |
| view.setUint16(32, blockAlign, true); |
| view.setUint16(34, bitDepth, true); |
| writeString(36, 'data'); |
| view.setUint32(40, length * numChannels * bytesPerSample, true); |
| |
| // Convert float samples to 16-bit PCM |
| let offset = 44; |
| for (let i = 0; i < length; i++) { |
| for (let channel = 0; channel < numChannels; channel++) { |
| const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i])); |
| view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true); |
| offset += 2; |
| } |
| } |
| |
| return new Blob([arrayBuffer], { type: 'audio/wav' }); |
| } |
| |
| async function analyzeRecording() { |
| console.log('[BBPlease] analyzeRecording called:', { |
| audioChunksLength: audioChunks.length, |
| totalChunks: audioChunks.reduce((sum, chunk) => sum + (chunk.size || chunk.byteLength || 0), 0), |
| chunks: audioChunks.map(chunk => ({ size: chunk.size || chunk.byteLength || 0, type: chunk.type })) |
| }); |
| |
| // Check if we have any chunks |
| if (audioChunks.length === 0) { |
| console.error('[BBPlease] No audio chunks available!'); |
| showResult('error', getTranslation('errors.recording_failed') || 'No audio recorded. Please try again.'); |
| return; |
| } |
| |
| // Determine MIME type from MediaRecorder or use default |
| let mimeType = 'audio/webm'; |
| if (mediaRecorder && mediaRecorder.mimeType) { |
| mimeType = mediaRecorder.mimeType; |
| } |
| |
| // Create blob from chunks |
| const audioBlob = new Blob(audioChunks, { type: mimeType }); |
| |
| console.log('[BBPlease] Recording blob created:', { |
| size: audioBlob.size, |
| type: audioBlob.type, |
| mimeType: mimeType, |
| isEmpty: audioBlob.size === 0 |
| }); |
| |
| if (audioBlob.size === 0) { |
| console.error('[BBPlease] Recording blob is empty despite having chunks!'); |
| showResult('error', getTranslation('errors.recording_failed') || 'Recording is empty. Please try again.'); |
| return; |
| } |
| |
| // Show calming message during conversion |
| const calmingMsg = document.getElementById('calmingMessage'); |
| if (calmingMsg) { |
| calmingMsg.classList.add('visible'); |
| } |
| |
| // Convert to WAV format for backend compatibility |
| let wavBlob; |
| try { |
| showResult('status', getTranslation('status_converting') || 'Converting audio format...'); |
| wavBlob = await convertBlobToWav(audioBlob); |
| console.log('[BBPlease] Conversion successful, proceeding with analysis'); |
| } catch (conversionError) { |
| // Hide calming message on error |
| if (calmingMsg) { |
| calmingMsg.classList.remove('visible'); |
| } |
| console.error('[BBPlease] Audio conversion failed:', conversionError); |
| showResult('error', getTranslation('errors.conversion_failed') || `Audio conversion failed: ${conversionError.message}. Please try again.`); |
| return; |
| } |
| |
| // Use WAV filename |
| const filename = 'recording.wav'; |
| await analyzeAudio(wavBlob, filename); |
| } |
| |
| async function analyzeFile() { |
| const file = document.getElementById('audioFile').files[0]; |
| if (file) { |
| showResult('status', getTranslation('status_analyzing')); |
| await analyzeAudio(file, file.name); |
| } |
| } |
| |
| async function analyzeAudio(audioBlob, filename) { |
| // Show calming message |
| const calmingMsg = document.getElementById('calmingMessage'); |
| if (calmingMsg) { |
| calmingMsg.classList.add('visible'); |
| } |
| |
| // Log blob details |
| console.log('[BBPlease] analyzeAudio called:', { |
| filename: filename, |
| blobSize: audioBlob.size, |
| blobType: audioBlob.type, |
| isBlob: audioBlob instanceof Blob, |
| isFile: audioBlob instanceof File, |
| isEmpty: audioBlob.size === 0 |
| }); |
| |
| if (!audioBlob || audioBlob.size === 0) { |
| console.error('[BBPlease] Invalid audio blob:', { |
| blob: audioBlob, |
| size: audioBlob ? audioBlob.size : 'null' |
| }); |
| // Hide calming message on error |
| if (calmingMsg) { |
| calmingMsg.classList.remove('visible'); |
| } |
| showResult('error', getTranslation('errors.invalid_audio') || 'Invalid audio. Please try again.'); |
| return; |
| } |
| |
| const formData = new FormData(); |
| formData.append('audio', audioBlob, filename); |
| const analysisMode = localStorage.getItem('bbplease_analysis_mode') || 'local'; |
| formData.append('analysis_mode', (analysisMode === 'cloud') ? 'cloud' : 'local'); |
| |
| // Verify formData |
| const formDataAudio = formData.get('audio'); |
| console.log('[BBPlease] FormData created:', { |
| hasAudio: !!formDataAudio, |
| audioSize: formDataAudio ? formDataAudio.size : 'null', |
| audioType: formDataAudio ? formDataAudio.type : 'null' |
| }); |
| |
| // Define url before try block so it's available in catch block |
| let url = '/analyze?debug=true'; // Default fallback |
| try { |
| // Use debug mode to get diagnostics |
| // Safely get URL with error handling |
| try { |
| if (window.APP_CONFIG && typeof window.APP_CONFIG.getApiUrl === 'function') { |
| url = window.APP_CONFIG.getApiUrl('/analyze?debug=true'); |
| } else { |
| url = '/analyze?debug=true'; |
| } |
| } catch (urlError) { |
| console.warn('[BBPlease] Error getting API URL, using default:', urlError); |
| url = '/analyze?debug=true'; |
| } |
| |
| // Log to console (visible in Android Studio Logcat) |
| console.log('[BBPlease] Starting analysis:', { |
| url: url, |
| hasConfig: !!window.APP_CONFIG, |
| configUrl: window.APP_CONFIG ? window.APP_CONFIG.API_BASE_URL : 'none', |
| platform: window.Capacitor ? window.Capacitor.getPlatform() : 'web', |
| online: navigator.onLine, |
| capacitorServer: window.Capacitor && window.Capacitor.getServerUrl ? window.Capacitor.getServerUrl() : 'none', |
| blobSize: audioBlob.size, |
| filename: filename |
| }); |
| |
| // #region agent log |
| // #endregion |
| |
| // #region agent log |
| // #endregion |
| |
| let response; |
| try { |
| console.log('[BBPlease] Making fetch request to:', url); |
| console.log('[BBPlease] FormData details:', { |
| hasAudio: !!formData.get('audio'), |
| audioSize: formData.get('audio') ? formData.get('audio').size : 'null', |
| audioType: formData.get('audio') ? formData.get('audio').type : 'null' |
| }); |
| |
| // Add timeout to fetch |
| const controller = new AbortController(); |
| const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout |
| |
| // Get user_id from localStorage to include in request header |
| const USER_DATA_KEY = 'itmain_user_data'; |
| let user_id = null; |
| try { |
| const userData = localStorage.getItem(USER_DATA_KEY); |
| if (userData) { |
| const parsed = JSON.parse(userData); |
| user_id = parsed.user_id || null; |
| } |
| } catch (e) { |
| console.warn('Could not get user_id from localStorage:', e); |
| } |
| |
| response = await fetch(url, { |
| method: 'POST', |
| headers: user_id ? { 'X-User-ID': user_id } : {}, |
| body: formData, |
| signal: controller.signal |
| }); |
| |
| clearTimeout(timeoutId); |
| |
| console.log('[BBPlease] Fetch response:', { |
| status: response.status, |
| statusText: response.statusText, |
| ok: response.ok, |
| url: response.url, |
| contentType: response.headers.get('content-type') |
| }); |
| |
| // #region agent log |
| // #endregion |
| } catch (fetchError) { |
| const errorDetails = { |
| error: fetchError.message || String(fetchError), |
| errorType: fetchError.name || 'Error', |
| url: url, |
| online: navigator.onLine, |
| stack: fetchError.stack ? fetchError.stack.substring(0, 300) : 'no stack', |
| isAbort: fetchError.name === 'AbortError', |
| isNetwork: fetchError.message && (fetchError.message.includes('Failed to fetch') || fetchError.message.includes('NetworkError') || fetchError.message.includes('Network request failed')) |
| }; |
| |
| console.error('[BBPlease] Fetch failed:', errorDetails); |
| |
| // #region agent log |
| // #endregion |
| |
| // Provide more specific error message |
| if (errorDetails.isAbort) { |
| throw new Error('Request timeout. Please check your internet connection and try again.'); |
| } else if (errorDetails.isNetwork) { |
| throw new Error('Network error. Please check your internet connection and try again.'); |
| } else { |
| throw fetchError; |
| } |
| } |
| |
| // #region agent log |
| // #endregion |
| |
| let result; |
| try { |
| result = await response.json(); |
| } catch (jsonError) { |
| // #region agent log |
| // #endregion |
| const text = await response.text(); |
| // #region agent log |
| // #endregion |
| throw new Error('Invalid JSON response: ' + text.substring(0, 100)); |
| } |
| |
| // Hide calming message |
| if (calmingMsg) { |
| calmingMsg.classList.remove('visible'); |
| } |
| |
| if (result.error) { |
| // Show validation-specific error (incl. "not a cry" gate rejection) |
| if (result.validation_failed || result.not_a_cry) { |
| // Use translation key if available, otherwise use error message |
| const errorMsg = result.error_key ? getTranslation(result.error_key) : result.error; |
| showResult('validation_error', errorMsg); |
| } else { |
| showResult('error', result.error); |
| } |
| |
| // Log diagnostics for debugging |
| if (result.diagnostics) { |
| console.log('Audio diagnostics:', result.diagnostics); |
| } |
| } else { |
| currentPrediction = result.prediction; |
| currentAudioId = result.audio_id; |
| showResult('success', result); |
| showFeedback(); |
| refreshDailySummary(); |
| |
| // Track analysis event |
| if (window.AppAnalytics) { |
| window.AppAnalytics.trackEvent('audio_analyzed', { |
| prediction: result.prediction, |
| confidence: result.confidence, |
| model_type: result.model_type |
| }); |
| } |
| |
| // Log diagnostics |
| if (result.diagnostics) { |
| console.log('Audio diagnostics:', result.diagnostics); |
| } |
| } |
| } catch (e) { |
| // Hide calming message on error |
| const calmingMsg = document.getElementById('calmingMessage'); |
| if (calmingMsg) { |
| calmingMsg.classList.remove('visible'); |
| } |
| |
| // Categorize error type for better user feedback |
| const errorType = categorizeError(e); |
| |
| console.error('[BBPlease] Error caught:', { |
| error: e.message || String(e), |
| errorType: errorType, |
| errorName: e.name || 'Error', |
| stack: e.stack ? e.stack.substring(0, 300) : 'no stack', |
| online: navigator.onLine, |
| url: url || 'not defined', |
| blobType: audioBlob ? audioBlob.type : 'unknown', |
| blobSize: audioBlob ? audioBlob.size : 0 |
| }); |
| |
| // #region agent log |
| // #endregion |
| |
| // Show specific error message based on error type |
| let errorMsg = getSpecificErrorMessage(e, errorType); |
| |
| // Log full error for debugging |
| console.error('[BBPlease] Full error details:', { |
| message: e.message, |
| name: e.name, |
| type: errorType, |
| stack: e.stack, |
| url: url || 'not defined', |
| online: navigator.onLine, |
| blobInfo: audioBlob ? { type: audioBlob.type, size: audioBlob.size } : null |
| }); |
| |
| showResult('error', errorMsg); |
| } |
| } |
| |
| /** |
| * Categorize error type for better error handling |
| */ |
| function categorizeError(error) { |
| const message = (error.message || '').toLowerCase(); |
| const name = (error.name || '').toLowerCase(); |
| |
| // Network errors |
| if (name === 'aborterror' || message.includes('timeout') || message.includes('aborted')) { |
| return 'timeout'; |
| } |
| if (message.includes('failed to fetch') || message.includes('networkerror') || |
| message.includes('network request failed') || message.includes('networkerror')) { |
| return 'network'; |
| } |
| if (message.includes('cors') || message.includes('cross-origin')) { |
| return 'cors'; |
| } |
| |
| // Format/conversion errors (should be caught earlier, but just in case) |
| if (message.includes('convert') || message.includes('decode') || |
| message.includes('format') || message.includes('unsupported')) { |
| return 'format'; |
| } |
| |
| // Backend/processing errors |
| if (message.includes('validation') || message.includes('invalid audio') || |
| message.includes('could not analyze')) { |
| return 'processing'; |
| } |
| |
| // Unknown error |
| return 'unknown'; |
| } |
| |
| /** |
| * Get specific error message based on error type |
| */ |
| function getSpecificErrorMessage(error, errorType) { |
| const baseMsg = getTranslation('errors.network_error') || 'An error occurred'; |
| |
| switch (errorType) { |
| case 'timeout': |
| return getTranslation('errors.timeout') || |
| 'Request timeout. Please check your internet connection and try again.'; |
| |
| case 'network': |
| return getTranslation('errors.network_error') || |
| 'Network error. Please check your internet connection and try again.'; |
| |
| case 'cors': |
| return getTranslation('errors.cors_error') || |
| 'Connection error. Please check your internet connection and try again.'; |
| |
| case 'format': |
| return getTranslation('errors.format_error') || |
| 'Audio format error. Please try recording again.'; |
| |
| case 'processing': |
| // Use the error message from backend if available |
| return error.message || |
| (getTranslation('errors.processing_error') || 'Audio processing failed. Please try again.'); |
| |
| default: |
| // For unknown errors, show the actual error message if available |
| if (error.message && !error.message.includes('Failed to fetch')) { |
| return error.message; |
| } |
| return baseMsg + '. Please try again.'; |
| } |
| } |
| |
| let currentSoothingAudio = null; |
| |
| function stopSoothingSound() { |
| if (!currentSoothingAudio) return; |
| try { |
| if (currentSoothingAudio instanceof HTMLAudioElement) { |
| currentSoothingAudio.pause(); |
| } else if (currentSoothingAudio.context) { |
| try { currentSoothingAudio.source.stop(); } catch (e) {} |
| try { currentSoothingAudio.context.close(); } catch (e) {} |
| } |
| } catch (e) {} |
| currentSoothingAudio = null; |
| } |
| |
| function startWhiteNoise() { |
| try { |
| const Ctx = window.AudioContext || window.webkitAudioContext; |
| if (!Ctx) return null; |
| const ctx = new Ctx(); |
| const sampleRate = ctx.sampleRate; |
| const duration = 2; |
| const buffer = ctx.createBuffer(1, sampleRate * duration, sampleRate); |
| const data = buffer.getChannelData(0); |
| for (let i = 0; i < data.length; i++) { |
| data[i] = (Math.random() * 2 - 1) * 0.3; |
| } |
| const source = ctx.createBufferSource(); |
| source.buffer = buffer; |
| source.loop = true; |
| source.connect(ctx.destination); |
| source.start(0); |
| return { context: ctx, source: source }; |
| } catch (e) { |
| console.warn('White noise fallback failed:', e); |
| return null; |
| } |
| } |
| |
| function toggleSoothingSound(btn) { |
| if (!btn) return; |
| if (currentSoothingAudio) { |
| const wasAudio = currentSoothingAudio instanceof HTMLAudioElement; |
| const wasPlaying = wasAudio ? !currentSoothingAudio.paused : true; |
| if (wasPlaying) { |
| stopSoothingSound(); |
| btn.textContent = getTranslation('play_soothing_sound') || 'Play soothing sound'; |
| return; |
| } |
| } |
| stopSoothingSound(); |
| const audio = new Audio('/static/audio/soothing.mp3'); |
| audio.loop = true; |
| audio.addEventListener('error', function onErr() { |
| audio.removeEventListener('error', onErr); |
| if (currentSoothingAudio !== null) return; |
| const fallback = startWhiteNoise(); |
| if (fallback) { |
| currentSoothingAudio = fallback; |
| btn.textContent = getTranslation('stop_soothing_sound') || 'Stop soothing sound'; |
| } |
| }); |
| audio.play().then(function() { |
| if (currentSoothingAudio === null) { |
| currentSoothingAudio = audio; |
| btn.textContent = getTranslation('stop_soothing_sound') || 'Stop soothing sound'; |
| } |
| }).catch(function() { |
| if (currentSoothingAudio !== null) return; |
| const fallback = startWhiteNoise(); |
| if (fallback) { |
| currentSoothingAudio = fallback; |
| btn.textContent = getTranslation('stop_soothing_sound') || 'Stop soothing sound'; |
| } |
| }); |
| } |
| |
| function showResult(type, data) { |
| const card = document.getElementById('resultCard'); |
| card.classList.remove('success'); |
| stopSoothingSound(); |
| |
| if (type === 'status') { |
| card.innerHTML = `<div class="spinner" style="margin: 0 auto;"></div><div class="result-placeholder" style="margin-top: 12px;">${data}</div>`; |
| } else if (type === 'validation_error') { |
| // Special styling for validation errors with helpful hints |
| card.innerHTML = ` |
| <div class="result-emoji">๐ค</div> |
| <div class="result-placeholder" style="color: var(--accent-yellow); font-weight: 600;">${getTranslation('errors.validation_failed')}</div> |
| <div class="result-suggestion" style="margin-top: 12px;">${data}</div> |
| <div class="result-suggestion" style="margin-top: 8px; font-size: 12px;"> |
| ${getTranslation('errors.validation_hint')} |
| </div> |
| `; |
| } else if (type === 'error') { |
| card.innerHTML = `<div class="result-placeholder" style="color: var(--accent-orange);">โ ๏ธ ${data}</div>`; |
| } else if (type === 'success') { |
| card.classList.add('success'); |
| const emoji = getEmoji(data.prediction); |
| const categoryName = getTranslation(`categories.${data.prediction}`); |
| let suggestion; |
| try { |
| const userDataRaw = localStorage.getItem(USER_DATA_KEY); |
| const userData = userDataRaw ? JSON.parse(userDataRaw) : null; |
| const babyName = (userData && userData.baby_profile && userData.baby_profile.name) ? userData.baby_profile.name : (userData && userData.baby_name) || null; |
| if (babyName) { |
| const key = `category_suggestions_with_name.${data.prediction}`; |
| const tpl = getTranslation(key); |
| suggestion = (tpl && tpl !== key) ? tpl.replace(/\{name\}/g, babyName) : getSuggestion(data.prediction); |
| } else { |
| suggestion = getSuggestion(data.prediction); |
| } |
| } catch (e) { |
| suggestion = getSuggestion(data.prediction); |
| } |
| let seekHelpHtml = ''; |
| if (data.prediction === 'pain') { |
| seekHelpHtml = `<div class="result-seek-help">${getTranslation('seek_help_pain')}</div>`; |
| } else if (data.prediction === 'discomfort') { |
| seekHelpHtml = `<div class="result-seek-help">${getTranslation('seek_help_discomfort')}</div>`; |
| } |
| const tipsHtml = '<div class="result-tip">' + getTip(data.prediction) + '</div>'; |
| const isSoothingCategory = data.prediction === 'sleep' || data.prediction === 'fussiness'; |
| const soothingHtml = isSoothingCategory ? ('<div class="result-soothing"><button type="button" class="soothing-btn" onclick="toggleSoothingSound(this)">' + (getTranslation('play_soothing_sound') || 'Play soothing sound') + '</button></div>') : ''; |
| |
| // Confidence language |
| let confidenceText; |
| const conf = data.confidence; |
| if (conf > 70) { |
| confidenceText = getTranslation('result_best_guess') || 'Our best guess'; |
| } else if (conf >= 40) { |
| confidenceText = getTranslation('result_we_think') || 'We think'; |
| } else { |
| confidenceText = getTranslation('result_not_sure') || 'Not sure, help us learn'; |
| } |
| confidenceText += ` (${Math.round(conf)}%)`; |
| |
| // Second guess: shown when the model's #2 category is plausible |
| let secondGuessHtml = ''; |
| if (data.top_predictions && data.top_predictions.length > 1 && data.top_predictions[1].confidence >= 15) { |
| const alt = data.top_predictions[1]; |
| const altName = getTranslation('categories.' + alt.label); |
| const orMaybe = getTranslation('result_or_maybe') || 'or maybe'; |
| secondGuessHtml = `<div class="result-second-guess">${orMaybe} ${getEmoji(alt.label)} ${altName} (${Math.round(alt.confidence)}%)</div>`; |
| } |
| |
| // Build category correction buttons (exclude predicted) |
| const allCategories = ['hunger', 'sleep', 'pain', 'discomfort', 'fussiness']; |
| const catEmojis = {'hunger':'๐ผ','sleep':'๐ด','pain':'๐ค','discomfort':'๐ฃ','fussiness':'๐ญ'}; |
| const otherCats = allCategories.filter(c => c !== data.prediction.toLowerCase()); |
| const correctionBtns = otherCats.map(c => |
| `<button class="category-btn" onclick="diaryCorrect('${c}')"><span class="emoji">${catEmojis[c]}</span><span>${getTranslation('categories.' + c)}</span></button>` |
| ).join(''); |
| |
| // Build "what helped" buttons |
| const helpedKeys = ['helped_fed','helped_rocked','helped_diaper','helped_held','helped_pacifier','helped_other']; |
| const helpedBtns = helpedKeys.map(k => |
| `<button class="helped-btn" onclick="toggleHelped(this, '${k}')" data-key="${k}">${getTranslation(k) || k}</button>` |
| ).join(''); |
| |
| const wasItText = (getTranslation('diary_was_it') || 'Was it {category}?').replace('{category}', categoryName); |
| |
| card.innerHTML = ` |
| <div class="result-emoji">${emoji}</div> |
| <div class="result-label">${categoryName}</div> |
| <div class="result-confidence">${confidenceText}</div> |
| ${secondGuessHtml} |
| <div class="result-suggestion">${suggestion}</div> |
| ${seekHelpHtml} |
| ${tipsHtml} |
| ${soothingHtml} |
| <div class="diary-section visible" id="diarySection"> |
| <div class="diary-question">${wasItText}</div> |
| <div class="diary-buttons" id="diaryButtons"> |
| <button class="diary-btn yes" onclick="diaryConfirm(true)">${getTranslation('diary_yes') || 'โ Yes, that\\'s right'}</button> |
| <button class="diary-btn no" onclick="diaryConfirm(false)">${getTranslation('diary_no') || 'โ No, it was...'}</button> |
| </div> |
| <div class="diary-categories" id="diaryCategories"> |
| <div class="diary-question">${getTranslation('feedback_select_category')}</div> |
| <div class="category-grid">${correctionBtns}</div> |
| </div> |
| <div class="diary-helped" id="diaryHelped"> |
| <div class="diary-question">${getTranslation('diary_what_helped') || 'What helped?'}</div> |
| <div class="helped-grid">${helpedBtns}</div> |
| <input class="helped-other-input" id="helpedOtherInput" placeholder="${getTranslation('what_helped_placeholder') || 'e.g. Fed, changed diaper'}"> |
| <button class="diary-save-btn" onclick="diarySave()">${getTranslation('diary_save') || 'Save โ'}</button> |
| </div> |
| </div> |
| `; |
| } else { |
| card.innerHTML = `<div class="result-placeholder">${data}</div>`; |
| } |
| } |
| |
| function getEmoji(category) { |
| const emojis = { |
| 'hunger': '๐ผ', |
| 'sleep': '๐ด', |
| 'discomfort': '๐ฃ', |
| 'pain': '๐ค', |
| 'fussiness': '๐ญ' |
| }; |
| return emojis[category.toLowerCase()] || '๐ถ'; |
| } |
| |
| function getSuggestion(category) { |
| const key = `category_suggestions.${category.toLowerCase()}`; |
| return getTranslation(key) || 'Take care of your baby.'; |
| } |
| |
| function getTip(category) { |
| const key = 'tips_' + (category || '').toLowerCase(); |
| return getTranslation(key) || ''; |
| } |
| |
| // Diary flow state |
| let diaryCorrectLabel = null; |
| let diaryIsCorrect = null; |
| let diaryHelpedItems = []; |
| |
| function diaryConfirm(isCorrect) { |
| diaryIsCorrect = isCorrect; |
| const btns = document.getElementById('diaryButtons'); |
| if (isCorrect) { |
| diaryCorrectLabel = currentPrediction; |
| btns.style.display = 'none'; |
| // Show what-helped step |
| const helped = document.getElementById('diaryHelped'); |
| if (helped) helped.classList.add('visible'); |
| } else { |
| btns.style.display = 'none'; |
| const cats = document.getElementById('diaryCategories'); |
| if (cats) cats.classList.add('visible'); |
| } |
| } |
| |
| function diaryCorrect(category) { |
| diaryCorrectLabel = category; |
| diaryIsCorrect = false; |
| const cats = document.getElementById('diaryCategories'); |
| if (cats) cats.classList.remove('visible'); |
| // Show what-helped step |
| const helped = document.getElementById('diaryHelped'); |
| if (helped) helped.classList.add('visible'); |
| } |
| |
| function toggleHelped(btn, key) { |
| btn.classList.toggle('selected'); |
| if (key === 'helped_other') { |
| const inp = document.getElementById('helpedOtherInput'); |
| if (inp) inp.classList.toggle('visible'); |
| } |
| // Update selected items |
| diaryHelpedItems = []; |
| document.querySelectorAll('.helped-btn.selected').forEach(b => { |
| diaryHelpedItems.push(b.getAttribute('data-key')); |
| }); |
| } |
| |
| async function diarySave() { |
| const otherInput = document.getElementById('helpedOtherInput'); |
| let whatHelped = diaryHelpedItems.map(k => { |
| if (k === 'helped_other' && otherInput && otherInput.value.trim()) { |
| return otherInput.value.trim(); |
| } |
| return k.replace('helped_', ''); |
| }); |
| |
| await sendFeedback(diaryCorrectLabel || currentPrediction, diaryIsCorrect !== false, whatHelped); |
| } |
| |
| // Legacy compat |
| function showFeedback() { /* diary is now integrated into result card */ } |
| function hideFeedback() { /* no-op */ } |
| |
| async function sendFeedback(correctLabel, isCorrect, whatHelped) { |
| |
| // Get user_id from localStorage |
| const USER_DATA_KEY = 'itmain_user_data'; |
| let user_id = null; |
| try { |
| const userData = localStorage.getItem(USER_DATA_KEY); |
| if (userData) { |
| const parsed = JSON.parse(userData); |
| user_id = parsed.user_id || null; |
| } |
| } catch (e) { |
| console.warn('Could not get user_id from localStorage:', e); |
| } |
| |
| // Store current values before clearing |
| const audioIdToSubmit = currentAudioId; |
| const predictionToSubmit = currentPrediction; |
| |
| const feedbackData = { |
| audio_id: audioIdToSubmit, |
| predicted_label: predictionToSubmit, |
| correct_label: correctLabel, |
| is_correct: isCorrect, |
| user_id: user_id, |
| what_helped: whatHelped || [] |
| }; |
| |
| // Check if offline - queue feedback if so |
| if (window.OfflineManager && !window.OfflineManager.isOnline()) { |
| const queueId = window.OfflineManager.queueFeedback(feedbackData); |
| showToast('Feedback saved offline. Will sync when online.'); |
| loadStats(); |
| // Clear state after queuing |
| resetAfterFeedback(); |
| return; |
| } |
| |
| try { |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) |
| ? window.APP_CONFIG.getApiUrl('/feedback') |
| : '/feedback'; |
| const response = await fetch(url, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(feedbackData) |
| }); |
| |
| const result = await response.json(); |
| |
| if (result.error || !response.ok) { |
| console.error('โ Feedback submission failed:', result.error || 'Unknown error'); |
| // If network error, queue for later |
| if (!response.ok && window.OfflineManager) { |
| window.OfflineManager.queueFeedback(feedbackData); |
| showToast('Network error. Feedback saved offline.'); |
| } else { |
| showToast('Feedback failed: ' + (result.error || 'Unknown error')); |
| } |
| } else { |
| console.log('โ
Feedback submitted successfully:', result); |
| // Track feedback event |
| if (window.AppAnalytics) { |
| window.AppAnalytics.trackEvent('feedback_submitted', { |
| predicted: predictionToSubmit, |
| correct: correctLabel, |
| is_correct: isCorrect |
| }); |
| } |
| |
| // Show thank-you in diary section |
| const diarySection = document.getElementById('diarySection'); |
| if (diarySection) { |
| const appName = getTranslation('app_name') || "Itma'In"; |
| const thankyou = (getTranslation('feedback_thankyou') || 'Thanks! This helps {app_name} learn ๐').replace('{app_name}', appName); |
| diarySection.innerHTML = '<div class="diary-thankyou">' + thankyou + '</div>'; |
| } |
| loadStats(); |
| refreshDailySummary(); |
| } |
| } catch (e) { |
| // Network error - queue for later |
| if (window.OfflineManager) { |
| window.OfflineManager.queueFeedback(feedbackData); |
| showToast('Network error. Feedback saved offline.'); |
| } else { |
| showToast('Network error'); |
| } |
| } finally { |
| // Always reset state after feedback submission (success or failure) |
| resetAfterFeedback(); |
| } |
| } |
| |
| function resetAfterFeedback() { |
| // Clear audio state to allow new recordings |
| currentAudioId = null; |
| currentPrediction = null; |
| diaryCorrectLabel = null; |
| diaryIsCorrect = null; |
| diaryHelpedItems = []; |
| |
| // Ensure all buttons are enabled |
| const recordBtn = document.getElementById('recordBtn'); |
| if (recordBtn) { |
| recordBtn.disabled = false; |
| recordBtn.style.pointerEvents = 'auto'; |
| } |
| |
| // Enable any other potentially disabled buttons |
| const allButtons = document.querySelectorAll('button'); |
| allButtons.forEach(btn => { |
| if (btn.id !== 'recordBtn' && !btn.classList.contains('feedback-btn')) { |
| btn.disabled = false; |
| btn.style.pointerEvents = 'auto'; |
| } |
| }); |
| |
| console.log('[BBPlease] State reset after feedback - ready for new recording'); |
| } |
| |
| // Daily summary |
| let dailySummaryCache = null; |
| async function refreshDailySummary() { |
| try { |
| const USER_DATA_KEY = 'itmain_user_data'; |
| const userData = localStorage.getItem(USER_DATA_KEY); |
| if (!userData) return; |
| const parsed = JSON.parse(userData); |
| const userId = parsed.user_id; |
| if (!userId) return; |
| |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) |
| ? window.APP_CONFIG.getApiUrl('/api/me/predictions') |
| : '/api/me/predictions'; |
| const response = await fetch(url, { |
| headers: { 'X-User-ID': userId } |
| }); |
| if (!response.ok) return; |
| const data = await response.json(); |
| const predictions = data.predictions || data || []; |
| |
| // Filter today's recordings |
| const today = new Date().toISOString().slice(0, 10); |
| const todayPreds = predictions.filter(p => { |
| const ts = p.created_at || p.timestamp || ''; |
| return ts.slice(0, 10) === today; |
| }); |
| |
| const card = document.getElementById('dailySummaryCard'); |
| if (!card) return; |
| |
| if (todayPreds.length === 0) { |
| card.style.display = 'block'; |
| document.getElementById('dailySummaryTitle').textContent = getTranslation('today_summary') || 'Today'; |
| document.getElementById('dailySummaryCounts').textContent = getTranslation('no_recordings_today') || 'No recordings yet today'; |
| document.getElementById('dailySummaryMost').textContent = ''; |
| return; |
| } |
| |
| // Count by category |
| const counts = {}; |
| const catEmojis = {'hunger':'๐ผ','sleep':'๐ด','pain':'๐ค','discomfort':'๐ฃ','fussiness':'๐ญ'}; |
| todayPreds.forEach(p => { |
| const cat = (p.predicted_label || p.prediction || '').toLowerCase(); |
| counts[cat] = (counts[cat] || 0) + 1; |
| }); |
| |
| const countStr = Object.entries(counts) |
| .map(([cat, n]) => `${catEmojis[cat] || ''} ${getTranslation('categories.' + cat) || cat} ร${n}`) |
| .join(' '); |
| |
| const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]); |
| const topCat = sorted[0]; |
| const topPct = Math.round((topCat[1] / todayPreds.length) * 100); |
| const mostCommonLabel = (getTranslation('most_common') || 'Most common') + ': ' + |
| (getTranslation('categories.' + topCat[0]) || topCat[0]) + ' (' + topPct + '%)'; |
| |
| card.style.display = 'block'; |
| document.getElementById('dailySummaryTitle').textContent = |
| (getTranslation('today_summary') || 'Today') + ': ' + todayPreds.length + ' recordings'; |
| document.getElementById('dailySummaryCounts').textContent = countStr; |
| document.getElementById('dailySummaryMost').textContent = mostCommonLabel; |
| |
| dailySummaryCache = { count: todayPreds.length, counts }; |
| } catch (e) { |
| console.warn('Could not load daily summary:', e); |
| } |
| } |
| |
| // Admin |
| function toggleAdmin() { |
| const panel = document.getElementById('adminPanel'); |
| panel.classList.toggle('visible'); |
| } |
| |
| async function manualRetrain() { |
| const btn = document.getElementById('retrainBtn'); |
| btn.textContent = 'โณ Retraining...'; |
| btn.disabled = true; |
| |
| try { |
| const url = (window.APP_CONFIG && window.APP_CONFIG.getApiUrl) |
| ? window.APP_CONFIG.getApiUrl('/model/retrain') |
| : '/model/retrain'; |
| const response = await fetch(url, { method: 'POST' }); |
| const result = await response.json(); |
| |
| if (result.success) { |
| showToast(`Retrained! Accuracy: ${(result.accuracy * 100).toFixed(0)}%`); |
| loadStatus(); |
| loadStats(); |
| } else { |
| showToast('Retrain failed'); |
| } |
| } catch (e) { |
| showToast('Network error'); |
| } finally { |
| btn.textContent = '๐ Retrain Model'; |
| btn.disabled = false; |
| } |
| } |
| |
| // Toast |
| function showToast(message) { |
| const toast = document.getElementById('toast'); |
| toast.textContent = message; |
| toast.classList.add('visible'); |
| setTimeout(() => toast.classList.remove('visible'), 2500); |
| } |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| |
| ADMIN_LOGIN_TEMPLATE = """ |
| <!DOCTYPE html> |
| <html lang="{{LANG}}" dir="{{DIR}}"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Admin Login - Itma'In</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; |
| background: linear-gradient(135deg, #FF6B9D 0%, #FFB3D1 100%); |
| min-height: 100vh; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 20px; |
| } |
| .login-container { |
| background: white; |
| border-radius: 24px; |
| padding: 40px; |
| box-shadow: 0 20px 60px rgba(0,0,0,0.3); |
| max-width: 400px; |
| width: 100%; |
| } |
| .login-title { |
| text-align: center; |
| color: #1a1a2e; |
| margin-bottom: 30px; |
| font-size: 28px; |
| } |
| .login-form { |
| display: flex; |
| flex-direction: column; |
| gap: 20px; |
| } |
| .form-group { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| .form-label { |
| color: #666; |
| font-size: 14px; |
| } |
| .form-input { |
| padding: 12px 16px; |
| border: 2px solid #FFE0E8; |
| border-radius: 12px; |
| font-size: 16px; |
| transition: border-color 0.3s; |
| } |
| .form-input:focus { |
| outline: none; |
| border-color: #FF6B9D; |
| } |
| .login-btn { |
| padding: 14px; |
| background: linear-gradient(135deg, #FF6B9D 0%, #FFB3D1 100%); |
| color: white; |
| border: none; |
| border-radius: 12px; |
| font-size: 16px; |
| font-weight: 600; |
| cursor: pointer; |
| transition: transform 0.2s, box-shadow 0.2s; |
| } |
| .login-btn:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 6px 20px rgba(255, 107, 157, 0.4); |
| } |
| .error-message { |
| background: #fee; |
| color: #c33; |
| padding: 12px; |
| border-radius: 8px; |
| display: none; |
| margin-top: 10px; |
| } |
| html[dir="rtl"] .login-container { |
| text-align: right; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="login-container"> |
| <h1 class="login-title" id="loginTitle">๐ถ๐ Admin Login</h1> |
| <form class="login-form" id="adminLoginForm" onsubmit="handleAdminLogin(event)"> |
| <div class="form-group"> |
| <label class="form-label" for="adminPassword" id="passwordLabel">Password</label> |
| <input type="password" id="adminPassword" class="form-input" required autofocus /> |
| </div> |
| <button type="submit" class="login-btn" id="loginBtn">Login</button> |
| <div class="error-message" id="errorMessage"></div> |
| </form> |
| </div> |
| <script> |
| let translations = {{TRANSLATIONS}} || {}; |
| |
| function t(key, defaultValue = '') { |
| return translations[key] || defaultValue || key; |
| } |
| |
| // Apply translations on page load |
| function applyTranslations() { |
| if (Object.keys(translations).length === 0) return; |
| |
| const title = document.getElementById('loginTitle'); |
| if (title) title.textContent = '๐ถ๐ ' + (t('login_title', 'Admin Login') || 'Admin Login'); |
| |
| const passwordLabel = document.getElementById('passwordLabel'); |
| if (passwordLabel) passwordLabel.textContent = t('login_password', 'Password'); |
| |
| const loginBtn = document.getElementById('loginBtn'); |
| if (loginBtn) loginBtn.textContent = t('login_submit', 'Login'); |
| } |
| |
| // Apply translations when page loads |
| window.addEventListener('DOMContentLoaded', applyTranslations); |
| |
| async function handleAdminLogin(event) { |
| event.preventDefault(); |
| const password = document.getElementById('adminPassword').value; |
| const errorMsg = document.getElementById('errorMessage'); |
| |
| try { |
| const response = await fetch('/admin/login', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({password: password}) |
| }); |
| |
| const data = await response.json(); |
| |
| if (data.success) { |
| window.location.href = '/admin'; |
| } else { |
| errorMsg.textContent = data.error || t('login_error', 'Invalid password'); |
| errorMsg.style.display = 'block'; |
| } |
| } catch (e) { |
| errorMsg.textContent = 'Connection error. Please try again.'; |
| errorMsg.style.display = 'block'; |
| } |
| } |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| |
| @app.route('/static/<path:filename>') |
| def serve_static(filename): |
| return send_from_directory('static', filename) |
|
|
| @app.route('/login') |
| def login_page(): |
| """Serve the login page""" |
| lang = detect_language() |
| translations = load_translations(lang) |
| |
| |
| if session.get('logged_in'): |
| return redirect('/') |
| |
| |
| template = HTML_TEMPLATE.replace('{{LANG}}', lang) |
| template = template.replace('{{DIR}}', 'rtl' if lang == 'ar' else 'ltr') |
| |
| return render_template_string(template) |
|
|
| @app.route('/') |
| def home(): |
| """Serve the main web interface""" |
| |
| |
| |
| |
| |
| lang = detect_language() |
| |
| translations = load_translations(lang) |
| |
| |
| template = HTML_TEMPLATE.replace('{{LANG}}', lang) |
| template = template.replace('{{DIR}}', 'rtl' if lang == 'ar' else 'ltr') |
| |
| |
| |
| return render_template_string(template) |
|
|
| @app.route('/api/translations') |
| def get_translations(): |
| """Get translations for current language""" |
| |
| lang_param = request.args.get('lang', '').lower() |
| if lang_param in ['ar', 'fr', 'en']: |
| lang = lang_param |
| |
| session['lang'] = lang |
| else: |
| |
| lang = detect_language() |
| |
| translations = load_translations(lang) |
| return jsonify({ |
| 'lang': lang, |
| 'translations': translations, |
| 'rtl': lang == 'ar' |
| }) |
|
|
| @app.route('/api/set-language', methods=['POST']) |
| def set_language(): |
| """Set language preference""" |
| data = request.get_json() |
| lang = data.get('lang', 'ar') |
| |
| if lang in ['ar', 'fr', 'en']: |
| session['lang'] = lang |
| return jsonify({'success': True, 'lang': lang}) |
| |
| return jsonify({'success': False, 'error': 'Invalid language'}), 400 |
|
|
| @app.route('/model-info') |
| def model_info(): |
| """Get model information""" |
| try: |
| |
| model_ready = False |
| if hasattr(model, 'is_trained'): |
| model_ready = model.is_trained |
| elif hasattr(model, 'rf_loaded') or hasattr(model, 'nn_loaded'): |
| model_ready = getattr(model, 'rf_loaded', False) or getattr(model, 'nn_loaded', False) |
| else: |
| model_ready = True |
| |
| if not model_ready: |
| if hasattr(model, 'load_model'): |
| if not model.load_model(): |
| return jsonify({'error': 'Model not trained'}) |
| elif hasattr(model, 'load_models'): |
| if not model.load_models(): |
| return jsonify({'error': 'Model not trained'}) |
| else: |
| return jsonify({'error': 'Model not available'}) |
| |
| info = model.get_model_info() |
| info['model_type'] = model_type |
| active_version = model_manager.get_active_version() |
| info['version'] = active_version or 'v1' |
| |
| return jsonify(info) |
| except Exception as e: |
| return jsonify({"status": "Error", "error": str(e), "model_type": model_type}) |
|
|
| def cleanup_old_temp_files(max_age_hours=1): |
| """ |
| Delete temp audio files older than max_age_hours if no feedback was provided. |
| This protects user privacy by removing unverified audio files. |
| """ |
| import time |
| temp_dir = os.path.join(feedback_manager.feedback_dir, "temp") |
| if not os.path.exists(temp_dir): |
| return 0 |
| |
| current_time = time.time() |
| max_age_seconds = max_age_hours * 3600 |
| |
| deleted_count = 0 |
| for filename in os.listdir(temp_dir): |
| file_path = os.path.join(temp_dir, filename) |
| if os.path.isfile(file_path) and filename.endswith('.wav'): |
| file_age = current_time - os.path.getmtime(file_path) |
| |
| |
| if file_age > max_age_seconds: |
| try: |
| os.remove(file_path) |
| deleted_count += 1 |
| |
| |
| audio_id = filename.replace('.wav', '') |
| if audio_id in pending_feedback: |
| del pending_feedback[audio_id] |
| except Exception as e: |
| print(f"Error deleting temp file {filename}: {e}") |
| |
| if deleted_count > 0: |
| print(f"Cleaned up {deleted_count} old temp audio files") |
| |
| return deleted_count |
|
|
| @app.route('/analyze', methods=['POST']) |
| def analyze_audio(): |
| """Analyze uploaded audio file with validation and diagnostics. Only registered users.""" |
| try: |
| |
| if not isinstance(db, _LocalStubDb): |
| user_id = (request.headers.get('X-User-ID') or '').strip() |
| if not user_id: |
| return jsonify({ |
| 'error': 'Please register to use the app. Only registered users can analyze recordings.', |
| 'error_key': 'errors.registration_required', |
| 'requires_registration': True |
| }), 403 |
| if not db.get_user(user_id): |
| return jsonify({ |
| 'error': 'Please register to use the app. Only registered users can analyze recordings.', |
| 'error_key': 'errors.registration_required', |
| 'requires_registration': True |
| }), 403 |
| |
| |
| cleanup_old_temp_files() |
| |
| |
| debug_mode = request.args.get('debug', 'false').lower() == 'true' |
| |
| if 'audio' not in request.files: |
| return jsonify({'error': 'No audio file provided'}) |
| |
| audio_file = request.files['audio'] |
| |
| if audio_file.filename == '': |
| return jsonify({'error': 'No file selected'}) |
| |
| |
| model_ready = False |
| if hasattr(model, 'is_trained'): |
| model_ready = model.is_trained |
| elif hasattr(model, 'rf_loaded') or hasattr(model, 'nn_loaded'): |
| |
| model_ready = getattr(model, 'rf_loaded', False) or getattr(model, 'nn_loaded', False) |
| else: |
| model_ready = True |
| |
| if not model_ready: |
| if hasattr(model, 'load_model'): |
| if not model.load_model(): |
| return jsonify({'error': 'Model not trained'}) |
| if not model.load_model(): |
| return jsonify({'error': 'Model not trained'}) |
| elif hasattr(model, 'load_models'): |
| if not model.load_models(): |
| return jsonify({'error': 'Model not trained'}) |
| else: |
| return jsonify({'error': 'Model not available'}) |
| audio_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{os.urandom(4).hex()}" |
| |
| feedback_temp_dir = os.path.join(feedback_manager.feedback_dir, "temp") |
| os.makedirs(feedback_temp_dir, exist_ok=True) |
| |
| temp_path = os.path.join(feedback_temp_dir, f"{audio_id}.wav") |
| audio_file.save(temp_path) |
| |
| |
| is_valid, validation_message, audio_stats = audio_processor.validate_audio(temp_path) |
| |
| if not is_valid: |
| |
| if os.path.exists(temp_path): |
| os.remove(temp_path) |
| |
| |
| error_key = 'errors.validation_failed' |
| if 'too short' in validation_message.lower(): |
| duration = audio_stats.get('duration_seconds', 0) |
| error_key = f"errors.too_short" |
| elif 'too long' in validation_message.lower(): |
| duration = audio_stats.get('duration_seconds', 0) |
| error_key = f"errors.too_long" |
| elif 'too quiet' in validation_message.lower(): |
| level = audio_stats.get('audio_level_db', 0) |
| error_key = f"errors.too_quiet" |
| elif 'silence' in validation_message.lower(): |
| ratio = audio_stats.get('silence_ratio', 0) * 100 |
| error_key = f"errors.mostly_silence" |
| elif 'no meaningful' in validation_message.lower(): |
| error_key = 'errors.no_audio_content' |
| |
| response = { |
| 'error_key': error_key, |
| 'error': validation_message, |
| 'validation_failed': True, |
| 'stats': audio_stats if debug_mode else None |
| } |
| if debug_mode: |
| response['diagnostics'] = audio_stats |
| return jsonify(response) |
| |
| pending_feedback[audio_id] = { |
| 'path': temp_path, |
| 'timestamp': datetime.now().isoformat() |
| } |
| |
| |
| analysis_mode = (request.form.get('analysis_mode') or 'local').strip().lower() |
| if analysis_mode not in ('local', 'cloud'): |
| analysis_mode = 'local' |
| |
| try: |
| |
| features, diagnostics = audio_processor.extract_features_with_diagnostics(temp_path) |
|
|
| |
| p_cry = cry_gate_probability(features) |
| if p_cry is not None and p_cry < cry_gate.get('reject_threshold', 0.30): |
| if os.path.exists(temp_path): |
| os.remove(temp_path) |
| pending_feedback.pop(audio_id, None) |
| return jsonify({ |
| 'not_a_cry': True, |
| 'error_key': 'errors.not_a_cry', |
| 'error': 'This does not sound like a baby cry. Try recording closer to the baby.', |
| 'cry_probability': round(p_cry * 100, 1) |
| }) |
|
|
| top_predictions = None |
| if analysis_mode == 'cloud': |
| |
| if predict_via_openai_audio is None: |
| if os.path.exists(temp_path): |
| os.remove(temp_path) |
| return jsonify({'error': 'OpenAI client not installed. Install the openai package.'}) |
| result = predict_via_openai_audio(temp_path) |
| prediction, confidence = result[0], result[1] |
| openai_error = result[2] if len(result) > 2 else None |
| used_model_type = 'openai_audio' |
| if prediction is None or confidence is None: |
| if os.path.exists(temp_path): |
| os.remove(temp_path) |
| return jsonify({ |
| 'error': openai_error or 'OpenAI API key not configured or request failed. Check OPENAI_API_KEY and try again.' |
| }) |
| else: |
| |
| prediction, confidence = None, None |
| if features is not None: |
| top_predictions = model.predict_top_k(features, k=2) |
| if top_predictions: |
| prediction, confidence = top_predictions[0] |
| used_model_type = model_type |
| if prediction is None or confidence is None: |
| |
| if voc2vec_predict_top_k is not None and voc2vec_available(): |
| classifier_path = os.path.join(MODELS_DIR, 'voc2vec_classifier.pkl') |
| top_predictions = voc2vec_predict_top_k(temp_path, classifier_path=classifier_path, k=2) |
| if top_predictions: |
| prediction, confidence = top_predictions[0] |
| used_model_type = 'voc2vec' |
| if prediction is None or confidence is None: |
| if os.path.exists(temp_path): |
| os.remove(temp_path) |
| return jsonify({ |
| 'error': (diagnostics or {}).get('message', 'Could not analyze audio'), |
| 'diagnostics': diagnostics if debug_mode else None |
| }) |
|
|
| pending_feedback[audio_id]['prediction'] = prediction |
| pending_feedback[audio_id]['confidence'] = confidence |
|
|
| |
| UNCERTAIN_THRESHOLD = 0.40 |
| response = { |
| 'prediction': prediction, |
| 'confidence': confidence * 100, |
| 'top_predictions': [ |
| {'label': label, 'confidence': round(prob * 100, 1)} |
| for label, prob in (top_predictions or [(prediction, confidence)]) |
| ], |
| 'uncertain': bool(confidence < UNCERTAIN_THRESHOLD), |
| 'timestamp': datetime.now().isoformat(), |
| 'audio_id': audio_id, |
| 'model_type': used_model_type |
| } |
| |
| |
| try: |
| |
| user_id = request.headers.get('X-User-ID') |
| if user_id and user_id.strip(): |
| user_id = user_id.strip() |
| else: |
| user_id = None |
| |
| prediction_data = { |
| 'user_id': user_id, |
| 'prediction': prediction, |
| 'confidence': float(confidence * 100), |
| 'audio_id': audio_id, |
| 'model_type': used_model_type, |
| 'timestamp': datetime.now().isoformat() |
| } |
| |
| |
| try: |
| db.create_prediction(prediction_data) |
| user_info = f"user: {user_id}" if user_id else "anonymous" |
| print(f"โ
Prediction saved to database: {prediction} (confidence: {confidence*100:.1f}%, {user_info})") |
| except Exception as db_error: |
| print(f"โ Database error saving prediction: {db_error}") |
| import traceback |
| traceback.print_exc() |
| |
| |
| |
| except Exception as e: |
| print(f"โ Error tracking prediction: {e}") |
| import traceback |
| traceback.print_exc() |
| |
| |
| |
| if debug_mode and diagnostics is not None: |
| response['diagnostics'] = { |
| 'duration_seconds': audio_stats.get('duration_seconds'), |
| 'sample_rate': audio_stats.get('sample_rate'), |
| 'audio_level_db': audio_stats.get('audio_level_db'), |
| 'silence_ratio': audio_stats.get('silence_ratio'), |
| 'cry_likelihood': audio_stats.get('cry_likelihood'), |
| 'feature_summary': diagnostics.get('feature_summary', {}) |
| } |
| |
| return jsonify(response) |
| |
| except Exception as e: |
| if os.path.exists(temp_path): |
| os.remove(temp_path) |
| if audio_id in pending_feedback: |
| del pending_feedback[audio_id] |
| raise e |
| |
| except Exception as e: |
| return jsonify({'error': f'Analysis failed: {str(e)}'}) |
|
|
| @app.route('/feedback', methods=['POST']) |
| def receive_feedback(): |
| """Receive user feedback. Only registered users.""" |
| try: |
| feedback_data = request.get_json() or {} |
| |
| |
| if not isinstance(db, _LocalStubDb): |
| user_id = (feedback_data.get('user_id') or '').strip() if isinstance(feedback_data.get('user_id'), str) else None |
| if not user_id: |
| return jsonify({ |
| 'error': 'Please register to use the app. Only registered users can submit feedback.', |
| 'error_key': 'errors.registration_required', |
| 'requires_registration': True |
| }), 403 |
| if not db.get_user(user_id): |
| return jsonify({ |
| 'error': 'Please register to use the app. Only registered users can submit feedback.', |
| 'error_key': 'errors.registration_required', |
| 'requires_registration': True |
| }), 403 |
| |
| audio_id = feedback_data.get('audio_id') |
| predicted_label = feedback_data.get('predicted_label') |
| correct_label = feedback_data.get('correct_label') |
| is_correct = feedback_data.get('is_correct', False) |
| what_helped = feedback_data.get('what_helped', []) |
| |
| if not audio_id or not correct_label: |
| return jsonify({'error': 'Missing data'}) |
| |
| if audio_id not in pending_feedback: |
| return jsonify({'error': 'Audio expired'}) |
| |
| pending = pending_feedback[audio_id] |
| audio_path = pending.get('path') |
| |
| if not audio_path or not os.path.exists(audio_path): |
| return jsonify({'error': 'Audio file not found'}) |
| |
| confidence = pending.get('confidence', 0.5) |
| |
| submission_id, should_retrain = feedback_manager.save_feedback( |
| audio_path=audio_path, |
| predicted_label=predicted_label, |
| correct_label=correct_label, |
| confidence=confidence, |
| is_correct=is_correct |
| ) |
| |
| |
| storage_path = f"verified/{correct_label}/{submission_id}.wav" |
| audio_url = None |
| |
| if supabase_storage.enabled: |
| |
| audio_url = supabase_storage.upload_audio(audio_path, storage_path) |
| if audio_url: |
| print(f"โ
Audio uploaded to Supabase Storage: {audio_url}") |
| else: |
| print("โ ๏ธ Failed to upload to Supabase Storage, using local path") |
| |
| |
| if not audio_url: |
| |
| file_path_abs = os.path.abspath(os.path.join(feedback_manager.verified_dir, correct_label, f"{submission_id}.wav")) |
| app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| |
| try: |
| file_path = os.path.relpath(file_path_abs, app_root) |
| except ValueError: |
| file_path = os.path.join("feedback_data", "verified", correct_label, f"{submission_id}.wav") |
| if file_path.startswith(".."): |
| file_path = os.path.join("feedback_data", "verified", correct_label, f"{submission_id}.wav") |
| else: |
| |
| file_path = audio_url |
| |
| |
| try: |
| |
| user_id = feedback_data.get('user_id') |
| if user_id and isinstance(user_id, str) and user_id.strip(): |
| user_id = user_id.strip() |
| else: |
| user_id = None |
| |
| feedback_record = { |
| 'audio_id': audio_id, |
| 'user_id': user_id, |
| 'predicted_label': predicted_label, |
| 'correct_label': correct_label, |
| 'is_correct': is_correct, |
| 'confidence': confidence, |
| 'file_path': file_path, |
| 'submission_id': submission_id, |
| 'what_helped': ','.join(what_helped) if what_helped else '', |
| 'timestamp': datetime.now().isoformat() |
| } |
| |
| |
| try: |
| db.create_feedback(feedback_record) |
| user_info = f"user: {user_id}" if user_id else "anonymous" |
| print(f"โ
Feedback saved to Supabase: {correct_label} (predicted: {predicted_label}, correct: {is_correct}, confidence: {confidence:.2f}, {user_info})") |
| print(f" Feedback record: {feedback_record}") |
| except Exception as db_error: |
| print(f"โ Database error saving feedback to Supabase: {db_error}") |
| import traceback |
| traceback.print_exc() |
| print(f" Attempted feedback record: {feedback_record}") |
| |
| return jsonify({'error': f'Failed to save feedback to database: {str(db_error)}'}), 500 |
| |
| |
| except Exception as e: |
| print(f"โ Error tracking feedback: {e}") |
| import traceback |
| traceback.print_exc() |
| |
| |
| |
| |
| |
| del pending_feedback[audio_id] |
| |
| |
| |
| |
| retrain_triggered = False |
| if should_retrain and os.environ.get('ENABLE_AUTO_RETRAIN') == '1': |
| retrain_result = model_manager.check_and_auto_retrain() |
| if retrain_result and retrain_result.get('success'): |
| retrain_triggered = True |
| model.load_model() |
| |
| return jsonify({ |
| 'status': 'Feedback saved', |
| 'submission_id': submission_id, |
| 'retrain_triggered': retrain_triggered |
| }) |
| |
| except Exception as e: |
| return jsonify({'error': str(e)}) |
|
|
| @app.route('/feedback/stats') |
| def feedback_stats(): |
| """Get feedback statistics""" |
| try: |
| stats = feedback_manager.get_stats() |
| return jsonify(stats) |
| except Exception as e: |
| return jsonify({'error': str(e)}) |
|
|
| @app.route('/model/retrain', methods=['POST']) |
| def retrain_model(): |
| """Manually trigger retraining""" |
| try: |
| result = model_manager.retrain_with_feedback(include_feedback=True) |
| |
| if result.get('success'): |
| model.load_model() |
| |
| return jsonify(result) |
| except Exception as e: |
| return jsonify({'success': False, 'error': str(e)}) |
|
|
| @app.route('/api/analytics/error', methods=['POST']) |
| def analytics_error(): |
| """Receive error reports from mobile app""" |
| try: |
| error_data = request.get_json() |
| |
| print(f"๐ Error reported: {error_data.get('error', {}).get('message', 'Unknown error')}") |
| return jsonify({'status': 'received'}) |
| except Exception as e: |
| return jsonify({'error': str(e)}) |
|
|
| @app.route('/model/versions') |
| def model_versions(): |
| """Get model versions""" |
| try: |
| versions = model_manager.get_versions() |
| active = model_manager.get_active_version() |
| return jsonify({ |
| 'versions': versions, |
| 'active_version': active |
| }) |
| except Exception as e: |
| return jsonify({'error': str(e)}) |
|
|
| @app.route('/model/switch/<version>', methods=['POST']) |
| def switch_model_version(version): |
| """Switch model version""" |
| try: |
| result = model_manager.switch_version(version) |
| |
| if result.get('success'): |
| model.load_model() |
| |
| return jsonify(result) |
| except Exception as e: |
| return jsonify({'success': False, 'error': str(e)}) |
|
|
| @app.route('/model/summary') |
| def model_summary(): |
| """Get model summary""" |
| try: |
| summary = model_manager.get_model_summary() |
| return jsonify(summary) |
| except Exception as e: |
| return jsonify({'error': str(e)}) |
|
|
| @app.route('/test-connection', methods=['GET', 'POST']) |
| def test_connection(): |
| """Test endpoint to verify connectivity from mobile app""" |
| return jsonify({ |
| 'status': 'success', |
| 'message': 'Connection successful!', |
| 'timestamp': datetime.now().isoformat(), |
| 'method': request.method, |
| 'headers': dict(request.headers), |
| 'origin': request.headers.get('Origin', 'not provided'), |
| 'user_agent': request.headers.get('User-Agent', 'not provided') |
| }) |
|
|
| def _try_reconnect_db(): |
| """If we're on the stub but Supabase is configured, try to reconnect once. |
| |
| Lets the app self-heal after a paused Supabase project is restored, |
| without needing a Space rebuild. Called from /health, which doubles as |
| the keep-alive ping target. |
| """ |
| global db, DB_INIT_ERROR |
| if not isinstance(db, _LocalStubDb) or not SUPABASE_DB_URL: |
| return |
| try: |
| from database_supabase import SupabaseDatabase |
| db = SupabaseDatabase() |
| DB_INIT_ERROR = None |
| print("โ
Reconnected to Supabase (was on stub)") |
| except Exception as e: |
| DB_INIT_ERROR = str(e)[:200] |
|
|
|
|
| @app.route('/privacy') |
| def privacy_policy(): |
| """Public privacy policy (required for the Play Store listing).""" |
| return send_from_directory(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static'), 'privacy.html') |
|
|
|
|
| @app.route('/health') |
| def health_check(): |
| _try_reconnect_db() |
| db_alive = False |
| if not isinstance(db, _LocalStubDb): |
| |
| |
| try: |
| with db.get_connection(retries=1) as conn: |
| with conn.cursor() as cur: |
| cur.execute('SELECT 1') |
| db_alive = True |
| except Exception as e: |
| DB_INIT_ERROR_local = str(e)[:200] |
| return jsonify({ |
| 'status': 'healthy', |
| 'database': 'supabase-unreachable', |
| 'db_error': DB_INIT_ERROR_local, |
| 'timestamp': datetime.now().isoformat() |
| }) |
| return jsonify({ |
| 'status': 'healthy', |
| 'database': 'supabase' if db_alive else 'stub', |
| 'db_error': None if db_alive else DB_INIT_ERROR, |
| 'timestamp': datetime.now().isoformat() |
| }) |
|
|
|
|
| @app.route('/health-legacy') |
| def health_check_legacy(): |
| """Health check endpoint for cloud deployments""" |
| return jsonify({'status': 'healthy', 'timestamp': datetime.now().isoformat()}) |
|
|
| |
|
|
| @app.route('/api/admin/users/stats') |
| @admin_required |
| def admin_users_stats(): |
| """Get user statistics""" |
| try: |
| stats = db.get_user_stats() |
| return jsonify(stats) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/users', methods=['GET']) |
| @admin_required |
| def admin_users_list(): |
| """Get all users with pagination""" |
| try: |
| limit = request.args.get('limit', type=int) |
| offset = request.args.get('offset', 0, type=int) |
| users = db.get_all_users(limit=limit, offset=offset) |
| total = db.count_users() |
| return jsonify({ |
| 'users': users, |
| 'total': total, |
| 'limit': limit, |
| 'offset': offset |
| }) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/users/<user_id>', methods=['GET']) |
| @admin_required |
| def admin_user_get(user_id): |
| """Get a specific user""" |
| try: |
| user = db.get_user(user_id) |
| if not user: |
| return jsonify({'error': 'User not found'}), 404 |
| return jsonify(user) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/users/<user_id>', methods=['PUT']) |
| @admin_required |
| def admin_user_update(user_id): |
| """Update a user""" |
| try: |
| user_data = request.get_json() |
| if not user_data: |
| return jsonify({'error': 'No data provided'}), 400 |
| |
| success = db.update_user(user_id, user_data) |
| if not success: |
| return jsonify({'error': 'User not found or no changes made'}), 404 |
| |
| user = db.get_user(user_id) |
| return jsonify({'success': True, 'user': user}) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/users/<user_id>', methods=['DELETE']) |
| @admin_required |
| def admin_user_delete(user_id): |
| """Delete a user""" |
| try: |
| success = db.delete_user(user_id) |
| if not success: |
| return jsonify({'error': 'User not found'}), 404 |
| return jsonify({'success': True}) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/users/platform') |
| @admin_required |
| def admin_users_platform(): |
| """Get platform distribution""" |
| try: |
| distribution = db.get_platform_distribution() |
| return jsonify(distribution) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/users/language') |
| @admin_required |
| def admin_users_language(): |
| """Get language distribution""" |
| try: |
| distribution = db.get_language_distribution() |
| return jsonify(distribution) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/users/timeline') |
| @admin_required |
| def admin_users_timeline(): |
| """Get user registration timeline""" |
| try: |
| days = int(request.args.get('days', 30)) |
| timeline = db.get_user_timeline(days=days) |
| return jsonify(timeline) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/model/summary') |
| @admin_required |
| def admin_model_summary(): |
| """Get model summary""" |
| try: |
| summary = model_manager.get_model_summary() |
| return jsonify(summary) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/model/performance') |
| @admin_required |
| def admin_model_performance(): |
| """Get model performance metrics""" |
| try: |
| |
| feedback_stats = db.get_feedback_stats() |
| |
| |
| total_predictions = db.count_predictions() |
| |
| |
| from psycopg2.extras import RealDictCursor |
| with db.get_connection() as conn: |
| cursor = conn.cursor(cursor_factory=RealDictCursor) |
| cursor.execute(''' |
| SELECT AVG(confidence) as avg_confidence |
| FROM predictions |
| WHERE timestamp >= NOW() - INTERVAL '30 days' |
| ''') |
| result = cursor.fetchone() |
| avg_confidence = float(result['avg_confidence']) if result and result['avg_confidence'] is not None else 0.0 |
| |
| return jsonify({ |
| 'average_confidence': avg_confidence, |
| 'total_predictions': total_predictions, |
| 'feedback_stats': feedback_stats |
| }) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/feedback/categories') |
| @admin_required |
| def admin_feedback_categories(): |
| """Get feedback breakdown by category""" |
| try: |
| stats = db.get_feedback_stats() |
| return jsonify({ |
| 'category_counts': stats.get('category_counts', {}), |
| 'total': stats.get('total_feedback', 0) |
| }) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/feedback/timeline') |
| @admin_required |
| def admin_feedback_timeline(): |
| """Get feedback submission timeline""" |
| try: |
| days = int(request.args.get('days', 30)) |
| timeline = db.get_feedback_timeline(days=days) |
| return jsonify(timeline) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/feedback/stats') |
| @admin_required |
| def admin_feedback_stats(): |
| """Get comprehensive feedback statistics""" |
| try: |
| stats = db.get_feedback_stats() |
| return jsonify(stats) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/feedback/list') |
| @admin_required |
| def admin_feedback_list(): |
| """Get feedback entries for admin dashboard""" |
| try: |
| limit = request.args.get('limit', type=int) |
| offset = request.args.get('offset', 0, type=int) |
| is_correct = request.args.get('is_correct') |
| |
| |
| is_correct_bool = None |
| if is_correct is not None: |
| is_correct_bool = is_correct.lower() in ['true', '1', 'yes'] |
| |
| feedback_list = db.get_feedback_list(limit=limit, offset=offset, is_correct=is_correct_bool) |
| |
| return jsonify({ |
| 'feedback': feedback_list, |
| 'total': len(feedback_list) |
| }) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/predictions/list') |
| @admin_required |
| def admin_predictions_list(): |
| """Get predictions list for admin dashboard""" |
| try: |
| limit = request.args.get('limit', type=int) |
| offset = request.args.get('offset', 0, type=int) |
| user_id = request.args.get('user_id') |
| |
| predictions_list = db.get_predictions_list(limit=limit, offset=offset, user_id=user_id) |
| |
| return jsonify({ |
| 'predictions': predictions_list, |
| 'total': len(predictions_list) |
| }) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/feedback/audio/<path:file_path>') |
| @admin_required |
| def admin_feedback_audio(file_path): |
| """Serve audio file for feedback (admin only)""" |
| try: |
| import urllib.parse |
| |
| |
| file_path = urllib.parse.unquote(file_path) |
| |
| |
| if file_path.startswith('https://'): |
| |
| from flask import redirect |
| return redirect(file_path, code=302) |
| |
| |
| |
| if file_path.startswith('/') and not file_path.startswith('../'): |
| return jsonify({'error': 'Invalid file path'}), 400 |
| |
| |
| feedback_dir_abs = os.path.abspath(feedback_manager.feedback_dir) |
| _app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| _app_feedback = os.path.normpath(os.path.join(_app_root, 'feedback_data')) |
| |
| |
| relative_path = None |
| if file_path.startswith('../'): |
| if '../feedback_data/' in file_path: |
| relative_path = file_path.replace('../feedback_data/', '') |
| actual_path = os.path.join(feedback_dir_abs, relative_path) |
| else: |
| actual_path = os.path.join(os.path.dirname(feedback_dir_abs), file_path[3:]) |
| elif file_path.startswith('feedback_data/'): |
| relative_path = file_path.replace('feedback_data/', '') |
| actual_path = os.path.join(feedback_dir_abs, relative_path) |
| else: |
| actual_path = os.path.join(feedback_dir_abs, file_path) |
| |
| |
| actual_path = os.path.normpath(actual_path) |
| |
| |
| |
| if not os.path.exists(actual_path) and relative_path: |
| fallback_path = os.path.normpath(os.path.join(_app_feedback, relative_path)) |
| if os.path.exists(fallback_path) and fallback_path.startswith(_app_feedback): |
| actual_path = fallback_path |
| print(f"๐ Resolved old path to: {actual_path}") |
| |
| |
| if not actual_path.startswith(feedback_dir_abs) and not actual_path.startswith(_app_feedback): |
| print(f"โ Security check failed: {actual_path} not in {feedback_dir_abs!r} or {_app_feedback!r}") |
| return jsonify({'error': 'File not in feedback directory'}), 403 |
| |
| print(f"๐ Serving local audio file: {actual_path}") |
| |
| |
| if not os.path.exists(actual_path): |
| return jsonify({'error': 'Audio file not found'}), 404 |
| |
| |
| if not actual_path.lower().endswith('.wav'): |
| return jsonify({'error': 'Invalid file type'}), 400 |
| |
| |
| from flask import send_file |
| return send_file(actual_path, mimetype='audio/wav', as_attachment=False) |
| |
| except Exception as e: |
| print(f"โ Error serving audio file: {e}") |
| import traceback |
| traceback.print_exc() |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/system/health') |
| @admin_required |
| def admin_system_health(): |
| """Get system health status""" |
| try: |
| |
| model_status = 'loaded' if (model and hasattr(model, 'is_trained') and model.is_trained) else 'not_loaded' |
| |
| |
| data_ok = os.path.exists(DATA_DIR) |
| feedback_ok = os.path.exists(FEEDBACK_DIR) |
| models_ok = os.path.exists(MODELS_DIR) |
| |
| return jsonify({ |
| 'status': 'healthy' if (model_status == 'loaded' and data_ok) else 'degraded', |
| 'model_status': model_status, |
| 'directories': { |
| 'data': data_ok, |
| 'feedback': feedback_ok, |
| 'models': models_ok |
| }, |
| 'timestamp': datetime.now().isoformat() |
| }) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/system/storage') |
| @admin_required |
| def admin_system_storage(): |
| """Get storage usage""" |
| try: |
| def get_dir_size(path): |
| total = 0 |
| if os.path.exists(path): |
| for dirpath, dirnames, filenames in os.walk(path): |
| for f in filenames: |
| fp = os.path.join(dirpath, f) |
| if os.path.exists(fp): |
| total += os.path.getsize(fp) |
| return total |
| |
| feedback_size = get_dir_size(FEEDBACK_DIR) |
| models_size = get_dir_size(MODELS_DIR) |
| data_size = get_dir_size(DATA_DIR) |
| |
| return jsonify({ |
| 'feedback_data': { |
| 'size_bytes': feedback_size, |
| 'size_mb': round(feedback_size / (1024 * 1024), 2) |
| }, |
| 'models': { |
| 'size_bytes': models_size, |
| 'size_mb': round(models_size / (1024 * 1024), 2) |
| }, |
| 'training_data': { |
| 'size_bytes': data_size, |
| 'size_mb': round(data_size / (1024 * 1024), 2) |
| }, |
| 'total_mb': round((feedback_size + models_size + data_size) / (1024 * 1024), 2) |
| }) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| |
|
|
| @app.route('/admin/login', methods=['GET', 'POST']) |
| def admin_login(): |
| """Admin login page and authentication""" |
| if request.method == 'POST': |
| data = request.get_json() if request.is_json else request.form |
| password = data.get('password', '') |
| |
| if password == ADMIN_PASSWORD: |
| session['admin_authenticated'] = True |
| return jsonify({'success': True, 'redirect': '/admin'}) |
| else: |
| return jsonify({'success': False, 'error': 'Invalid password'}), 401 |
| |
| |
| lang = detect_language() |
| translations = load_translations(lang) |
| admin_translations = translations.get('admin', {}) |
| |
| template = ADMIN_LOGIN_TEMPLATE.replace('{{LANG}}', lang) |
| template = template.replace('{{DIR}}', 'rtl' if lang == 'ar' else 'ltr') |
| |
| |
| translations_json = json.dumps(admin_translations, ensure_ascii=False) |
| template = template.replace('{{TRANSLATIONS}}', translations_json) |
| |
| return render_template_string(template) |
|
|
| @app.route('/admin/logout') |
| def admin_logout(): |
| """Admin logout""" |
| session.pop('admin_authenticated', None) |
| return jsonify({'success': True, 'redirect': '/admin/login'}) |
|
|
| @app.route('/admin') |
| @admin_required |
| def admin_dashboard(): |
| """Admin dashboard page""" |
| from admin_dashboard_template import ADMIN_DASHBOARD_TEMPLATE_STR as ADMIN_DASHBOARD_TEMPLATE |
| lang = detect_language() |
| translations = load_translations(lang) |
| admin_translations = translations.get('admin', {}) |
| |
| template = ADMIN_DASHBOARD_TEMPLATE.replace('{{LANG}}', lang) |
| template = template.replace('{{DIR}}', 'rtl' if lang == 'ar' else 'ltr') |
| |
| |
| translations_json = json.dumps(admin_translations if admin_translations else {}, ensure_ascii=False) |
| template = template.replace('{{TRANSLATIONS}}', translations_json) |
| |
| return render_template_string(template) |
|
|
| |
|
|
| @app.route('/api/users/register', methods=['POST']) |
| def register_user(): |
| """Register a new user""" |
| try: |
| user_data = request.get_json() |
| |
| if not user_data: |
| print("โ Registration failed: No data provided") |
| return jsonify({'error': 'No data provided'}), 400 |
| |
| |
| baby_name = user_data.get('baby_name', '').strip() |
| if not baby_name: |
| print("โ Registration failed: baby_name is required") |
| return jsonify({'error': 'Baby name is required'}), 400 |
| |
| |
| baby_name = ' '.join(baby_name.split())[:100] |
| |
| |
| user_id = user_data.get('user_id', 'user_' + str(int(datetime.now().timestamp() * 1000))) |
| user_id = str(user_id).strip() if user_id else None |
| |
| |
| if not user_id or len(user_id) < 5 or len(user_id) > 100: |
| print("โ Registration failed: Invalid user_id format") |
| return jsonify({'error': 'Invalid user ID format'}), 400 |
| |
| |
| email = user_data.get('email') |
| if email: |
| email = str(email).strip().lower() |
| |
| if '@' not in email or '.' not in email.split('@')[1] or len(email) > 255: |
| print(f"โ Registration failed: Invalid email format: {email}") |
| return jsonify({'error': 'Invalid email format'}), 400 |
| elif len(email) < 3: |
| print(f"โ Registration failed: Email too short: {email}") |
| return jsonify({'error': 'Invalid email format'}), 400 |
| |
| |
| phone = user_data.get('phone') |
| if phone: |
| phone = str(phone).strip() |
| |
| |
| phone_digits = ''.join(c for c in phone if c.isdigit()) |
| if len(phone_digits) < 7 or len(phone_digits) > 15 or len(phone) > 25: |
| print(f"โ Registration failed: Invalid phone format: {phone}") |
| return jsonify({'error': 'Invalid phone number format'}), 400 |
| else: |
| |
| phone = phone[:25] |
| |
| |
| if not email and not phone: |
| print("โ Registration failed: Email or phone number is required") |
| return jsonify({'error': 'Email or phone number is required'}), 400 |
| |
| |
| baby_gender = user_data.get('baby_gender') |
| if baby_gender: |
| baby_gender = str(baby_gender).strip().lower() |
| |
| if baby_gender not in ['male', 'female', 'm', 'f']: |
| baby_gender = None |
| elif baby_gender in ['m', 'f']: |
| baby_gender = 'male' if baby_gender == 'm' else 'female' |
| |
| baby_birthday = user_data.get('baby_birthday') |
| if baby_birthday: |
| baby_birthday = str(baby_birthday).strip()[:10] |
| |
| platform = str(user_data.get('platform', 'web')).strip().lower() |
| if platform not in ['web', 'android', 'ios']: |
| platform = 'web' |
| |
| language = str(user_data.get('language', 'ar')).strip().lower() |
| if language not in ['ar', 'en', 'fr']: |
| language = 'ar' |
| |
| |
| password = user_data.get('password', '').strip() |
| password_hash = None |
| if password: |
| if len(password) < 6: |
| return jsonify({'error': 'Password must be at least 6 characters'}), 400 |
| password_hash = generate_password_hash(password) |
| |
| user_info = { |
| 'user_id': user_id, |
| 'email': email, |
| 'phone': phone, |
| 'password_hash': password_hash, |
| 'baby_name': baby_name, |
| 'baby_gender': baby_gender, |
| 'baby_birthday': baby_birthday, |
| 'platform': platform, |
| 'language': language, |
| 'registration_date': datetime.now().isoformat() |
| } |
| |
| |
| try: |
| db.create_user(user_info) |
| print(f"โ
User registered successfully: {user_id} (baby: {baby_name}, platform: {user_info['platform']})") |
| except Exception as db_error: |
| print(f"โ Database error during registration: {db_error}") |
| import traceback |
| traceback.print_exc() |
| return jsonify({'error': f'Failed to save user: {str(db_error)}'}), 500 |
| |
| |
| |
| return jsonify({'success': True, 'user_id': user_id}) |
| except Exception as e: |
| print(f"โ Registration error: {e}") |
| import traceback |
| traceback.print_exc() |
| return jsonify({'error': f'Registration failed: {str(e)}'}), 500 |
|
|
| @app.route('/api/users/login', methods=['POST']) |
| def login_user(): |
| """Login user with email/phone and password""" |
| try: |
| login_data = request.get_json() |
| |
| if not login_data: |
| return jsonify({'error': 'No data provided'}), 400 |
| |
| identifier = login_data.get('email', '').strip() |
| password = login_data.get('password', '') |
| |
| if not identifier: |
| return jsonify({'error': 'Email or phone number is required'}), 400 |
| |
| if not password: |
| return jsonify({'error': 'Password is required'}), 400 |
| |
| |
| user = db.get_user_by_email_or_phone(identifier) |
| |
| if not user: |
| return jsonify({'error': 'Invalid email/phone or password'}), 401 |
| |
| |
| if not user.get('password_hash'): |
| return jsonify({'error': 'Account not set up with password. Please register again.'}), 401 |
| |
| |
| if not check_password_hash(user['password_hash'], password): |
| return jsonify({'error': 'Invalid email/phone or password'}), 401 |
| |
| |
| session['user_id'] = user['user_id'] |
| session['email'] = user.get('email') |
| session['phone'] = user.get('phone') |
| session['logged_in'] = True |
| |
| login_identifier = user.get('email') or user.get('phone') or identifier |
| print(f"โ
User logged in successfully: {user['user_id']} ({login_identifier})") |
| |
| return jsonify({ |
| 'success': True, |
| 'user_id': user['user_id'], |
| 'email': user['email'], |
| 'phone': user.get('phone'), |
| 'baby_name': user.get('baby_name'), |
| 'baby_birthday': user.get('baby_birthday'), |
| 'baby_gender': user.get('baby_gender') |
| }) |
| |
| except Exception as e: |
| print(f"โ Login error: {e}") |
| import traceback |
| traceback.print_exc() |
| return jsonify({'error': f'Login failed: {str(e)}'}), 500 |
|
|
| @app.route('/api/users/logout', methods=['POST']) |
| def logout_user(): |
| """Logout user""" |
| try: |
| session.clear() |
| return jsonify({'success': True}) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/me/predictions') |
| def me_predictions_list(): |
| """Get current user's predictions (cry history). Requires X-User-ID header.""" |
| try: |
| user_id = (request.headers.get('X-User-ID') or '').strip() |
| if not user_id: |
| return jsonify({'error': 'User ID required'}), 401 |
| if not isinstance(db, _LocalStubDb) and not db.get_user(user_id): |
| return jsonify({'error': 'User not found'}), 401 |
| limit = request.args.get('limit', type=int, default=50) |
| offset = request.args.get('offset', type=int, default=0) |
| limit = max(1, min(100, limit)) if limit else 50 |
| offset = max(0, offset) if offset else 0 |
| predictions_list = db.get_predictions_list(limit=limit, offset=offset, user_id=user_id) |
| total = db.count_predictions(user_id=user_id) |
| |
| out = [] |
| for row in predictions_list: |
| r = dict(row) |
| ts = r.get('timestamp') |
| if hasattr(ts, 'isoformat'): |
| r['timestamp'] = ts.isoformat() |
| out.append(r) |
| return jsonify({'predictions': out, 'total': total}) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/me/predictions/<int:prediction_id>', methods=['PATCH']) |
| def me_predictions_update(prediction_id): |
| """Update what_helped for a prediction. Requires X-User-ID header.""" |
| try: |
| user_id = (request.headers.get('X-User-ID') or '').strip() |
| if not user_id: |
| return jsonify({'error': 'User ID required'}), 401 |
| data = request.get_json() or {} |
| what_helped = data.get('what_helped') |
| if what_helped is not None and not isinstance(what_helped, str): |
| what_helped = str(what_helped) |
| updated = db.update_prediction_what_helped(prediction_id, user_id, what_helped) |
| if not updated: |
| return jsonify({'error': 'Prediction not found'}), 404 |
| return jsonify({'success': True}) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/analytics/track', methods=['POST']) |
| def track_analytics(): |
| """Track an analytics event - saved to Supabase""" |
| try: |
| data = request.get_json() |
| event_name = data.get('event_name') |
| event_data = data.get('event_data', {}) |
| user_id = data.get('user_id') |
| |
| if not event_name: |
| return jsonify({'error': 'event_name is required'}), 400 |
| |
| |
| import json as json_lib |
| analytics_record = { |
| 'event_name': event_name, |
| 'user_id': user_id, |
| 'event_data': event_data, |
| 'timestamp': datetime.now().isoformat() |
| } |
| db.create_analytics_event(analytics_record) |
| return jsonify({'success': True}) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/predictions/track', methods=['POST']) |
| def track_prediction_endpoint(): |
| """Track a prediction - saved to Supabase""" |
| try: |
| prediction_data = request.get_json() |
| if not prediction_data: |
| return jsonify({'error': 'No data provided'}), 400 |
| |
| |
| db.create_prediction(prediction_data) |
| return jsonify({'success': True}) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
| if __name__ == '__main__': |
| print("๐ Starting Baby Cry AI (PWA Mode)...") |
| print("=" * 50) |
| |
| if model.load_model(): |
| print("โ
Model loaded") |
| version = model_manager.get_active_version() |
| if version: |
| print(f"๐ฆ Version: {version}") |
| else: |
| print("โ ๏ธ No model found") |
| |
| stats = feedback_manager.get_stats() |
| print(f"๐ Feedback: {stats['total_submissions']} submissions") |
| |
| |
| port = int(os.environ.get('PORT', 5001)) |
| debug = os.environ.get('FLASK_ENV') != 'production' |
| |
| print(f"\n๐ Server starting on port {port}...") |
| print(f"๐ฑ Open: http://localhost:{port}") |
| |
| app.run(debug=debug, host='0.0.0.0', port=port) |
|
|
|
|