BBPlease / src /app.py
hamza-ks's picture
UI polish: real logo, SVG mic, quiet status pill, plain-language labels
d635309
Raw
History Blame Contribute Delete
256 kB
"""
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
# Load environment variables from .env file if it exists (for local development)
try:
from dotenv import load_dotenv
# Try loading from project root
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:
# Try loading from current directory
load_dotenv()
except ImportError:
# python-dotenv not installed, continue without it
pass
except Exception as e:
# If .env loading fails, continue (might be in production)
pass
# Log whether OpenAI key is available (for Cloud analysis mode)
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)")
# Add the src directory to Python path for imports to work from any location
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
# user_analytics and database (SQLite) removed - using Supabase only
# Admin dashboard template will be imported at route definition to avoid circular imports
app = Flask(__name__, static_folder='static')
app.secret_key = os.urandom(24)
# Configure CORS to allow requests from mobile apps and web
CORS(app, resources={
r"/*": {
"origins": "*", # Allow all origins for mobile APK and web
"methods": ["GET", "POST", "OPTIONS"],
"allow_headers": ["Content-Type", "Authorization"]
}
})
# Initialize components
# Use fast_mode=True for faster analysis (skips expensive CQT features)
# This reduces analysis time from ~10s to ~2-3s
audio_processor = AudioProcessor(fast_mode=True)
# Initialize model - Random Forest fallback for 'local' mode (voc2vec is tried first)
# Neural Network is disabled due to poor performance (23.3% accuracy)
model = None
model_type = "unknown"
# Load Random Forest model directly (fallback model)
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() # Fallback to untrained model
model_type = "random_forest"
# Use environment variables for paths (for cloud deployment)
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/not-cry gate: rejects non-cry audio before classification (Phase 2)
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
# Initialize Supabase Storage for audio files
supabase_storage = SupabaseStorage()
# Ensure bucket exists on startup
if supabase_storage.enabled:
supabase_storage.ensure_bucket_exists()
# user_analytics removed - all analytics now handled by Supabase
# Stub database for local run without Supabase (predictions/feedback not persisted)
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 []
# Initialize database (Supabase PostgreSQL, or stub for local run)
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()
# Store for pending feedback
pending_feedback = {}
# ==================== ADMIN AUTHENTICATION ====================
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'admin123') # Default password, should be changed in production
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
# ==================== TRANSLATION SYSTEM ====================
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):
# Fallback to Arabic if translation file doesn't exist
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"""
# Priority: query param > session > default (Arabic)
# Always default to Arabic, ignore browser language
lang = request.args.get('lang', '').lower()
if lang in ['ar', 'fr', 'en']:
session['lang'] = lang
return lang
# Check session
if 'lang' in session and session['lang'] in ['ar', 'fr', 'en']:
return session['lang']
# Default to Arabic (ignore browser language)
# Always set session to ensure consistency
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)
# Handle nested keys (e.g., "categories.hunger")
keys = key.split('.')
value = translations
for k in keys:
if isinstance(value, dict):
value = value.get(k, key)
else:
value = key
break
# If translation not found, try English, then return key
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
# Format with kwargs if provided
if isinstance(value, str) and kwargs:
try:
return value.format(**kwargs)
except:
return value
return value if isinstance(value, str) else key
# Mobile-First PWA HTML Template
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
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>
"""
# Serve static files
@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)
# Check if already logged in
if session.get('logged_in'):
return redirect('/')
# Inject translations and language into template
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"""
# Check if user is logged in (if login is required)
# For now, we'll allow both logged in and not logged in users
# But we can add authentication check here if needed
# detect_language() now always sets session['lang'] to 'ar' if not set
lang = detect_language()
translations = load_translations(lang)
# Inject translations and language into template
template = HTML_TEMPLATE.replace('{{LANG}}', lang)
template = template.replace('{{DIR}}', 'rtl' if lang == 'ar' else 'ltr')
# Replace translation keys with actual translations
# This will be done in JavaScript for dynamic updates
return render_template_string(template)
@app.route('/api/translations')
def get_translations():
"""Get translations for current language"""
# Check if lang is provided as query parameter (for immediate language switching)
lang_param = request.args.get('lang', '').lower()
if lang_param in ['ar', 'fr', 'en']:
lang = lang_param
# Also update session
session['lang'] = lang
else:
# detect_language() now always sets session['lang'] to 'ar' if not set
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:
# Try to load model if not ready
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)
# Delete if older than max_age_hours
if file_age > max_age_seconds:
try:
os.remove(file_path)
deleted_count += 1
# Also remove from pending_feedback if exists
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:
# Require registered user (when using Supabase)
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 before processing new request
cleanup_old_temp_files()
# Check for debug mode
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'})
# Check if model is trained/loaded (different models have different attributes)
model_ready = False
if hasattr(model, 'is_trained'):
model_ready = model.is_trained
elif hasattr(model, 'rf_loaded') or hasattr(model, 'nn_loaded'):
# Ensemble model
model_ready = getattr(model, 'rf_loaded', False) or getattr(model, 'nn_loaded', False)
else:
model_ready = True # Assume ready if no check available
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)
# Validate audio before analysis
is_valid, validation_message, audio_stats = audio_processor.validate_audio(temp_path)
if not is_valid:
# Clean up invalid file
if os.path.exists(temp_path):
os.remove(temp_path)
# Map validation errors to translation keys
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, # Keep original for fallback
'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: local (voc2vec or fallback RF) vs cloud (OpenAI)
analysis_mode = (request.form.get('analysis_mode') or 'local').strip().lower()
if analysis_mode not in ('local', 'cloud'):
analysis_mode = 'local'
try:
# Extract features once โ€” used by the cry gate and the RF classifier
features, diagnostics = audio_processor.extract_features_with_diagnostics(temp_path)
# Cry/not-cry gate: reject audio that clearly isn't a baby cry
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':
# Cloud: OpenAI Audio API
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:
# Local: Random Forest v5 first (CV 0.73), voc2vec as fallback (CV 0.61)
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:
# Fallback: voc2vec embedding classifier
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
# Build response
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
}
# Track prediction for analytics
try:
# Get user_id from request headers
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()
}
# Save to database (user already validated as registered above)
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()
# Continue even if database save fails
# Data saved to Supabase only
except Exception as e:
print(f"โŒ Error tracking prediction: {e}")
import traceback
traceback.print_exc()
# Don't fail the request, but log the error
# Add diagnostics if debug mode (only when we have diagnostics from local path)
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 {}
# Require registered user (when using Supabase)
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
)
# Upload audio to Supabase Storage
storage_path = f"verified/{correct_label}/{submission_id}.wav"
audio_url = None
if supabase_storage.enabled:
# Upload to Supabase Storage
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")
# Fallback to local path if storage upload failed
if not audio_url:
# Get the local file path where audio was saved
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__)))
# Store path relative to app root as "feedback_data/verified/category/id.wav" for consistent playback
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:
# Use storage URL instead of local path
file_path = audio_url
# Track feedback submission (for analytics)
try:
# Get user_id from request body
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, # Can be None, which is allowed by foreign key
'predicted_label': predicted_label,
'correct_label': correct_label, # This is the correct label - saved to category field too
'is_correct': is_correct,
'confidence': confidence,
'file_path': file_path, # Supabase Storage URL or local path
'submission_id': submission_id, # Submission ID from feedback_manager
'what_helped': ','.join(what_helped) if what_helped else '',
'timestamp': datetime.now().isoformat()
}
# Save to database (user already validated as registered above)
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 error so frontend knows it failed
return jsonify({'error': f'Failed to save feedback to database: {str(db_error)}'}), 500
# Data saved to Supabase only
except Exception as e:
print(f"โŒ Error tracking feedback: {e}")
import traceback
traceback.print_exc()
# Don't fail the request, but log the error
# Audio file has already been copied to verified directory by feedback_manager
# The temp file will be cleaned up by cleanup_old_temp_files() automatically
# Only remove from pending_feedback dict
del pending_feedback[audio_id]
# In-request retraining takes far longer than the request timeout, so it is
# opt-in for local/dev runs only. Production retrains offline via
# scripts/pull_feedback_and_retrain.py, which reads feedback from Supabase.
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()
# Log error for debugging (in production, could store in database)
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):
# Touch the database so the ping also counts as Supabase activity
# (free-tier projects pause after ~7 idle days)
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()})
# ==================== ADMIN API ENDPOINTS ====================
@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:
# Get feedback stats from database
feedback_stats = db.get_feedback_stats()
# Get total predictions from database
total_predictions = db.count_predictions()
# Calculate average confidence from recent predictions (using database)
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')
# Convert is_correct string to boolean if provided
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
# Decode the file path
file_path = urllib.parse.unquote(file_path)
# Check if it's a Supabase Storage URL (starts with https://)
if file_path.startswith('https://'):
# Redirect to Supabase Storage URL
from flask import redirect
return redirect(file_path, code=302)
# Otherwise, it's a local file path - serve from local filesystem
# Security: Only allow files from feedback_data directory
if file_path.startswith('/') and not file_path.startswith('../'):
return jsonify({'error': 'Invalid file path'}), 400
# Get the absolute path of the feedback directory
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'))
# Handle different path formats and extract relative_path for fallback
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)
# Normalize path to prevent directory traversal
actual_path = os.path.normpath(actual_path)
# Fallback for old DB paths: if file not found, try under app root feedback_data
# (e.g. old records stored "../feedback_data/verified/..." when cwd differed)
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}")
# Security check: allow feedback_dir_abs or app_root/feedback_data
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}")
# Check if file exists
if not os.path.exists(actual_path):
return jsonify({'error': 'Audio file not found'}), 404
# Check if it's a WAV file
if not actual_path.lower().endswith('.wav'):
return jsonify({'error': 'Invalid file type'}), 400
# Serve the file
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:
# Check if model is loaded
model_status = 'loaded' if (model and hasattr(model, 'is_trained') and model.is_trained) else 'not_loaded'
# Check if data directories exist
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
# ==================== ADMIN ROUTES ====================
@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
# GET request - serve login page
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')
# Inject translations JSON for JavaScript
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')
# Inject translations JSON for JavaScript (empty object if no translations)
translations_json = json.dumps(admin_translations if admin_translations else {}, ensure_ascii=False)
template = template.replace('{{TRANSLATIONS}}', translations_json)
return render_template_string(template)
# ==================== DATA COLLECTION ENDPOINTS ====================
@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
# Validate and sanitize required fields
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
# Sanitize baby_name (remove excessive whitespace, limit length)
baby_name = ' '.join(baby_name.split())[:100] # Limit to 100 chars
# Extract and validate user_id
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
# Validate user_id format
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
# Validate and sanitize email if provided
email = user_data.get('email')
if email:
email = str(email).strip().lower()
# Basic email validation (contains @ and ., and reasonable length)
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
# Validate and sanitize phone if provided
phone = user_data.get('phone')
if phone:
phone = str(phone).strip()
# Basic phone validation (should contain digits, allow + and spaces)
# Remove common phone formatting characters for validation
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:
# Keep original format but limit length
phone = phone[:25]
# Require at least email OR phone
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
# Sanitize other optional fields
baby_gender = user_data.get('baby_gender')
if baby_gender:
baby_gender = str(baby_gender).strip().lower()
# Only allow valid gender values
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] # Limit to date format length
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'
# Handle password if provided
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()
}
# Save to database
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
# Data saved to Supabase only
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() # Can be email or phone
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
# Get user by email or phone
user = db.get_user_by_email_or_phone(identifier)
if not user:
return jsonify({'error': 'Invalid email/phone or password'}), 401
# Check if user has a password_hash (for existing users without password)
if not user.get('password_hash'):
return jsonify({'error': 'Account not set up with password. Please register again.'}), 401
# Verify password
if not check_password_hash(user['password_hash'], password):
return jsonify({'error': 'Invalid email/phone or password'}), 401
# Create session
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)
# Serialize for JSON (timestamps)
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
# Save to Supabase analytics_events table
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
# Save to Supabase predictions table
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")
# Use PORT env variable (7860 for HF Spaces, 5001 for local)
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)