feat: docker and nginx
Working on deployments
This commit is contained in:
parent
6fad123dec
commit
5500449334
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.25.5-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/server .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["./server"]
|
||||||
Binary file not shown.
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: .
|
||||||
|
# ports:
|
||||||
|
# - "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./web
|
||||||
|
ports:
|
||||||
|
- "3005:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
@ -1,14 +1,37 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// CORS allows requests from the Vite dev server during development.
|
// CORS allows requests from the frontend.
|
||||||
// In production, update the allowed origin to match your deployed frontend domain.
|
// Supports both development (Vite dev server) and production (nginx/Docker) environments.
|
||||||
func CORS(next http.Handler) http.Handler {
|
func CORS(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
|
origin := r.Header.Get("Origin")
|
||||||
|
|
||||||
|
// Allow requests from common development and production origins
|
||||||
|
allowedOrigins := []string{
|
||||||
|
"http://localhost:5173", // Vite dev server
|
||||||
|
"http://localhost", // Docker nginx
|
||||||
|
"http://localhost:80", // Docker nginx with port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the origin is allowed
|
||||||
|
allowed := slices.Contains(allowedOrigins, origin)
|
||||||
|
|
||||||
|
// Set CORS headers if origin is allowed
|
||||||
|
if allowed {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@ -18,3 +41,89 @@ func CORS(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestHistory stores timestamps of requests for each IP address
|
||||||
|
type requestHistory struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
timestamps map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var rateLimiter = &requestHistory{
|
||||||
|
timestamps: make(map[string][]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimit restricts requests to 10 per hour per IP address
|
||||||
|
// Trace: SRD_FuncReq_0014 - Prevent users from grading more than 10 resumes per hour
|
||||||
|
// Trace: SRD_SecReq_0004 - Implement rate limit of 10 requests per hour
|
||||||
|
// Trace: SRD_SecReq_0006 - Use device sending IP to impose rate limit
|
||||||
|
// Trace: SDD_LLD_0011 - Restrict request frequency to 10 calls per hour per unique source IP
|
||||||
|
// Trace: SDD_HLD_0006 - Function only within call rate limit of 10 calls per hour
|
||||||
|
func RateLimit(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Extract client IP address
|
||||||
|
// Trace: SRD_SecReq_0006 - Use device sending IP to impose rate limit
|
||||||
|
ip := getClientIP(r)
|
||||||
|
|
||||||
|
rateLimiter.mu.Lock()
|
||||||
|
defer rateLimiter.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
oneHourAgo := now.Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
// Get request history for this IP
|
||||||
|
history := rateLimiter.timestamps[ip]
|
||||||
|
|
||||||
|
// Filter out requests older than 1 hour
|
||||||
|
var recentRequests []time.Time
|
||||||
|
for _, timestamp := range history {
|
||||||
|
if timestamp.After(oneHourAgo) {
|
||||||
|
recentRequests = append(recentRequests, timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if rate limit exceeded
|
||||||
|
// Trace: SDD_LLD_0011 - Restrict to 10 calls per hour per IP
|
||||||
|
if len(recentRequests) >= 10 {
|
||||||
|
// Trace: SRD_UseCase_0005, SRD_UseCase_0006 - Rate limit error handling
|
||||||
|
// Trace: SRD_QualAssurReq_0006 - Return error when rate limit reached
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte(`{"error": "Rate limit exceeded. You can make up to 10 requests per hour. Please try again later."}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current request to history
|
||||||
|
recentRequests = append(recentRequests, now)
|
||||||
|
rateLimiter.timestamps[ip] = recentRequests
|
||||||
|
|
||||||
|
// Allow request to proceed
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientIP extracts the real client IP address from the request
|
||||||
|
// Trace: SRD_SecReq_0006 - Use device sending IP to impose rate limit
|
||||||
|
func getClientIP(r *http.Request) string {
|
||||||
|
// Check X-Forwarded-For header (for requests behind proxies/load balancers)
|
||||||
|
xff := r.Header.Get("X-Forwarded-For")
|
||||||
|
if xff != "" {
|
||||||
|
// X-Forwarded-For can contain multiple IPs, take the first one
|
||||||
|
ips := strings.Split(xff, ",")
|
||||||
|
if len(ips) > 0 {
|
||||||
|
return strings.TrimSpace(ips[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check X-Real-IP header (alternative proxy header)
|
||||||
|
xri := r.Header.Get("X-Real-IP")
|
||||||
|
if xri != "" {
|
||||||
|
return xri
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to RemoteAddr (direct connection)
|
||||||
|
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|||||||
@ -8,10 +8,17 @@ import (
|
|||||||
|
|
||||||
// Mount registers all API routes onto the provided router.
|
// Mount registers all API routes onto the provided router.
|
||||||
// Trace: SDD_LLD_0005 - Provide HTTP handler and endpoint for form data uploads
|
// Trace: SDD_LLD_0005 - Provide HTTP handler and endpoint for form data uploads
|
||||||
|
// Trace: SDD_LLD_0011 - Enforce per-IP rate limit (10/hr) at middleware level
|
||||||
func Mount(r chi.Router) {
|
func Mount(r chi.Router) {
|
||||||
r.Use(CORS)
|
r.Use(CORS)
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
// Apply rate limiting to all API endpoints
|
||||||
|
// Trace: SRD_FuncReq_0014 - Prevent more than 10 resume gradings per hour
|
||||||
|
// Trace: SRD_SecReq_0004 - Implement rate limit of 10 requests per hour
|
||||||
|
// Trace: SDD_LLD_0011 - Rate limiting implemented at middleware level
|
||||||
|
r.Use(RateLimit)
|
||||||
|
|
||||||
// Trace: SDD_LLD_0005 - HTTP endpoint that accepts multipart/form data uploads
|
// Trace: SDD_LLD_0005 - HTTP endpoint that accepts multipart/form data uploads
|
||||||
r.Post("/analyze", handlers.Analyze)
|
r.Post("/analyze", handlers.Analyze)
|
||||||
})
|
})
|
||||||
|
|||||||
23
web/Dockerfile
Normal file
23
web/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy and install files
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files from builder and project
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
36
web/nginx.conf
Normal file
36
web/nginx.conf
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Enable gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||||
|
|
||||||
|
# Serve static files
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
148
web/src/assets/css/modal.css
Normal file
148
web/src/assets/css/modal.css
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
/* Modal Styles - Trace: SDD_LLD_0030, SRD_InterfaceReq_0015 */
|
||||||
|
|
||||||
|
/* Backdrop - darkens background when modal is open */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Container */
|
||||||
|
.modal-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1001;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -40%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Header */
|
||||||
|
.modal-header {
|
||||||
|
padding: 32px 32px 24px 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Body */
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-message {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4a5568;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Footer */
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 32px 32px 32px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer .btn {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Type Variants */
|
||||||
|
.modal-error .modal-header {
|
||||||
|
border-bottom-color: #feb2b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error .modal-icon {
|
||||||
|
color: #f56565;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-warning .modal-header {
|
||||||
|
border-bottom-color: #fbd38d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-warning .modal-icon {
|
||||||
|
color: #ed8936;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-info .modal-header {
|
||||||
|
border-bottom-color: #90cdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-info .modal-icon {
|
||||||
|
color: #4299e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal-container {
|
||||||
|
width: 95%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 24px 20px 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 12px 20px 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
web/src/components/Modal.tsx
Normal file
87
web/src/components/Modal.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import '../assets/css/modal.css';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type?: 'error' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal component for displaying messages to the user
|
||||||
|
* Trace: SDD_LLD_0030 - Display persistent error messages
|
||||||
|
* Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner (not toasts or popups)
|
||||||
|
* Trace: SRD_FuncReq_0015 - Display any errors that may occur in the process
|
||||||
|
*/
|
||||||
|
export default function Modal({ isOpen, onClose, title, message, type = 'error' }: ModalProps) {
|
||||||
|
// Close modal on Escape key press
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error':
|
||||||
|
return '⚠️';
|
||||||
|
case 'warning':
|
||||||
|
return '⚠️';
|
||||||
|
case 'info':
|
||||||
|
return 'ℹ️';
|
||||||
|
default:
|
||||||
|
return '⚠️';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Modal Backdrop */}
|
||||||
|
<div className="modal-backdrop" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div className={`modal-container modal-${type}`}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div className="modal-icon">{getIcon()}</div>
|
||||||
|
<h2 className="modal-title">{title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="modal-message">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn btn-primary"
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
web/src/config/api.ts
Normal file
15
web/src/config/api.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* API configuration
|
||||||
|
* Uses environment variable or defaults based on environment
|
||||||
|
*/
|
||||||
|
|
||||||
|
// In production (Docker), use relative path so nginx can proxy
|
||||||
|
// In development, use full URL to reach the Go backend directly
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ||
|
||||||
|
(import.meta.env.MODE === 'production' ? '' : 'http://localhost:3000');
|
||||||
|
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
analyze: `${API_BASE_URL}/api/analyze`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default API_BASE_URL;
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import type { ResumeAnalysisResult } from '../types/resumeAnalysis';
|
import type { ResumeAnalysisResult } from '../types/resumeAnalysis';
|
||||||
import { mockAnalysisResult } from '../data/mockAnalysis';
|
import Modal from '../components/Modal';
|
||||||
|
import { API_ENDPOINTS } from '../config/api';
|
||||||
import '../assets/css/upload.css';
|
import '../assets/css/upload.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,9 +20,29 @@ export default function UploadPage() {
|
|||||||
const [jobDescription, setJobDescription] = useState('');
|
const [jobDescription, setJobDescription] = useState('');
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [modalState, setModalState] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: 'error' | 'warning' | 'info';
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Helper to show modal
|
||||||
|
const showModal = (title: string, message: string, type: 'error' | 'warning' | 'info' = 'error') => {
|
||||||
|
setModalState({ isOpen: true, title, message, type });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalState(prev => ({ ...prev, isOpen: false }));
|
||||||
|
};
|
||||||
|
|
||||||
// Trace: SDD_LLD_0001 - Accept only PDF uploads (validation)
|
// Trace: SDD_LLD_0001 - Accept only PDF uploads (validation)
|
||||||
// Trace: SDD_HLD_0001 - Accept only PDF formatting for input resumes
|
// Trace: SDD_HLD_0001 - Accept only PDF formatting for input resumes
|
||||||
const handleFileSelect = (file: File) => {
|
const handleFileSelect = (file: File) => {
|
||||||
@ -43,7 +64,12 @@ export default function UploadPage() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Trace: SDD_LLD_0002 - Reject non-PDF uploads
|
// Trace: SDD_LLD_0002 - Reject non-PDF uploads
|
||||||
alert('Please upload a PDF or image file');
|
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
||||||
|
showModal(
|
||||||
|
'Invalid File Type',
|
||||||
|
'Please upload a PDF or image file. Other file formats are not supported.',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -86,11 +112,19 @@ export default function UploadPage() {
|
|||||||
// Trace: SDD_LLD_0027 - Show loading spinner during active API calls
|
// Trace: SDD_LLD_0027 - Show loading spinner during active API calls
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
if (!resumeFile) {
|
if (!resumeFile) {
|
||||||
alert('Please upload a resume first');
|
showModal(
|
||||||
|
'Resume Required',
|
||||||
|
'Please upload a resume before analyzing.',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!jobDescription.trim()) {
|
if (!jobDescription.trim()) {
|
||||||
alert('Please enter a job description');
|
showModal(
|
||||||
|
'Job Description Required',
|
||||||
|
'Please enter a job description to compare your resume against.',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,7 +138,22 @@ export default function UploadPage() {
|
|||||||
formData.append('job_description', jobDescription);
|
formData.append('job_description', jobDescription);
|
||||||
|
|
||||||
// Trace: SDD_HLD_0005 - Provide structured inputs to AI grader
|
// Trace: SDD_HLD_0005 - Provide structured inputs to AI grader
|
||||||
const response = await fetch('http://localhost:3000/api/analyze', { method: 'POST', body: formData });
|
const response = await fetch(API_ENDPOINTS.analyze, { method: 'POST', body: formData });
|
||||||
|
|
||||||
|
// Trace: SRD_UseCase_0006 - Display error message when rate limit reached
|
||||||
|
// Trace: SRD_QualAssurReq_0006 - Handle rate limit error appropriately
|
||||||
|
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
||||||
|
if (response.status === 429) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
showModal(
|
||||||
|
'Rate Limit Exceeded',
|
||||||
|
errorData.error || 'You can make up to 10 requests per hour. Please try again later.',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Server error: ${response.status}`);
|
if (!response.ok) throw new Error(`Server error: ${response.status}`);
|
||||||
|
|
||||||
// Trace: SDD_LLD_0019 - Receive JSON output from backend
|
// Trace: SDD_LLD_0019 - Receive JSON output from backend
|
||||||
@ -114,8 +163,13 @@ export default function UploadPage() {
|
|||||||
navigate('/results', { state: analysisData });
|
navigate('/results', { state: analysisData });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Trace: SDD_LLD_0030 - Display persistent error messages
|
// Trace: SDD_LLD_0030 - Display persistent error messages
|
||||||
|
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
||||||
console.error('Error analyzing resume:', error);
|
console.error('Error analyzing resume:', error);
|
||||||
alert('Failed to analyze resume. Please try again.');
|
showModal(
|
||||||
|
'Analysis Failed',
|
||||||
|
'Failed to analyze resume. Please check your connection and try again.',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
setIsAnalyzing(false);
|
setIsAnalyzing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -261,6 +315,17 @@ export default function UploadPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Error/Warning Modal */}
|
||||||
|
{/* Trace: SDD_LLD_0030 - Display persistent error messages */}
|
||||||
|
{/* Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner (not toasts or popups) */}
|
||||||
|
<Modal
|
||||||
|
isOpen={modalState.isOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={modalState.title}
|
||||||
|
message={modalState.message}
|
||||||
|
type={modalState.type}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user