Merge branch 'main' into feature/user-auth

Resolved merge conflicts integrating PR #13 (Home Page with MUI v2):

Backend changes:
- settings.py: Combined INSTALLED_APPS (accounts + jobs + corsheaders),
  kept HEAD's REST_FRAMEWORK (AllowAny + throttling) and SIMPLE_JWT,
  added origin/main's CORS config
- urls.py: Combined admin/, api/auth/, api/ routes
- pyproject.toml: Combined all dependencies (simplejwt + cors-headers)
- uv.lock: Regenerated with updated dependencies

Frontend changes:
- package.json: Combined all deps (axios + MUI + emotion)
- App.tsx: Integrated AuthProvider with MUI AppLayout, all routes
- HomePage.tsx: Show landing view when unauthenticated, MUI dashboard
  when authenticated
- main.tsx, tsconfig.json, vite-env.d.ts: Combined both versions
- yarn.lock: Kept origin/main's version (regenerated on install)
This commit is contained in:
Marko (Hermes Implementer)
2026-05-26 19:07:02 +00:00
30 changed files with 2095 additions and 58 deletions
+9 -5
View File
@@ -1,19 +1,23 @@
import { type ReactNode } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import AppLayout from "./components/AppLayout";
import HomePage from "./pages/HomePage";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
export default function App() {
export default function App(): ReactNode {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { type ReactNode } from "react";
import { Outlet } from "react-router-dom";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider, createTheme } from "@mui/material/styles";
const theme = createTheme({
palette: {
primary: {
main: "#1976d2",
},
background: {
default: "#f5f5f5",
},
},
});
export default function AppLayout(): ReactNode {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
<AppBar position="sticky">
<Toolbar>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
Job Tracker
</Typography>
</Toolbar>
</AppBar>
<Container component="main" maxWidth="lg" sx={{ mt: 4, mb: 4, flexGrow: 1 }}>
<Outlet />
</Container>
</Box>
</ThemeProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import Skeleton from "@mui/material/Skeleton";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
export default function LoadingSkeleton(): ReactNode {
return (
<Box>
{/* Metrics skeleton row */}
<Grid container spacing={3} sx={{ mb: 4 }}>
{[...Array(4)].map((_, index) => (
<Grid key={index} size={{ xs: 12, sm: 6, md: 3 }}>
<Skeleton variant="rounded" height={100} />
</Grid>
))}
</Grid>
{/* Button skeleton */}
<Skeleton variant="rounded" width={240} height={40} sx={{ mb: 3 }} />
{/* Update cards skeleton */}
<Grid container spacing={3}>
{[...Array(3)].map((_, index) => (
<Grid key={index} size={{ xs: 12, sm: 6, md: 4 }}>
<Skeleton variant="rounded" height={180} />
</Grid>
))}
</Grid>
</Box>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import Grid from "@mui/material/Grid";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import type { DashboardMetrics } from "../types/index.ts";
interface MetricsPanelProps {
metrics: DashboardMetrics;
}
interface MetricCardProps {
title: string;
value: string | number;
subtitle?: string;
}
function MetricCard({ title, value, subtitle }: MetricCardProps): ReactNode {
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent>
<Typography variant="h4" component="p" sx={{ fontWeight: 700 }}>
{value}
</Typography>
<Typography variant="body2" color="text.secondary">
{title}
</Typography>
{subtitle && (
<Typography variant="caption" color="text.secondary">
{subtitle}
</Typography>
)}
</CardContent>
</Card>
);
}
export default function MetricsPanel({ metrics }: MetricsPanelProps): ReactNode {
return (
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<MetricCard title="Total Applications" value={metrics.total_applications} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<MetricCard title="Interviews" value={metrics.interviews_count} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<MetricCard title="Offers" value={metrics.offers_count} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<MetricCard
title="Rejection Rate"
value={`${metrics.rejection_rate}%`}
/>
</Grid>
</Grid>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import Box from "@mui/material/Box";
import type { JobUpdate } from "../types/index.ts";
interface UpdateCardProps {
update: JobUpdate;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatStatus(status: string): string {
return status.charAt(0) + status.slice(1).toLowerCase();
}
const statusColors: Record<string, "default" | "success" | "info" | "warning" | "error"> = {
APPLIED: "default",
SCREENING: "info",
INTERVIEW: "info",
OFFER: "success",
REJECTED: "error",
WITHDRAWN: "default",
};
export default function UpdateCard({ update }: UpdateCardProps): ReactNode {
const changeLabel = update.from_status
? `${formatStatus(update.from_status)}${formatStatus(update.to_status)}`
: formatStatus(update.to_status);
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent>
<Typography variant="h6" component="h2" gutterBottom sx={{ fontWeight: 600 }}>
{update.company_name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{update.position_title}
</Typography>
<Box sx={{ mt: 1, mb: 1 }}>
<Chip
label={changeLabel}
size="small"
color={statusColors[update.to_status] ?? "default"}
variant="outlined"
/>
</Box>
<Typography variant="caption" color="text.secondary" display="block">
Updated: {formatDate(update.created_at)}
</Typography>
{update.notes && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{update.notes}
</Typography>
)}
{Object.keys(update.metrics).length > 0 && (
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}>
Metrics: {JSON.stringify(update.metrics)}
</Typography>
)}
</CardContent>
</Card>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, useEffect, useCallback } from "react";
import type { DashboardData, JobUpdate, DashboardMetrics } from "../types/index.ts";
interface UseDashboardDataResult {
data: DashboardData | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
const API_BASE = "/api";
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
export function useDashboardData(): UseDashboardDataResult {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [updates, metrics] = await Promise.all([
fetchJson<JobUpdate[]>(`${API_BASE}/updates/latest/`),
fetchJson<DashboardMetrics>(`${API_BASE}/updates/metrics/`),
]);
setData({ updates, metrics });
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "An unknown error occurred";
setError(message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import App from "./App.tsx";
const rootElement = document.getElementById("root");
if (!rootElement) {
@@ -11,4 +11,4 @@ createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
);
+133 -35
View File
@@ -1,41 +1,139 @@
import { Link } from "react-router-dom";
import { type ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import styles from "./HomePage.module.css";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
import Alert from "@mui/material/Alert";
import AddIcon from "@mui/icons-material/Add";
import UpdateCard from "../components/UpdateCard";
import MetricsPanel from "../components/MetricsPanel";
import LoadingSkeleton from "../components/LoadingSkeleton";
import { useDashboardData } from "../hooks/useDashboardData";
export default function HomePage() {
const { state, logout } = useAuth();
function LandingView(): ReactNode {
return (
<Box
sx={{
minHeight: "60vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
}}
>
<Typography variant="h3" component="h1" gutterBottom sx={{ fontWeight: 700 }}>
Job Tracker
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ mb: 4, maxWidth: 500 }}>
Track your job applications, interviews, and offers all in one place.
</Typography>
<Box sx={{ display: "flex", gap: 2 }}>
<Button
variant="contained"
size="large"
component={Link}
to="/login"
>
Sign In
</Button>
<Button
variant="outlined"
size="large"
component={Link}
to="/register"
>
Register
</Button>
</Box>
</Box>
);
}
function DashboardView(): ReactNode {
const navigate = useNavigate();
const { data, loading, error } = useDashboardData();
if (loading) {
return <LoadingSkeleton />;
}
return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.heading}>Job Tracker</h1>
<p className={styles.welcomeText}>
Welcome to the Job Tracker application.
</p>
<Box>
<Typography
variant="h4"
component="h2"
gutterBottom
sx={{ fontWeight: 600 }}
>
Dashboard
</Typography>
{state.isAuthenticated && state.user ? (
<div>
<p className={styles.signedInText}>
Signed in as{" "}
<strong className={styles.emailStrong}>
{state.user.email}
</strong>
</p>
<button onClick={logout} className={styles.logoutButton}>
Sign Out
</button>
</div>
) : (
<div className={styles.authLinks}>
<Link to="/login" className={styles.signInLink}>
Sign In
</Link>
<Link to="/register" className={styles.registerLink}>
Register
</Link>
</div>
)}
</div>
</div>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
Failed to load dashboard data: {error}
</Alert>
)}
{data && (
<>
<Box sx={{ mb: 4 }}>
<MetricsPanel metrics={data.metrics} />
</Box>
<Box
sx={{ mb: 3, display: "flex", justifyContent: "flex-end" }}
>
<Button
variant="contained"
size="large"
startIcon={<AddIcon />}
onClick={() => navigate("/applications/new")}
>
Create New Job Application
</Button>
</Box>
<Typography
variant="h5"
component="h3"
gutterBottom
sx={{ fontWeight: 600 }}
>
Recent Updates
</Typography>
<Grid container spacing={3}>
{data.updates.map((update) => (
<Grid key={update.id} size={{ xs: 12, sm: 6, md: 4 }}>
<UpdateCard update={update} />
</Grid>
))}
</Grid>
{data.updates.length === 0 && (
<Typography
variant="body1"
color="text.secondary"
sx={{ mt: 2 }}
>
No updates yet. Create your first job application to get started.
</Typography>
)}
</>
)}
</Box>
);
}
}
export default function HomePage(): ReactNode {
const { state } = useAuth();
if (!state.isAuthenticated) {
return <LandingView />;
}
return <DashboardView />;
}
+24
View File
@@ -0,0 +1,24 @@
export interface JobUpdate {
id: number;
job_id: number;
company_name: string;
position_title: string;
from_status: string | null;
to_status: string;
notes: string;
created_at: string;
metrics: Record<string, unknown>;
}
export interface DashboardMetrics {
total_applications: number;
status_breakdown: Record<string, number>;
interviews_count: number;
offers_count: number;
rejection_rate: number;
}
export interface DashboardData {
updates: JobUpdate[];
metrics: DashboardMetrics;
}
+9 -1
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}