supporiting documents uploaded. One step away from starting development.

This commit is contained in:
2025-08-02 14:43:20 -04:00
parent da8c5db890
commit 3f2f14ac66
23 changed files with 1633 additions and 0 deletions

6
.gitignore vendored
View File

@@ -162,10 +162,16 @@ Thumbs.db
# User data and uploads # User data and uploads
user_data/ user_data/
uploads/ uploads/
documents/
# AI model cache # AI model cache
.cache/ .cache/
models/ models/
ai_cache/
embeddings_cache/
# Database volumes
postgres_data/
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject

28
Dockerfile.backend Normal file
View File

@@ -0,0 +1,28 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements-backend.txt .
RUN pip install --no-cache-dir -r requirements-backend.txt
# Copy source code
COPY src/ ./src/
# Create non-root user
RUN useradd -m -u 1000 jobforge && chown -R jobforge:jobforge /app
USER jobforge
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["uvicorn", "src.backend.main:app", "--host", "0.0.0.0", "--port", "8000"]

23
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements-frontend.txt .
RUN pip install --no-cache-dir -r requirements-frontend.txt
# Copy source code
COPY src/ ./src/
# Create non-root user
RUN useradd -m -u 1000 jobforge && chown -R jobforge:jobforge /app
USER jobforge
EXPOSE 8501
CMD ["python", "src/frontend/main.py"]

View File

@@ -11,11 +11,15 @@ httpx==0.27.0
pandas==2.2.1 pandas==2.2.1
plotly==5.18.0 plotly==5.18.0
# File handling
Pillow==10.2.0
# Utilities # Utilities
python-dotenv==1.0.1 python-dotenv==1.0.1
structlog==24.1.0 structlog==24.1.0
# Development # Development
pytest==8.0.2 pytest==8.0.2
pytest-dash==2.5.0
black==24.2.0 black==24.2.0
isort==5.13.2 isort==5.13.2

