Spaces:
Running
Running
feat: implement JWT auth and voice calibration onboarding
Browse files- backend/main.py +2 -22
- backend/models/database.py +1 -0
- backend/requirements.txt +3 -1
- backend/routes/alerts.py +4 -3
- backend/routes/analyze.py +9 -6
- backend/routes/auth.py +164 -0
- backend/routes/chat.py +11 -10
- backend/routes/entries.py +4 -3
- backend/routes/trends.py +4 -3
- backend/seed_data.py +6 -0
- backend/services/mood_calculator.py +12 -1
- frontend/app/chat/page.tsx +2 -2
- frontend/app/dashboard/page.tsx +3 -3
- frontend/app/layout.tsx +7 -3
- frontend/app/login/page.tsx +100 -0
- frontend/app/onboarding/page.tsx +138 -0
- frontend/app/record/page.tsx +1 -1
- frontend/app/signup/page.tsx +108 -0
- frontend/app/timeline/page.tsx +2 -2
- frontend/components/Navbar.tsx +13 -2
- frontend/contexts/AuthContext.tsx +97 -0
- frontend/lib/api.ts +52 -18
backend/main.py
CHANGED
|
@@ -14,6 +14,7 @@ from routes.entries import router as entries_router
|
|
| 14 |
from routes.trends import router as trends_router
|
| 15 |
from routes.alerts import router as alerts_router
|
| 16 |
from routes.chat import router as chat_router
|
|
|
|
| 17 |
|
| 18 |
app = FastAPI(
|
| 19 |
title="InnerVoice API",
|
|
@@ -43,28 +44,7 @@ app.include_router(entries_router, prefix="/api")
|
|
| 43 |
app.include_router(trends_router, prefix="/api")
|
| 44 |
app.include_router(alerts_router, prefix="/api")
|
| 45 |
app.include_router(chat_router, prefix="/api")
|
| 46 |
-
|
| 47 |
-
# User creation helper endpoint
|
| 48 |
-
from fastapi import Depends
|
| 49 |
-
from sqlalchemy.orm import Session
|
| 50 |
-
from models.database import get_db, User
|
| 51 |
-
from pydantic import BaseModel
|
| 52 |
-
import uuid
|
| 53 |
-
|
| 54 |
-
class UserCreate(BaseModel):
|
| 55 |
-
email: str
|
| 56 |
-
name: str
|
| 57 |
-
|
| 58 |
-
@app.post("/api/users")
|
| 59 |
-
def create_or_get_user(body: UserCreate, db: Session = Depends(get_db)):
|
| 60 |
-
user = db.query(User).filter(User.email == body.email).first()
|
| 61 |
-
if user:
|
| 62 |
-
return {"id": user.id, "email": user.email, "name": user.name, "existed": True}
|
| 63 |
-
user = User(id=str(uuid.uuid4()), email=body.email, name=body.name)
|
| 64 |
-
db.add(user)
|
| 65 |
-
db.commit()
|
| 66 |
-
db.refresh(user)
|
| 67 |
-
return {"id": user.id, "email": user.email, "name": user.name, "existed": False}
|
| 68 |
|
| 69 |
|
| 70 |
@app.get("/api/health")
|
|
|
|
| 14 |
from routes.trends import router as trends_router
|
| 15 |
from routes.alerts import router as alerts_router
|
| 16 |
from routes.chat import router as chat_router
|
| 17 |
+
from routes.auth import router as auth_router
|
| 18 |
|
| 19 |
app = FastAPI(
|
| 20 |
title="InnerVoice API",
|
|
|
|
| 44 |
app.include_router(trends_router, prefix="/api")
|
| 45 |
app.include_router(alerts_router, prefix="/api")
|
| 46 |
app.include_router(chat_router, prefix="/api")
|
| 47 |
+
app.include_router(auth_router, prefix="/api")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
@app.get("/api/health")
|
backend/models/database.py
CHANGED
|
@@ -37,6 +37,7 @@ class User(Base):
|
|
| 37 |
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 38 |
email = Column(String, unique=True, nullable=False, index=True)
|
| 39 |
name = Column(String, nullable=False)
|
|
|
|
| 40 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 41 |
baseline_pitch = Column(Float, nullable=True)
|
| 42 |
baseline_energy = Column(Float, nullable=True)
|
|
|
|
| 37 |
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 38 |
email = Column(String, unique=True, nullable=False, index=True)
|
| 39 |
name = Column(String, nullable=False)
|
| 40 |
+
password_hash = Column(String, nullable=False)
|
| 41 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 42 |
baseline_pitch = Column(Float, nullable=True)
|
| 43 |
baseline_energy = Column(Float, nullable=True)
|
backend/requirements.txt
CHANGED
|
@@ -10,8 +10,10 @@ scipy==1.13.0
|
|
| 10 |
openai==1.30.1
|
| 11 |
httpx==0.27.0
|
| 12 |
aiofiles==23.2.1
|
|
|
|
|
|
|
| 13 |
# ML models — comment out if you want faster install without AI analysis
|
| 14 |
transformers==4.41.2
|
| 15 |
torch==2.3.0
|
| 16 |
torchaudio==2.3.0
|
| 17 |
-
openai-whisper
|
|
|
|
| 10 |
openai==1.30.1
|
| 11 |
httpx==0.27.0
|
| 12 |
aiofiles==23.2.1
|
| 13 |
+
passlib[bcrypt]==1.7.4
|
| 14 |
+
PyJWT==2.8.0
|
| 15 |
# ML models — comment out if you want faster install without AI analysis
|
| 16 |
transformers==4.41.2
|
| 17 |
torch==2.3.0
|
| 18 |
torchaudio==2.3.0
|
| 19 |
+
openai-whisper
|
backend/routes/alerts.py
CHANGED
|
@@ -4,16 +4,17 @@ PUT /api/alerts/{id}/read — mark alert as read
|
|
| 4 |
"""
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
-
from models.database import get_db, MoodAlert
|
|
|
|
| 8 |
|
| 9 |
router = APIRouter()
|
| 10 |
|
| 11 |
|
| 12 |
@router.get("/alerts")
|
| 13 |
-
def get_alerts(
|
| 14 |
alerts = (
|
| 15 |
db.query(MoodAlert)
|
| 16 |
-
.filter(MoodAlert.user_id ==
|
| 17 |
.order_by(MoodAlert.created_at.desc())
|
| 18 |
.all()
|
| 19 |
)
|
|
|
|
| 4 |
"""
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
+
from models.database import get_db, MoodAlert, User
|
| 8 |
+
from routes.auth import get_current_user
|
| 9 |
|
| 10 |
router = APIRouter()
|
| 11 |
|
| 12 |
|
| 13 |
@router.get("/alerts")
|
| 14 |
+
def get_alerts(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 15 |
alerts = (
|
| 16 |
db.query(MoodAlert)
|
| 17 |
+
.filter(MoodAlert.user_id == current_user.id, MoodAlert.is_read == False)
|
| 18 |
.order_by(MoodAlert.created_at.desc())
|
| 19 |
.all()
|
| 20 |
)
|
backend/routes/analyze.py
CHANGED
|
@@ -3,7 +3,7 @@ POST /api/analyze — Full voice analysis pipeline
|
|
| 3 |
"""
|
| 4 |
import os
|
| 5 |
import tempfile
|
| 6 |
-
from fastapi import APIRouter, UploadFile, File,
|
| 7 |
from sqlalchemy.orm import Session
|
| 8 |
from models.database import get_db, VoiceEntry, MoodAlert, User
|
| 9 |
from services.audio_processor import convert_to_wav, extract_features
|
|
@@ -14,6 +14,7 @@ from services.transcriber import transcribe
|
|
| 14 |
from services.mood_calculator import calculate_mood_scores
|
| 15 |
from services.trend_detector import check_trends
|
| 16 |
from services.gpt_service import generate_insight
|
|
|
|
| 17 |
import uuid
|
| 18 |
|
| 19 |
router = APIRouter()
|
|
@@ -22,7 +23,7 @@ router = APIRouter()
|
|
| 22 |
@router.post("/analyze")
|
| 23 |
async def analyze_audio(
|
| 24 |
audio: UploadFile = File(...),
|
| 25 |
-
|
| 26 |
db: Session = Depends(get_db),
|
| 27 |
):
|
| 28 |
# 1. Save uploaded audio to temp file
|
|
@@ -64,6 +65,8 @@ async def analyze_audio(
|
|
| 64 |
tempo=features["speech_rate"],
|
| 65 |
avg_pause=features["avg_pause_duration"],
|
| 66 |
filler_rate=features["filler_rate"],
|
|
|
|
|
|
|
| 67 |
)
|
| 68 |
|
| 69 |
# 7. Generate AI insight
|
|
@@ -78,7 +81,7 @@ async def analyze_audio(
|
|
| 78 |
# 8. Save voice entry to DB
|
| 79 |
entry = VoiceEntry(
|
| 80 |
id=str(uuid.uuid4()),
|
| 81 |
-
user_id=
|
| 82 |
transcription=transcription,
|
| 83 |
primary_emotion=emotion,
|
| 84 |
emotion_confidence=confidence,
|
|
@@ -103,7 +106,7 @@ async def analyze_audio(
|
|
| 103 |
# 9. Check for trend alerts
|
| 104 |
all_entries = (
|
| 105 |
db.query(VoiceEntry)
|
| 106 |
-
.filter(VoiceEntry.user_id ==
|
| 107 |
.order_by(VoiceEntry.created_at)
|
| 108 |
.all()
|
| 109 |
)
|
|
@@ -116,7 +119,7 @@ async def analyze_audio(
|
|
| 116 |
existing = (
|
| 117 |
db.query(MoodAlert)
|
| 118 |
.filter(
|
| 119 |
-
MoodAlert.user_id ==
|
| 120 |
MoodAlert.alert_type == alert_data["type"],
|
| 121 |
MoodAlert.created_at >= cutoff,
|
| 122 |
)
|
|
@@ -124,7 +127,7 @@ async def analyze_audio(
|
|
| 124 |
)
|
| 125 |
if not existing:
|
| 126 |
alert = MoodAlert(
|
| 127 |
-
user_id=
|
| 128 |
alert_type=alert_data["type"],
|
| 129 |
severity=alert_data["severity"],
|
| 130 |
message=alert_data["message"],
|
|
|
|
| 3 |
"""
|
| 4 |
import os
|
| 5 |
import tempfile
|
| 6 |
+
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
| 7 |
from sqlalchemy.orm import Session
|
| 8 |
from models.database import get_db, VoiceEntry, MoodAlert, User
|
| 9 |
from services.audio_processor import convert_to_wav, extract_features
|
|
|
|
| 14 |
from services.mood_calculator import calculate_mood_scores
|
| 15 |
from services.trend_detector import check_trends
|
| 16 |
from services.gpt_service import generate_insight
|
| 17 |
+
from routes.auth import get_current_user
|
| 18 |
import uuid
|
| 19 |
|
| 20 |
router = APIRouter()
|
|
|
|
| 23 |
@router.post("/analyze")
|
| 24 |
async def analyze_audio(
|
| 25 |
audio: UploadFile = File(...),
|
| 26 |
+
current_user: User = Depends(get_current_user),
|
| 27 |
db: Session = Depends(get_db),
|
| 28 |
):
|
| 29 |
# 1. Save uploaded audio to temp file
|
|
|
|
| 65 |
tempo=features["speech_rate"],
|
| 66 |
avg_pause=features["avg_pause_duration"],
|
| 67 |
filler_rate=features["filler_rate"],
|
| 68 |
+
baseline_energy=current_user.baseline_energy,
|
| 69 |
+
baseline_pitch=current_user.baseline_pitch,
|
| 70 |
)
|
| 71 |
|
| 72 |
# 7. Generate AI insight
|
|
|
|
| 81 |
# 8. Save voice entry to DB
|
| 82 |
entry = VoiceEntry(
|
| 83 |
id=str(uuid.uuid4()),
|
| 84 |
+
user_id=current_user.id,
|
| 85 |
transcription=transcription,
|
| 86 |
primary_emotion=emotion,
|
| 87 |
emotion_confidence=confidence,
|
|
|
|
| 106 |
# 9. Check for trend alerts
|
| 107 |
all_entries = (
|
| 108 |
db.query(VoiceEntry)
|
| 109 |
+
.filter(VoiceEntry.user_id == current_user.id)
|
| 110 |
.order_by(VoiceEntry.created_at)
|
| 111 |
.all()
|
| 112 |
)
|
|
|
|
| 119 |
existing = (
|
| 120 |
db.query(MoodAlert)
|
| 121 |
.filter(
|
| 122 |
+
MoodAlert.user_id == current_user.id,
|
| 123 |
MoodAlert.alert_type == alert_data["type"],
|
| 124 |
MoodAlert.created_at >= cutoff,
|
| 125 |
)
|
|
|
|
| 127 |
)
|
| 128 |
if not existing:
|
| 129 |
alert = MoodAlert(
|
| 130 |
+
user_id=current_user.id,
|
| 131 |
alert_type=alert_data["type"],
|
| 132 |
severity=alert_data["severity"],
|
| 133 |
message=alert_data["message"],
|
backend/routes/auth.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from typing import Optional
|
| 4 |
+
import jwt
|
| 5 |
+
import bcrypt
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
| 7 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
from models.database import get_db, User
|
| 11 |
+
from services.audio_processor import convert_to_wav, extract_features
|
| 12 |
+
import tempfile
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 15 |
+
|
| 16 |
+
SECRET_KEY = os.getenv("SECRET_KEY", "change_me_to_a_random_secret_key_32chars")
|
| 17 |
+
ALGORITHM = "HS256"
|
| 18 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
| 19 |
+
|
| 20 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class UserCreate(BaseModel):
|
| 24 |
+
name: str
|
| 25 |
+
email: str
|
| 26 |
+
password: str
|
| 27 |
+
|
| 28 |
+
class UserLogin(BaseModel):
|
| 29 |
+
email: str
|
| 30 |
+
password: str
|
| 31 |
+
|
| 32 |
+
class Token(BaseModel):
|
| 33 |
+
access_token: str
|
| 34 |
+
token_type: str
|
| 35 |
+
user_id: str
|
| 36 |
+
name: str
|
| 37 |
+
has_baseline: bool
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def verify_password(plain_password, hashed_password):
|
| 41 |
+
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
| 42 |
+
|
| 43 |
+
def get_password_hash(password):
|
| 44 |
+
salt = bcrypt.gensalt()
|
| 45 |
+
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
|
| 46 |
+
|
| 47 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
| 48 |
+
to_encode = data.copy()
|
| 49 |
+
if expires_delta:
|
| 50 |
+
expire = datetime.utcnow() + expires_delta
|
| 51 |
+
else:
|
| 52 |
+
expire = datetime.utcnow() + timedelta(minutes=15)
|
| 53 |
+
to_encode.update({"exp": expire})
|
| 54 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 55 |
+
return encoded_jwt
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
| 59 |
+
credentials_exception = HTTPException(
|
| 60 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 61 |
+
detail="Could not validate credentials",
|
| 62 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 63 |
+
)
|
| 64 |
+
try:
|
| 65 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 66 |
+
user_id: str = payload.get("sub")
|
| 67 |
+
if user_id is None:
|
| 68 |
+
raise credentials_exception
|
| 69 |
+
except jwt.PyJWTError:
|
| 70 |
+
raise credentials_exception
|
| 71 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 72 |
+
if user is None:
|
| 73 |
+
raise credentials_exception
|
| 74 |
+
return user
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.post("/register", response_model=Token)
|
| 78 |
+
def register(user_in: UserCreate, db: Session = Depends(get_db)):
|
| 79 |
+
# Check if exists
|
| 80 |
+
if db.query(User).filter(User.email == user_in.email).first():
|
| 81 |
+
raise HTTPException(status_code=400, detail="Email already registered")
|
| 82 |
+
|
| 83 |
+
hashed_password = get_password_hash(user_in.password)
|
| 84 |
+
user = User(
|
| 85 |
+
name=user_in.name,
|
| 86 |
+
email=user_in.email,
|
| 87 |
+
password_hash=hashed_password
|
| 88 |
+
)
|
| 89 |
+
db.add(user)
|
| 90 |
+
db.commit()
|
| 91 |
+
db.refresh(user)
|
| 92 |
+
|
| 93 |
+
# Create token
|
| 94 |
+
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 95 |
+
access_token = create_access_token(
|
| 96 |
+
data={"sub": user.id}, expires_delta=access_token_expires
|
| 97 |
+
)
|
| 98 |
+
return {
|
| 99 |
+
"access_token": access_token,
|
| 100 |
+
"token_type": "bearer",
|
| 101 |
+
"user_id": user.id,
|
| 102 |
+
"name": user.name,
|
| 103 |
+
"has_baseline": False
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@router.post("/login", response_model=Token)
|
| 108 |
+
def login(user_in: UserLogin, db: Session = Depends(get_db)):
|
| 109 |
+
user = db.query(User).filter(User.email == user_in.email).first()
|
| 110 |
+
if not user or not verify_password(user_in.password, user.password_hash):
|
| 111 |
+
raise HTTPException(
|
| 112 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 113 |
+
detail="Incorrect email or password",
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 117 |
+
access_token = create_access_token(
|
| 118 |
+
data={"sub": user.id}, expires_delta=access_token_expires
|
| 119 |
+
)
|
| 120 |
+
return {
|
| 121 |
+
"access_token": access_token,
|
| 122 |
+
"token_type": "bearer",
|
| 123 |
+
"user_id": user.id,
|
| 124 |
+
"name": user.name,
|
| 125 |
+
"has_baseline": user.baseline_pitch is not None
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.post("/baseline")
|
| 130 |
+
async def setup_baseline(
|
| 131 |
+
audio: UploadFile = File(...),
|
| 132 |
+
current_user: User = Depends(get_current_user),
|
| 133 |
+
db: Session = Depends(get_db)
|
| 134 |
+
):
|
| 135 |
+
"""
|
| 136 |
+
Onboarding step: Takes a neutral reading audio clip, extracts features,
|
| 137 |
+
and sets the baseline statistics for the user.
|
| 138 |
+
"""
|
| 139 |
+
suffix = ".webm" if audio.content_type == "audio/webm" else ".wav"
|
| 140 |
+
tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix)
|
| 141 |
+
wav_path = None
|
| 142 |
+
try:
|
| 143 |
+
content = await audio.read()
|
| 144 |
+
with os.fdopen(tmp_fd, "wb") as f:
|
| 145 |
+
f.write(content)
|
| 146 |
+
|
| 147 |
+
wav_path = convert_to_wav(tmp_path)
|
| 148 |
+
features = extract_features(wav_path)
|
| 149 |
+
|
| 150 |
+
# Save to user
|
| 151 |
+
current_user.baseline_pitch = features.get("pitch_mean", 0.0)
|
| 152 |
+
current_user.baseline_energy = features.get("energy_raw", 0.0)
|
| 153 |
+
current_user.baseline_speech_rate = features.get("speech_rate", 0.0)
|
| 154 |
+
|
| 155 |
+
db.commit()
|
| 156 |
+
db.refresh(current_user)
|
| 157 |
+
return {"msg": "Baseline set successfully", "has_baseline": True}
|
| 158 |
+
|
| 159 |
+
finally:
|
| 160 |
+
if os.path.exists(tmp_path):
|
| 161 |
+
os.remove(tmp_path)
|
| 162 |
+
if wav_path and os.path.exists(wav_path):
|
| 163 |
+
os.remove(wav_path)
|
| 164 |
+
|
backend/routes/chat.py
CHANGED
|
@@ -4,25 +4,26 @@ POST /api/chat — AI companion chat with full user context
|
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
from pydantic import BaseModel
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
-
from models.database import get_db, ChatMessage, VoiceEntry
|
|
|
|
| 8 |
from services.gpt_service import chat_response
|
|
|
|
| 9 |
import uuid
|
| 10 |
|
| 11 |
router = APIRouter()
|
| 12 |
|
| 13 |
|
| 14 |
class ChatRequest(BaseModel):
|
| 15 |
-
user_id: str
|
| 16 |
message: str
|
| 17 |
-
voice_entry_id: str
|
| 18 |
|
| 19 |
|
| 20 |
@router.post("/chat")
|
| 21 |
-
def chat(req: ChatRequest, db: Session = Depends(get_db)):
|
| 22 |
# Fetch recent voice entries for context
|
| 23 |
recent_entries = (
|
| 24 |
db.query(VoiceEntry)
|
| 25 |
-
.filter(VoiceEntry.user_id ==
|
| 26 |
.order_by(VoiceEntry.created_at.desc())
|
| 27 |
.limit(30)
|
| 28 |
.all()
|
|
@@ -32,7 +33,7 @@ def chat(req: ChatRequest, db: Session = Depends(get_db)):
|
|
| 32 |
# Fetch conversation history
|
| 33 |
history = (
|
| 34 |
db.query(ChatMessage)
|
| 35 |
-
.filter(ChatMessage.user_id ==
|
| 36 |
.order_by(ChatMessage.created_at.asc())
|
| 37 |
.limit(40)
|
| 38 |
.all()
|
|
@@ -49,7 +50,7 @@ def chat(req: ChatRequest, db: Session = Depends(get_db)):
|
|
| 49 |
# Save user message
|
| 50 |
user_msg = ChatMessage(
|
| 51 |
id=str(uuid.uuid4()),
|
| 52 |
-
user_id=
|
| 53 |
role="user",
|
| 54 |
content=req.message,
|
| 55 |
voice_entry_id=req.voice_entry_id,
|
|
@@ -59,7 +60,7 @@ def chat(req: ChatRequest, db: Session = Depends(get_db)):
|
|
| 59 |
# Save assistant response
|
| 60 |
ai_msg = ChatMessage(
|
| 61 |
id=str(uuid.uuid4()),
|
| 62 |
-
user_id=
|
| 63 |
role="assistant",
|
| 64 |
content=response_text,
|
| 65 |
voice_entry_id=req.voice_entry_id,
|
|
@@ -74,10 +75,10 @@ def chat(req: ChatRequest, db: Session = Depends(get_db)):
|
|
| 74 |
|
| 75 |
|
| 76 |
@router.get("/chat/history")
|
| 77 |
-
def get_chat_history(
|
| 78 |
messages = (
|
| 79 |
db.query(ChatMessage)
|
| 80 |
-
.filter(ChatMessage.user_id ==
|
| 81 |
.order_by(ChatMessage.created_at.asc())
|
| 82 |
.limit(100)
|
| 83 |
.all()
|
|
|
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
from pydantic import BaseModel
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
+
from models.database import get_db, ChatMessage, VoiceEntry, User
|
| 8 |
+
from routes.auth import get_current_user
|
| 9 |
from services.gpt_service import chat_response
|
| 10 |
+
from typing import Optional
|
| 11 |
import uuid
|
| 12 |
|
| 13 |
router = APIRouter()
|
| 14 |
|
| 15 |
|
| 16 |
class ChatRequest(BaseModel):
|
|
|
|
| 17 |
message: str
|
| 18 |
+
voice_entry_id: Optional[str] = None
|
| 19 |
|
| 20 |
|
| 21 |
@router.post("/chat")
|
| 22 |
+
def chat(req: ChatRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 23 |
# Fetch recent voice entries for context
|
| 24 |
recent_entries = (
|
| 25 |
db.query(VoiceEntry)
|
| 26 |
+
.filter(VoiceEntry.user_id == current_user.id)
|
| 27 |
.order_by(VoiceEntry.created_at.desc())
|
| 28 |
.limit(30)
|
| 29 |
.all()
|
|
|
|
| 33 |
# Fetch conversation history
|
| 34 |
history = (
|
| 35 |
db.query(ChatMessage)
|
| 36 |
+
.filter(ChatMessage.user_id == current_user.id)
|
| 37 |
.order_by(ChatMessage.created_at.asc())
|
| 38 |
.limit(40)
|
| 39 |
.all()
|
|
|
|
| 50 |
# Save user message
|
| 51 |
user_msg = ChatMessage(
|
| 52 |
id=str(uuid.uuid4()),
|
| 53 |
+
user_id=current_user.id,
|
| 54 |
role="user",
|
| 55 |
content=req.message,
|
| 56 |
voice_entry_id=req.voice_entry_id,
|
|
|
|
| 60 |
# Save assistant response
|
| 61 |
ai_msg = ChatMessage(
|
| 62 |
id=str(uuid.uuid4()),
|
| 63 |
+
user_id=current_user.id,
|
| 64 |
role="assistant",
|
| 65 |
content=response_text,
|
| 66 |
voice_entry_id=req.voice_entry_id,
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
@router.get("/chat/history")
|
| 78 |
+
def get_chat_history(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 79 |
messages = (
|
| 80 |
db.query(ChatMessage)
|
| 81 |
+
.filter(ChatMessage.user_id == current_user.id)
|
| 82 |
.order_by(ChatMessage.created_at.asc())
|
| 83 |
.limit(100)
|
| 84 |
.all()
|
backend/routes/entries.py
CHANGED
|
@@ -3,7 +3,8 @@ GET /api/entries — fetch voice entries for a user
|
|
| 3 |
"""
|
| 4 |
from fastapi import APIRouter, Depends, Query
|
| 5 |
from sqlalchemy.orm import Session
|
| 6 |
-
from models.database import get_db, VoiceEntry
|
|
|
|
| 7 |
from datetime import datetime, timedelta
|
| 8 |
|
| 9 |
router = APIRouter()
|
|
@@ -11,14 +12,14 @@ router = APIRouter()
|
|
| 11 |
|
| 12 |
@router.get("/entries")
|
| 13 |
def get_entries(
|
| 14 |
-
|
| 15 |
days: int = Query(default=30, ge=1, le=365),
|
| 16 |
db: Session = Depends(get_db),
|
| 17 |
):
|
| 18 |
cutoff = datetime.utcnow() - timedelta(days=days)
|
| 19 |
entries = (
|
| 20 |
db.query(VoiceEntry)
|
| 21 |
-
.filter(VoiceEntry.user_id ==
|
| 22 |
.order_by(VoiceEntry.created_at)
|
| 23 |
.all()
|
| 24 |
)
|
|
|
|
| 3 |
"""
|
| 4 |
from fastapi import APIRouter, Depends, Query
|
| 5 |
from sqlalchemy.orm import Session
|
| 6 |
+
from models.database import get_db, VoiceEntry, User
|
| 7 |
+
from routes.auth import get_current_user
|
| 8 |
from datetime import datetime, timedelta
|
| 9 |
|
| 10 |
router = APIRouter()
|
|
|
|
| 12 |
|
| 13 |
@router.get("/entries")
|
| 14 |
def get_entries(
|
| 15 |
+
current_user: User = Depends(get_current_user),
|
| 16 |
days: int = Query(default=30, ge=1, le=365),
|
| 17 |
db: Session = Depends(get_db),
|
| 18 |
):
|
| 19 |
cutoff = datetime.utcnow() - timedelta(days=days)
|
| 20 |
entries = (
|
| 21 |
db.query(VoiceEntry)
|
| 22 |
+
.filter(VoiceEntry.user_id == current_user.id, VoiceEntry.created_at >= cutoff)
|
| 23 |
.order_by(VoiceEntry.created_at)
|
| 24 |
.all()
|
| 25 |
)
|
backend/routes/trends.py
CHANGED
|
@@ -3,7 +3,8 @@ GET /api/trends — week-over-week comparisons and pattern insights
|
|
| 3 |
"""
|
| 4 |
from fastapi import APIRouter, Depends, Query
|
| 5 |
from sqlalchemy.orm import Session
|
| 6 |
-
from models.database import get_db, VoiceEntry
|
|
|
|
| 7 |
from datetime import datetime, timedelta
|
| 8 |
from statistics import mean
|
| 9 |
from collections import Counter
|
|
@@ -12,10 +13,10 @@ router = APIRouter()
|
|
| 12 |
|
| 13 |
|
| 14 |
@router.get("/trends")
|
| 15 |
-
def get_trends(
|
| 16 |
entries = (
|
| 17 |
db.query(VoiceEntry)
|
| 18 |
-
.filter(VoiceEntry.user_id ==
|
| 19 |
.order_by(VoiceEntry.created_at)
|
| 20 |
.all()
|
| 21 |
)
|
|
|
|
| 3 |
"""
|
| 4 |
from fastapi import APIRouter, Depends, Query
|
| 5 |
from sqlalchemy.orm import Session
|
| 6 |
+
from models.database import get_db, VoiceEntry, User
|
| 7 |
+
from routes.auth import get_current_user
|
| 8 |
from datetime import datetime, timedelta
|
| 9 |
from statistics import mean
|
| 10 |
from collections import Counter
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
@router.get("/trends")
|
| 16 |
+
def get_trends(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 17 |
entries = (
|
| 18 |
db.query(VoiceEntry)
|
| 19 |
+
.filter(VoiceEntry.user_id == current_user.id)
|
| 20 |
.order_by(VoiceEntry.created_at)
|
| 21 |
.all()
|
| 22 |
)
|
backend/seed_data.py
CHANGED
|
@@ -125,10 +125,16 @@ def seed():
|
|
| 125 |
# Create or get demo user
|
| 126 |
user = db.query(User).filter(User.email == DEMO_USER_EMAIL).first()
|
| 127 |
if not user:
|
|
|
|
|
|
|
| 128 |
user = User(
|
| 129 |
id=str(uuid.uuid4()),
|
| 130 |
email=DEMO_USER_EMAIL,
|
| 131 |
name=DEMO_USER_NAME,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
)
|
| 133 |
db.add(user)
|
| 134 |
db.commit()
|
|
|
|
| 125 |
# Create or get demo user
|
| 126 |
user = db.query(User).filter(User.email == DEMO_USER_EMAIL).first()
|
| 127 |
if not user:
|
| 128 |
+
from routes.auth import get_password_hash
|
| 129 |
+
hashed_pwd = get_password_hash("password")
|
| 130 |
user = User(
|
| 131 |
id=str(uuid.uuid4()),
|
| 132 |
email=DEMO_USER_EMAIL,
|
| 133 |
name=DEMO_USER_NAME,
|
| 134 |
+
password_hash=hashed_pwd,
|
| 135 |
+
baseline_pitch=150.0,
|
| 136 |
+
baseline_energy=0.03,
|
| 137 |
+
baseline_speech_rate=100.0,
|
| 138 |
)
|
| 139 |
db.add(user)
|
| 140 |
db.commit()
|
backend/services/mood_calculator.py
CHANGED
|
@@ -12,11 +12,22 @@ def calculate_mood_scores(
|
|
| 12 |
tempo: float,
|
| 13 |
avg_pause: float,
|
| 14 |
filler_rate: float,
|
|
|
|
|
|
|
| 15 |
) -> dict:
|
| 16 |
"""Calculate Energy, Calmness, Mood, and Clarity scores (0-100)."""
|
| 17 |
|
| 18 |
# ── Energy Score ──────────────────────────────────────────────────────────
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
if emotion == "happy":
|
| 21 |
energy_score = min(100, energy_score + 15)
|
| 22 |
if emotion == "sad":
|
|
|
|
| 12 |
tempo: float,
|
| 13 |
avg_pause: float,
|
| 14 |
filler_rate: float,
|
| 15 |
+
baseline_energy: float = None,
|
| 16 |
+
baseline_pitch: float = None,
|
| 17 |
) -> dict:
|
| 18 |
"""Calculate Energy, Calmness, Mood, and Clarity scores (0-100)."""
|
| 19 |
|
| 20 |
# ── Energy Score ──────────────────────────────────────────────────────────
|
| 21 |
+
if baseline_energy and baseline_energy > 0:
|
| 22 |
+
# Calculate relative to baseline
|
| 23 |
+
ratio = energy / baseline_energy
|
| 24 |
+
# If ratio is 1.0 (baseline), score is 50.
|
| 25 |
+
# Max out at 2x baseline
|
| 26 |
+
energy_score = min(100, int((ratio) * 50))
|
| 27 |
+
else:
|
| 28 |
+
# Fallback to absolute
|
| 29 |
+
energy_score = min(100, int(energy * 5000))
|
| 30 |
+
|
| 31 |
if emotion == "happy":
|
| 32 |
energy_score = min(100, energy_score + 15)
|
| 33 |
if emotion == "sad":
|
frontend/app/chat/page.tsx
CHANGED
|
@@ -32,7 +32,7 @@ export default function ChatPage() {
|
|
| 32 |
if (isDemoMode) {
|
| 33 |
setMessages(DEMO_CHAT_HISTORY as Message[]);
|
| 34 |
} else {
|
| 35 |
-
const hist = await api.getChatHistory(
|
| 36 |
setMessages(hist as Message[]);
|
| 37 |
}
|
| 38 |
} catch (e) {
|
|
@@ -68,7 +68,7 @@ export default function ChatPage() {
|
|
| 68 |
setLoading(false);
|
| 69 |
});
|
| 70 |
} else {
|
| 71 |
-
const res = await api.chat(
|
| 72 |
setMessages(prev => [...prev, { id: Date.now().toString(), role: "assistant", content: res.response }]);
|
| 73 |
}
|
| 74 |
} catch (e) {
|
|
|
|
| 32 |
if (isDemoMode) {
|
| 33 |
setMessages(DEMO_CHAT_HISTORY as Message[]);
|
| 34 |
} else {
|
| 35 |
+
const hist = await api.getChatHistory();
|
| 36 |
setMessages(hist as Message[]);
|
| 37 |
}
|
| 38 |
} catch (e) {
|
|
|
|
| 68 |
setLoading(false);
|
| 69 |
});
|
| 70 |
} else {
|
| 71 |
+
const res = await api.chat(text);
|
| 72 |
setMessages(prev => [...prev, { id: Date.now().toString(), role: "assistant", content: res.response }]);
|
| 73 |
}
|
| 74 |
} catch (e) {
|
frontend/app/dashboard/page.tsx
CHANGED
|
@@ -27,9 +27,9 @@ export default function DashboardPage() {
|
|
| 27 |
setTrends(DEMO_TRENDS);
|
| 28 |
} else {
|
| 29 |
const [eRes, aRes, tRes] = await Promise.all([
|
| 30 |
-
api.getEntries(
|
| 31 |
-
api.getAlerts(
|
| 32 |
-
api.getTrends(
|
| 33 |
]);
|
| 34 |
setEntries(eRes);
|
| 35 |
setAlerts(aRes);
|
|
|
|
| 27 |
setTrends(DEMO_TRENDS);
|
| 28 |
} else {
|
| 29 |
const [eRes, aRes, tRes] = await Promise.all([
|
| 30 |
+
api.getEntries(14),
|
| 31 |
+
api.getAlerts(),
|
| 32 |
+
api.getTrends(),
|
| 33 |
]);
|
| 34 |
setEntries(eRes);
|
| 35 |
setAlerts(aRes);
|
frontend/app/layout.tsx
CHANGED
|
@@ -4,6 +4,8 @@ import "./globals.css";
|
|
| 4 |
import Navbar from "@/components/Navbar";
|
| 5 |
import DemoModeBanner from "@/components/DemoModeBanner";
|
| 6 |
|
|
|
|
|
|
|
| 7 |
const inter = Inter({ subsets: ["latin"] });
|
| 8 |
|
| 9 |
export const metadata: Metadata = {
|
|
@@ -24,9 +26,11 @@ export default function RootLayout({
|
|
| 24 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
| 25 |
</head>
|
| 26 |
<body className={`${inter.className} bg-[#0f0a1e] text-white min-h-screen`}>
|
| 27 |
-
<
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
</body>
|
| 31 |
</html>
|
| 32 |
);
|
|
|
|
| 4 |
import Navbar from "@/components/Navbar";
|
| 5 |
import DemoModeBanner from "@/components/DemoModeBanner";
|
| 6 |
|
| 7 |
+
import { AuthProvider } from "@/contexts/AuthContext";
|
| 8 |
+
|
| 9 |
const inter = Inter({ subsets: ["latin"] });
|
| 10 |
|
| 11 |
export const metadata: Metadata = {
|
|
|
|
| 26 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
| 27 |
</head>
|
| 28 |
<body className={`${inter.className} bg-[#0f0a1e] text-white min-h-screen`}>
|
| 29 |
+
<AuthProvider>
|
| 30 |
+
<DemoModeBanner />
|
| 31 |
+
<Navbar />
|
| 32 |
+
<main className="md:ml-64 min-h-screen pt-10 md:pt-0">{children}</main>
|
| 33 |
+
</AuthProvider>
|
| 34 |
</body>
|
| 35 |
</html>
|
| 36 |
);
|
frontend/app/login/page.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { useAuth } from "@/contexts/AuthContext";
|
| 6 |
+
import { apiPost } from "@/lib/api";
|
| 7 |
+
|
| 8 |
+
export default function LoginPage() {
|
| 9 |
+
const [email, setEmail] = useState("");
|
| 10 |
+
const [password, setPassword] = useState("");
|
| 11 |
+
const [error, setError] = useState("");
|
| 12 |
+
const [loading, setLoading] = useState(false);
|
| 13 |
+
const router = useRouter();
|
| 14 |
+
const { loginState } = useAuth();
|
| 15 |
+
|
| 16 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 17 |
+
e.preventDefault();
|
| 18 |
+
setLoading(true);
|
| 19 |
+
setError("");
|
| 20 |
+
|
| 21 |
+
try {
|
| 22 |
+
const data = await apiPost<{ access_token: string; user_id: string; name: string; has_baseline: boolean }>(
|
| 23 |
+
"/api/auth/login",
|
| 24 |
+
{ email, password }
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
loginState(data.access_token, {
|
| 28 |
+
id: data.user_id,
|
| 29 |
+
name: data.name,
|
| 30 |
+
hasBaseline: data.has_baseline,
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
if (!data.has_baseline) {
|
| 34 |
+
router.push("/onboarding");
|
| 35 |
+
} else {
|
| 36 |
+
router.push("/dashboard");
|
| 37 |
+
}
|
| 38 |
+
} catch (err: any) {
|
| 39 |
+
setError("Invalid email or password");
|
| 40 |
+
} finally {
|
| 41 |
+
setLoading(false);
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div className="min-h-screen flex items-center justify-center p-6 relative z-10 md:-ml-64">
|
| 47 |
+
<div className="w-full max-w-sm glass-card p-8 animate-slide-up">
|
| 48 |
+
<div className="text-center mb-8">
|
| 49 |
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-600 to-teal-500 flex items-center justify-center text-2xl shadow-glow-purple mx-auto mb-4">
|
| 50 |
+
🎵
|
| 51 |
+
</div>
|
| 52 |
+
<h1 className="text-2xl font-bold">Welcome Back</h1>
|
| 53 |
+
<p className="text-sm text-white/50 mt-2">Sign in to InnerVoice</p>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 57 |
+
<div>
|
| 58 |
+
<label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Email</label>
|
| 59 |
+
<input
|
| 60 |
+
type="email"
|
| 61 |
+
required
|
| 62 |
+
value={email}
|
| 63 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 64 |
+
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
|
| 65 |
+
placeholder="you@example.com"
|
| 66 |
+
/>
|
| 67 |
+
</div>
|
| 68 |
+
<div>
|
| 69 |
+
<label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Password</label>
|
| 70 |
+
<input
|
| 71 |
+
type="password"
|
| 72 |
+
required
|
| 73 |
+
value={password}
|
| 74 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 75 |
+
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
|
| 76 |
+
placeholder="••••••••"
|
| 77 |
+
/>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
{error && <div className="text-red-400 text-sm text-center py-2">{error}</div>}
|
| 81 |
+
|
| 82 |
+
<button
|
| 83 |
+
type="submit"
|
| 84 |
+
disabled={loading}
|
| 85 |
+
className="w-full py-3 mt-4 bg-gradient-to-r from-purple-600 to-teal-500 font-semibold rounded-xl hover:opacity-90 transition-opacity text-white disabled:opacity-50"
|
| 86 |
+
>
|
| 87 |
+
{loading ? "Signing in..." : "Sign In"}
|
| 88 |
+
</button>
|
| 89 |
+
</form>
|
| 90 |
+
|
| 91 |
+
<p className="text-center text-sm text-white/40 mt-6">
|
| 92 |
+
Don't have an account?{" "}
|
| 93 |
+
<Link href="/signup" className="text-purple-400 hover:text-purple-300">
|
| 94 |
+
Sign up
|
| 95 |
+
</Link>
|
| 96 |
+
</p>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
);
|
| 100 |
+
}
|
frontend/app/onboarding/page.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState, useRef, useEffect } from "react";
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import { useAuth } from "@/contexts/AuthContext";
|
| 5 |
+
import { useAudioRecorder } from "@/hooks/useAudioRecorder";
|
| 6 |
+
import { api } from "@/lib/api";
|
| 7 |
+
import WaveformVisualizer from "@/components/WaveformVisualizer";
|
| 8 |
+
import CircularProgress from "@/components/CircularProgress";
|
| 9 |
+
import { motion } from "framer-motion";
|
| 10 |
+
|
| 11 |
+
export default function OnboardingPage() {
|
| 12 |
+
const router = useRouter();
|
| 13 |
+
const { user, loginState, token } = useAuth();
|
| 14 |
+
|
| 15 |
+
const {
|
| 16 |
+
state,
|
| 17 |
+
seconds,
|
| 18 |
+
startRecording,
|
| 19 |
+
stopRecording,
|
| 20 |
+
audioBlob,
|
| 21 |
+
analyserNode,
|
| 22 |
+
reset
|
| 23 |
+
} = useAudioRecorder(15);
|
| 24 |
+
|
| 25 |
+
const isRecording = state === "recording";
|
| 26 |
+
|
| 27 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 28 |
+
const [error, setError] = useState<string | null>(null);
|
| 29 |
+
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (seconds >= 15 && isRecording) {
|
| 32 |
+
stopRecording();
|
| 33 |
+
}
|
| 34 |
+
}, [seconds, isRecording, stopRecording]);
|
| 35 |
+
|
| 36 |
+
const handleSubmit = async () => {
|
| 37 |
+
if (!audioBlob) return;
|
| 38 |
+
setIsSubmitting(true);
|
| 39 |
+
setError(null);
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
const res = await api.setBaseline(audioBlob);
|
| 43 |
+
if (res.has_baseline && user && token) {
|
| 44 |
+
// Update user state globally
|
| 45 |
+
loginState(token, { ...user, hasBaseline: true });
|
| 46 |
+
router.push("/dashboard");
|
| 47 |
+
}
|
| 48 |
+
} catch (err: any) {
|
| 49 |
+
setError("Failed to process calibration audio. Please try again.");
|
| 50 |
+
} finally {
|
| 51 |
+
setIsSubmitting(false);
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="max-w-3xl mx-auto p-6 md:p-10 relative mt-20 md:mt-0">
|
| 57 |
+
<div className="mb-10 animate-fade-in text-center">
|
| 58 |
+
<h1 className="text-3xl font-bold bg-gradient-to-r from-teal-400 to-purple-400 bg-clip-text text-transparent">
|
| 59 |
+
Voice Calibration
|
| 60 |
+
</h1>
|
| 61 |
+
<p className="text-white/60 mt-2 max-w-lg mx-auto">
|
| 62 |
+
Before we begin, we need a baseline of your "normal" voice. This helps
|
| 63 |
+
our AI detect subtle shifts in your mood relative to your personal baseline.
|
| 64 |
+
</p>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div className="glass-card p-6 md:p-10 mb-8 max-w-2xl mx-auto">
|
| 68 |
+
<div className="bg-black/30 p-6 rounded-2xl border border-white/5 mb-8">
|
| 69 |
+
<p className="text-xs uppercase text-white/50 mb-4 font-semibold tracking-wider">Please read this short text aloud:</p>
|
| 70 |
+
<p className="text-lg md:text-xl font-medium leading-relaxed">
|
| 71 |
+
"I am recording this to calibrate my voice. The quick brown fox jumps over the lazy dog. It is a beautiful day outside, and I am feeling calm and neutral."
|
| 72 |
+
</p>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div className="flex flex-col items-center">
|
| 76 |
+
<div className="h-24 w-full mb-8 relative">
|
| 77 |
+
{!isRecording && !audioBlob ? (
|
| 78 |
+
<div className="absolute inset-0 flex items-center justify-center text-white/20 text-sm">
|
| 79 |
+
Voice visualizer will appear here...
|
| 80 |
+
</div>
|
| 81 |
+
) : (
|
| 82 |
+
<WaveformVisualizer analyserNode={analyserNode} isActive={isRecording} />
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
{!audioBlob ? (
|
| 87 |
+
<div className="relative w-24 h-24">
|
| 88 |
+
<CircularProgress progress={(seconds / 15) * 100} />
|
| 89 |
+
<button
|
| 90 |
+
onClick={isRecording ? stopRecording : startRecording}
|
| 91 |
+
className={`absolute inset-0 m-auto w-16 h-16 rounded-full flex items-center justify-center transition-all ${
|
| 92 |
+
isRecording
|
| 93 |
+
? "bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
| 94 |
+
: "bg-purple-500 hover:bg-purple-400 hover:shadow-glow-purple text-white shadow-lg"
|
| 95 |
+
}`}
|
| 96 |
+
>
|
| 97 |
+
{isRecording ? (
|
| 98 |
+
<div className="w-5 h-5 bg-red-400 rounded-sm animate-pulse" />
|
| 99 |
+
) : (
|
| 100 |
+
<svg className="w-6 h-6 ml-1" fill="currentColor" viewBox="0 0 24 24"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg>
|
| 101 |
+
)}
|
| 102 |
+
</button>
|
| 103 |
+
{isRecording && (
|
| 104 |
+
<div className="absolute -bottom-8 left-0 right-0 text-center text-sm font-medium text-red-400">
|
| 105 |
+
{Math.floor(seconds)}s / 15s
|
| 106 |
+
</div>
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
) : (
|
| 110 |
+
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="flex gap-4">
|
| 111 |
+
<button
|
| 112 |
+
disabled={isSubmitting}
|
| 113 |
+
onClick={() => reset()}
|
| 114 |
+
className="px-6 py-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors font-medium border border-white/10 disabled:opacity-50"
|
| 115 |
+
>
|
| 116 |
+
Retake
|
| 117 |
+
</button>
|
| 118 |
+
<button
|
| 119 |
+
onClick={handleSubmit}
|
| 120 |
+
disabled={isSubmitting}
|
| 121 |
+
className="px-8 py-3 rounded-xl bg-gradient-to-r from-purple-600 to-teal-500 font-medium hover:opacity-90 transition-opacity shadow-glow-purple disabled:opacity-50 flex items-center gap-2"
|
| 122 |
+
>
|
| 123 |
+
{isSubmitting ? (
|
| 124 |
+
<>
|
| 125 |
+
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
| 126 |
+
Calibrating...
|
| 127 |
+
</>
|
| 128 |
+
) : "Set Baseline"}
|
| 129 |
+
</button>
|
| 130 |
+
</motion.div>
|
| 131 |
+
)}
|
| 132 |
+
|
| 133 |
+
{error && <div className="mt-6 text-red-400 text-sm">{error}</div>}
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
);
|
| 138 |
+
}
|
frontend/app/record/page.tsx
CHANGED
|
@@ -55,7 +55,7 @@ export default function RecordPage() {
|
|
| 55 |
setAnalyzing(false);
|
| 56 |
});
|
| 57 |
} else {
|
| 58 |
-
const res = await api.analyzeAudio(audioBlob
|
| 59 |
setResult(res);
|
| 60 |
setAnalyzing(false);
|
| 61 |
}
|
|
|
|
| 55 |
setAnalyzing(false);
|
| 56 |
});
|
| 57 |
} else {
|
| 58 |
+
const res = await api.analyzeAudio(audioBlob);
|
| 59 |
setResult(res);
|
| 60 |
setAnalyzing(false);
|
| 61 |
}
|
frontend/app/signup/page.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { useAuth } from "@/contexts/AuthContext";
|
| 6 |
+
import { apiPost } from "@/lib/api";
|
| 7 |
+
|
| 8 |
+
export default function SignupPage() {
|
| 9 |
+
const [name, setName] = useState("");
|
| 10 |
+
const [email, setEmail] = useState("");
|
| 11 |
+
const [password, setPassword] = useState("");
|
| 12 |
+
const [error, setError] = useState("");
|
| 13 |
+
const [loading, setLoading] = useState(false);
|
| 14 |
+
const router = useRouter();
|
| 15 |
+
const { loginState } = useAuth();
|
| 16 |
+
|
| 17 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 18 |
+
e.preventDefault();
|
| 19 |
+
setLoading(true);
|
| 20 |
+
setError("");
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
const data = await apiPost<{ access_token: string; user_id: string; name: string; has_baseline: boolean }>(
|
| 24 |
+
"/api/auth/register",
|
| 25 |
+
{ name, email, password }
|
| 26 |
+
);
|
| 27 |
+
|
| 28 |
+
loginState(data.access_token, {
|
| 29 |
+
id: data.user_id,
|
| 30 |
+
name: data.name,
|
| 31 |
+
hasBaseline: data.has_baseline,
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
router.push("/onboarding");
|
| 35 |
+
} catch (err: any) {
|
| 36 |
+
setError("Registration failed, email might be in use.");
|
| 37 |
+
} finally {
|
| 38 |
+
setLoading(false);
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="min-h-screen flex items-center justify-center p-6 relative z-10 md:-ml-64">
|
| 44 |
+
<div className="w-full max-w-sm glass-card p-8 animate-slide-up">
|
| 45 |
+
<div className="text-center mb-8">
|
| 46 |
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-600 to-teal-500 flex items-center justify-center text-2xl shadow-glow-purple mx-auto mb-4">
|
| 47 |
+
✨
|
| 48 |
+
</div>
|
| 49 |
+
<h1 className="text-2xl font-bold">Join InnerVoice</h1>
|
| 50 |
+
<p className="text-sm text-white/50 mt-2">Start your emotional wellness journey</p>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 54 |
+
<div>
|
| 55 |
+
<label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Name</label>
|
| 56 |
+
<input
|
| 57 |
+
type="text"
|
| 58 |
+
required
|
| 59 |
+
value={name}
|
| 60 |
+
onChange={(e) => setName(e.target.value)}
|
| 61 |
+
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
|
| 62 |
+
placeholder="Your Name"
|
| 63 |
+
/>
|
| 64 |
+
</div>
|
| 65 |
+
<div>
|
| 66 |
+
<label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Email</label>
|
| 67 |
+
<input
|
| 68 |
+
type="email"
|
| 69 |
+
required
|
| 70 |
+
value={email}
|
| 71 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 72 |
+
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
|
| 73 |
+
placeholder="you@example.com"
|
| 74 |
+
/>
|
| 75 |
+
</div>
|
| 76 |
+
<div>
|
| 77 |
+
<label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Password</label>
|
| 78 |
+
<input
|
| 79 |
+
type="password"
|
| 80 |
+
required
|
| 81 |
+
value={password}
|
| 82 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 83 |
+
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
|
| 84 |
+
placeholder="••••••••"
|
| 85 |
+
/>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
{error && <div className="text-red-400 text-sm text-center py-2">{error}</div>}
|
| 89 |
+
|
| 90 |
+
<button
|
| 91 |
+
type="submit"
|
| 92 |
+
disabled={loading}
|
| 93 |
+
className="w-full py-3 mt-4 bg-gradient-to-r from-purple-600 to-teal-500 font-semibold rounded-xl hover:opacity-90 transition-opacity text-white disabled:opacity-50"
|
| 94 |
+
>
|
| 95 |
+
{loading ? "Creating Account..." : "Create Account"}
|
| 96 |
+
</button>
|
| 97 |
+
</form>
|
| 98 |
+
|
| 99 |
+
<p className="text-center text-sm text-white/40 mt-6">
|
| 100 |
+
Already have an account?{" "}
|
| 101 |
+
<Link href="/login" className="text-purple-400 hover:text-purple-300">
|
| 102 |
+
Sign in
|
| 103 |
+
</Link>
|
| 104 |
+
</p>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
);
|
| 108 |
+
}
|
frontend/app/timeline/page.tsx
CHANGED
|
@@ -25,8 +25,8 @@ export default function TimelinePage() {
|
|
| 25 |
setTrends(DEMO_TRENDS);
|
| 26 |
} else {
|
| 27 |
const [eRes, tRes] = await Promise.all([
|
| 28 |
-
api.getEntries(
|
| 29 |
-
api.getTrends(
|
| 30 |
]);
|
| 31 |
setEntries(eRes);
|
| 32 |
setTrends(tRes);
|
|
|
|
| 25 |
setTrends(DEMO_TRENDS);
|
| 26 |
} else {
|
| 27 |
const [eRes, tRes] = await Promise.all([
|
| 28 |
+
api.getEntries(30),
|
| 29 |
+
api.getTrends(),
|
| 30 |
]);
|
| 31 |
setEntries(eRes);
|
| 32 |
setTrends(tRes);
|
frontend/components/Navbar.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
import Link from "next/link";
|
| 3 |
import { usePathname } from "next/navigation";
|
| 4 |
import { useDemoMode } from "@/hooks/useDemoMode";
|
|
|
|
| 5 |
|
| 6 |
const navItems = [
|
| 7 |
{ href: "/dashboard", label: "Dashboard", icon: "🏠" },
|
|
@@ -14,6 +15,7 @@ const navItems = [
|
|
| 14 |
export default function Navbar() {
|
| 15 |
const pathname = usePathname();
|
| 16 |
const { isDemoMode, toggleDemoMode } = useDemoMode();
|
|
|
|
| 17 |
|
| 18 |
return (
|
| 19 |
<>
|
|
@@ -56,8 +58,17 @@ export default function Navbar() {
|
|
| 56 |
})}
|
| 57 |
</div>
|
| 58 |
|
| 59 |
-
{/* Demo mode
|
| 60 |
-
<div className="p-4 border-t border-white/5">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<button
|
| 62 |
onClick={toggleDemoMode}
|
| 63 |
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all text-sm ${
|
|
|
|
| 2 |
import Link from "next/link";
|
| 3 |
import { usePathname } from "next/navigation";
|
| 4 |
import { useDemoMode } from "@/hooks/useDemoMode";
|
| 5 |
+
import { useAuth } from "@/contexts/AuthContext";
|
| 6 |
|
| 7 |
const navItems = [
|
| 8 |
{ href: "/dashboard", label: "Dashboard", icon: "🏠" },
|
|
|
|
| 15 |
export default function Navbar() {
|
| 16 |
const pathname = usePathname();
|
| 17 |
const { isDemoMode, toggleDemoMode } = useDemoMode();
|
| 18 |
+
const { user, logout } = useAuth();
|
| 19 |
|
| 20 |
return (
|
| 21 |
<>
|
|
|
|
| 58 |
})}
|
| 59 |
</div>
|
| 60 |
|
| 61 |
+
{/* Demo mode & Auth */}
|
| 62 |
+
<div className="p-4 border-t border-white/5 space-y-2">
|
| 63 |
+
{user && !isDemoMode && (
|
| 64 |
+
<button
|
| 65 |
+
onClick={logout}
|
| 66 |
+
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all text-sm text-white/40 hover:text-red-400 hover:bg-white/5"
|
| 67 |
+
>
|
| 68 |
+
<span>👋</span>
|
| 69 |
+
<span className="font-medium">Sign Out</span>
|
| 70 |
+
</button>
|
| 71 |
+
)}
|
| 72 |
<button
|
| 73 |
onClick={toggleDemoMode}
|
| 74 |
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all text-sm ${
|
frontend/contexts/AuthContext.tsx
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
| 3 |
+
import { useRouter, usePathname } from "next/navigation";
|
| 4 |
+
import { useDemoMode } from "@/hooks/useDemoMode";
|
| 5 |
+
|
| 6 |
+
interface User {
|
| 7 |
+
id: string;
|
| 8 |
+
name: string;
|
| 9 |
+
hasBaseline: boolean;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface AuthContextType {
|
| 13 |
+
user: User | null;
|
| 14 |
+
token: string | null;
|
| 15 |
+
loginState: (token: string, user: User) => void;
|
| 16 |
+
logout: () => void;
|
| 17 |
+
isLoading: boolean;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const AuthContext = createContext<AuthContextType>({
|
| 21 |
+
user: null,
|
| 22 |
+
token: null,
|
| 23 |
+
loginState: () => {},
|
| 24 |
+
logout: () => {},
|
| 25 |
+
isLoading: true,
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
| 29 |
+
const [user, setUser] = useState<User | null>(null);
|
| 30 |
+
const [token, setToken] = useState<string | null>(null);
|
| 31 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 32 |
+
const router = useRouter();
|
| 33 |
+
const pathname = usePathname();
|
| 34 |
+
const { isDemoMode } = useDemoMode();
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
// Load auth from local storage on mount
|
| 38 |
+
const savedToken = localStorage.getItem("token");
|
| 39 |
+
const savedUser = localStorage.getItem("user");
|
| 40 |
+
|
| 41 |
+
if (savedToken && savedUser) {
|
| 42 |
+
setToken(savedToken);
|
| 43 |
+
setUser(JSON.parse(savedUser));
|
| 44 |
+
}
|
| 45 |
+
setIsLoading(false);
|
| 46 |
+
}, []);
|
| 47 |
+
|
| 48 |
+
// Route guarding
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
if (isLoading) return;
|
| 51 |
+
|
| 52 |
+
const publicPaths = ["/", "/login", "/signup"];
|
| 53 |
+
const isPublic = publicPaths.includes(pathname);
|
| 54 |
+
|
| 55 |
+
if (isDemoMode) {
|
| 56 |
+
// Allow full access in Demo Mode
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
if (!user && !isPublic) {
|
| 61 |
+
router.push("/login");
|
| 62 |
+
} else if (user && isPublic && pathname !== "/") {
|
| 63 |
+
if (!user.hasBaseline) {
|
| 64 |
+
router.push("/onboarding");
|
| 65 |
+
} else {
|
| 66 |
+
router.push("/dashboard");
|
| 67 |
+
}
|
| 68 |
+
} else if (user && !user.hasBaseline && pathname !== "/onboarding") {
|
| 69 |
+
router.push("/onboarding");
|
| 70 |
+
}
|
| 71 |
+
}, [user, pathname, isLoading, router, isDemoMode]);
|
| 72 |
+
|
| 73 |
+
const loginState = (newToken: string, newUser: User) => {
|
| 74 |
+
setToken(newToken);
|
| 75 |
+
setUser(newUser);
|
| 76 |
+
localStorage.setItem("token", newToken);
|
| 77 |
+
localStorage.setItem("user", JSON.stringify(newUser));
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const logout = () => {
|
| 81 |
+
setToken(null);
|
| 82 |
+
setUser(null);
|
| 83 |
+
localStorage.removeItem("token");
|
| 84 |
+
localStorage.removeItem("user");
|
| 85 |
+
router.push("/");
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
<AuthContext.Provider value={{ user, token, loginState, logout, isLoading }}>
|
| 90 |
+
{children}
|
| 91 |
+
</AuthContext.Provider>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export function useAuth() {
|
| 96 |
+
return useContext(AuthContext);
|
| 97 |
+
}
|
frontend/lib/api.ts
CHANGED
|
@@ -2,8 +2,16 @@
|
|
| 2 |
|
| 3 |
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
export async function apiGet<T>(path: string): Promise<T> {
|
| 6 |
-
const res = await fetch(`${API_URL}${path}`
|
|
|
|
|
|
|
| 7 |
if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
|
| 8 |
return res.json();
|
| 9 |
}
|
|
@@ -11,7 +19,7 @@ export async function apiGet<T>(path: string): Promise<T> {
|
|
| 11 |
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
| 12 |
const res = await fetch(`${API_URL}${path}`, {
|
| 13 |
method: "POST",
|
| 14 |
-
headers: { "Content-Type": "application/json" },
|
| 15 |
body: JSON.stringify(body),
|
| 16 |
});
|
| 17 |
if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
|
|
@@ -19,7 +27,10 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
|
| 19 |
}
|
| 20 |
|
| 21 |
export async function apiPut<T>(path: string): Promise<T> {
|
| 22 |
-
const res = await fetch(`${API_URL}${path}`, {
|
|
|
|
|
|
|
|
|
|
| 23 |
if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
|
| 24 |
return res.json();
|
| 25 |
}
|
|
@@ -74,38 +85,61 @@ export interface AnalyzeResult {
|
|
| 74 |
}
|
| 75 |
|
| 76 |
export const api = {
|
| 77 |
-
getEntries: (
|
| 78 |
-
apiGet<VoiceEntry[]>(`/api/entries?
|
| 79 |
|
| 80 |
-
getTrends: (
|
| 81 |
-
apiGet<TrendsData>(`/api/trends
|
| 82 |
|
| 83 |
-
getAlerts: (
|
| 84 |
-
apiGet<MoodAlert[]>(`/api/alerts
|
| 85 |
|
| 86 |
markAlertRead: (alertId: string) =>
|
| 87 |
apiPut<{ success: boolean }>(`/api/alerts/${alertId}/read`),
|
| 88 |
|
| 89 |
-
chat: (
|
| 90 |
-
apiPost<{ response: string }>("/api/chat", {
|
| 91 |
|
| 92 |
-
getChatHistory: (
|
| 93 |
apiGet<Array<{ id: string; role: string; content: string; created_at: string }>>(
|
| 94 |
-
`/api/chat/history
|
| 95 |
),
|
| 96 |
|
| 97 |
-
|
| 98 |
-
apiPost<{ id: string; email: string; name: string }>("/api/users", { email, name }),
|
| 99 |
-
|
| 100 |
-
analyzeAudio: async (audioBlob: Blob, userId: string): Promise<AnalyzeResult> => {
|
| 101 |
const formData = new FormData();
|
| 102 |
formData.append("audio", audioBlob, "recording.webm");
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
const res = await fetch(`${API_URL}/api/analyze`, {
|
| 105 |
method: "POST",
|
|
|
|
| 106 |
body: formData,
|
| 107 |
});
|
| 108 |
if (!res.ok) throw new Error(`Analyze error ${res.status}`);
|
| 109 |
return res.json();
|
| 110 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
};
|
|
|
|
| 2 |
|
| 3 |
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
| 4 |
|
| 5 |
+
function getAuthHeader(): Record<string, string> {
|
| 6 |
+
if (typeof window === "undefined") return {};
|
| 7 |
+
const token = localStorage.getItem("token");
|
| 8 |
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
export async function apiGet<T>(path: string): Promise<T> {
|
| 12 |
+
const res = await fetch(`${API_URL}${path}`, {
|
| 13 |
+
headers: { ...getAuthHeader() },
|
| 14 |
+
});
|
| 15 |
if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
|
| 16 |
return res.json();
|
| 17 |
}
|
|
|
|
| 19 |
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
| 20 |
const res = await fetch(`${API_URL}${path}`, {
|
| 21 |
method: "POST",
|
| 22 |
+
headers: { "Content-Type": "application/json", ...getAuthHeader() },
|
| 23 |
body: JSON.stringify(body),
|
| 24 |
});
|
| 25 |
if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
export async function apiPut<T>(path: string): Promise<T> {
|
| 30 |
+
const res = await fetch(`${API_URL}${path}`, {
|
| 31 |
+
method: "PUT",
|
| 32 |
+
headers: { ...getAuthHeader() },
|
| 33 |
+
});
|
| 34 |
if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
|
| 35 |
return res.json();
|
| 36 |
}
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
export const api = {
|
| 88 |
+
getEntries: (days = 30) =>
|
| 89 |
+
apiGet<VoiceEntry[]>(`/api/entries?days=${days}`),
|
| 90 |
|
| 91 |
+
getTrends: () =>
|
| 92 |
+
apiGet<TrendsData>(`/api/trends`),
|
| 93 |
|
| 94 |
+
getAlerts: () =>
|
| 95 |
+
apiGet<MoodAlert[]>(`/api/alerts`),
|
| 96 |
|
| 97 |
markAlertRead: (alertId: string) =>
|
| 98 |
apiPut<{ success: boolean }>(`/api/alerts/${alertId}/read`),
|
| 99 |
|
| 100 |
+
chat: (message: string) =>
|
| 101 |
+
apiPost<{ response: string }>("/api/chat", { message }),
|
| 102 |
|
| 103 |
+
getChatHistory: () =>
|
| 104 |
apiGet<Array<{ id: string; role: string; content: string; created_at: string }>>(
|
| 105 |
+
`/api/chat/history`
|
| 106 |
),
|
| 107 |
|
| 108 |
+
analyzeAudio: async (audioBlob: Blob): Promise<AnalyzeResult> => {
|
|
|
|
|
|
|
|
|
|
| 109 |
const formData = new FormData();
|
| 110 |
formData.append("audio", audioBlob, "recording.webm");
|
| 111 |
+
|
| 112 |
+
const headers: Record<string, string> = {};
|
| 113 |
+
if (typeof window !== "undefined") {
|
| 114 |
+
const token = localStorage.getItem("token");
|
| 115 |
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
const res = await fetch(`${API_URL}/api/analyze`, {
|
| 119 |
method: "POST",
|
| 120 |
+
headers,
|
| 121 |
body: formData,
|
| 122 |
});
|
| 123 |
if (!res.ok) throw new Error(`Analyze error ${res.status}`);
|
| 124 |
return res.json();
|
| 125 |
},
|
| 126 |
+
|
| 127 |
+
setBaseline: async (audioBlob: Blob): Promise<{msg: string, has_baseline: boolean}> => {
|
| 128 |
+
const formData = new FormData();
|
| 129 |
+
formData.append("audio", audioBlob, "calibration.webm");
|
| 130 |
+
|
| 131 |
+
const headers: Record<string, string> = {};
|
| 132 |
+
if (typeof window !== "undefined") {
|
| 133 |
+
const token = localStorage.getItem("token");
|
| 134 |
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const res = await fetch(`${API_URL}/api/auth/baseline`, {
|
| 138 |
+
method: "POST",
|
| 139 |
+
headers,
|
| 140 |
+
body: formData,
|
| 141 |
+
});
|
| 142 |
+
if (!res.ok) throw new Error(`Baseline error ${res.status}`);
|
| 143 |
+
return res.json();
|
| 144 |
+
}
|
| 145 |
};
|