Compare commits
No commits in common. "340d06944032c9f914eb6f03ced18c7f81e70fd1" and "c101d49d195cdbebbebfde44ef8f7c6d95dee11a" have entirely different histories.
340d069440
...
c101d49d19
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,3 @@
|
|||||||
/flake.lock
|
/flake.lock
|
||||||
/.env
|
/.env
|
||||||
/**/.env
|
/**/.env
|
||||||
/.pat
|
|
||||||
|
|||||||
13
DEPLOY.md
13
DEPLOY.md
@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
## Build and push backend
|
|
||||||
|
|
||||||
```zsh
|
|
||||||
docker build -t git.gophernest.net/azpect/resumelens/backend:latest .
|
|
||||||
docker push git.gophernest.net/azpect/resumelens/backend:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build and push frontend
|
|
||||||
```zsh
|
|
||||||
docker build -t git.gophernest.net/azpect/resumelens/frontend:latest ./web
|
|
||||||
docker push git.gophernest.net/azpect/resumelens/frontend:latest
|
|
||||||
```
|
|
||||||
22
Dockerfile
22
Dockerfile
@ -1,22 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
BIN
HaydenHargreaves_Oct01.pdf
Normal file
BIN
HaydenHargreaves_Oct01.pdf
Normal file
Binary file not shown.
@ -1,14 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
build: .
|
|
||||||
# ports:
|
|
||||||
# - "3000:3000"
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build: ./web
|
|
||||||
ports:
|
|
||||||
- "3005:80"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
@ -1,37 +1,14 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import "net/http"
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CORS allows requests from the frontend.
|
// CORS allows requests from the Vite dev server during development.
|
||||||
// Supports both development (Vite dev server) and production (nginx/Docker) environments.
|
// In production, update the allowed origin to match your deployed frontend domain.
|
||||||
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) {
|
||||||
origin := r.Header.Get("Origin")
|
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
// Allow requests from common development and production origins
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
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-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)
|
||||||
@ -41,89 +18,3 @@ 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,17 +8,10 @@ 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)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
# 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;"]
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { ResumeAnalysisResult } from '../../types/resumeAnalysis';
|
|
||||||
|
|
||||||
interface AnalysisActionsProps {
|
interface AnalysisActionsProps {
|
||||||
primaryAction?: {
|
primaryAction?: {
|
||||||
@ -10,61 +9,17 @@ interface AnalysisActionsProps {
|
|||||||
label: string;
|
label: string;
|
||||||
to: string;
|
to: string;
|
||||||
};
|
};
|
||||||
analysisData?: ResumeAnalysisResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default function AnalysisActions({
|
||||||
* AnalysisActions component with navigation and download options
|
|
||||||
* Trace: SRD_FuncReq_0018 - Allow user to download response as JSON file
|
|
||||||
* Trace: SDD_LLD_0019 - Marshal evaluation data structure into formatted JSON for local user storage
|
|
||||||
*/
|
|
||||||
export default function AnalysisActions({
|
|
||||||
primaryAction = { label: 'Analyze Another Resume', to: '/upload' },
|
primaryAction = { label: 'Analyze Another Resume', to: '/upload' },
|
||||||
secondaryAction = { label: 'Back to Home', to: '/' },
|
secondaryAction = { label: 'Back to Home', to: '/' }
|
||||||
analysisData
|
|
||||||
}: AnalysisActionsProps) {
|
}: AnalysisActionsProps) {
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads the analysis results as a JSON file
|
|
||||||
* Trace: SRD_FuncReq_0018 - Allow user to download response as JSON file
|
|
||||||
* Trace: SDD_LLD_0019 - Marshal completed evaluation data structure into formatted JSON
|
|
||||||
*/
|
|
||||||
const handleDownloadJSON = () => {
|
|
||||||
if (!analysisData) return;
|
|
||||||
|
|
||||||
// Create formatted JSON string
|
|
||||||
const jsonString = JSON.stringify(analysisData, null, 2);
|
|
||||||
|
|
||||||
// Create blob and download link
|
|
||||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// Create temporary link and trigger download
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `resume-analysis-${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="demo-actions">
|
<div className="demo-actions">
|
||||||
<Link to={primaryAction.to} className="btn btn-primary btn-large">
|
<Link to={primaryAction.to} className="btn btn-primary btn-large">
|
||||||
{primaryAction.label}
|
{primaryAction.label}
|
||||||
</Link>
|
</Link>
|
||||||
{analysisData && (
|
|
||||||
<button
|
|
||||||
onClick={handleDownloadJSON}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
title="Download analysis results as JSON file"
|
|
||||||
>
|
|
||||||
Download JSON
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Link to={secondaryAction.to} className="btn btn-secondary">
|
<Link to={secondaryAction.to} className="btn btn-secondary">
|
||||||
{secondaryAction.label}
|
{secondaryAction.label}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@ -72,8 +72,7 @@ export default function ResultsPage() {
|
|||||||
{/* Trace: SRD_InterfaceReq_0010 - Display rating of grammar and spelling errors */}
|
{/* Trace: SRD_InterfaceReq_0010 - Display rating of grammar and spelling errors */}
|
||||||
<GrammarSection grammarSpelling={analysisData.grammar_spelling} />
|
<GrammarSection grammarSpelling={analysisData.grammar_spelling} />
|
||||||
|
|
||||||
{/* Trace: SRD_FuncReq_0018 - Allow user to download response as JSON file */}
|
<AnalysisActions />
|
||||||
<AnalysisActions analysisData={analysisData} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
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 Modal from '../components/Modal';
|
import { mockAnalysisResult } from '../data/mockAnalysis';
|
||||||
import { API_ENDPOINTS } from '../config/api';
|
|
||||||
import '../assets/css/upload.css';
|
import '../assets/css/upload.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,29 +19,9 @@ 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) => {
|
||||||
@ -64,12 +43,7 @@ export default function UploadPage() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Trace: SDD_LLD_0002 - Reject non-PDF uploads
|
// Trace: SDD_LLD_0002 - Reject non-PDF uploads
|
||||||
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
alert('Please upload a PDF or image file');
|
||||||
showModal(
|
|
||||||
'Invalid File Type',
|
|
||||||
'Please upload a PDF or image file. Other file formats are not supported.',
|
|
||||||
'warning'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -112,19 +86,11 @@ 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) {
|
||||||
showModal(
|
alert('Please upload a resume first');
|
||||||
'Resume Required',
|
|
||||||
'Please upload a resume before analyzing.',
|
|
||||||
'warning'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!jobDescription.trim()) {
|
if (!jobDescription.trim()) {
|
||||||
showModal(
|
alert('Please enter a job description');
|
||||||
'Job Description Required',
|
|
||||||
'Please enter a job description to compare your resume against.',
|
|
||||||
'warning'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,22 +104,7 @@ 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(API_ENDPOINTS.analyze, { method: 'POST', body: formData });
|
const response = await fetch('http://localhost:3000/api/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
|
||||||
@ -163,13 +114,8 @@ 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);
|
||||||
showModal(
|
alert('Failed to analyze resume. Please try again.');
|
||||||
'Analysis Failed',
|
|
||||||
'Failed to analyze resume. Please check your connection and try again.',
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
setIsAnalyzing(false);
|
setIsAnalyzing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -315,17 +261,6 @@ 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