0
src/backend/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,205 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from ..core.database import get_db
from ..models.user import User
from ..models.application import Application, ApplicationStatus
from ..models.job import Job
from .auth import get_current_user
router = APIRouter()
class ApplicationCreate(BaseModel):
job_id: int
notes: Optional[str] = None
class ApplicationUpdate(BaseModel):
status: Optional[ApplicationStatus] = None
notes: Optional[str] = None
applied_date: Optional[datetime] = None
follow_up_date: Optional[datetime] = None
class ApplicationResponse(BaseModel):
id: int
job_id: int
status: ApplicationStatus
notes: Optional[str]
applied_date: Optional[datetime]
follow_up_date: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class ApplicationWithJobResponse(ApplicationResponse):
job_title: str
company: str
location: Optional[str]
@router.post("/", response_model=ApplicationResponse)
async def create_application(
application_data: ApplicationCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
# Verify job exists
job = await db.get(Job, application_data.job_id)
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
# Check if application already exists
existing = await db.execute(
select(Application).where(
Application.user_id == current_user.id,
Application.job_id == application_data.job_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Application already exists for this job"
)
application = Application(
user_id=current_user.id,
job_id=application_data.job_id,
notes=application_data.notes
)
db.add(application)
await db.commit()
await db.refresh(application)
return ApplicationResponse.from_orm(application)
@router.get("/", response_model=List[ApplicationWithJobResponse])
async def get_applications(
status: Optional[ApplicationStatus] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
query = select(Application, Job).join(Job).where(Application.user_id == current_user.id)
if status:
query = query.where(Application.status == status)
result = await db.execute(query)
applications = []
for app, job in result.all():
app_dict = {
"id": app.id,
"job_id": app.job_id,
"status": app.status,
"notes": app.notes,
"applied_date": app.applied_date,
"follow_up_date": app.follow_up_date,
"created_at": app.created_at,
"job_title": job.title,
"company": job.company,
"location": job.location
}
applications.append(ApplicationWithJobResponse(**app_dict))
return applications
@router.get("/{application_id}", response_model=ApplicationWithJobResponse)
async def get_application(
application_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(Application, Job)
.join(Job)
.where(
Application.id == application_id,
Application.user_id == current_user.id
)
)
app_job = result.first()
if not app_job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found"
)
app, job = app_job
return ApplicationWithJobResponse(
id=app.id,
job_id=app.job_id,
status=app.status,
notes=app.notes,
applied_date=app.applied_date,
follow_up_date=app.follow_up_date,
created_at=app.created_at,
job_title=job.title,
company=job.company,
location=job.location
)
@router.put("/{application_id}", response_model=ApplicationResponse)
async def update_application(
application_id: int,
update_data: ApplicationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
application = await db.execute(
select(Application).where(
Application.id == application_id,
Application.user_id == current_user.id
)
)
application = application.scalar_one_or_none()
if not application:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found"
)
update_dict = update_data.dict(exclude_unset=True)
if update_dict.get("status") == ApplicationStatus.APPLIED and not application.applied_date:
update_dict["applied_date"] = datetime.utcnow()
for field, value in update_dict.items():
setattr(application, field, value)
await db.commit()
await db.refresh(application)
return ApplicationResponse.from_orm(application)
@router.delete("/{application_id}")
async def delete_application(
application_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
application = await db.execute(
select(Application).where(
Application.id == application_id,
Application.user_id == current_user.id
)
)
application = application.scalar_one_or_none()
if not application:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found"
)
await db.delete(application)
await db.commit()
return {"message": "Application deleted successfully"}

130
src/backend/api/auth.py Normal file
View File

@@ -0,0 +1,130 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, EmailStr
from typing import Optional
import bcrypt
import jwt
from datetime import datetime, timedelta
from ..core.database import get_db
from ..core.config import settings
from ..models.user import User
router = APIRouter()
security = HTTPBearer()
class UserCreate(BaseModel):
email: EmailStr
password: str
first_name: str
last_name: str
phone: Optional[str] = None
class UserLogin(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: int
email: str
first_name: str
last_name: str
is_active: bool
class Config:
from_attributes = True
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=settings.jwt_expire_minutes)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
):
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm]
)
user_id: int = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
user = await db.get(User, user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
return user
@router.post("/register", response_model=UserResponse)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
# Check if user exists
existing_user = await db.execute(
User.__table__.select().where(User.email == user_data.email)
)
if existing_user.first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
hashed_pwd = hash_password(user_data.password)
user = User(
email=user_data.email,
hashed_password=hashed_pwd,
first_name=user_data.first_name,
last_name=user_data.last_name,
phone=user_data.phone
)
db.add(user)
await db.commit()
await db.refresh(user)
return UserResponse.from_orm(user)
@router.post("/login", response_model=Token)
async def login(login_data: UserLogin, db: AsyncSession = Depends(get_db)):
user_result = await db.execute(
User.__table__.select().where(User.email == login_data.email)
)
user_row = user_result.first()
if not user_row or not verify_password(login_data.password, user_row.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
access_token = create_access_token(data={"sub": str(user_row.id)})
return Token(access_token=access_token)
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: User = Depends(get_current_user)):
return UserResponse.from_orm(current_user)

View File

@@ -0,0 +1,184 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import List, Optional
import structlog
from ..core.database import get_db
from ..models.user import User
from ..models.document import Document, DocumentType
from .auth import get_current_user
logger = structlog.get_logger()
router = APIRouter()
class DocumentResponse(BaseModel):
id: int
filename: str
document_type: DocumentType
file_size: Optional[int]
ai_generated: str
created_at: str
class Config:
from_attributes = True
class DocumentCreate(BaseModel):
filename: str
document_type: DocumentType
text_content: Optional[str] = None
@router.post("/upload", response_model=DocumentResponse)
async def upload_document(
file: UploadFile = File(...),
document_type: DocumentType = DocumentType.OTHER,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
try:
file_content = await file.read()
# Basic file validation
if len(file_content) > 10 * 1024 * 1024: # 10MB limit
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File too large. Maximum size is 10MB."
)
document = Document(
user_id=current_user.id,
filename=file.filename or "uploaded_file",
original_filename=file.filename,
document_type=document_type,
file_size=len(file_content),
mime_type=file.content_type,
file_content=file_content,
ai_generated="false"
)
db.add(document)
await db.commit()
await db.refresh(document)
logger.info("Document uploaded",
user_id=current_user.id,
document_id=document.id,
filename=file.filename)
return DocumentResponse.from_orm(document)
except Exception as e:
logger.error("Document upload failed", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to upload document"
)
@router.get("/", response_model=List[DocumentResponse])
async def get_documents(
document_type: Optional[DocumentType] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
query = select(Document).where(Document.user_id == current_user.id)
if document_type:
query = query.where(Document.document_type == document_type)
query = query.order_by(Document.created_at.desc())
result = await db.execute(query)
documents = result.scalars().all()
return [DocumentResponse.from_orm(doc) for doc in documents]
@router.get("/{document_id}", response_model=DocumentResponse)
async def get_document(
document_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
document = await db.execute(
select(Document).where(
Document.id == document_id,
Document.user_id == current_user.id
)
)
document = document.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
return DocumentResponse.from_orm(document)
@router.delete("/{document_id}")
async def delete_document(
document_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
document = await db.execute(
select(Document).where(
Document.id == document_id,
Document.user_id == current_user.id
)
)
document = document.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
await db.delete(document)
await db.commit()
logger.info("Document deleted",
user_id=current_user.id,
document_id=document_id)
return {"message": "Document deleted successfully"}
@router.post("/generate-cover-letter")
async def generate_cover_letter(
job_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
# Placeholder for AI cover letter generation
# In full implementation, this would use Claude/OpenAI APIs
cover_letter_content = f"""
Dear Hiring Manager,
I am writing to express my interest in the position at your company.
[AI-generated content would be here based on job requirements and user profile]
Best regards,
{current_user.full_name}
"""
document = Document(
user_id=current_user.id,
filename=f"cover_letter_job_{job_id}.txt",
document_type=DocumentType.COVER_LETTER,
text_content=cover_letter_content,
ai_generated="true",
ai_model_used="claude-3",
generation_prompt=f"Generate cover letter for job ID {job_id}"
)
db.add(document)
await db.commit()
await db.refresh(document)
return {
"message": "Cover letter generated successfully",
"document_id": document.id,
"content": cover_letter_content
}

143
src/backend/api/jobs.py Normal file
View File

@@ -0,0 +1,143 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from ..core.database import get_db
from ..models.user import User
from ..models.job import Job
from .auth import get_current_user
router = APIRouter()
class JobCreate(BaseModel):
title: str
company: str
location: Optional[str] = None
salary_min: Optional[int] = None
salary_max: Optional[int] = None
remote_option: bool = False
description: str
requirements: Optional[str] = None
benefits: Optional[str] = None
source_url: Optional[str] = None
source_platform: Optional[str] = None
posted_date: Optional[datetime] = None
class JobResponse(BaseModel):
id: int
title: str
company: str
location: Optional[str]
salary_min: Optional[int]
salary_max: Optional[int]
remote_option: bool
description: str
requirements: Optional[str]
benefits: Optional[str]
source_url: Optional[str]
posted_date: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class JobSearchResponse(BaseModel):
id: int
title: str
company: str
location: Optional[str]
salary_min: Optional[int]
salary_max: Optional[int]
remote_option: bool
description: str
match_score: Optional[float]
posted_date: Optional[datetime]
class Config:
from_attributes = True
@router.post("/", response_model=JobResponse)
async def create_job(
job_data: JobCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
job = Job(**job_data.dict())
db.add(job)
await db.commit()
await db.refresh(job)
return JobResponse.from_orm(job)
@router.get("/", response_model=List[JobSearchResponse])
async def search_jobs(
q: Optional[str] = Query(None, description="Search query"),
location: Optional[str] = Query(None, description="Location filter"),
remote: Optional[bool] = Query(None, description="Remote work filter"),
salary_min: Optional[int] = Query(None, description="Minimum salary"),
company: Optional[str] = Query(None, description="Company filter"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
query = select(Job).where(Job.is_active == True)
if q:
search_filter = func.lower(Job.title).contains(q.lower()) | \
func.lower(Job.description).contains(q.lower()) | \
func.lower(Job.company).contains(q.lower())
query = query.where(search_filter)
if location:
query = query.where(func.lower(Job.location).contains(location.lower()))
if remote is not None:
query = query.where(Job.remote_option == remote)
if salary_min:
query = query.where(Job.salary_min >= salary_min)
if company:
query = query.where(func.lower(Job.company).contains(company.lower()))
query = query.order_by(Job.posted_date.desc().nullslast(), Job.created_at.desc())
query = query.offset(offset).limit(limit)
result = await db.execute(query)
jobs = result.scalars().all()
return [JobSearchResponse.from_orm(job) for job in jobs]
@router.get("/{job_id}", response_model=JobResponse)
async def get_job(
job_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
job = await db.get(Job, job_id)
if not job or not job.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
return JobResponse.from_orm(job)
@router.get("/recommendations/")
async def get_job_recommendations(
limit: int = Query(10, ge=1, le=50),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
# For now, return recent jobs sorted by created date
# In a full implementation, this would use AI matching based on user profile
query = select(Job).where(Job.is_active == True).order_by(Job.created_at.desc()).limit(limit)
result = await db.execute(query)
jobs = result.scalars().all()
return [JobSearchResponse.from_orm(job) for job in jobs]

View File

View File

@@ -0,0 +1,30 @@
from pydantic_settings import BaseSettings
from pydantic import Field
import os
class Settings(BaseSettings):
database_url: str = Field(
default="postgresql+asyncpg://jobforge_user:jobforge_password@localhost:5432/jobforge_mvp",
env="DATABASE_URL"
)
claude_api_key: str = Field(env="CLAUDE_API_KEY")
openai_api_key: str = Field(env="OPENAI_API_KEY")
jwt_secret_key: str = Field(env="JWT_SECRET_KEY")
debug: bool = Field(default=False, env="DEBUG")
log_level: str = Field(default="INFO", env="LOG_LEVEL")
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24 * 7 # 7 days
cors_origins: list[str] = [
"http://localhost:8501",
"http://frontend:8501"
]
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()

View File

@@ -0,0 +1,49 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import text
import structlog
from .config import settings
logger = structlog.get_logger()
class Base(DeclarativeBase):
pass
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
pool_pre_ping=True,
pool_recycle=300
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
try:
async with engine.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
logger.info("Database extensions initialized")
from ..models import user, application, job, document
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables created")
except Exception as e:
logger.error("Database initialization failed", error=str(e))
raise

63
src/backend/main.py Normal file
View File

@@ -0,0 +1,63 @@
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import structlog
from contextlib import asynccontextmanager
from .core.config import settings
from .core.database import init_db
from .api import auth, applications, jobs, documents
logger = structlog.get_logger()
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting Job Forge backend...")
await init_db()
logger.info("Database initialized")
yield
logger.info("Shutting down Job Forge backend...")
app = FastAPI(
title="Job Forge API",
description="AI-Powered Job Application Assistant",
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8501", "http://frontend:8501"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "job-forge-backend"}
@app.get("/")
async def root():
return {"message": "Job Forge API", "version": "1.0.0"}
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(applications.router, prefix="/api/applications", tags=["Applications"])
app.include_router(jobs.router, prefix="/api/jobs", tags=["Jobs"])
app.include_router(documents.router, prefix="/api/documents", tags=["Documents"])
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
logger.error("HTTP exception", status_code=exc.status_code, detail=exc.detail)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail}
)
@app.exception_handler(Exception)
async def general_exception_handler(request, exc):
logger.error("Unhandled exception", error=str(exc))
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"}
)

0
src/frontend/__init__.py Normal file
View File

235
src/frontend/callbacks.py Normal file
View File

@@ -0,0 +1,235 @@
from dash import Input, Output, State, callback, clientside_callback
import dash_mantine_components as dmc
import httpx
import structlog
from .pages.home import create_home_page
from .pages.auth import create_login_page
logger = structlog.get_logger()
def register_callbacks(app, config):
@app.callback(
Output("page-content", "children"),
Output("header-actions", "children"),
Input("url", "pathname"),
State("auth-store", "data")
)
def display_page(pathname, auth_data):
# Check if user is authenticated
is_authenticated = auth_data and auth_data.get("token")
if not is_authenticated:
# Show login page for unauthenticated users
if pathname == "/login" or pathname is None or pathname == "/":
return create_login_page(), []
else:
return create_login_page(), []
# Authenticated user navigation
header_actions = [
dmc.Button(
"Logout",
id="logout-btn",
variant="outline",
color="red",
leftIcon="tabler:logout"
)
]
# Route to different pages
if pathname == "/" or pathname is None:
return create_home_page(), header_actions
elif pathname == "/jobs":
return create_jobs_page(), header_actions
elif pathname == "/applications":
return create_applications_page(), header_actions
elif pathname == "/documents":
return create_documents_page(), header_actions
elif pathname == "/profile":
return create_profile_page(), header_actions
else:
return create_home_page(), header_actions
@app.callback(
Output("auth-store", "data"),
Output("auth-alerts", "children"),
Input("login-submit", "n_clicks"),
State("login-email", "value"),
State("login-password", "value"),
prevent_initial_call=True
)
def handle_login(n_clicks, email, password):
if not n_clicks or not email or not password:
return None, []
try:
response = httpx.post(
f"{config.auth_url}/login",
json={"email": email, "password": password},
timeout=10.0
)
if response.status_code == 200:
token_data = response.json()
auth_data = {
"token": token_data["access_token"],
"email": email
}
success_alert = dmc.Alert(
"Login successful! Redirecting...",
title="Success",
color="green",
duration=3000
)
return auth_data, success_alert
else:
error_alert = dmc.Alert(
"Invalid email or password",
title="Login Failed",
color="red"
)
return None, error_alert
except Exception as e:
logger.error("Login error", error=str(e))
error_alert = dmc.Alert(
"Connection error. Please try again.",
title="Error",
color="red"
)
return None, error_alert
@app.callback(
Output("auth-store", "data", allow_duplicate=True),
Output("auth-alerts", "children", allow_duplicate=True),
Input("register-submit", "n_clicks"),
State("register-email", "value"),
State("register-password", "value"),
State("register-password-confirm", "value"),
State("register-first-name", "value"),
State("register-last-name", "value"),
State("register-phone", "value"),
prevent_initial_call=True
)
def handle_register(n_clicks, email, password, password_confirm, first_name, last_name, phone):
if not n_clicks:
return None, []
# Validation
if not all([email, password, first_name, last_name]):
error_alert = dmc.Alert(
"All required fields must be filled",
title="Validation Error",
color="red"
)
return None, error_alert
if password != password_confirm:
error_alert = dmc.Alert(
"Passwords do not match",
title="Validation Error",
color="red"
)
return None, error_alert
try:
user_data = {
"email": email,
"password": password,
"first_name": first_name,
"last_name": last_name
}
if phone:
user_data["phone"] = phone
response = httpx.post(
f"{config.auth_url}/register",
json=user_data,
timeout=10.0
)
if response.status_code == 200:
# Auto-login after successful registration
login_response = httpx.post(
f"{config.auth_url}/login",
json={"email": email, "password": password},
timeout=10.0
)
if login_response.status_code == 200:
token_data = login_response.json()
auth_data = {
"token": token_data["access_token"],
"email": email
}
success_alert = dmc.Alert(
"Registration successful! Welcome to Job Forge!",
title="Success",
color="green",
duration=3000
)
return auth_data, success_alert
error_alert = dmc.Alert(
"Registration failed. Email may already be in use.",
title="Registration Failed",
color="red"
)
return None, error_alert
except Exception as e:
logger.error("Registration error", error=str(e))
error_alert = dmc.Alert(
"Connection error. Please try again.",
title="Error",
color="red"
)
return None, error_alert
@app.callback(
Output("auth-store", "clear_data"),
Input("logout-btn", "n_clicks"),
prevent_initial_call=True
)
def handle_logout(n_clicks):
if n_clicks:
return True
return False
# Placeholder functions for other pages
def create_jobs_page():
return dmc.Container(
children=[
dmc.Title("Job Search", mb="lg"),
dmc.Text("Job search functionality coming soon...")
]
)
def create_applications_page():
return dmc.Container(
children=[
dmc.Title("My Applications", mb="lg"),
dmc.Text("Application tracking functionality coming soon...")
]
)
def create_documents_page():
return dmc.Container(
children=[
dmc.Title("Documents", mb="lg"),
dmc.Text("Document management functionality coming soon...")
]
)
def create_profile_page():
return dmc.Container(
children=[
dmc.Title("Profile", mb="lg"),
dmc.Text("Profile management functionality coming soon...")
]
)

27
src/frontend/config.py Normal file
View File

@@ -0,0 +1,27 @@
import os
from typing import Optional
class Config:
def __init__(self):
self.BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
self.DEBUG = os.getenv("DEBUG", "false").lower() == "true"
@property
def api_base_url(self) -> str:
return f"{self.BACKEND_URL}/api"
@property
def auth_url(self) -> str:
return f"{self.api_base_url}/auth"
@property
def applications_url(self) -> str:
return f"{self.api_base_url}/applications"
@property
def jobs_url(self) -> str:
return f"{self.api_base_url}/jobs"
@property
def documents_url(self) -> str:
return f"{self.api_base_url}/documents"

View File

View File

@@ -0,0 +1,121 @@
from dash import html, dcc
import dash_mantine_components as dmc
from dash_iconify import DashIconify
def create_layout():
return dmc.MantineProvider(
theme={
"fontFamily": "'Inter', sans-serif",
"primaryColor": "blue",
"components": {
"Button": {"styles": {"root": {"fontWeight": 400}}},
"Alert": {"styles": {"title": {"fontWeight": 500}}},
"AvatarGroup": {"styles": {"truncated": {"fontWeight": 500}}},
},
},
children=[
dcc.Store(id="auth-store", storage_type="session"),
dcc.Store(id="user-store", storage_type="session"),
dcc.Location(id="url", refresh=False),
html.Div(
id="main-content",
children=[
create_header(),
html.Div(id="page-content")
]
)
]
)
def create_header():
return dmc.Header(
height=70,
fixed=True,
children=[
dmc.Container(
size="xl",
children=[
dmc.Group(
position="apart",
align="center",
style={"height": 70},
children=[
dmc.Group(
align="center",
spacing="xs",
children=[
DashIconify(
icon="tabler:briefcase",
width=32,
color="#228BE6"
),
dmc.Text(
"Job Forge",
size="xl",
weight=700,
color="blue"
)
]
),
dmc.Group(
id="header-actions",
spacing="md",
children=[
dmc.Button(
"Login",
id="login-btn",
variant="outline",
leftIcon=DashIconify(icon="tabler:login")
)
]
)
]
)
]
)
]
)
def create_navigation():
return dmc.Navbar(
width={"base": 300},
children=[
dmc.ScrollArea(
style={"height": "calc(100vh - 70px)"},
children=[
dmc.NavLink(
label="Dashboard",
icon=DashIconify(icon="tabler:dashboard"),
href="/",
id="nav-dashboard"
),
dmc.NavLink(
label="Job Search",
icon=DashIconify(icon="tabler:search"),
href="/jobs",
id="nav-jobs"
),
dmc.NavLink(
label="Applications",
icon=DashIconify(icon="tabler:briefcase"),
href="/applications",
id="nav-applications"
),
dmc.NavLink(
label="Documents",
icon=DashIconify(icon="tabler:file-text"),
href="/documents",
id="nav-documents"
),
dmc.NavLink(
label="Profile",
icon=DashIconify(icon="tabler:user"),
href="/profile",
id="nav-profile"
)
]
)
]
)

36
src/frontend/main.py Normal file
View File

@@ -0,0 +1,36 @@
import dash
from dash import html, dcc
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import os
from .config import Config
from .layouts.layout import create_layout
from .callbacks import register_callbacks
# Initialize config
config = Config()
# Initialize Dash app
app = dash.Dash(
__name__,
external_stylesheets=[
"https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
],
suppress_callback_exceptions=True,
title="Job Forge - AI-Powered Job Application Assistant"
)
# Set up the layout
app.layout = create_layout()
# Register callbacks
register_callbacks(app, config)
if __name__ == "__main__":
app.run_server(
host="0.0.0.0",
port=8501,
debug=config.DEBUG,
dev_tools_hot_reload=config.DEBUG
)

View File

164
src/frontend/pages/auth.py Normal file
View File

@@ -0,0 +1,164 @@
from dash import html, dcc
import dash_mantine_components as dmc
from dash_iconify import DashIconify
def create_login_page():
return dmc.Container(
size="xs",
style={"marginTop": "10vh"},
children=[
dmc.Paper(
shadow="lg",
radius="md",
p="xl",
children=[
dmc.Group(
position="center",
mb="xl",
children=[
DashIconify(
icon="tabler:briefcase",
width=40,
color="#228BE6"
),
dmc.Title("Job Forge", order=2, color="blue")
]
),
dmc.Tabs(
id="auth-tabs",
value="login",
children=[
dmc.TabsList(
grow=True,
children=[
dmc.Tab("Login", value="login"),
dmc.Tab("Register", value="register")
]
),
dmc.TabsPanel(
value="login",
children=[
html.Form(
id="login-form",
children=[
dmc.TextInput(
id="login-email",
label="Email",
placeholder="your.email@example.com",
icon=DashIconify(icon="tabler:mail"),
required=True,
mb="md"
),
dmc.PasswordInput(
id="login-password",
label="Password",
placeholder="Your password",
icon=DashIconify(icon="tabler:lock"),
required=True,
mb="xl"
),
dmc.Button(
"Login",
id="login-submit",
fullWidth=True,
leftIcon=DashIconify(icon="tabler:login")
)
]
)
]
),
dmc.TabsPanel(
value="register",
children=[
html.Form(
id="register-form",
children=[
dmc.Group(
grow=True,
children=[
dmc.TextInput(
id="register-first-name",
label="First Name",
placeholder="John",
required=True,
style={"flex": 1}
),
dmc.TextInput(
id="register-last-name",
label="Last Name",
placeholder="Doe",
required=True,
style={"flex": 1}
)
]
),
dmc.TextInput(
id="register-email",
label="Email",
placeholder="your.email@example.com",
icon=DashIconify(icon="tabler:mail"),
required=True,
mt="md"
),
dmc.TextInput(
id="register-phone",
label="Phone (Optional)",
placeholder="+1 (555) 123-4567",
icon=DashIconify(icon="tabler:phone"),
mt="md"
),
dmc.PasswordInput(
id="register-password",
label="Password",
placeholder="Your password",
icon=DashIconify(icon="tabler:lock"),
required=True,
mt="md"
),
dmc.PasswordInput(
id="register-password-confirm",
label="Confirm Password",
placeholder="Confirm your password",
icon=DashIconify(icon="tabler:lock"),
required=True,
mt="md",
mb="xl"
),
dmc.Button(
"Register",
id="register-submit",
fullWidth=True,
leftIcon=DashIconify(icon="tabler:user-plus")
)
]
)
]
)
]
),
html.Div(id="auth-alerts", style={"marginTop": "1rem"})
]
)
]
)
def create_logout_confirmation():
return dmc.Modal(
title="Confirm Logout",
id="logout-modal",
children=[
dmc.Text("Are you sure you want to logout?"),
dmc.Group(
position="right",
mt="md",
children=[
dmc.Button("Cancel", id="logout-cancel", variant="outline"),
dmc.Button("Logout", id="logout-confirm", color="red")
]
)
]
)

185
src/frontend/pages/home.py Normal file
View File

@@ -0,0 +1,185 @@
from dash import html
import dash_mantine_components as dmc
from dash_iconify import DashIconify
def create_home_page():
return dmc.Container(
size="xl",
pt="md",
children=[
dmc.Title("Welcome to Job Forge", order=1, mb="lg"),
dmc.Grid(
children=[
dmc.Col(
dmc.Card(
children=[
dmc.Group(
children=[
DashIconify(
icon="tabler:search",
width=40,
color="#228BE6"
),
dmc.Stack(
spacing=5,
children=[
dmc.Text("Find Jobs", weight=600, size="lg"),
dmc.Text(
"Search and discover job opportunities",
size="sm",
color="dimmed"
)
]
)
]
),
dmc.Button(
"Search Jobs",
fullWidth=True,
mt="md",
id="home-search-jobs-btn"
)
],
withBorder=True,
shadow="sm",
radius="md",
p="lg"
),
span=6
),
dmc.Col(
dmc.Card(
children=[
dmc.Group(
children=[
DashIconify(
icon="tabler:briefcase",
width=40,
color="#40C057"
),
dmc.Stack(
spacing=5,
children=[
dmc.Text("Track Applications", weight=600, size="lg"),
dmc.Text(
"Manage your job applications",
size="sm",
color="dimmed"
)
]
)
]
),
dmc.Button(
"View Applications",
fullWidth=True,
mt="md",
color="green",
id="home-applications-btn"
)
],
withBorder=True,
shadow="sm",
radius="md",
p="lg"
),
span=6
),
dmc.Col(
dmc.Card(
children=[
dmc.Group(
children=[
DashIconify(
icon="tabler:file-text",
width=40,
color="#FD7E14"
),
dmc.Stack(
spacing=5,
children=[
dmc.Text("AI Documents", weight=600, size="lg"),
dmc.Text(
"Generate resumes and cover letters",
size="sm",
color="dimmed"
)
]
)
]
),
dmc.Button(
"Create Documents",
fullWidth=True,
mt="md",
color="orange",
id="home-documents-btn"
)
],
withBorder=True,
shadow="sm",
radius="md",
p="lg"
),
span=6
),
dmc.Col(
dmc.Card(
children=[
dmc.Group(
children=[
DashIconify(
icon="tabler:user",
width=40,
color="#BE4BDB"
),
dmc.Stack(
spacing=5,
children=[
dmc.Text("Profile", weight=600, size="lg"),
dmc.Text(
"Manage your profile and settings",
size="sm",
color="dimmed"
)
]
)
]
),
dmc.Button(
"Edit Profile",
fullWidth=True,
mt="md",
color="violet",
id="home-profile-btn"
)
],
withBorder=True,
shadow="sm",
radius="md",
p="lg"
),
span=6
)
],
gutter="md"
),
dmc.Divider(my="xl"),
dmc.Title("Recent Activity", order=2, mb="md"),
dmc.Card(
children=[
dmc.Text("No recent activity yet. Start by searching for jobs or uploading your resume!")
],
withBorder=True,
shadow="sm",
radius="md",
p="lg"
)
]
)