Commit ·
3998131
0
Parent(s):
chore: finally untrack large database files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +22 -0
- .env.example +1 -0
- .env.production.example +16 -0
- .github/workflows/ci.yml +48 -0
- .gitignore +103 -0
- Backend.Dockerfile +36 -0
- Backend/.env.example +1 -0
- Backend/core/config.py +17 -0
- Backend/core/deps.py +37 -0
- Backend/core/security.py +33 -0
- Backend/db/mongodb.py +10 -0
- Backend/main.py +42 -0
- Backend/requirements.txt +7 -0
- Backend/routes/auth.py +55 -0
- Backend/schemas/user.py +25 -0
- Frontend.Dockerfile +29 -0
- Frontend/.env.production.example +4 -0
- Frontend/.gitignore +29 -0
- Frontend/app/api/Bias_Chat/route.ts +30 -0
- Frontend/app/api/Legal_Chat/route.ts +97 -0
- Frontend/app/api/bias-detection-hitl/approve/route.ts +39 -0
- Frontend/app/api/bias-detection-hitl/generate-pdf/route.ts +53 -0
- Frontend/app/api/bias-detection-hitl/regenerate/route.ts +39 -0
- Frontend/app/api/bias-detection/route.ts +222 -0
- Frontend/app/api/letter-generation/route.ts +97 -0
- Frontend/app/api/login/route.ts +20 -0
- Frontend/app/bias-checker/page.tsx +47 -0
- Frontend/app/chatbot/page.tsx +45 -0
- Frontend/app/dashboard/page.tsx +323 -0
- Frontend/app/globals.css +160 -0
- Frontend/app/layout.tsx +48 -0
- Frontend/app/letter-generator/page.tsx +64 -0
- Frontend/app/loading.tsx +3 -0
- Frontend/app/login/page.tsx +131 -0
- Frontend/app/page.tsx +147 -0
- Frontend/app/profile/page.tsx +197 -0
- Frontend/app/register/page.tsx +17 -0
- Frontend/components.json +21 -0
- Frontend/components/auth/register-form.tsx +194 -0
- Frontend/components/chatbot/bias-checker.tsx +805 -0
- Frontend/components/chatbot/bias-checker.tsx.backup +213 -0
- Frontend/components/chatbot/law-chatbot.tsx +674 -0
- Frontend/components/chatbot/letter-generator.tsx +739 -0
- Frontend/components/common/file-upload.tsx +108 -0
- Frontend/components/dashboard/stats-card.tsx +37 -0
- Frontend/components/layout/footer.tsx +133 -0
- Frontend/components/layout/navbar.tsx +192 -0
- Frontend/components/theme-provider.tsx +11 -0
- Frontend/components/ui/accordion.tsx +66 -0
- Frontend/components/ui/alert-dialog.tsx +157 -0
.dockerignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Exclude node modules and build artifacts from Docker build context
|
| 2 |
+
Frontend/node_modules
|
| 3 |
+
Frontend/.next
|
| 4 |
+
Frontend/.pnpm-store
|
| 5 |
+
**/node_modules
|
| 6 |
+
**/.next
|
| 7 |
+
|
| 8 |
+
# General ignores
|
| 9 |
+
.git
|
| 10 |
+
.gitignore
|
| 11 |
+
.vscode
|
| 12 |
+
__pycache__/
|
| 13 |
+
*.pyc
|
| 14 |
+
*.pyo
|
| 15 |
+
*.pyd
|
| 16 |
+
dist/
|
| 17 |
+
build/
|
| 18 |
+
.DS_Store
|
| 19 |
+
|
| 20 |
+
# env ignore
|
| 21 |
+
.env
|
| 22 |
+
.env.local
|
.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
MISTRAL_API_KEY=your_api_key_here
|
.env.production.example
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Backend Production Environment Variables ---
|
| 2 |
+
|
| 3 |
+
# Mistral AI API Key (Required for RAG)
|
| 4 |
+
MISTRAL_API_KEY=your_mistral_api_key_here
|
| 5 |
+
|
| 6 |
+
# Supabase Configuration (Required for Auth and Chat History)
|
| 7 |
+
SUPABASE_URL=https://your-project-id.supabase.co
|
| 8 |
+
SUPABASE_ANON_KEY=your-anon-key
|
| 9 |
+
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
| 10 |
+
|
| 11 |
+
# JWT Configuration
|
| 12 |
+
JWT_SECRET=your-super-secret-key-change-this
|
| 13 |
+
JWT_ALGORITHM=HS256
|
| 14 |
+
|
| 15 |
+
# Optional: Port (Defaults to 7860 in Dockerfile)
|
| 16 |
+
# PORT=7860
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: '**'
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: '**'
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
backend-tests:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- uses: actions/checkout@v4
|
| 14 |
+
|
| 15 |
+
- name: Set up Python
|
| 16 |
+
uses: actions/setup-python@v4
|
| 17 |
+
with:
|
| 18 |
+
python-version: '3.11'
|
| 19 |
+
|
| 20 |
+
- name: Install dependencies
|
| 21 |
+
run: |
|
| 22 |
+
python -m pip install --upgrade pip
|
| 23 |
+
pip install -r requirements.txt
|
| 24 |
+
if [ -f Backend/requirements.txt ]; then pip install -r Backend/requirements.txt; fi
|
| 25 |
+
|
| 26 |
+
- name: Run tests
|
| 27 |
+
run: |
|
| 28 |
+
python -m pytest api/ -v --ignore=api/test_supabase_auth.py
|
| 29 |
+
|
| 30 |
+
docker-build:
|
| 31 |
+
runs-on: ubuntu-latest
|
| 32 |
+
needs: [backend-tests]
|
| 33 |
+
steps:
|
| 34 |
+
- uses: actions/checkout@v4
|
| 35 |
+
|
| 36 |
+
- name: Set up QEMU
|
| 37 |
+
uses: docker/setup-qemu-action@v2
|
| 38 |
+
|
| 39 |
+
- name: Set up Docker Buildx
|
| 40 |
+
uses: docker/setup-buildx-action@v3
|
| 41 |
+
|
| 42 |
+
- name: Build backend image
|
| 43 |
+
uses: docker/build-push-action@v4
|
| 44 |
+
with:
|
| 45 |
+
context: .
|
| 46 |
+
file: Backend.Dockerfile
|
| 47 |
+
push: false
|
| 48 |
+
tags: backend:local
|
.gitignore
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===============================
|
| 2 |
+
# Python
|
| 3 |
+
# ===============================
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
*.pyo
|
| 7 |
+
*.pyd
|
| 8 |
+
*.so
|
| 9 |
+
*.egg-info/
|
| 10 |
+
.eggs/
|
| 11 |
+
*.egg
|
| 12 |
+
|
| 13 |
+
# ===============================
|
| 14 |
+
# Virtual Environments
|
| 15 |
+
# ===============================
|
| 16 |
+
venv/
|
| 17 |
+
.venv/
|
| 18 |
+
env/
|
| 19 |
+
ENV/
|
| 20 |
+
.python-version
|
| 21 |
+
Backend/.env
|
| 22 |
+
|
| 23 |
+
# ===============================
|
| 24 |
+
# Environment Variables
|
| 25 |
+
# ===============================
|
| 26 |
+
.env
|
| 27 |
+
.env.*
|
| 28 |
+
!.env.example
|
| 29 |
+
!.env.*.example
|
| 30 |
+
|
| 31 |
+
# ===============================
|
| 32 |
+
# FastAPI / ASGI
|
| 33 |
+
# ===============================
|
| 34 |
+
*.log
|
| 35 |
+
logs/
|
| 36 |
+
uvicorn.log
|
| 37 |
+
gunicorn.log
|
| 38 |
+
|
| 39 |
+
# ===============================
|
| 40 |
+
# Databases
|
| 41 |
+
# ===============================
|
| 42 |
+
*.sqlite3
|
| 43 |
+
*.db
|
| 44 |
+
|
| 45 |
+
# ===============================
|
| 46 |
+
# Testing / Coverage
|
| 47 |
+
# ===============================
|
| 48 |
+
.pytest_cache/
|
| 49 |
+
.coverage
|
| 50 |
+
htmlcov/
|
| 51 |
+
.tox/
|
| 52 |
+
.nox/
|
| 53 |
+
|
| 54 |
+
# ===============================
|
| 55 |
+
# Build / Distribution
|
| 56 |
+
# ===============================
|
| 57 |
+
build/
|
| 58 |
+
dist/
|
| 59 |
+
wheelhouse/
|
| 60 |
+
pip-wheel-metadata/
|
| 61 |
+
|
| 62 |
+
# ===============================
|
| 63 |
+
# IDEs & Editors
|
| 64 |
+
# ===============================
|
| 65 |
+
.vscode/
|
| 66 |
+
.idea/
|
| 67 |
+
*.swp
|
| 68 |
+
*.swo
|
| 69 |
+
|
| 70 |
+
# ===============================
|
| 71 |
+
# OS Files
|
| 72 |
+
# ===============================
|
| 73 |
+
.DS_Store
|
| 74 |
+
Thumbs.db
|
| 75 |
+
|
| 76 |
+
# ===============================
|
| 77 |
+
# Jupyter
|
| 78 |
+
# ===============================
|
| 79 |
+
.ipynb_checkpoints/
|
| 80 |
+
|
| 81 |
+
# ===============================
|
| 82 |
+
# MyPy / Ruff / Pyright
|
| 83 |
+
# ===============================
|
| 84 |
+
.mypy_cache/
|
| 85 |
+
.ruff_cache/
|
| 86 |
+
.pyright/
|
| 87 |
+
|
| 88 |
+
# ===============================
|
| 89 |
+
# Docker
|
| 90 |
+
# ===============================
|
| 91 |
+
docker-compose.override.yml
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
**/.env
|
| 95 |
+
**/.env.*
|
| 96 |
+
!**/.env.example
|
| 97 |
+
!**/.env.*.example
|
| 98 |
+
|
| 99 |
+
# ===============================
|
| 100 |
+
# Data & Vector DBs
|
| 101 |
+
# ===============================
|
| 102 |
+
**/vector_db/
|
| 103 |
+
**/chunks/
|
Backend.Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.12-slim as the base image
|
| 2 |
+
FROM python:3.12-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
build-essential \
|
| 10 |
+
libmagic1 \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
# Copy requirements.txt
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
|
| 16 |
+
# Install Python dependencies
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Copy the rest of the application code
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Expose the port the app runs on (Hugging Face Spaces uses 7860 by default)
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
# Script to build vector DBs and start the server
|
| 26 |
+
RUN echo '#!/bin/bash\n\
|
| 27 |
+
echo "Building Vector Databases..."\n\
|
| 28 |
+
python -m module_a.process_documents\n\
|
| 29 |
+
python -m module_a.build_vector_db\n\
|
| 30 |
+
python -m module_c.indexer\n\
|
| 31 |
+
echo "Starting FastAPI server on port ${PORT:-7860}..."\n\
|
| 32 |
+
uvicorn api.main:app --host 0.0.0.0 --port ${PORT:-7860}\n\
|
| 33 |
+
' > /app/start.sh && chmod +x /app/start.sh
|
| 34 |
+
|
| 35 |
+
# Run the startup script
|
| 36 |
+
CMD ["/app/start.sh"]
|
Backend/.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
MONGODB_URL == YOUR_MONGODB_URL
|
Backend/core/config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings , SettingsConfigDict
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
|
| 4 |
+
class Settings(BaseSettings):
|
| 5 |
+
|
| 6 |
+
#MongoDB
|
| 7 |
+
mongodb_url: str
|
| 8 |
+
database_name: str
|
| 9 |
+
secret_key: str
|
| 10 |
+
access_token_expire_minutes: int
|
| 11 |
+
algorithm: str
|
| 12 |
+
frontend_url: str
|
| 13 |
+
|
| 14 |
+
## To get all the values
|
| 15 |
+
model_config = SettingsConfigDict(env_file=".env")
|
| 16 |
+
|
| 17 |
+
settings = Settings()
|
Backend/core/deps.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 3 |
+
from jose import JWTError, jwt
|
| 4 |
+
from db.mongodb import get_database
|
| 5 |
+
from core.config import settings
|
| 6 |
+
|
| 7 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
| 8 |
+
|
| 9 |
+
async def get_current_user(
|
| 10 |
+
token: str = Depends(oauth2_scheme),
|
| 11 |
+
db = Depends(get_database)
|
| 12 |
+
):
|
| 13 |
+
credentials_exception = HTTPException(
|
| 14 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 15 |
+
detail="Could not validate credentials",
|
| 16 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
# Decode the JWT token
|
| 21 |
+
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
| 22 |
+
email: str = payload.get("sub")
|
| 23 |
+
if email is None:
|
| 24 |
+
raise credentials_exception
|
| 25 |
+
except JWTError:
|
| 26 |
+
raise credentials_exception
|
| 27 |
+
|
| 28 |
+
# Find user in database
|
| 29 |
+
user = await db["users"].find_one({"email": email})
|
| 30 |
+
if user is None:
|
| 31 |
+
raise credentials_exception
|
| 32 |
+
|
| 33 |
+
# Convert ObjectId to string and remove password
|
| 34 |
+
user["_id"] = str(user["_id"])
|
| 35 |
+
user.pop("password", None)
|
| 36 |
+
|
| 37 |
+
return user
|
Backend/core/security.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import bcrypt
|
| 2 |
+
import hashlib
|
| 3 |
+
import base64
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from jose import jwt
|
| 6 |
+
from core.config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _prehash_password(password: str) -> bytes:
|
| 10 |
+
"""Pre-hash password with SHA-256 to handle any length."""
|
| 11 |
+
sha256_hash = hashlib.sha256(password.encode('utf-8')).digest()
|
| 12 |
+
return base64.b64encode(sha256_hash)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def hash_password(password: str) -> str:
|
| 16 |
+
"""Hash a password using bcrypt."""
|
| 17 |
+
prehashed = _prehash_password(password)
|
| 18 |
+
salt = bcrypt.gensalt()
|
| 19 |
+
hashed = bcrypt.hashpw(prehashed, salt)
|
| 20 |
+
return hashed.decode('utf-8')
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 24 |
+
"""Verify a password against its hash."""
|
| 25 |
+
prehashed = _prehash_password(plain_password)
|
| 26 |
+
return bcrypt.checkpw(prehashed, hashed_password.encode('utf-8'))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def create_access_token(data: dict):
|
| 30 |
+
to_encode = data.copy()
|
| 31 |
+
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
| 32 |
+
to_encode.update({"exp": expire})
|
| 33 |
+
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
Backend/db/mongodb.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 2 |
+
from core.config import settings
|
| 3 |
+
|
| 4 |
+
class Database:
|
| 5 |
+
client: AsyncIOMotorClient = None
|
| 6 |
+
|
| 7 |
+
db_instance = Database()
|
| 8 |
+
|
| 9 |
+
async def get_database():
|
| 10 |
+
return db_instance.client[settings.database_name]
|
Backend/main.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI , Request
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 4 |
+
from core.config import settings
|
| 5 |
+
from db.mongodb import db_instance
|
| 6 |
+
from routes import auth
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
from fastapi.exceptions import RequestValidationError
|
| 9 |
+
from fastapi.responses import JSONResponse
|
| 10 |
+
|
| 11 |
+
## CORS Setup
|
| 12 |
+
@asynccontextmanager
|
| 13 |
+
async def lifespan(app: FastAPI):
|
| 14 |
+
## Starting logic
|
| 15 |
+
db_instance.client = AsyncIOMotorClient(settings.mongodb_url)
|
| 16 |
+
print("Succesfully Connected to MongoDB")
|
| 17 |
+
|
| 18 |
+
yield
|
| 19 |
+
|
| 20 |
+
db_instance.client.close()
|
| 21 |
+
print("MongoDB connection closed")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
app = FastAPI(title="FastAPI Mongo Auth",lifespan=lifespan)
|
| 25 |
+
app.add_middleware(
|
| 26 |
+
CORSMiddleware,
|
| 27 |
+
allow_origins=["*"],
|
| 28 |
+
allow_credentials= True,
|
| 29 |
+
allow_methods = ["*"],
|
| 30 |
+
allow_headers = ["*"],
|
| 31 |
+
)
|
| 32 |
+
app.include_router(auth.router)
|
| 33 |
+
|
| 34 |
+
@app.exception_handler(RequestValidationError)
|
| 35 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 36 |
+
print("❌ Validation Error:")
|
| 37 |
+
print(exc.errors()) # This will print in your terminal
|
| 38 |
+
print("📦 Request body:", await request.body())
|
| 39 |
+
return JSONResponse(
|
| 40 |
+
status_code=422,
|
| 41 |
+
content={"detail": exc.errors()}
|
| 42 |
+
)
|
Backend/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
motor
|
| 4 |
+
pydantic-settings
|
| 5 |
+
passlib[bcrypt]
|
| 6 |
+
python-jose[cryptography]
|
| 7 |
+
python-multipart
|
Backend/routes/auth.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter , HTTPException , Depends , status
|
| 2 |
+
from schemas.user import UserCreate, Userlogin , Token
|
| 3 |
+
from core.security import hash_password , verify_password , create_access_token
|
| 4 |
+
from db.mongodb import get_database
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/auth",tags=["Authentication"])
|
| 7 |
+
|
| 8 |
+
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
| 9 |
+
async def signup(user_in: UserCreate, db = Depends(get_database)):
|
| 10 |
+
|
| 11 |
+
# 1. Check if user exists
|
| 12 |
+
user_exists = await db["users"].find_one({'email': user_in.email})
|
| 13 |
+
if user_exists:
|
| 14 |
+
raise HTTPException(status_code=400, detail="User with this email already exists")
|
| 15 |
+
|
| 16 |
+
# 2. Convert Pydantic model to dict
|
| 17 |
+
user_dict = user_in.model_dump()
|
| 18 |
+
|
| 19 |
+
# 3. Replace plain password with hashed password
|
| 20 |
+
user_dict["password"] = hash_password(user_in.password)
|
| 21 |
+
|
| 22 |
+
# 4. Insert into MongoDB
|
| 23 |
+
result = await db["users"].insert_one(user_dict)
|
| 24 |
+
|
| 25 |
+
# 5. Create access token for auto-login
|
| 26 |
+
access_token = create_access_token(data={"sub": user_in.email})
|
| 27 |
+
|
| 28 |
+
# 6. Prepare user data for response (remove password)
|
| 29 |
+
user_response = {k: v for k, v in user_dict.items() if k != "password"}
|
| 30 |
+
user_response["_id"] = str(result.inserted_id)
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
"message": "User registered successfully",
|
| 34 |
+
"access_token": access_token,
|
| 35 |
+
"token_type": "bearer",
|
| 36 |
+
"user": user_response
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
@router.post("/login",response_model=Token)
|
| 40 |
+
async def login(user_in:Userlogin,db= Depends(get_database)):
|
| 41 |
+
## Finding the user
|
| 42 |
+
user = await db["users"].find_one({"email":user_in.email})
|
| 43 |
+
|
| 44 |
+
## Verifying the password
|
| 45 |
+
if not user or not verify_password(user_in.password,user["password"]):
|
| 46 |
+
raise HTTPException(status_code=401,detail="Incorrect email or password")
|
| 47 |
+
|
| 48 |
+
##Create Token
|
| 49 |
+
access_token = create_access_token(data={"sub":user["email"]})
|
| 50 |
+
return {"access_token":access_token,"token_type": "bearer"}
|
| 51 |
+
|
| 52 |
+
@router.get("/me")
|
| 53 |
+
async def get_user_info(current_user: dict = Depends(get_current_user)):
|
| 54 |
+
"""Get current logged-in user's information"""
|
| 55 |
+
return current_user
|
Backend/schemas/user.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
class UserCreate(BaseModel):
|
| 5 |
+
name: str
|
| 6 |
+
email: EmailStr
|
| 7 |
+
password: str
|
| 8 |
+
nid: str
|
| 9 |
+
age: str
|
| 10 |
+
education: str
|
| 11 |
+
salary: str
|
| 12 |
+
hasDisability: str # or bool if you convert "yes"/"no"
|
| 13 |
+
disabilityType: Optional[str] = None
|
| 14 |
+
province: str
|
| 15 |
+
district: str
|
| 16 |
+
municipality: str
|
| 17 |
+
ward: str
|
| 18 |
+
|
| 19 |
+
class Userlogin(BaseModel):
|
| 20 |
+
email: EmailStr
|
| 21 |
+
password: str
|
| 22 |
+
|
| 23 |
+
class Token(BaseModel):
|
| 24 |
+
access_token: str
|
| 25 |
+
token_type: str
|
Frontend.Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Node.js 20-alpine as the base image
|
| 2 |
+
FROM node:20-alpine AS base
|
| 3 |
+
|
| 4 |
+
# Install pnpm
|
| 5 |
+
RUN npm install -g pnpm
|
| 6 |
+
|
| 7 |
+
# Set working directory
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
# Copy package.json and pnpm-lock.yaml
|
| 11 |
+
COPY Frontend/package.json Frontend/pnpm-lock.yaml* ./
|
| 12 |
+
|
| 13 |
+
# Install dependencies
|
| 14 |
+
RUN pnpm install
|
| 15 |
+
|
| 16 |
+
# Copy the rest of the frontend code
|
| 17 |
+
COPY Frontend/ .
|
| 18 |
+
|
| 19 |
+
# Set environment variables for build
|
| 20 |
+
ENV NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
|
| 21 |
+
|
| 22 |
+
# Build the Next.js application
|
| 23 |
+
RUN pnpm build
|
| 24 |
+
|
| 25 |
+
# Expose the port the app runs on
|
| 26 |
+
EXPOSE 3000
|
| 27 |
+
|
| 28 |
+
# Start the application
|
| 29 |
+
CMD ["pnpm", "start"]
|
Frontend/.env.production.example
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Frontend Production Environment Variables ---
|
| 2 |
+
|
| 3 |
+
# URL of your deployed FastAPI backend (e.g., Hugging Face Space URL)
|
| 4 |
+
NEXT_PUBLIC_BACKEND_URL=https://your-backend-space.hf.space
|
Frontend/.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
|
| 6 |
+
# next.js
|
| 7 |
+
/.next/
|
| 8 |
+
/out/
|
| 9 |
+
|
| 10 |
+
# production
|
| 11 |
+
/build
|
| 12 |
+
|
| 13 |
+
# debug
|
| 14 |
+
npm-debug.log*
|
| 15 |
+
yarn-debug.log*
|
| 16 |
+
yarn-error.log*
|
| 17 |
+
.pnpm-debug.log*
|
| 18 |
+
|
| 19 |
+
# env files
|
| 20 |
+
.env*
|
| 21 |
+
!.env.example
|
| 22 |
+
!.env.*.example
|
| 23 |
+
|
| 24 |
+
# vercel
|
| 25 |
+
.vercel
|
| 26 |
+
|
| 27 |
+
# typescript
|
| 28 |
+
*.tsbuildinfo
|
| 29 |
+
next-env.d.ts
|
Frontend/app/api/Bias_Chat/route.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
export async function POST(req: NextRequest) {
|
| 4 |
+
try {
|
| 5 |
+
const { messages, type } = await req.json()
|
| 6 |
+
|
| 7 |
+
// Example for a custom AI API call:
|
| 8 |
+
// const response = await fetch("https://your-ai-api.com/v1/chat", {
|
| 9 |
+
// method: "POST",
|
| 10 |
+
// headers: { "Authorization": `Bearer ${process.env.MY_AI_KEY}` },
|
| 11 |
+
// body: JSON.stringify({ messages })
|
| 12 |
+
// })
|
| 13 |
+
// const data = await response.json()
|
| 14 |
+
|
| 15 |
+
// For now, we'll simulate a real API response that you can easily swap
|
| 16 |
+
const lastMessage = messages[messages.length - 1].content
|
| 17 |
+
|
| 18 |
+
let aiContent = ""
|
| 19 |
+
if (type === "legal") {
|
| 20 |
+
aiContent = `Based on your query about "${lastMessage}", under Nepali Law, this is typically governed by specific statutes...`
|
| 21 |
+
} else {
|
| 22 |
+
aiContent = "Standard AI response for non-legal queries."
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return NextResponse.json({ content: aiContent })
|
| 26 |
+
} catch (error) {
|
| 27 |
+
console.error("[v0] AI API Error:", error)
|
| 28 |
+
return NextResponse.json({ error: "Failed to fetch AI response" }, { status: 500 })
|
| 29 |
+
}
|
| 30 |
+
}
|
Frontend/app/api/Legal_Chat/route.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function POST(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const { messages, type, conversation_id } = await req.json()
|
| 8 |
+
|
| 9 |
+
if (type !== "legal") {
|
| 10 |
+
return NextResponse.json({ content: "Standard AI response for non-legal queries." })
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const lastMessage = messages[messages.length - 1].content
|
| 14 |
+
|
| 15 |
+
// Get Authorization header from incoming request
|
| 16 |
+
const authHeader = req.headers.get("authorization")
|
| 17 |
+
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
| 18 |
+
if (authHeader) {
|
| 19 |
+
headers["Authorization"] = authHeader
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Use the new context-aware chat endpoint
|
| 23 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/law-explanation/chat`, {
|
| 24 |
+
method: "POST",
|
| 25 |
+
headers,
|
| 26 |
+
body: JSON.stringify({
|
| 27 |
+
query: lastMessage,
|
| 28 |
+
conversation_id: conversation_id || null,
|
| 29 |
+
}),
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
if (!response.ok) {
|
| 33 |
+
throw new Error(`Backend API returned ${response.status}`)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const data = await response.json()
|
| 37 |
+
|
| 38 |
+
// Check if this is a non-legal query (greeting, thanks, etc.)
|
| 39 |
+
if (data.is_non_legal) {
|
| 40 |
+
return NextResponse.json({ content: data.explanation })
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// Format sources properly from backend response
|
| 44 |
+
let sourcesText = ""
|
| 45 |
+
console.log("[Legal Chat] Sources from backend:", data.sources)
|
| 46 |
+
|
| 47 |
+
if (data.sources && data.sources.length > 0) {
|
| 48 |
+
const formattedSources = data.sources
|
| 49 |
+
.filter((s: any) => s && (s.file || s.section)) // Only include sources with file or section
|
| 50 |
+
.map((s: any, i: number) => {
|
| 51 |
+
// Extract and clean filename
|
| 52 |
+
let fileName = "Legal Document"
|
| 53 |
+
if (s.file) {
|
| 54 |
+
fileName = s.file
|
| 55 |
+
.replace(/_en\.pdf$/i, "") // Remove _en.pdf extension
|
| 56 |
+
.replace(/\.pdf$/i, "") // Remove .pdf extension
|
| 57 |
+
.replace(/_/g, " ") // Replace underscores with spaces
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Format: **filename** (section)
|
| 61 |
+
const section = s.section || s.article_section || "General Reference"
|
| 62 |
+
return ` ${i + 1}. **${fileName}** (${section})`
|
| 63 |
+
})
|
| 64 |
+
.join("\n")
|
| 65 |
+
|
| 66 |
+
if (formattedSources) {
|
| 67 |
+
sourcesText = `\n\n### 📚 Resources\n${formattedSources}`
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Add context indicator if context was used
|
| 72 |
+
const contextBadge = data.context_used ? "\n\n> 💡 *Used conversation context*" : ""
|
| 73 |
+
|
| 74 |
+
// Return the data as-is since backend already returns markdown format
|
| 75 |
+
// Format it nicely for the frontend with clear sections
|
| 76 |
+
const formattedContent = `### 📝 Summary\n${data.summary}
|
| 77 |
+
|
| 78 |
+
### 💬 Detailed Explanation\n${data.explanation}
|
| 79 |
+
|
| 80 |
+
### 🔑 Key Points
|
| 81 |
+
- ${data.key_point}
|
| 82 |
+
|
| 83 |
+
### 📋 Next Steps
|
| 84 |
+
${data.next_steps}${sourcesText}${contextBadge}`.trim()
|
| 85 |
+
|
| 86 |
+
return NextResponse.json({
|
| 87 |
+
content: formattedContent,
|
| 88 |
+
suggested_action: data.suggested_action || null
|
| 89 |
+
})
|
| 90 |
+
} catch (error) {
|
| 91 |
+
console.error("[Legal Chat API Error]:", error)
|
| 92 |
+
return NextResponse.json(
|
| 93 |
+
{ error: "Failed to fetch response from legal AI backend. Please ensure the backend server is running." },
|
| 94 |
+
{ status: 500 }
|
| 95 |
+
)
|
| 96 |
+
}
|
| 97 |
+
}
|
Frontend/app/api/bias-detection-hitl/approve/route.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function POST(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const authHeader = req.headers.get("authorization")
|
| 8 |
+
const headers: Record<string, string> = {
|
| 9 |
+
"Content-Type": "application/json"
|
| 10 |
+
}
|
| 11 |
+
if (authHeader) {
|
| 12 |
+
headers["Authorization"] = authHeader
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const body = await req.json()
|
| 16 |
+
|
| 17 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/bias-detection-hitl/approve-suggestion`, {
|
| 18 |
+
method: "POST",
|
| 19 |
+
headers,
|
| 20 |
+
body: JSON.stringify(body),
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
if (!response.ok) {
|
| 24 |
+
const errorData = await response.json().catch(() => ({}))
|
| 25 |
+
throw new Error(errorData.detail || "Failed to approve/reject suggestion")
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const data = await response.json()
|
| 29 |
+
return NextResponse.json(data)
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error("[HITL Approve Error]:", error)
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{
|
| 34 |
+
error: error instanceof Error ? error.message : "Failed to process approval",
|
| 35 |
+
},
|
| 36 |
+
{ status: 500 }
|
| 37 |
+
)
|
| 38 |
+
}
|
| 39 |
+
}
|
Frontend/app/api/bias-detection-hitl/generate-pdf/route.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function POST(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const authHeader = req.headers.get("authorization")
|
| 8 |
+
const headers: Record<string, string> = {
|
| 9 |
+
"Content-Type": "application/json"
|
| 10 |
+
}
|
| 11 |
+
if (authHeader) {
|
| 12 |
+
headers["Authorization"] = authHeader
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const body = await req.json()
|
| 16 |
+
|
| 17 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/bias-detection-hitl/generate-pdf`, {
|
| 18 |
+
method: "POST",
|
| 19 |
+
headers,
|
| 20 |
+
body: JSON.stringify(body),
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
if (!response.ok) {
|
| 24 |
+
const errorData = await response.json().catch(() => ({}))
|
| 25 |
+
throw new Error(errorData.detail || "Failed to generate PDF")
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Get PDF blob
|
| 29 |
+
const pdfBlob = await response.blob()
|
| 30 |
+
|
| 31 |
+
// Get filename from headers
|
| 32 |
+
const contentDisposition = response.headers.get("Content-Disposition")
|
| 33 |
+
const changesApplied = response.headers.get("X-Changes-Applied")
|
| 34 |
+
|
| 35 |
+
// Return PDF as blob with headers
|
| 36 |
+
return new NextResponse(pdfBlob, {
|
| 37 |
+
status: 200,
|
| 38 |
+
headers: {
|
| 39 |
+
"Content-Type": "application/pdf",
|
| 40 |
+
"Content-Disposition": contentDisposition || 'attachment; filename="debiased_document.pdf"',
|
| 41 |
+
"X-Changes-Applied": changesApplied || "0"
|
| 42 |
+
}
|
| 43 |
+
})
|
| 44 |
+
} catch (error) {
|
| 45 |
+
console.error("[HITL Generate PDF Error]:", error)
|
| 46 |
+
return NextResponse.json(
|
| 47 |
+
{
|
| 48 |
+
error: error instanceof Error ? error.message : "Failed to generate PDF",
|
| 49 |
+
},
|
| 50 |
+
{ status: 500 }
|
| 51 |
+
)
|
| 52 |
+
}
|
| 53 |
+
}
|
Frontend/app/api/bias-detection-hitl/regenerate/route.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function POST(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const authHeader = req.headers.get("authorization")
|
| 8 |
+
const headers: Record<string, string> = {
|
| 9 |
+
"Content-Type": "application/json"
|
| 10 |
+
}
|
| 11 |
+
if (authHeader) {
|
| 12 |
+
headers["Authorization"] = authHeader
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const body = await req.json()
|
| 16 |
+
|
| 17 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/bias-detection-hitl/regenerate-suggestion`, {
|
| 18 |
+
method: "POST",
|
| 19 |
+
headers,
|
| 20 |
+
body: JSON.stringify(body),
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
if (!response.ok) {
|
| 24 |
+
const errorData = await response.json().catch(() => ({}))
|
| 25 |
+
throw new Error(errorData.detail || "Failed to regenerate suggestion")
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const data = await response.json()
|
| 29 |
+
return NextResponse.json(data)
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error("[HITL Regenerate Error]:", error)
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{
|
| 34 |
+
error: error instanceof Error ? error.message : "Failed to regenerate suggestion",
|
| 35 |
+
},
|
| 36 |
+
{ status: 500 }
|
| 37 |
+
)
|
| 38 |
+
}
|
| 39 |
+
}
|
Frontend/app/api/bias-detection/route.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function POST(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
// Get Authorization header from incoming request
|
| 8 |
+
const authHeader = req.headers.get("authorization")
|
| 9 |
+
const headers: Record<string, string> = {}
|
| 10 |
+
if (authHeader) {
|
| 11 |
+
headers["Authorization"] = authHeader
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const formData = await req.formData()
|
| 15 |
+
const file = formData.get("file") as File | null
|
| 16 |
+
const text = formData.get("text") as string | null
|
| 17 |
+
const confidenceThreshold = parseFloat(formData.get("confidence_threshold") as string || "0.7")
|
| 18 |
+
const useHITL = formData.get("use_hitl") === "true"
|
| 19 |
+
|
| 20 |
+
// If using HITL workflow for PDF files
|
| 21 |
+
if (file && useHITL) {
|
| 22 |
+
console.log("Using HITL workflow for PDF processing...")
|
| 23 |
+
const hitlFormData = new FormData()
|
| 24 |
+
hitlFormData.append("file", file)
|
| 25 |
+
hitlFormData.append("refine_with_llm", "true")
|
| 26 |
+
hitlFormData.append("confidence_threshold", confidenceThreshold.toString())
|
| 27 |
+
|
| 28 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/bias-detection-hitl/start-review`, {
|
| 29 |
+
method: "POST",
|
| 30 |
+
headers,
|
| 31 |
+
body: hitlFormData,
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
if (!response.ok) {
|
| 35 |
+
const errorData = await response.json().catch(() => ({}))
|
| 36 |
+
throw new Error(errorData.detail || "Failed to start HITL review")
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const data = await response.json()
|
| 40 |
+
console.log("HITL session created:", data.session_id)
|
| 41 |
+
|
| 42 |
+
// Return the HITL session data
|
| 43 |
+
return NextResponse.json(data)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Original non-HITL workflow for text or non-HITL PDF processing
|
| 47 |
+
let sentences: string[] = []
|
| 48 |
+
let filename: string | undefined
|
| 49 |
+
|
| 50 |
+
if (file) {
|
| 51 |
+
// Step 1: Process PDF to extract sentences with LLM refinement
|
| 52 |
+
console.log("Step 1: Processing PDF to extract sentences...")
|
| 53 |
+
const pdfFormData = new FormData()
|
| 54 |
+
pdfFormData.append("file", file)
|
| 55 |
+
pdfFormData.append("refine_with_llm", "true")
|
| 56 |
+
|
| 57 |
+
const pdfResponse = await fetch(`${BACKEND_URL}/api/v1/process-pdf`, {
|
| 58 |
+
method: "POST",
|
| 59 |
+
headers,
|
| 60 |
+
body: pdfFormData,
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
if (!pdfResponse.ok) {
|
| 64 |
+
throw new Error("Failed to process PDF")
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const pdfData = await pdfResponse.json()
|
| 68 |
+
sentences = pdfData.sentences
|
| 69 |
+
filename = pdfData.filename
|
| 70 |
+
console.log(`Extracted ${sentences.length} sentences from PDF`)
|
| 71 |
+
} else if (text) {
|
| 72 |
+
// For text input, use single sentence as array
|
| 73 |
+
sentences = [text]
|
| 74 |
+
} else {
|
| 75 |
+
return NextResponse.json({ error: "Either file or text must be provided" }, { status: 400 })
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Step 2: Detect bias using batch endpoint
|
| 79 |
+
console.log("Step 2: Detecting bias in sentences...")
|
| 80 |
+
const biasHeaders = { ...headers, "Content-Type": "application/json" }
|
| 81 |
+
const batchBiasResponse = await fetch(`${BACKEND_URL}/api/v1/detect-bias/batch`, {
|
| 82 |
+
method: "POST",
|
| 83 |
+
headers: biasHeaders,
|
| 84 |
+
body: JSON.stringify({
|
| 85 |
+
texts: sentences,
|
| 86 |
+
confidence_threshold: confidenceThreshold,
|
| 87 |
+
}),
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
if (!batchBiasResponse.ok) {
|
| 91 |
+
throw new Error("Failed to detect bias")
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const batchBiasData = await batchBiasResponse.json()
|
| 95 |
+
|
| 96 |
+
// Count biased and unbiased sentences
|
| 97 |
+
let biasedCount = 0
|
| 98 |
+
let unbiasedCount = 0
|
| 99 |
+
const biasedSentencesData: Array<{
|
| 100 |
+
sentence: string
|
| 101 |
+
category: string
|
| 102 |
+
confidence: number
|
| 103 |
+
explanation?: string
|
| 104 |
+
}> = []
|
| 105 |
+
|
| 106 |
+
// Process batch results
|
| 107 |
+
for (const item of batchBiasData.items) {
|
| 108 |
+
const result = item.result
|
| 109 |
+
if (result.success && result.results) {
|
| 110 |
+
for (const sentenceResult of result.results) {
|
| 111 |
+
if (sentenceResult.is_biased) {
|
| 112 |
+
biasedCount++
|
| 113 |
+
biasedSentencesData.push({
|
| 114 |
+
sentence: sentenceResult.sentence,
|
| 115 |
+
category: sentenceResult.category,
|
| 116 |
+
confidence: sentenceResult.confidence,
|
| 117 |
+
})
|
| 118 |
+
} else {
|
| 119 |
+
unbiasedCount++
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
console.log(`Found ${biasedCount} biased sentences and ${unbiasedCount} unbiased sentences`)
|
| 126 |
+
|
| 127 |
+
// Step 3: Get debiasing suggestions for biased sentences
|
| 128 |
+
console.log("Step 3: Getting debiasing suggestions...")
|
| 129 |
+
const suggestionsData: Array<{
|
| 130 |
+
original: string
|
| 131 |
+
category: string
|
| 132 |
+
confidence: number
|
| 133 |
+
suggestion: string | null
|
| 134 |
+
explanation: string | null
|
| 135 |
+
success: boolean
|
| 136 |
+
}> = []
|
| 137 |
+
|
| 138 |
+
if (biasedSentencesData.length > 0) {
|
| 139 |
+
// Prepare batch debias request
|
| 140 |
+
const debiasItems = biasedSentencesData.map((item) => ({
|
| 141 |
+
sentence: item.sentence,
|
| 142 |
+
category: item.category,
|
| 143 |
+
context: null,
|
| 144 |
+
}))
|
| 145 |
+
|
| 146 |
+
const debiasResponse = await fetch(`${BACKEND_URL}/api/v1/debias-sentence/batch`, {
|
| 147 |
+
method: "POST",
|
| 148 |
+
headers: biasHeaders,
|
| 149 |
+
body: JSON.stringify({
|
| 150 |
+
items: debiasItems,
|
| 151 |
+
}),
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
if (debiasResponse.ok) {
|
| 155 |
+
const debiasData = await debiasResponse.json()
|
| 156 |
+
|
| 157 |
+
for (let i = 0; i < biasedSentencesData.length; i++) {
|
| 158 |
+
const original = biasedSentencesData[i]
|
| 159 |
+
const debiasResult = debiasData.items?.[i]?.result
|
| 160 |
+
|
| 161 |
+
// Generate explanation based on category
|
| 162 |
+
const categoryExplanations: Record<string, string> = {
|
| 163 |
+
gender: "This sentence contains gender-based bias, using language that may stereotype, exclude, or unfairly characterize individuals based on their gender.",
|
| 164 |
+
religional: "This sentence contains religious bias, using language that may discriminate against or unfairly characterize individuals based on their religious beliefs.",
|
| 165 |
+
caste: "This sentence contains caste-based bias, using language that may perpetuate discrimination or unfair treatment based on caste identity.",
|
| 166 |
+
religion: "This sentence contains religious bias, with language that may show prejudice or discrimination based on religious affiliation.",
|
| 167 |
+
appearence: "This sentence contains appearance-based bias, using language that may judge or discriminate based on physical appearance or looks.",
|
| 168 |
+
socialstatus: "This sentence contains social status bias, using language that may discriminate or show prejudice based on socioeconomic status or class.",
|
| 169 |
+
political: "This sentence contains political bias, showing unfair preference or prejudice toward a particular political viewpoint or party.",
|
| 170 |
+
Age: "This sentence contains age-based bias, using language that may stereotype or discriminate based on age or generation.",
|
| 171 |
+
Disablity: "This sentence contains disability-based bias, using language that may discriminate against or unfairly characterize individuals with disabilities.",
|
| 172 |
+
amiguity: "This sentence contains ambiguous language that may lead to misinterpretation or unclear bias.",
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const explanation = categoryExplanations[original.category] || "This sentence has been flagged for potential bias."
|
| 176 |
+
|
| 177 |
+
suggestionsData.push({
|
| 178 |
+
original: original.sentence,
|
| 179 |
+
category: original.category,
|
| 180 |
+
confidence: original.confidence,
|
| 181 |
+
suggestion: debiasResult?.suggestion || null,
|
| 182 |
+
explanation: explanation,
|
| 183 |
+
success: debiasResult?.success || false,
|
| 184 |
+
})
|
| 185 |
+
}
|
| 186 |
+
} else {
|
| 187 |
+
// If debias fails, still return results without suggestions
|
| 188 |
+
console.warn("Debiasing failed, returning results without suggestions")
|
| 189 |
+
for (const item of biasedSentencesData) {
|
| 190 |
+
suggestionsData.push({
|
| 191 |
+
original: item.sentence,
|
| 192 |
+
category: item.category,
|
| 193 |
+
confidence: item.confidence,
|
| 194 |
+
suggestion: null,
|
| 195 |
+
explanation: null,
|
| 196 |
+
success: false,
|
| 197 |
+
})
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
console.log("Analysis complete!")
|
| 203 |
+
|
| 204 |
+
// Return formatted response
|
| 205 |
+
return NextResponse.json({
|
| 206 |
+
success: true,
|
| 207 |
+
biasedCount,
|
| 208 |
+
unbiasedCount,
|
| 209 |
+
totalSentences: biasedCount + unbiasedCount,
|
| 210 |
+
biasedSentences: suggestionsData,
|
| 211 |
+
filename,
|
| 212 |
+
})
|
| 213 |
+
} catch (error) {
|
| 214 |
+
console.error("[Bias Detection API Error]:", error)
|
| 215 |
+
return NextResponse.json(
|
| 216 |
+
{
|
| 217 |
+
error: error instanceof Error ? error.message : "Failed to analyze bias. Please ensure the backend server is running.",
|
| 218 |
+
},
|
| 219 |
+
{ status: 500 }
|
| 220 |
+
)
|
| 221 |
+
}
|
| 222 |
+
}
|
Frontend/app/api/letter-generation/route.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function POST(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const body = await req.json()
|
| 8 |
+
const { action, data } = body
|
| 9 |
+
|
| 10 |
+
// Get Authorization header from incoming request
|
| 11 |
+
const authHeader = req.headers.get("authorization")
|
| 12 |
+
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
| 13 |
+
if (authHeader) {
|
| 14 |
+
headers["Authorization"] = authHeader
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
let response: Response
|
| 18 |
+
let result: any
|
| 19 |
+
|
| 20 |
+
switch (action) {
|
| 21 |
+
case "search-template":
|
| 22 |
+
// Step 1: Search for template based on user requirement
|
| 23 |
+
response = await fetch(`${BACKEND_URL}/api/v1/search-template`, {
|
| 24 |
+
method: "POST",
|
| 25 |
+
headers,
|
| 26 |
+
body: JSON.stringify({ query: data.query }),
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
if (!response.ok) {
|
| 30 |
+
throw new Error("Failed to search template")
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
result = await response.json()
|
| 34 |
+
return NextResponse.json({
|
| 35 |
+
success: result.success,
|
| 36 |
+
templateName: result.template_name,
|
| 37 |
+
score: result.score,
|
| 38 |
+
content: result.content,
|
| 39 |
+
error: result.error,
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
case "get-template-details":
|
| 43 |
+
// Step 2: Get placeholder details for the template
|
| 44 |
+
response = await fetch(`${BACKEND_URL}/api/v1/get-template-details`, {
|
| 45 |
+
method: "POST",
|
| 46 |
+
headers,
|
| 47 |
+
body: JSON.stringify({ template_name: data.templateName }),
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
if (!response.ok) {
|
| 51 |
+
throw new Error("Failed to get template details")
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
result = await response.json()
|
| 55 |
+
return NextResponse.json({
|
| 56 |
+
success: result.success,
|
| 57 |
+
templateName: result.template_name,
|
| 58 |
+
placeholders: result.placeholders,
|
| 59 |
+
content: result.content,
|
| 60 |
+
error: result.error,
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
case "fill-template":
|
| 64 |
+
// Step 3: Fill template with provided placeholder values
|
| 65 |
+
response = await fetch(`${BACKEND_URL}/api/v1/fill-template`, {
|
| 66 |
+
method: "POST",
|
| 67 |
+
headers,
|
| 68 |
+
body: JSON.stringify({
|
| 69 |
+
template_name: data.templateName,
|
| 70 |
+
placeholders: data.placeholders,
|
| 71 |
+
}),
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
if (!response.ok) {
|
| 75 |
+
throw new Error("Failed to fill template")
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
result = await response.json()
|
| 79 |
+
return NextResponse.json({
|
| 80 |
+
success: result.success,
|
| 81 |
+
letter: result.letter,
|
| 82 |
+
error: result.error,
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
default:
|
| 86 |
+
return NextResponse.json({ error: "Invalid action" }, { status: 400 })
|
| 87 |
+
}
|
| 88 |
+
} catch (error) {
|
| 89 |
+
console.error("[Letter Generation API Error]:", error)
|
| 90 |
+
return NextResponse.json(
|
| 91 |
+
{
|
| 92 |
+
error: error instanceof Error ? error.message : "Failed to process request. Please ensure the backend server is running.",
|
| 93 |
+
},
|
| 94 |
+
{ status: 500 }
|
| 95 |
+
)
|
| 96 |
+
}
|
| 97 |
+
}
|
Frontend/app/api/login/route.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
export async function POST(req: NextRequest) {
|
| 4 |
+
try {
|
| 5 |
+
const body = await req.json()
|
| 6 |
+
|
| 7 |
+
const backendRes = await fetch("http://localhost:8000/login", {
|
| 8 |
+
method: "POST",
|
| 9 |
+
headers: { "Content-Type": "application/json" },
|
| 10 |
+
body: JSON.stringify(body),
|
| 11 |
+
})
|
| 12 |
+
|
| 13 |
+
const data = await backendRes.json().catch(() => null)
|
| 14 |
+
|
| 15 |
+
return NextResponse.json(data ?? { message: "No JSON response from backend" }, { status: backendRes.status })
|
| 16 |
+
} catch (error) {
|
| 17 |
+
console.error("/api/login proxy error:", error)
|
| 18 |
+
return NextResponse.json({ error: "Proxy failed" }, { status: 500 })
|
| 19 |
+
}
|
| 20 |
+
}
|
Frontend/app/bias-checker/page.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { BiasChecker } from "@/components/chatbot/bias-checker"
|
| 4 |
+
import { Navbar } from "@/components/layout/navbar"
|
| 5 |
+
import { Footer } from "@/components/layout/footer"
|
| 6 |
+
import { useAuthGuard } from "@/hooks/use-auth-guard"
|
| 7 |
+
|
| 8 |
+
export default function BiasCheckerPage() {
|
| 9 |
+
const { isLoading, isAuthenticated } = useAuthGuard()
|
| 10 |
+
|
| 11 |
+
if (isLoading) {
|
| 12 |
+
return (
|
| 13 |
+
<div className="flex flex-col min-h-screen">
|
| 14 |
+
<Navbar />
|
| 15 |
+
<main className="flex-1 flex items-center justify-center">
|
| 16 |
+
<div className="text-center">
|
| 17 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
| 18 |
+
<p className="text-muted-foreground">Checking authentication...</p>
|
| 19 |
+
</div>
|
| 20 |
+
</main>
|
| 21 |
+
<Footer />
|
| 22 |
+
</div>
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
if (!isAuthenticated) {
|
| 27 |
+
return null // Will redirect to login
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="flex flex-col min-h-screen">
|
| 32 |
+
<Navbar />
|
| 33 |
+
<main className="flex-1 p-4 md:p-8 bg-muted/20">
|
| 34 |
+
<div className="container mx-auto max-w-7xl">
|
| 35 |
+
<div className="mb-8">
|
| 36 |
+
<h1 className="text-3xl font-bold text-primary tracking-tight">Legal Notice Analyzer</h1>
|
| 37 |
+
<p className="text-muted-foreground">
|
| 38 |
+
Empowering fair communication by detecting socioeconomic and linguistic biases in legal documents.
|
| 39 |
+
</p>
|
| 40 |
+
</div>
|
| 41 |
+
<BiasChecker />
|
| 42 |
+
</div>
|
| 43 |
+
</main>
|
| 44 |
+
<Footer />
|
| 45 |
+
</div>
|
| 46 |
+
)
|
| 47 |
+
}
|
Frontend/app/chatbot/page.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { LawChatbot } from "@/components/chatbot/law-chatbot"
|
| 4 |
+
import { Navbar } from "@/components/layout/navbar"
|
| 5 |
+
import { Footer } from "@/components/layout/footer"
|
| 6 |
+
import { useAuthGuard } from "@/hooks/use-auth-guard"
|
| 7 |
+
|
| 8 |
+
export default function ChatbotPage() {
|
| 9 |
+
const { isLoading, isAuthenticated } = useAuthGuard()
|
| 10 |
+
|
| 11 |
+
if (isLoading) {
|
| 12 |
+
return (
|
| 13 |
+
<div className="flex flex-col min-h-screen">
|
| 14 |
+
<Navbar />
|
| 15 |
+
<main className="flex-1 flex items-center justify-center">
|
| 16 |
+
<div className="text-center">
|
| 17 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
| 18 |
+
<p className="text-muted-foreground">Checking authentication...</p>
|
| 19 |
+
</div>
|
| 20 |
+
</main>
|
| 21 |
+
<Footer />
|
| 22 |
+
</div>
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
if (!isAuthenticated) {
|
| 27 |
+
return null // Will redirect to login
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="flex flex-col min-h-screen">
|
| 32 |
+
<Navbar />
|
| 33 |
+
<main className="flex-1 p-4 md:p-8 bg-muted/20">
|
| 34 |
+
<div className="container mx-auto max-w-7xl h-full">
|
| 35 |
+
<div className="mb-6">
|
| 36 |
+
<h1 className="text-2xl font-bold text-primary">Nepali Law Assistant</h1>
|
| 37 |
+
<p className="text-sm text-muted-foreground">Ask questions about Nepali law, rights, and regulations.</p>
|
| 38 |
+
</div>
|
| 39 |
+
<LawChatbot />
|
| 40 |
+
</div>
|
| 41 |
+
</main>
|
| 42 |
+
<Footer />
|
| 43 |
+
</div>
|
| 44 |
+
)
|
| 45 |
+
}
|
Frontend/app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react"
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
import { useAuth } from "@/context/auth-context"
|
| 7 |
+
import { Navbar } from "@/components/layout/navbar"
|
| 8 |
+
import { Footer } from "@/components/layout/footer"
|
| 9 |
+
import { Button } from "@/components/ui/button"
|
| 10 |
+
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card"
|
| 11 |
+
import { StatsCard } from "@/components/dashboard/stats-card"
|
| 12 |
+
import { MessageSquare, ShieldCheck, Clock, ArrowUpRight, BookOpen, Lightbulb, Bell, FileText } from "lucide-react"
|
| 13 |
+
import Link from "next/link"
|
| 14 |
+
import { redirect } from "next/navigation"
|
| 15 |
+
import { getDocumentStats } from "@/lib/document-cache"
|
| 16 |
+
|
| 17 |
+
export default function DashboardPage() {
|
| 18 |
+
const { user, isLoading } = useAuth()
|
| 19 |
+
const [mounted, setMounted] = useState(false)
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
setMounted(true)
|
| 23 |
+
console.log("Dashboard - isLoading:", isLoading);
|
| 24 |
+
console.log("Dashboard - user:", user);
|
| 25 |
+
console.log("Token in localStorage:", localStorage.getItem('access_token'));
|
| 26 |
+
}, [isLoading, user])
|
| 27 |
+
|
| 28 |
+
const [tips, setTips] = useState<any[]>([])
|
| 29 |
+
const [currentTipIndex, setCurrentTipIndex] = useState<number>(0)
|
| 30 |
+
const [showNepali, setShowNepali] = useState<boolean>(false)
|
| 31 |
+
const [totalConsultations, setTotalConsultations] = useState<number>(0)
|
| 32 |
+
const [recentActivities, setRecentActivities] = useState<any[]>([])
|
| 33 |
+
const [documentStats, setDocumentStats] = useState({
|
| 34 |
+
totalAnalyzed: 0,
|
| 35 |
+
totalInclusive: 0,
|
| 36 |
+
totalFlagged: 0,
|
| 37 |
+
totalLetters: 0,
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
fetch("/data.json")
|
| 42 |
+
.then((res) => res.json())
|
| 43 |
+
.then((data) => {
|
| 44 |
+
if (Array.isArray(data) && data.length > 0) {
|
| 45 |
+
setTips(data)
|
| 46 |
+
setCurrentTipIndex(Math.floor(Math.random() * data.length))
|
| 47 |
+
}
|
| 48 |
+
})
|
| 49 |
+
.catch((err) => console.error("Failed to load tips:", err))
|
| 50 |
+
|
| 51 |
+
// Load document stats from cache
|
| 52 |
+
const stats = getDocumentStats()
|
| 53 |
+
setDocumentStats(stats)
|
| 54 |
+
}, [])
|
| 55 |
+
|
| 56 |
+
// Fetch total consultations count and recent activities
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
const fetchConsultations = async () => {
|
| 59 |
+
const token = localStorage.getItem("access_token")
|
| 60 |
+
if (!token) return
|
| 61 |
+
|
| 62 |
+
const BACKEND_URL = (process.env.NEXT_PUBLIC_BACKEND_URL as string) || "http://localhost:8000"
|
| 63 |
+
|
| 64 |
+
try {
|
| 65 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/chat-history/conversations`, {
|
| 66 |
+
headers: {
|
| 67 |
+
"Content-Type": "application/json",
|
| 68 |
+
Authorization: `Bearer ${token}`,
|
| 69 |
+
},
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
if (response.ok) {
|
| 73 |
+
const data = await response.json()
|
| 74 |
+
setTotalConsultations(data.length)
|
| 75 |
+
|
| 76 |
+
// Transform conversations into activity format
|
| 77 |
+
const activities = data.slice(0, 5).map((conv: any) => ({
|
| 78 |
+
type: "chat",
|
| 79 |
+
title: conv.title,
|
| 80 |
+
time: formatRelativeTime(conv.updated_at),
|
| 81 |
+
icon: MessageSquare,
|
| 82 |
+
color: "text-primary bg-primary/10",
|
| 83 |
+
conversationId: conv.id,
|
| 84 |
+
}))
|
| 85 |
+
setRecentActivities(activities)
|
| 86 |
+
}
|
| 87 |
+
} catch (error) {
|
| 88 |
+
console.error("Failed to fetch consultations:", error)
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if (user) {
|
| 93 |
+
fetchConsultations()
|
| 94 |
+
}
|
| 95 |
+
}, [user])
|
| 96 |
+
|
| 97 |
+
// Helper function to format relative time
|
| 98 |
+
const formatRelativeTime = (dateString: string) => {
|
| 99 |
+
const date = new Date(dateString)
|
| 100 |
+
const now = new Date()
|
| 101 |
+
const diffMs = now.getTime() - date.getTime()
|
| 102 |
+
const diffMins = Math.floor(diffMs / 60000)
|
| 103 |
+
const diffHours = Math.floor(diffMs / 3600000)
|
| 104 |
+
const diffDays = Math.floor(diffMs / 86400000)
|
| 105 |
+
|
| 106 |
+
if (diffMins < 60) {
|
| 107 |
+
return diffMins <= 1 ? "Just now" : `${diffMins} minutes ago`
|
| 108 |
+
} else if (diffHours < 24) {
|
| 109 |
+
return diffHours === 1 ? "1 hour ago" : `${diffHours} hours ago`
|
| 110 |
+
} else if (diffDays === 1) {
|
| 111 |
+
return "Yesterday"
|
| 112 |
+
} else if (diffDays < 7) {
|
| 113 |
+
return `${diffDays} days ago`
|
| 114 |
+
} else {
|
| 115 |
+
return date.toLocaleDateString()
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
if (!mounted || isLoading) return null
|
| 120 |
+
if (!user) redirect("/login")
|
| 121 |
+
|
| 122 |
+
// Calculate profile progress based on name, email, NID, and age
|
| 123 |
+
const calculateProfileProgress = () => {
|
| 124 |
+
let completedFields = 0
|
| 125 |
+
const totalFields = 4
|
| 126 |
+
|
| 127 |
+
// Name is always present (required during registration)
|
| 128 |
+
if (user.name) completedFields++
|
| 129 |
+
|
| 130 |
+
// Email is always present (required during registration)
|
| 131 |
+
if (user.email) completedFields++
|
| 132 |
+
|
| 133 |
+
// Check if NID is filled
|
| 134 |
+
if (user.details?.nid) completedFields++
|
| 135 |
+
|
| 136 |
+
// Check if age is filled
|
| 137 |
+
if (user.details?.age) completedFields++
|
| 138 |
+
|
| 139 |
+
return Math.round((completedFields / totalFields) * 100)
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const profileProgress = calculateProfileProgress()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
return (
|
| 146 |
+
<div className="flex flex-col min-h-screen">
|
| 147 |
+
<Navbar />
|
| 148 |
+
<main className="flex-1 p-4 md:p-8 bg-muted/20">
|
| 149 |
+
<div className="container mx-auto max-w-7xl">
|
| 150 |
+
{/* Header */}
|
| 151 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
| 152 |
+
<div className="space-y-1">
|
| 153 |
+
<h1 className="text-3xl font-bold tracking-tight">Welcome back, {user.name}</h1>
|
| 154 |
+
<p className="text-muted-foreground">Here's an overview of your legal welfare activity.</p>
|
| 155 |
+
</div>
|
| 156 |
+
<div className="flex items-center gap-3">
|
| 157 |
+
<Button variant="outline" size="icon" className="relative bg-transparent">
|
| 158 |
+
<Bell className="h-5 w-5" />
|
| 159 |
+
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-destructive" />
|
| 160 |
+
</Button>
|
| 161 |
+
<Button className="bg-primary" asChild>
|
| 162 |
+
<Link href="/chatbot">Start New Consultation</Link>
|
| 163 |
+
</Button>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
{/* Stats Grid */}
|
| 168 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
| 169 |
+
<StatsCard
|
| 170 |
+
title="Total Consultations"
|
| 171 |
+
value={totalConsultations.toString()}
|
| 172 |
+
description={`${totalConsultations} chat ${totalConsultations === 1 ? 'history' : 'histories'}`}
|
| 173 |
+
icon={MessageSquare}
|
| 174 |
+
color="primary"
|
| 175 |
+
/>
|
| 176 |
+
<StatsCard
|
| 177 |
+
title="Documents Analyzed"
|
| 178 |
+
value={documentStats.totalAnalyzed.toString()}
|
| 179 |
+
description={`${documentStats.totalInclusive} inclusive, ${documentStats.totalFlagged} flagged`}
|
| 180 |
+
icon={ShieldCheck}
|
| 181 |
+
color="accent"
|
| 182 |
+
/>
|
| 183 |
+
<StatsCard
|
| 184 |
+
title="Letters Generated"
|
| 185 |
+
value={documentStats.totalLetters.toString()}
|
| 186 |
+
description="Cached locally"
|
| 187 |
+
icon={FileText}
|
| 188 |
+
color="success"
|
| 189 |
+
/>
|
| 190 |
+
<Link href="/profile" className="block">
|
| 191 |
+
<StatsCard
|
| 192 |
+
title="Profile Progress"
|
| 193 |
+
value={`${profileProgress}%`}
|
| 194 |
+
description="Complete your details"
|
| 195 |
+
icon={ArrowUpRight}
|
| 196 |
+
color="primary"
|
| 197 |
+
/>
|
| 198 |
+
</Link>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 202 |
+
{/* Recent Activity */}
|
| 203 |
+
<Card className="lg:col-span-2 border-primary/10">
|
| 204 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 205 |
+
<div>
|
| 206 |
+
<CardTitle>Recent Activity</CardTitle>
|
| 207 |
+
<CardDescription>Your latest interactions with our legal tools.</CardDescription>
|
| 208 |
+
</div>
|
| 209 |
+
<Button variant="ghost" size="sm" className="text-primary">
|
| 210 |
+
View All
|
| 211 |
+
</Button>
|
| 212 |
+
</CardHeader>
|
| 213 |
+
<CardContent>
|
| 214 |
+
<div className="space-y-6">
|
| 215 |
+
{recentActivities.length > 0 ? (
|
| 216 |
+
recentActivities.map((activity, i) => (
|
| 217 |
+
<Link key={i} href="/chatbot">
|
| 218 |
+
<div className="flex items-start gap-4 group cursor-pointer">
|
| 219 |
+
<div className={cn("p-2 rounded-lg border border-transparent transition-colors", activity.color)}>
|
| 220 |
+
<activity.icon className="h-5 w-5" />
|
| 221 |
+
</div>
|
| 222 |
+
<div className="flex-1 space-y-1">
|
| 223 |
+
<p className="text-sm font-bold group-hover:text-primary transition-colors">{activity.title}</p>
|
| 224 |
+
<p className="text-xs text-muted-foreground">{activity.time}</p>
|
| 225 |
+
</div>
|
| 226 |
+
<ArrowUpRight className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 227 |
+
</div>
|
| 228 |
+
</Link>
|
| 229 |
+
))
|
| 230 |
+
) : (
|
| 231 |
+
<div className="text-center py-8 text-muted-foreground">
|
| 232 |
+
<MessageSquare className="h-12 w-12 mx-auto mb-3 opacity-20" />
|
| 233 |
+
<p className="text-sm">No recent activity yet</p>
|
| 234 |
+
<p className="text-xs mt-1">Start a conversation with our legal chatbot</p>
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
+
</div>
|
| 238 |
+
</CardContent>
|
| 239 |
+
</Card>
|
| 240 |
+
|
| 241 |
+
{/* Side Panel: Tips & Actions */}
|
| 242 |
+
<div className="space-y-8">
|
| 243 |
+
<Card className="bg-primary text-primary-foreground border-none relative overflow-hidden">
|
| 244 |
+
<div className="absolute inset-0 nepali-pattern opacity-10" />
|
| 245 |
+
<CardHeader className="relative">
|
| 246 |
+
<CardTitle className="flex items-center gap-2">
|
| 247 |
+
<Lightbulb className="h-5 w-5 text-accent" />
|
| 248 |
+
Legal Tip of the Day
|
| 249 |
+
</CardTitle>
|
| 250 |
+
</CardHeader>
|
| 251 |
+
<CardContent className="relative">
|
| 252 |
+
<p className="text-sm leading-relaxed opacity-90">
|
| 253 |
+
{tips.length > 0 ? (showNepali ? tips[currentTipIndex]?.fact_np : tips[currentTipIndex]?.fact_en) : "Loading tip..."}
|
| 254 |
+
</p>
|
| 255 |
+
|
| 256 |
+
<div className="mt-4 w-full flex gap-3 items-center">
|
| 257 |
+
<Button
|
| 258 |
+
variant="ghost"
|
| 259 |
+
size="lg"
|
| 260 |
+
className="flex-1 bg-white/10 hover:bg-white/20 text-white/95 rounded-lg px-4 py-2 flex items-center gap-3 justify-center shadow-md"
|
| 261 |
+
onClick={() => {
|
| 262 |
+
// pick a new random tip (keep current language)
|
| 263 |
+
if (tips.length > 1) {
|
| 264 |
+
let idx = Math.floor(Math.random() * tips.length)
|
| 265 |
+
if (tips.length > 1 && idx === currentTipIndex) idx = (idx + 1) % tips.length
|
| 266 |
+
setCurrentTipIndex(idx)
|
| 267 |
+
}
|
| 268 |
+
}}
|
| 269 |
+
title={showNepali ? "नेपालीमा" : "View more tips"}
|
| 270 |
+
>
|
| 271 |
+
<Lightbulb className="h-4 w-4 text-accent" />
|
| 272 |
+
<span className="font-medium">
|
| 273 |
+
{showNepali ? "थप सुझावहरू" : "View More Tips"}
|
| 274 |
+
</span>
|
| 275 |
+
</Button>
|
| 276 |
+
|
| 277 |
+
<Button
|
| 278 |
+
variant="default"
|
| 279 |
+
size="sm"
|
| 280 |
+
className="rounded-full w-12 h-12 flex items-center justify-center shadow ring-1 ring-white/10 hover:scale-105 transition-transform"
|
| 281 |
+
onClick={() => setShowNepali((s) => !s)}
|
| 282 |
+
aria-label={showNepali ? "Switch to English" : "नेपालीमा हेर्नुहोस्"}
|
| 283 |
+
title={showNepali ? "Show in English" : "नेपालीमा हेर्नुहोस्"}
|
| 284 |
+
>
|
| 285 |
+
<span className="text-lg">{showNepali ? "EN" : "🇳🇵"}</span>
|
| 286 |
+
</Button>
|
| 287 |
+
</div>
|
| 288 |
+
</CardContent>
|
| 289 |
+
</Card>
|
| 290 |
+
|
| 291 |
+
<Card className="border-primary/10">
|
| 292 |
+
<CardHeader>
|
| 293 |
+
<CardTitle>Quick Actions</CardTitle>
|
| 294 |
+
</CardHeader>
|
| 295 |
+
<CardContent className="grid gap-4">
|
| 296 |
+
<Button variant="outline" className="w-full justify-start text-sm bg-transparent" asChild>
|
| 297 |
+
<Link href="/bias-checker">
|
| 298 |
+
<ShieldCheck className="mr-2 h-4 w-4 text-accent" />
|
| 299 |
+
Analyze a New Document
|
| 300 |
+
</Link>
|
| 301 |
+
</Button>
|
| 302 |
+
<Button variant="outline" className="w-full justify-start text-sm bg-transparent" asChild>
|
| 303 |
+
<Link href="/resources">
|
| 304 |
+
<BookOpen className="mr-2 h-4 w-4 text-success" />
|
| 305 |
+
Browse Legal Guides
|
| 306 |
+
</Link>
|
| 307 |
+
</Button>
|
| 308 |
+
<Button variant="outline" className="w-full justify-start text-sm bg-transparent" asChild>
|
| 309 |
+
<Link href="/profile">
|
| 310 |
+
<Clock className="mr-2 h-4 w-4 text-primary" />
|
| 311 |
+
Update Personal Details
|
| 312 |
+
</Link>
|
| 313 |
+
</Button>
|
| 314 |
+
</CardContent>
|
| 315 |
+
</Card>
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
</main>
|
| 320 |
+
<Footer />
|
| 321 |
+
</div>
|
| 322 |
+
)
|
| 323 |
+
}
|
Frontend/app/globals.css
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
@import "tw-animate-css";
|
| 3 |
+
|
| 4 |
+
@custom-variant dark (&:is(.dark *));
|
| 5 |
+
|
| 6 |
+
:root {
|
| 7 |
+
--background: oklch(0.99 0.01 240);
|
| 8 |
+
--foreground: oklch(0.1 0.02 240);
|
| 9 |
+
--card: oklch(1 0 0);
|
| 10 |
+
--card-foreground: oklch(0.1 0.02 240);
|
| 11 |
+
--popover: oklch(1 0 0);
|
| 12 |
+
--popover-foreground: oklch(0.1 0.02 240);
|
| 13 |
+
/* deep blue primary */
|
| 14 |
+
--primary: oklch(0.25 0.05 240);
|
| 15 |
+
--primary-foreground: oklch(0.98 0.01 240);
|
| 16 |
+
/* bright blue secondary */
|
| 17 |
+
--secondary: oklch(0.6 0.15 240);
|
| 18 |
+
--secondary-foreground: oklch(1 0 0);
|
| 19 |
+
--muted: oklch(0.95 0.01 240);
|
| 20 |
+
--muted-foreground: oklch(0.4 0.02 240);
|
| 21 |
+
/* gold accent */
|
| 22 |
+
--accent: oklch(0.75 0.12 85);
|
| 23 |
+
--accent-foreground: oklch(0.2 0.05 85);
|
| 24 |
+
--destructive: oklch(0.6 0.2 25);
|
| 25 |
+
--destructive-foreground: oklch(0.98 0.01 25);
|
| 26 |
+
--success: oklch(0.65 0.15 150);
|
| 27 |
+
--success-foreground: oklch(0.98 0.01 150);
|
| 28 |
+
--border: oklch(0.9 0.01 240);
|
| 29 |
+
--input: oklch(0.9 0.01 240);
|
| 30 |
+
--ring: oklch(0.6 0.15 240);
|
| 31 |
+
--radius: 0.75rem;
|
| 32 |
+
--sidebar: oklch(0.985 0 0);
|
| 33 |
+
--sidebar-foreground: oklch(0.145 0 0);
|
| 34 |
+
--sidebar-primary: oklch(0.205 0 0);
|
| 35 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 36 |
+
--sidebar-accent: oklch(0.97 0 0);
|
| 37 |
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
| 38 |
+
--sidebar-border: oklch(0.922 0 0);
|
| 39 |
+
--sidebar-ring: oklch(0.708 0 0);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.dark {
|
| 43 |
+
--background: oklch(0.145 0 0);
|
| 44 |
+
--foreground: oklch(0.985 0 0);
|
| 45 |
+
--card: oklch(0.145 0 0);
|
| 46 |
+
--card-foreground: oklch(0.985 0 0);
|
| 47 |
+
--popover: oklch(0.145 0 0);
|
| 48 |
+
--popover-foreground: oklch(0.985 0 0);
|
| 49 |
+
--primary: oklch(0.985 0 0);
|
| 50 |
+
--primary-foreground: oklch(0.205 0 0);
|
| 51 |
+
--secondary: oklch(0.269 0 0);
|
| 52 |
+
--secondary-foreground: oklch(0.985 0 0);
|
| 53 |
+
--muted: oklch(0.269 0 0);
|
| 54 |
+
--muted-foreground: oklch(0.708 0 0);
|
| 55 |
+
--accent: oklch(0.269 0 0);
|
| 56 |
+
--accent-foreground: oklch(0.985 0 0);
|
| 57 |
+
--destructive: oklch(0.396 0.141 25.723);
|
| 58 |
+
--destructive-foreground: oklch(0.637 0.237 25.331);
|
| 59 |
+
--border: oklch(0.269 0 0);
|
| 60 |
+
--input: oklch(0.269 0 0);
|
| 61 |
+
--ring: oklch(0.439 0 0);
|
| 62 |
+
--chart-1: oklch(0.488 0.243 264.376);
|
| 63 |
+
--chart-2: oklch(0.696 0.17 162.48);
|
| 64 |
+
--chart-3: oklch(0.769 0.188 70.08);
|
| 65 |
+
--chart-4: oklch(0.627 0.265 303.9);
|
| 66 |
+
--chart-5: oklch(0.645 0.246 16.439);
|
| 67 |
+
--sidebar: oklch(0.205 0 0);
|
| 68 |
+
--sidebar-foreground: oklch(0.985 0 0);
|
| 69 |
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
| 70 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 71 |
+
--sidebar-accent: oklch(0.269 0 0);
|
| 72 |
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
| 73 |
+
--sidebar-border: oklch(0.269 0 0);
|
| 74 |
+
--sidebar-ring: oklch(0.439 0 0);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
@theme inline {
|
| 78 |
+
--font-sans: "Geist", "Geist Fallback";
|
| 79 |
+
--font-mono: "Geist Mono", "Geist Mono Fallback";
|
| 80 |
+
--color-background: var(--background);
|
| 81 |
+
--color-foreground: var(--foreground);
|
| 82 |
+
--color-card: var(--card);
|
| 83 |
+
--color-card-foreground: var(--card-foreground);
|
| 84 |
+
--color-popover: var(--popover);
|
| 85 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 86 |
+
--color-primary: var(--primary);
|
| 87 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 88 |
+
--color-secondary: var(--secondary);
|
| 89 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 90 |
+
--color-muted: var(--muted);
|
| 91 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 92 |
+
--color-accent: var(--accent);
|
| 93 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 94 |
+
--color-destructive: var(--destructive);
|
| 95 |
+
--color-destructive-foreground: var(--destructive-foreground);
|
| 96 |
+
--color-border: var(--border);
|
| 97 |
+
--color-input: var(--input);
|
| 98 |
+
--color-ring: var(--ring);
|
| 99 |
+
--color-success: var(--success);
|
| 100 |
+
--color-success-foreground: var(--success-foreground);
|
| 101 |
+
|
| 102 |
+
/* Custom animations for chat and micro-interactions */
|
| 103 |
+
--animate-in: in 0.3s ease-out;
|
| 104 |
+
--animate-out: out 0.3s ease-in;
|
| 105 |
+
--animate-fade-in: fade-in 0.5s ease-out;
|
| 106 |
+
--animate-slide-up: slide-up 0.4s ease-out;
|
| 107 |
+
--color-chart-1: var(--chart-1);
|
| 108 |
+
--color-chart-2: var(--chart-2);
|
| 109 |
+
--color-chart-3: var(--chart-3);
|
| 110 |
+
--color-chart-4: var(--chart-4);
|
| 111 |
+
--color-chart-5: var(--chart-5);
|
| 112 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 113 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 114 |
+
--radius-lg: var(--radius);
|
| 115 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 116 |
+
--color-sidebar: var(--sidebar);
|
| 117 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 118 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
| 119 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 120 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 121 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 122 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 123 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
@keyframes fade-in {
|
| 127 |
+
from {
|
| 128 |
+
opacity: 0;
|
| 129 |
+
}
|
| 130 |
+
to {
|
| 131 |
+
opacity: 1;
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
@keyframes slide-up {
|
| 136 |
+
from {
|
| 137 |
+
transform: translateY(10px);
|
| 138 |
+
opacity: 0;
|
| 139 |
+
}
|
| 140 |
+
to {
|
| 141 |
+
transform: translateY(0);
|
| 142 |
+
opacity: 1;
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
@layer base {
|
| 147 |
+
* {
|
| 148 |
+
@apply border-border outline-ring/50;
|
| 149 |
+
}
|
| 150 |
+
body {
|
| 151 |
+
@apply bg-background text-foreground selection:bg-accent/30 px-4 sm:px-6 lg:px-8;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/* Custom patterns for Nepali cultural feel */
|
| 155 |
+
.nepali-pattern {
|
| 156 |
+
background-image: radial-gradient(var(--accent) 0.5px, transparent 0.5px);
|
| 157 |
+
background-size: 20px 20px;
|
| 158 |
+
opacity: 0.05;
|
| 159 |
+
}
|
| 160 |
+
}
|
Frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type React from "react"
|
| 2 |
+
import type { Metadata } from "next"
|
| 3 |
+
import { Geist, Geist_Mono } from "next/font/google"
|
| 4 |
+
import "./globals.css"
|
| 5 |
+
import { AuthProvider } from "@/context/auth-context"
|
| 6 |
+
import { Toaster } from "@/components/ui/toaster"
|
| 7 |
+
|
| 8 |
+
const _geist = Geist({ subsets: ["latin"] })
|
| 9 |
+
const _geistMono = Geist_Mono({ subsets: ["latin"] })
|
| 10 |
+
|
| 11 |
+
export const metadata: Metadata = {
|
| 12 |
+
title: "Setu",
|
| 13 |
+
description: "Your digital legal assistant for legal welfare and rights in Nepal.",
|
| 14 |
+
// icons: {
|
| 15 |
+
// icon: [
|
| 16 |
+
// {
|
| 17 |
+
// url: "/icon-light-32x32.png",
|
| 18 |
+
// media: "(prefers-color-scheme: light)",
|
| 19 |
+
// },
|
| 20 |
+
// {
|
| 21 |
+
// url: "/icon-dark-32x32.png",
|
| 22 |
+
// media: "(prefers-color-scheme: dark)",
|
| 23 |
+
// },
|
| 24 |
+
// {
|
| 25 |
+
// url: "/icon.svg",
|
| 26 |
+
// type: "image/svg+xml",
|
| 27 |
+
// },
|
| 28 |
+
// ],
|
| 29 |
+
// apple: "/apple-icon.png",
|
| 30 |
+
// },
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export default function RootLayout({
|
| 34 |
+
children,
|
| 35 |
+
}: Readonly<{
|
| 36 |
+
children: React.ReactNode
|
| 37 |
+
}>) {
|
| 38 |
+
return (
|
| 39 |
+
<html lang="en" suppressHydrationWarning>
|
| 40 |
+
<body className={`font-sans antialiased min-h-screen flex flex-col`}>
|
| 41 |
+
<AuthProvider>
|
| 42 |
+
{children}
|
| 43 |
+
<Toaster />
|
| 44 |
+
</AuthProvider>
|
| 45 |
+
</body>
|
| 46 |
+
</html>
|
| 47 |
+
)
|
| 48 |
+
}
|
Frontend/app/letter-generator/page.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { LetterGenerator } from "@/components/chatbot/letter-generator"
|
| 4 |
+
import { Navbar } from "@/components/layout/navbar"
|
| 5 |
+
import { Footer } from "@/components/layout/footer"
|
| 6 |
+
import { useAuthGuard } from "@/hooks/use-auth-guard"
|
| 7 |
+
|
| 8 |
+
export default function LetterGeneratorPage() {
|
| 9 |
+
const { isLoading, isAuthenticated } = useAuthGuard()
|
| 10 |
+
|
| 11 |
+
if (isLoading) {
|
| 12 |
+
return (
|
| 13 |
+
<div className="flex flex-col min-h-screen">
|
| 14 |
+
<Navbar />
|
| 15 |
+
<main className="flex-1 flex items-center justify-center">
|
| 16 |
+
<div className="text-center">
|
| 17 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
| 18 |
+
<p className="text-muted-foreground">Checking authentication...</p>
|
| 19 |
+
</div>
|
| 20 |
+
</main>
|
| 21 |
+
<Footer />
|
| 22 |
+
</div>
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
if (!isAuthenticated) {
|
| 27 |
+
return null // Will redirect to login
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<>
|
| 32 |
+
{/* Load Nepali fonts for letter generation */}
|
| 33 |
+
<link
|
| 34 |
+
rel="preconnect"
|
| 35 |
+
href="https://fonts.googleapis.com"
|
| 36 |
+
/>
|
| 37 |
+
<link
|
| 38 |
+
rel="preconnect"
|
| 39 |
+
href="https://fonts.gstatic.com"
|
| 40 |
+
crossOrigin="anonymous"
|
| 41 |
+
/>
|
| 42 |
+
<link
|
| 43 |
+
href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&display=swap"
|
| 44 |
+
rel="stylesheet"
|
| 45 |
+
/>
|
| 46 |
+
|
| 47 |
+
<div className="flex flex-col min-h-screen">
|
| 48 |
+
<Navbar />
|
| 49 |
+
<main className="flex-1 p-4 md:p-8 bg-muted/20">
|
| 50 |
+
<div className="container mx-auto max-w-7xl h-full">
|
| 51 |
+
<div className="mb-6">
|
| 52 |
+
<h1 className="text-2xl font-bold text-primary">Letter Generation Assistant</h1>
|
| 53 |
+
<p className="text-sm text-muted-foreground">
|
| 54 |
+
Generate official letters for various purposes with AI assistance
|
| 55 |
+
</p>
|
| 56 |
+
</div>
|
| 57 |
+
<LetterGenerator />
|
| 58 |
+
</div>
|
| 59 |
+
</main>
|
| 60 |
+
<Footer />
|
| 61 |
+
</div>
|
| 62 |
+
</>
|
| 63 |
+
)
|
| 64 |
+
}
|
Frontend/app/loading.tsx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function Loading() {
|
| 2 |
+
return null
|
| 3 |
+
}
|
Frontend/app/login/page.tsx
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import type React from "react"
|
| 4 |
+
import { useState } from "react"
|
| 5 |
+
import { useRouter } from "next/navigation"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Input } from "@/components/ui/input"
|
| 8 |
+
import { Label } from "@/components/ui/label"
|
| 9 |
+
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card"
|
| 10 |
+
import { Navbar } from "@/components/layout/navbar"
|
| 11 |
+
import { Footer } from "@/components/layout/footer"
|
| 12 |
+
import Link from "next/link"
|
| 13 |
+
import { Scale, AlertCircle } from "lucide-react"
|
| 14 |
+
import { Alert, AlertDescription } from "@/components/ui/alert"
|
| 15 |
+
import { useAuth } from "@/context/auth-context"
|
| 16 |
+
import { apiClient } from "@/lib/api-client"
|
| 17 |
+
|
| 18 |
+
export default function LoginPage() {
|
| 19 |
+
const [email, setEmail] = useState("")
|
| 20 |
+
const [password, setPassword] = useState("")
|
| 21 |
+
const [error, setError] = useState<string | null>(null)
|
| 22 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 23 |
+
const router = useRouter()
|
| 24 |
+
const { login } = useAuth()
|
| 25 |
+
|
| 26 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 27 |
+
e.preventDefault()
|
| 28 |
+
setError(null)
|
| 29 |
+
setIsLoading(true)
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
// Call backend login endpoint using API client
|
| 33 |
+
const data = await apiClient.login(email, password)
|
| 34 |
+
|
| 35 |
+
console.log("Login response:", data)
|
| 36 |
+
console.log("User data from backend:", data.user)
|
| 37 |
+
|
| 38 |
+
// Store token and user data in localStorage
|
| 39 |
+
localStorage.setItem("access_token", data.access_token)
|
| 40 |
+
if (data.user) {
|
| 41 |
+
localStorage.setItem("user", JSON.stringify(data.user))
|
| 42 |
+
console.log("Stored user in localStorage:", localStorage.getItem("user"))
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Update auth context with the new token
|
| 46 |
+
login(data.access_token)
|
| 47 |
+
|
| 48 |
+
// Check for redirect parameter
|
| 49 |
+
const searchParams = new URLSearchParams(window.location.search)
|
| 50 |
+
const redirectTo = searchParams.get("redirect")
|
| 51 |
+
|
| 52 |
+
// Redirect to the original page or dashboard
|
| 53 |
+
router.push(redirectTo || "/dashboard")
|
| 54 |
+
} catch (err) {
|
| 55 |
+
if (err instanceof Error) {
|
| 56 |
+
setError(err.message)
|
| 57 |
+
} else {
|
| 58 |
+
setError("Server connection failed. Please try again later.")
|
| 59 |
+
}
|
| 60 |
+
} finally {
|
| 61 |
+
setIsLoading(false)
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div className="flex flex-col min-h-screen">
|
| 67 |
+
<Navbar />
|
| 68 |
+
<main className="flex-1 flex items-center justify-center p-4 bg-muted/20">
|
| 69 |
+
<Card className="w-full max-w-md border-primary/20 shadow-lg">
|
| 70 |
+
<CardHeader className="text-center">
|
| 71 |
+
<div className="flex justify-center mb-4">
|
| 72 |
+
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
| 73 |
+
<Scale className="h-6 w-6" />
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
|
| 77 |
+
<CardDescription>Enter your email to access your legal dashboard</CardDescription>
|
| 78 |
+
</CardHeader>
|
| 79 |
+
<form onSubmit={handleSubmit}>
|
| 80 |
+
<CardContent className="space-y-4">
|
| 81 |
+
|
| 82 |
+
{/* --- ERROR MESSAGE DISPLAY --- */}
|
| 83 |
+
{error && (
|
| 84 |
+
<Alert variant="destructive" className="bg-red-50 text-red-900 border-red-200">
|
| 85 |
+
<AlertCircle className="h-4 w-4" />
|
| 86 |
+
<AlertDescription>{error}</AlertDescription>
|
| 87 |
+
</Alert>
|
| 88 |
+
)}
|
| 89 |
+
|
| 90 |
+
<div className="space-y-2">
|
| 91 |
+
<Label htmlFor="email">Email</Label>
|
| 92 |
+
<Input
|
| 93 |
+
id="email"
|
| 94 |
+
name="email"
|
| 95 |
+
type="email"
|
| 96 |
+
placeholder="name@example.com"
|
| 97 |
+
required
|
| 98 |
+
value={email}
|
| 99 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 100 |
+
/>
|
| 101 |
+
</div>
|
| 102 |
+
<div className="space-y-2">
|
| 103 |
+
<Label htmlFor="password">Password</Label>
|
| 104 |
+
<Input
|
| 105 |
+
id="password"
|
| 106 |
+
name="password"
|
| 107 |
+
type="password"
|
| 108 |
+
required
|
| 109 |
+
value={password}
|
| 110 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 111 |
+
/>
|
| 112 |
+
</div>
|
| 113 |
+
</CardContent>
|
| 114 |
+
<CardFooter className="flex flex-col gap-4 pt-4">
|
| 115 |
+
<Button type="submit" className="w-full bg-primary" disabled={isLoading}>
|
| 116 |
+
{isLoading ? "Signing In..." : "Sign In"}
|
| 117 |
+
</Button>
|
| 118 |
+
<p className="text-sm text-center text-muted-foreground">
|
| 119 |
+
Don't have an account?{" "}
|
| 120 |
+
<Link href="/register" className="text-primary hover:underline">
|
| 121 |
+
Register here
|
| 122 |
+
</Link>
|
| 123 |
+
</p>
|
| 124 |
+
</CardFooter>
|
| 125 |
+
</form>
|
| 126 |
+
</Card>
|
| 127 |
+
</main>
|
| 128 |
+
<Footer />
|
| 129 |
+
</div>
|
| 130 |
+
)
|
| 131 |
+
}
|
Frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Navbar } from "@/components/layout/navbar"
|
| 2 |
+
import { Footer } from "@/components/layout/footer"
|
| 3 |
+
import { Button } from "@/components/ui/button"
|
| 4 |
+
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"
|
| 5 |
+
import { MessageSquare, Search, BookOpen, CheckCircle } from "lucide-react"
|
| 6 |
+
import Link from "next/link"
|
| 7 |
+
|
| 8 |
+
export default function Home() {
|
| 9 |
+
return (
|
| 10 |
+
<div className="flex flex-col min-h-screen">
|
| 11 |
+
<Navbar />
|
| 12 |
+
<main className="flex-1">
|
| 13 |
+
{/* Hero Section */}
|
| 14 |
+
<section className="relative py-20 md:py-32 overflow-hidden">
|
| 15 |
+
<div className="absolute inset-0 nepali-pattern -z-10" />
|
| 16 |
+
<div className="container max-w-7xl mx-auto relative text-center space-y-8">
|
| 17 |
+
{/* <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-semibold mb-4 border border-primary/20">
|
| 18 |
+
<CheckCircle className="h-3 w-3" />
|
| 19 |
+
Trusted by 5,000+ Nepali Citizens
|
| 20 |
+
</div> */}
|
| 21 |
+
<h1 className="text-4xl md:text-6xl font-extrabold tracking-tight text-balance">
|
| 22 |
+
Know Your Rights - <span className="text-primary">Nepal Legal Assistance</span>
|
| 23 |
+
</h1>
|
| 24 |
+
<p className="max-w-[700px] mx-auto text-muted-foreground md:text-xl text-pretty">
|
| 25 |
+
Your digital gateway to legal justice in Nepal. Access our AI-powered law assistant, analyze legal notices
|
| 26 |
+
for bias, and learn about your fundamental rights.
|
| 27 |
+
</p>
|
| 28 |
+
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
| 29 |
+
<Button size="lg" className="h-12 px-8 bg-primary hover:bg-primary/90" asChild>
|
| 30 |
+
<Link href="/chatbot">Start Legal Consultation</Link>
|
| 31 |
+
</Button>
|
| 32 |
+
<Button
|
| 33 |
+
size="lg"
|
| 34 |
+
variant="outline"
|
| 35 |
+
className="h-12 px-8 border-primary text-primary hover:bg-primary/5 bg-transparent"
|
| 36 |
+
asChild
|
| 37 |
+
>
|
| 38 |
+
<Link href="/bias-checker">Analyze Legal Documents</Link>
|
| 39 |
+
</Button>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</section>
|
| 43 |
+
|
| 44 |
+
{/* Features Grid */}
|
| 45 |
+
<section className="py-20 bg-muted/30">
|
| 46 |
+
<div className="container max-w-7xl mx-auto">
|
| 47 |
+
<div className="text-center mb-16 space-y-4">
|
| 48 |
+
<h2 className="text-3xl font-bold tracking-tight">Comprehensive Legal Services</h2>
|
| 49 |
+
<p className="text-muted-foreground max-w-[600px] mx-auto">
|
| 50 |
+
Tailored solutions for every legal need, built with privacy and accessibility in mind.
|
| 51 |
+
</p>
|
| 52 |
+
</div>
|
| 53 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
| 54 |
+
<Card className="hover:shadow-lg transition-all border-primary/10">
|
| 55 |
+
<CardHeader>
|
| 56 |
+
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
|
| 57 |
+
<MessageSquare className="h-6 w-6 text-primary" />
|
| 58 |
+
</div>
|
| 59 |
+
<CardTitle>Law Chatbot</CardTitle>
|
| 60 |
+
<CardDescription>Get instant answers to your legal queries based on Nepali law.</CardDescription>
|
| 61 |
+
</CardHeader>
|
| 62 |
+
<CardContent>
|
| 63 |
+
<Button variant="link" className="p-0 h-auto text-primary" asChild>
|
| 64 |
+
<Link href="/chatbot">Learn more →</Link>
|
| 65 |
+
</Button>
|
| 66 |
+
</CardContent>
|
| 67 |
+
</Card>
|
| 68 |
+
|
| 69 |
+
<Card className="hover:shadow-lg transition-all border-primary/10">
|
| 70 |
+
<CardHeader>
|
| 71 |
+
<div className="h-12 w-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4">
|
| 72 |
+
<Search className="h-6 w-6 text-accent" />
|
| 73 |
+
</div>
|
| 74 |
+
<CardTitle>Bias Checker</CardTitle>
|
| 75 |
+
<CardDescription>
|
| 76 |
+
Analyze legal notices and contracts for potential linguistic biases.
|
| 77 |
+
</CardDescription>
|
| 78 |
+
</CardHeader>
|
| 79 |
+
<CardContent>
|
| 80 |
+
<Button variant="link" className="p-0 h-auto text-accent" asChild>
|
| 81 |
+
<Link href="/bias-checker">Try it now →</Link>
|
| 82 |
+
</Button>
|
| 83 |
+
</CardContent>
|
| 84 |
+
</Card>
|
| 85 |
+
|
| 86 |
+
<Card className="hover:shadow-lg transition-all border-primary/10">
|
| 87 |
+
<CardHeader>
|
| 88 |
+
<div className="h-12 w-12 rounded-lg bg-success/10 flex items-center justify-center mb-4">
|
| 89 |
+
<BookOpen className="h-6 w-6 text-success" />
|
| 90 |
+
</div>
|
| 91 |
+
<CardTitle>Letter Generator</CardTitle>
|
| 92 |
+
<CardDescription>
|
| 93 |
+
Generate professional government letters effortlessly. Describe what you need, provide the required information, and get an official letter tailored to your reques
|
| 94 |
+
</CardDescription>
|
| 95 |
+
</CardHeader>
|
| 96 |
+
<CardContent>
|
| 97 |
+
<Button variant="link" className="p-0 h-auto text-success" asChild>
|
| 98 |
+
<Link href="/letter-generator">Generate Letters →</Link>
|
| 99 |
+
</Button>
|
| 100 |
+
</CardContent>
|
| 101 |
+
</Card>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</section>
|
| 105 |
+
|
| 106 |
+
{/* It is the section that shows the users stats */}
|
| 107 |
+
{/* Statistics Section */}
|
| 108 |
+
{/* <section className="py-20 border-y">
|
| 109 |
+
<div className="container grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
|
| 110 |
+
<div className="space-y-2">
|
| 111 |
+
<div className="text-4xl font-bold text-primary">15k+</div>
|
| 112 |
+
<p className="text-sm text-muted-foreground uppercase tracking-wider font-semibold">Users Helped</p>
|
| 113 |
+
</div>
|
| 114 |
+
<div className="space-y-2">
|
| 115 |
+
<div className="text-4xl font-bold text-primary">50k+</div>
|
| 116 |
+
<p className="text-sm text-muted-foreground uppercase tracking-wider font-semibold">Queries Answered</p>
|
| 117 |
+
</div>
|
| 118 |
+
<div className="space-y-2">
|
| 119 |
+
<div className="text-4xl font-bold text-primary">2.5k+</div>
|
| 120 |
+
<p className="text-sm text-muted-foreground uppercase tracking-wider font-semibold">Docs Analyzed</p>
|
| 121 |
+
</div>
|
| 122 |
+
<div className="space-y-2">
|
| 123 |
+
<div className="text-4xl font-bold text-primary">98%</div>
|
| 124 |
+
<p className="text-sm text-muted-foreground uppercase tracking-wider font-semibold">Satisfaction Rate</p>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</section> */}
|
| 128 |
+
|
| 129 |
+
{/* CTA Section */}
|
| 130 |
+
{/** This is the section we can add to get the Create account section */}
|
| 131 |
+
{/* <section className="py-20 bg-primary text-primary-foreground relative overflow-hidden">
|
| 132 |
+
<div className="absolute inset-0 opacity-10 nepali-pattern" />
|
| 133 |
+
<div className="container relative text-center space-y-6">
|
| 134 |
+
<h2 className="text-3xl md:text-4xl font-bold">Ready to take control of your legal journey?</h2>
|
| 135 |
+
<p className="max-w-[600px] mx-auto opacity-90">
|
| 136 |
+
Join thousands of Nepali citizens who are using our platform to understand their rights and seek justice.
|
| 137 |
+
</p>
|
| 138 |
+
<Button size="lg" variant="secondary" className="h-12 px-8" asChild>
|
| 139 |
+
<Link href="/register">Create Your Account</Link>
|
| 140 |
+
</Button>
|
| 141 |
+
</div>
|
| 142 |
+
</section> */}
|
| 143 |
+
</main>
|
| 144 |
+
<Footer />
|
| 145 |
+
</div>
|
| 146 |
+
)
|
| 147 |
+
}
|
Frontend/app/profile/page.tsx
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
import { useAuth } from "@/context/auth-context"
|
| 5 |
+
import { Navbar } from "@/components/layout/navbar"
|
| 6 |
+
import { Footer } from "@/components/layout/footer"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card"
|
| 9 |
+
import { Input } from "@/components/ui/input"
|
| 10 |
+
import { Label } from "@/components/ui/label"
|
| 11 |
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
| 12 |
+
import { useToast } from "@/hooks/use-toast"
|
| 13 |
+
import { redirect } from "next/navigation"
|
| 14 |
+
|
| 15 |
+
export default function ProfilePage() {
|
| 16 |
+
const { user, isLoading, updateUserDetails } = useAuth()
|
| 17 |
+
const { toast } = useToast()
|
| 18 |
+
const [age, setAge] = useState("")
|
| 19 |
+
const [nid, setNid] = useState("")
|
| 20 |
+
const [nidError, setNidError] = useState("")
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
if (user?.details?.age) {
|
| 24 |
+
setAge(user.details.age.toString())
|
| 25 |
+
}
|
| 26 |
+
if (user?.details?.nid) {
|
| 27 |
+
setNid(user.details.nid)
|
| 28 |
+
}
|
| 29 |
+
}, [user])
|
| 30 |
+
|
| 31 |
+
if (isLoading) return null
|
| 32 |
+
if (!user) redirect("/login")
|
| 33 |
+
|
| 34 |
+
// Get the first letter of the user's name
|
| 35 |
+
const getInitial = () => {
|
| 36 |
+
if (user?.name) {
|
| 37 |
+
return user.name.charAt(0).toUpperCase()
|
| 38 |
+
}
|
| 39 |
+
return "U"
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Handle age input - only allow numbers, max 3 digits
|
| 43 |
+
const handleAgeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 44 |
+
const value = e.target.value
|
| 45 |
+
// Only allow numbers and limit to 3 digits
|
| 46 |
+
if (value === "" || (/^\d+$/.test(value) && value.length <= 3)) {
|
| 47 |
+
setAge(value)
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Handle NID input with auto-hyphen formatting (xxx-xxx-xxx-x)
|
| 52 |
+
const handleNidChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 53 |
+
let value = e.target.value
|
| 54 |
+
// Remove all non-digit characters
|
| 55 |
+
value = value.replace(/\D/g, "")
|
| 56 |
+
|
| 57 |
+
// Limit to 10 digits
|
| 58 |
+
if (value.length > 10) {
|
| 59 |
+
value = value.slice(0, 10)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Format with hyphens: xxx-xxx-xxx-x
|
| 63 |
+
let formatted = ""
|
| 64 |
+
if (value.length > 0) {
|
| 65 |
+
formatted = value.slice(0, 3)
|
| 66 |
+
if (value.length > 3) {
|
| 67 |
+
formatted += "-" + value.slice(3, 6)
|
| 68 |
+
}
|
| 69 |
+
if (value.length > 6) {
|
| 70 |
+
formatted += "-" + value.slice(6, 9)
|
| 71 |
+
}
|
| 72 |
+
if (value.length > 9) {
|
| 73 |
+
formatted += "-" + value.slice(9, 10)
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
setNid(formatted)
|
| 78 |
+
|
| 79 |
+
// Validate NID format
|
| 80 |
+
if (formatted && formatted.length < 13) {
|
| 81 |
+
setNidError("NID must be in format: xxx-xxx-xxx-x")
|
| 82 |
+
} else {
|
| 83 |
+
setNidError("")
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Handle form submission
|
| 88 |
+
const handleSave = async () => {
|
| 89 |
+
// Validate NID format before saving
|
| 90 |
+
if (nid && nid.length !== 13) {
|
| 91 |
+
toast({
|
| 92 |
+
title: "Error",
|
| 93 |
+
description: "NID must be in format: xxx-xxx-xxx-x (10 digits)",
|
| 94 |
+
variant: "destructive",
|
| 95 |
+
})
|
| 96 |
+
return
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Validate age
|
| 100 |
+
if (age && (parseInt(age) < 0 || parseInt(age) > 150)) {
|
| 101 |
+
toast({
|
| 102 |
+
title: "Error",
|
| 103 |
+
description: "Please enter a valid age",
|
| 104 |
+
variant: "destructive",
|
| 105 |
+
})
|
| 106 |
+
return
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Update user details in auth context
|
| 110 |
+
const updatedDetails = {
|
| 111 |
+
...user.details,
|
| 112 |
+
nid: nid || user.details?.nid,
|
| 113 |
+
age: age ? parseInt(age) : user.details?.age,
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
await updateUserDetails(updatedDetails)
|
| 117 |
+
|
| 118 |
+
// Here you would typically save to backend
|
| 119 |
+
toast({
|
| 120 |
+
title: "Success",
|
| 121 |
+
description: "Profile updated successfully!",
|
| 122 |
+
})
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return (
|
| 126 |
+
<div className="flex flex-col min-h-screen">
|
| 127 |
+
<Navbar />
|
| 128 |
+
<main className="flex-1 p-4 md:p-8 bg-muted/20">
|
| 129 |
+
<div className="container mx-auto max-w-2xl">
|
| 130 |
+
<div className="mb-8">
|
| 131 |
+
<h1 className="text-3xl font-bold text-primary tracking-tight">Profile</h1>
|
| 132 |
+
<p className="text-muted-foreground">View and manage your personal information</p>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<Card className="border-primary/10">
|
| 136 |
+
<CardHeader>
|
| 137 |
+
<div className="flex flex-col items-center gap-4 text-center">
|
| 138 |
+
<Avatar className="h-32 w-32 border-4 border-primary">
|
| 139 |
+
<AvatarFallback className="bg-primary text-primary-foreground text-5xl">
|
| 140 |
+
{getInitial()}
|
| 141 |
+
</AvatarFallback>
|
| 142 |
+
</Avatar>
|
| 143 |
+
<div>
|
| 144 |
+
<CardTitle className="text-2xl">{user.name}</CardTitle>
|
| 145 |
+
<CardDescription className="text-base mt-1">{user.email}</CardDescription>
|
| 146 |
+
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</CardHeader>
|
| 150 |
+
<CardContent className="space-y-6">
|
| 151 |
+
|
| 152 |
+
<div className="space-y-2">
|
| 153 |
+
<Label htmlFor="age">Age</Label>
|
| 154 |
+
<Input
|
| 155 |
+
id="age"
|
| 156 |
+
type="text"
|
| 157 |
+
value={age}
|
| 158 |
+
onChange={handleAgeChange}
|
| 159 |
+
placeholder="Enter your age"
|
| 160 |
+
className="max-w-xs"
|
| 161 |
+
/>
|
| 162 |
+
<p className="text-xs text-muted-foreground">Enter numbers only</p>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div className="space-y-2">
|
| 166 |
+
<Label htmlFor="nid">National ID (NID)</Label>
|
| 167 |
+
<Input
|
| 168 |
+
id="nid"
|
| 169 |
+
type="text"
|
| 170 |
+
value={nid}
|
| 171 |
+
onChange={handleNidChange}
|
| 172 |
+
placeholder="xxx-xxx-xxx-x"
|
| 173 |
+
maxLength={13}
|
| 174 |
+
/>
|
| 175 |
+
{nidError && (
|
| 176 |
+
<p className="text-xs text-destructive">{nidError}</p>
|
| 177 |
+
)}
|
| 178 |
+
<p className="text-xs text-muted-foreground">Format: xxx-xxx-xxx-x (10 digits)</p>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<div className="pt-4">
|
| 182 |
+
<Button
|
| 183 |
+
onClick={handleSave}
|
| 184 |
+
className="w-full bg-primary hover:bg-primary/90"
|
| 185 |
+
disabled={!!nidError && nid.length > 0}
|
| 186 |
+
>
|
| 187 |
+
Save Changes
|
| 188 |
+
</Button>
|
| 189 |
+
</div>
|
| 190 |
+
</CardContent>
|
| 191 |
+
</Card>
|
| 192 |
+
</div>
|
| 193 |
+
</main>
|
| 194 |
+
<Footer />
|
| 195 |
+
</div>
|
| 196 |
+
)
|
| 197 |
+
}
|
Frontend/app/register/page.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { RegisterForm } from "@/components/auth/register-form"
|
| 2 |
+
import { Navbar } from "@/components/layout/navbar"
|
| 3 |
+
import { Footer } from "@/components/layout/footer"
|
| 4 |
+
|
| 5 |
+
export default function RegisterPage() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="flex flex-col min-h-screen">
|
| 8 |
+
<Navbar />
|
| 9 |
+
<main className="flex-1 flex items-center justify-center p-4 md:p-8 bg-muted/20">
|
| 10 |
+
<div className="w-full max-w-2xl">
|
| 11 |
+
<RegisterForm />
|
| 12 |
+
</div>
|
| 13 |
+
</main>
|
| 14 |
+
<Footer />
|
| 15 |
+
</div>
|
| 16 |
+
)
|
| 17 |
+
}
|
Frontend/components.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "app/globals.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
},
|
| 20 |
+
"iconLibrary": "lucide"
|
| 21 |
+
}
|
Frontend/components/auth/register-form.tsx
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import type React from "react"
|
| 4 |
+
import { useState } from "react"
|
| 5 |
+
import { useRouter } from "next/navigation"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Input } from "@/components/ui/input"
|
| 8 |
+
import { Label } from "@/components/ui/label"
|
| 9 |
+
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card"
|
| 10 |
+
import { useToast } from "@/hooks/use-toast"
|
| 11 |
+
import { Scale, Eye, EyeOff } from "lucide-react"
|
| 12 |
+
import { apiClient } from "@/lib/api-client"
|
| 13 |
+
import { setAuthData } from "@/lib/auth-utils"
|
| 14 |
+
import { useAuth } from "@/context/auth-context"
|
| 15 |
+
|
| 16 |
+
export function RegisterForm() {
|
| 17 |
+
const [formData, setFormData] = useState({
|
| 18 |
+
name: "",
|
| 19 |
+
email: "",
|
| 20 |
+
password: "",
|
| 21 |
+
confirmPassword: "",
|
| 22 |
+
})
|
| 23 |
+
const [showPassword, setShowPassword] = useState(false)
|
| 24 |
+
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
| 25 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 26 |
+
const router = useRouter()
|
| 27 |
+
const { toast } = useToast()
|
| 28 |
+
const { login } = useAuth()
|
| 29 |
+
|
| 30 |
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 31 |
+
setFormData({ ...formData, [e.target.name]: e.target.value })
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 35 |
+
e.preventDefault()
|
| 36 |
+
|
| 37 |
+
// Validate password match
|
| 38 |
+
if (formData.password !== formData.confirmPassword) {
|
| 39 |
+
toast({
|
| 40 |
+
title: "Error",
|
| 41 |
+
description: "Passwords do not match",
|
| 42 |
+
variant: "destructive",
|
| 43 |
+
})
|
| 44 |
+
return
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Validate password length
|
| 48 |
+
if (formData.password.length < 6) {
|
| 49 |
+
toast({
|
| 50 |
+
title: "Error",
|
| 51 |
+
description: "Password must be at least 6 characters long",
|
| 52 |
+
variant: "destructive",
|
| 53 |
+
})
|
| 54 |
+
return
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
setIsLoading(true)
|
| 58 |
+
|
| 59 |
+
try {
|
| 60 |
+
// Call the backend API
|
| 61 |
+
const response = await apiClient.register(
|
| 62 |
+
formData.email,
|
| 63 |
+
formData.password,
|
| 64 |
+
formData.name
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
console.log("Registration response:", response)
|
| 68 |
+
|
| 69 |
+
// Store the access token and user data in localStorage
|
| 70 |
+
if (response.access_token) {
|
| 71 |
+
setAuthData(response.access_token, response.refresh_token, response.user)
|
| 72 |
+
|
| 73 |
+
// Update auth context
|
| 74 |
+
login(response.access_token)
|
| 75 |
+
|
| 76 |
+
console.log("Stored user data:", localStorage.getItem("user"))
|
| 77 |
+
|
| 78 |
+
toast({
|
| 79 |
+
title: "Success",
|
| 80 |
+
description: "Account created successfully! Redirecting...",
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
// Redirect to dashboard or home page after successful registration
|
| 84 |
+
setTimeout(() => {
|
| 85 |
+
router.push("/dashboard")
|
| 86 |
+
}, 1500)
|
| 87 |
+
}
|
| 88 |
+
} catch (error) {
|
| 89 |
+
console.error("Registration error:", error)
|
| 90 |
+
toast({
|
| 91 |
+
title: "Registration Failed",
|
| 92 |
+
description: error instanceof Error ? error.message : "An error occurred during registration",
|
| 93 |
+
variant: "destructive",
|
| 94 |
+
})
|
| 95 |
+
} finally {
|
| 96 |
+
setIsLoading(false)
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return (
|
| 101 |
+
<Card className="w-full max-w-md mx-auto border-primary/20 shadow-xl overflow-hidden">
|
| 102 |
+
<CardHeader className="text-center pt-8">
|
| 103 |
+
<div className="flex justify-center mb-4">
|
| 104 |
+
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
| 105 |
+
<Scale className="h-6 w-6" />
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
<CardTitle className="text-2xl font-bold">Create Your Account</CardTitle>
|
| 109 |
+
<CardDescription>
|
| 110 |
+
Enter your basic information to get started
|
| 111 |
+
</CardDescription>
|
| 112 |
+
</CardHeader>
|
| 113 |
+
|
| 114 |
+
<form onSubmit={handleSubmit}>
|
| 115 |
+
<CardContent className="space-y-4 pt-4">
|
| 116 |
+
<div className="space-y-2">
|
| 117 |
+
<Label htmlFor="name">Full Name</Label>
|
| 118 |
+
<Input
|
| 119 |
+
name="name"
|
| 120 |
+
id="name"
|
| 121 |
+
placeholder="John Doe"
|
| 122 |
+
required
|
| 123 |
+
value={formData.name}
|
| 124 |
+
onChange={handleInputChange}
|
| 125 |
+
/>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<div className="space-y-2">
|
| 129 |
+
<Label htmlFor="email">Email Address</Label>
|
| 130 |
+
<Input
|
| 131 |
+
name="email"
|
| 132 |
+
id="email"
|
| 133 |
+
type="email"
|
| 134 |
+
placeholder="john@example.com"
|
| 135 |
+
required
|
| 136 |
+
value={formData.email}
|
| 137 |
+
onChange={handleInputChange}
|
| 138 |
+
/>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
<div className="space-y-2">
|
| 142 |
+
<Label htmlFor="password">Password</Label>
|
| 143 |
+
<div className="relative">
|
| 144 |
+
<Input
|
| 145 |
+
name="password"
|
| 146 |
+
id="password"
|
| 147 |
+
type={showPassword ? "text" : "password"}
|
| 148 |
+
required
|
| 149 |
+
value={formData.password}
|
| 150 |
+
onChange={handleInputChange}
|
| 151 |
+
className="pr-10"
|
| 152 |
+
/>
|
| 153 |
+
<button
|
| 154 |
+
type="button"
|
| 155 |
+
onClick={() => setShowPassword(!showPassword)}
|
| 156 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
| 157 |
+
>
|
| 158 |
+
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
| 159 |
+
</button>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="space-y-2">
|
| 164 |
+
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
| 165 |
+
<div className="relative">
|
| 166 |
+
<Input
|
| 167 |
+
name="confirmPassword"
|
| 168 |
+
id="confirmPassword"
|
| 169 |
+
type={showConfirmPassword ? "text" : "password"}
|
| 170 |
+
required
|
| 171 |
+
value={formData.confirmPassword}
|
| 172 |
+
onChange={handleInputChange}
|
| 173 |
+
className="pr-10"
|
| 174 |
+
/>
|
| 175 |
+
<button
|
| 176 |
+
type="button"
|
| 177 |
+
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
| 178 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
| 179 |
+
>
|
| 180 |
+
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
| 181 |
+
</button>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
</CardContent>
|
| 185 |
+
|
| 186 |
+
<CardFooter className="flex justify-center pb-8">
|
| 187 |
+
<Button type="submit" className="w-full bg-primary hover:bg-primary/90" disabled={isLoading}>
|
| 188 |
+
{isLoading ? "Creating Account..." : "Create Account"}
|
| 189 |
+
</Button>
|
| 190 |
+
</CardFooter>
|
| 191 |
+
</form>
|
| 192 |
+
</Card>
|
| 193 |
+
)
|
| 194 |
+
}
|
Frontend/components/chatbot/bias-checker.tsx
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useRef } from "react"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import { Textarea } from "@/components/ui/textarea"
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
| 7 |
+
import { Progress } from "@/components/ui/progress"
|
| 8 |
+
import { Badge } from "@/components/ui/badge"
|
| 9 |
+
import { AlertCircle, Info, Search, Upload, X, CheckCircle2, ShieldAlert, FileText, Download, RefreshCw, ThumbsUp, ThumbsDown } from "lucide-react"
|
| 10 |
+
import { cn } from "@/lib/utils"
|
| 11 |
+
import { addDocumentToCache } from "@/lib/document-cache"
|
| 12 |
+
|
| 13 |
+
interface BiasedSentence {
|
| 14 |
+
original: string
|
| 15 |
+
category: string
|
| 16 |
+
confidence: number
|
| 17 |
+
suggestion: string | null
|
| 18 |
+
explanation: string | null
|
| 19 |
+
success: boolean
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface BiasAnalysisResult {
|
| 23 |
+
success: boolean
|
| 24 |
+
biasedCount: number
|
| 25 |
+
unbiasedCount: number
|
| 26 |
+
totalSentences: number
|
| 27 |
+
biasedSentences: BiasedSentence[]
|
| 28 |
+
filename?: string
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// HITL Interfaces
|
| 32 |
+
interface HITLReviewItem {
|
| 33 |
+
sentence_id: string
|
| 34 |
+
original_sentence: string
|
| 35 |
+
is_biased: boolean
|
| 36 |
+
category: string
|
| 37 |
+
confidence: number
|
| 38 |
+
suggestion: string | null
|
| 39 |
+
approved_suggestion: string | null
|
| 40 |
+
status: "pending" | "approved" | "needs_regeneration"
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
interface HITLSessionData {
|
| 44 |
+
success: boolean
|
| 45 |
+
session_id: string
|
| 46 |
+
total_sentences: number
|
| 47 |
+
biased_count: number
|
| 48 |
+
neutral_count: number
|
| 49 |
+
sentences: HITLReviewItem[]
|
| 50 |
+
filename: string
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Category display names in Nepali/English
|
| 54 |
+
const categoryLabels: Record<string, string> = {
|
| 55 |
+
neutral: "Neutral",
|
| 56 |
+
gender: "Gender Bias",
|
| 57 |
+
religional: "Religious Bias",
|
| 58 |
+
caste: "Caste Bias",
|
| 59 |
+
religion: "Religion Bias",
|
| 60 |
+
appearence: "Appearance Bias",
|
| 61 |
+
socialstatus: "Social Status Bias",
|
| 62 |
+
amiguity: "Ambiguity",
|
| 63 |
+
political: "Political Bias",
|
| 64 |
+
Age: "Age Bias",
|
| 65 |
+
Disablity: "Disability Bias",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function BiasChecker() {
|
| 69 |
+
const [text, setText] = useState("")
|
| 70 |
+
const [file, setFile] = useState<File | null>(null)
|
| 71 |
+
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
| 72 |
+
const [result, setResult] = useState<BiasAnalysisResult | null>(null)
|
| 73 |
+
const [dragActive, setDragActive] = useState(false)
|
| 74 |
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
| 75 |
+
|
| 76 |
+
// HITL state
|
| 77 |
+
const [useHITL, setUseHITL] = useState(true) // Enable HITL by default for PDFs
|
| 78 |
+
const [hitlSession, setHitlSession] = useState<HITLSessionData | null>(null)
|
| 79 |
+
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false)
|
| 80 |
+
|
| 81 |
+
const handleDrag = (e: React.DragEvent) => {
|
| 82 |
+
e.preventDefault()
|
| 83 |
+
e.stopPropagation()
|
| 84 |
+
if (e.type === "dragenter" || e.type === "dragover") {
|
| 85 |
+
setDragActive(true)
|
| 86 |
+
} else if (e.type === "dragleave") {
|
| 87 |
+
setDragActive(false)
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 92 |
+
e.preventDefault()
|
| 93 |
+
setDragActive(false)
|
| 94 |
+
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
| 95 |
+
const selectedFile = e.dataTransfer.files[0]
|
| 96 |
+
if (selectedFile.type === "application/pdf" || selectedFile.name.endsWith(".pdf")) {
|
| 97 |
+
setFile(selectedFile)
|
| 98 |
+
setText("") // Clear text input when file is uploaded
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 104 |
+
if (e.target.files && e.target.files[0]) {
|
| 105 |
+
setFile(e.target.files[0])
|
| 106 |
+
setText("") // Clear text input when file is uploaded
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const removeFile = () => {
|
| 111 |
+
setFile(null)
|
| 112 |
+
if (fileInputRef.current) fileInputRef.current.value = ""
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const analyzeText = async () => {
|
| 116 |
+
if (!text.trim() && !file) return
|
| 117 |
+
setIsAnalyzing(true)
|
| 118 |
+
setResult(null)
|
| 119 |
+
setHitlSession(null)
|
| 120 |
+
|
| 121 |
+
try {
|
| 122 |
+
const formData = new FormData()
|
| 123 |
+
|
| 124 |
+
if (file) {
|
| 125 |
+
formData.append("file", file)
|
| 126 |
+
formData.append("use_hitl", useHITL ? "true" : "false")
|
| 127 |
+
} else {
|
| 128 |
+
formData.append("text", text)
|
| 129 |
+
formData.append("use_hitl", "false")
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
formData.append("confidence_threshold", "0.7")
|
| 133 |
+
|
| 134 |
+
const token = localStorage.getItem("access_token")
|
| 135 |
+
const headers: Record<string, string> = {}
|
| 136 |
+
if (token) {
|
| 137 |
+
headers["Authorization"] = `Bearer ${token}`
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const response = await fetch("/api/bias-detection", {
|
| 141 |
+
method: "POST",
|
| 142 |
+
headers,
|
| 143 |
+
body: formData,
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
if (!response.ok) {
|
| 147 |
+
throw new Error("Failed to analyze bias")
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const data = await response.json()
|
| 151 |
+
|
| 152 |
+
// Check if response is HITL session data
|
| 153 |
+
if (data.session_id && data.sentences) {
|
| 154 |
+
setHitlSession(data as HITLSessionData)
|
| 155 |
+
|
| 156 |
+
// Cache the analyzed document
|
| 157 |
+
if (file) {
|
| 158 |
+
addDocumentToCache({
|
| 159 |
+
filename: data.filename || file.name,
|
| 160 |
+
type: "bias-detection",
|
| 161 |
+
result: {
|
| 162 |
+
totalSentences: data.total_sentences,
|
| 163 |
+
biasedCount: data.biased_count,
|
| 164 |
+
neutralCount: data.neutral_count,
|
| 165 |
+
success: data.success,
|
| 166 |
+
},
|
| 167 |
+
sessionId: data.session_id,
|
| 168 |
+
})
|
| 169 |
+
}
|
| 170 |
+
} else {
|
| 171 |
+
setResult(data as BiasAnalysisResult)
|
| 172 |
+
|
| 173 |
+
// Cache the analyzed document
|
| 174 |
+
if (file) {
|
| 175 |
+
addDocumentToCache({
|
| 176 |
+
filename: data.filename || file.name,
|
| 177 |
+
type: "bias-detection",
|
| 178 |
+
result: {
|
| 179 |
+
totalSentences: data.totalSentences,
|
| 180 |
+
biasedCount: data.biasedCount,
|
| 181 |
+
neutralCount: data.unbiasedCount,
|
| 182 |
+
success: data.success,
|
| 183 |
+
},
|
| 184 |
+
})
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
} catch (error) {
|
| 188 |
+
console.error("[Bias Detection Error]:", error)
|
| 189 |
+
alert("Failed to analyze bias. Please ensure the backend server is running.")
|
| 190 |
+
} finally {
|
| 191 |
+
setIsAnalyzing(false)
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
const handleApproveSuggestion = async (sentenceId: string, suggestion: string) => {
|
| 196 |
+
if (!hitlSession) return
|
| 197 |
+
|
| 198 |
+
try {
|
| 199 |
+
const token = localStorage.getItem("access_token")
|
| 200 |
+
const headers: Record<string, string> = {
|
| 201 |
+
"Content-Type": "application/json"
|
| 202 |
+
}
|
| 203 |
+
if (token) {
|
| 204 |
+
headers["Authorization"] = `Bearer ${token}`
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const response = await fetch("/api/bias-detection-hitl/approve", {
|
| 208 |
+
method: "POST",
|
| 209 |
+
headers,
|
| 210 |
+
body: JSON.stringify({
|
| 211 |
+
session_id: hitlSession.session_id,
|
| 212 |
+
sentence_id: sentenceId,
|
| 213 |
+
action: "approve",
|
| 214 |
+
approved_suggestion: suggestion
|
| 215 |
+
})
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
if (!response.ok) {
|
| 219 |
+
throw new Error("Failed to approve suggestion")
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// Update local state
|
| 223 |
+
setHitlSession({
|
| 224 |
+
...hitlSession,
|
| 225 |
+
sentences: hitlSession.sentences.map(s =>
|
| 226 |
+
s.sentence_id === sentenceId
|
| 227 |
+
? { ...s, status: "approved", approved_suggestion: suggestion }
|
| 228 |
+
: s
|
| 229 |
+
)
|
| 230 |
+
})
|
| 231 |
+
} catch (error) {
|
| 232 |
+
console.error("[Approve Error]:", error)
|
| 233 |
+
alert("Failed to approve suggestion")
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
const handleRejectSuggestion = async (sentenceId: string) => {
|
| 238 |
+
if (!hitlSession) return
|
| 239 |
+
|
| 240 |
+
try {
|
| 241 |
+
const token = localStorage.getItem("access_token")
|
| 242 |
+
const headers: Record<string, string> = {
|
| 243 |
+
"Content-Type": "application/json"
|
| 244 |
+
}
|
| 245 |
+
if (token) {
|
| 246 |
+
headers["Authorization"] = `Bearer ${token}`
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
const response = await fetch("/api/bias-detection-hitl/approve", {
|
| 250 |
+
method: "POST",
|
| 251 |
+
headers,
|
| 252 |
+
body: JSON.stringify({
|
| 253 |
+
session_id: hitlSession.session_id,
|
| 254 |
+
sentence_id: sentenceId,
|
| 255 |
+
action: "reject"
|
| 256 |
+
})
|
| 257 |
+
})
|
| 258 |
+
|
| 259 |
+
if (!response.ok) {
|
| 260 |
+
throw new Error("Failed to reject suggestion")
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// Update local state
|
| 264 |
+
setHitlSession({
|
| 265 |
+
...hitlSession,
|
| 266 |
+
sentences: hitlSession.sentences.map(s =>
|
| 267 |
+
s.sentence_id === sentenceId
|
| 268 |
+
? { ...s, status: "needs_regeneration" }
|
| 269 |
+
: s
|
| 270 |
+
)
|
| 271 |
+
})
|
| 272 |
+
} catch (error) {
|
| 273 |
+
console.error("[Reject Error]:", error)
|
| 274 |
+
alert("Failed to reject suggestion")
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
const handleRegenerateSuggestion = async (sentenceId: string) => {
|
| 279 |
+
if (!hitlSession) return
|
| 280 |
+
|
| 281 |
+
try {
|
| 282 |
+
const token = localStorage.getItem("access_token")
|
| 283 |
+
const headers: Record<string, string> = {
|
| 284 |
+
"Content-Type": "application/json"
|
| 285 |
+
}
|
| 286 |
+
if (token) {
|
| 287 |
+
headers["Authorization"] = `Bearer ${token}`
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
const response = await fetch("/api/bias-detection-hitl/regenerate", {
|
| 291 |
+
method: "POST",
|
| 292 |
+
headers,
|
| 293 |
+
body: JSON.stringify({
|
| 294 |
+
session_id: hitlSession.session_id,
|
| 295 |
+
sentence_id: sentenceId
|
| 296 |
+
})
|
| 297 |
+
})
|
| 298 |
+
|
| 299 |
+
if (!response.ok) {
|
| 300 |
+
throw new Error("Failed to regenerate suggestion")
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
const data = await response.json()
|
| 304 |
+
|
| 305 |
+
// Update local state with new suggestion
|
| 306 |
+
setHitlSession({
|
| 307 |
+
...hitlSession,
|
| 308 |
+
sentences: hitlSession.sentences.map(s =>
|
| 309 |
+
s.sentence_id === sentenceId
|
| 310 |
+
? { ...s, suggestion: data.new_suggestion, status: "pending" }
|
| 311 |
+
: s
|
| 312 |
+
)
|
| 313 |
+
})
|
| 314 |
+
} catch (error) {
|
| 315 |
+
console.error("[Regenerate Error]:", error)
|
| 316 |
+
alert("Failed to regenerate suggestion")
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
const handleGeneratePDF = async () => {
|
| 321 |
+
if (!hitlSession) return
|
| 322 |
+
|
| 323 |
+
setIsGeneratingPDF(true)
|
| 324 |
+
try {
|
| 325 |
+
const token = localStorage.getItem("access_token")
|
| 326 |
+
const headers: Record<string, string> = {
|
| 327 |
+
"Content-Type": "application/json"
|
| 328 |
+
}
|
| 329 |
+
if (token) {
|
| 330 |
+
headers["Authorization"] = `Bearer ${token}`
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
const response = await fetch("/api/bias-detection-hitl/generate-pdf", {
|
| 334 |
+
method: "POST",
|
| 335 |
+
headers,
|
| 336 |
+
body: JSON.stringify({
|
| 337 |
+
session_id: hitlSession.session_id
|
| 338 |
+
})
|
| 339 |
+
})
|
| 340 |
+
|
| 341 |
+
if (!response.ok) {
|
| 342 |
+
const errorData = await response.json().catch(() => ({}))
|
| 343 |
+
throw new Error(errorData.error || "Failed to generate PDF")
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
// Download the PDF
|
| 347 |
+
const blob = await response.blob()
|
| 348 |
+
const url = window.URL.createObjectURL(blob)
|
| 349 |
+
const link = document.createElement("a")
|
| 350 |
+
link.href = url
|
| 351 |
+
|
| 352 |
+
const contentDisposition = response.headers.get("Content-Disposition")
|
| 353 |
+
const filename = contentDisposition
|
| 354 |
+
? contentDisposition.split("filename=")[1]?.replace(/"/g, "")
|
| 355 |
+
: `debiased_${hitlSession.filename}`
|
| 356 |
+
|
| 357 |
+
link.setAttribute("download", filename)
|
| 358 |
+
document.body.appendChild(link)
|
| 359 |
+
link.click()
|
| 360 |
+
link.remove()
|
| 361 |
+
window.URL.revokeObjectURL(url)
|
| 362 |
+
|
| 363 |
+
alert("PDF generated successfully!")
|
| 364 |
+
} catch (error) {
|
| 365 |
+
console.error("[Generate PDF Error]:", error)
|
| 366 |
+
alert(error instanceof Error ? error.message : "Failed to generate PDF")
|
| 367 |
+
} finally {
|
| 368 |
+
setIsGeneratingPDF(false)
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
const getPendingCount = () => {
|
| 373 |
+
if (!hitlSession) return 0
|
| 374 |
+
return hitlSession.sentences.filter(s => s.status === "pending" && s.is_biased).length
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
const getNeedsRegenerationCount = () => {
|
| 378 |
+
if (!hitlSession) return 0
|
| 379 |
+
return hitlSession.sentences.filter(s => s.status === "needs_regeneration").length
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
const isReadyForPDF = () => {
|
| 383 |
+
return getPendingCount() === 0 && getNeedsRegenerationCount() === 0
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
const calculateScore = (result: BiasAnalysisResult) => {
|
| 387 |
+
if (result.totalSentences === 0) return 100
|
| 388 |
+
return Math.round((result.unbiasedCount / result.totalSentences) * 100)
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
return (
|
| 392 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
| 393 |
+
{/* Input Side */}
|
| 394 |
+
<div className="space-y-6">
|
| 395 |
+
<Card className="border-primary/10 shadow-lg">
|
| 396 |
+
<CardHeader>
|
| 397 |
+
<CardTitle>Nepali Bias Checker</CardTitle>
|
| 398 |
+
<CardDescription>
|
| 399 |
+
Paste Nepali text or upload a PDF document to check for linguistic biases.
|
| 400 |
+
</CardDescription>
|
| 401 |
+
</CardHeader>
|
| 402 |
+
<CardContent className="space-y-6">
|
| 403 |
+
<div className="space-y-4">
|
| 404 |
+
<Textarea
|
| 405 |
+
placeholder="नेपाली पाठ यहाँ पेस्ट गर्नुहोस्... (Paste Nepali text here...)"
|
| 406 |
+
className="min-h-[300px] resize-none border-primary/10 focus-visible:ring-primary font-[system-ui]"
|
| 407 |
+
value={text}
|
| 408 |
+
onChange={(e) => {
|
| 409 |
+
setText(e.target.value)
|
| 410 |
+
if (e.target.value.trim()) setFile(null) // Clear file when typing
|
| 411 |
+
}}
|
| 412 |
+
disabled={!!file}
|
| 413 |
+
/>
|
| 414 |
+
<div className="flex items-center gap-4">
|
| 415 |
+
<div className="h-px flex-1 bg-border" />
|
| 416 |
+
<span className="text-xs text-muted-foreground font-medium uppercase tracking-widest">or</span>
|
| 417 |
+
<div className="h-px flex-1 bg-border" />
|
| 418 |
+
</div>
|
| 419 |
+
|
| 420 |
+
{/* File Upload */}
|
| 421 |
+
<div className="relative">
|
| 422 |
+
<input
|
| 423 |
+
ref={fileInputRef}
|
| 424 |
+
type="file"
|
| 425 |
+
accept=".pdf"
|
| 426 |
+
onChange={handleFileChange}
|
| 427 |
+
className="hidden"
|
| 428 |
+
id="file-upload"
|
| 429 |
+
/>
|
| 430 |
+
<label
|
| 431 |
+
htmlFor="file-upload"
|
| 432 |
+
onDragEnter={handleDrag}
|
| 433 |
+
onDragLeave={handleDrag}
|
| 434 |
+
onDragOver={handleDrag}
|
| 435 |
+
onDrop={handleDrop}
|
| 436 |
+
className={cn(
|
| 437 |
+
"flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-xl cursor-pointer transition-all duration-200",
|
| 438 |
+
dragActive
|
| 439 |
+
? "border-primary bg-primary/5 scale-[1.01]"
|
| 440 |
+
: "border-muted hover:border-primary/50 hover:bg-muted/30",
|
| 441 |
+
file ? "border-success/50 bg-success/5" : ""
|
| 442 |
+
)}
|
| 443 |
+
>
|
| 444 |
+
{file ? (
|
| 445 |
+
<div className="flex flex-col items-center gap-2">
|
| 446 |
+
<CheckCircle2 className="h-10 w-10 text-success" />
|
| 447 |
+
<div className="flex items-center gap-2">
|
| 448 |
+
<FileText className="h-4 w-4" />
|
| 449 |
+
<p className="text-sm font-medium">{file.name}</p>
|
| 450 |
+
</div>
|
| 451 |
+
<Button
|
| 452 |
+
variant="ghost"
|
| 453 |
+
size="sm"
|
| 454 |
+
className="h-7 text-xs"
|
| 455 |
+
onClick={(e) => {
|
| 456 |
+
e.preventDefault()
|
| 457 |
+
removeFile()
|
| 458 |
+
}}
|
| 459 |
+
>
|
| 460 |
+
<X className="h-3 w-3 mr-1" /> Remove
|
| 461 |
+
</Button>
|
| 462 |
+
</div>
|
| 463 |
+
) : (
|
| 464 |
+
<div className="flex flex-col items-center gap-2 p-6 text-center">
|
| 465 |
+
<Upload className="h-10 w-10 text-muted-foreground mb-2" />
|
| 466 |
+
<p className="text-sm font-semibold">Click or drag PDF to upload</p>
|
| 467 |
+
<p className="text-xs text-muted-foreground">Nepali PDF documents supported</p>
|
| 468 |
+
</div>
|
| 469 |
+
)}
|
| 470 |
+
</label>
|
| 471 |
+
</div>
|
| 472 |
+
</div>
|
| 473 |
+
</CardContent>
|
| 474 |
+
<CardContent className="pt-0">
|
| 475 |
+
<Button
|
| 476 |
+
className="w-full h-12 text-lg bg-primary hover:bg-primary/90"
|
| 477 |
+
onClick={analyzeText}
|
| 478 |
+
disabled={isAnalyzing || (!text.trim() && !file)}
|
| 479 |
+
>
|
| 480 |
+
{isAnalyzing ? (
|
| 481 |
+
<>
|
| 482 |
+
<Search className="mr-2 h-5 w-5 animate-spin" />
|
| 483 |
+
Analyzing Document...
|
| 484 |
+
</>
|
| 485 |
+
) : (
|
| 486 |
+
<>
|
| 487 |
+
<ShieldAlert className="mr-2 h-5 w-5" />
|
| 488 |
+
Analyze for Bias
|
| 489 |
+
</>
|
| 490 |
+
)}
|
| 491 |
+
</Button>
|
| 492 |
+
</CardContent>
|
| 493 |
+
</Card>
|
| 494 |
+
</div>
|
| 495 |
+
|
| 496 |
+
{/* Results Side */}
|
| 497 |
+
<div className="space-y-6 sticky top-24">
|
| 498 |
+
{hitlSession ? (
|
| 499 |
+
<div className="space-y-6 animate-in fade-in slide-in-from-right-8 duration-500">
|
| 500 |
+
<Card className="border-primary/20 shadow-xl overflow-hidden">
|
| 501 |
+
<div className="h-2 w-full bg-primary" />
|
| 502 |
+
<CardHeader className="pb-2">
|
| 503 |
+
<div className="flex justify-between items-center mb-4">
|
| 504 |
+
<CardTitle>HITL Review Session</CardTitle>
|
| 505 |
+
<Badge variant="outline" className="text-lg py-1 px-3">
|
| 506 |
+
{hitlSession.biased_count} Biased
|
| 507 |
+
</Badge>
|
| 508 |
+
</div>
|
| 509 |
+
<Progress
|
| 510 |
+
value={((hitlSession.total_sentences - getPendingCount() - getNeedsRegenerationCount()) / hitlSession.total_sentences) * 100}
|
| 511 |
+
className="h-3"
|
| 512 |
+
/>
|
| 513 |
+
<div className="flex justify-between text-xs text-muted-foreground font-bold pt-2">
|
| 514 |
+
<span>Pending: {getPendingCount()}</span>
|
| 515 |
+
<span>Needs Regen: {getNeedsRegenerationCount()}</span>
|
| 516 |
+
<span>Total: {hitlSession.total_sentences}</span>
|
| 517 |
+
</div>
|
| 518 |
+
</CardHeader>
|
| 519 |
+
<CardContent className="space-y-4 pt-4">
|
| 520 |
+
<div className="p-4 rounded-xl bg-muted/50 border border-primary/5">
|
| 521 |
+
<h4 className="flex items-center gap-2 text-sm font-bold mb-2">
|
| 522 |
+
<Info className="h-4 w-4 text-primary" />
|
| 523 |
+
Session Info
|
| 524 |
+
</h4>
|
| 525 |
+
<div className="space-y-2 text-sm">
|
| 526 |
+
<p>Session ID: <code className="text-xs bg-muted px-1 py-0.5 rounded">{hitlSession.session_id}</code></p>
|
| 527 |
+
<p>File: {hitlSession.filename}</p>
|
| 528 |
+
<p>Total Sentences: {hitlSession.total_sentences}</p>
|
| 529 |
+
<p className="text-destructive font-semibold">Biased: {hitlSession.biased_count}</p>
|
| 530 |
+
<p className="text-success font-semibold">Neutral: {hitlSession.neutral_count}</p>
|
| 531 |
+
</div>
|
| 532 |
+
</div>
|
| 533 |
+
|
| 534 |
+
{/* Biased Sentences Review */}
|
| 535 |
+
<div className="space-y-4">
|
| 536 |
+
<h4 className="text-sm font-bold flex items-center gap-2">
|
| 537 |
+
<AlertCircle className="h-4 w-4 text-destructive" />
|
| 538 |
+
Review Biased Sentences
|
| 539 |
+
</h4>
|
| 540 |
+
<div className="space-y-4 max-h-[500px] overflow-y-auto pr-2">
|
| 541 |
+
{hitlSession.sentences
|
| 542 |
+
.filter(s => s.is_biased)
|
| 543 |
+
.map((item) => (
|
| 544 |
+
<div key={item.sentence_id} className="p-4 rounded-xl border bg-background space-y-3 shadow-sm">
|
| 545 |
+
<div className="space-y-2">
|
| 546 |
+
<div className="flex items-center gap-2 flex-wrap mb-2">
|
| 547 |
+
<Badge variant="destructive" className="text-xs">
|
| 548 |
+
{categoryLabels[item.category] || item.category}
|
| 549 |
+
</Badge>
|
| 550 |
+
<Badge variant="outline" className="text-xs">
|
| 551 |
+
{Math.round(item.confidence * 100)}% confidence
|
| 552 |
+
</Badge>
|
| 553 |
+
<Badge
|
| 554 |
+
variant={item.status === "approved" ? "default" : item.status === "needs_regeneration" ? "destructive" : "secondary"}
|
| 555 |
+
className="text-xs"
|
| 556 |
+
>
|
| 557 |
+
{item.status}
|
| 558 |
+
</Badge>
|
| 559 |
+
</div>
|
| 560 |
+
|
| 561 |
+
{/* Original Sentence */}
|
| 562 |
+
<div>
|
| 563 |
+
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
|
| 564 |
+
Original (Biased):
|
| 565 |
+
</p>
|
| 566 |
+
<p className="text-sm font-medium leading-relaxed font-[system-ui] bg-destructive/5 p-2 rounded border border-destructive/20">
|
| 567 |
+
{item.original_sentence}
|
| 568 |
+
</p>
|
| 569 |
+
</div>
|
| 570 |
+
|
| 571 |
+
{/* Suggestion */}
|
| 572 |
+
{item.suggestion && (
|
| 573 |
+
<div>
|
| 574 |
+
<p className="text-xs font-bold text-success uppercase tracking-wider mb-1">
|
| 575 |
+
Suggested (Unbiased):
|
| 576 |
+
</p>
|
| 577 |
+
<p className="text-sm font-medium leading-relaxed font-[system-ui] bg-success/5 p-2 rounded border border-success/20">
|
| 578 |
+
{item.suggestion}
|
| 579 |
+
</p>
|
| 580 |
+
</div>
|
| 581 |
+
)}
|
| 582 |
+
|
| 583 |
+
{/* Action Buttons */}
|
| 584 |
+
{item.status === "pending" && item.suggestion && (
|
| 585 |
+
<div className="flex gap-2 pt-2">
|
| 586 |
+
<Button
|
| 587 |
+
size="sm"
|
| 588 |
+
variant="default"
|
| 589 |
+
className="flex-1"
|
| 590 |
+
onClick={() => handleApproveSuggestion(item.sentence_id, item.suggestion!)}
|
| 591 |
+
>
|
| 592 |
+
<ThumbsUp className="h-4 w-4 mr-1" />
|
| 593 |
+
Approve
|
| 594 |
+
</Button>
|
| 595 |
+
<Button
|
| 596 |
+
size="sm"
|
| 597 |
+
variant="destructive"
|
| 598 |
+
className="flex-1"
|
| 599 |
+
onClick={() => handleRejectSuggestion(item.sentence_id)}
|
| 600 |
+
>
|
| 601 |
+
<ThumbsDown className="h-4 w-4 mr-1" />
|
| 602 |
+
Reject
|
| 603 |
+
</Button>
|
| 604 |
+
</div>
|
| 605 |
+
)}
|
| 606 |
+
|
| 607 |
+
{item.status === "needs_regeneration" && (
|
| 608 |
+
<div className="pt-2">
|
| 609 |
+
<Button
|
| 610 |
+
size="sm"
|
| 611 |
+
variant="outline"
|
| 612 |
+
className="w-full"
|
| 613 |
+
onClick={() => handleRegenerateSuggestion(item.sentence_id)}
|
| 614 |
+
>
|
| 615 |
+
<RefreshCw className="h-4 w-4 mr-1" />
|
| 616 |
+
Regenerate Suggestion
|
| 617 |
+
</Button>
|
| 618 |
+
</div>
|
| 619 |
+
)}
|
| 620 |
+
|
| 621 |
+
{item.status === "approved" && (
|
| 622 |
+
<div className="pt-2">
|
| 623 |
+
<div className="flex items-center gap-2 text-success text-sm">
|
| 624 |
+
<CheckCircle2 className="h-4 w-4" />
|
| 625 |
+
<span className="font-semibold">Approved</span>
|
| 626 |
+
</div>
|
| 627 |
+
{item.approved_suggestion && (
|
| 628 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 629 |
+
Approved: {item.approved_suggestion}
|
| 630 |
+
</p>
|
| 631 |
+
)}
|
| 632 |
+
</div>
|
| 633 |
+
)}
|
| 634 |
+
</div>
|
| 635 |
+
</div>
|
| 636 |
+
))}
|
| 637 |
+
</div>
|
| 638 |
+
</div>
|
| 639 |
+
|
| 640 |
+
{/* Generate PDF Button */}
|
| 641 |
+
<div className="pt-4">
|
| 642 |
+
<Button
|
| 643 |
+
className="w-full h-12 text-lg"
|
| 644 |
+
onClick={handleGeneratePDF}
|
| 645 |
+
disabled={!isReadyForPDF() || isGeneratingPDF}
|
| 646 |
+
>
|
| 647 |
+
{isGeneratingPDF ? (
|
| 648 |
+
<>
|
| 649 |
+
<RefreshCw className="mr-2 h-5 w-5 animate-spin" />
|
| 650 |
+
Generating PDF...
|
| 651 |
+
</>
|
| 652 |
+
) : !isReadyForPDF() ? (
|
| 653 |
+
<>
|
| 654 |
+
<AlertCircle className="mr-2 h-5 w-5" />
|
| 655 |
+
Review All Sentences First
|
| 656 |
+
</>
|
| 657 |
+
) : (
|
| 658 |
+
<>
|
| 659 |
+
<Download className="mr-2 h-5 w-5" />
|
| 660 |
+
Generate Debiased PDF
|
| 661 |
+
</>
|
| 662 |
+
)}
|
| 663 |
+
</Button>
|
| 664 |
+
{!isReadyForPDF() && (
|
| 665 |
+
<p className="text-xs text-center text-muted-foreground mt-2">
|
| 666 |
+
{getPendingCount()} pending, {getNeedsRegenerationCount()} need regeneration
|
| 667 |
+
</p>
|
| 668 |
+
)}
|
| 669 |
+
</div>
|
| 670 |
+
</CardContent>
|
| 671 |
+
</Card>
|
| 672 |
+
</div>
|
| 673 |
+
) : result ? (
|
| 674 |
+
<div className="space-y-6 animate-in fade-in slide-in-from-right-8 duration-500">
|
| 675 |
+
<Card className="border-primary/20 shadow-xl overflow-hidden">
|
| 676 |
+
<div
|
| 677 |
+
className={cn(
|
| 678 |
+
"h-2 w-full",
|
| 679 |
+
calculateScore(result) > 70
|
| 680 |
+
? "bg-success"
|
| 681 |
+
: calculateScore(result) > 40
|
| 682 |
+
? "bg-accent"
|
| 683 |
+
: "bg-destructive"
|
| 684 |
+
)}
|
| 685 |
+
/>
|
| 686 |
+
<CardHeader className="pb-2">
|
| 687 |
+
<div className="flex justify-between items-center mb-4">
|
| 688 |
+
<CardTitle>Analysis Results</CardTitle>
|
| 689 |
+
<Badge variant="outline" className="text-lg py-1 px-3">
|
| 690 |
+
Score: {calculateScore(result)}/100
|
| 691 |
+
</Badge>
|
| 692 |
+
</div>
|
| 693 |
+
<Progress value={calculateScore(result)} className="h-3" />
|
| 694 |
+
<div className="flex justify-between text-[10px] text-muted-foreground font-bold pt-2 uppercase tracking-tighter">
|
| 695 |
+
<span>Critical Bias</span>
|
| 696 |
+
<span>Moderate</span>
|
| 697 |
+
<span>Inclusive</span>
|
| 698 |
+
</div>
|
| 699 |
+
</CardHeader>
|
| 700 |
+
<CardContent className="space-y-4 pt-4">
|
| 701 |
+
<div className="p-4 rounded-xl bg-muted/50 border border-primary/5">
|
| 702 |
+
<h4 className="flex items-center gap-2 text-sm font-bold mb-2">
|
| 703 |
+
<Info className="h-4 w-4 text-primary" />
|
| 704 |
+
Summary
|
| 705 |
+
</h4>
|
| 706 |
+
<div className="space-y-2">
|
| 707 |
+
<p className="text-base leading-relaxed font-semibold">
|
| 708 |
+
Biased sentences = <span className="text-destructive">{result.biasedCount}</span>
|
| 709 |
+
</p>
|
| 710 |
+
<p className="text-base leading-relaxed font-semibold">
|
| 711 |
+
Unbiased sentences = <span className="text-success">{result.unbiasedCount}</span>
|
| 712 |
+
</p>
|
| 713 |
+
{result.filename && (
|
| 714 |
+
<p className="text-xs text-muted-foreground mt-2">File: {result.filename}</p>
|
| 715 |
+
)}
|
| 716 |
+
</div>
|
| 717 |
+
</div>
|
| 718 |
+
|
| 719 |
+
{result.biasedSentences.length > 0 && (
|
| 720 |
+
<div className="space-y-4">
|
| 721 |
+
<h4 className="text-sm font-bold flex items-center gap-2">
|
| 722 |
+
<AlertCircle className="h-4 w-4 text-destructive" />
|
| 723 |
+
Biased Sentences with Suggestions
|
| 724 |
+
</h4>
|
| 725 |
+
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2">
|
| 726 |
+
{result.biasedSentences.map((item, idx) => (
|
| 727 |
+
<div key={idx} className="p-4 rounded-xl border bg-background space-y-3 shadow-sm">
|
| 728 |
+
<div className="space-y-2">
|
| 729 |
+
<div className="flex items-center gap-2 flex-wrap mb-2">
|
| 730 |
+
<Badge variant="destructive" className="text-xs">
|
| 731 |
+
{categoryLabels[item.category] || item.category}
|
| 732 |
+
</Badge>
|
| 733 |
+
<Badge variant="outline" className="text-xs">
|
| 734 |
+
{Math.round(item.confidence * 100)}% confidence
|
| 735 |
+
</Badge>
|
| 736 |
+
</div>
|
| 737 |
+
<div>
|
| 738 |
+
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
|
| 739 |
+
Original (Biased):
|
| 740 |
+
</p>
|
| 741 |
+
<p className="text-sm font-medium leading-relaxed font-[system-ui] bg-destructive/5 p-2 rounded border border-destructive/20">
|
| 742 |
+
{item.original}
|
| 743 |
+
</p>
|
| 744 |
+
</div>
|
| 745 |
+
{item.explanation && (
|
| 746 |
+
<div>
|
| 747 |
+
<p className="text-xs font-bold text-amber-600 uppercase tracking-wider mb-1">
|
| 748 |
+
Why is this biased?
|
| 749 |
+
</p>
|
| 750 |
+
<p className="text-xs leading-relaxed bg-amber-50 dark:bg-amber-950/20 p-2 rounded border border-amber-200 dark:border-amber-800 text-amber-900 dark:text-amber-100">
|
| 751 |
+
{item.explanation}
|
| 752 |
+
</p>
|
| 753 |
+
</div>
|
| 754 |
+
)}
|
| 755 |
+
{item.suggestion && item.success ? (
|
| 756 |
+
<div>
|
| 757 |
+
<p className="text-xs font-bold text-success uppercase tracking-wider mb-1">
|
| 758 |
+
Suggested (Unbiased):
|
| 759 |
+
</p>
|
| 760 |
+
<p className="text-sm font-medium leading-relaxed font-[system-ui] bg-success/5 p-2 rounded border border-success/20">
|
| 761 |
+
{item.suggestion}
|
| 762 |
+
</p>
|
| 763 |
+
</div>
|
| 764 |
+
) : (
|
| 765 |
+
<div>
|
| 766 |
+
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
|
| 767 |
+
Suggestion:
|
| 768 |
+
</p>
|
| 769 |
+
<p className="text-xs text-muted-foreground italic">
|
| 770 |
+
Suggestion not available
|
| 771 |
+
</p>
|
| 772 |
+
</div>
|
| 773 |
+
)}
|
| 774 |
+
</div>
|
| 775 |
+
</div>
|
| 776 |
+
))}
|
| 777 |
+
</div>
|
| 778 |
+
</div>
|
| 779 |
+
)}
|
| 780 |
+
|
| 781 |
+
{result.biasedSentences.length === 0 && (
|
| 782 |
+
<div className="p-6 rounded-xl border border-success/20 bg-success/5 text-center">
|
| 783 |
+
<CheckCircle2 className="h-12 w-12 text-success mx-auto mb-2" />
|
| 784 |
+
<p className="font-semibold text-success">No biases detected!</p>
|
| 785 |
+
<p className="text-xs text-muted-foreground mt-1">The text appears to be neutral and inclusive.</p>
|
| 786 |
+
</div>
|
| 787 |
+
)}
|
| 788 |
+
</CardContent>
|
| 789 |
+
</Card>
|
| 790 |
+
</div>
|
| 791 |
+
) : (
|
| 792 |
+
<div className="h-full min-h-[500px] flex flex-col items-center justify-center text-center p-8 rounded-3xl border-2 border-dashed bg-muted/10 opacity-60">
|
| 793 |
+
<div className="h-20 w-20 rounded-full bg-muted flex items-center justify-center mb-6">
|
| 794 |
+
<Search className="h-10 w-10 text-muted-foreground/50" />
|
| 795 |
+
</div>
|
| 796 |
+
<h3 className="text-xl font-bold mb-2 text-muted-foreground">Waiting for analysis</h3>
|
| 797 |
+
<p className="max-w-[300px] text-sm text-muted-foreground">
|
| 798 |
+
Enter Nepali text or upload a PDF on the left and click "Analyze for Bias" to see linguistic insights.
|
| 799 |
+
</p>
|
| 800 |
+
</div>
|
| 801 |
+
)}
|
| 802 |
+
</div>
|
| 803 |
+
</div>
|
| 804 |
+
)
|
| 805 |
+
}
|
Frontend/components/chatbot/bias-checker.tsx.backup
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState } from "react"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import { Textarea } from "@/components/ui/textarea"
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
| 7 |
+
import { Progress } from "@/components/ui/progress"
|
| 8 |
+
import { Badge } from "@/components/ui/badge"
|
| 9 |
+
import { FileUpload } from "@/components/common/file-upload"
|
| 10 |
+
import { AlertCircle, Info, Search, History, ArrowRight, ShieldAlert } from "lucide-react"
|
| 11 |
+
import { cn } from "@/lib/utils"
|
| 12 |
+
|
| 13 |
+
interface BiasResult {
|
| 14 |
+
score: number
|
| 15 |
+
problematicPhrases: { phrase: string; reason: string; recommendation: string }[]
|
| 16 |
+
summary: string
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function BiasChecker() {
|
| 20 |
+
const [text, setText] = useState("")
|
| 21 |
+
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
| 22 |
+
const [result, setResult] = useState<BiasResult | null>(null)
|
| 23 |
+
|
| 24 |
+
const analyzeText = () => {
|
| 25 |
+
if (!text.trim()) return
|
| 26 |
+
setIsAnalyzing(true)
|
| 27 |
+
|
| 28 |
+
// Simulate AI analysis delay
|
| 29 |
+
setTimeout(() => {
|
| 30 |
+
const mockResult: BiasResult = {
|
| 31 |
+
score: 65,
|
| 32 |
+
summary:
|
| 33 |
+
"The document contains several instances of exclusionary language and ambiguous legal terminology that might favor certain socioeconomic groups.",
|
| 34 |
+
problematicPhrases: [
|
| 35 |
+
{
|
| 36 |
+
phrase: "duly qualified applicants",
|
| 37 |
+
reason: "Vague terminology that often serves as a gatekeeping mechanism without specific criteria.",
|
| 38 |
+
recommendation: "Use 'applicants meeting the specific criteria listed below'.",
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
phrase: "standard processing fees apply",
|
| 42 |
+
reason: "Potentially exclusionary for lower-income individuals without stating exact amounts.",
|
| 43 |
+
recommendation: "Clearly list the fee structure or mention available waivers.",
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
phrase: "formal education only",
|
| 47 |
+
reason: "Excludes qualified individuals with non-traditional or vocational backgrounds.",
|
| 48 |
+
recommendation: "Consider 'equivalent experience' or specific skill-based testing.",
|
| 49 |
+
},
|
| 50 |
+
],
|
| 51 |
+
}
|
| 52 |
+
setResult(mockResult)
|
| 53 |
+
setIsAnalyzing(false)
|
| 54 |
+
}, 2000)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
| 59 |
+
{/* Input Side */}
|
| 60 |
+
<div className="space-y-6">
|
| 61 |
+
<Card className="border-primary/10 shadow-lg">
|
| 62 |
+
<CardHeader>
|
| 63 |
+
<CardTitle>Legal Notice Analyzer</CardTitle>
|
| 64 |
+
<CardDescription>Paste legal content or upload a document to check for linguistic biases.</CardDescription>
|
| 65 |
+
</CardHeader>
|
| 66 |
+
<CardContent className="space-y-6">
|
| 67 |
+
<div className="space-y-4">
|
| 68 |
+
<Textarea
|
| 69 |
+
placeholder="Paste legal notice content here..."
|
| 70 |
+
className="min-h-[300px] resize-none border-primary/10 focus-visible:ring-primary"
|
| 71 |
+
value={text}
|
| 72 |
+
onChange={(e) => setText(e.target.value)}
|
| 73 |
+
/>
|
| 74 |
+
<div className="flex items-center gap-4">
|
| 75 |
+
<div className="h-px flex-1 bg-border" />
|
| 76 |
+
<span className="text-xs text-muted-foreground font-medium uppercase tracking-widest">or</span>
|
| 77 |
+
<div className="h-px flex-1 bg-border" />
|
| 78 |
+
</div>
|
| 79 |
+
<FileUpload onFileSelect={(txt) => setText(txt)} />
|
| 80 |
+
</div>
|
| 81 |
+
</CardContent>
|
| 82 |
+
<CardContent className="pt-0">
|
| 83 |
+
<Button
|
| 84 |
+
className="w-full h-12 text-lg bg-primary hover:bg-primary/90"
|
| 85 |
+
onClick={analyzeText}
|
| 86 |
+
disabled={isAnalyzing || !text.trim()}
|
| 87 |
+
>
|
| 88 |
+
{isAnalyzing ? (
|
| 89 |
+
<>
|
| 90 |
+
<Search className="mr-2 h-5 w-5 animate-spin" />
|
| 91 |
+
Analyzing Document...
|
| 92 |
+
</>
|
| 93 |
+
) : (
|
| 94 |
+
<>
|
| 95 |
+
<ShieldAlert className="mr-2 h-5 w-5" />
|
| 96 |
+
Analyze for Bias
|
| 97 |
+
</>
|
| 98 |
+
)}
|
| 99 |
+
</Button>
|
| 100 |
+
</CardContent>
|
| 101 |
+
</Card>
|
| 102 |
+
|
| 103 |
+
<Card className="bg-muted/50 border-none">
|
| 104 |
+
<CardHeader className="py-4">
|
| 105 |
+
<div className="flex items-center gap-2 text-sm font-semibold">
|
| 106 |
+
<History className="h-4 w-4 text-primary" />
|
| 107 |
+
Recent Analyses
|
| 108 |
+
</div>
|
| 109 |
+
</CardHeader>
|
| 110 |
+
<CardContent className="py-0 pb-4">
|
| 111 |
+
<div className="space-y-2">
|
| 112 |
+
{[1, 2].map((i) => (
|
| 113 |
+
<div
|
| 114 |
+
key={i}
|
| 115 |
+
className="flex items-center justify-between p-3 rounded-lg bg-background border text-sm group cursor-pointer hover:border-primary/30 transition-colors"
|
| 116 |
+
>
|
| 117 |
+
<span className="truncate max-w-[200px] font-medium">Labor Notice Draft 0{i}.txt</span>
|
| 118 |
+
<div className="flex items-center gap-3">
|
| 119 |
+
<Badge variant="secondary" className="bg-success/10 text-success border-success/20">
|
| 120 |
+
Clean
|
| 121 |
+
</Badge>
|
| 122 |
+
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-transform group-hover:translate-x-1" />
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
))}
|
| 126 |
+
</div>
|
| 127 |
+
</CardContent>
|
| 128 |
+
</Card>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
{/* Results Side */}
|
| 132 |
+
<div className="space-y-6 sticky top-24">
|
| 133 |
+
{result ? (
|
| 134 |
+
<div className="space-y-6 animate-in fade-in slide-in-from-right-8 duration-500">
|
| 135 |
+
<Card className="border-primary/20 shadow-xl overflow-hidden">
|
| 136 |
+
<div
|
| 137 |
+
className={cn(
|
| 138 |
+
"h-2 w-full",
|
| 139 |
+
result.score > 70 ? "bg-success" : result.score > 40 ? "bg-accent" : "bg-destructive",
|
| 140 |
+
)}
|
| 141 |
+
/>
|
| 142 |
+
<CardHeader className="pb-2">
|
| 143 |
+
<div className="flex justify-between items-center mb-4">
|
| 144 |
+
<CardTitle>Analysis Results</CardTitle>
|
| 145 |
+
<Badge variant="outline" className="text-lg py-1 px-3">
|
| 146 |
+
Score: {result.score}/100
|
| 147 |
+
</Badge>
|
| 148 |
+
</div>
|
| 149 |
+
<Progress value={result.score} className="h-3" />
|
| 150 |
+
<div className="flex justify-between text-[10px] text-muted-foreground font-bold pt-2 uppercase tracking-tighter">
|
| 151 |
+
<span>Critical Bias</span>
|
| 152 |
+
<span>Moderate</span>
|
| 153 |
+
<span>Inclusive</span>
|
| 154 |
+
</div>
|
| 155 |
+
</CardHeader>
|
| 156 |
+
<CardContent className="space-y-4 pt-4">
|
| 157 |
+
<div className="p-4 rounded-xl bg-muted/50 border border-primary/5">
|
| 158 |
+
<h4 className="flex items-center gap-2 text-sm font-bold mb-2">
|
| 159 |
+
<Info className="h-4 w-4 text-primary" />
|
| 160 |
+
Summary
|
| 161 |
+
</h4>
|
| 162 |
+
<p className="text-sm leading-relaxed">{result.summary}</p>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div className="space-y-4">
|
| 166 |
+
<h4 className="text-sm font-bold flex items-center gap-2">
|
| 167 |
+
<AlertCircle className="h-4 w-4 text-destructive" />
|
| 168 |
+
Detected Issues
|
| 169 |
+
</h4>
|
| 170 |
+
<div className="space-y-3">
|
| 171 |
+
{result.problematicPhrases.map((item, idx) => (
|
| 172 |
+
<div key={idx} className="p-4 rounded-xl border bg-background space-y-3 shadow-sm">
|
| 173 |
+
<div className="flex items-start justify-between">
|
| 174 |
+
<code className="text-destructive font-bold bg-destructive/5 px-2 py-0.5 rounded text-sm">
|
| 175 |
+
"{item.phrase}"
|
| 176 |
+
</code>
|
| 177 |
+
</div>
|
| 178 |
+
<div className="grid gap-2">
|
| 179 |
+
<p className="text-xs leading-relaxed">
|
| 180 |
+
<span className="font-bold text-muted-foreground uppercase tracking-widest text-[9px] mr-2">
|
| 181 |
+
Reason:
|
| 182 |
+
</span>
|
| 183 |
+
{item.reason}
|
| 184 |
+
</p>
|
| 185 |
+
<div className="p-2 rounded bg-success/5 border border-success/10">
|
| 186 |
+
<p className="text-xs leading-relaxed font-medium text-success">
|
| 187 |
+
<span className="font-bold uppercase tracking-widest text-[9px] mr-2">Recommended:</span>
|
| 188 |
+
{item.recommendation}
|
| 189 |
+
</p>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
))}
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
</CardContent>
|
| 197 |
+
</Card>
|
| 198 |
+
</div>
|
| 199 |
+
) : (
|
| 200 |
+
<div className="h-full min-h-[500px] flex flex-col items-center justify-center text-center p-8 rounded-3xl border-2 border-dashed bg-muted/10 opacity-60">
|
| 201 |
+
<div className="h-20 w-20 rounded-full bg-muted flex items-center justify-center mb-6">
|
| 202 |
+
<Search className="h-10 w-10 text-muted-foreground/50" />
|
| 203 |
+
</div>
|
| 204 |
+
<h3 className="text-xl font-bold mb-2 text-muted-foreground">Waiting for analysis</h3>
|
| 205 |
+
<p className="max-w-[300px] text-sm text-muted-foreground">
|
| 206 |
+
Enter text on the left and click "Analyze for Bias" to see linguistic insights and recommendations.
|
| 207 |
+
</p>
|
| 208 |
+
</div>
|
| 209 |
+
)}
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
)
|
| 213 |
+
}
|
Frontend/components/chatbot/law-chatbot.tsx
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import { Button } from "@/components/ui/button"
|
| 6 |
+
import { Input } from "@/components/ui/input"
|
| 7 |
+
import { ScrollArea } from "@/components/ui/scroll-area"
|
| 8 |
+
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
| 9 |
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
| 10 |
+
import { Badge } from "@/components/ui/badge"
|
| 11 |
+
import { Send, Scale, User, Download, Plus, MessageSquare, Trash2, Menu, X } from "lucide-react"
|
| 12 |
+
import { cn } from "@/lib/utils"
|
| 13 |
+
import ReactMarkdown from "react-markdown"
|
| 14 |
+
import remarkGfm from "remark-gfm"
|
| 15 |
+
import { useToast } from "@/hooks/use-toast"
|
| 16 |
+
|
| 17 |
+
interface SuggestedAction {
|
| 18 |
+
action: string
|
| 19 |
+
description: string
|
| 20 |
+
letter_type: string
|
| 21 |
+
prompt: string
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface Message {
|
| 25 |
+
id: string
|
| 26 |
+
role: "user" | "assistant"
|
| 27 |
+
content: string
|
| 28 |
+
timestamp: Date
|
| 29 |
+
suggested_action?: SuggestedAction
|
| 30 |
+
metadata?: {
|
| 31 |
+
summary?: string
|
| 32 |
+
key_point?: string
|
| 33 |
+
next_steps?: string
|
| 34 |
+
sources?: any[]
|
| 35 |
+
context_used?: boolean
|
| 36 |
+
is_non_legal?: boolean
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
interface Conversation {
|
| 41 |
+
id: string
|
| 42 |
+
title: string
|
| 43 |
+
updated_at: string
|
| 44 |
+
message_count: number
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function LawChatbot() {
|
| 48 |
+
const router = useRouter()
|
| 49 |
+
const [messages, setMessages] = useState<Message[]>([
|
| 50 |
+
{
|
| 51 |
+
id: "1",
|
| 52 |
+
role: "assistant",
|
| 53 |
+
content: "Namaste! I am your Nepali Law Assistant. How can I help you today?",
|
| 54 |
+
timestamp: new Date(),
|
| 55 |
+
},
|
| 56 |
+
])
|
| 57 |
+
const [input, setInput] = useState("")
|
| 58 |
+
const [isTyping, setIsTyping] = useState(false)
|
| 59 |
+
const [conversationId, setConversationId] = useState<string | null>(null)
|
| 60 |
+
const [conversations, setConversations] = useState<Conversation[]>([])
|
| 61 |
+
const [isLoadingConversations, setIsLoadingConversations] = useState(false)
|
| 62 |
+
const [showLetterDialog, setShowLetterDialog] = useState(false)
|
| 63 |
+
const [pendingAction, setPendingAction] = useState<SuggestedAction | null>(null)
|
| 64 |
+
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false)
|
| 65 |
+
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
| 66 |
+
const { toast } = useToast()
|
| 67 |
+
|
| 68 |
+
const suggestedQuestions = [
|
| 69 |
+
"What are my property rights?",
|
| 70 |
+
"Labor law basics",
|
| 71 |
+
"Consumer rights",
|
| 72 |
+
"Women's rights in Nepal",
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
// Load conversations on mount
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
loadConversations()
|
| 78 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 79 |
+
}, [])
|
| 80 |
+
|
| 81 |
+
// Scroll to bottom whenever messages change
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
const timer = setTimeout(() => {
|
| 84 |
+
if (scrollAreaRef.current) {
|
| 85 |
+
const scrollContainer = scrollAreaRef.current.querySelector("[data-radix-scroll-area-viewport]")
|
| 86 |
+
if (scrollContainer) {
|
| 87 |
+
scrollContainer.scrollTo({
|
| 88 |
+
top: scrollContainer.scrollHeight,
|
| 89 |
+
behavior: "smooth",
|
| 90 |
+
})
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
}, 100)
|
| 94 |
+
return () => clearTimeout(timer)
|
| 95 |
+
}, [messages, isTyping])
|
| 96 |
+
|
| 97 |
+
const getAuthHeaders = () => {
|
| 98 |
+
const token = localStorage.getItem("access_token")
|
| 99 |
+
return {
|
| 100 |
+
"Content-Type": "application/json",
|
| 101 |
+
...(token && { Authorization: `Bearer ${token}` }),
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Use public env var so frontend can target backend in different environments
|
| 106 |
+
const BACKEND_URL = (process.env.NEXT_PUBLIC_BACKEND_URL as string) || "http://localhost:8000"
|
| 107 |
+
|
| 108 |
+
const loadConversations = async () => {
|
| 109 |
+
const token = localStorage.getItem("access_token")
|
| 110 |
+
if (!token) return
|
| 111 |
+
|
| 112 |
+
setIsLoadingConversations(true)
|
| 113 |
+
try {
|
| 114 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/chat-history/conversations`, {
|
| 115 |
+
headers: getAuthHeaders(),
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
if (response.ok) {
|
| 119 |
+
const data = await response.json()
|
| 120 |
+
setConversations(data)
|
| 121 |
+
}
|
| 122 |
+
} catch (error) {
|
| 123 |
+
console.error("Failed to load conversations:", error)
|
| 124 |
+
} finally {
|
| 125 |
+
setIsLoadingConversations(false)
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const loadConversation = async (convId: string) => {
|
| 130 |
+
try {
|
| 131 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/chat-history/conversations/${convId}`, {
|
| 132 |
+
headers: getAuthHeaders(),
|
| 133 |
+
})
|
| 134 |
+
|
| 135 |
+
if (response.ok) {
|
| 136 |
+
const data = await response.json()
|
| 137 |
+
const loadedMessages: Message[] = data.messages.map((msg: any) => {
|
| 138 |
+
let content = msg.content
|
| 139 |
+
|
| 140 |
+
// If this is an assistant message with metadata, reconstruct the full formatted content
|
| 141 |
+
if (msg.role === "assistant" && msg.metadata) {
|
| 142 |
+
const meta = msg.metadata
|
| 143 |
+
|
| 144 |
+
// Check if metadata has the structured fields
|
| 145 |
+
if (meta.summary || meta.key_point || meta.next_steps) {
|
| 146 |
+
// Format sources
|
| 147 |
+
let sourcesText = ""
|
| 148 |
+
if (meta.sources && meta.sources.length > 0) {
|
| 149 |
+
const formattedSources = meta.sources
|
| 150 |
+
.filter((s: any) => s && (s.file || s.section))
|
| 151 |
+
.map((s: any, i: number) => {
|
| 152 |
+
let fileName = "Legal Document"
|
| 153 |
+
if (s.file) {
|
| 154 |
+
fileName = s.file
|
| 155 |
+
.replace(/_en\.pdf$/i, "")
|
| 156 |
+
.replace(/\.pdf$/i, "")
|
| 157 |
+
.replace(/_/g, " ")
|
| 158 |
+
}
|
| 159 |
+
const section = s.section || s.article_section || "General Reference"
|
| 160 |
+
return ` ${i + 1}. **${fileName}** (${section})`
|
| 161 |
+
})
|
| 162 |
+
.join("\n")
|
| 163 |
+
|
| 164 |
+
if (formattedSources) {
|
| 165 |
+
sourcesText = `\n\n### 📚 Resources\n${formattedSources}`
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const contextBadge = meta.context_used ? "\n\n> 💡 *Used conversation context*" : ""
|
| 170 |
+
|
| 171 |
+
// Reconstruct the full formatted content
|
| 172 |
+
content = `### 📝 Summary\n${meta.summary || ""}
|
| 173 |
+
|
| 174 |
+
### 💬 Detailed Explanation
|
| 175 |
+
${msg.content}
|
| 176 |
+
|
| 177 |
+
### 🔑 Key Points
|
| 178 |
+
- ${meta.key_point || ""}
|
| 179 |
+
|
| 180 |
+
### 📋 Next Steps
|
| 181 |
+
${meta.next_steps || ""}${sourcesText}${contextBadge}`.trim()
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
id: msg.id,
|
| 187 |
+
role: msg.role,
|
| 188 |
+
content: content,
|
| 189 |
+
timestamp: new Date(msg.timestamp),
|
| 190 |
+
metadata: msg.metadata,
|
| 191 |
+
}
|
| 192 |
+
})
|
| 193 |
+
setMessages(loadedMessages)
|
| 194 |
+
setConversationId(convId)
|
| 195 |
+
// Close mobile sidebar after loading conversation
|
| 196 |
+
setIsMobileSidebarOpen(false)
|
| 197 |
+
}
|
| 198 |
+
} catch (error) {
|
| 199 |
+
console.error("Failed to load conversation:", error)
|
| 200 |
+
toast({
|
| 201 |
+
title: "Error",
|
| 202 |
+
description: "Failed to load conversation",
|
| 203 |
+
variant: "destructive",
|
| 204 |
+
})
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const createNewConversation = async (firstMessage: string) => {
|
| 209 |
+
const token = localStorage.getItem("access_token")
|
| 210 |
+
if (!token) {
|
| 211 |
+
toast({
|
| 212 |
+
title: "Authentication Required",
|
| 213 |
+
description: "Please login to save chat history",
|
| 214 |
+
variant: "destructive",
|
| 215 |
+
})
|
| 216 |
+
return null
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
try {
|
| 220 |
+
const title = firstMessage.slice(0, 50) + (firstMessage.length > 50 ? "..." : "")
|
| 221 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/chat-history/conversations`, {
|
| 222 |
+
method: "POST",
|
| 223 |
+
headers: getAuthHeaders(),
|
| 224 |
+
body: JSON.stringify({ title }),
|
| 225 |
+
})
|
| 226 |
+
|
| 227 |
+
if (response.ok) {
|
| 228 |
+
const data = await response.json()
|
| 229 |
+
setConversationId(data.id)
|
| 230 |
+
loadConversations() // Refresh conversation list
|
| 231 |
+
return data.id
|
| 232 |
+
}
|
| 233 |
+
} catch (error) {
|
| 234 |
+
console.error("Failed to create conversation:", error)
|
| 235 |
+
}
|
| 236 |
+
return null
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
const deleteConversation = async (convId: string) => {
|
| 240 |
+
try {
|
| 241 |
+
const response = await fetch(`${BACKEND_URL}/api/v1/chat-history/conversations/${convId}`, {
|
| 242 |
+
method: "DELETE",
|
| 243 |
+
headers: getAuthHeaders(),
|
| 244 |
+
})
|
| 245 |
+
|
| 246 |
+
if (response.ok) {
|
| 247 |
+
toast({
|
| 248 |
+
title: "Success",
|
| 249 |
+
description: "Conversation deleted successfully",
|
| 250 |
+
})
|
| 251 |
+
|
| 252 |
+
// If deleted conversation was active, start new chat
|
| 253 |
+
if (conversationId === convId) {
|
| 254 |
+
startNewChat()
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
// Refresh conversation list
|
| 258 |
+
loadConversations()
|
| 259 |
+
} else {
|
| 260 |
+
throw new Error("Failed to delete")
|
| 261 |
+
}
|
| 262 |
+
} catch (error) {
|
| 263 |
+
console.error("Failed to delete conversation:", error)
|
| 264 |
+
toast({
|
| 265 |
+
title: "Error",
|
| 266 |
+
description: "Failed to delete conversation",
|
| 267 |
+
variant: "destructive",
|
| 268 |
+
})
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
const confirmLetterGeneration = () => {
|
| 274 |
+
if (!pendingAction) return
|
| 275 |
+
|
| 276 |
+
// Store the description in localStorage to pass to letter generator page
|
| 277 |
+
localStorage.setItem("letter_generation_prompt", pendingAction.description)
|
| 278 |
+
localStorage.setItem("letter_type", pendingAction.letter_type)
|
| 279 |
+
|
| 280 |
+
// Close dialog
|
| 281 |
+
setShowLetterDialog(false)
|
| 282 |
+
setPendingAction(null)
|
| 283 |
+
|
| 284 |
+
// Redirect to letter generator page
|
| 285 |
+
router.push("/letter-generator")
|
| 286 |
+
|
| 287 |
+
toast({
|
| 288 |
+
title: "Redirecting...",
|
| 289 |
+
description: "Taking you to the letter generation section",
|
| 290 |
+
})
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
const cancelLetterGeneration = () => {
|
| 294 |
+
setShowLetterDialog(false)
|
| 295 |
+
setPendingAction(null)
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
const startNewChat = () => {
|
| 299 |
+
setMessages([
|
| 300 |
+
{
|
| 301 |
+
id: "1",
|
| 302 |
+
role: "assistant",
|
| 303 |
+
content: "Namaste! I am your Nepali Law Assistant. How can I help you today?",
|
| 304 |
+
timestamp: new Date(),
|
| 305 |
+
},
|
| 306 |
+
])
|
| 307 |
+
setConversationId(null)
|
| 308 |
+
// Close mobile sidebar after starting new chat
|
| 309 |
+
setIsMobileSidebarOpen(false)
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
const handleSend = async (content: string) => {
|
| 313 |
+
if (!content.trim()) return
|
| 314 |
+
|
| 315 |
+
const userMsg: Message = {
|
| 316 |
+
id: Date.now().toString(),
|
| 317 |
+
role: "user",
|
| 318 |
+
content,
|
| 319 |
+
timestamp: new Date(),
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
setMessages((prev) => [...prev, userMsg])
|
| 323 |
+
setInput("")
|
| 324 |
+
setIsTyping(true)
|
| 325 |
+
|
| 326 |
+
try {
|
| 327 |
+
// Create conversation if this is the first message
|
| 328 |
+
let currentConvId = conversationId
|
| 329 |
+
if (!currentConvId && localStorage.getItem("access_token")) {
|
| 330 |
+
currentConvId = await createNewConversation(content)
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
const token = localStorage.getItem("access_token")
|
| 334 |
+
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
| 335 |
+
if (token) {
|
| 336 |
+
headers["Authorization"] = `Bearer ${token}`
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
const response = await fetch("/api/Legal_Chat", {
|
| 340 |
+
method: "POST",
|
| 341 |
+
headers,
|
| 342 |
+
body: JSON.stringify({
|
| 343 |
+
messages: [...messages, { role: "user", content }],
|
| 344 |
+
type: "legal",
|
| 345 |
+
conversation_id: currentConvId,
|
| 346 |
+
}),
|
| 347 |
+
})
|
| 348 |
+
|
| 349 |
+
const data = await response.json()
|
| 350 |
+
|
| 351 |
+
const assistantMsg: Message = {
|
| 352 |
+
id: (Date.now() + 1).toString(),
|
| 353 |
+
role: "assistant",
|
| 354 |
+
content: data.content || data.error || "I am having trouble connecting to my legal database.",
|
| 355 |
+
timestamp: new Date(),
|
| 356 |
+
suggested_action: data.suggested_action || undefined,
|
| 357 |
+
}
|
| 358 |
+
setMessages((prev) => [...prev, assistantMsg])
|
| 359 |
+
|
| 360 |
+
// If there's a suggested action, show the dialog
|
| 361 |
+
if (data.suggested_action) {
|
| 362 |
+
setPendingAction(data.suggested_action)
|
| 363 |
+
setShowLetterDialog(true)
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
// Note: Messages are now automatically saved by the backend /chat endpoint
|
| 367 |
+
} catch (error) {
|
| 368 |
+
console.error("[Legal Chat Error]:", error)
|
| 369 |
+
const errorMsg: Message = {
|
| 370 |
+
id: (Date.now() + 1).toString(),
|
| 371 |
+
role: "assistant",
|
| 372 |
+
content: "I encountered an error while processing your request. Please ensure the backend server is running.",
|
| 373 |
+
timestamp: new Date(),
|
| 374 |
+
}
|
| 375 |
+
setMessages((prev) => [...prev, errorMsg])
|
| 376 |
+
} finally {
|
| 377 |
+
setIsTyping(false)
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
const exportChat = () => {
|
| 382 |
+
const chatContent = messages
|
| 383 |
+
.map((m) => `[${m.role.toUpperCase()} - ${m.timestamp.toLocaleTimeString()}]: ${m.content}`)
|
| 384 |
+
.join("\n\n")
|
| 385 |
+
const blob = new Blob([chatContent], { type: "text/plain" })
|
| 386 |
+
const url = URL.createObjectURL(blob)
|
| 387 |
+
const a = document.createElement("a")
|
| 388 |
+
a.href = url
|
| 389 |
+
a.download = "legal-chat-history.txt"
|
| 390 |
+
a.click()
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
return (
|
| 394 |
+
<div className="w-full max-w-[1600px] mx-auto">
|
| 395 |
+
<div className="flex h-[calc(100vh-10rem)] gap-4 relative">
|
| 396 |
+
{/* Mobile Sidebar Overlay */}
|
| 397 |
+
{isMobileSidebarOpen && (
|
| 398 |
+
<div
|
| 399 |
+
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
| 400 |
+
onClick={() => setIsMobileSidebarOpen(false)}
|
| 401 |
+
/>
|
| 402 |
+
)}
|
| 403 |
+
|
| 404 |
+
{/* Sidebar - Chat History */}
|
| 405 |
+
<div
|
| 406 |
+
className={cn(
|
| 407 |
+
"w-64 flex-col gap-4 border-r pr-4 bg-background z-50",
|
| 408 |
+
"lg:flex lg:relative lg:translate-x-0",
|
| 409 |
+
"fixed left-0 top-0 h-full transition-transform duration-300 ease-in-out p-4 lg:p-0",
|
| 410 |
+
isMobileSidebarOpen ? "flex translate-x-0" : "hidden lg:flex -translate-x-full lg:translate-x-0"
|
| 411 |
+
)}
|
| 412 |
+
>
|
| 413 |
+
{/* Close button for mobile */}
|
| 414 |
+
<div className="flex items-center justify-between lg:hidden mb-4">
|
| 415 |
+
<h3 className="font-semibold">Chat History</h3>
|
| 416 |
+
<Button
|
| 417 |
+
variant="ghost"
|
| 418 |
+
size="icon"
|
| 419 |
+
onClick={() => setIsMobileSidebarOpen(false)}
|
| 420 |
+
className="h-8 w-8"
|
| 421 |
+
>
|
| 422 |
+
<X className="h-4 w-4" />
|
| 423 |
+
</Button>
|
| 424 |
+
</div>
|
| 425 |
+
|
| 426 |
+
<Button className="w-full justify-start gap-2 bg-primary" onClick={startNewChat}>
|
| 427 |
+
<Plus className="h-4 w-4" /> New Conversation
|
| 428 |
+
</Button>
|
| 429 |
+
<ScrollArea className="flex-1">
|
| 430 |
+
<div className="space-y-2">
|
| 431 |
+
<p className="text-xs font-semibold text-muted-foreground px-2 uppercase tracking-wider">Recent Chats</p>
|
| 432 |
+
{isLoadingConversations ? (
|
| 433 |
+
<p className="text-xs text-muted-foreground px-2">Loading...</p>
|
| 434 |
+
) : conversations.length > 0 ? (
|
| 435 |
+
conversations.map((conv) => (
|
| 436 |
+
<div
|
| 437 |
+
key={conv.id}
|
| 438 |
+
className={cn(
|
| 439 |
+
"w-full rounded-lg transition-colors border flex items-center gap-2",
|
| 440 |
+
conversationId === conv.id
|
| 441 |
+
? "bg-muted border-border"
|
| 442 |
+
: "border-transparent hover:border-border hover:bg-muted/50"
|
| 443 |
+
)}
|
| 444 |
+
>
|
| 445 |
+
<button
|
| 446 |
+
className="p-2 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all shrink-0"
|
| 447 |
+
onClick={(e) => {
|
| 448 |
+
e.stopPropagation()
|
| 449 |
+
if (confirm("Delete this conversation? This cannot be undone.")) {
|
| 450 |
+
deleteConversation(conv.id)
|
| 451 |
+
}
|
| 452 |
+
}}
|
| 453 |
+
title="Delete conversation"
|
| 454 |
+
>
|
| 455 |
+
<Trash2 className="h-4 w-4" />
|
| 456 |
+
</button>
|
| 457 |
+
<button
|
| 458 |
+
className="flex-1 text-left p-3 pr-2"
|
| 459 |
+
onClick={() => loadConversation(conv.id)}
|
| 460 |
+
>
|
| 461 |
+
<div className="flex items-center gap-2 mb-1">
|
| 462 |
+
<MessageSquare className="h-4 w-4 text-primary shrink-0" />
|
| 463 |
+
<span className="text-sm font-medium truncate">{conv.title}</span>
|
| 464 |
+
</div>
|
| 465 |
+
<div className="flex items-center justify-between">
|
| 466 |
+
<span className="text-xs text-muted-foreground">
|
| 467 |
+
{new Date(conv.updated_at).toLocaleDateString()}
|
| 468 |
+
</span>
|
| 469 |
+
<span className="text-xs text-muted-foreground">{conv.message_count} msgs</span>
|
| 470 |
+
</div>
|
| 471 |
+
</button>
|
| 472 |
+
</div>
|
| 473 |
+
))
|
| 474 |
+
) : (
|
| 475 |
+
<p className="text-xs text-muted-foreground px-2">No conversations yet</p>
|
| 476 |
+
)}
|
| 477 |
+
</div>
|
| 478 |
+
</ScrollArea>
|
| 479 |
+
</div>
|
| 480 |
+
|
| 481 |
+
{/* Main Chat Interface */}
|
| 482 |
+
<Card className="flex-1 flex flex-col shadow-lg border-primary/10 overflow-hidden bg-card/50 backdrop-blur-sm">
|
| 483 |
+
<CardHeader className="border-b py-4 flex flex-row items-center justify-between">
|
| 484 |
+
<div className="flex items-center gap-3">
|
| 485 |
+
{/* Mobile menu button */}
|
| 486 |
+
<Button
|
| 487 |
+
variant="ghost"
|
| 488 |
+
size="icon"
|
| 489 |
+
className="lg:hidden"
|
| 490 |
+
onClick={() => setIsMobileSidebarOpen(true)}
|
| 491 |
+
title="Open chat history"
|
| 492 |
+
>
|
| 493 |
+
<Menu className="h-5 w-5" />
|
| 494 |
+
</Button>
|
| 495 |
+
|
| 496 |
+
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
| 497 |
+
<Scale className="h-5 w-5" />
|
| 498 |
+
</div>
|
| 499 |
+
<div>
|
| 500 |
+
<CardTitle className="text-lg">Nepali Law Assistant</CardTitle>
|
| 501 |
+
<div className="flex items-center gap-2">
|
| 502 |
+
<span className="h-2 w-2 rounded-full bg-success animate-pulse" />
|
| 503 |
+
<span className="text-xs text-muted-foreground">AI Powered & Ready</span>
|
| 504 |
+
</div>
|
| 505 |
+
</div>
|
| 506 |
+
</div>
|
| 507 |
+
<div className="flex items-center gap-2">
|
| 508 |
+
<Button variant="ghost" size="sm" onClick={exportChat} title="Export conversation">
|
| 509 |
+
<Download className="h-4 w-4 mr-2" />
|
| 510 |
+
Export
|
| 511 |
+
</Button>
|
| 512 |
+
</div>
|
| 513 |
+
</CardHeader>
|
| 514 |
+
|
| 515 |
+
<CardContent className="flex-1 p-0 overflow-hidden">
|
| 516 |
+
<ScrollArea ref={scrollAreaRef} className="h-full">
|
| 517 |
+
<div className="space-y-6 max-w-4xl mx-auto p-4 md:p-6">
|
| 518 |
+
{messages.map((m) => (
|
| 519 |
+
<div
|
| 520 |
+
key={m.id}
|
| 521 |
+
className={cn(
|
| 522 |
+
"flex gap-3 animate-in fade-in slide-in-from-bottom-2",
|
| 523 |
+
m.role === "user" ? "flex-row-reverse" : "flex-row",
|
| 524 |
+
)}
|
| 525 |
+
>
|
| 526 |
+
<Avatar className={cn("h-8 w-8 border", m.role === "assistant" ? "bg-primary/10" : "bg-muted")}>
|
| 527 |
+
<AvatarFallback className={m.role === "assistant" ? "text-primary" : "text-muted-foreground"}>
|
| 528 |
+
{m.role === "assistant" ? <Scale className="h-4 w-4" /> : <User className="h-4 w-4" />}
|
| 529 |
+
</AvatarFallback>
|
| 530 |
+
</Avatar>
|
| 531 |
+
<div
|
| 532 |
+
className={cn("flex flex-col gap-1 max-w-[80%]", m.role === "user" ? "items-end" : "items-start")}
|
| 533 |
+
>
|
| 534 |
+
<div
|
| 535 |
+
className={cn(
|
| 536 |
+
"rounded-2xl px-4 py-3 text-sm shadow-sm",
|
| 537 |
+
m.role === "user"
|
| 538 |
+
? "bg-primary text-primary-foreground rounded-tr-none"
|
| 539 |
+
: "bg-muted/80 backdrop-blur-sm rounded-tl-none border",
|
| 540 |
+
)}
|
| 541 |
+
>
|
| 542 |
+
{m.role === "assistant" ? (
|
| 543 |
+
<div className="prose prose-sm dark:prose-invert max-w-none overflow-wrap-anywhere wrap-break-word">
|
| 544 |
+
<ReactMarkdown
|
| 545 |
+
remarkPlugins={[remarkGfm]}
|
| 546 |
+
components={{
|
| 547 |
+
p: ({ children }) => <p className="mb-3 last:mb-0 leading-relaxed">{children}</p>,
|
| 548 |
+
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
|
| 549 |
+
ul: ({ children }) => <ul className="list-disc list-inside mb-3 space-y-1">{children}</ul>,
|
| 550 |
+
ol: ({ children }) => <ol className="list-decimal list-inside mb-3 space-y-1">{children}</ol>,
|
| 551 |
+
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
| 552 |
+
a: ({ href, children }) => (
|
| 553 |
+
<a
|
| 554 |
+
href={href}
|
| 555 |
+
className="text-primary hover:underline underline-offset-2"
|
| 556 |
+
target="_blank"
|
| 557 |
+
rel="noopener noreferrer"
|
| 558 |
+
>
|
| 559 |
+
{children}
|
| 560 |
+
</a>
|
| 561 |
+
),
|
| 562 |
+
h1: ({ children }) => <h1 className="text-lg font-bold mb-2 mt-4 first:mt-0">{children}</h1>,
|
| 563 |
+
h2: ({ children }) => <h2 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h2>,
|
| 564 |
+
h3: ({ children }) => <h3 className="text-sm font-semibold mb-2 mt-2 first:mt-0">{children}</h3>,
|
| 565 |
+
code: ({ children }) => (
|
| 566 |
+
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">{children}</code>
|
| 567 |
+
),
|
| 568 |
+
pre: ({ children }) => (
|
| 569 |
+
<pre className="bg-muted p-3 rounded-lg overflow-x-auto mb-3 text-xs">{children}</pre>
|
| 570 |
+
),
|
| 571 |
+
}}
|
| 572 |
+
>
|
| 573 |
+
{m.content}
|
| 574 |
+
</ReactMarkdown>
|
| 575 |
+
</div>
|
| 576 |
+
) : (
|
| 577 |
+
<div className="wrap-break-word">{m.content}</div>
|
| 578 |
+
)}
|
| 579 |
+
</div>
|
| 580 |
+
<span className="text-[10px] text-muted-foreground px-1">
|
| 581 |
+
{m.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
| 582 |
+
</span>
|
| 583 |
+
</div>
|
| 584 |
+
</div>
|
| 585 |
+
))}
|
| 586 |
+
{isTyping && (
|
| 587 |
+
<div className="flex gap-3 animate-in fade-in">
|
| 588 |
+
<Avatar className="h-8 w-8 border bg-primary/10">
|
| 589 |
+
<AvatarFallback className="text-primary">
|
| 590 |
+
<Scale className="h-4 w-4" />
|
| 591 |
+
</AvatarFallback>
|
| 592 |
+
</Avatar>
|
| 593 |
+
<div className="bg-muted/80 border rounded-2xl rounded-tl-none px-4 py-3 flex gap-1">
|
| 594 |
+
<span
|
| 595 |
+
className="h-1.5 w-1.5 rounded-full bg-primary/40 animate-bounce"
|
| 596 |
+
style={{ animationDelay: "0ms" }}
|
| 597 |
+
/>
|
| 598 |
+
<span
|
| 599 |
+
className="h-1.5 w-1.5 rounded-full bg-primary/40 animate-bounce"
|
| 600 |
+
style={{ animationDelay: "150ms" }}
|
| 601 |
+
/>
|
| 602 |
+
<span
|
| 603 |
+
className="h-1.5 w-1.5 rounded-full bg-primary/40 animate-bounce"
|
| 604 |
+
style={{ animationDelay: "300ms" }}
|
| 605 |
+
/>
|
| 606 |
+
</div>
|
| 607 |
+
</div>
|
| 608 |
+
)}
|
| 609 |
+
</div>
|
| 610 |
+
</ScrollArea>
|
| 611 |
+
</CardContent>
|
| 612 |
+
|
| 613 |
+
<CardFooter className="p-4 border-t bg-muted/20 backdrop-blur-md">
|
| 614 |
+
<div className="w-full space-y-4 max-w-4xl mx-auto">
|
| 615 |
+
{messages.length < 3 && (
|
| 616 |
+
<div className="flex flex-wrap gap-2">
|
| 617 |
+
{suggestedQuestions.map((q) => (
|
| 618 |
+
<Badge
|
| 619 |
+
key={q}
|
| 620 |
+
variant="outline"
|
| 621 |
+
className="cursor-pointer hover:bg-primary/10 hover:border-primary/30 transition-colors px-3 py-1 bg-background"
|
| 622 |
+
onClick={() => handleSend(q)}
|
| 623 |
+
>
|
| 624 |
+
{q}
|
| 625 |
+
</Badge>
|
| 626 |
+
))}
|
| 627 |
+
</div>
|
| 628 |
+
)}
|
| 629 |
+
<div className="flex gap-2 relative">
|
| 630 |
+
<Input
|
| 631 |
+
placeholder="Type your legal question here..."
|
| 632 |
+
value={input}
|
| 633 |
+
onChange={(e) => setInput(e.target.value)}
|
| 634 |
+
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
| 635 |
+
className="pr-12 h-12 bg-background border-primary/20 focus-visible:ring-primary shadow-inner"
|
| 636 |
+
/>
|
| 637 |
+
<Button
|
| 638 |
+
size="icon"
|
| 639 |
+
onClick={() => handleSend(input)}
|
| 640 |
+
className="absolute right-1 top-1 h-10 w-10 bg-primary hover:bg-primary/90 transition-transform active:scale-95"
|
| 641 |
+
>
|
| 642 |
+
<Send className="h-4 w-4" />
|
| 643 |
+
</Button>
|
| 644 |
+
</div>
|
| 645 |
+
<p className="text-[10px] text-center text-muted-foreground">
|
| 646 |
+
AI responses are for information only and not professional legal advice.
|
| 647 |
+
</p>
|
| 648 |
+
</div>
|
| 649 |
+
</CardFooter>
|
| 650 |
+
</Card>
|
| 651 |
+
</div>
|
| 652 |
+
|
| 653 |
+
{/* Letter Generation Confirmation Card */}
|
| 654 |
+
{showLetterDialog && pendingAction && (
|
| 655 |
+
<Card className="fixed bottom-6 right-6 z-50 shadow-2xl border-primary/20 w-[90vw] sm:w-96 animate-in slide-in-from-bottom-4 fade-in duration-300">
|
| 656 |
+
<CardHeader className="pb-3">
|
| 657 |
+
<CardTitle className="text-base">Generate Letter</CardTitle>
|
| 658 |
+
<p className="text-sm text-muted-foreground mt-1">
|
| 659 |
+
Would you like me to help you draft a {pendingAction.letter_type}?
|
| 660 |
+
</p>
|
| 661 |
+
</CardHeader>
|
| 662 |
+
<CardFooter className="gap-2 pt-3">
|
| 663 |
+
<Button variant="outline" onClick={cancelLetterGeneration} className="flex-1">
|
| 664 |
+
No, thanks
|
| 665 |
+
</Button>
|
| 666 |
+
<Button onClick={confirmLetterGeneration} className="flex-1">
|
| 667 |
+
Yes, generate it
|
| 668 |
+
</Button>
|
| 669 |
+
</CardFooter>
|
| 670 |
+
</Card>
|
| 671 |
+
)}
|
| 672 |
+
</div>
|
| 673 |
+
)
|
| 674 |
+
}
|
Frontend/components/chatbot/letter-generator.tsx
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from "react"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import { Input } from "@/components/ui/input"
|
| 6 |
+
import { ScrollArea } from "@/components/ui/scroll-area"
|
| 7 |
+
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
| 8 |
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
| 9 |
+
import { Badge } from "@/components/ui/badge"
|
| 10 |
+
import { Send, FileText, User, Download, Loader2, CheckCircle2, Printer } from "lucide-react"
|
| 11 |
+
import { cn } from "@/lib/utils"
|
| 12 |
+
import ReactMarkdown from "react-markdown"
|
| 13 |
+
import remarkGfm from "remark-gfm"
|
| 14 |
+
import html2canvas from "html2canvas"
|
| 15 |
+
import { addDocumentToCache } from "@/lib/document-cache"
|
| 16 |
+
|
| 17 |
+
type MessageRole = "user" | "assistant" | "system"
|
| 18 |
+
|
| 19 |
+
interface Message {
|
| 20 |
+
id: string
|
| 21 |
+
role: MessageRole
|
| 22 |
+
content: string
|
| 23 |
+
timestamp: Date
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
type ConversationState = "initial" | "template-search" | "collecting-placeholders" | "generating" | "completed"
|
| 27 |
+
|
| 28 |
+
interface PlaceholderData {
|
| 29 |
+
[key: string]: string
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function LetterGenerator() {
|
| 33 |
+
const [messages, setMessages] = useState<Message[]>([
|
| 34 |
+
{
|
| 35 |
+
id: "1",
|
| 36 |
+
role: "assistant",
|
| 37 |
+
content: "Namaste! I am your Letter Generation Assistant. What kind of letter would you like to generate? (e.g., citizenship application, leave application, complaint letter, etc.)",
|
| 38 |
+
timestamp: new Date(),
|
| 39 |
+
},
|
| 40 |
+
])
|
| 41 |
+
const [input, setInput] = useState("")
|
| 42 |
+
const [isTyping, setIsTyping] = useState(false)
|
| 43 |
+
const [conversationState, setConversationState] = useState<ConversationState>("initial")
|
| 44 |
+
const [templateName, setTemplateName] = useState<string | null>(null)
|
| 45 |
+
const [placeholders, setPlaceholders] = useState<string[]>([])
|
| 46 |
+
const [placeholderData, setPlaceholderData] = useState<PlaceholderData>({})
|
| 47 |
+
const [currentPlaceholderIndex, setCurrentPlaceholderIndex] = useState(0)
|
| 48 |
+
const [generatedLetter, setGeneratedLetter] = useState<string | null>(null)
|
| 49 |
+
const [generatedLetterImage, setGeneratedLetterImage] = useState<string | null>(null)
|
| 50 |
+
const [isGeneratingImage, setIsGeneratingImage] = useState(false)
|
| 51 |
+
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
| 52 |
+
const letterTemplateRef = useRef<HTMLDivElement>(null)
|
| 53 |
+
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
const timer = setTimeout(() => {
|
| 56 |
+
if (scrollAreaRef.current) {
|
| 57 |
+
const scrollContainer = scrollAreaRef.current.querySelector("[data-radix-scroll-area-viewport]")
|
| 58 |
+
if (scrollContainer) {
|
| 59 |
+
scrollContainer.scrollTo({
|
| 60 |
+
top: scrollContainer.scrollHeight,
|
| 61 |
+
behavior: "smooth",
|
| 62 |
+
})
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
}, 100)
|
| 66 |
+
return () => clearTimeout(timer)
|
| 67 |
+
}, [messages, isTyping])
|
| 68 |
+
|
| 69 |
+
// Auto-fill prompt from chatbot redirect
|
| 70 |
+
useEffect(() => {
|
| 71 |
+
const prompt = localStorage.getItem("letter_generation_prompt")
|
| 72 |
+
const letterType = localStorage.getItem("letter_type")
|
| 73 |
+
|
| 74 |
+
if (prompt) {
|
| 75 |
+
// Clear the stored data
|
| 76 |
+
localStorage.removeItem("letter_generation_prompt")
|
| 77 |
+
localStorage.removeItem("letter_type")
|
| 78 |
+
|
| 79 |
+
// Auto-send the prompt
|
| 80 |
+
setInput(prompt)
|
| 81 |
+
setTimeout(() => {
|
| 82 |
+
handleSend(prompt)
|
| 83 |
+
}, 500)
|
| 84 |
+
}
|
| 85 |
+
}, [])
|
| 86 |
+
|
| 87 |
+
const addMessage = (role: MessageRole, content: string) => {
|
| 88 |
+
const newMessage: Message = {
|
| 89 |
+
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // Unique ID
|
| 90 |
+
role,
|
| 91 |
+
content,
|
| 92 |
+
timestamp: new Date(),
|
| 93 |
+
}
|
| 94 |
+
setMessages((prev) => [...prev, newMessage])
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const handleSend = async (content: string) => {
|
| 98 |
+
if (!content.trim()) return
|
| 99 |
+
|
| 100 |
+
addMessage("user", content)
|
| 101 |
+
setInput("")
|
| 102 |
+
setIsTyping(true)
|
| 103 |
+
|
| 104 |
+
try {
|
| 105 |
+
if (conversationState === "initial" || conversationState === "template-search") {
|
| 106 |
+
// Step 1: Search for template
|
| 107 |
+
await searchTemplate(content)
|
| 108 |
+
} else if (conversationState === "collecting-placeholders") {
|
| 109 |
+
// Step 2: Collect placeholder values
|
| 110 |
+
await collectPlaceholder(content)
|
| 111 |
+
}
|
| 112 |
+
} catch (error) {
|
| 113 |
+
console.error("[Letter Generation Error]:", error)
|
| 114 |
+
addMessage("assistant", "I encountered an error. Please try again or rephrase your request.")
|
| 115 |
+
} finally {
|
| 116 |
+
setIsTyping(false)
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const searchTemplate = async (query: string) => {
|
| 121 |
+
try {
|
| 122 |
+
const token = localStorage.getItem("access_token")
|
| 123 |
+
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
| 124 |
+
if (token) {
|
| 125 |
+
headers["Authorization"] = `Bearer ${token}`
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const response = await fetch("/api/letter-generation", {
|
| 129 |
+
method: "POST",
|
| 130 |
+
headers,
|
| 131 |
+
body: JSON.stringify({
|
| 132 |
+
action: "search-template",
|
| 133 |
+
data: { query },
|
| 134 |
+
}),
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
const data = await response.json()
|
| 138 |
+
|
| 139 |
+
if (data.success && data.templateName) {
|
| 140 |
+
setTemplateName(data.templateName)
|
| 141 |
+
addMessage(
|
| 142 |
+
"assistant",
|
| 143 |
+
`I found a template: **${data.templateName}**. Let me get the required information for this letter.`
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
// Get template details
|
| 147 |
+
await getTemplateDetails(data.templateName)
|
| 148 |
+
} else {
|
| 149 |
+
addMessage(
|
| 150 |
+
"assistant",
|
| 151 |
+
"I couldn't find a suitable template for your request. Could you please provide more details about the type of letter you need?"
|
| 152 |
+
)
|
| 153 |
+
setConversationState("template-search")
|
| 154 |
+
}
|
| 155 |
+
} catch (error) {
|
| 156 |
+
throw error
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const getTemplateDetails = async (templateName: string) => {
|
| 161 |
+
try {
|
| 162 |
+
const token = localStorage.getItem("access_token")
|
| 163 |
+
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
| 164 |
+
if (token) {
|
| 165 |
+
headers["Authorization"] = `Bearer ${token}`
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const response = await fetch("/api/letter-generation", {
|
| 169 |
+
method: "POST",
|
| 170 |
+
headers,
|
| 171 |
+
body: JSON.stringify({
|
| 172 |
+
action: "get-template-details",
|
| 173 |
+
data: { templateName },
|
| 174 |
+
}),
|
| 175 |
+
})
|
| 176 |
+
|
| 177 |
+
const data = await response.json()
|
| 178 |
+
|
| 179 |
+
if (data.success && data.placeholders) {
|
| 180 |
+
setPlaceholders(data.placeholders)
|
| 181 |
+
setCurrentPlaceholderIndex(0)
|
| 182 |
+
setConversationState("collecting-placeholders")
|
| 183 |
+
|
| 184 |
+
if (data.placeholders.length > 0) {
|
| 185 |
+
const firstPlaceholder = data.placeholders[0]
|
| 186 |
+
addMessage(
|
| 187 |
+
"assistant",
|
| 188 |
+
`Great! I need some information from you. Please provide the **${formatPlaceholder(firstPlaceholder)}**:`
|
| 189 |
+
)
|
| 190 |
+
} else {
|
| 191 |
+
// No placeholders needed, generate directly
|
| 192 |
+
await fillTemplate(templateName, {})
|
| 193 |
+
}
|
| 194 |
+
} else {
|
| 195 |
+
addMessage("assistant", "I encountered an issue retrieving the template details. Please try again.")
|
| 196 |
+
}
|
| 197 |
+
} catch (error) {
|
| 198 |
+
throw error
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const collectPlaceholder = async (value: string) => {
|
| 203 |
+
const currentPlaceholder = placeholders[currentPlaceholderIndex]
|
| 204 |
+
const newPlaceholderData = {
|
| 205 |
+
...placeholderData,
|
| 206 |
+
[currentPlaceholder]: value,
|
| 207 |
+
}
|
| 208 |
+
setPlaceholderData(newPlaceholderData)
|
| 209 |
+
|
| 210 |
+
const nextIndex = currentPlaceholderIndex + 1
|
| 211 |
+
|
| 212 |
+
if (nextIndex < placeholders.length) {
|
| 213 |
+
// Ask for next placeholder
|
| 214 |
+
setCurrentPlaceholderIndex(nextIndex)
|
| 215 |
+
const nextPlaceholder = placeholders[nextIndex]
|
| 216 |
+
addMessage("assistant", `Thank you! Now, please provide the **${formatPlaceholder(nextPlaceholder)}**:`)
|
| 217 |
+
} else {
|
| 218 |
+
// All placeholders collected, generate letter
|
| 219 |
+
setConversationState("generating")
|
| 220 |
+
addMessage("assistant", "Perfect! I have all the information I need. Generating your letter...")
|
| 221 |
+
await fillTemplate(templateName!, newPlaceholderData)
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const fillTemplate = async (templateName: string, placeholders: PlaceholderData) => {
|
| 226 |
+
try {
|
| 227 |
+
const token = localStorage.getItem("access_token")
|
| 228 |
+
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
| 229 |
+
if (token) {
|
| 230 |
+
headers["Authorization"] = `Bearer ${token}`
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const response = await fetch("/api/letter-generation", {
|
| 234 |
+
method: "POST",
|
| 235 |
+
headers,
|
| 236 |
+
body: JSON.stringify({
|
| 237 |
+
action: "fill-template",
|
| 238 |
+
data: { templateName, placeholders },
|
| 239 |
+
}),
|
| 240 |
+
})
|
| 241 |
+
|
| 242 |
+
const data = await response.json()
|
| 243 |
+
|
| 244 |
+
if (data.success && data.letter) {
|
| 245 |
+
setGeneratedLetter(data.letter)
|
| 246 |
+
setConversationState("completed")
|
| 247 |
+
addMessage(
|
| 248 |
+
"assistant",
|
| 249 |
+
`✅ Your letter has been generated successfully! You can download it using the button below.\n\n**Preview:**\n\n${data.letter}`
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
// Cache the generated letter
|
| 253 |
+
addDocumentToCache({
|
| 254 |
+
filename: `${templateName || "letter"}_${Date.now()}.txt`,
|
| 255 |
+
type: "letter-generation",
|
| 256 |
+
result: {
|
| 257 |
+
success: true,
|
| 258 |
+
},
|
| 259 |
+
})
|
| 260 |
+
} else {
|
| 261 |
+
addMessage("assistant", "Failed to generate the letter. Please try again.")
|
| 262 |
+
setConversationState("initial")
|
| 263 |
+
}
|
| 264 |
+
} catch (error) {
|
| 265 |
+
throw error
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const formatPlaceholder = (placeholder: string): string => {
|
| 270 |
+
return placeholder
|
| 271 |
+
.replace(/{|}/g, "")
|
| 272 |
+
.replace(/_/g, " ")
|
| 273 |
+
.replace(/\b\w/g, (char) => char.toUpperCase())
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
const generateLetterImage = async () => {
|
| 277 |
+
if (!letterTemplateRef.current) {
|
| 278 |
+
console.log("Letter template ref not available")
|
| 279 |
+
return
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
setIsGeneratingImage(true)
|
| 283 |
+
try {
|
| 284 |
+
console.log("Starting image generation...")
|
| 285 |
+
|
| 286 |
+
// Create an iframe to completely isolate rendering context
|
| 287 |
+
const iframe = document.createElement('iframe')
|
| 288 |
+
iframe.style.cssText = 'position: fixed; left: -10000px; top: 0; width: 794px; height: 1123px; border: none;'
|
| 289 |
+
document.body.appendChild(iframe)
|
| 290 |
+
|
| 291 |
+
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document
|
| 292 |
+
if (!iframeDoc) throw new Error('Unable to access iframe document')
|
| 293 |
+
|
| 294 |
+
// Write clean HTML with only our content
|
| 295 |
+
iframeDoc.open()
|
| 296 |
+
iframeDoc.write(`
|
| 297 |
+
<!DOCTYPE html>
|
| 298 |
+
<html>
|
| 299 |
+
<head>
|
| 300 |
+
<meta charset="UTF-8">
|
| 301 |
+
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&display=swap" rel="stylesheet">
|
| 302 |
+
<style>
|
| 303 |
+
* { margin: 10; padding: 10; box-sizing: border-box; }
|
| 304 |
+
body { background: white; margin: 0; padding: 0; }
|
| 305 |
+
</style>
|
| 306 |
+
</head>
|
| 307 |
+
<body>${letterTemplateRef.current.innerHTML}</body>
|
| 308 |
+
</html>
|
| 309 |
+
`)
|
| 310 |
+
iframeDoc.close()
|
| 311 |
+
|
| 312 |
+
// Wait for fonts to load
|
| 313 |
+
await new Promise(resolve => setTimeout(resolve, 500))
|
| 314 |
+
|
| 315 |
+
const canvas = await html2canvas(iframeDoc.body, {
|
| 316 |
+
scale: 2,
|
| 317 |
+
useCORS: true,
|
| 318 |
+
allowTaint: true,
|
| 319 |
+
backgroundColor: '#ffffff',
|
| 320 |
+
logging: false,
|
| 321 |
+
width: 794,
|
| 322 |
+
height: 1123,
|
| 323 |
+
})
|
| 324 |
+
|
| 325 |
+
// Clean up
|
| 326 |
+
document.body.removeChild(iframe)
|
| 327 |
+
|
| 328 |
+
console.log("Canvas created, converting to data URL...")
|
| 329 |
+
const dataUrl = canvas.toDataURL("image/png", 1.0)
|
| 330 |
+
console.log("Image generated successfully")
|
| 331 |
+
setGeneratedLetterImage(dataUrl)
|
| 332 |
+
setIsGeneratingImage(false)
|
| 333 |
+
} catch (error) {
|
| 334 |
+
console.error("Image generation failed:", error)
|
| 335 |
+
setIsGeneratingImage(false)
|
| 336 |
+
addMessage("assistant", "Failed to generate letter image. You can still download the text version.")
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
useEffect(() => {
|
| 341 |
+
if (generatedLetter && conversationState === "completed") {
|
| 342 |
+
// Generate image after a short delay to ensure DOM is rendered
|
| 343 |
+
setTimeout(() => {
|
| 344 |
+
generateLetterImage()
|
| 345 |
+
}, 500)
|
| 346 |
+
}
|
| 347 |
+
}, [generatedLetter, conversationState])
|
| 348 |
+
|
| 349 |
+
const downloadLetterImage = () => {
|
| 350 |
+
if (!generatedLetterImage) {
|
| 351 |
+
console.log("No image available to download")
|
| 352 |
+
return
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
try {
|
| 356 |
+
console.log("Starting download...")
|
| 357 |
+
const link = document.createElement("a")
|
| 358 |
+
link.href = generatedLetterImage
|
| 359 |
+
link.download = `${templateName || "letter"}_${Date.now()}.png`
|
| 360 |
+
document.body.appendChild(link)
|
| 361 |
+
link.click()
|
| 362 |
+
document.body.removeChild(link)
|
| 363 |
+
console.log("Download initiated successfully")
|
| 364 |
+
} catch (error) {
|
| 365 |
+
console.error("Download failed:", error)
|
| 366 |
+
addMessage("assistant", "Download failed. Please try the Print option or download as text.")
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
const downloadLetterText = () => {
|
| 371 |
+
if (!generatedLetter) return
|
| 372 |
+
|
| 373 |
+
const blob = new Blob([generatedLetter], { type: "text/plain;charset=utf-8" })
|
| 374 |
+
const url = URL.createObjectURL(blob)
|
| 375 |
+
const a = document.createElement("a")
|
| 376 |
+
a.href = url
|
| 377 |
+
a.download = `${templateName || "letter"}_${Date.now()}.txt`
|
| 378 |
+
a.click()
|
| 379 |
+
URL.revokeObjectURL(url)
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
const printLetter = () => {
|
| 383 |
+
if (!generatedLetterImage) return
|
| 384 |
+
|
| 385 |
+
const printWindow = window.open("", "", "width=800,height=600")
|
| 386 |
+
if (!printWindow) return
|
| 387 |
+
|
| 388 |
+
printWindow.document.write(`
|
| 389 |
+
<html>
|
| 390 |
+
<head>
|
| 391 |
+
<title>Print Letter</title>
|
| 392 |
+
<style>
|
| 393 |
+
@page { size: A4; margin: 0; }
|
| 394 |
+
body { margin: 10; padding: 10; }
|
| 395 |
+
img { width: 80%; height: auto; display: block; }
|
| 396 |
+
</style>
|
| 397 |
+
</head>
|
| 398 |
+
<body>
|
| 399 |
+
<img src="${generatedLetterImage}" onload="window.print(); window.close();" />
|
| 400 |
+
</body>
|
| 401 |
+
</html>
|
| 402 |
+
`)
|
| 403 |
+
printWindow.document.close()
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
const startNew = () => {
|
| 407 |
+
setConversationState("initial")
|
| 408 |
+
setTemplateName(null)
|
| 409 |
+
setPlaceholders([])
|
| 410 |
+
setPlaceholderData({})
|
| 411 |
+
setCurrentPlaceholderIndex(0)
|
| 412 |
+
setGeneratedLetter(null)
|
| 413 |
+
setGeneratedLetterImage(null)
|
| 414 |
+
setMessages([
|
| 415 |
+
{
|
| 416 |
+
id: Date.now().toString(),
|
| 417 |
+
role: "assistant",
|
| 418 |
+
content:
|
| 419 |
+
"Let's generate another letter! What kind of letter would you like to create?",
|
| 420 |
+
timestamp: new Date(),
|
| 421 |
+
},
|
| 422 |
+
])
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
return (
|
| 426 |
+
<div className="w-full max-w-[1600px] mx-auto">
|
| 427 |
+
<div className="flex h-[calc(100vh-10rem)] gap-4">
|
| 428 |
+
{/* Main Chat Interface */}
|
| 429 |
+
<Card className="flex-1 flex flex-col shadow-lg border-primary/10 overflow-hidden bg-card/50 backdrop-blur-sm">
|
| 430 |
+
<CardHeader className="border-b py-4 flex flex-row items-center justify-between">
|
| 431 |
+
<div className="flex items-center gap-3">
|
| 432 |
+
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
| 433 |
+
<FileText className="h-5 w-5" />
|
| 434 |
+
</div>
|
| 435 |
+
<div>
|
| 436 |
+
<CardTitle className="text-lg">Letter Generation Assistant</CardTitle>
|
| 437 |
+
<div className="flex items-center gap-2">
|
| 438 |
+
<span className="h-2 w-2 rounded-full bg-success animate-pulse" />
|
| 439 |
+
<span className="text-xs text-muted-foreground">AI Powered & Ready</span>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
</div>
|
| 443 |
+
{conversationState === "completed" && (
|
| 444 |
+
<div className="flex gap-2">
|
| 445 |
+
<Button
|
| 446 |
+
variant="default"
|
| 447 |
+
size="sm"
|
| 448 |
+
onClick={downloadLetterImage}
|
| 449 |
+
disabled={!generatedLetterImage || isGeneratingImage}
|
| 450 |
+
>
|
| 451 |
+
<Download className="h-4 w-4 mr-2" />
|
| 452 |
+
{isGeneratingImage ? "Generating..." : "Download Image"}
|
| 453 |
+
</Button>
|
| 454 |
+
<Button
|
| 455 |
+
variant="outline"
|
| 456 |
+
size="sm"
|
| 457 |
+
onClick={printLetter}
|
| 458 |
+
disabled={!generatedLetterImage || isGeneratingImage}
|
| 459 |
+
>
|
| 460 |
+
<Printer className="h-4 w-4 mr-2" />
|
| 461 |
+
Print
|
| 462 |
+
</Button>
|
| 463 |
+
<Button variant="outline" size="sm" onClick={downloadLetterText}>
|
| 464 |
+
<FileText className="h-4 w-4 mr-2" />
|
| 465 |
+
Download Text
|
| 466 |
+
</Button>
|
| 467 |
+
<Button variant="secondary" size="sm" onClick={startNew}>
|
| 468 |
+
<FileText className="h-4 w-4 mr-2" />
|
| 469 |
+
New Letter
|
| 470 |
+
</Button>
|
| 471 |
+
</div>
|
| 472 |
+
)}
|
| 473 |
+
</CardHeader>
|
| 474 |
+
|
| 475 |
+
<CardContent className="flex-1 p-0 overflow-hidden">
|
| 476 |
+
<ScrollArea ref={scrollAreaRef} className="h-full">
|
| 477 |
+
<div className="space-y-6 max-w-4xl mx-auto p-4 md:p-6">
|
| 478 |
+
{messages.map((m) => (
|
| 479 |
+
<div
|
| 480 |
+
key={m.id}
|
| 481 |
+
className={cn(
|
| 482 |
+
"flex gap-3 animate-in fade-in slide-in-from-bottom-2",
|
| 483 |
+
m.role === "user" ? "flex-row-reverse" : "flex-row"
|
| 484 |
+
)}
|
| 485 |
+
>
|
| 486 |
+
<Avatar className={cn("h-8 w-8 border", m.role === "assistant" ? "bg-primary/10" : "bg-muted")}>
|
| 487 |
+
<AvatarFallback className={m.role === "assistant" ? "text-primary" : "text-muted-foreground"}>
|
| 488 |
+
{m.role === "assistant" ? <FileText className="h-4 w-4" /> : <User className="h-4 w-4" />}
|
| 489 |
+
</AvatarFallback>
|
| 490 |
+
</Avatar>
|
| 491 |
+
<div
|
| 492 |
+
className={cn("flex flex-col gap-1 max-w-[80%]", m.role === "user" ? "items-end" : "items-start")}
|
| 493 |
+
>
|
| 494 |
+
<div
|
| 495 |
+
className={cn(
|
| 496 |
+
"rounded-2xl px-4 py-3 text-sm shadow-sm",
|
| 497 |
+
m.role === "user"
|
| 498 |
+
? "bg-primary text-primary-foreground rounded-tr-none"
|
| 499 |
+
: "bg-muted/80 backdrop-blur-sm rounded-tl-none border"
|
| 500 |
+
)}
|
| 501 |
+
>
|
| 502 |
+
{m.role === "assistant" ? (
|
| 503 |
+
<div className="prose prose-sm dark:prose-invert max-w-none overflow-wrap-anywhere wrap-break-word">
|
| 504 |
+
<ReactMarkdown
|
| 505 |
+
remarkPlugins={[remarkGfm]}
|
| 506 |
+
components={{
|
| 507 |
+
p: ({ children }) => <p className="mb-3 last:mb-0 leading-relaxed">{children}</p>,
|
| 508 |
+
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
|
| 509 |
+
ul: ({ children }) => <ul className="list-disc list-inside mb-3 space-y-1">{children}</ul>,
|
| 510 |
+
ol: ({ children }) => <ol className="list-decimal list-inside mb-3 space-y-1">{children}</ol>,
|
| 511 |
+
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
| 512 |
+
code: ({ children }) => (
|
| 513 |
+
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">{children}</code>
|
| 514 |
+
),
|
| 515 |
+
pre: ({ children }) => (
|
| 516 |
+
<pre className="bg-muted p-3 rounded-lg overflow-x-auto mb-3 text-xs whitespace-pre-wrap">
|
| 517 |
+
{children}
|
| 518 |
+
</pre>
|
| 519 |
+
),
|
| 520 |
+
}}
|
| 521 |
+
>
|
| 522 |
+
{m.content}
|
| 523 |
+
</ReactMarkdown>
|
| 524 |
+
</div>
|
| 525 |
+
) : (
|
| 526 |
+
<div className="wrap-break-word">{m.content}</div>
|
| 527 |
+
)}
|
| 528 |
+
</div>
|
| 529 |
+
<span className="text-[10px] text-muted-foreground px-1">
|
| 530 |
+
{m.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
| 531 |
+
</span>
|
| 532 |
+
</div>
|
| 533 |
+
</div>
|
| 534 |
+
))}
|
| 535 |
+
{isTyping && (
|
| 536 |
+
<div className="flex gap-3 animate-in fade-in">
|
| 537 |
+
<Avatar className="h-8 w-8 border bg-primary/10">
|
| 538 |
+
<AvatarFallback className="text-primary">
|
| 539 |
+
<FileText className="h-4 w-4" />
|
| 540 |
+
</AvatarFallback>
|
| 541 |
+
</Avatar>
|
| 542 |
+
<div className="bg-muted/80 border rounded-2xl rounded-tl-none px-4 py-3 flex gap-1">
|
| 543 |
+
<span
|
| 544 |
+
className="h-1.5 w-1.5 rounded-full bg-primary/40 animate-bounce"
|
| 545 |
+
style={{ animationDelay: "0ms" }}
|
| 546 |
+
/>
|
| 547 |
+
<span
|
| 548 |
+
className="h-1.5 w-1.5 rounded-full bg-primary/40 animate-bounce"
|
| 549 |
+
style={{ animationDelay: "150ms" }}
|
| 550 |
+
/>
|
| 551 |
+
<span
|
| 552 |
+
className="h-1.5 w-1.5 rounded-full bg-primary/40 animate-bounce"
|
| 553 |
+
style={{ animationDelay: "300ms" }}
|
| 554 |
+
/>
|
| 555 |
+
</div>
|
| 556 |
+
</div>
|
| 557 |
+
)}
|
| 558 |
+
</div>
|
| 559 |
+
</ScrollArea>
|
| 560 |
+
|
| 561 |
+
{/* Preview of generated letter image */}
|
| 562 |
+
{conversationState === "completed" && (
|
| 563 |
+
<div className="p-4 border-t bg-muted/10">
|
| 564 |
+
<div className="max-w-4xl mx-auto">
|
| 565 |
+
<div className="flex justify-between items-center mb-3">
|
| 566 |
+
<h3 className="text-sm font-semibold">Generated Letter Preview (Nepali Format):</h3>
|
| 567 |
+
{!generatedLetterImage && !isGeneratingImage && (
|
| 568 |
+
<Button size="sm" variant="outline" onClick={generateLetterImage}>
|
| 569 |
+
Generate Image
|
| 570 |
+
</Button>
|
| 571 |
+
)}
|
| 572 |
+
{isGeneratingImage && (
|
| 573 |
+
<span className="text-xs text-muted-foreground flex items-center gap-2">
|
| 574 |
+
<Loader2 className="h-3 w-3 animate-spin" />
|
| 575 |
+
Generating image...
|
| 576 |
+
</span>
|
| 577 |
+
)}
|
| 578 |
+
</div>
|
| 579 |
+
{generatedLetterImage ? (
|
| 580 |
+
<div className="border rounded-lg overflow-hidden shadow-lg bg-white">
|
| 581 |
+
<img src={generatedLetterImage} alt="Nepali Letter" className="w-full" />
|
| 582 |
+
</div>
|
| 583 |
+
) : (
|
| 584 |
+
<div className="border rounded-lg p-8 text-center bg-muted/50">
|
| 585 |
+
<p className="text-sm text-muted-foreground">
|
| 586 |
+
Click "Generate Image" to create the Nepali format version
|
| 587 |
+
</p>
|
| 588 |
+
</div>
|
| 589 |
+
)}
|
| 590 |
+
</div>
|
| 591 |
+
</div>
|
| 592 |
+
)}
|
| 593 |
+
</CardContent>
|
| 594 |
+
|
| 595 |
+
<CardFooter className="p-4 border-t bg-muted/20 backdrop-blur-md">
|
| 596 |
+
<div className="w-full space-y-4 max-w-4xl mx-auto">
|
| 597 |
+
{conversationState !== "completed" && (
|
| 598 |
+
<div className="flex gap-2 relative">
|
| 599 |
+
<Input
|
| 600 |
+
placeholder={
|
| 601 |
+
conversationState === "collecting-placeholders"
|
| 602 |
+
? `Enter ${formatPlaceholder(placeholders[currentPlaceholderIndex] || "")}...`
|
| 603 |
+
: "Describe the letter you need..."
|
| 604 |
+
}
|
| 605 |
+
value={input}
|
| 606 |
+
onChange={(e) => setInput(e.target.value)}
|
| 607 |
+
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
| 608 |
+
className="pr-12 h-12 bg-background border-primary/20 focus-visible:ring-primary shadow-inner"
|
| 609 |
+
disabled={isTyping}
|
| 610 |
+
/>
|
| 611 |
+
<Button
|
| 612 |
+
size="icon"
|
| 613 |
+
onClick={() => handleSend(input)}
|
| 614 |
+
className="absolute right-1 top-1 h-10 w-10 bg-primary hover:bg-primary/90 transition-transform active:scale-95"
|
| 615 |
+
disabled={isTyping || !input.trim()}
|
| 616 |
+
>
|
| 617 |
+
{isTyping ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
| 618 |
+
</Button>
|
| 619 |
+
</div>
|
| 620 |
+
)}
|
| 621 |
+
<p className="text-[10px] text-center text-muted-foreground">
|
| 622 |
+
AI-generated letters should be reviewed before official use.
|
| 623 |
+
</p>
|
| 624 |
+
</div>
|
| 625 |
+
</CardFooter>
|
| 626 |
+
</Card>
|
| 627 |
+
</div>
|
| 628 |
+
|
| 629 |
+
{/* Hidden Nepali Paper Template for Image Generation */}
|
| 630 |
+
{generatedLetter && (
|
| 631 |
+
<div
|
| 632 |
+
ref={letterTemplateRef}
|
| 633 |
+
style={{
|
| 634 |
+
position: "fixed",
|
| 635 |
+
left: "-10000px",
|
| 636 |
+
top: "90",
|
| 637 |
+
width: "794px", // A4 width at 96 DPI
|
| 638 |
+
minHeight: "1123px", // A4 height at 96 DPI
|
| 639 |
+
backgroundColor: "rgb(255, 255, 255)",
|
| 640 |
+
fontFamily: '"Noto Sans Devanagari", "Kalimati", Arial, sans-serif',
|
| 641 |
+
fontSize: "14pt",
|
| 642 |
+
lineHeight: "1.8",
|
| 643 |
+
color: "rgb(0, 0, 0)",
|
| 644 |
+
padding: "80px 80px",
|
| 645 |
+
boxSizing: "border-box",
|
| 646 |
+
visibility: "hidden",
|
| 647 |
+
pointerEvents: "none",
|
| 648 |
+
border: "none",
|
| 649 |
+
margin: "10",
|
| 650 |
+
isolation: "isolate", // Isolate from parent styles
|
| 651 |
+
}}
|
| 652 |
+
>
|
| 653 |
+
{/* Reference Number (Date removed) */}
|
| 654 |
+
<div
|
| 655 |
+
style={{
|
| 656 |
+
marginBottom: "24px",
|
| 657 |
+
fontSize: "11pt",
|
| 658 |
+
fontWeight: "600",
|
| 659 |
+
color: "rgb(0, 0, 0)",
|
| 660 |
+
}}
|
| 661 |
+
>
|
| 662 |
+
<div style={{marginLeft:"40px",marginTop:"50px"}}>चि. नं.: {placeholderData.reference_no || placeholderData.ref_no || "............."}</div>
|
| 663 |
+
</div>
|
| 664 |
+
|
| 665 |
+
{/* Recipient Address */}
|
| 666 |
+
{(placeholderData.recipient_name || placeholderData.receiver_name) && (
|
| 667 |
+
<div style={{ marginBottom: "24px", fontSize: "12pt", color: "rgb(0, 0, 0)",marginRight:"30px", }}>
|
| 668 |
+
<div style={{ fontWeight: "600" }}>श्रीमान्,</div>
|
| 669 |
+
<div>{placeholderData.recipient_name || placeholderData.receiver_name}</div>
|
| 670 |
+
{placeholderData.recipient_designation && <div>{placeholderData.recipient_designation}</div>}
|
| 671 |
+
{placeholderData.recipient_address && <div>{placeholderData.recipient_address}</div>}
|
| 672 |
+
</div>
|
| 673 |
+
)}
|
| 674 |
+
|
| 675 |
+
{/* Subject */}
|
| 676 |
+
{placeholderData.subject && (
|
| 677 |
+
<div style={{ marginBottom: "20px", fontSize: "12pt", fontWeight: "600", color: "rgb(0, 0, 0)",marginLeft:"50px",marginRight:"30px", }}>
|
| 678 |
+
<strong>विषय:</strong> {placeholderData.subject}
|
| 679 |
+
</div>
|
| 680 |
+
)}
|
| 681 |
+
|
| 682 |
+
{/* Salutation */}
|
| 683 |
+
<div style={{ marginBottom: "16px", fontSize: "12pt", color: "rgb(0, 0, 0)",marginLeft:"40px",marginRight:"30px", }}>महोदय,</div>
|
| 684 |
+
|
| 685 |
+
{/* Main Letter Content */}
|
| 686 |
+
<div
|
| 687 |
+
style={{
|
| 688 |
+
marginBottom: "40px",
|
| 689 |
+
marginLeft: "40px",
|
| 690 |
+
marginRight:"30px",
|
| 691 |
+
textAlign: "justify",
|
| 692 |
+
whiteSpace: "pre-wrap",
|
| 693 |
+
fontSize: "12pt",
|
| 694 |
+
lineHeight: "2",
|
| 695 |
+
color: "rgb(0, 0, 0)",
|
| 696 |
+
}}
|
| 697 |
+
>
|
| 698 |
+
{generatedLetter}
|
| 699 |
+
</div>
|
| 700 |
+
|
| 701 |
+
{/* Signature Section */}
|
| 702 |
+
{/* <div style={{ marginTop: "60px", float: "right", textAlign: "center", fontSize: "12pt", color: "rgb(0, 0, 0)" }}>
|
| 703 |
+
<div
|
| 704 |
+
style={{
|
| 705 |
+
borderTop: "2px solid rgb(0, 0, 0)",
|
| 706 |
+
paddingTop: "8px",
|
| 707 |
+
minWidth: "200px",
|
| 708 |
+
marginTop: "60px",
|
| 709 |
+
}}
|
| 710 |
+
>
|
| 711 |
+
<div style={{ fontWeight: "700", marginBottom: "4px", color: "rgb(0, 0, 0)" }}>
|
| 712 |
+
{placeholderData.sender_name || placeholderData.name || "........................."}
|
| 713 |
+
</div>
|
| 714 |
+
<div style={{ fontSize: "11pt", color: "rgb(55, 65, 81)" }}>
|
| 715 |
+
{placeholderData.sender_designation ||
|
| 716 |
+
placeholderData.designation ||
|
| 717 |
+
placeholderData.position ||
|
| 718 |
+
"........................."}
|
| 719 |
+
</div>
|
| 720 |
+
{placeholderData.sender_office && (
|
| 721 |
+
<div style={{ fontSize: "10pt", color: "rgb(107, 114, 128)", marginTop: "2px" }}>
|
| 722 |
+
{placeholderData.sender_office}
|
| 723 |
+
</div>
|
| 724 |
+
)}
|
| 725 |
+
</div>
|
| 726 |
+
</div> */}
|
| 727 |
+
|
| 728 |
+
{/* Footer with contact info (if available) */}
|
| 729 |
+
<div style={{ clear: "both", marginTop: "100px", paddingTop: "20px", borderTop: "1px solid rgb(229, 231, 235)" }}>
|
| 730 |
+
<div style={{ fontSize: "9pt", color: "rgb(107, 114, 128)", textAlign: "center" }}>
|
| 731 |
+
{placeholderData.contact_email && <span>Email: {placeholderData.contact_email} | </span>}
|
| 732 |
+
{placeholderData.contact_phone && <span>Phone: {placeholderData.contact_phone}</span>}
|
| 733 |
+
</div>
|
| 734 |
+
</div>
|
| 735 |
+
</div>
|
| 736 |
+
)}
|
| 737 |
+
</div>
|
| 738 |
+
)
|
| 739 |
+
}
|
Frontend/components/common/file-upload.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import type React from "react"
|
| 4 |
+
|
| 5 |
+
import { useState, useRef } from "react"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Upload, X, CheckCircle2 } from "lucide-react"
|
| 8 |
+
import { cn } from "@/lib/utils"
|
| 9 |
+
|
| 10 |
+
interface FileUploadProps {
|
| 11 |
+
onFileSelect: (text: string) => void
|
| 12 |
+
accept?: string
|
| 13 |
+
className?: string
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function FileUpload({ onFileSelect, accept = ".txt,.pdf", className }: FileUploadProps) {
|
| 17 |
+
const [dragActive, setDragActive] = useState(false)
|
| 18 |
+
const [fileName, setFileName] = useState<string | null>(null)
|
| 19 |
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
| 20 |
+
|
| 21 |
+
const handleDrag = (e: React.DragEvent) => {
|
| 22 |
+
e.preventDefault()
|
| 23 |
+
e.stopPropagation()
|
| 24 |
+
if (e.type === "dragenter" || e.type === "dragover") {
|
| 25 |
+
setDragActive(true)
|
| 26 |
+
} else if (e.type === "dragleave") {
|
| 27 |
+
setDragActive(false)
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const processFile = async (file: File) => {
|
| 32 |
+
setFileName(file.name)
|
| 33 |
+
if (file.type === "text/plain") {
|
| 34 |
+
const text = await file.text()
|
| 35 |
+
onFileSelect(text)
|
| 36 |
+
} else {
|
| 37 |
+
// PDF processing would normally happen here via a library
|
| 38 |
+
// For this demo, we'll simulate it
|
| 39 |
+
onFileSelect(`Simulated text content from: ${file.name}\n\nThis legal notice contains various clauses...`)
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 44 |
+
e.preventDefault()
|
| 45 |
+
setDragActive(false)
|
| 46 |
+
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
| 47 |
+
processFile(e.dataTransfer.files[0])
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 52 |
+
if (e.target.files && e.target.files[0]) {
|
| 53 |
+
processFile(e.target.files[0])
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<div className={cn("relative", className)}>
|
| 59 |
+
<input
|
| 60 |
+
ref={fileInputRef}
|
| 61 |
+
type="file"
|
| 62 |
+
accept={accept}
|
| 63 |
+
onChange={handleChange}
|
| 64 |
+
className="hidden"
|
| 65 |
+
id="file-upload"
|
| 66 |
+
/>
|
| 67 |
+
<label
|
| 68 |
+
htmlFor="file-upload"
|
| 69 |
+
onDragEnter={handleDrag}
|
| 70 |
+
onDragLeave={handleDrag}
|
| 71 |
+
onDragOver={handleDrag}
|
| 72 |
+
onDrop={handleDrop}
|
| 73 |
+
className={cn(
|
| 74 |
+
"flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-xl cursor-pointer transition-all duration-200",
|
| 75 |
+
dragActive
|
| 76 |
+
? "border-primary bg-primary/5 scale-[1.01]"
|
| 77 |
+
: "border-muted hover:border-primary/50 hover:bg-muted/30",
|
| 78 |
+
fileName ? "border-success/50 bg-success/5" : "",
|
| 79 |
+
)}
|
| 80 |
+
>
|
| 81 |
+
{fileName ? (
|
| 82 |
+
<div className="flex flex-col items-center gap-2">
|
| 83 |
+
<CheckCircle2 className="h-10 w-10 text-success" />
|
| 84 |
+
<p className="text-sm font-medium">{fileName}</p>
|
| 85 |
+
<Button
|
| 86 |
+
variant="ghost"
|
| 87 |
+
size="sm"
|
| 88 |
+
className="h-7 text-xs"
|
| 89 |
+
onClick={(e) => {
|
| 90 |
+
e.preventDefault()
|
| 91 |
+
setFileName(null)
|
| 92 |
+
if (fileInputRef.current) fileInputRef.current.value = ""
|
| 93 |
+
}}
|
| 94 |
+
>
|
| 95 |
+
<X className="h-3 w-3 mr-1" /> Remove
|
| 96 |
+
</Button>
|
| 97 |
+
</div>
|
| 98 |
+
) : (
|
| 99 |
+
<div className="flex flex-col items-center gap-2 p-6 text-center">
|
| 100 |
+
<Upload className="h-10 w-10 text-muted-foreground mb-2" />
|
| 101 |
+
<p className="text-sm font-semibold">Click or drag to upload document</p>
|
| 102 |
+
<p className="text-xs text-muted-foreground">Supported formats: .txt, .pdf</p>
|
| 103 |
+
</div>
|
| 104 |
+
)}
|
| 105 |
+
</label>
|
| 106 |
+
</div>
|
| 107 |
+
)
|
| 108 |
+
}
|
Frontend/components/dashboard/stats-card.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 2 |
+
import type { LucideIcon } from "lucide-react"
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
interface StatsCardProps {
|
| 6 |
+
title: string
|
| 7 |
+
value: string | number
|
| 8 |
+
description: string
|
| 9 |
+
icon: LucideIcon
|
| 10 |
+
color: "primary" | "accent" | "success" | "destructive"
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function StatsCard({ title, value, description, icon: Icon, color }: StatsCardProps) {
|
| 14 |
+
const colorMap = {
|
| 15 |
+
primary: "bg-primary/10 text-primary border-primary/20",
|
| 16 |
+
accent: "bg-accent/10 text-accent border-accent/20",
|
| 17 |
+
success: "bg-success/10 text-success border-success/20",
|
| 18 |
+
destructive: "bg-destructive/10 text-destructive border-destructive/20",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<Card className="border-primary/10 hover:shadow-md transition-shadow">
|
| 23 |
+
<CardContent className="p-6">
|
| 24 |
+
<div className="flex items-center justify-between space-y-0 pb-2">
|
| 25 |
+
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
| 26 |
+
<div className={cn("p-2 rounded-lg border", colorMap[color])}>
|
| 27 |
+
<Icon className="h-4 w-4" />
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
<div className="pt-2">
|
| 31 |
+
<div className="text-2xl font-bold">{value}</div>
|
| 32 |
+
<p className="text-xs text-muted-foreground pt-1">{description}</p>
|
| 33 |
+
</div>
|
| 34 |
+
</CardContent>
|
| 35 |
+
</Card>
|
| 36 |
+
)
|
| 37 |
+
}
|
Frontend/components/layout/footer.tsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from "@/components/ui/button"
|
| 2 |
+
import Link from "next/link"
|
| 3 |
+
import { Scale, MessageSquare, ShieldCheck, BookOpen, ArrowRight } from "lucide-react"
|
| 4 |
+
|
| 5 |
+
export function Footer() {
|
| 6 |
+
return (
|
| 7 |
+
<footer className="w-full border-t bg-muted/30">
|
| 8 |
+
<div className="container max-w-7xl mx-auto py-12 md:py-16">
|
| 9 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
| 10 |
+
<div className="space-y-4">
|
| 11 |
+
<div className="flex items-center gap-2 text-primary">
|
| 12 |
+
<Scale className="h-6 w-6" />
|
| 13 |
+
<span className="font-bold text-lg">Know Your Rights</span>
|
| 14 |
+
</div>
|
| 15 |
+
<p className="text-sm text-muted-foreground">
|
| 16 |
+
Empowering citizens of Nepal through legal literacy and digital assistance.
|
| 17 |
+
</p>
|
| 18 |
+
{/* <div className="flex gap-4">
|
| 19 |
+
<Link href="#" className="text-muted-foreground hover:text-primary">
|
| 20 |
+
<Facebook className="h-5 w-5" />
|
| 21 |
+
</Link>
|
| 22 |
+
<Link href="#" className="text-muted-foreground hover:text-primary">
|
| 23 |
+
<Twitter className="h-5 w-5" />
|
| 24 |
+
</Link>
|
| 25 |
+
<Link href="#" className="text-muted-foreground hover:text-primary">
|
| 26 |
+
<Instagram className="h-5 w-5" />
|
| 27 |
+
</Link>
|
| 28 |
+
<Link href="#" className="text-muted-foreground hover:text-primary">
|
| 29 |
+
<Mail className="h-5 w-5" />
|
| 30 |
+
</Link>
|
| 31 |
+
</div> */}
|
| 32 |
+
</div>
|
| 33 |
+
<div className="md:col-span-3">
|
| 34 |
+
<h3 className="font-semibold mb-6 text-lg">Quick Access</h3>
|
| 35 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 36 |
+
<Link
|
| 37 |
+
href="/chatbot"
|
| 38 |
+
className="group relative overflow-hidden rounded-lg border bg-card p-5 hover:shadow-lg transition-all duration-300 hover:border-primary/50"
|
| 39 |
+
>
|
| 40 |
+
<div className="flex items-start gap-3">
|
| 41 |
+
<div className="rounded-lg bg-primary/10 p-2.5 group-hover:bg-primary/20 transition-colors">
|
| 42 |
+
<MessageSquare className="h-5 w-5 text-primary" />
|
| 43 |
+
</div>
|
| 44 |
+
<div className="flex-1">
|
| 45 |
+
<h4 className="font-semibold text-sm mb-1 group-hover:text-primary transition-colors">
|
| 46 |
+
Law Chatbot
|
| 47 |
+
</h4>
|
| 48 |
+
<p className="text-xs text-muted-foreground">
|
| 49 |
+
Get instant legal guidance
|
| 50 |
+
</p>
|
| 51 |
+
</div>
|
| 52 |
+
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
|
| 53 |
+
</div>
|
| 54 |
+
</Link>
|
| 55 |
+
|
| 56 |
+
<Link
|
| 57 |
+
href="/bias-checker"
|
| 58 |
+
className="group relative overflow-hidden rounded-lg border bg-card p-5 hover:shadow-lg transition-all duration-300 hover:border-primary/50"
|
| 59 |
+
>
|
| 60 |
+
<div className="flex items-start gap-3">
|
| 61 |
+
<div className="rounded-lg bg-primary/10 p-2.5 group-hover:bg-primary/20 transition-colors">
|
| 62 |
+
<ShieldCheck className="h-5 w-5 text-primary" />
|
| 63 |
+
</div>
|
| 64 |
+
<div className="flex-1">
|
| 65 |
+
<h4 className="font-semibold text-sm mb-1 group-hover:text-primary transition-colors">
|
| 66 |
+
Bias Checker
|
| 67 |
+
</h4>
|
| 68 |
+
<p className="text-xs text-muted-foreground">
|
| 69 |
+
Analyze legal documents
|
| 70 |
+
</p>
|
| 71 |
+
</div>
|
| 72 |
+
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
|
| 73 |
+
</div>
|
| 74 |
+
</Link>
|
| 75 |
+
|
| 76 |
+
<Link
|
| 77 |
+
href="/resources"
|
| 78 |
+
className="group relative overflow-hidden rounded-lg border bg-card p-5 hover:shadow-lg transition-all duration-300 hover:border-primary/50"
|
| 79 |
+
>
|
| 80 |
+
<div className="flex items-start gap-3">
|
| 81 |
+
<div className="rounded-lg bg-primary/10 p-2.5 group-hover:bg-primary/20 transition-colors">
|
| 82 |
+
<BookOpen className="h-5 w-5 text-primary" />
|
| 83 |
+
</div>
|
| 84 |
+
<div className="flex-1">
|
| 85 |
+
<h4 className="font-semibold text-sm mb-1 group-hover:text-primary transition-colors">
|
| 86 |
+
Legal Resources
|
| 87 |
+
</h4>
|
| 88 |
+
<p className="text-xs text-muted-foreground">
|
| 89 |
+
Browse legal information
|
| 90 |
+
</p>
|
| 91 |
+
</div>
|
| 92 |
+
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
|
| 93 |
+
</div>
|
| 94 |
+
</Link>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
{/* <div>
|
| 98 |
+
<h3 className="font-semibold mb-4">Support</h3>
|
| 99 |
+
<ul className="space-y-2 text-sm">
|
| 100 |
+
<li>
|
| 101 |
+
<Link href="#" className="text-muted-foreground hover:text-primary">
|
| 102 |
+
Contact Us
|
| 103 |
+
</Link>
|
| 104 |
+
</li>
|
| 105 |
+
<li>
|
| 106 |
+
<Link href="#" className="text-muted-foreground hover:text-primary">
|
| 107 |
+
Privacy Policy
|
| 108 |
+
</Link>
|
| 109 |
+
</li>
|
| 110 |
+
<li>
|
| 111 |
+
<Link href="#" className="text-muted-foreground hover:text-primary">
|
| 112 |
+
Terms of Service
|
| 113 |
+
</Link>
|
| 114 |
+
</li>
|
| 115 |
+
</ul>
|
| 116 |
+
</div> */}
|
| 117 |
+
{/* <div className="bg-primary/5 p-6 rounded-xl border border-primary/10">
|
| 118 |
+
<h3 className="font-semibold mb-2">Need Urgent Help?</h3>
|
| 119 |
+
<p className="text-xs text-muted-foreground mb-4">
|
| 120 |
+
Contact our 24/7 legal helpline for immediate assistance.
|
| 121 |
+
</p>
|
| 122 |
+
<Button size="sm" className="w-full">
|
| 123 |
+
Call Hotline
|
| 124 |
+
</Button>
|
| 125 |
+
</div> */}
|
| 126 |
+
</div>
|
| 127 |
+
<div className="mt-12 pt-8 border-t text-center text-xs text-muted-foreground">
|
| 128 |
+
© {new Date().getFullYear()} Know Your Rights Nepal. All rights reserved.
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</footer>
|
| 132 |
+
)
|
| 133 |
+
}
|
Frontend/components/layout/navbar.tsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import Link from "next/link"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import { useAuth } from "@/context/auth-context"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
| 8 |
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
| 9 |
+
import { Menu, X, Scale, LogOut, LayoutDashboard, MessageSquare, Search, User } from "lucide-react"
|
| 10 |
+
import { useState } from "react"
|
| 11 |
+
import { clearAuthData } from "@/lib/auth-utils"
|
| 12 |
+
|
| 13 |
+
export function Navbar() {
|
| 14 |
+
const { user, logout } = useAuth()
|
| 15 |
+
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
| 16 |
+
const router = useRouter()
|
| 17 |
+
|
| 18 |
+
const handleLogout = () => {
|
| 19 |
+
// Clear all auth data from localStorage first
|
| 20 |
+
clearAuthData()
|
| 21 |
+
// Update auth context
|
| 22 |
+
logout()
|
| 23 |
+
// Force navigation to landing page
|
| 24 |
+
window.location.href = "/"
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const handleHomeClick = (e: React.MouseEvent) => {
|
| 28 |
+
if (user) {
|
| 29 |
+
e.preventDefault()
|
| 30 |
+
router.push("/dashboard")
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Define navigation items based on authentication status
|
| 35 |
+
const getNavItems = () => {
|
| 36 |
+
if (user) {
|
| 37 |
+
// Show only protected features when logged in
|
| 38 |
+
return [
|
| 39 |
+
{ label: "Chatbot", href: "/chatbot", icon: MessageSquare },
|
| 40 |
+
{ label: "Bias Checker", href: "/bias-checker", icon: Search },
|
| 41 |
+
{ label: "Letter Generator", href: "/letter-generator" },
|
| 42 |
+
]
|
| 43 |
+
} else {
|
| 44 |
+
// Show only Home when not logged in
|
| 45 |
+
return [
|
| 46 |
+
{ label: "Home", href: "/" },
|
| 47 |
+
]
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const navItems = getNavItems()
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<nav className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
| 55 |
+
<div className="container flex h-16 items-center justify-between">
|
| 56 |
+
<div className="flex items-center gap-2">
|
| 57 |
+
<Link
|
| 58 |
+
href={user ? "/dashboard" : "/"}
|
| 59 |
+
onClick={handleHomeClick}
|
| 60 |
+
className="flex items-center gap-2 text-primary"
|
| 61 |
+
>
|
| 62 |
+
<Scale className="h-6 w-6" />
|
| 63 |
+
<span className="font-bold text-lg tracking-tight">Know Your Rights Nepal</span>
|
| 64 |
+
</Link>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
{/* Desktop Navigation */}
|
| 68 |
+
<div className="hidden md:flex items-center gap-6">
|
| 69 |
+
{navItems.map((item) => (
|
| 70 |
+
<Link key={item.href} href={item.href} className="text-sm font-medium transition-colors hover:text-primary">
|
| 71 |
+
{item.label}
|
| 72 |
+
</Link>
|
| 73 |
+
))}
|
| 74 |
+
|
| 75 |
+
{user ? (
|
| 76 |
+
<DropdownMenu>
|
| 77 |
+
<DropdownMenuTrigger asChild>
|
| 78 |
+
<Button variant="ghost" className="relative h-8 w-8 rounded-full focus-visible:ring-0 focus-visible:ring-offset-0 hover:bg-transparent">
|
| 79 |
+
<Avatar className="h-8 w-8 border hover:border-primary transition-colors">
|
| 80 |
+
<AvatarFallback className="bg-primary text-primary-foreground">
|
| 81 |
+
{user?.name ? user.name.slice(0, 2).toUpperCase() : 'U'}
|
| 82 |
+
</AvatarFallback>
|
| 83 |
+
</Avatar>
|
| 84 |
+
</Button>
|
| 85 |
+
</DropdownMenuTrigger>
|
| 86 |
+
<DropdownMenuContent align="end" className="w-56">
|
| 87 |
+
<div className="px-4 py-3 border-b">
|
| 88 |
+
<div className="font-medium">{user?.name}</div>
|
| 89 |
+
<div className="text-xs text-muted-foreground">{user?.email}</div>
|
| 90 |
+
</div>
|
| 91 |
+
<DropdownMenuItem asChild>
|
| 92 |
+
<Link href="/dashboard">
|
| 93 |
+
<LayoutDashboard className="mr-2 h-4 w-4" />
|
| 94 |
+
Dashboard
|
| 95 |
+
</Link>
|
| 96 |
+
</DropdownMenuItem>
|
| 97 |
+
<DropdownMenuItem asChild>
|
| 98 |
+
<Link href="/profile">
|
| 99 |
+
<User className="mr-2 h-4 w-4" />
|
| 100 |
+
Profile
|
| 101 |
+
</Link>
|
| 102 |
+
</DropdownMenuItem>
|
| 103 |
+
<DropdownMenuItem onClick={handleLogout} className="text-destructive">
|
| 104 |
+
<LogOut className="mr-2 h-4 w-4" />
|
| 105 |
+
Logout
|
| 106 |
+
</DropdownMenuItem>
|
| 107 |
+
</DropdownMenuContent>
|
| 108 |
+
</DropdownMenu>
|
| 109 |
+
) : (
|
| 110 |
+
<div className="flex items-center gap-2">
|
| 111 |
+
<Button asChild variant="ghost">
|
| 112 |
+
<Link href="/login">Login</Link>
|
| 113 |
+
</Button>
|
| 114 |
+
<Button asChild className="bg-primary hover:bg-primary/90">
|
| 115 |
+
<Link href="/register">Register</Link>
|
| 116 |
+
</Button>
|
| 117 |
+
</div>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
{/* Mobile Navigation */}
|
| 122 |
+
<div className="md:hidden flex items-center">
|
| 123 |
+
<Button variant="ghost" size="icon" onClick={() => setIsMenuOpen(!isMenuOpen)}>
|
| 124 |
+
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
| 125 |
+
</Button>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
{isMenuOpen && (
|
| 130 |
+
<div className="md:hidden border-t bg-background p-4 animate-in fade-in slide-in-from-top-2">
|
| 131 |
+
<div className="flex flex-col gap-4">
|
| 132 |
+
{navItems.map((item) => (
|
| 133 |
+
<Link
|
| 134 |
+
key={item.href}
|
| 135 |
+
href={item.href}
|
| 136 |
+
onClick={() => setIsMenuOpen(false)}
|
| 137 |
+
className="text-sm font-medium p-2 hover:bg-muted rounded-md"
|
| 138 |
+
>
|
| 139 |
+
{item.label}
|
| 140 |
+
</Link>
|
| 141 |
+
))}
|
| 142 |
+
|
| 143 |
+
{user ? (
|
| 144 |
+
<>
|
| 145 |
+
<Link
|
| 146 |
+
href="/dashboard"
|
| 147 |
+
onClick={() => setIsMenuOpen(false)}
|
| 148 |
+
className="text-sm font-medium p-2 hover:bg-muted rounded-md flex items-center gap-2"
|
| 149 |
+
>
|
| 150 |
+
<LayoutDashboard className="h-4 w-4" />
|
| 151 |
+
Dashboard
|
| 152 |
+
</Link>
|
| 153 |
+
<Link
|
| 154 |
+
href="/profile"
|
| 155 |
+
onClick={() => setIsMenuOpen(false)}
|
| 156 |
+
className="text-sm font-medium p-2 hover:bg-muted rounded-md flex items-center gap-2"
|
| 157 |
+
>
|
| 158 |
+
<User className="h-4 w-4" />
|
| 159 |
+
Profile
|
| 160 |
+
</Link>
|
| 161 |
+
<Button
|
| 162 |
+
onClick={() => {
|
| 163 |
+
setIsMenuOpen(false)
|
| 164 |
+
handleLogout()
|
| 165 |
+
}}
|
| 166 |
+
variant="destructive"
|
| 167 |
+
className="w-full"
|
| 168 |
+
>
|
| 169 |
+
<LogOut className="mr-2 h-4 w-4" />
|
| 170 |
+
Logout
|
| 171 |
+
</Button>
|
| 172 |
+
</>
|
| 173 |
+
) : (
|
| 174 |
+
<>
|
| 175 |
+
<Button asChild variant="outline" className="w-full">
|
| 176 |
+
<Link href="/login" onClick={() => setIsMenuOpen(false)}>
|
| 177 |
+
Login
|
| 178 |
+
</Link>
|
| 179 |
+
</Button>
|
| 180 |
+
<Button asChild className="w-full bg-primary">
|
| 181 |
+
<Link href="/register" onClick={() => setIsMenuOpen(false)}>
|
| 182 |
+
Register
|
| 183 |
+
</Link>
|
| 184 |
+
</Button>
|
| 185 |
+
</>
|
| 186 |
+
)}
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
)}
|
| 190 |
+
</nav>
|
| 191 |
+
)
|
| 192 |
+
}
|
Frontend/components/theme-provider.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import * as React from 'react'
|
| 4 |
+
import {
|
| 5 |
+
ThemeProvider as NextThemesProvider,
|
| 6 |
+
type ThemeProviderProps,
|
| 7 |
+
} from 'next-themes'
|
| 8 |
+
|
| 9 |
+
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
| 10 |
+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
| 11 |
+
}
|
Frontend/components/ui/accordion.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import * as React from 'react'
|
| 4 |
+
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
| 5 |
+
import { ChevronDownIcon } from 'lucide-react'
|
| 6 |
+
|
| 7 |
+
import { cn } from '@/lib/utils'
|
| 8 |
+
|
| 9 |
+
function Accordion({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
| 12 |
+
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function AccordionItem({
|
| 16 |
+
className,
|
| 17 |
+
...props
|
| 18 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
| 19 |
+
return (
|
| 20 |
+
<AccordionPrimitive.Item
|
| 21 |
+
data-slot="accordion-item"
|
| 22 |
+
className={cn('border-b last:border-b-0', className)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function AccordionTrigger({
|
| 29 |
+
className,
|
| 30 |
+
children,
|
| 31 |
+
...props
|
| 32 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
| 33 |
+
return (
|
| 34 |
+
<AccordionPrimitive.Header className="flex">
|
| 35 |
+
<AccordionPrimitive.Trigger
|
| 36 |
+
data-slot="accordion-trigger"
|
| 37 |
+
className={cn(
|
| 38 |
+
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
| 39 |
+
className,
|
| 40 |
+
)}
|
| 41 |
+
{...props}
|
| 42 |
+
>
|
| 43 |
+
{children}
|
| 44 |
+
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
| 45 |
+
</AccordionPrimitive.Trigger>
|
| 46 |
+
</AccordionPrimitive.Header>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AccordionContent({
|
| 51 |
+
className,
|
| 52 |
+
children,
|
| 53 |
+
...props
|
| 54 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
| 55 |
+
return (
|
| 56 |
+
<AccordionPrimitive.Content
|
| 57 |
+
data-slot="accordion-content"
|
| 58 |
+
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
| 59 |
+
{...props}
|
| 60 |
+
>
|
| 61 |
+
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
| 62 |
+
</AccordionPrimitive.Content>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
Frontend/components/ui/alert-dialog.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import * as React from 'react'
|
| 4 |
+
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
| 5 |
+
|
| 6 |
+
import { cn } from '@/lib/utils'
|
| 7 |
+
import { buttonVariants } from '@/components/ui/button'
|
| 8 |
+
|
| 9 |
+
function AlertDialog({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
| 12 |
+
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function AlertDialogTrigger({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
| 18 |
+
return (
|
| 19 |
+
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function AlertDialogPortal({
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
| 26 |
+
return (
|
| 27 |
+
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function AlertDialogOverlay({
|
| 32 |
+
className,
|
| 33 |
+
...props
|
| 34 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
| 35 |
+
return (
|
| 36 |
+
<AlertDialogPrimitive.Overlay
|
| 37 |
+
data-slot="alert-dialog-overlay"
|
| 38 |
+
className={cn(
|
| 39 |
+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
| 40 |
+
className,
|
| 41 |
+
)}
|
| 42 |
+
{...props}
|
| 43 |
+
/>
|
| 44 |
+
)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function AlertDialogContent({
|
| 48 |
+
className,
|
| 49 |
+
...props
|
| 50 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
| 51 |
+
return (
|
| 52 |
+
<AlertDialogPortal>
|
| 53 |
+
<AlertDialogOverlay />
|
| 54 |
+
<AlertDialogPrimitive.Content
|
| 55 |
+
data-slot="alert-dialog-content"
|
| 56 |
+
className={cn(
|
| 57 |
+
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
| 58 |
+
className,
|
| 59 |
+
)}
|
| 60 |
+
{...props}
|
| 61 |
+
/>
|
| 62 |
+
</AlertDialogPortal>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function AlertDialogHeader({
|
| 67 |
+
className,
|
| 68 |
+
...props
|
| 69 |
+
}: React.ComponentProps<'div'>) {
|
| 70 |
+
return (
|
| 71 |
+
<div
|
| 72 |
+
data-slot="alert-dialog-header"
|
| 73 |
+
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
| 74 |
+
{...props}
|
| 75 |
+
/>
|
| 76 |
+
)
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function AlertDialogFooter({
|
| 80 |
+
className,
|
| 81 |
+
...props
|
| 82 |
+
}: React.ComponentProps<'div'>) {
|
| 83 |
+
return (
|
| 84 |
+
<div
|
| 85 |
+
data-slot="alert-dialog-footer"
|
| 86 |
+
className={cn(
|
| 87 |
+
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
| 88 |
+
className,
|
| 89 |
+
)}
|
| 90 |
+
{...props}
|
| 91 |
+
/>
|
| 92 |
+
)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function AlertDialogTitle({
|
| 96 |
+
className,
|
| 97 |
+
...props
|
| 98 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
| 99 |
+
return (
|
| 100 |
+
<AlertDialogPrimitive.Title
|
| 101 |
+
data-slot="alert-dialog-title"
|
| 102 |
+
className={cn('text-lg font-semibold', className)}
|
| 103 |
+
{...props}
|
| 104 |
+
/>
|
| 105 |
+
)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function AlertDialogDescription({
|
| 109 |
+
className,
|
| 110 |
+
...props
|
| 111 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
| 112 |
+
return (
|
| 113 |
+
<AlertDialogPrimitive.Description
|
| 114 |
+
data-slot="alert-dialog-description"
|
| 115 |
+
className={cn('text-muted-foreground text-sm', className)}
|
| 116 |
+
{...props}
|
| 117 |
+
/>
|
| 118 |
+
)
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function AlertDialogAction({
|
| 122 |
+
className,
|
| 123 |
+
...props
|
| 124 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
| 125 |
+
return (
|
| 126 |
+
<AlertDialogPrimitive.Action
|
| 127 |
+
className={cn(buttonVariants(), className)}
|
| 128 |
+
{...props}
|
| 129 |
+
/>
|
| 130 |
+
)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function AlertDialogCancel({
|
| 134 |
+
className,
|
| 135 |
+
...props
|
| 136 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
| 137 |
+
return (
|
| 138 |
+
<AlertDialogPrimitive.Cancel
|
| 139 |
+
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
| 140 |
+
{...props}
|
| 141 |
+
/>
|
| 142 |
+
)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
export {
|
| 146 |
+
AlertDialog,
|
| 147 |
+
AlertDialogPortal,
|
| 148 |
+
AlertDialogOverlay,
|
| 149 |
+
AlertDialogTrigger,
|
| 150 |
+
AlertDialogContent,
|
| 151 |
+
AlertDialogHeader,
|
| 152 |
+
AlertDialogFooter,
|
| 153 |
+
AlertDialogTitle,
|
| 154 |
+
AlertDialogDescription,
|
| 155 |
+
AlertDialogAction,
|
| 156 |
+
AlertDialogCancel,
|
| 157 |
+
}
|