Reformatting tsx

This commit is contained in:
James Ketr 2025-06-18 14:26:07 -07:00
parent 54d5df3fac
commit f9307070a3
84 changed files with 13793 additions and 9676 deletions

4
frontend/.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
build
coverage

43
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,43 @@
{
"env": {
"browser": true,
"es2021": true,
"jest": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"plugins": [
"@typescript-eslint",
"react",
"react-hooks",
"prettier"
],
"rules": {
"react/prop-types": "off",
"@typescript-eslint/explicit-function-return-type": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }],
"prettier/prettier": "error"
},
"settings": {
"react": {
"version": "detect"
},
"import/resolver": {
"typescript": {}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@
"react-phone-number-input": "^3.4.12", "react-phone-number-input": "^3.4.12",
"react-plotly.js": "^2.6.0", "react-plotly.js": "^2.6.0",
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"react-scripts": "5.0.1", "react-scripts": "^5.0.1",
"react-spinners": "^0.15.0", "react-spinners": "^0.15.0",
"react-to-print": "^3.1.0", "react-to-print": "^3.1.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
@ -50,7 +50,10 @@
"scripts": { "scripts": {
"start": "WDS_SOCKET_HOST=backstory-beta.ketrenos.com WDS_SOCKET_PORT=443 craco start", "start": "WDS_SOCKET_HOST=backstory-beta.ketrenos.com WDS_SOCKET_PORT=443 craco start",
"build": "craco build", "build": "craco build",
"test": "craco test" "test": "craco test",
"lint": "eslint src/**/*.{ts,tsx}",
"lint:fix": "eslint src/**/*.{ts,tsx} --fix",
"format": "prettier --write src/**/*.{ts,tsx}"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@ -73,6 +76,14 @@
"devDependencies": { "devDependencies": {
"@craco/craco": "^7.1.0", "@craco/craco": "^7.1.0",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/plotly.js": "^2.35.5" "@types/plotly.js": "^2.35.5",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8"
} }
} }

10
frontend/prettier Normal file
View File

@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid"
}

View File

@ -15,7 +15,7 @@ pre {
overflow: auto; overflow: auto;
white-space: pre-wrap; white-space: pre-wrap;
box-sizing: border-box; box-sizing: border-box;
border: 3px solid #E0E0E0; border: 3px solid #e0e0e0;
} }
button { button {
@ -72,8 +72,8 @@ button {
.Controls { .Controls {
display: flex; display: flex;
background-color: #F5F5F5; background-color: #f5f5f5;
border: 1px solid #E0E0E0; border: 1px solid #e0e0e0;
overflow-y: auto; overflow-y: auto;
padding: 10px; padding: 10px;
flex-direction: column; flex-direction: column;
@ -93,8 +93,8 @@ button {
flex-direction: column; flex-direction: column;
min-width: 10rem; min-width: 10rem;
flex-grow: 1; flex-grow: 1;
background-color: #1A2536; /* Midnight Blue */ background-color: #1a2536; /* Midnight Blue */
color: #D3CDBF; /* Warm Gray */ color: #d3cdbf; /* Warm Gray */
border-radius: 0; border-radius: 0;
} }
@ -115,12 +115,12 @@ button {
max-width: 1024px; max-width: 1024px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
background-color: #D3CDBF; background-color: #d3cdbf;
} }
.user-message.MuiCard-root { .user-message.MuiCard-root {
background-color: #DCF8C6; background-color: #dcf8c6;
border: 1px solid #B2E0A7; border: 1px solid #b2e0a7;
color: #333333; color: #333333;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
margin-left: 1rem; margin-left: 1rem;
@ -140,8 +140,8 @@ button {
.Docs.MuiCard-root, .Docs.MuiCard-root,
.assistant-message.MuiCard-root { .assistant-message.MuiCard-root {
border: 1px solid #E0E0E0; border: 1px solid #e0e0e0;
background-color: #FFFFFF; background-color: #ffffff;
color: #333333; color: #333333;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
margin-right: 1rem; margin-right: 1rem;
@ -158,7 +158,6 @@ button {
font-size: 0.9rem; font-size: 0.9rem;
} }
.Docs.MuiCard-root { .Docs.MuiCard-root {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
@ -193,7 +192,7 @@ button {
} }
.metadata { .metadata {
border: 1px solid #E0E0E0; border: 1px solid #e0e0e0;
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.125rem; padding: 0.125rem;
} }
@ -239,7 +238,7 @@ button {
/* Reduce space around code blocks */ /* Reduce space around code blocks */
* .MuiTypography-root pre { * .MuiTypography-root pre {
border: 1px solid #F5F5F5; border: 1px solid #f5f5f5;
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
margin-top: 0; margin-top: 0;

View File

@ -1,39 +1,40 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, JSX } from "react";
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from "@mui/material/styles";
import { backstoryTheme } from './BackstoryTheme'; import { backstoryTheme } from "./BackstoryTheme";
import { SeverityType } from 'components/Snack'; import { ConversationHandle } from "components/Conversation";
import { ConversationHandle } from 'components/Conversation'; import { CandidateRoute } from "routes/CandidateRoute";
import { CandidateRoute } from 'routes/CandidateRoute'; import { BackstoryLayout } from "components/layout/BackstoryLayout";
import { BackstoryLayout } from 'components/layout/BackstoryLayout'; import { ChatQuery } from "types/types";
import { ChatQuery } from 'types/types'; import { AuthProvider } from "hooks/AuthContext";
import { AuthProvider } from 'hooks/AuthContext'; import { AppStateProvider } from "hooks/GlobalContext";
import { AppStateProvider } from 'hooks/GlobalContext';
import './BackstoryApp.css'; import "./BackstoryApp.css";
import '@fontsource/roboto/300.css'; import "@fontsource/roboto/300.css";
import '@fontsource/roboto/400.css'; import "@fontsource/roboto/400.css";
import '@fontsource/roboto/500.css'; import "@fontsource/roboto/500.css";
import '@fontsource/roboto/700.css'; import "@fontsource/roboto/700.css";
const BackstoryApp = () => { const BackstoryApp = (): JSX.Element => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const chatRef = useRef<ConversationHandle>(null); const chatRef = useRef<ConversationHandle>(null);
const snackRef = useRef<any>(null); const submitQuery = (query: ChatQuery): void => {
const setSnack = useCallback((message: string, severity?: SeverityType) => { console.log(
snackRef.current?.setSnack(message, severity); `handleSubmitChatQuery:`,
}, [snackRef]); query,
const submitQuery = (query: ChatQuery) => { chatRef.current ? " sending" : "no handler"
console.log(`handleSubmitChatQuery:`, query, chatRef.current ? ' sending' : 'no handler'); );
chatRef.current?.submitQuery(query); chatRef.current?.submitQuery(query);
navigate('/chat'); navigate("/chat");
}; };
const [page, setPage] = useState<string>(""); const [page, setPage] = useState<string>("");
useEffect(() => { useEffect(() => {
const currentRoute = location.pathname.split("/")[1] ? `/${location.pathname.split("/")[1]}` : "/"; const currentRoute = location.pathname.split("/")[1]
? `/${location.pathname.split("/")[1]}`
: "/";
setPage(currentRoute); setPage(currentRoute);
}, [location.pathname]); }, [location.pathname]);
@ -43,12 +44,17 @@ const BackstoryApp = () => {
<AuthProvider> <AuthProvider>
<AppStateProvider> <AppStateProvider>
<Routes> <Routes>
<Route path="/u/:username" element={<CandidateRoute {...{ setSnack }} />} /> <Route
path="/u/:username"
element={<CandidateRoute />}
/>
{/* Static/shared routes */} {/* Static/shared routes */}
<Route <Route
path="/*" path="/*"
element={ element={
<BackstoryLayout {...{ setSnack, page, chatRef, snackRef, submitQuery }} /> <BackstoryLayout
{...{ page, chatRef, submitQuery }}
/>
} }
/> />
</Routes> </Routes>
@ -58,6 +64,4 @@ const BackstoryApp = () => {
); );
}; };
export { export { BackstoryApp };
BackstoryApp
};

View File

@ -1,76 +1,76 @@
import { createTheme } from '@mui/material/styles'; import { createTheme } from "@mui/material/styles";
const backstoryTheme = createTheme({ const backstoryTheme = createTheme({
palette: { palette: {
primary: { primary: {
main: '#1A2536', // Midnight Blue main: "#1A2536", // Midnight Blue
contrastText: '#D3CDBF', // Warm Gray contrastText: "#D3CDBF", // Warm Gray
}, },
secondary: { secondary: {
main: '#4A7A7D', // Dusty Teal main: "#4A7A7D", // Dusty Teal
contrastText: '#FFFFFF', // White contrastText: "#FFFFFF", // White
}, },
text: { text: {
primary: '#2E2E2E', // Charcoal Black primary: "#2E2E2E", // Charcoal Black
secondary: '#1A2536', // Midnight Blue secondary: "#1A2536", // Midnight Blue
}, },
background: { background: {
default: '#D3CDBF', // Warm Gray default: "#D3CDBF", // Warm Gray
paper: '#FFFFFF', // White paper: "#FFFFFF", // White
}, },
action: { action: {
active: '#D4A017', // Golden Ochre active: "#D4A017", // Golden Ochre
hover: 'rgba(212, 160, 23, 0.1)', // Golden Ochre with opacity hover: "rgba(212, 160, 23, 0.1)", // Golden Ochre with opacity
}, },
custom: { custom: {
highlight: '#D4A017', // Golden Ochre highlight: "#D4A017", // Golden Ochre
contrast: '#2E2E2E', // Charcoal Black contrast: "#2E2E2E", // Charcoal Black
}, },
}, },
typography: { typography: {
fontFamily: "'Roboto', sans-serif", fontFamily: "'Roboto', sans-serif",
h1: { h1: {
fontSize: '2rem', fontSize: "2rem",
fontWeight: 500, fontWeight: 500,
color: '#2E2E2E', // Charcoal Black color: "#2E2E2E", // Charcoal Black
}, },
h2: { h2: {
fontSize: '1.75rem', fontSize: "1.75rem",
fontWeight: 500, fontWeight: 500,
color: '#2E2E2E', // Charcoal Black color: "#2E2E2E", // Charcoal Black
marginBottom: '1rem', marginBottom: "1rem",
}, },
h3: { h3: {
fontSize: '1.5rem', fontSize: "1.5rem",
fontWeight: 500, fontWeight: 500,
color: '#2E2E2E', // Charcoal Black color: "#2E2E2E", // Charcoal Black
marginBottom: '0.75rem', marginBottom: "0.75rem",
}, },
h4: { h4: {
fontSize: '1.25rem', fontSize: "1.25rem",
fontWeight: 500, fontWeight: 500,
color: '#2E2E2E', // Charcoal Black color: "#2E2E2E", // Charcoal Black
marginBottom: '0.5rem', marginBottom: "0.5rem",
}, },
body1: { body1: {
fontSize: '1rem', fontSize: "1rem",
color: '#2E2E2E', // Charcoal Black color: "#2E2E2E", // Charcoal Black
marginBottom: '0.5rem', marginBottom: "0.5rem",
}, },
body2: { body2: {
fontSize: '0.875rem', fontSize: "0.875rem",
color: '#2E2E2E', // Charcoal Black color: "#2E2E2E", // Charcoal Black
}, },
}, },
components: { components: {
MuiLink: { MuiLink: {
styleOverrides: { styleOverrides: {
root: { root: {
color: '#4A7A7D', // Dusty Teal (your secondary color) color: "#4A7A7D", // Dusty Teal (your secondary color)
textDecoration: 'none', textDecoration: "none",
'&:hover': { "&:hover": {
color: '#D4A017', // Golden Ochre on hover color: "#D4A017", // Golden Ochre on hover
textDecoration: 'underline', textDecoration: "underline",
}, },
}, },
}, },
@ -78,9 +78,9 @@ const backstoryTheme = createTheme({
MuiButton: { MuiButton: {
styleOverrides: { styleOverrides: {
root: { root: {
textTransform: 'none', textTransform: "none",
'&:hover': { "&:hover": {
backgroundColor: 'rgba(212, 160, 23, 0.2)', // Golden Ochre hover backgroundColor: "rgba(212, 160, 23, 0.2)", // Golden Ochre hover
}, },
}, },
}, },
@ -88,7 +88,7 @@ const backstoryTheme = createTheme({
MuiAppBar: { MuiAppBar: {
styleOverrides: { styleOverrides: {
root: { root: {
backgroundColor: '#1A2536', // Midnight Blue backgroundColor: "#1A2536", // Midnight Blue
}, },
}, },
}, },
@ -96,23 +96,23 @@ const backstoryTheme = createTheme({
styleOverrides: { styleOverrides: {
root: { root: {
// padding: '0.5rem', // padding: '0.5rem',
borderRadius: '4px', borderRadius: "4px",
}, },
}, },
}, },
MuiList: { MuiList: {
styleOverrides: { styleOverrides: {
root: { root: {
padding: '0.5rem', padding: "0.5rem",
}, },
}, },
}, },
MuiListItem: { MuiListItem: {
styleOverrides: { styleOverrides: {
root: { root: {
borderRadius: '4px', borderRadius: "4px",
'&:hover': { "&:hover": {
backgroundColor: 'rgba(212, 160, 23, 0.1)', // Golden Ochre with opacity backgroundColor: "rgba(212, 160, 23, 0.1)", // Golden Ochre with opacity
}, },
}, },
}, },

View File

@ -1,39 +1,39 @@
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import { CandidateQuestion } from "types/types"; import { CandidateQuestion } from "types/types";
type ChatSubmitQueryInterface = (query: CandidateQuestion) => void; type ChatSubmitQueryInterface = (query: CandidateQuestion) => void;
interface BackstoryQueryInterface { interface BackstoryQueryInterface {
question: CandidateQuestion, question: CandidateQuestion;
submitQuery?: ChatSubmitQueryInterface submitQuery?: ChatSubmitQueryInterface;
} }
const BackstoryQuery = (props: BackstoryQueryInterface) => { const BackstoryQuery = (props: BackstoryQueryInterface) => {
const { question, submitQuery } = props; const { question, submitQuery } = props;
if (submitQuery === undefined) { if (submitQuery === undefined) {
return (<Box>{question.question}</Box>); return <Box>{question.question}</Box>;
} }
return ( return (
<Button variant="outlined" sx={{ <Button
color: theme => theme.palette.custom.highlight, // Golden Ochre (#D4A017) variant="outlined"
borderColor: theme => theme.palette.custom.highlight, sx={{
m: 1 color: (theme) => theme.palette.custom.highlight, // Golden Ochre (#D4A017)
borderColor: (theme) => theme.palette.custom.highlight,
m: 1,
}} }}
size="small" onClick={(e: any) => { submitQuery(question); }}> size="small"
onClick={(e: any) => {
submitQuery(question);
}}
>
{question.question} {question.question}
</Button> </Button>
); );
}
export type {
BackstoryQueryInterface,
ChatSubmitQueryInterface,
}; };
export { export type { BackstoryQueryInterface, ChatSubmitQueryInterface };
BackstoryQuery,
};
export { BackstoryQuery };

View File

@ -1,33 +1,36 @@
import React, { ReactElement, JSXElementConstructor } from 'react'; import React, { ReactElement, JSXElementConstructor } from "react";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from "@mui/material";
import { ChatSubmitQueryInterface } from './BackstoryQuery'; import { ChatSubmitQueryInterface } from "./BackstoryQuery";
import { SetSnackType } from './Snack'; import { SetSnackType } from "./Snack";
interface BackstoryElementProps { interface BackstoryElementProps {
// setSnack: SetSnackType, // setSnack: SetSnackType,
// submitQuery: ChatSubmitQueryInterface, // submitQuery: ChatSubmitQueryInterface,
sx?: SxProps<Theme>, sx?: SxProps<Theme>;
} }
interface BackstoryPageProps extends BackstoryElementProps { interface BackstoryPageProps extends BackstoryElementProps {
route?: string, route?: string;
setRoute?: (route: string) => void, setRoute?: (route: string) => void;
}; }
interface BackstoryTabProps { interface BackstoryTabProps {
label?: string, label?: string;
path: string, path: string;
children?: ReactElement<BackstoryPageProps>, children?: ReactElement<BackstoryPageProps>;
active?: boolean, active?: boolean;
className?: string, className?: string;
tabProps?: { tabProps?: {
label?: string, label?: string;
sx?: SxProps, sx?: SxProps;
icon?: string | ReactElement<unknown, string | JSXElementConstructor<any>> | undefined, icon?:
iconPosition?: "bottom" | "top" | "start" | "end" | undefined | string
} | ReactElement<unknown, string | JSXElementConstructor<any>>
| undefined;
iconPosition?: "bottom" | "top" | "start" | "end" | undefined;
}; };
}
function BackstoryPage(props: BackstoryTabProps) { function BackstoryPage(props: BackstoryTabProps) {
const { className, active, children } = props; const { className, active, children } = props;
@ -35,19 +38,13 @@ function BackstoryPage(props: BackstoryTabProps) {
return ( return (
<Box <Box
className={className || "BackstoryTab"} className={className || "BackstoryTab"}
sx={{ "display": active ? "flex" : "none", p: 0, m: 0, borders: "none" }} sx={{ display: active ? "flex" : "none", p: 0, m: 0, borders: "none" }}
> >
{children} {children}
</Box> </Box>
); );
} }
export type { export type { BackstoryPageProps, BackstoryTabProps, BackstoryElementProps };
BackstoryPageProps,
BackstoryTabProps,
BackstoryElementProps,
};
export { export { BackstoryPage };
BackstoryPage
}

View File

@ -1,6 +1,13 @@
import React, { useRef, useEffect, CSSProperties, KeyboardEvent, useState, useImperativeHandle } from 'react'; import React, {
import { useTheme } from '@mui/material/styles'; useRef,
import './BackstoryTextField.css'; useEffect,
CSSProperties,
KeyboardEvent,
useState,
useImperativeHandle,
} from "react";
import { useTheme } from "@mui/material/styles";
import "./BackstoryTextField.css";
// Define ref interface for exposed methods // Define ref interface for exposed methods
interface BackstoryTextFieldRef { interface BackstoryTextFieldRef {
@ -18,9 +25,12 @@ interface BackstoryTextFieldProps {
style?: CSSProperties; style?: CSSProperties;
} }
const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryTextFieldProps>((props, ref) => { const BackstoryTextField = React.forwardRef<
BackstoryTextFieldRef,
BackstoryTextFieldProps
>((props, ref) => {
const { const {
value = '', value = "",
disabled = false, disabled = false,
placeholder, placeholder,
onEnter, onEnter,
@ -59,12 +69,12 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
// Use requestAnimationFrame to ensure DOM is settled // Use requestAnimationFrame to ensure DOM is settled
const raf = requestAnimationFrame(() => { const raf = requestAnimationFrame(() => {
const paddingTop = parseFloat(computed.paddingTop || '0'); const paddingTop = parseFloat(computed.paddingTop || "0");
const paddingBottom = parseFloat(computed.paddingBottom || '0'); const paddingBottom = parseFloat(computed.paddingBottom || "0");
const totalPadding = paddingTop + paddingBottom; const totalPadding = paddingTop + paddingBottom;
// Reset height to auto to allow shrinking // Reset height to auto to allow shrinking
textarea.style.height = 'auto'; textarea.style.height = "auto";
const newHeight = shadow.scrollHeight + totalPadding; const newHeight = shadow.scrollHeight + totalPadding;
textarea.style.height = `${newHeight}px`; textarea.style.height = `${newHeight}px`;
@ -78,33 +88,37 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getValue: () => editValue, getValue: () => editValue,
setValue: (value: string) => setEditValue(value), setValue: (value: string) => setEditValue(value),
getAndResetValue: () => { const _ev = editValue; setEditValue(''); return _ev; } getAndResetValue: () => {
const _ev = editValue;
setEditValue("");
return _ev;
},
})); }));
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (!onEnter) { if (!onEnter) {
return; return;
} }
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); // Prevent newline event.preventDefault(); // Prevent newline
onEnter(editValue); onEnter(editValue);
setEditValue(''); // Clear textarea setEditValue(""); // Clear textarea
} }
}; };
const fullStyle: CSSProperties = { const fullStyle: CSSProperties = {
display: 'flex', display: "flex",
flexGrow: 1, flexGrow: 1,
width: '100%', width: "100%",
padding: '16.5px 14px', padding: "16.5px 14px",
resize: 'none', resize: "none",
overflow: 'hidden', overflow: "hidden",
boxSizing: 'border-box', boxSizing: "border-box",
// minHeight: 'calc(1.5rem + 28px)', // lineHeight + padding // minHeight: 'calc(1.5rem + 28px)', // lineHeight + padding
lineHeight: '1.5', lineHeight: "1.5",
borderRadius: '4px', borderRadius: "4px",
fontSize: '16px', fontSize: "16px",
backgroundColor: 'rgba(0, 0, 0, 0)', backgroundColor: "rgba(0, 0, 0, 0)",
fontFamily: theme.typography.fontFamily, fontFamily: theme.typography.fontFamily,
...style, ...style,
}; };
@ -117,7 +131,10 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
value={editValue} value={editValue}
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => { setEditValue(e.target.value); onChange && onChange(e.target.value); }} onChange={(e) => {
setEditValue(e.target.value);
onChange && onChange(e.target.value);
}}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
style={fullStyle} style={fullStyle}
/> />
@ -127,15 +144,15 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
aria-hidden="true" aria-hidden="true"
style={{ style={{
...fullStyle, ...fullStyle,
position: 'absolute', position: "absolute",
top: '-9999px', top: "-9999px",
left: '-9999px', left: "-9999px",
visibility: 'hidden', visibility: "hidden",
padding: '0px', // No padding to match content height padding: "0px", // No padding to match content height
margin: '0px', margin: "0px",
border: '0px', // Remove border to avoid extra height border: "0px", // Remove border to avoid extra height
height: 'auto', // Allow natural height height: "auto", // Allow natural height
minHeight: '0px', minHeight: "0px",
}} }}
readOnly readOnly
tabIndex={-1} tabIndex={-1}
@ -144,8 +161,6 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
); );
}); });
export type { export type { BackstoryTextFieldRef };
BackstoryTextFieldRef
};
export { BackstoryTextField }; export { BackstoryTextField };

View File

@ -1,32 +1,65 @@
import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react'; import React, {
import Tooltip from '@mui/material/Tooltip'; useState,
import IconButton from '@mui/material/IconButton'; useImperativeHandle,
import Button from '@mui/material/Button'; forwardRef,
import Box from '@mui/material/Box'; useEffect,
import SendIcon from '@mui/icons-material/Send'; useRef,
import CancelIcon from '@mui/icons-material/Cancel'; useCallback,
import { SxProps, Theme } from '@mui/material'; } from "react";
import Tooltip from "@mui/material/Tooltip";
import IconButton from "@mui/material/IconButton";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import SendIcon from "@mui/icons-material/Send";
import CancelIcon from "@mui/icons-material/Cancel";
import { SxProps, Theme } from "@mui/material";
import PropagateLoader from "react-spinners/PropagateLoader"; import PropagateLoader from "react-spinners/PropagateLoader";
import { Message } from './Message'; import { Message } from "./Message";
import { DeleteConfirmation } from 'components/DeleteConfirmation'; import { DeleteConfirmation } from "components/DeleteConfirmation";
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import {
import { BackstoryElementProps } from './BackstoryTab'; BackstoryTextField,
BackstoryTextFieldRef,
} from "components/BackstoryTextField";
import { BackstoryElementProps } from "./BackstoryTab";
import { useAuth } from "hooks/AuthContext"; import { useAuth } from "hooks/AuthContext";
import { StreamingResponse } from 'services/api-client'; import { StreamingResponse } from "services/api-client";
import { ChatMessage, ChatContext, ChatSession, ChatQuery, ChatMessageUser, ChatMessageError, ChatMessageStreaming, ChatMessageStatus } from 'types/types'; import {
import { PaginatedResponse } from 'types/conversion'; ChatMessage,
ChatContext,
ChatSession,
ChatQuery,
ChatMessageUser,
ChatMessageError,
ChatMessageStreaming,
ChatMessageStatus,
} from "types/types";
import { PaginatedResponse } from "types/conversion";
import './Conversation.css'; import "./Conversation.css";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
const defaultMessage: ChatMessage = { const defaultMessage: ChatMessage = {
status: "done", type: "text", sessionId: "", timestamp: new Date(), content: "", role: "assistant", metadata: null as any status: "done",
type: "text",
sessionId: "",
timestamp: new Date(),
content: "",
role: "assistant",
metadata: null as any,
}; };
const loadingMessage: ChatMessage = { ...defaultMessage, content: "Establishing connection with server..." }; const loadingMessage: ChatMessage = {
...defaultMessage,
content: "Establishing connection with server...",
};
type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check' | 'persona'; type ConversationMode =
| "chat"
| "job_description"
| "resume"
| "fact_check"
| "persona";
interface ConversationHandle { interface ConversationHandle {
submitQuery: (query: ChatQuery) => void; submitQuery: (query: ChatQuery) => void;
@ -34,24 +67,25 @@ interface ConversationHandle {
} }
interface ConversationProps extends BackstoryElementProps { interface ConversationProps extends BackstoryElementProps {
className?: string, // Override default className className?: string; // Override default className
type: ConversationMode, // Type of Conversation chat type: ConversationMode; // Type of Conversation chat
placeholder?: string, // Prompt to display in TextField input placeholder?: string; // Prompt to display in TextField input
actionLabel?: string, // Label to put on the primary button actionLabel?: string; // Label to put on the primary button
resetAction?: () => void, // Callback when Reset is pressed resetAction?: () => void; // Callback when Reset is pressed
resetLabel?: string, // Label to put on Reset button resetLabel?: string; // Label to put on Reset button
defaultPrompts?: React.ReactElement[], // Set of Elements to display after the TextField defaultPrompts?: React.ReactElement[]; // Set of Elements to display after the TextField
defaultQuery?: string, // Default text to populate the TextField input defaultQuery?: string; // Default text to populate the TextField input
preamble?: ChatMessage[], // Messages to display at start of Conversation until Action has been invoked preamble?: ChatMessage[]; // Messages to display at start of Conversation until Action has been invoked
hidePreamble?: boolean, // Whether to hide the preamble after an Action has been invoked hidePreamble?: boolean; // Whether to hide the preamble after an Action has been invoked
hideDefaultPrompts?: boolean, // Whether to hide the defaultPrompts after an Action has been invoked hideDefaultPrompts?: boolean; // Whether to hide the defaultPrompts after an Action has been invoked
messageFilter?: ((messages: ChatMessage[]) => ChatMessage[]) | undefined, // Filter callback to determine which Messages to display in Conversation messageFilter?: ((messages: ChatMessage[]) => ChatMessage[]) | undefined; // Filter callback to determine which Messages to display in Conversation
messages?: ChatMessage[], // messages?: ChatMessage[]; //
sx?: SxProps<Theme>, sx?: SxProps<Theme>;
onResponse?: ((message: ChatMessage) => void) | undefined, // Event called when a query completes (provides messages) onResponse?: ((message: ChatMessage) => void) | undefined; // Event called when a query completes (provides messages)
}; }
const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: ConversationProps, ref) => { const Conversation = forwardRef<ConversationHandle, ConversationProps>(
(props: ConversationProps, ref) => {
const { const {
actionLabel, actionLabel,
defaultPrompts, defaultPrompts,
@ -67,14 +101,20 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
sx, sx,
type, type,
} = props; } = props;
const { apiClient } = useAuth() const { apiClient } = useAuth();
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0); const [countdown, setCountdown] = useState<number>(0);
const [conversation, setConversation] = useState<ChatMessage[]>([]); const [conversation, setConversation] = useState<ChatMessage[]>([]);
const conversationRef = useRef<ChatMessage[]>([]); const conversationRef = useRef<ChatMessage[]>([]);
const [filteredConversation, setFilteredConversation] = useState<ChatMessage[]>([]); const [filteredConversation, setFilteredConversation] = useState<
const [processingMessage, setProcessingMessage] = useState<ChatMessage | undefined>(undefined); ChatMessage[]
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | undefined>(undefined); >([]);
const [processingMessage, setProcessingMessage] = useState<
ChatMessage | undefined
>(undefined);
const [streamingMessage, setStreamingMessage] = useState<
ChatMessage | undefined
>(undefined);
const [noInteractions, setNoInteractions] = useState<boolean>(true); const [noInteractions, setNoInteractions] = useState<boolean>(true);
const viewableElementRef = useRef<HTMLDivElement>(null); const viewableElementRef = useRef<HTMLDivElement>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null); const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
@ -99,22 +139,29 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
// console.log('No message filter provided. Using all messages.', filtered); // console.log('No message filter provided. Using all messages.', filtered);
} else { } else {
//console.log('Filtering conversation...') //console.log('Filtering conversation...')
filtered = messageFilter(conversation); /* Do not copy conversation or useEffect will loop forever */ filtered =
messageFilter(
conversation
); /* Do not copy conversation or useEffect will loop forever */
//console.log(`${conversation.length - filtered.length} messages filtered out.`); //console.log(`${conversation.length - filtered.length} messages filtered out.`);
} }
if (filtered.length === 0) { if (filtered.length === 0) {
setFilteredConversation([ setFilteredConversation([...(preamble || []), ...(messages || [])]);
...(preamble || []),
...(messages || []),
]);
} else { } else {
setFilteredConversation([ setFilteredConversation([
...(hidePreamble ? [] : (preamble || [])), ...(hidePreamble ? [] : preamble || []),
...(messages || []), ...(messages || []),
...filtered, ...filtered,
]); ]);
}; }
}, [conversation, setFilteredConversation, messageFilter, preamble, messages, hidePreamble]); }, [
conversation,
setFilteredConversation,
messageFilter,
preamble,
messages,
hidePreamble,
]);
useEffect(() => { useEffect(() => {
if (chatSession) { if (chatSession) {
@ -123,7 +170,9 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
const createChatSession = async () => { const createChatSession = async () => {
try { try {
const chatContext: ChatContext = { type: "general" }; const chatContext: ChatContext = { type: "general" };
const response: ChatSession = await apiClient.createChatSession(chatContext); const response: ChatSession = await apiClient.createChatSession(
chatContext
);
setChatSession(response); setChatSession(response);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -132,7 +181,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
}; };
createChatSession(); createChatSession();
}, [chatSession, setChatSession]); }, [chatSession, setChatSession]);
const getChatMessages = useCallback(async () => { const getChatMessages = useCallback(async () => {
@ -140,24 +188,32 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
return; return;
} }
try { try {
const response: PaginatedResponse<ChatMessage> = await apiClient.getChatMessages(chatSession.id); const response: PaginatedResponse<ChatMessage> =
await apiClient.getChatMessages(chatSession.id);
const messages: ChatMessage[] = response.data; const messages: ChatMessage[] = response.data;
setProcessingMessage(undefined); setProcessingMessage(undefined);
setStreamingMessage(undefined); setStreamingMessage(undefined);
if (messages.length === 0) { if (messages.length === 0) {
console.log(`History returned with 0 entries`) console.log(`History returned with 0 entries`);
setConversation([]) setConversation([]);
setNoInteractions(true); setNoInteractions(true);
} else { } else {
console.log(`History returned with ${messages.length} entries:`, messages) console.log(
`History returned with ${messages.length} entries:`,
messages
);
setConversation(messages); setConversation(messages);
setNoInteractions(false); setNoInteractions(false);
} }
} catch (error) { } catch (error) {
console.error('Unable to obtain chat history', error); console.error("Unable to obtain chat history", error);
setProcessingMessage({ ...defaultMessage, status: "error", content: `Unable to obtain history from server.` }); setProcessingMessage({
...defaultMessage,
status: "error",
content: `Unable to obtain history from server.`,
});
setTimeout(() => { setTimeout(() => {
setProcessingMessage(undefined); setProcessingMessage(undefined);
setNoInteractions(true); setNoInteractions(true);
@ -166,7 +222,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
} }
}, [chatSession]); }, [chatSession]);
// Set the initial chat history to "loading" or the welcome message if loaded. // Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => { useEffect(() => {
if (!chatSession) { if (!chatSession) {
@ -180,13 +235,12 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
setNoInteractions(true); setNoInteractions(true);
getChatMessages(); getChatMessages();
}, [chatSession]); }, [chatSession]);
const handleEnter = (value: string) => { const handleEnter = (value: string) => {
const query: ChatQuery = { const query: ChatQuery = {
prompt: value prompt: value,
} };
processQuery(query); processQuery(query);
}; };
@ -194,10 +248,11 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
submitQuery: (query: ChatQuery) => { submitQuery: (query: ChatQuery) => {
processQuery(query); processQuery(query);
}, },
fetchHistory: () => { getChatMessages(); } fetchHistory: () => {
getChatMessages();
},
})); }));
// const reset = async () => { // const reset = async () => {
// try { // try {
// const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, { // const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, {
@ -247,14 +302,15 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
...conversationRef.current, ...conversationRef.current,
{ {
...defaultMessage, ...defaultMessage,
type: 'text', type: "text",
content: query.prompt, content: query.prompt,
} },
]); ]);
setProcessing(true); setProcessing(true);
setProcessingMessage( setProcessingMessage({
{ ...defaultMessage, content: 'Submitting request...' } ...defaultMessage,
); content: "Submitting request...",
});
const chatMessage: ChatMessageUser = { const chatMessage: ChatMessageUser = {
role: "user", role: "user",
@ -263,16 +319,13 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
tunables: query.tunables, tunables: query.tunables,
status: "done", status: "done",
type: "text", type: "text",
timestamp: new Date() timestamp: new Date(),
}; };
controllerRef.current = apiClient.sendMessageStream(chatMessage, { controllerRef.current = apiClient.sendMessageStream(chatMessage, {
onMessage: (msg: ChatMessage) => { onMessage: (msg: ChatMessage) => {
console.log("onMessage:", msg); console.log("onMessage:", msg);
setConversation([ setConversation([...conversationRef.current, msg]);
...conversationRef.current,
msg
]);
setStreamingMessage(undefined); setStreamingMessage(undefined);
setProcessingMessage(undefined); setProcessingMessage(undefined);
setProcessing(false); setProcessing(false);
@ -283,12 +336,19 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
onError: (error: string | ChatMessageError) => { onError: (error: string | ChatMessageError) => {
console.log("onError:", error); console.log("onError:", error);
// Type-guard to determine if this is a ChatMessageBase or a string // Type-guard to determine if this is a ChatMessageBase or a string
if (typeof error === "object" && error !== null && "content" in error) { if (
typeof error === "object" &&
error !== null &&
"content" in error
) {
setProcessingMessage(error as ChatMessage); setProcessingMessage(error as ChatMessage);
setProcessing(false); setProcessing(false);
controllerRef.current = null; controllerRef.current = null;
} else { } else {
setProcessingMessage({ ...defaultMessage, content: error as string }); setProcessingMessage({
...defaultMessage,
content: error as string,
});
} }
}, },
onStreaming: (chunk: ChatMessageStreaming) => { onStreaming: (chunk: ChatMessageStreaming) => {
@ -301,12 +361,12 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
onComplete: () => { onComplete: () => {
console.log("onComplete"); console.log("onComplete");
controllerRef.current = null; controllerRef.current = null;
} },
}); });
}; };
if (!chatSession) { if (!chatSession) {
return (<></>); return <></>;
} }
return ( return (
// <Scrollable // <Scrollable
@ -320,28 +380,50 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
// ...sx // ...sx
// }} // }}
// > // >
<Box className="Conversation" sx={{ flexGrow: 1, minHeight: "max-content", height: "max-content", maxHeight: "max-content", overflow: "hidden" }}> <Box
className="Conversation"
sx={{
flexGrow: 1,
minHeight: "max-content",
height: "max-content",
maxHeight: "max-content",
overflow: "hidden",
}}
>
<Box sx={{ p: 1, mt: 0, ...sx }}> <Box sx={{ p: 1, mt: 0, ...sx }}>
{ {filteredConversation.map((message, index) => (
filteredConversation.map((message, index) => <Message
<Message key={index} {...{ chatSession, sendQuery: processQuery, message, }} /> key={index}
) {...{ chatSession, sendQuery: processQuery, message }}
} />
{ ))}
processingMessage !== undefined && {processingMessage !== undefined && (
<Message {...{ chatSession, sendQuery: processQuery, message: processingMessage, }} /> <Message
} {...{
{ chatSession,
streamingMessage !== undefined && sendQuery: processQuery,
<Message {...{ chatSession, sendQuery: processQuery, message: streamingMessage }} /> message: processingMessage,
} }}
<Box sx={{ />
)}
{streamingMessage !== undefined && (
<Message
{...{
chatSession,
sendQuery: processQuery,
message: streamingMessage,
}}
/>
)}
<Box
sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
m: 1, m: 1,
}}> }}
>
<PropagateLoader <PropagateLoader
size="10px" size="10px"
loading={processing} loading={processing}
@ -353,15 +435,28 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
sx={{ sx={{
pt: 1, pt: 1,
fontSize: "0.7rem", fontSize: "0.7rem",
color: "darkgrey" color: "darkgrey",
}} }}
>Response will be stopped in: {countdown}s</Box> >
Response will be stopped in: {countdown}s
</Box>
)} )}
</Box> </Box>
<Box className="Query" sx={{ display: "flex", flexDirection: "column", p: 1, flexGrow: 1 }}> <Box
{placeholder && className="Query"
<Box sx={{ display: "flex", flexGrow: 1, p: 0, m: 0, flexDirection: "column" }} sx={{ display: "flex", flexDirection: "column", p: 1, flexGrow: 1 }}
ref={viewableElementRef}> >
{placeholder && (
<Box
sx={{
display: "flex",
flexGrow: 1,
p: 0,
m: 0,
flexDirection: "column",
}}
ref={viewableElementRef}
>
<BackstoryTextField <BackstoryTextField
ref={backstoryTextRef} ref={backstoryTextRef}
disabled={processing} disabled={processing}
@ -369,33 +464,62 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
placeholder={placeholder} placeholder={placeholder}
/> />
</Box> </Box>
} )}
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}> <Box
key="jobActions"
sx={{
display: "flex",
justifyContent: "center",
flexDirection: "row",
}}
>
<DeleteConfirmation <DeleteConfirmation
label={resetLabel || "all data"} label={resetLabel || "all data"}
disabled={!chatSession || processingMessage !== undefined || noInteractions} disabled={
onDelete={() => { /*reset(); resetAction && resetAction(); */ }} /> !chatSession ||
processingMessage !== undefined ||
noInteractions
}
onDelete={() => {
/*reset(); resetAction && resetAction(); */
}}
/>
<Tooltip title={actionLabel || "Send"}> <Tooltip title={actionLabel || "Send"}>
<span style={{ display: "flex", flexGrow: 1 }}> <span style={{ display: "flex", flexGrow: 1 }}>
<Button <Button
sx={{ m: 1, gap: 1, flexGrow: 1 }} sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained" variant="contained"
disabled={!chatSession || processingMessage !== undefined} disabled={!chatSession || processingMessage !== undefined}
onClick={() => { processQuery({ prompt: (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || "" }); }}> onClick={() => {
{actionLabel}<SendIcon /> processQuery({
prompt:
(backstoryTextRef.current &&
backstoryTextRef.current.getAndResetValue()) ||
"",
});
}}
>
{actionLabel}
<SendIcon />
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
<Tooltip title="Cancel"> <Tooltip title="Cancel">
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */} <span style={{ display: "flex" }}>
{" "}
{/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton <IconButton
aria-label="cancel" aria-label="cancel"
onClick={() => { cancelQuery(); }} onClick={() => {
sx={{ display: "flex", margin: 'auto 0px' }} cancelQuery();
}}
sx={{ display: "flex", margin: "auto 0px" }}
size="large" size="large"
edge="start" edge="start"
disabled={stopRef.current || !chatSession || processing === false} disabled={
stopRef.current || !chatSession || processing === false
}
> >
<CancelIcon /> <CancelIcon />
</IconButton> </IconButton>
@ -403,26 +527,22 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
</Tooltip> </Tooltip>
</Box> </Box>
</Box> </Box>
{(noInteractions || !hideDefaultPrompts) && defaultPrompts !== undefined && defaultPrompts.length !== 0 && {(noInteractions || !hideDefaultPrompts) &&
defaultPrompts !== undefined &&
defaultPrompts.length !== 0 && (
<Box sx={{ display: "flex", flexDirection: "column" }}> <Box sx={{ display: "flex", flexDirection: "column" }}>
{ {defaultPrompts.map((element, index) => {
defaultPrompts.map((element, index) => { return <Box key={index}>{element}</Box>;
return (<Box key={index}>{element}</Box>); })}
})
}
</Box> </Box>
} )}
<Box sx={{ display: "flex", flexGrow: 1 }}></Box> <Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box> </Box>
</Box> </Box>
); );
}); }
);
export type { export type { ConversationProps, ConversationHandle };
ConversationProps,
ConversationHandle,
};
export { export { Conversation };
Conversation
};

View File

@ -1,12 +1,12 @@
import { useState } from 'react'; import { useState } from "react";
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import CheckIcon from '@mui/icons-material/Check'; import CheckIcon from "@mui/icons-material/Check";
import IconButton, { IconButtonProps } from '@mui/material/IconButton'; import IconButton, { IconButtonProps } from "@mui/material/IconButton";
import { Tooltip } from '@mui/material'; import { Tooltip } from "@mui/material";
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from "@mui/material";
interface CopyBubbleProps extends IconButtonProps { interface CopyBubbleProps extends IconButtonProps {
content: string | undefined, content: string | undefined;
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
tooltip?: string; tooltip?: string;
} }
@ -38,25 +38,29 @@ const CopyBubble = ({
return ( return (
<Tooltip title={tooltip} placement="top" arrow> <Tooltip title={tooltip} placement="top" arrow>
<IconButton <IconButton
onClick={(e) => { handleCopy(e) }} onClick={(e) => {
handleCopy(e);
}}
sx={{ sx={{
width: 24, width: 24,
height: 24, height: 24,
opacity: 0.75, opacity: 0.75,
bgcolor: 'background.paper', bgcolor: "background.paper",
'&:hover': { bgcolor: 'action.hover', opacity: 1 }, "&:hover": { bgcolor: "action.hover", opacity: 1 },
...sx, ...sx,
}} }}
size="small" size="small"
color={copied ? "success" : "default"} color={copied ? "success" : "default"}
{...rest} {...rest}
> >
{copied ? <CheckIcon sx={{ width: 16, height: 16 }} /> : <ContentCopyIcon sx={{ width: 16, height: 16 }} />} {copied ? (
<CheckIcon sx={{ width: 16, height: 16 }} />
) : (
<ContentCopyIcon sx={{ width: 16, height: 16 }} />
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
); );
} };
export { export { CopyBubble };
CopyBubble
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { import {
IconButton, IconButton,
Dialog, Dialog,
@ -10,9 +10,9 @@ import {
useMediaQuery, useMediaQuery,
Tooltip, Tooltip,
SxProps, SxProps,
} from '@mui/material'; } from "@mui/material";
import { useTheme } from '@mui/material/styles'; import { useTheme } from "@mui/material/styles";
import ResetIcon from '@mui/icons-material/History'; import ResetIcon from "@mui/icons-material/History";
interface DeleteConfirmationProps { interface DeleteConfirmationProps {
// Legacy props for backward compatibility (uncontrolled mode) // Legacy props for backward compatibility (uncontrolled mode)
@ -20,7 +20,16 @@ interface DeleteConfirmationProps {
disabled?: boolean; disabled?: boolean;
label?: string; label?: string;
action?: "delete" | "reset"; action?: "delete" | "reset";
color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined; color?:
| "inherit"
| "default"
| "primary"
| "secondary"
| "error"
| "info"
| "success"
| "warning"
| undefined;
sx?: SxProps; sx?: SxProps;
// New props for controlled mode // New props for controlled mode
open?: boolean; open?: boolean;
@ -64,7 +73,7 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
// Internal state for uncontrolled mode // Internal state for uncontrolled mode
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const theme = useTheme(); const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md')); const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
// Determine if we're in controlled or uncontrolled mode // Determine if we're in controlled or uncontrolled mode
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
@ -95,20 +104,34 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
// Determine dialog content based on mode // Determine dialog content based on mode
const dialogTitle = title || "Confirm Reset"; const dialogTitle = title || "Confirm Reset";
const dialogMessage = message || `This action will permanently ${capitalizeFirstLetter(action)} ${label ? label.toLowerCase() : "all data"} without the ability to recover it. Are you sure you want to continue?`; const dialogMessage =
const confirmText = confirmButtonText || `${capitalizeFirstLetter(action)} ${label || "Everything"}`; message ||
`This action will permanently ${capitalizeFirstLetter(action)} ${
label ? label.toLowerCase() : "all data"
} without the ability to recover it. Are you sure you want to continue?`;
const confirmText =
confirmButtonText ||
`${capitalizeFirstLetter(action)} ${label || "Everything"}`;
return ( return (
<> <>
{/* Only show button if not hidden (for controlled mode) */} {/* Only show button if not hidden (for controlled mode) */}
{!hideButton && ( {!hideButton && (
<Tooltip title={label ? `${capitalizeFirstLetter(action)} ${label}` : "Reset"}> <Tooltip
<span style={{ display: "flex" }}> {/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */} title={label ? `${capitalizeFirstLetter(action)} ${label}` : "Reset"}
>
<span style={{ display: "flex" }}>
{" "}
{/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton <IconButton
aria-label={action} aria-label={action}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); handleClickOpen(); }} onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleClickOpen();
}}
color={color || "inherit"} color={color || "inherit"}
sx={{ display: "flex", margin: 'auto 0px', ...sx }} sx={{ display: "flex", margin: "auto 0px", ...sx }}
size="large" size="large"
edge="start" edge="start"
disabled={disabled} disabled={disabled}
@ -125,13 +148,9 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
onClose={handleClose} onClose={handleClose}
aria-labelledby="responsive-dialog-title" aria-labelledby="responsive-dialog-title"
> >
<DialogTitle id="responsive-dialog-title"> <DialogTitle id="responsive-dialog-title">{dialogTitle}</DialogTitle>
{dialogTitle}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{dialogMessage}</DialogContentText>
{dialogMessage}
</DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button autoFocus onClick={handleClose}> <Button autoFocus onClick={handleClose}>
@ -144,8 +163,6 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
</Dialog> </Dialog>
</> </>
); );
}
export {
DeleteConfirmation
}; };
export { DeleteConfirmation };

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { BackstoryElementProps } from './BackstoryTab'; import { BackstoryElementProps } from "./BackstoryTab";
import { StyledMarkdown } from './StyledMarkdown'; import { StyledMarkdown } from "./StyledMarkdown";
interface DocumentProps extends BackstoryElementProps { interface DocumentProps extends BackstoryElementProps {
filepath?: string; filepath?: string;
@ -19,9 +19,9 @@ const Document = (props: DocumentProps) => {
const fetchDocument = async () => { const fetchDocument = async () => {
try { try {
const response = await fetch(filepath, { const response = await fetch(filepath, {
method: 'GET', method: "GET",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}); });
if (!response.ok) { if (!response.ok) {
@ -30,19 +30,19 @@ const Document = (props: DocumentProps) => {
const data = await response.text(); const data = await response.text();
setDocument(data); setDocument(data);
} catch (error: any) { } catch (error: any) {
console.error('Error obtaining Docs content information:', error); console.error("Error obtaining Docs content information:", error);
setDocument(`${filepath} not found.`); setDocument(`${filepath} not found.`);
}; }
}; };
fetchDocument(); fetchDocument();
}, [document, setDocument, filepath]) }, [document, setDocument, filepath]);
return (<> return (
<>
<StyledMarkdown content={document} /> <StyledMarkdown content={document} />
</>); </>
);
}; };
export { export { Document };
Document
};

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Box, Box,
Button, Button,
@ -22,50 +22,54 @@ import {
Chip, Chip,
Divider, Divider,
Paper, Paper,
} from '@mui/material'; } from "@mui/material";
import { styled } from '@mui/material/styles'; import { styled } from "@mui/material/styles";
import { import {
CloudUpload, CloudUpload,
Edit, Edit,
Delete, Delete,
Visibility, Visibility,
Close, Close,
} from '@mui/icons-material'; } from "@mui/icons-material";
import { useTheme } from '@mui/material/styles'; import { useTheme } from "@mui/material/styles";
import { useAuth } from "hooks/AuthContext"; import { useAuth } from "hooks/AuthContext";
import * as Types from 'types/types'; import * as Types from "types/types";
import { BackstoryElementProps } from './BackstoryTab'; import { BackstoryElementProps } from "./BackstoryTab";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
const VisuallyHiddenInput = styled('input')({ const VisuallyHiddenInput = styled("input")({
clip: 'rect(0 0 0 0)', clip: "rect(0 0 0 0)",
clipPath: 'inset(50%)', clipPath: "inset(50%)",
height: 1, height: 1,
overflow: 'hidden', overflow: "hidden",
position: 'absolute', position: "absolute",
bottom: 0, bottom: 0,
left: 0, left: 0,
whiteSpace: 'nowrap', whiteSpace: "nowrap",
width: 1, width: 1,
}); });
const DocumentManager = (props: BackstoryElementProps) => { const DocumentManager = (props: BackstoryElementProps) => {
const theme = useTheme(); const theme = useTheme();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const [documents, setDocuments] = useState<Types.Document[]>([]); const [documents, setDocuments] = useState<Types.Document[]>([]);
const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null); const [selectedDocument, setSelectedDocument] =
const [documentContent, setDocumentContent] = useState<string>(''); useState<Types.Document | null>(null);
const [documentContent, setDocumentContent] = useState<string>("");
const [isViewingContent, setIsViewingContent] = useState(false); const [isViewingContent, setIsViewingContent] = useState(false);
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(null); const [editingDocument, setEditingDocument] = useState<Types.Document | null>(
const [editingName, setEditingName] = useState(''); null
);
const [editingName, setEditingName] = useState("");
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
// Check if user is a candidate // Check if user is a candidate
const candidate = user?.userType === 'candidate' ? user as Types.Candidate : null; const candidate =
user?.userType === "candidate" ? (user as Types.Candidate) : null;
// Load documents on component mount // Load documents on component mount
useEffect(() => { useEffect(() => {
@ -80,15 +84,17 @@ const DocumentManager = (props: BackstoryElementProps) => {
setDocuments(results.documents); setDocuments(results.documents);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setSnack('Failed to load documents', 'error'); setSnack("Failed to load documents", "error");
} }
}; };
// Handle document upload // Handle document upload
const handleDocumentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleDocumentUpload = async (
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]; const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); const fileExtension = "." + file.name.split(".").pop()?.toLowerCase();
let docType: Types.DocumentType | null = null; let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) { switch (fileExtension.substring(1)) {
case "pdf": case "pdf":
@ -106,29 +112,36 @@ const DocumentManager = (props: BackstoryElementProps) => {
} }
if (!docType) { if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error'); setSnack(
"Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.",
"error"
);
return; return;
} }
try { try {
// Upload file (replace with actual API call) // Upload file (replace with actual API call)
const controller = apiClient.uploadCandidateDocument(file, { includeInRag: true, isJobDocument: false }, { const controller = apiClient.uploadCandidateDocument(
file,
{ includeInRag: true, isJobDocument: false },
{
onError: (error) => { onError: (error) => {
console.error(error); console.error(error);
setSnack(error.content, 'error'); setSnack(error.content, "error");
},
} }
}); );
const result = await controller.promise; const result = await controller.promise;
if (result && result.document) { if (result && result.document) {
setDocuments(prev => [...prev, result.document]); setDocuments((prev) => [...prev, result.document]);
setSnack(`Document uploaded: ${file.name}`, 'success'); setSnack(`Document uploaded: ${file.name}`, "success");
} }
// Reset file input // Reset file input
e.target.value = ''; e.target.value = "";
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setSnack('Failed to upload document', 'error'); setSnack("Failed to upload document", "error");
} }
} }
}; };
@ -139,65 +152,70 @@ const DocumentManager = (props: BackstoryElementProps) => {
// Call API to delete document // Call API to delete document
await apiClient.deleteCandidateDocument(document); await apiClient.deleteCandidateDocument(document);
setDocuments(prev => prev.filter(doc => doc.id !== document.id)); setDocuments((prev) => prev.filter((doc) => doc.id !== document.id));
setSnack('Document deleted successfully', 'success'); setSnack("Document deleted successfully", "success");
// Close content view if this document was being viewed // Close content view if this document was being viewed
if (selectedDocument?.id === document.id) { if (selectedDocument?.id === document.id) {
setIsViewingContent(false); setIsViewingContent(false);
setSelectedDocument(null); setSelectedDocument(null);
setDocumentContent(''); setDocumentContent("");
} }
} catch (error) { } catch (error) {
setSnack('Failed to delete document', 'error'); setSnack("Failed to delete document", "error");
} }
}; };
// Handle RAG flag toggle // Handle RAG flag toggle
const handleRAGToggle = async (document: Types.Document, includeInRag: boolean) => { const handleRAGToggle = async (
document: Types.Document,
includeInRag: boolean
) => {
try { try {
document.options = { includeInRag }; document.options = { includeInRag };
// Call API to update RAG flag // Call API to update RAG flag
await apiClient.updateCandidateDocument(document); await apiClient.updateCandidateDocument(document);
setDocuments(prev => setDocuments((prev) =>
prev.map(doc => prev.map((doc) =>
doc.id === document.id doc.id === document.id ? { ...doc, includeInRag } : doc
? { ...doc, includeInRag }
: doc
) )
); );
setSnack(`Document ${includeInRag ? 'included in' : 'excluded from'} RAG`, 'success'); setSnack(
`Document ${includeInRag ? "included in" : "excluded from"} RAG`,
"success"
);
} catch (error) { } catch (error) {
setSnack('Failed to update RAG setting', 'error'); setSnack("Failed to update RAG setting", "error");
} }
}; };
// Handle document rename // Handle document rename
const handleRenameDocument = async (document: Types.Document, newName: string) => { const handleRenameDocument = async (
document: Types.Document,
newName: string
) => {
if (!newName.trim()) { if (!newName.trim()) {
setSnack('Document name cannot be empty', 'error'); setSnack("Document name cannot be empty", "error");
return; return;
} }
try { try {
// Call API to rename document // Call API to rename document
document.filename = newName document.filename = newName;
await apiClient.updateCandidateDocument(document); await apiClient.updateCandidateDocument(document);
setDocuments(prev => setDocuments((prev) =>
prev.map(doc => prev.map((doc) =>
doc.id === document.id doc.id === document.id ? { ...doc, filename: newName.trim() } : doc
? { ...doc, filename: newName.trim() }
: doc
) )
); );
setSnack('Document renamed successfully', 'success'); setSnack("Document renamed successfully", "success");
setIsRenameDialogOpen(false); setIsRenameDialogOpen(false);
setEditingDocument(null); setEditingDocument(null);
setEditingName(''); setEditingName("");
} catch (error) { } catch (error) {
setSnack('Failed to rename document', 'error'); setSnack("Failed to rename document", "error");
} }
}; };
@ -211,7 +229,7 @@ const DocumentManager = (props: BackstoryElementProps) => {
const result = await apiClient.getCandidateDocumentText(document); const result = await apiClient.getCandidateDocumentText(document);
setDocumentContent(result.content); setDocumentContent(result.content);
} catch (error) { } catch (error) {
setSnack('Failed to load document content', 'error'); setSnack("Failed to load document content", "error");
setIsViewingContent(false); setIsViewingContent(false);
} }
}; };
@ -225,32 +243,50 @@ const DocumentManager = (props: BackstoryElementProps) => {
// Format file size // Format file size
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return "0 Bytes";
const k = 1024; const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB']; const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}; };
// Get file type color // Get file type color
const getFileTypeColor = (type: string): 'primary' | 'secondary' | 'success' | 'warning' => { const getFileTypeColor = (
type: string
): "primary" | "secondary" | "success" | "warning" => {
switch (type) { switch (type) {
case 'pdf': return 'primary'; case "pdf":
case 'docx': return 'secondary'; return "primary";
case 'txt': return 'success'; case "docx":
case 'md': return 'warning'; return "secondary";
default: return 'primary'; case "txt":
return "success";
case "md":
return "warning";
default:
return "primary";
} }
}; };
if (!candidate) { if (!candidate) {
return (<Box>You must be logged in as a candidate to view this content.</Box>); return (
<Box>You must be logged in as a candidate to view this content.</Box>
);
} }
return ( return (
<> <>
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}> <Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: "100%" }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, width: "100%", verticalAlign: "center" }}> <Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
width: "100%",
verticalAlign: "center",
}}
>
<Typography variant={isMobile ? "subtitle2" : "h6"}> <Typography variant={isMobile ? "subtitle2" : "h6"}>
Documents Documents
</Typography> </Typography>
@ -258,7 +294,8 @@ const DocumentManager = (props: BackstoryElementProps) => {
component="label" component="label"
variant="contained" variant="contained"
startIcon={<CloudUpload />} startIcon={<CloudUpload />}
size={isMobile ? "small" : "medium"}> size={isMobile ? "small" : "medium"}
>
Upload Document Upload Document
<VisuallyHiddenInput <VisuallyHiddenInput
type="file" type="file"
@ -272,26 +309,40 @@ const DocumentManager = (props: BackstoryElementProps) => {
<Card variant="outlined"> <Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}> <CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
{documents.length === 0 ? ( {documents.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ <Typography
fontSize: { xs: '0.8rem', sm: '0.875rem' }, variant="body2"
textAlign: 'center', color="text.secondary"
py: 3 sx={{
}}> fontSize: { xs: "0.8rem", sm: "0.875rem" },
textAlign: "center",
py: 3,
}}
>
No additional documents uploaded No additional documents uploaded
</Typography> </Typography>
) : ( ) : (
<List sx={{ width: '100%' }}> <List sx={{ width: "100%" }}>
{documents.map((doc, index) => ( {documents.map((doc, index) => (
<React.Fragment key={doc.id}> <React.Fragment key={doc.id}>
{index > 0 && <Divider />} {index > 0 && <Divider />}
<ListItem sx={{ px: 0 }}> <ListItem sx={{ px: 0 }}>
<ListItemText <ListItemText
primary={ primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Box
<Typography variant="body1" sx={{ sx={{
wordBreak: 'break-word', display: "flex",
fontSize: { xs: '0.9rem', sm: '1rem' } alignItems: "center",
}}> gap: 1,
flexWrap: "wrap",
}}
>
<Typography
variant="body1"
sx={{
wordBreak: "break-word",
fontSize: { xs: "0.9rem", sm: "1rem" },
}}
>
{doc.filename} {doc.filename}
</Typography> </Typography>
<Chip <Chip
@ -311,15 +362,21 @@ const DocumentManager = (props: BackstoryElementProps) => {
} }
secondary={ secondary={
<Box sx={{ mt: 0.5 }}> <Box sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary"> <Typography
{formatFileSize(doc.size)} {doc?.uploadDate?.toLocaleDateString()} variant="caption"
color="text.secondary"
>
{formatFileSize(doc.size)} {" "}
{doc?.uploadDate?.toLocaleDateString()}
</Typography> </Typography>
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
checked={doc.options?.includeInRag} checked={doc.options?.includeInRag}
onChange={(e) => handleRAGToggle(doc, e.target.checked)} onChange={(e) =>
handleRAGToggle(doc, e.target.checked)
}
size="small" size="small"
/> />
} }
@ -334,7 +391,7 @@ const DocumentManager = (props: BackstoryElementProps) => {
} }
/> />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}> <Box sx={{ display: "flex", gap: 0.5 }}>
<IconButton <IconButton
edge="end" edge="end"
size="small" size="small"
@ -376,7 +433,14 @@ const DocumentManager = (props: BackstoryElementProps) => {
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<Card variant="outlined"> <Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}> <CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant={isMobile ? "subtitle2" : "h6"}> <Typography variant={isMobile ? "subtitle2" : "h6"}>
Document Content Document Content
</Typography> </Typography>
@ -385,7 +449,7 @@ const DocumentManager = (props: BackstoryElementProps) => {
onClick={() => { onClick={() => {
setIsViewingContent(false); setIsViewingContent(false);
setSelectedDocument(null); setSelectedDocument(null);
setDocumentContent(''); setDocumentContent("");
}} }}
> >
<Close /> <Close />
@ -396,18 +460,20 @@ const DocumentManager = (props: BackstoryElementProps) => {
sx={{ sx={{
p: 2, p: 2,
maxHeight: 400, maxHeight: 400,
overflow: 'auto', overflow: "auto",
backgroundColor: 'grey.50' backgroundColor: "grey.50",
}} }}
> >
<pre style={{ <pre
style={{
margin: 0, margin: 0,
fontFamily: 'monospace', fontFamily: "monospace",
fontSize: isMobile ? '0.75rem' : '0.875rem', fontSize: isMobile ? "0.75rem" : "0.875rem",
whiteSpace: 'pre-wrap', whiteSpace: "pre-wrap",
wordBreak: 'break-word' wordBreak: "break-word",
}}> }}
{documentContent || 'Loading content...'} >
{documentContent || "Loading content..."}
</pre> </pre>
</Paper> </Paper>
</CardContent> </CardContent>
@ -433,18 +499,19 @@ const DocumentManager = (props: BackstoryElementProps) => {
value={editingName} value={editingName}
onChange={(e) => setEditingName(e.target.value)} onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => { onKeyPress={(e) => {
if (e.key === 'Enter' && editingDocument) { if (e.key === "Enter" && editingDocument) {
handleRenameDocument(editingDocument, editingName); handleRenameDocument(editingDocument, editingName);
} }
}} }}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setIsRenameDialogOpen(false)}> <Button onClick={() => setIsRenameDialogOpen(false)}>Cancel</Button>
Cancel
</Button>
<Button <Button
onClick={() => editingDocument && handleRenameDocument(editingDocument, editingName)} onClick={() =>
editingDocument &&
handleRenameDocument(editingDocument, editingName)
}
variant="contained" variant="contained"
disabled={!editingName.trim()} disabled={!editingName.trim()}
> >

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Box, Box,
Card, Card,
@ -18,8 +18,8 @@ import {
Checkbox, Checkbox,
FormControlLabel, FormControlLabel,
Grid, Grid,
IconButton IconButton,
} from '@mui/material'; } from "@mui/material";
import { import {
Email as EmailIcon, Email as EmailIcon,
Security as SecurityIcon, Security as SecurityIcon,
@ -28,25 +28,33 @@ import {
Refresh as RefreshIcon, Refresh as RefreshIcon,
DevicesOther as DevicesIcon, DevicesOther as DevicesIcon,
VisibilityOff, VisibilityOff,
Visibility Visibility,
} from '@mui/icons-material'; } from "@mui/icons-material";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { BackstoryPageProps } from './BackstoryTab'; import { BackstoryPageProps } from "./BackstoryTab";
import { Navigate, useNavigate } from 'react-router-dom'; import { Navigate, useNavigate } from "react-router-dom";
// Email Verification Component // Email Verification Component
const EmailVerificationPage = (props: BackstoryPageProps) => { const EmailVerificationPage = (props: BackstoryPageProps) => {
const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } = useAuth(); const {
verifyEmail,
resendEmailVerification,
getPendingVerificationEmail,
isLoading,
error,
} = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [verificationToken, setVerificationToken] = useState(''); const [verificationToken, setVerificationToken] = useState("");
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending'); const [status, setStatus] = useState<"pending" | "success" | "error">(
const [message, setMessage] = useState(''); "pending"
const [userType, setUserType] = useState<string>(''); );
const [message, setMessage] = useState("");
const [userType, setUserType] = useState<string>("");
useEffect(() => { useEffect(() => {
// Get token from URL parameters // Get token from URL parameters
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token'); const token = urlParams.get("token");
if (token) { if (token) {
setVerificationToken(token); setVerificationToken(token);
@ -56,8 +64,8 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
const handleVerifyEmail = async (token: string) => { const handleVerifyEmail = async (token: string) => {
if (!token) { if (!token) {
setStatus('error'); setStatus("error");
setMessage('Invalid verification link'); setMessage("Invalid verification link");
return; return;
} }
@ -65,58 +73,60 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
const result = await verifyEmail({ token }); const result = await verifyEmail({ token });
if (result) { if (result) {
setStatus('success'); setStatus("success");
setMessage(result.message); setMessage(result.message);
setUserType(result.userType); setUserType(result.userType);
// Redirect to login after 3 seconds // Redirect to login after 3 seconds
setTimeout(() => { setTimeout(() => {
navigate('/login'); navigate("/login");
}, 3000); }, 3000);
} else { } else {
setStatus('error'); setStatus("error");
setMessage('Email verification failed'); setMessage("Email verification failed");
} }
} catch (error) { } catch (error) {
setStatus('error'); setStatus("error");
setMessage('Email verification failed'); setMessage("Email verification failed");
} }
}; };
const handleResendVerification = async () => { const handleResendVerification = async () => {
const email = getPendingVerificationEmail(); const email = getPendingVerificationEmail();
if (!email) { if (!email) {
setMessage('No pending verification email found.'); setMessage("No pending verification email found.");
return; return;
} }
try { try {
const success = await resendEmailVerification(email); const success = await resendEmailVerification(email);
if (success) { if (success) {
setMessage('Verification email sent successfully!'); setMessage("Verification email sent successfully!");
} }
} catch (error) { } catch (error) {
setMessage('Failed to resend verification email.'); setMessage("Failed to resend verification email.");
} }
}; };
return ( return (
<Box <Box
sx={{ sx={{
minHeight: '100vh', minHeight: "100vh",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
bgcolor: 'grey.50', bgcolor: "grey.50",
p: 2 p: 2,
}} }}
> >
<Card sx={{ maxWidth: 500, width: '100%' }}> <Card sx={{ maxWidth: 500, width: "100%" }}>
<CardContent sx={{ p: 4 }}> <CardContent sx={{ p: 4 }}>
<Box textAlign="center" mb={3}> <Box textAlign="center" mb={3}>
{status === 'pending' && ( {status === "pending" && (
<> <>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} /> <EmailIcon
sx={{ fontSize: 64, color: "primary.main", mb: 2 }}
/>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
Verifying Email Verifying Email
</Typography> </Typography>
@ -126,9 +136,11 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</> </>
)} )}
{status === 'success' && ( {status === "success" && (
<> <>
<CheckCircleIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} /> <CheckCircleIcon
sx={{ fontSize: 64, color: "success.main", mb: 2 }}
/>
<Typography variant="h4" gutterBottom color="success.main"> <Typography variant="h4" gutterBottom color="success.main">
Email Verified! Email Verified!
</Typography> </Typography>
@ -138,9 +150,9 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</> </>
)} )}
{status === 'error' && ( {status === "error" && (
<> <>
<ErrorIcon sx={{ fontSize: 64, color: 'error.main', mb: 2 }} /> <ErrorIcon sx={{ fontSize: 64, color: "error.main", mb: 2 }} />
<Typography variant="h4" gutterBottom color="error.main"> <Typography variant="h4" gutterBottom color="error.main">
Verification Failed Verification Failed
</Typography> </Typography>
@ -159,21 +171,27 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
{(message || error) && ( {(message || error) && (
<Alert <Alert
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'} severity={
status === "success"
? "success"
: status === "error"
? "error"
: "info"
}
sx={{ mt: 2 }} sx={{ mt: 2 }}
> >
{message || error} {message || error}
</Alert> </Alert>
)} )}
{status === 'success' && ( {status === "success" && (
<Box mt={3} textAlign="center"> <Box mt={3} textAlign="center">
<Typography variant="body2" color="text.secondary" mb={2}> <Typography variant="body2" color="text.secondary" mb={2}>
You will be redirected to the login page in a few seconds... You will be redirected to the login page in a few seconds...
</Typography> </Typography>
<Button <Button
variant="contained" variant="contained"
onClick={() => navigate('/login')} onClick={() => navigate("/login")}
fullWidth fullWidth
> >
Go to Login Go to Login
@ -181,7 +199,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Box> </Box>
)} )}
{status === 'error' && ( {status === "error" && (
<Box mt={3}> <Box mt={3}>
<Button <Button
variant="outlined" variant="outlined"
@ -195,7 +213,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
onClick={() => navigate('/login')} onClick={() => navigate("/login")}
fullWidth fullWidth
> >
Back to Login Back to Login
@ -206,7 +224,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Card> </Card>
</Box> </Box>
); );
} };
// MFA Verification Component // MFA Verification Component
interface MFAVerificationDialogProps { interface MFAVerificationDialogProps {
@ -215,15 +233,12 @@ interface MFAVerificationDialogProps {
onVerificationSuccess: (authData: any) => void; onVerificationSuccess: (authData: any) => void;
} }
const MFAVerificationDialog = (props: MFAVerificationDialogProps) => { const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
const { const { open, onClose, onVerificationSuccess } = props;
open, const { verifyMFA, resendMFACode, clearMFA, mfaResponse, isLoading, error } =
onClose, useAuth();
onVerificationSuccess const [code, setCode] = useState("");
} = props;
const { verifyMFA, resendMFACode, clearMFA, mfaResponse, isLoading, error } = useAuth();
const [code, setCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(false); const [rememberDevice, setRememberDevice] = useState(false);
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState("");
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
@ -232,10 +247,9 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
return; return;
} }
/* Remove 'HTTP .*: ' from error string */ /* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, ''); const jsonStr = error.replace(/^[^{]*/, "");
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message); setErrorMessage(data.error.message);
}, [error]); }, [error]);
useEffect(() => { useEffect(() => {
@ -245,7 +259,7 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
setTimeLeft((prev) => { setTimeLeft((prev) => {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer); clearInterval(timer);
setLocalError('MFA code has expired. Please try logging in again.'); setLocalError("MFA code has expired. Please try logging in again.");
return 0; return 0;
} }
return prev - 1; return prev - 1;
@ -258,21 +272,21 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = seconds % 60; const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, "0")}`;
}; };
const handleVerifyMFA = async () => { const handleVerifyMFA = async () => {
if (!code || code.length !== 6) { if (!code || code.length !== 6) {
setLocalError('Please enter a valid 6-digit code'); setLocalError("Please enter a valid 6-digit code");
return; return;
} }
if (!mfaResponse || !mfaResponse.mfaData) { if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available'); setLocalError("MFA data not available");
return; return;
} }
setLocalError(''); setLocalError("");
try { try {
const success = await verifyMFA({ const success = await verifyMFA({
@ -287,25 +301,29 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
onClose(); onClose();
} }
} catch (error) { } catch (error) {
setLocalError('Verification failed. Please try again.'); setLocalError("Verification failed. Please try again.");
} }
}; };
const handleResendCode = async () => { const handleResendCode = async () => {
if (!mfaResponse || !mfaResponse.mfaData) { if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available'); setLocalError("MFA data not available");
return; return;
} }
try { try {
const success = await resendMFACode(mfaResponse.mfaData.email, mfaResponse.mfaData.deviceId, mfaResponse.mfaData.deviceName); const success = await resendMFACode(
mfaResponse.mfaData.email,
mfaResponse.mfaData.deviceId,
mfaResponse.mfaData.deviceName
);
if (success) { if (success) {
setTimeLeft(600); // Reset timer setTimeLeft(600); // Reset timer
setLocalError(''); setLocalError("");
alert('New verification code sent to your email'); alert("New verification code sent to your email");
} }
} catch (error) { } catch (error) {
setLocalError('Failed to resend code'); setLocalError("Failed to resend code");
} }
}; };
@ -321,15 +339,14 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
<DialogTitle> <DialogTitle>
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<SecurityIcon color="primary" /> <SecurityIcon color="primary" />
<Typography variant="h6"> <Typography variant="h6">Verify Your Identity</Typography>
Verify Your Identity
</Typography>
</Box> </Box>
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Alert severity="info" sx={{ mb: 3 }}> <Alert severity="info" sx={{ mb: 3 }}>
We've detected a login from a new device: <strong>{mfaResponse.mfaData.deviceName}</strong> We've detected a login from a new device:{" "}
<strong>{mfaResponse.mfaData.deviceName}</strong>
</Alert> </Alert>
<Typography variant="body1" gutterBottom> <Typography variant="body1" gutterBottom>
@ -344,25 +361,30 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
label="Enter 6-digit code" label="Enter 6-digit code"
value={code} value={code}
onChange={(e) => { onChange={(e) => {
const value = e.target.value.replace(/\D/g, '').slice(0, 6); const value = e.target.value.replace(/\D/g, "").slice(0, 6);
setCode(value); setCode(value);
setLocalError(''); setLocalError("");
}} }}
placeholder="000000" placeholder="000000"
inputProps={{ inputProps={{
maxLength: 6, maxLength: 6,
style: { style: {
fontSize: 24, fontSize: 24,
textAlign: 'center', textAlign: "center",
letterSpacing: 8 letterSpacing: 8,
} },
}} }}
sx={{ mt: 2, mb: 2 }} sx={{ mt: 2, mb: 2 }}
error={!!(localError || errorMessage)} error={!!(localError || errorMessage)}
helperText={localError || errorMessage} helperText={localError || errorMessage}
/> />
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}> <Box
display="flex"
justifyContent="space-between"
alignItems="center"
mb={2}
>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Code expires in: {formatTime(timeLeft)} Code expires in: {formatTime(timeLeft)}
</Typography> </Typography>
@ -387,7 +409,8 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
<Alert severity="warning" sx={{ mt: 2 }}> <Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2"> <Typography variant="body2">
If you didn't attempt to log in, please change your password immediately. If you didn't attempt to log in, please change your password
immediately.
</Typography> </Typography>
</Alert> </Alert>
</DialogContent> </DialogContent>
@ -401,19 +424,19 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
onClick={handleVerifyMFA} onClick={handleVerifyMFA}
disabled={isLoading || !code || code.length !== 6 || timeLeft === 0} disabled={isLoading || !code || code.length !== 6 || timeLeft === 0}
> >
{isLoading ? <CircularProgress size={20} /> : 'Verify'} {isLoading ? <CircularProgress size={20} /> : "Verify"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
} };
// Enhanced Registration Success Component // Enhanced Registration Success Component
const RegistrationSuccessDialog = ({ const RegistrationSuccessDialog = ({
open, open,
onClose, onClose,
email, email,
userType userType,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@ -421,23 +444,23 @@ const RegistrationSuccessDialog = ({
userType: string; userType: string;
}) => { }) => {
const { resendEmailVerification, isLoading } = useAuth(); const { resendEmailVerification, isLoading } = useAuth();
const [resendMessage, setResendMessage] = useState(''); const [resendMessage, setResendMessage] = useState("");
const handleResendVerification = async () => { const handleResendVerification = async () => {
try { try {
const success = await resendEmailVerification(email); const success = await resendEmailVerification(email);
if (success) { if (success) {
setResendMessage('Verification email sent!'); setResendMessage("Verification email sent!");
} }
} catch (error: any) { } catch (error: any) {
setResendMessage(error?.message || 'Network error. Please try again.'); setResendMessage(error?.message || "Network error. Please try again.");
} }
}; };
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogContent sx={{ textAlign: 'center', p: 4 }}> <DialogContent sx={{ textAlign: "center", p: 4 }}>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} /> <EmailIcon sx={{ fontSize: 64, color: "primary.main", mb: 2 }} />
<Typography variant="h5" gutterBottom> <Typography variant="h5" gutterBottom>
Check Your Email Check Your Email
@ -451,7 +474,7 @@ const RegistrationSuccessDialog = ({
{email} {email}
</Typography> </Typography>
<Alert severity="info" sx={{ mt: 2, mb: 3, textAlign: 'left' }}> <Alert severity="info" sx={{ mt: 2, mb: 3, textAlign: "left" }}>
<Typography variant="body2"> <Typography variant="body2">
<strong>Next steps:</strong> <strong>Next steps:</strong>
<br /> <br />
@ -465,7 +488,7 @@ const RegistrationSuccessDialog = ({
{resendMessage && ( {resendMessage && (
<Alert <Alert
severity={resendMessage.includes('sent') ? 'success' : 'error'} severity={resendMessage.includes("sent") ? "success" : "error"}
sx={{ mb: 2 }} sx={{ mb: 2 }}
> >
{resendMessage} {resendMessage}
@ -473,11 +496,13 @@ const RegistrationSuccessDialog = ({
)} )}
</DialogContent> </DialogContent>
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}> <DialogActions sx={{ p: 3, justifyContent: "space-between" }}>
<Button <Button
onClick={handleResendVerification} onClick={handleResendVerification}
disabled={isLoading} disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <RefreshIcon />} startIcon={
isLoading ? <CircularProgress size={16} /> : <RefreshIcon />
}
> >
Resend Email Resend Email
</Button> </Button>
@ -487,13 +512,13 @@ const RegistrationSuccessDialog = ({
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
} };
// Enhanced Login Component with MFA Support // Enhanced Login Component with MFA Support
const LoginForm = () => { const LoginForm = () => {
const { login, mfaResponse, isLoading, error, user } = useAuth(); const { login, mfaResponse, isLoading, error, user } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState("");
const [password, setPassword] = useState(''); const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@ -503,10 +528,9 @@ const LoginForm = () => {
return; return;
} }
/* Remove 'HTTP .*: ' from error string */ /* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, ''); const jsonStr = error.replace(/^[^{]*/, "");
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message); setErrorMessage(data.error.message);
}, [error]); }, [error]);
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
@ -514,7 +538,7 @@ const LoginForm = () => {
const success = await login({ const success = await login({
login: email, login: email,
password password,
}); });
console.log(`login success: ${success}`); console.log(`login success: ${success}`);
@ -531,11 +555,11 @@ const LoginForm = () => {
const handleLoginSuccess = () => { const handleLoginSuccess = () => {
if (!user) { if (!user) {
navigate('/'); navigate("/");
} else { } else {
navigate(`/${user.userType}/dashboard`); navigate(`/${user.userType}/dashboard`);
} }
console.log('Login successful - redirect to dashboard'); console.log("Login successful - redirect to dashboard");
}; };
return ( return (
@ -553,7 +577,7 @@ const LoginForm = () => {
<TextField <TextField
fullWidth fullWidth
label="Password" label="Password"
type={showPassword ? 'text' : 'password'} type={showPassword ? "text" : "password"}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
@ -588,7 +612,7 @@ const LoginForm = () => {
disabled={isLoading} disabled={isLoading}
sx={{ mt: 3, mb: 2 }} sx={{ mt: 3, mb: 2 }}
> >
{isLoading ? <CircularProgress size={20} /> : 'Sign In'} {isLoading ? <CircularProgress size={20} /> : "Sign In"}
</Button> </Button>
{/* MFA Dialog */} {/* MFA Dialog */}
@ -599,7 +623,7 @@ const LoginForm = () => {
/> />
</Box> </Box>
); );
} };
// Device Management Component // Device Management Component
const TrustedDevicesManager = () => { const TrustedDevicesManager = () => {
@ -616,7 +640,7 @@ const TrustedDevicesManager = () => {
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
<DevicesIcon sx={{ mr: 1, verticalAlign: 'middle' }} /> <DevicesIcon sx={{ mr: 1, verticalAlign: "middle" }} />
Trusted Devices Trusted Devices
</Typography> </Typography>
@ -643,7 +667,8 @@ const TrustedDevicesManager = () => {
Added: {new Date(device.addedAt).toLocaleDateString()} Added: {new Date(device.addedAt).toLocaleDateString()}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Last used: {new Date(device.lastUsed).toLocaleDateString()} Last used:{" "}
{new Date(device.lastUsed).toLocaleDateString()}
</Typography> </Typography>
<Button <Button
size="small" size="small"
@ -664,6 +689,12 @@ const TrustedDevicesManager = () => {
</CardContent> </CardContent>
</Card> </Card>
); );
} };
export { EmailVerificationPage, MFAVerificationDialog, TrustedDevicesManager, RegistrationSuccessDialog, LoginForm }; export {
EmailVerificationPage,
MFAVerificationDialog,
TrustedDevicesManager,
RegistrationSuccessDialog,
LoginForm,
};

View File

@ -1,5 +1,5 @@
import { styled } from '@mui/material/styles'; import { styled } from "@mui/material/styles";
import IconButton, { IconButtonProps } from '@mui/material/IconButton'; import IconButton, { IconButtonProps } from "@mui/material/IconButton";
interface ExpandMoreProps extends IconButtonProps { interface ExpandMoreProps extends IconButtonProps {
expand: boolean; expand: boolean;
@ -9,26 +9,24 @@ const ExpandMore = styled((props: ExpandMoreProps) => {
const { expand, ...other } = props; const { expand, ...other } = props;
return <IconButton {...other} />; return <IconButton {...other} />;
})(({ theme }) => ({ })(({ theme }) => ({
marginLeft: 'auto', marginLeft: "auto",
transition: theme.transitions.create('transform', { transition: theme.transitions.create("transform", {
duration: theme.transitions.duration.shortest, duration: theme.transitions.duration.shortest,
}), }),
variants: [ variants: [
{ {
props: ({ expand }) => !expand, props: ({ expand }) => !expand,
style: { style: {
transform: 'rotate(0deg)', transform: "rotate(0deg)",
}, },
}, },
{ {
props: ({ expand }) => !!expand, props: ({ expand }) => !!expand,
style: { style: {
transform: 'rotate(180deg)', transform: "rotate(180deg)",
}, },
}, },
], ],
})); }));
export { export { ExpandMore };
ExpandMore
};

View File

@ -1,26 +1,29 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from "react";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import PropagateLoader from 'react-spinners/PropagateLoader'; import PropagateLoader from "react-spinners/PropagateLoader";
import { Quote } from 'components/Quote'; import { Quote } from "components/Quote";
import { BackstoryElementProps } from 'components/BackstoryTab'; import { BackstoryElementProps } from "components/BackstoryTab";
import { Candidate, ChatSession } from 'types/types'; import { Candidate, ChatSession } from "types/types";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
interface GenerateImageProps extends BackstoryElementProps { interface GenerateImageProps extends BackstoryElementProps {
prompt: string; prompt: string;
chatSession: ChatSession; chatSession: ChatSession;
}; }
const GenerateImage = (props: GenerateImageProps) => { const GenerateImage = (props: GenerateImageProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { chatSession, prompt } = props; const { chatSession, prompt } = props;
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>("");
const [image, setImage] = useState<string>(''); const [image, setImage] = useState<string>("");
const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || ''; const name =
(user?.userType === "candidate"
? (user as Candidate).username
: user?.email) || "";
// Only keep refs that are truly necessary // Only keep refs that are truly necessary
const controllerRef = useRef<string>(null); const controllerRef = useRef<string>(null);
@ -33,7 +36,7 @@ const GenerateImage = (props: GenerateImageProps) => {
if (!prompt) { if (!prompt) {
return; return;
} }
setStatus('Starting image generation...'); setStatus("Starting image generation...");
setProcessing(true); setProcessing(true);
const start = Date.now(); const start = Date.now();
@ -89,20 +92,28 @@ const GenerateImage = (props: GenerateImageProps) => {
} }
return ( return (
<Box className="GenerateImage" sx={{ <Box
className="GenerateImage"
sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
flexGrow: 1, flexGrow: 1,
gap: 1, gap: 1,
maxWidth: { xs: '100%', md: '700px', lg: '1024px' }, maxWidth: { xs: "100%", md: "700px", lg: "1024px" },
minHeight: "max-content", minHeight: "max-content",
}}> }}
{image !== '' && <img alt={prompt} src={`${image}/${chatSession.id}`} />} >
{ prompt && {image !== "" && <img alt={prompt} src={`${image}/${chatSession.id}`} />}
<Quote size={processing ? "normal" : "small"} quote={prompt} sx={{ "& *": { color: "#2E2E2E !important" }}}/> {prompt && (
} <Quote
{processing && size={processing ? "normal" : "small"}
<Box sx={{ quote={prompt}
sx={{ "& *": { color: "#2E2E2E !important" } }}
/>
)}
{processing && (
<Box
sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
@ -110,14 +121,15 @@ const GenerateImage = (props: GenerateImageProps) => {
m: 0, m: 0,
gap: 1, gap: 1,
minHeight: "min-content", minHeight: "min-content",
mb: 2 mb: 2,
}}> }}
{ status && >
{status && (
<Box sx={{ display: "flex", flexDirection: "column" }}> <Box sx={{ display: "flex", flexDirection: "column" }}>
<Box sx={{ fontSize: "0.5rem" }}>Generation status</Box> <Box sx={{ fontSize: "0.5rem" }}>Generation status</Box>
<Box sx={{ fontWeight: "bold" }}>{status}</Box> <Box sx={{ fontWeight: "bold" }}>{status}</Box>
</Box> </Box>
} )}
<PropagateLoader <PropagateLoader
size="10px" size="10px"
loading={processing} loading={processing}
@ -126,10 +138,9 @@ const GenerateImage = (props: GenerateImageProps) => {
data-testid="loader" data-testid="loader"
/> />
</Box> </Box>
} )}
</Box>); </Box>
);
}; };
export { export { GenerateImage };
GenerateImage
};

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, JSX } from 'react'; import React, { useState, useRef, JSX } from "react";
import { import {
Box, Box,
Button, Button,
@ -14,7 +14,7 @@ import {
LinearProgress, LinearProgress,
Stack, Stack,
Paper, Paper,
} from '@mui/material'; } from "@mui/material";
import { import {
SyncAlt, SyncAlt,
Favorite, Favorite,
@ -30,31 +30,31 @@ import {
Business, Business,
Work, Work,
CheckCircle, CheckCircle,
Star Star,
} from '@mui/icons-material'; } from "@mui/icons-material";
import { styled } from '@mui/material/styles'; import { styled } from "@mui/material/styles";
import FileUploadIcon from '@mui/icons-material/FileUpload'; import FileUploadIcon from "@mui/icons-material/FileUpload";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useAppState, useSelectedJob } from 'hooks/GlobalContext'; import { useAppState, useSelectedJob } from "hooks/GlobalContext";
import { BackstoryElementProps } from './BackstoryTab'; import { BackstoryElementProps } from "./BackstoryTab";
import { LoginRequired } from 'components/ui/LoginRequired'; import { LoginRequired } from "components/ui/LoginRequired";
import * as Types from 'types/types'; import * as Types from "types/types";
import { StyledMarkdown } from './StyledMarkdown'; import { StyledMarkdown } from "./StyledMarkdown";
import { JobInfo } from './ui/JobInfo'; import { JobInfo } from "./ui/JobInfo";
import { Scrollable } from './Scrollable'; import { Scrollable } from "./Scrollable";
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon'; import { StatusIcon, StatusBox } from "components/ui/StatusIcon";
const VisuallyHiddenInput = styled('input')({ const VisuallyHiddenInput = styled("input")({
clip: 'rect(0 0 0 0)', clip: "rect(0 0 0 0)",
clipPath: 'inset(50%)', clipPath: "inset(50%)",
height: 1, height: 1,
overflow: 'hidden', overflow: "hidden",
position: 'absolute', position: "absolute",
bottom: 0, bottom: 0,
left: 0, left: 0,
whiteSpace: 'nowrap', whiteSpace: "nowrap",
width: 1, width: 1,
}); });
@ -62,11 +62,11 @@ const UploadBox = styled(Box)(({ theme }) => ({
border: `2px dashed ${theme.palette.primary.main}`, border: `2px dashed ${theme.palette.primary.main}`,
borderRadius: theme.shape.borderRadius * 2, borderRadius: theme.shape.borderRadius * 2,
padding: theme.spacing(4), padding: theme.spacing(4),
textAlign: 'center', textAlign: "center",
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
transition: 'all 0.3s ease', transition: "all 0.3s ease",
cursor: 'pointer', cursor: "pointer",
'&:hover': { "&:hover": {
backgroundColor: theme.palette.action.selected, backgroundColor: theme.palette.action.selected,
borderColor: theme.palette.primary.dark, borderColor: theme.palette.primary.dark,
}, },
@ -81,54 +81,56 @@ const JobCreator = (props: JobCreatorProps) => {
const { selectedJob, setSelectedJob } = useSelectedJob(); const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [jobDescription, setJobDescription] = useState<string>(''); const [jobDescription, setJobDescription] = useState<string>("");
const [jobRequirements, setJobRequirements] = useState<Types.JobRequirements | null>(null); const [jobRequirements, setJobRequirements] =
const [jobTitle, setJobTitle] = useState<string>(''); useState<Types.JobRequirements | null>(null);
const [company, setCompany] = useState<string>(''); const [jobTitle, setJobTitle] = useState<string>("");
const [summary, setSummary] = useState<string>(''); const [company, setCompany] = useState<string>("");
const [summary, setSummary] = useState<string>("");
const [job, setJob] = useState<Types.Job | null>(null); const [job, setJob] = useState<Types.Job | null>(null);
const [jobStatus, setJobStatus] = useState<string>(''); const [jobStatus, setJobStatus] = useState<string>("");
const [jobStatusType, setJobStatusType] = useState<Types.ApiActivityType | null>(null); const [jobStatusType, setJobStatusType] =
useState<Types.ApiActivityType | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false); const [isProcessing, setIsProcessing] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const jobStatusHandlers = { const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus) => { onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content); console.log("status:", status.content);
setJobStatusType(status.activity); setJobStatusType(status.activity);
setJobStatus(status.content); setJobStatus(status.content);
}, },
onMessage: (jobMessage: Types.JobRequirementsMessage) => { onMessage: (jobMessage: Types.JobRequirementsMessage) => {
const job: Types.Job = jobMessage.job const job: Types.Job = jobMessage.job;
console.log('onMessage - job', job); console.log("onMessage - job", job);
setJob(job); setJob(job);
setCompany(job.company || ''); setCompany(job.company || "");
setJobDescription(job.description); setJobDescription(job.description);
setSummary(job.summary || ''); setSummary(job.summary || "");
setJobTitle(job.title || ''); setJobTitle(job.title || "");
setJobRequirements(job.requirements || null); setJobRequirements(job.requirements || null);
setJobStatusType(null); setJobStatusType(null);
setJobStatus(''); setJobStatus("");
}, },
onError: (error: Types.ChatMessageError) => { onError: (error: Types.ChatMessageError) => {
console.log('onError', error); console.log("onError", error);
setSnack(error.content, "error"); setSnack(error.content, "error");
setIsProcessing(false); setIsProcessing(false);
}, },
onComplete: () => { onComplete: () => {
setJobStatusType(null); setJobStatusType(null);
setJobStatus(''); setJobStatus("");
setIsProcessing(false); setIsProcessing(false);
} },
}; };
const handleJobUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleJobUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]; const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); const fileExtension = "." + file.name.split(".").pop()?.toLowerCase();
let docType: Types.DocumentType | null = null; let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) { switch (fileExtension.substring(1)) {
case "pdf": case "pdf":
@ -146,26 +148,29 @@ const JobCreator = (props: JobCreatorProps) => {
} }
if (!docType) { if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error'); setSnack(
"Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.",
"error"
);
return; return;
} }
try { try {
setIsProcessing(true); setIsProcessing(true);
setJobDescription(''); setJobDescription("");
setJobTitle(''); setJobTitle("");
setJobRequirements(null); setJobRequirements(null);
setSummary(''); setSummary("");
const controller = apiClient.createJobFromFile(file, jobStatusHandlers); const controller = apiClient.createJobFromFile(file, jobStatusHandlers);
const job = await controller.promise; const job = await controller.promise;
if (!job) { if (!job) {
return; return;
} }
console.log(`Job id: ${job.id}`); console.log(`Job id: ${job.id}`);
e.target.value = ''; e.target.value = "";
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setSnack('Failed to upload document', 'error'); setSnack("Failed to upload document", "error");
setIsProcessing(false); setIsProcessing(false);
} }
} }
@ -175,17 +180,24 @@ const JobCreator = (props: JobCreatorProps) => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const renderRequirementSection = (title: string, items: string[] | undefined, icon: JSX.Element, required = false) => { const renderRequirementSection = (
title: string,
items: string[] | undefined,
icon: JSX.Element,
required = false
) => {
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}> <Box sx={{ display: "flex", alignItems: "center", mb: 1.5 }}>
{icon} {icon}
<Typography variant="subtitle1" sx={{ ml: 1, fontWeight: 600 }}> <Typography variant="subtitle1" sx={{ ml: 1, fontWeight: 600 }}>
{title} {title}
</Typography> </Typography>
{required && <Chip label="Required" size="small" color="error" sx={{ ml: 1 }} />} {required && (
<Chip label="Required" size="small" color="error" sx={{ ml: 1 }} />
)}
</Box> </Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{items.map((item, index) => ( {items.map((item, index) => (
@ -267,8 +279,8 @@ const JobCreator = (props: JobCreatorProps) => {
const handleSave = async () => { const handleSave = async () => {
const newJob: Types.Job = { const newJob: Types.Job = {
ownerId: user?.id || '', ownerId: user?.id || "",
ownerType: 'candidate', ownerType: "candidate",
description: jobDescription, description: jobDescription,
company: company, company: company,
summary: summary, summary: summary,
@ -281,7 +293,7 @@ const JobCreator = (props: JobCreatorProps) => {
const job = await apiClient.createJob(newJob); const job = await apiClient.createJob(newJob);
setIsProcessing(false); setIsProcessing(false);
if (!job) { if (!job) {
setSnack('Failed to save job', 'error'); setSnack("Failed to save job", "error");
return; return;
} }
onSave && onSave(job); onSave && onSave(job);
@ -290,7 +302,10 @@ const JobCreator = (props: JobCreatorProps) => {
const handleExtractRequirements = async () => { const handleExtractRequirements = async () => {
try { try {
setIsProcessing(true); setIsProcessing(true);
const controller = apiClient.createJobFromDescription(jobDescription, jobStatusHandlers); const controller = apiClient.createJobFromDescription(
jobDescription,
jobStatusHandlers
);
const job = await controller.promise; const job = await controller.promise;
if (!job) { if (!job) {
setIsProcessing(false); setIsProcessing(false);
@ -299,7 +314,7 @@ const JobCreator = (props: JobCreatorProps) => {
console.log(`Job id: ${job.id}`); console.log(`Job id: ${job.id}`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setSnack('Failed to upload document', 'error'); setSnack("Failed to upload document", "error");
setIsProcessing(false); setIsProcessing(false);
} }
setIsProcessing(false); setIsProcessing(false);
@ -307,10 +322,12 @@ const JobCreator = (props: JobCreatorProps) => {
const renderJobCreation = () => { const renderJobCreation = () => {
return ( return (
<Box sx={{ <Box
sx={{
width: "100%", width: "100%",
p: 1 p: 1,
}}> }}
>
{/* Upload Section */} {/* Upload Section */}
<Card elevation={3} sx={{ mb: 4 }}> <Card elevation={3} sx={{ mb: 4 }}>
<CardHeader <CardHeader
@ -321,16 +338,26 @@ const JobCreator = (props: JobCreatorProps) => {
<CardContent> <CardContent>
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}> <Typography
variant="h6"
gutterBottom
sx={{ display: "flex", alignItems: "center" }}
>
<CloudUpload sx={{ mr: 1 }} /> <CloudUpload sx={{ mr: 1 }} />
Upload Job Description Upload Job Description
</Typography> </Typography>
<UploadBox onClick={handleUploadClick}> <UploadBox onClick={handleUploadClick}>
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} /> <CloudUpload
sx={{ fontSize: 48, color: "primary.main", mb: 2 }}
/>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Drop your job description here Drop your job description here
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2 }}
>
Supported formats: PDF, DOCX, TXT, MD Supported formats: PDF, DOCX, TXT, MD
</Typography> </Typography>
<Button <Button
@ -351,7 +378,11 @@ const JobCreator = (props: JobCreatorProps) => {
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}> <Typography
variant="h6"
gutterBottom
sx={{ display: "flex", alignItems: "center" }}
>
<Description sx={{ mr: 1 }} /> <Description sx={{ mr: 1 }} />
Or Enter Manually Or Enter Manually
</Typography> </Typography>
@ -385,7 +416,7 @@ const JobCreator = (props: JobCreatorProps) => {
<StatusBox> <StatusBox>
{jobStatusType && <StatusIcon type={jobStatusType} />} {jobStatusType && <StatusIcon type={jobStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}> <Typography variant="body2" sx={{ ml: 1 }}>
{jobStatus || 'Processing...'} {jobStatus || "Processing..."}
</Typography> </Typography>
</StatusBox> </StatusBox>
{isProcessing && <LinearProgress sx={{ mt: 1 }} />} {isProcessing && <LinearProgress sx={{ mt: 1 }} />}
@ -413,7 +444,9 @@ const JobCreator = (props: JobCreatorProps) => {
required required
disabled={isProcessing} disabled={isProcessing}
InputProps={{ InputProps={{
startAdornment: <Work sx={{ mr: 1, color: 'text.secondary' }} /> startAdornment: (
<Work sx={{ mr: 1, color: "text.secondary" }} />
),
}} }}
/> />
</Grid> </Grid>
@ -428,7 +461,9 @@ const JobCreator = (props: JobCreatorProps) => {
required required
disabled={isProcessing} disabled={isProcessing}
InputProps={{ InputProps={{
startAdornment: <Business sx={{ mr: 1, color: 'text.secondary' }} /> startAdornment: (
<Business sx={{ mr: 1, color: "text.secondary" }} />
),
}} }}
/> />
</Grid> </Grid>
@ -451,66 +486,91 @@ const JobCreator = (props: JobCreatorProps) => {
</Card> </Card>
{/* Job Summary */} {/* Job Summary */}
{summary !== '' && {summary !== "" && (
<Card elevation={2} sx={{ mt: 3 }}> <Card elevation={2} sx={{ mt: 3 }}>
<CardHeader <CardHeader
title="Job Summary" title="Job Summary"
avatar={<CheckCircle color="success" />} avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }} sx={{ pb: 1 }}
/> />
<CardContent sx={{ pt: 0 }}> <CardContent sx={{ pt: 0 }}>{summary}</CardContent>
{summary}
</CardContent>
</Card> </Card>
} )}
{/* Requirements Display */} {/* Requirements Display */}
{renderJobRequirements()} {renderJobRequirements()}
</Box> </Box>
); );
}; };
return ( return (
<Box className="JobManagement" <Box
className="JobManagement"
sx={{ sx={{
background: "white", background: "white",
p: 0, p: 0,
width: "100%", width: "100%",
display: "flex", flexDirection: "column" display: "flex",
}}> flexDirection: "column",
}}
>
{job === null && renderJobCreation()} {job === null && renderJobCreation()}
{job && {job && (
<Box sx={{ <Box
display: "flex", flexDirection: "column", sx={{
height: "100%", /* Restrict to main-container's height */ display: "flex",
flexDirection: "column",
height: "100%" /* Restrict to main-container's height */,
width: "100%", width: "100%",
minHeight: 0,/* Prevent flex overflow */ minHeight: 0 /* Prevent flex overflow */,
maxHeight: "min-content", maxHeight: "min-content",
position: "relative", position: "relative",
}}> }}
<Box sx={{ >
<Box
sx={{
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
flexGrow: 1, flexGrow: 1,
gap: 1, gap: 1,
height: "100%", /* Restrict to main-container's height */ height: "100%" /* Restrict to main-container's height */,
width: "100%", width: "100%",
minHeight: 0,/* Prevent flex overflow */ minHeight: 0 /* Prevent flex overflow */,
maxHeight: "min-content", maxHeight: "min-content",
"& > *:not(.Scrollable)": { "& > *:not(.Scrollable)": {
flexShrink: 0, /* Prevent shrinking */ flexShrink: 0 /* Prevent shrinking */,
}, },
position: "relative", position: "relative",
}}> }}
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><JobInfo job={job} /></Scrollable> >
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><StyledMarkdown content={job.description} /></Scrollable> <Scrollable
sx={{
display: "flex",
flexGrow: 1,
position: "relative",
maxHeight: "30rem",
}}
>
<JobInfo job={job} />
</Scrollable>
<Scrollable
sx={{
display: "flex",
flexGrow: 1,
position: "relative",
maxHeight: "30rem",
}}
>
<StyledMarkdown content={job.description} />
</Scrollable>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}> <Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
<Button <Button
variant="contained" variant="contained"
onClick={handleSave} onClick={handleSave}
disabled={!jobTitle || !company || !jobDescription || isProcessing} disabled={
!jobTitle || !company || !jobDescription || isProcessing
}
fullWidth={isMobile} fullWidth={isMobile}
size="large" size="large"
startIcon={<CheckCircle />} startIcon={<CheckCircle />}
@ -518,9 +578,8 @@ const JobCreator = (props: JobCreatorProps) => {
Save Job Save Job
</Button> </Button>
</Box> </Box>
</Box> </Box>
} )}
</Box> </Box>
); );
}; };

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Box, Box,
Typography, Typography,
@ -15,24 +15,36 @@ import {
useTheme, useTheme,
LinearProgress, LinearProgress,
useMediaQuery, useMediaQuery,
Button Button,
} from '@mui/material'; } from "@mui/material";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from "@mui/icons-material/Error";
import PendingIcon from '@mui/icons-material/Pending'; import PendingIcon from "@mui/icons-material/Pending";
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from "@mui/icons-material/Warning";
import { Candidate, ChatMessage, ChatMessageError, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, ChatSession, EvidenceDetail, JobRequirements, SkillAssessment, SkillStatus } from 'types/types'; import {
import { useAuth } from 'hooks/AuthContext'; Candidate,
import { BackstoryPageProps } from './BackstoryTab'; ChatMessage,
import { Job } from 'types/types'; ChatMessageError,
import { StyledMarkdown } from './StyledMarkdown'; ChatMessageStatus,
import { Scrollable } from './Scrollable'; ChatMessageStreaming,
import { useAppState } from 'hooks/GlobalContext'; ChatMessageUser,
import * as Types from 'types/types'; ChatSession,
import JsonView from '@uiw/react-json-view'; EvidenceDetail,
import { VectorVisualizer } from './VectorVisualizer'; JobRequirements,
import { JobInfo } from './ui/JobInfo'; SkillAssessment,
SkillStatus,
} from "types/types";
import { useAuth } from "hooks/AuthContext";
import { BackstoryPageProps } from "./BackstoryTab";
import { Job } from "types/types";
import { StyledMarkdown } from "./StyledMarkdown";
import { Scrollable } from "./Scrollable";
import { useAppState } from "hooks/GlobalContext";
import * as Types from "types/types";
import JsonView from "@uiw/react-json-view";
import { VectorVisualizer } from "./VectorVisualizer";
import { JobInfo } from "./ui/JobInfo";
interface JobAnalysisProps extends BackstoryPageProps { interface JobAnalysisProps extends BackstoryPageProps {
job: Job; job: Job;
@ -42,7 +54,13 @@ interface JobAnalysisProps extends BackstoryPageProps {
} }
const defaultMessage: ChatMessage = { const defaultMessage: ChatMessage = {
status: "done", type: "text", sessionId: "", timestamp: new Date(), content: "", role: "assistant", metadata: null as any status: "done",
type: "text",
sessionId: "",
timestamp: new Date(),
content: "",
role: "assistant",
metadata: null as any,
}; };
interface SkillMatch extends SkillAssessment { interface SkillMatch extends SkillAssessment {
@ -51,33 +69,36 @@ interface SkillMatch extends SkillAssessment {
matchScore: number; matchScore: number;
} }
const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => { const JobMatchAnalysis: React.FC<JobAnalysisProps> = (
const { props: JobAnalysisProps
job, ) => {
candidate, const { job, candidate, onAnalysisComplete, variant = "normal" } = props;
onAnalysisComplete,
variant = "normal",
} = props
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const theme = useTheme(); const theme = useTheme();
const [requirements, setRequirements] = useState<{ requirement: string, domain: string }[]>([]); const [requirements, setRequirements] = useState<
{ requirement: string; domain: string }[]
>([]);
const [skillMatches, setSkillMatches] = useState<SkillMatch[]>([]); const [skillMatches, setSkillMatches] = useState<SkillMatch[]>([]);
const [creatingSession, setCreatingSession] = useState<boolean>(false); const [creatingSession, setCreatingSession] = useState<boolean>(false);
const [loadingRequirements, setLoadingRequirements] = useState<boolean>(false); const [loadingRequirements, setLoadingRequirements] =
useState<boolean>(false);
const [expanded, setExpanded] = useState<string | false>(false); const [expanded, setExpanded] = useState<string | false>(false);
const [overallScore, setOverallScore] = useState<number>(0); const [overallScore, setOverallScore] = useState<number>(0);
const [requirementsSession, setRequirementsSession] = useState<ChatSession | null>(null); const [requirementsSession, setRequirementsSession] =
useState<ChatSession | null>(null);
const [statusMessage, setStatusMessage] = useState<ChatMessage | null>(null); const [statusMessage, setStatusMessage] = useState<ChatMessage | null>(null);
const [startAnalysis, setStartAnalysis] = useState<boolean>(false); const [startAnalysis, setStartAnalysis] = useState<boolean>(false);
const [analyzing, setAnalyzing] = useState<boolean>(false); const [analyzing, setAnalyzing] = useState<boolean>(false);
const [matchStatus, setMatchStatus] = useState<string>(''); const [matchStatus, setMatchStatus] = useState<string>("");
const [matchStatusType, setMatchStatusType] = useState<Types.ApiActivityType | null>(null); const [matchStatusType, setMatchStatusType] =
useState<Types.ApiActivityType | null>(null);
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
// Handle accordion expansion // Handle accordion expansion
const handleAccordionChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => { const handleAccordionChange =
(panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpanded(isExpanded ? panel : false); setExpanded(isExpanded ? panel : false);
}; };
@ -85,37 +106,64 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
if (!job || !job.requirements) { if (!job || !job.requirements) {
return; return;
} }
const requirements: { requirement: string, domain: string }[] = []; const requirements: { requirement: string; domain: string }[] = [];
if (job.requirements?.technicalSkills) { if (job.requirements?.technicalSkills) {
job.requirements.technicalSkills.required?.forEach(req => requirements.push({ requirement: req, domain: 'Technical Skills (required)' })); job.requirements.technicalSkills.required?.forEach((req) =>
job.requirements.technicalSkills.preferred?.forEach(req => requirements.push({ requirement: req, domain: 'Technical Skills (preferred)' })); requirements.push({
requirement: req,
domain: "Technical Skills (required)",
})
);
job.requirements.technicalSkills.preferred?.forEach((req) =>
requirements.push({
requirement: req,
domain: "Technical Skills (preferred)",
})
);
} }
if (job.requirements?.experienceRequirements) { if (job.requirements?.experienceRequirements) {
job.requirements.experienceRequirements.required?.forEach(req => requirements.push({ requirement: req, domain: 'Experience (required)' })); job.requirements.experienceRequirements.required?.forEach((req) =>
job.requirements.experienceRequirements.preferred?.forEach(req => requirements.push({ requirement: req, domain: 'Experience (preferred)' })); requirements.push({ requirement: req, domain: "Experience (required)" })
);
job.requirements.experienceRequirements.preferred?.forEach((req) =>
requirements.push({
requirement: req,
domain: "Experience (preferred)",
})
);
} }
if (job.requirements?.softSkills) { if (job.requirements?.softSkills) {
job.requirements.softSkills.forEach(req => requirements.push({ requirement: req, domain: 'Soft Skills' })); job.requirements.softSkills.forEach((req) =>
requirements.push({ requirement: req, domain: "Soft Skills" })
);
} }
if (job.requirements?.experience) { if (job.requirements?.experience) {
job.requirements.experience.forEach(req => requirements.push({ requirement: req, domain: 'Experience' })); job.requirements.experience.forEach((req) =>
requirements.push({ requirement: req, domain: "Experience" })
);
} }
if (job.requirements?.education) { if (job.requirements?.education) {
job.requirements.education.forEach(req => requirements.push({ requirement: req, domain: 'Education' })); job.requirements.education.forEach((req) =>
requirements.push({ requirement: req, domain: "Education" })
);
} }
if (job.requirements?.certifications) { if (job.requirements?.certifications) {
job.requirements.certifications.forEach(req => requirements.push({ requirement: req, domain: 'Certifications' })); job.requirements.certifications.forEach((req) =>
requirements.push({ requirement: req, domain: "Certifications" })
);
} }
if (job.requirements?.preferredAttributes) { if (job.requirements?.preferredAttributes) {
job.requirements.preferredAttributes.forEach(req => requirements.push({ requirement: req, domain: 'Preferred Attributes' })); job.requirements.preferredAttributes.forEach((req) =>
requirements.push({ requirement: req, domain: "Preferred Attributes" })
);
} }
const initialSkillMatches: SkillMatch[] = requirements.map(req => ({ const initialSkillMatches: SkillMatch[] = requirements.map((req) => ({
skill: req.requirement, skill: req.requirement,
skillModified: req.requirement, skillModified: req.requirement,
candidateId: candidate.id || "", candidateId: candidate.id || "",
domain: req.domain, domain: req.domain,
status: 'waiting' as const, status: "waiting" as const,
assessment: "", assessment: "",
description: "", description: "",
evidenceFound: false, evidenceFound: false,
@ -129,7 +177,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
setStatusMessage(null); setStatusMessage(null);
setLoadingRequirements(false); setLoadingRequirements(false);
setOverallScore(0); setOverallScore(0);
} };
useEffect(() => { useEffect(() => {
initializeRequirements(job); initializeRequirements(job);
@ -154,56 +202,81 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
// Process requirements one by one // Process requirements one by one
for (let i = 0; i < requirements.length; i++) { for (let i = 0; i < requirements.length; i++) {
try { try {
setSkillMatches(prev => { setSkillMatches((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[i] = { ...updated[i], status: 'pending' }; updated[i] = { ...updated[i], status: "pending" };
return updated; return updated;
}); });
const request: any = await apiClient.candidateMatchForRequirement(candidate.id || '', requirements[i].requirement, skillMatchHandlers); const request: any = await apiClient.candidateMatchForRequirement(
candidate.id || "",
requirements[i].requirement,
skillMatchHandlers
);
const result = await request.promise; const result = await request.promise;
const skillMatch = result.skillAssessment; const skillMatch = result.skillAssessment;
skills.push(skillMatch); skills.push(skillMatch);
setMatchStatus(''); setMatchStatus("");
let matchScore: number = 0; let matchScore = 0;
switch (skillMatch.evidenceStrength.toUpperCase()) { switch (skillMatch.evidenceStrength.toUpperCase()) {
case "STRONG": matchScore = 100; break; case "STRONG":
case "MODERATE": matchScore = 75; break; matchScore = 100;
case "WEAK": matchScore = 50; break; break;
case "NONE": matchScore = 0; break; case "MODERATE":
matchScore = 75;
break;
case "WEAK":
matchScore = 50;
break;
case "NONE":
matchScore = 0;
break;
} }
if (skillMatch.evidenceStrength == "NONE" && skillMatch.citations && skillMatch.citations.length > 3) { if (
skillMatch.evidenceStrength == "NONE" &&
skillMatch.citations &&
skillMatch.citations.length > 3
) {
matchScore = Math.min(skillMatch.citations.length * 8, 40); matchScore = Math.min(skillMatch.citations.length * 8, 40);
} }
const match: SkillMatch = { const match: SkillMatch = {
...skillMatch, ...skillMatch,
status: 'complete', status: "complete",
matchScore, matchScore,
domain: requirements[i].domain, domain: requirements[i].domain,
}; };
setSkillMatches(prev => { setSkillMatches((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[i] = match; updated[i] = match;
return updated; return updated;
}); });
// Update overall score // Update overall score
setSkillMatches(current => { setSkillMatches((current) => {
const completedMatches = current.filter(match => match.status === 'complete'); const completedMatches = current.filter(
(match) => match.status === "complete"
);
if (completedMatches.length > 0) { if (completedMatches.length > 0) {
const newOverallScore = completedMatches.reduce((sum, match) => sum + match.matchScore, 0) / completedMatches.length; const newOverallScore =
completedMatches.reduce(
(sum, match) => sum + match.matchScore,
0
) / completedMatches.length;
setOverallScore(newOverallScore); setOverallScore(newOverallScore);
} }
return current; return current;
}); });
} catch (error) { } catch (error) {
console.error(`Error fetching match for requirement ${requirements[i]}:`, error); console.error(
setSkillMatches(prev => { `Error fetching match for requirement ${requirements[i]}:`,
error
);
setSkillMatches((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[i] = { updated[i] = {
...updated[i], ...updated[i],
status: 'error', status: "error",
assessment: 'Failed to analyze this requirement.' assessment: "Failed to analyze this requirement.",
}; };
return updated; return updated;
}); });
@ -218,7 +291,14 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
setStartAnalysis(false); setStartAnalysis(false);
onAnalysisComplete && onAnalysisComplete(skills); onAnalysisComplete && onAnalysisComplete(skills);
}); });
}, [job, onAnalysisComplete, startAnalysis, analyzing, requirements, loadingRequirements]); }, [
job,
onAnalysisComplete,
startAnalysis,
analyzing,
requirements,
loadingRequirements,
]);
// Get color based on match score // Get color based on match score
const getMatchColor = (score: number): string => { const getMatchColor = (score: number): string => {
@ -230,8 +310,8 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
// Get icon based on status // Get icon based on status
const getStatusIcon = (status: string, score: number) => { const getStatusIcon = (status: string, score: number) => {
if (status === 'pending' || status === 'waiting') return <PendingIcon />; if (status === "pending" || status === "waiting") return <PendingIcon />;
if (status === 'error') return <ErrorIcon color="error" />; if (status === "error") return <ErrorIcon color="error" />;
if (score >= 70) return <CheckCircleIcon color="success" />; if (score >= 70) return <CheckCircleIcon color="success" />;
if (score >= 40) return <WarningIcon color="warning" />; if (score >= 40) return <WarningIcon color="warning" />;
return <ErrorIcon color="error" />; return <ErrorIcon color="error" />;
@ -244,21 +324,38 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", m: 0, p: 0 }}> <Box sx={{ display: "flex", flexDirection: "column", m: 0, p: 0 }}>
{variant !== "small" && {variant !== "small" && <JobInfo job={job} variant="normal" />}
<JobInfo job={job} variant="normal" />
}
<Box sx={{ display: 'flex', flexDirection: "row", alignItems: 'center', mb: isMobile ? 1 : 2, gap: 1, justifyContent: "space-between" }}> <Box
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", flexGrow: 1, gap: 1 }}> sx={{
{overallScore !== 0 && <> display: "flex",
flexDirection: "row",
alignItems: "center",
mb: isMobile ? 1 : 2,
gap: 1,
justifyContent: "space-between",
}}
>
<Box
sx={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
flexGrow: 1,
gap: 1,
}}
>
{overallScore !== 0 && (
<>
<Typography variant="h5" component="h2" sx={{ mr: 2 }}> <Typography variant="h5" component="h2" sx={{ mr: 2 }}>
Overall Match: Overall Match:
</Typography> </Typography>
<Box sx={{ <Box
position: 'relative', sx={{
display: 'inline-flex', position: "relative",
mr: 2 display: "inline-flex",
}}> mr: 2,
}}
>
<CircularProgress <CircularProgress
variant="determinate" variant="determinate"
value={overallScore} value={overallScore}
@ -274,38 +371,52 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
left: 0, left: 0,
bottom: 0, bottom: 0,
right: 0, right: 0,
position: 'absolute', position: "absolute",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}} }}
> >
<Typography variant="caption" component="div" sx={{ fontWeight: 'bold' }}> <Typography
variant="caption"
component="div"
sx={{ fontWeight: "bold" }}
>
{`${Math.round(overallScore)}%`} {`${Math.round(overallScore)}%`}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
<Chip <Chip
label={ label={
overallScore >= 80 ? "Excellent Match" : overallScore >= 80
overallScore >= 60 ? "Good Match" : ? "Excellent Match"
overallScore >= 40 ? "Partial Match" : "Low Match" : overallScore >= 60
? "Good Match"
: overallScore >= 40
? "Partial Match"
: "Low Match"
} }
sx={{ sx={{
bgcolor: getMatchColor(overallScore), bgcolor: getMatchColor(overallScore),
color: 'white', color: "white",
fontWeight: 'bold' fontWeight: "bold",
}} }}
/> />
</>} </>
)}
</Box> </Box>
<Button sx={{ marginLeft: "auto" }} disabled={analyzing || startAnalysis} onClick={beginAnalysis} variant="contained"> <Button
sx={{ marginLeft: "auto" }}
disabled={analyzing || startAnalysis}
onClick={beginAnalysis}
variant="contained"
>
{analyzing ? "Assessment in Progress" : "Start Skill Assessment"} {analyzing ? "Assessment in Progress" : "Start Skill Assessment"}
</Button> </Button>
</Box> </Box>
{loadingRequirements ? ( {loadingRequirements ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}> <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress /> <CircularProgress />
<Typography variant="h6" sx={{ ml: 2 }}> <Typography variant="h6" sx={{ ml: 2 }}>
Analyzing job requirements... Analyzing job requirements...
@ -324,10 +435,11 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
onChange={handleAccordionChange(`panel${index}`)} onChange={handleAccordionChange(`panel${index}`)}
sx={{ sx={{
mb: 2, mb: 2,
border: '1px solid', border: "1px solid",
borderColor: match.status === 'complete' borderColor:
match.status === "complete"
? getMatchColor(match.matchScore) ? getMatchColor(match.matchScore)
: theme.palette.divider : theme.palette.divider,
}} }}
> >
<AccordionSummary <AccordionSummary
@ -335,72 +447,107 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
aria-controls={`panel${index}bh-content`} aria-controls={`panel${index}bh-content`}
id={`panel${index}bh-header`} id={`panel${index}bh-header`}
sx={{ sx={{
bgcolor: match.status === 'complete' bgcolor:
match.status === "complete"
? `${getMatchColor(match.matchScore)}22` // Add transparency ? `${getMatchColor(match.matchScore)}22` // Add transparency
: 'inherit' : "inherit",
}} }}
> >
<Box sx={{ <Box
display: 'flex', sx={{
alignItems: 'center', display: "flex",
width: '100%', alignItems: "center",
justifyContent: 'space-between' width: "100%",
}}> justifyContent: "space-between",
<Box sx={{ display: 'flex', alignItems: 'center' }}> }}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
{getStatusIcon(match.status, match.matchScore)} {getStatusIcon(match.status, match.matchScore)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 0, p: 0, m: 0 }}> <Box
<Typography sx={{ ml: 1, mb: 0, fontWeight: 'medium', marginBottom: "0px !important" }}> sx={{
display: "flex",
flexDirection: "column",
gap: 0,
p: 0,
m: 0,
}}
>
<Typography
sx={{
ml: 1,
mb: 0,
fontWeight: "medium",
marginBottom: "0px !important",
}}
>
{match.skill} {match.skill}
</Typography> </Typography>
<Typography variant="caption" sx={{ ml: 1, fontWeight: 'light' }}> <Typography
variant="caption"
sx={{ ml: 1, fontWeight: "light" }}
>
{match.domain} {match.domain}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
{match.status === 'complete' ? ( {match.status === "complete" ? (
<Chip <Chip
label={`${match.matchScore}% Match`} label={`${match.matchScore}% Match`}
size="small" size="small"
sx={{ sx={{
bgcolor: getMatchColor(match.matchScore), bgcolor: getMatchColor(match.matchScore),
color: 'white', color: "white",
minWidth: 90 minWidth: 90,
}} }}
/> />
) : match.status === 'waiting' ? ( ) : match.status === "waiting" ? (
<Chip <Chip
label="Waiting..." label="Waiting..."
size="small" size="small"
sx={{ bgcolor: "rgb(189, 173, 85)", color: 'white', minWidth: 90 }} sx={{
bgcolor: "rgb(189, 173, 85)",
color: "white",
minWidth: 90,
}}
/> />
) : match.status === 'pending' ? ( ) : match.status === "pending" ? (
<Chip <Chip
label="Analyzing..." label="Analyzing..."
size="small" size="small"
sx={{ bgcolor: theme.palette.grey[400], color: 'white', minWidth: 90 }} sx={{
bgcolor: theme.palette.grey[400],
color: "white",
minWidth: 90,
}}
/> />
) : ( ) : (
<Chip <Chip
label="Error" label="Error"
size="small" size="small"
sx={{ bgcolor: theme.palette.error.main, color: 'white', minWidth: 90 }} sx={{
bgcolor: theme.palette.error.main,
color: "white",
minWidth: 90,
}}
/> />
)} )}
</Box> </Box>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
{match.status === 'pending' ? ( {match.status === "pending" ? (
<Box sx={{ width: '100%', p: 2 }}> <Box sx={{ width: "100%", p: 2 }}>
<LinearProgress /> <LinearProgress />
<Typography sx={{ mt: 2 }}> <Typography sx={{ mt: 2 }}>
Analyzing candidate's match for this requirement... {matchStatus} Analyzing candidate's match for this requirement...{" "}
{matchStatus}
</Typography> </Typography>
</Box> </Box>
) : match.status === 'error' ? ( ) : match.status === "error" ? (
<Typography color="error"> <Typography color="error">
{match.assessment || "An error occurred while analyzing this requirement."} {match.assessment ||
"An error occurred while analyzing this requirement."}
</Typography> </Typography>
) : ( ) : (
<Box> <Box>
@ -415,26 +562,44 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Supporting Evidence Supporting Evidence
</Typography> </Typography>
{match.evidenceDetails && match.evidenceDetails.length > 0 ? ( {match.evidenceDetails &&
match.evidenceDetails.length > 0 ? (
match.evidenceDetails.map((evidence, evndex) => ( match.evidenceDetails.map((evidence, evndex) => (
<Card <Card
key={evndex} key={evndex}
variant="outlined" variant="outlined"
sx={{ sx={{
mb: 2, mb: 2,
borderLeft: '4px solid', borderLeft: "4px solid",
borderColor: theme.palette.primary.main, borderColor: theme.palette.primary.main,
}} }}
> >
<CardContent> <CardContent>
<Typography variant="body1" component="div" sx={{ mb: 1, fontStyle: 'italic' }}> <Typography
variant="body1"
component="div"
sx={{ mb: 1, fontStyle: "italic" }}
>
"{evidence.quote}" "{evidence.quote}"
</Typography> </Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexDirection: "column" }}> <Box
<Typography variant="body2" color="text.secondary"> sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
flexDirection: "column",
}}
>
<Typography
variant="body2"
color="text.secondary"
>
Relevance: {evidence.context} Relevance: {evidence.context}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography
variant="caption"
color="text.secondary"
>
Source: {evidence.source} Source: {evidence.source}
</Typography> </Typography>
{/* <Chip {/* <Chip
@ -456,9 +621,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Skill description Skill description
</Typography> </Typography>
<Typography paragraph> <Typography paragraph>{match.description}</Typography>
{match.description}
</Typography>
{/* { match.ragResults && match.ragResults.length !== 0 && <> {/* { match.ragResults && match.ragResults.length !== 0 && <>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
RAG Information RAG Information
@ -466,7 +629,6 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
<VectorVisualizer inline rag={match.ragResults[0]} /> <VectorVisualizer inline rag={match.ragResults[0]} />
</> </>
} */} } */}
</Box> </Box>
)} )}
</AccordionDetails> </AccordionDetails>

View File

@ -1,13 +1,20 @@
import React from 'react'; import React from "react";
import { Box, CircularProgress, Typography, Grid, LinearProgress, Fade } from '@mui/material'; import {
import { styled } from '@mui/material/styles'; Box,
CircularProgress,
Typography,
Grid,
LinearProgress,
Fade,
} from "@mui/material";
import { styled } from "@mui/material/styles";
// Types for props // Types for props
interface LoadingComponentProps { interface LoadingComponentProps {
/** Text to display while loading */ /** Text to display while loading */
loadingText?: string; loadingText?: string;
/** Type of loader to show */ /** Type of loader to show */
loaderType?: 'circular' | 'linear'; loaderType?: "circular" | "linear";
/** Whether to show with fade-in animation */ /** Whether to show with fade-in animation */
withFade?: boolean; withFade?: boolean;
/** Duration of fade-in animation in ms */ /** Duration of fade-in animation in ms */
@ -16,37 +23,37 @@ interface LoadingComponentProps {
// Styled components // Styled components
const LoadingContainer = styled(Box)(({ theme }) => ({ const LoadingContainer = styled(Box)(({ theme }) => ({
width: '100%', width: "100%",
padding: theme.spacing(3), padding: theme.spacing(3),
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
})); }));
/** /**
* A loading component to display at the top of pages while content is loading * A loading component to display at the top of pages while content is loading
*/ */
const LoadingComponent: React.FC<LoadingComponentProps> = ({ const LoadingComponent: React.FC<LoadingComponentProps> = ({
loadingText = 'Loading content...', loadingText = "Loading content...",
loaderType = 'circular', loaderType = "circular",
withFade = true, withFade = true,
fadeDuration = 800, fadeDuration = 800,
}) => { }) => {
const content = ( const content = (
<LoadingContainer> <LoadingContainer>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center', mb: 2 }}> <Grid size={{ xs: 12 }} sx={{ textAlign: "center", mb: 2 }}>
{loaderType === 'circular' ? ( {loaderType === "circular" ? (
<CircularProgress color="primary" /> <CircularProgress color="primary" />
) : ( ) : (
<Box sx={{ width: '100%', maxWidth: 400 }}> <Box sx={{ width: "100%", maxWidth: 400 }}>
<LinearProgress color="primary" /> <LinearProgress color="primary" />
</Box> </Box>
)} )}
</Grid> </Grid>
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center' }}> <Grid size={{ xs: 12 }} sx={{ textAlign: "center" }}>
<Typography variant="body1" color="textSecondary"> <Typography variant="body1" color="textSecondary">
{loadingText} {loadingText}
</Typography> </Typography>

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Box, Box,
TextField, TextField,
@ -7,13 +7,13 @@ import {
Grid, Grid,
Chip, Chip,
FormControlLabel, FormControlLabel,
Checkbox Checkbox,
} from '@mui/material'; } from "@mui/material";
import { LocationOn, Public, Home } from '@mui/icons-material'; import { LocationOn, Public, Home } from "@mui/icons-material";
import { Country, State, City } from 'country-state-city'; import { Country, State, City } from "country-state-city";
import type { ICountry, IState, ICity } from 'country-state-city'; import type { ICountry, IState, ICity } from "country-state-city";
// Import from your types file - adjust path as needed // Import from your types file - adjust path as needed
import type { Location } from 'types/types'; import type { Location } from "types/types";
interface LocationInputProps { interface LocationInputProps {
value?: Partial<Location>; value?: Partial<Location>;
@ -32,37 +32,42 @@ const LocationInput: React.FC<LocationInputProps> = ({
helperText, helperText,
required = false, required = false,
disabled = false, disabled = false,
showCity = false showCity = false,
}) => { }) => {
// Get all countries from the library // Get all countries from the library
const allCountries = Country.getAllCountries(); const allCountries = Country.getAllCountries();
const [selectedCountry, setSelectedCountry] = useState<ICountry | null>( const [selectedCountry, setSelectedCountry] = useState<ICountry | null>(
value.country ? allCountries.find(c => c.name === value.country) || null : null value.country
? allCountries.find((c) => c.name === value.country) || null
: null
); );
const [selectedState, setSelectedState] = useState<IState | null>(null); const [selectedState, setSelectedState] = useState<IState | null>(null);
const [selectedCity, setSelectedCity] = useState<ICity | null>(null); const [selectedCity, setSelectedCity] = useState<ICity | null>(null);
const [isRemote, setIsRemote] = useState<boolean>(value.remote || false); const [isRemote, setIsRemote] = useState<boolean>(value.remote || false);
// Get states for selected country // Get states for selected country
const availableStates = selectedCountry ? State.getStatesOfCountry(selectedCountry.isoCode) : []; const availableStates = selectedCountry
? State.getStatesOfCountry(selectedCountry.isoCode)
: [];
// Get cities for selected state // Get cities for selected state
const availableCities = selectedCountry && selectedState const availableCities =
selectedCountry && selectedState
? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode) ? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode)
: []; : [];
// Initialize state and city from value prop // Initialize state and city from value prop
useEffect(() => { useEffect(() => {
if (selectedCountry && value.state) { if (selectedCountry && value.state) {
const stateMatch = availableStates.find(s => s.name === value.state); const stateMatch = availableStates.find((s) => s.name === value.state);
setSelectedState(stateMatch || null); setSelectedState(stateMatch || null);
} }
}, [selectedCountry, value.state, availableStates]); }, [selectedCountry, value.state, availableStates]);
useEffect(() => { useEffect(() => {
if (selectedCountry && selectedState && value.city && showCity) { if (selectedCountry && selectedState && value.city && showCity) {
const cityMatch = availableCities.find(c => c.name === value.city); const cityMatch = availableCities.find((c) => c.name === value.city);
setSelectedCity(cityMatch || null); setSelectedCity(cityMatch || null);
} }
}, [selectedCountry, selectedState, value.city, availableCities, showCity]); }, [selectedCountry, selectedState, value.city, availableCities, showCity]);
@ -88,10 +93,25 @@ const LocationInput: React.FC<LocationInputProps> = ({
} }
// Only call onChange if there's actual data or if clearing // Only call onChange if there's actual data or if clearing
if (Object.keys(newLocation).length > 0 || (value.country || value.state || value.city)) { if (
Object.keys(newLocation).length > 0 ||
value.country ||
value.state ||
value.city
) {
onChange(newLocation); onChange(newLocation);
} }
}, [selectedCountry, selectedState, selectedCity, isRemote, onChange, value.country, value.state, value.city, showCity]); }, [
selectedCountry,
selectedState,
selectedCity,
isRemote,
onChange,
value.country,
value.state,
value.city,
showCity,
]);
const handleCountryChange = (event: any, newValue: ICountry | null) => { const handleCountryChange = (event: any, newValue: ICountry | null) => {
setSelectedCountry(newValue); setSelectedCountry(newValue);
@ -116,9 +136,13 @@ const LocationInput: React.FC<LocationInputProps> = ({
return ( return (
<Box> <Box>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography
variant="h6"
gutterBottom
sx={{ display: "flex", alignItems: "center", gap: 1 }}
>
<LocationOn color="primary" /> <LocationOn color="primary" />
Location {required && <span style={{ color: 'red' }}>*</span>} Location {required && <span style={{ color: "red" }}>*</span>}
</Typography> </Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
@ -137,10 +161,16 @@ const LocationInput: React.FC<LocationInputProps> = ({
variant="outlined" variant="outlined"
required={required} required={required}
error={error && required && !selectedCountry} error={error && required && !selectedCountry}
helperText={error && required && !selectedCountry ? 'Country is required' : helperText} helperText={
error && required && !selectedCountry
? "Country is required"
: helperText
}
InputProps={{ InputProps={{
...params.InputProps, ...params.InputProps,
startAdornment: <Public sx={{ mr: 1, color: 'text.secondary' }} /> startAdornment: (
<Public sx={{ mr: 1, color: "text.secondary" }} />
),
}} }}
/> />
)} )}
@ -174,7 +204,11 @@ const LocationInput: React.FC<LocationInputProps> = ({
{...params} {...params}
label="State/Region" label="State/Region"
variant="outlined" variant="outlined"
placeholder={availableStates.length > 0 ? "Select state/region" : "No states available"} placeholder={
availableStates.length > 0
? "Select state/region"
: "No states available"
}
/> />
)} )}
/> />
@ -195,10 +229,16 @@ const LocationInput: React.FC<LocationInputProps> = ({
{...params} {...params}
label="City" label="City"
variant="outlined" variant="outlined"
placeholder={availableCities.length > 0 ? "Select city" : "No cities available"} placeholder={
availableCities.length > 0
? "Select city"
: "No cities available"
}
InputProps={{ InputProps={{
...params.InputProps, ...params.InputProps,
startAdornment: <Home sx={{ mr: 1, color: 'text.secondary' }} /> startAdornment: (
<Home sx={{ mr: 1, color: "text.secondary" }} />
),
}} }}
/> />
)} )}
@ -224,7 +264,7 @@ const LocationInput: React.FC<LocationInputProps> = ({
{/* Location Summary Chips */} {/* Location Summary Chips */}
{(selectedCountry || selectedState || selectedCity || isRemote) && ( {(selectedCountry || selectedState || selectedCity || isRemote) && (
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}> <Box sx={{ display: "flex", flexWrap: "wrap", gap: 1, mt: 1 }}>
{selectedCountry && ( {selectedCountry && (
<Chip <Chip
icon={<Public />} icon={<Public />}
@ -274,23 +314,29 @@ const LocationInputDemo: React.FC = () => {
const handleLocationChange = (newLocation: Partial<Location>) => { const handleLocationChange = (newLocation: Partial<Location>) => {
setLocation(newLocation); setLocation(newLocation);
console.log('Location updated:', newLocation); console.log("Location updated:", newLocation);
}; };
// Show some stats about the data // Show some stats about the data
const totalCountries = Country.getAllCountries().length; const totalCountries = Country.getAllCountries().length;
const usStates = State.getStatesOfCountry('US').length; const usStates = State.getStatesOfCountry("US").length;
const canadaProvinces = State.getStatesOfCountry('CA').length; const canadaProvinces = State.getStatesOfCountry("CA").length;
return ( return (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto' }}> <Box sx={{ p: 3, maxWidth: 800, mx: "auto" }}>
<Typography variant="h4" gutterBottom align="center" color="primary"> <Typography variant="h4" gutterBottom align="center" color="primary">
Location Input with Real Data Location Input with Real Data
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" align="center" sx={{ mb: 3 }}> <Typography
variant="body2"
color="text.secondary"
align="center"
sx={{ mb: 3 }}
>
Using country-state-city library with {totalCountries} countries, Using country-state-city library with {totalCountries} countries,
{usStates} US states, {canadaProvinces} Canadian provinces, and thousands of cities {usStates} US states, {canadaProvinces} Canadian provinces, and
thousands of cities
</Typography> </Typography>
<Grid container spacing={4}> <Grid container spacing={4}>
@ -336,21 +382,25 @@ const LocationInputDemo: React.FC = () => {
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Current Location Data: Current Location Data:
</Typography> </Typography>
<Box component="pre" sx={{ <Box
bgcolor: 'grey.100', component="pre"
sx={{
bgcolor: "grey.100",
p: 2, p: 2,
borderRadius: 1, borderRadius: 1,
overflow: 'auto', overflow: "auto",
fontSize: '0.875rem' fontSize: "0.875rem",
}}> }}
>
{JSON.stringify(location, null, 2)} {JSON.stringify(location, null, 2)}
</Box> </Box>
</Grid> </Grid>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
💡 This component uses the country-state-city library which is regularly updated 💡 This component uses the country-state-city library which is
and includes ISO codes, flags, and comprehensive location data. regularly updated and includes ISO codes, flags, and comprehensive
location data.
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -1,13 +1,13 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from "react";
import mermaid, { MermaidConfig } from 'mermaid'; import mermaid, { MermaidConfig } from "mermaid";
import { SxProps } from '@mui/material/styles'; import { SxProps } from "@mui/material/styles";
import { Box } from '@mui/material'; import { Box } from "@mui/material";
import { useResizeObserverAndMutationObserver } from '../hooks/useAutoScrollToBottom'; import { useResizeObserverAndMutationObserver } from "../hooks/useAutoScrollToBottom";
const defaultMermaidConfig: MermaidConfig = { const defaultMermaidConfig: MermaidConfig = {
startOnLoad: true, startOnLoad: true,
securityLevel: 'loose', securityLevel: "loose",
fontFamily: 'Fira Code', fontFamily: "Fira Code",
}; };
interface MermaidProps { interface MermaidProps {
@ -41,22 +41,26 @@ const Mermaid: React.FC<MermaidProps> = (props: MermaidProps) => {
console.error("Mermaid render error:", e, containerRef.current); console.error("Mermaid render error:", e, containerRef.current);
} }
} }
} };
renderMermaid(); renderMermaid();
}, [containerRef, mermaidConfig, visible, chart]); }, [containerRef, mermaidConfig, visible, chart]);
// Observe container and TextField size, plus DOM changes // Observe container and TextField size, plus DOM changes
useResizeObserverAndMutationObserver(containerRef, null, checkVisible); useResizeObserverAndMutationObserver(containerRef, null, checkVisible);
return <Box className={className || "Mermaid"} ref={containerRef} sx={{ return (
<Box
className={className || "Mermaid"}
ref={containerRef}
sx={{
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
...sx ...sx,
}}> }}
>
{chart} {chart}
</Box>; </Box>
);
}; };
export { export { Mermaid };
Mermaid
};

View File

@ -1,54 +1,72 @@
import { useState, useRef } from 'react'; import { useState, useRef } from "react";
import Divider from '@mui/material/Divider'; import Divider from "@mui/material/Divider";
import Accordion from '@mui/material/Accordion'; import Accordion from "@mui/material/Accordion";
import AccordionSummary from '@mui/material/AccordionSummary'; import AccordionSummary from "@mui/material/AccordionSummary";
import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionDetails from "@mui/material/AccordionDetails";
import Card from '@mui/material/Card'; import Card from "@mui/material/Card";
import Table from '@mui/material/Table'; import Table from "@mui/material/Table";
import TableBody from '@mui/material/TableBody'; import TableBody from "@mui/material/TableBody";
import TableCell from '@mui/material/TableCell'; import TableCell from "@mui/material/TableCell";
import TableContainer from '@mui/material/TableContainer'; import TableContainer from "@mui/material/TableContainer";
import TableHead from '@mui/material/TableHead'; import TableHead from "@mui/material/TableHead";
import TableRow from '@mui/material/TableRow'; import TableRow from "@mui/material/TableRow";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import CardContent from '@mui/material/CardContent'; import CardContent from "@mui/material/CardContent";
import CardActions from '@mui/material/CardActions'; import CardActions from "@mui/material/CardActions";
import Collapse from '@mui/material/Collapse'; import Collapse from "@mui/material/Collapse";
import { ExpandMore } from './ExpandMore'; import { ExpandMore } from "./ExpandMore";
import JsonView from '@uiw/react-json-view'; import JsonView from "@uiw/react-json-view";
import React from 'react'; import React from "react";
import { Box } from '@mui/material'; import { Box } from "@mui/material";
import { useTheme } from '@mui/material/styles'; import { useTheme } from "@mui/material/styles";
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from "@mui/material";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import LocationSearchingIcon from '@mui/icons-material/LocationSearching'; import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
import { ErrorOutline, InfoOutline, Memory, Psychology, /* Stream, */ } from '@mui/icons-material'; import {
ErrorOutline,
InfoOutline,
Memory,
Psychology /* Stream, */,
} from "@mui/icons-material";
import { StyledMarkdown } from './StyledMarkdown'; import { StyledMarkdown } from "./StyledMarkdown";
import { VectorVisualizer } from './VectorVisualizer'; import { VectorVisualizer } from "./VectorVisualizer";
import { SetSnackType } from './Snack'; import { SetSnackType } from "./Snack";
import { CopyBubble } from './CopyBubble'; import { CopyBubble } from "./CopyBubble";
import { Scrollable } from './Scrollable'; import { Scrollable } from "./Scrollable";
import { BackstoryElementProps } from './BackstoryTab'; import { BackstoryElementProps } from "./BackstoryTab";
import { ChatMessage, ChatSession, ChatMessageMetaData, ChromaDBGetResponse, ApiActivityType, ChatMessageUser, ChatMessageError, ChatMessageStatus, ChatSenderType } from 'types/types'; import {
ChatMessage,
ChatSession,
ChatMessageMetaData,
ChromaDBGetResponse,
ApiActivityType,
ChatMessageUser,
ChatMessageError,
ChatMessageStatus,
ChatSenderType,
} from "types/types";
const getStyle = (theme: Theme, type: ApiActivityType | ChatSenderType | "error"): any => { const getStyle = (
const defaultRadius = '16px'; theme: Theme,
type: ApiActivityType | ChatSenderType | "error"
): any => {
const defaultRadius = "16px";
const defaultStyle = { const defaultStyle = {
padding: theme.spacing(1, 2), padding: theme.spacing(1, 2),
fontSize: '0.875rem', fontSize: "0.875rem",
alignSelf: 'flex-start', alignSelf: "flex-start",
maxWidth: '100%', maxWidth: "100%",
minWidth: '100%', minWidth: "100%",
height: 'fit-content', height: "fit-content",
'& > *': { "& > *": {
color: 'inherit', color: "inherit",
overflow: 'hidden', overflow: "hidden",
m: 0, m: 0,
}, },
'& > :last-child': { "& > :last-child": {
mb: 0, mb: 0,
m: 0, m: 0,
p: 0, p: 0,
@ -65,45 +83,45 @@ const getStyle = (theme: Theme, type: ApiActivityType | ChatSenderType | "error"
}, },
content: { content: {
...defaultStyle, ...defaultStyle,
backgroundColor: '#F5F2EA', backgroundColor: "#F5F2EA",
border: `1px solid ${theme.palette.custom.highlight}`, border: `1px solid ${theme.palette.custom.highlight}`,
borderRadius: 0, borderRadius: 0,
alignSelf: 'center', alignSelf: "center",
color: theme.palette.text.primary, color: theme.palette.text.primary,
padding: '8px 8px', padding: "8px 8px",
marginBottom: '0px', marginBottom: "0px",
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)', boxShadow: "0 2px 4px rgba(0, 0, 0, 0.05)",
fontSize: '0.9rem', fontSize: "0.9rem",
lineHeight: '1.3', lineHeight: "1.3",
fontFamily: theme.typography.fontFamily, fontFamily: theme.typography.fontFamily,
}, },
error: { error: {
...defaultStyle, ...defaultStyle,
backgroundColor: '#F8E7E7', backgroundColor: "#F8E7E7",
border: `1px solid #D83A3A`, border: `1px solid #D83A3A`,
borderRadius: defaultRadius, borderRadius: defaultRadius,
maxWidth: '90%', maxWidth: "90%",
minWidth: '90%', minWidth: "90%",
alignSelf: 'center', alignSelf: "center",
color: '#8B2525', color: "#8B2525",
padding: '10px 16px', padding: "10px 16px",
boxShadow: '0 1px 3px rgba(216, 58, 58, 0.15)', boxShadow: "0 1px 3px rgba(216, 58, 58, 0.15)",
}, },
'fact-check': 'qualifications', "fact-check": "qualifications",
generating: 'status', generating: "status",
'job-description': 'content', "job-description": "content",
'job-requirements': 'qualifications', "job-requirements": "qualifications",
information: { information: {
...defaultStyle, ...defaultStyle,
backgroundColor: '#BFD8D8', backgroundColor: "#BFD8D8",
border: `1px solid ${theme.palette.secondary.main}`, border: `1px solid ${theme.palette.secondary.main}`,
borderRadius: defaultRadius, borderRadius: defaultRadius,
color: theme.palette.text.primary, color: theme.palette.text.primary,
opacity: 0.95, opacity: 0.95,
}, },
info: 'information', info: "information",
preparing: 'status', preparing: "status",
processing: 'status', processing: "status",
qualifications: { qualifications: {
...defaultStyle, ...defaultStyle,
backgroundColor: theme.palette.primary.light, backgroundColor: theme.palette.primary.light,
@ -111,49 +129,49 @@ const getStyle = (theme: Theme, type: ApiActivityType | ChatSenderType | "error"
borderRadius: `${defaultRadius} ${defaultRadius} ${defaultRadius} 0`, borderRadius: `${defaultRadius} ${defaultRadius} ${defaultRadius} 0`,
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
}, },
resume: 'content', resume: "content",
searching: 'status', searching: "status",
status: { status: {
...defaultStyle, ...defaultStyle,
backgroundColor: 'rgba(74, 122, 125, 0.15)', backgroundColor: "rgba(74, 122, 125, 0.15)",
border: `1px solid ${theme.palette.secondary.light}`, border: `1px solid ${theme.palette.secondary.light}`,
borderRadius: '4px', borderRadius: "4px",
maxWidth: '75%', maxWidth: "75%",
minWidth: '75%', minWidth: "75%",
alignSelf: 'center', alignSelf: "center",
color: theme.palette.secondary.dark, color: theme.palette.secondary.dark,
fontWeight: 500, fontWeight: 500,
fontSize: '0.95rem', fontSize: "0.95rem",
padding: '8px 12px', padding: "8px 12px",
opacity: 0.9, opacity: 0.9,
transition: 'opacity 0.3s ease-in-out', transition: "opacity 0.3s ease-in-out",
}, },
streaming: 'response', streaming: "response",
system: { system: {
...defaultStyle, ...defaultStyle,
backgroundColor: '#EDEAE0', backgroundColor: "#EDEAE0",
border: `1px dashed ${theme.palette.custom.highlight}`, border: `1px dashed ${theme.palette.custom.highlight}`,
borderRadius: defaultRadius, borderRadius: defaultRadius,
maxWidth: '90%', maxWidth: "90%",
minWidth: '90%', minWidth: "90%",
alignSelf: 'center', alignSelf: "center",
color: theme.palette.text.primary, color: theme.palette.text.primary,
fontStyle: 'italic', fontStyle: "italic",
}, },
thinking: 'status', thinking: "status",
user: { user: {
...defaultStyle, ...defaultStyle,
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
border: `1px solid ${theme.palette.custom.highlight}`, border: `1px solid ${theme.palette.custom.highlight}`,
borderRadius: `${defaultRadius} ${defaultRadius} 0 ${defaultRadius}`, borderRadius: `${defaultRadius} ${defaultRadius} 0 ${defaultRadius}`,
alignSelf: 'flex-end', alignSelf: "flex-end",
color: theme.palette.primary.main, color: theme.palette.primary.main,
}, },
}; };
// Resolve string references in styles // Resolve string references in styles
for (const [key, value] of Object.entries(styles)) { for (const [key, value] of Object.entries(styles)) {
if (typeof value === 'string') { if (typeof value === "string") {
styles[key] = styles[value]; styles[key] = styles[value];
} }
} }
@ -163,9 +181,11 @@ const getStyle = (theme: Theme, type: ApiActivityType | ChatSenderType | "error"
} }
return styles[type]; return styles[type];
} };
const getIcon = (activityType: ApiActivityType | ChatSenderType | "error"): React.ReactNode | null => { const getIcon = (
activityType: ApiActivityType | ChatSenderType | "error"
): React.ReactNode | null => {
const icons: any = { const icons: any = {
error: <ErrorOutline color="error" />, error: <ErrorOutline color="error" />,
generating: <LocationSearchingIcon />, generating: <LocationSearchingIcon />,
@ -177,23 +197,23 @@ const getIcon = (activityType: ApiActivityType | ChatSenderType | "error"): Reac
tooling: <LocationSearchingIcon />, tooling: <LocationSearchingIcon />,
}; };
return icons[activityType] || null; return icons[activityType] || null;
} };
interface MessageProps extends BackstoryElementProps { interface MessageProps extends BackstoryElementProps {
message: ChatMessageUser | ChatMessage | ChatMessageError | ChatMessageStatus, message: ChatMessageUser | ChatMessage | ChatMessageError | ChatMessageStatus;
title?: string, title?: string;
chatSession?: ChatSession, chatSession?: ChatSession;
className?: string, className?: string;
sx?: SxProps<Theme>, sx?: SxProps<Theme>;
expandable?: boolean, expandable?: boolean;
expanded?: boolean, expanded?: boolean;
onExpand?: (open: boolean) => void, onExpand?: (open: boolean) => void;
}; }
interface MessageMetaProps { interface MessageMetaProps {
metadata: ChatMessageMetaData, metadata: ChatMessageMetaData;
messageProps: MessageProps messageProps: MessageProps;
}; }
const MessageMeta = (props: MessageMetaProps) => { const MessageMeta = (props: MessageMetaProps) => {
const { const {
@ -207,14 +227,19 @@ const MessageMeta = (props: MessageMetaProps) => {
} = props.metadata || {}; } = props.metadata || {};
const message: any = props.messageProps.message; const message: any = props.messageProps.message;
let llm_submission: string = "<|system|>\n" let llm_submission = "<|system|>\n";
llm_submission += message.system_prompt + "\n\n" llm_submission += message.system_prompt + "\n\n";
llm_submission += message.context_prompt llm_submission += message.context_prompt;
return (<> return (
{ <>
promptEvalDuration !== 0 && evalDuration !== 0 && <> {promptEvalDuration !== 0 && evalDuration !== 0 && (
<TableContainer component={Card} className="PromptStats" sx={{ mb: 1 }}> <>
<TableContainer
component={Card}
className="PromptStats"
sx={{ mb: 1 }}
>
<Table aria-label="prompt stats" size="small"> <Table aria-label="prompt stats" size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
@ -225,118 +250,211 @@ const MessageMeta = (props: MessageMetaProps) => {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> <TableRow
<TableCell component="th" scope="row">Prompt</TableCell> key="prompt"
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell component="th" scope="row">
Prompt
</TableCell>
<TableCell align="right">{promptEvalCount}</TableCell> <TableCell align="right">{promptEvalCount}</TableCell>
<TableCell align="right">{Math.round(promptEvalDuration / 10 ** 7) / 100}</TableCell> <TableCell align="right">
<TableCell align="right">{Math.round(promptEvalCount * 10 ** 9 / promptEvalDuration)}</TableCell> {Math.round(promptEvalDuration / 10 ** 7) / 100}
</TableCell>
<TableCell align="right">
{Math.round(
(promptEvalCount * 10 ** 9) / promptEvalDuration
)}
</TableCell>
</TableRow> </TableRow>
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> <TableRow
<TableCell component="th" scope="row">Response</TableCell> key="response"
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell component="th" scope="row">
Response
</TableCell>
<TableCell align="right">{evalCount}</TableCell> <TableCell align="right">{evalCount}</TableCell>
<TableCell align="right">{Math.round(evalDuration / 10 ** 7) / 100}</TableCell> <TableCell align="right">
<TableCell align="right">{Math.round(evalCount * 10 ** 9 / evalDuration)}</TableCell> {Math.round(evalDuration / 10 ** 7) / 100}
</TableCell>
<TableCell align="right">
{Math.round((evalCount * 10 ** 9) / evalDuration)}
</TableCell>
</TableRow> </TableRow>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> <TableRow
<TableCell component="th" scope="row">Total</TableCell> key="total"
<TableCell align="right">{promptEvalCount + evalCount}</TableCell> sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
<TableCell align="right">{Math.round((promptEvalDuration + evalDuration) / 10 ** 7) / 100}</TableCell> >
<TableCell align="right">{Math.round((promptEvalCount + evalCount) * 10 ** 9 / (promptEvalDuration + evalDuration))}</TableCell> <TableCell component="th" scope="row">
Total
</TableCell>
<TableCell align="right">
{promptEvalCount + evalCount}
</TableCell>
<TableCell align="right">
{Math.round((promptEvalDuration + evalDuration) / 10 ** 7) /
100}
</TableCell>
<TableCell align="right">
{Math.round(
((promptEvalCount + evalCount) * 10 ** 9) /
(promptEvalDuration + evalDuration)
)}
</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
</> </>
} )}
{ {tools && tools.tool_calls && tools.tool_calls.length !== 0 && (
tools && tools.tool_calls && tools.tool_calls.length !== 0 &&
<Accordion sx={{ boxSizing: "border-box" }}> <Accordion sx={{ boxSizing: "border-box" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}> <Box sx={{ fontSize: "0.8rem" }}>Tools queried</Box>
Tools queried
</Box>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
{ {tools.tool_calls.map((tool: any, index: number) => (
tools.tool_calls.map((tool: any, index: number) => <Box
<Box key={index} sx={{ m: 0, p: 1, pt: 0, display: "flex", flexDirection: "column", border: "1px solid #e0e0e0" }}> key={index}
sx={{
m: 0,
p: 1,
pt: 0,
display: "flex",
flexDirection: "column",
border: "1px solid #e0e0e0",
}}
>
{index !== 0 && <Divider />} {index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "column", mt: 1, mb: 1, fontWeight: "bold" }}> <Box
sx={{
fontSize: "0.75rem",
display: "flex",
flexDirection: "column",
mt: 1,
mb: 1,
fontWeight: "bold",
}}
>
{tool.name} {tool.name}
</Box> </Box>
{tool.content !== "null" && {tool.content !== "null" && (
<JsonView <JsonView
displayDataTypes={false} displayDataTypes={false}
objectSortKeys={true} objectSortKeys={true}
collapsed={1} value={JSON.parse(tool.content)} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}> collapsed={1}
value={JSON.parse(tool.content)}
style={{
fontSize: "0.8rem",
maxHeight: "20rem",
overflow: "auto",
}}
>
<JsonView.String <JsonView.String
render={({ children, ...reset }) => { render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) { if (
return <pre {...reset} style={{ display: "flex", border: "none", ...reset.style }}>{children}</pre> typeof children === "string" &&
children.match("\n")
) {
return (
<pre
{...reset}
style={{
display: "flex",
border: "none",
...reset.style,
}}
>
{children}
</pre>
);
} }
}} }}
/> />
</JsonView> </JsonView>
} )}
{tool.content === "null" && "No response from tool call"} {tool.content === "null" && "No response from tool call"}
</Box>) </Box>
} ))}
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
} )}
{ {ragResults.map((collection: ChromaDBGetResponse) => (
ragResults.map((collection: ChromaDBGetResponse) => (
<Accordion key={collection.name}> <Accordion key={collection.name}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}> <Box sx={{ fontSize: "0.8rem" }}>
Top {collection.ids?.length} RAG matches from {collection.size} entries using an embedding vector of {collection.queryEmbedding?.length} dimensions Top {collection.ids?.length} RAG matches from {collection.size}{" "}
entries using an embedding vector of{" "}
{collection.queryEmbedding?.length} dimensions
</Box> </Box>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<VectorVisualizer inline <VectorVisualizer
{...props.messageProps} {...props.metadata} inline
rag={collection} /> {...props.messageProps}
{...props.metadata}
rag={collection}
/>
{/* { ...rag, query: message.prompt }} /> */} {/* { ...rag, query: message.prompt }} /> */}
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
)) ))}
}
<Accordion> <Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}> <Box sx={{ fontSize: "0.8rem" }}>Full Response Details</Box>
Full Response Details
</Box>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<Box sx={{ pb: 1 }}>Copy LLM submission: <CopyBubble content={llm_submission} /></Box> <Box sx={{ pb: 1 }}>
<JsonView displayDataTypes={false} objectSortKeys={true} collapsed={1} value={message} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}> Copy LLM submission: <CopyBubble content={llm_submission} />
</Box>
<JsonView
displayDataTypes={false}
objectSortKeys={true}
collapsed={1}
value={message}
style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}
>
<JsonView.String <JsonView.String
render={({ children, ...reset }) => { render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) { if (typeof children === "string" && children.match("\n")) {
return <pre {...reset} style={{ display: "inline", border: "none", ...reset.style }}>{children.trim()}</pre> return (
<pre
{...reset}
style={{
display: "inline",
border: "none",
...reset.style,
}}
>
{children.trim()}
</pre>
);
} }
}} }}
/> />
</JsonView> </JsonView>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
</>); </>
);
}; };
interface MessageContainerProps { interface MessageContainerProps {
type: ApiActivityType | ChatSenderType | "error", type: ApiActivityType | ChatSenderType | "error";
metadataView?: React.ReactNode | null, metadataView?: React.ReactNode | null;
messageView?: React.ReactNode | null, messageView?: React.ReactNode | null;
sx?: SxProps<Theme>, sx?: SxProps<Theme>;
copyContent?: string, copyContent?: string;
}; }
const MessageContainer = (props: MessageContainerProps) => { const MessageContainer = (props: MessageContainerProps) => {
const { type, sx, messageView, metadataView, copyContent } = props; const { type, sx, messageView, metadataView, copyContent } = props;
const icon = getIcon(type); const icon = getIcon(type);
return <Box return (
<Box
className={`Message Message-${type}`} className={`Message Message-${type}`}
sx={{ sx={{
display: "flex", display: "flex",
@ -346,23 +464,59 @@ const MessageContainer = (props: MessageContainerProps) => {
marginBottom: "0px !important", // Remove whitespace from expanded Accordion marginBottom: "0px !important", // Remove whitespace from expanded Accordion
gap: 1, gap: 1,
...sx, ...sx,
}}> }}
<Box sx={{ display: "flex", flexDirection: 'row', alignItems: 'center', gap: 1 }}> >
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 1,
}}
>
{icon !== null && icon} {icon !== null && icon}
{messageView} {messageView}
</Box> </Box>
<Box flex={{ display: "flex", position: "relative", flexDirection: "row", justifyContent: "flex-end", alignItems: "center" }}> <Box
{copyContent && <CopyBubble content={copyContent} sx={{ position: "absolute", top: "11px", left: 0 }} />} flex={{
display: "flex",
position: "relative",
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
}}
>
{copyContent && (
<CopyBubble
content={copyContent}
sx={{ position: "absolute", top: "11px", left: 0 }}
/>
)}
{metadataView} {metadataView}
</Box> </Box>
</Box>; </Box>
);
}; };
const Message = (props: MessageProps) => { const Message = (props: MessageProps) => {
const { message, title, sx, className, chatSession, onExpand, expanded, expandable } = props; const {
message,
title,
sx,
className,
chatSession,
onExpand,
expanded,
expandable,
} = props;
const [metaExpanded, setMetaExpanded] = useState<boolean>(false); const [metaExpanded, setMetaExpanded] = useState<boolean>(false);
const theme = useTheme(); const theme = useTheme();
const type: ApiActivityType | ChatSenderType | "error" = ('activity' in message) ? message.activity : ('error' in message) ? 'error' : (message as ChatMessage).role; const type: ApiActivityType | ChatSenderType | "error" =
"activity" in message
? message.activity
: "error" in message
? "error"
: (message as ChatMessage).role;
const style: any = getStyle(theme, type); const style: any = getStyle(theme, type);
const handleMetaExpandClick = () => { const handleMetaExpandClick = () => {
@ -370,32 +524,52 @@ const Message = (props: MessageProps) => {
}; };
let content; let content;
if (typeof (message.content) === "string") { if (typeof message.content === "string") {
content = message.content.trim(); content = message.content.trim();
} else { } else {
console.error(`message content is not a string, it is a ${typeof message.content}`); console.error(
return (<></>) `message content is not a string, it is a ${typeof message.content}`
);
return <></>;
} }
if (!content) { if (!content) {
return (<></>) return <></>;
}; }
const messageView = ( const messageView = (
<StyledMarkdown chatSession={chatSession} streaming={message.status === "streaming"} content={content} /> <StyledMarkdown
chatSession={chatSession}
streaming={message.status === "streaming"}
content={content}
/>
); );
let metadataView = (<></>); let metadataView = <></>;
let metadata: ChatMessageMetaData | null = ('metadata' in message) ? (message.metadata as ChatMessageMetaData || null) : null; let metadata: ChatMessageMetaData | null =
if ('role' in message && message.role === 'user') { "metadata" in message
? (message.metadata as ChatMessageMetaData) || null
: null;
if ("role" in message && message.role === "user") {
metadata = null; metadata = null;
} }
if (metadata) { if (metadata) {
metadataView = ( metadataView = (
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}> <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexDirection: "row" }}> <Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexDirection: "row",
}}
>
<Box sx={{ display: "flex", flexGrow: 1 }} /> <Box sx={{ display: "flex", flexGrow: 1 }} />
<Button variant="text" onClick={handleMetaExpandClick} sx={{ flexShrink: 1, color: "darkgrey", p: 0 }}> <Button
variant="text"
onClick={handleMetaExpandClick}
sx={{ flexShrink: 1, color: "darkgrey", p: 0 }}
>
LLM information for this query LLM information for this query
</Button> </Button>
<ExpandMore <ExpandMore
@ -403,7 +577,8 @@ const Message = (props: MessageProps) => {
expand={metaExpanded} expand={metaExpanded}
onClick={handleMetaExpandClick} onClick={handleMetaExpandClick}
aria-expanded={true /*message.expanded*/} aria-expanded={true /*message.expanded*/}
aria-label="show more"> aria-label="show more"
>
<ExpandMoreIcon /> <ExpandMoreIcon />
</ExpandMore> </ExpandMore>
</Box> </Box>
@ -412,55 +587,69 @@ const Message = (props: MessageProps) => {
<MessageMeta messageProps={props} metadata={metadata} /> <MessageMeta messageProps={props} metadata={metadata} />
</CardContent> </CardContent>
</Collapse> </Collapse>
</Box>); </Box>
);
} }
const copyContent = (type === 'assistant') ? message.content : undefined; const copyContent = type === "assistant" ? message.content : undefined;
if (!expandable) { if (!expandable) {
/* When not expandable, the styles are applied directly to MessageContainer */ /* When not expandable, the styles are applied directly to MessageContainer */
return (<> return (
{messageView && <MessageContainer copyContent={copyContent} type={type} {...{ messageView, metadataView }} sx={{ ...style, ...sx }} />} <>
</>); {messageView && (
<MessageContainer
copyContent={copyContent}
type={type}
{...{ messageView, metadataView }}
sx={{ ...style, ...sx }}
/>
)}
</>
);
} }
// Determine if Accordion is controlled // Determine if Accordion is controlled
const isControlled = typeof expanded === 'boolean' && typeof onExpand === 'function'; const isControlled =
typeof expanded === "boolean" && typeof onExpand === "function";
return ( return (
<Accordion <Accordion
expanded={isControlled ? expanded : undefined} // Omit expanded prop for uncontrolled expanded={isControlled ? expanded : undefined} // Omit expanded prop for uncontrolled
defaultExpanded={expanded} // Default to collapsed for uncontrolled Accordion defaultExpanded={expanded} // Default to collapsed for uncontrolled Accordion
className={className} className={className}
onChange={(_event, newExpanded) => { isControlled && onExpand && onExpand(newExpanded) }} onChange={(_event, newExpanded) => {
sx={{ ...sx, ...style }}> isControlled && onExpand && onExpand(newExpanded);
}}
sx={{ ...sx, ...style }}
>
<AccordionSummary <AccordionSummary
expandIcon={<ExpandMoreIcon />} expandIcon={<ExpandMoreIcon />}
slotProps={{ slotProps={{
content: { content: {
sx: { sx: {
display: 'flex', display: "flex",
justifyItems: 'center', justifyItems: "center",
m: 0, p: 0, m: 0,
fontWeight: 'bold', p: 0,
fontSize: '1.1rem', fontWeight: "bold",
fontSize: "1.1rem",
}, },
}, },
}}> }}
{title || ''} >
{title || ""}
</AccordionSummary> </AccordionSummary>
<AccordionDetails sx={{ mt: 0, mb: 0, p: 0, pl: 2, pr: 2 }}> <AccordionDetails sx={{ mt: 0, mb: 0, p: 0, pl: 2, pr: 2 }}>
<MessageContainer copyContent={copyContent} type={type} {...{ messageView, metadataView }} /> <MessageContainer
copyContent={copyContent}
type={type}
{...{ messageView, metadataView }}
/>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
); );
}
export type {
MessageProps,
}; };
export { export type { MessageProps };
Message,
MessageMeta,
};
export { Message, MessageMeta };

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from "react";
import { SxProps } from '@mui/material'; import { SxProps } from "@mui/material";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
interface PulseProps { interface PulseProps {
timestamp: number | string; timestamp: number | string;
@ -15,7 +15,7 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
useEffect(() => { useEffect(() => {
if (timestamp && timestamp !== previousTimestamp.current) { if (timestamp && timestamp !== previousTimestamp.current) {
previousTimestamp.current = timestamp; previousTimestamp.current = timestamp;
setAnimationKey(prev => prev + 1); setAnimationKey((prev) => prev + 1);
setIsAnimating(true); setIsAnimating(true);
// Reset animation state after animation completes // Reset animation state after animation completes
@ -28,68 +28,68 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
}, [timestamp]); }, [timestamp]);
const containerStyle: React.CSSProperties = { const containerStyle: React.CSSProperties = {
position: 'relative', position: "relative",
width: 80, width: 80,
height: 80, height: 80,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}; };
const baseCoreStyle: React.CSSProperties = { const baseCoreStyle: React.CSSProperties = {
width: 0, width: 0,
height: 0, height: 0,
borderRadius: '50%', borderRadius: "50%",
backgroundColor: '#2196f3', backgroundColor: "#2196f3",
position: 'relative', position: "relative",
zIndex: 3, zIndex: 3,
}; };
const coreStyle: React.CSSProperties = { const coreStyle: React.CSSProperties = {
...baseCoreStyle, ...baseCoreStyle,
animation: isAnimating ? 'pulse-glow 1s ease-out' : 'none', animation: isAnimating ? "pulse-glow 1s ease-out" : "none",
}; };
const pulseRing1Style: React.CSSProperties = { const pulseRing1Style: React.CSSProperties = {
position: 'absolute', position: "absolute",
width: 24, width: 24,
height: 24, height: 24,
borderRadius: '50%', borderRadius: "50%",
backgroundColor: '#2196f3', backgroundColor: "#2196f3",
zIndex: 2, zIndex: 2,
animation: 'pulse-expand 1s ease-out forwards', animation: "pulse-expand 1s ease-out forwards",
}; };
const pulseRing2Style: React.CSSProperties = { const pulseRing2Style: React.CSSProperties = {
position: 'absolute', position: "absolute",
width: 24, width: 24,
height: 24, height: 24,
borderRadius: '50%', borderRadius: "50%",
backgroundColor: '#64b5f6', backgroundColor: "#64b5f6",
zIndex: 1, zIndex: 1,
animation: 'pulse-expand 1s ease-out 0.2s forwards', animation: "pulse-expand 1s ease-out 0.2s forwards",
}; };
const rippleStyle: React.CSSProperties = { const rippleStyle: React.CSSProperties = {
position: 'absolute', position: "absolute",
width: 32, width: 32,
height: 32, height: 32,
borderRadius: '50%', borderRadius: "50%",
border: '2px solid #2196f3', border: "2px solid #2196f3",
backgroundColor: 'transparent', backgroundColor: "transparent",
zIndex: 0, zIndex: 0,
animation: 'ripple-expand 1s ease-out forwards', animation: "ripple-expand 1s ease-out forwards",
}; };
const outerRippleStyle: React.CSSProperties = { const outerRippleStyle: React.CSSProperties = {
position: 'absolute', position: "absolute",
width: 40, width: 40,
height: 40, height: 40,
borderRadius: '50%', borderRadius: "50%",
border: '1px solid #90caf9', border: "1px solid #90caf9",
backgroundColor: 'transparent', backgroundColor: "transparent",
zIndex: 0, zIndex: 0,
animation: 'ripple-expand 1s ease-out 0.3s forwards', animation: "ripple-expand 1s ease-out 0.3s forwards",
}; };
return ( return (
@ -144,22 +144,13 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
{isAnimating && ( {isAnimating && (
<> <>
{/* Primary pulse ring */} {/* Primary pulse ring */}
<div <div key={`pulse-1-${animationKey}`} style={pulseRing1Style} />
key={`pulse-1-${animationKey}`}
style={pulseRing1Style}
/>
{/* Secondary pulse ring with delay */} {/* Secondary pulse ring with delay */}
<div <div key={`pulse-2-${animationKey}`} style={pulseRing2Style} />
key={`pulse-2-${animationKey}`}
style={pulseRing2Style}
/>
{/* Ripple effect */} {/* Ripple effect */}
<div <div key={`ripple-${animationKey}`} style={rippleStyle} />
key={`ripple-${animationKey}`}
style={rippleStyle}
/>
{/* Outer ripple */} {/* Outer ripple */}
<div <div
@ -168,8 +159,6 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
/> />
</> </>
)} )}
</Box> </Box>
</> </>
); );

View File

@ -1,111 +1,115 @@
import React from 'react'; import React from "react";
import { Box, Typography, Paper, SxProps } from '@mui/material'; import { Box, Typography, Paper, SxProps } from "@mui/material";
import { styled } from '@mui/material/styles'; import { styled } from "@mui/material/styles";
interface QuoteContainerProps { interface QuoteContainerProps {
size?: 'normal' | 'small'; size?: "normal" | "small";
} }
const QuoteContainer = styled(Paper, { const QuoteContainer = styled(Paper, {
shouldForwardProp: (prop) => prop !== 'size', shouldForwardProp: (prop) => prop !== "size",
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({ })<QuoteContainerProps>(({ theme, size = "normal" }) => ({
position: 'relative', position: "relative",
padding: size === 'small' ? theme.spacing(1) : theme.spacing(4), padding: size === "small" ? theme.spacing(1) : theme.spacing(4),
margin: size === 'small' ? theme.spacing(0.5) : theme.spacing(2), margin: size === "small" ? theme.spacing(0.5) : theme.spacing(2),
background: 'linear-gradient(135deg, #FFFFFF 0%, #D3CDBF 100%)', background: "linear-gradient(135deg, #FFFFFF 0%, #D3CDBF 100%)",
borderRadius: size === 'small' ? theme.spacing(1) : theme.spacing(2), borderRadius: size === "small" ? theme.spacing(1) : theme.spacing(2),
boxShadow: '0 8px 32px rgba(26, 37, 54, 0.15)', boxShadow: "0 8px 32px rgba(26, 37, 54, 0.15)",
overflow: 'hidden', overflow: "hidden",
border: '1px solid rgba(74, 122, 125, 0.2)', border: "1px solid rgba(74, 122, 125, 0.2)",
'&::before': { "&::before": {
content: '""', content: '""',
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
height: size === 'small' ? '2px' : '4px', height: size === "small" ? "2px" : "4px",
background: 'linear-gradient(90deg, #4A7A7D 0%, #D4A017 100%)', background: "linear-gradient(90deg, #4A7A7D 0%, #D4A017 100%)",
}, },
})); }));
const QuoteText = styled(Typography, { const QuoteText = styled(Typography, {
shouldForwardProp: (prop) => prop !== 'size', shouldForwardProp: (prop) => prop !== "size",
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({ })<QuoteContainerProps>(({ theme, size = "normal" }) => ({
fontSize: size === 'small' ? '0.9rem' : '1.2rem', fontSize: size === "small" ? "0.9rem" : "1.2rem",
lineHeight: size === 'small' ? 1.4 : 1.6, lineHeight: size === "small" ? 1.4 : 1.6,
fontStyle: 'italic', fontStyle: "italic",
color: '#2E2E2E', // Charcoal Black color: "#2E2E2E", // Charcoal Black
position: 'relative', position: "relative",
zIndex: 2, zIndex: 2,
textAlign: 'center', textAlign: "center",
fontFamily: '"Georgia", "Times New Roman", serif', fontFamily: '"Georgia", "Times New Roman", serif',
fontWeight: 400, fontWeight: 400,
})); }));
const QuoteMark = styled(Typography, { const QuoteMark = styled(Typography, {
shouldForwardProp: (prop) => prop !== 'size', shouldForwardProp: (prop) => prop !== "size",
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({ })<QuoteContainerProps>(({ theme, size = "normal" }) => ({
fontSize: size === 'small' ? '2.5rem' : '4rem', fontSize: size === "small" ? "2.5rem" : "4rem",
fontFamily: '"Georgia", "Times New Roman", serif', fontFamily: '"Georgia", "Times New Roman", serif',
fontWeight: 'bold', fontWeight: "bold",
opacity: 0.15, opacity: 0.15,
position: 'absolute', position: "absolute",
zIndex: 1, zIndex: 1,
color: '#4A7A7D', // Dusty Teal color: "#4A7A7D", // Dusty Teal
userSelect: 'none', userSelect: "none",
})); }));
const OpeningQuote = styled(QuoteMark)(({ size = 'normal' }: QuoteContainerProps) => ({ const OpeningQuote = styled(QuoteMark)(
top: size === 'small' ? '5px' : '10px', ({ size = "normal" }: QuoteContainerProps) => ({
left: size === 'small' ? '8px' : '15px', top: size === "small" ? "5px" : "10px",
})); left: size === "small" ? "8px" : "15px",
})
);
const ClosingQuote = styled(QuoteMark)(({ size = 'normal' }: QuoteContainerProps) => ({ const ClosingQuote = styled(QuoteMark)(
bottom: size === 'small' ? '5px' : '10px', ({ size = "normal" }: QuoteContainerProps) => ({
right: size === 'small' ? '8px' : '15px', bottom: size === "small" ? "5px" : "10px",
transform: 'rotate(180deg)', right: size === "small" ? "8px" : "15px",
})); transform: "rotate(180deg)",
})
);
const AuthorText = styled(Typography, { const AuthorText = styled(Typography, {
shouldForwardProp: (prop) => prop !== 'size', shouldForwardProp: (prop) => prop !== "size",
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({ })<QuoteContainerProps>(({ theme, size = "normal" }) => ({
marginTop: size === 'small' ? theme.spacing(1) : theme.spacing(2), marginTop: size === "small" ? theme.spacing(1) : theme.spacing(2),
textAlign: 'right', textAlign: "right",
fontStyle: 'normal', fontStyle: "normal",
fontWeight: 500, fontWeight: 500,
color: '#1A2536', // Midnight Blue color: "#1A2536", // Midnight Blue
fontSize: size === 'small' ? '0.8rem' : '0.95rem', fontSize: size === "small" ? "0.8rem" : "0.95rem",
'&::before': { "&::before": {
content: '"— "', content: '"— "',
color: '#D4A017', // Golden Ochre dash color: "#D4A017", // Golden Ochre dash
}, },
})); }));
const AccentLine = styled(Box, { const AccentLine = styled(Box, {
shouldForwardProp: (prop) => prop !== 'size', shouldForwardProp: (prop) => prop !== "size",
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({ })<QuoteContainerProps>(({ theme, size = "normal" }) => ({
width: size === 'small' ? '40px' : '60px', width: size === "small" ? "40px" : "60px",
height: size === 'small' ? '1px' : '2px', height: size === "small" ? "1px" : "2px",
background: 'linear-gradient(90deg, #D4A017 0%, #4A7A7D 100%)', // Golden Ochre to Dusty Teal background: "linear-gradient(90deg, #D4A017 0%, #4A7A7D 100%)", // Golden Ochre to Dusty Teal
margin: size === 'small' ? '0.5rem auto' : '1rem auto', margin: size === "small" ? "0.5rem auto" : "1rem auto",
borderRadius: '1px', borderRadius: "1px",
})); }));
interface QuoteProps { interface QuoteProps {
quote?: string; quote?: string;
author?: string; author?: string;
size?: 'small' | 'normal'; size?: "small" | "normal";
sx?: SxProps; sx?: SxProps;
} }
const Quote = (props: QuoteProps) => { const Quote = (props: QuoteProps) => {
const { quote, author, size = 'normal', sx } = props; const { quote, author, size = "normal", sx } = props;
return ( return (
<QuoteContainer size={size} elevation={0} sx={sx}> <QuoteContainer size={size} elevation={0} sx={sx}>
<OpeningQuote size={size}>"</OpeningQuote> <OpeningQuote size={size}>"</OpeningQuote>
<ClosingQuote size={size}>"</ClosingQuote> <ClosingQuote size={size}>"</ClosingQuote>
<Box sx={{ position: 'relative', zIndex: 2 }}> <Box sx={{ position: "relative", zIndex: 2 }}>
<QuoteText size={size} variant="body1"> <QuoteText size={size} variant="body1">
{quote} {quote}
</QuoteText> </QuoteText>

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect } from "react";
import { import {
Tabs, Tabs,
Tab, Tab,
@ -7,21 +7,21 @@ import {
Paper, Paper,
Typography, Typography,
LinearProgress, LinearProgress,
} from '@mui/material'; } from "@mui/material";
import { Job, Candidate, SkillAssessment } from "types/types"; import { Job, Candidate, SkillAssessment } from "types/types";
import { Scrollable } from './Scrollable'; import { Scrollable } from "./Scrollable";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import * as Types from 'types/types'; import * as Types from "types/types";
import { StyledMarkdown } from './StyledMarkdown'; import { StyledMarkdown } from "./StyledMarkdown";
import { Message } from './Message'; import { Message } from "./Message";
import InputIcon from '@mui/icons-material/Input'; import InputIcon from "@mui/icons-material/Input";
import TuneIcon from '@mui/icons-material/Tune'; import TuneIcon from "@mui/icons-material/Tune";
import ArticleIcon from '@mui/icons-material/Article'; import ArticleIcon from "@mui/icons-material/Article";
import { StatusBox, StatusIcon } from './ui/StatusIcon'; import { StatusBox, StatusIcon } from "./ui/StatusIcon";
import { CopyBubble } from './CopyBubble'; import { CopyBubble } from "./CopyBubble";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
import { StreamingOptions } from 'services/api-client'; import { StreamingOptions } from "services/api-client";
import { setDefaultResultOrder } from 'dns'; import { setDefaultResultOrder } from "dns";
interface ResumeGeneratorProps { interface ResumeGeneratorProps {
job: Job; job: Job;
@ -31,25 +31,34 @@ interface ResumeGeneratorProps {
} }
const defaultMessage: Types.ChatMessageStatus = { const defaultMessage: Types.ChatMessageStatus = {
status: "done", type: "text", sessionId: "", timestamp: new Date(), content: "", activity: 'info' status: "done",
type: "text",
sessionId: "",
timestamp: new Date(),
content: "",
activity: "info",
}; };
const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorProps) => { const ResumeGenerator: React.FC<ResumeGeneratorProps> = (
props: ResumeGeneratorProps
) => {
const { job, candidate, skills, onComplete } = props; const { job, candidate, skills, onComplete } = props;
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const { apiClient, user } = useAuth(); const { apiClient, user } = useAuth();
const [resume, setResume] = useState<string>(''); const [resume, setResume] = useState<string>("");
const [prompt, setPrompt] = useState<string>(''); const [prompt, setPrompt] = useState<string>("");
const [systemPrompt, setSystemPrompt] = useState<string>(''); const [systemPrompt, setSystemPrompt] = useState<string>("");
const [generated, setGenerated] = useState<boolean>(false); const [generated, setGenerated] = useState<boolean>(false);
const [tabValue, setTabValue] = useState<string>('resume'); const [tabValue, setTabValue] = useState<string>("resume");
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>("");
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null); const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(
null
);
const [error, setError] = useState<Types.ChatMessageError | null>(null); const [error, setError] = useState<Types.ChatMessageError | null>(null);
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue); setTabValue(newValue);
} };
useEffect(() => { useEffect(() => {
if (!job || !candidate || !skills || generated) { if (!job || !candidate || !skills || generated) {
@ -63,20 +72,20 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = { const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
onMessage: (message: Types.ChatMessageResume) => { onMessage: (message: Types.ChatMessageResume) => {
setSystemPrompt(message.systemPrompt || ''); setSystemPrompt(message.systemPrompt || "");
setPrompt(message.prompt || ''); setPrompt(message.prompt || "");
setResume(message.resume || ''); setResume(message.resume || "");
setStatus(''); setStatus("");
}, },
onStreaming: (chunk: Types.ChatMessageStreaming) => { onStreaming: (chunk: Types.ChatMessageStreaming) => {
if (status === '') { if (status === "") {
setStatus('Generating resume...'); setStatus("Generating resume...");
setStatusType("generating"); setStatusType("generating");
} }
setResume(chunk.content); setResume(chunk.content);
}, },
onStatus: (status: Types.ChatMessageStatus) => { onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content); console.log("status:", status.content);
setStatusType(status.activity); setStatusType(status.activity);
setStatus(status.content); setStatus(status.content);
}, },
@ -84,7 +93,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
onComplete && onComplete(resume); onComplete && onComplete(resume);
}, },
onError: (error: Types.ChatMessageError) => { onError: (error: Types.ChatMessageError) => {
console.log('error:', error); console.log("error:", error);
setStatusType(null); setStatusType(null);
setStatus(error.content); setStatus(error.content);
setError(error); setError(error);
@ -92,32 +101,46 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
}; };
const generateResume = async () => { const generateResume = async () => {
const request: any = await apiClient.generateResume(candidate.id || '', job.id || '', generateResumeHandlers); const request: any = await apiClient.generateResume(
candidate.id || "",
job.id || "",
generateResumeHandlers
);
const result = await request.promise; const result = await request.promise;
}; };
generateResume(); generateResume();
}, [job, candidate, apiClient, resume, skills, generated, setSystemPrompt, setPrompt, setResume]); }, [
job,
candidate,
apiClient,
resume,
skills,
generated,
setSystemPrompt,
setPrompt,
setResume,
]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!resume) { if (!resume) {
setSnack('No resume to save!'); setSnack("No resume to save!");
return; return;
} }
try { try {
if (!candidate.id || !job.id) { if (!candidate.id || !job.id) {
setSnack('Candidate or job ID is missing.'); setSnack("Candidate or job ID is missing.");
return; return;
} }
const controller = apiClient.saveResume(candidate.id, job.id, resume); const controller = apiClient.saveResume(candidate.id, job.id, resume);
const result = await controller.promise; const result = await controller.promise;
if (result.resume.id) { if (result.resume.id) {
setSnack('Resume saved successfully!'); setSnack("Resume saved successfully!");
} }
} catch (error) { } catch (error) {
console.error('Error saving resume:', error); console.error("Error saving resume:", error);
setSnack('Error saving resume.'); setSnack("Error saving resume.");
} }
}, [apiClient, candidate.id, job.id, resume, setSnack]); }, [apiClient, candidate.id, job.id, resume, setSnack]);
@ -127,40 +150,79 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}}> }}
{user?.isAdmin && <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}> >
{user?.isAdmin && (
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 1 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered> <Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" /> <Tab
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" /> disabled={systemPrompt === ""}
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" /> value="system"
icon={<TuneIcon />}
label="System"
/>
<Tab
disabled={prompt === ""}
value="prompt"
icon={<InputIcon />}
label="Prompt"
/>
<Tab
disabled={resume === ""}
value="resume"
icon={<ArticleIcon />}
label="Resume"
/>
</Tabs> </Tabs>
</Box>} </Box>
)}
{status && <Box sx={{ mt: 0, mb: 1 }}> {status && (
<Box sx={{ mt: 0, mb: 1 }}>
<StatusBox> <StatusBox>
{statusType && <StatusIcon type={statusType} />} {statusType && <StatusIcon type={statusType} />}
<Typography variant="body2" sx={{ ml: 1 }}> <Typography variant="body2" sx={{ ml: 1 }}>
{status || 'Processing...'} {status || "Processing..."}
</Typography> </Typography>
</StatusBox> </StatusBox>
{status && !error && <LinearProgress sx={{ mt: 1 }} />} {status && !error && <LinearProgress sx={{ mt: 1 }} />}
</Box>}
<Paper elevation={3} sx={{ p: 3, m: 1, mt: 0 }}><Scrollable autoscroll sx={{ display: "flex", flexGrow: 1, position: "relative" }}>
{tabValue === 'system' && <pre>{systemPrompt}</pre>}
{tabValue === 'prompt' && <pre>{prompt}</pre>}
{tabValue === 'resume' && <><CopyBubble onClick={() => { setSnack('Resume copied to clipboard!'); }} sx={{ position: "absolute", top: 0, right: 0 }} content={resume} /><StyledMarkdown content={resume} /></>}
</Scrollable></Paper>
{resume && !status && !error && <Button onClick={handleSave} variant="contained" color="primary" sx={{ mt: 2 }}>
Save Resume
</Button>}
</Box> </Box>
) )}
<Paper elevation={3} sx={{ p: 3, m: 1, mt: 0 }}>
<Scrollable
autoscroll
sx={{ display: "flex", flexGrow: 1, position: "relative" }}
>
{tabValue === "system" && <pre>{systemPrompt}</pre>}
{tabValue === "prompt" && <pre>{prompt}</pre>}
{tabValue === "resume" && (
<>
<CopyBubble
onClick={() => {
setSnack("Resume copied to clipboard!");
}}
sx={{ position: "absolute", top: 0, right: 0 }}
content={resume}
/>
<StyledMarkdown content={resume} />
</>
)}
</Scrollable>
</Paper>
{resume && !status && !error && (
<Button
onClick={handleSave}
variant="contained"
color="primary"
sx={{ mt: 2 }}
>
Save Resume
</Button>
)}
</Box>
);
}; };
export { export { ResumeGenerator };
ResumeGenerator
};

View File

@ -1,7 +1,7 @@
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from "@mui/material";
import { RefObject, useRef, forwardRef, useImperativeHandle } from 'react'; import { RefObject, useRef, forwardRef, useImperativeHandle } from "react";
import { useAutoScrollToBottom } from '../hooks/useAutoScrollToBottom'; import { useAutoScrollToBottom } from "../hooks/useAutoScrollToBottom";
interface ScrollableProps { interface ScrollableProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -14,22 +14,35 @@ interface ScrollableProps {
} }
const Scrollable = forwardRef((props: ScrollableProps, ref) => { const Scrollable = forwardRef((props: ScrollableProps, ref) => {
const { sx, className, children, autoscroll, textFieldRef, fallbackThreshold = 0.33, contentUpdateTrigger } = props; const {
sx,
className,
children,
autoscroll,
textFieldRef,
fallbackThreshold = 0.33,
contentUpdateTrigger,
} = props;
// Create a default ref if textFieldRef is not provided // Create a default ref if textFieldRef is not provided
const defaultTextFieldRef = useRef<HTMLElement | null>(null); const defaultTextFieldRef = useRef<HTMLElement | null>(null);
const scrollRef = useAutoScrollToBottom(textFieldRef ?? defaultTextFieldRef, true, fallbackThreshold, contentUpdateTrigger); const scrollRef = useAutoScrollToBottom(
textFieldRef ?? defaultTextFieldRef,
true,
fallbackThreshold,
contentUpdateTrigger
);
return ( return (
<Box <Box
className={`Scrollable ${className || ""}`} className={`Scrollable ${className || ""}`}
sx={{ sx={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
margin: '0 auto', margin: "0 auto",
p: 0, p: 0,
flexGrow: 1, flexGrow: 1,
overflow: 'auto', overflow: "auto",
position: 'relative', position: "relative",
// backgroundColor: '#F5F5F5', // backgroundColor: '#F5F5F5',
...sx, ...sx,
}} }}

View File

@ -1,50 +1,56 @@
import React, { useState, useCallback, useImperativeHandle, forwardRef } from 'react'; import React, {
import { SxProps, Theme } from '@mui/material'; useState,
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar'; useCallback,
import Alert from '@mui/material/Alert'; useImperativeHandle,
forwardRef,
} from "react";
import { SxProps, Theme } from "@mui/material";
import Snackbar, { SnackbarCloseReason } from "@mui/material/Snackbar";
import Alert from "@mui/material/Alert";
import './Snack.css'; import "./Snack.css";
type SeverityType = 'error' | 'info' | 'success' | 'warning' | undefined; type SeverityType = "error" | "info" | "success" | "warning" | undefined;
type SetSnackType = (message: string, severity?: SeverityType) => void; type SetSnackType = (message: string, severity?: SeverityType) => void;
interface SnackHandle { interface SnackHandle {
setSnack: SetSnackType; setSnack: SetSnackType;
}; }
interface SnackProps { interface SnackProps {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
className?: string; className?: string;
}; }
const Snack = forwardRef<SnackHandle, SnackProps>(({ const Snack = forwardRef<SnackHandle, SnackProps>(
className, ({ className, sx }: SnackProps, ref) => {
sx
}: SnackProps, ref) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [severity, setSeverity] = useState<SeverityType>("success"); const [severity, setSeverity] = useState<SeverityType>("success");
// Set the snack pop-up and open it // Set the snack pop-up and open it
const setSnack: SetSnackType = useCallback<SetSnackType>((message: string, severity: SeverityType = "success") => { const setSnack: SetSnackType = useCallback<SetSnackType>(
(message: string, severity: SeverityType = "success") => {
setTimeout(() => { setTimeout(() => {
setMessage(message); setMessage(message);
setSeverity(severity); setSeverity(severity);
setOpen(true); setOpen(true);
}); });
}, [setMessage, setSeverity, setOpen]); },
[setMessage, setSeverity, setOpen]
);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
setSnack: (message: string, severity?: SeverityType) => { setSnack: (message: string, severity?: SeverityType) => {
setSnack(message, severity); setSnack(message, severity);
} },
})); }));
const handleSnackClose = ( const handleSnackClose = (
event: React.SyntheticEvent | Event, event: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason, reason?: SnackbarCloseReason
) => { ) => {
if (reason === 'clickaway') { if (reason === "clickaway") {
return; return;
} }
@ -56,25 +62,24 @@ const Snack = forwardRef<SnackHandle, SnackProps>(({
className={className || "Snack"} className={className || "Snack"}
sx={{ ...sx }} sx={{ ...sx }}
open={open} open={open}
autoHideDuration={(severity === "success" || severity === "info") ? 1500 : 6000} autoHideDuration={
onClose={handleSnackClose}> severity === "success" || severity === "info" ? 1500 : 6000
}
onClose={handleSnackClose}
>
<Alert <Alert
onClose={handleSnackClose} onClose={handleSnackClose}
severity={severity} severity={severity}
variant="filled" variant="filled"
sx={{ width: '100%' }} sx={{ width: "100%" }}
> >
{message} {message}
</Alert> </Alert>
</Snackbar> </Snackbar>
) );
}); }
);
export type { export type { SeverityType, SetSnackType };
SeverityType,
SetSnackType
};
export { export { Snack };
Snack
};

View File

@ -1,37 +1,44 @@
import React from 'react'; import React from "react";
import { MuiMarkdown } from 'mui-markdown'; import { MuiMarkdown } from "mui-markdown";
import { useTheme } from '@mui/material/styles'; import { useTheme } from "@mui/material/styles";
import { Link } from '@mui/material'; import { Link } from "@mui/material";
import { BackstoryQuery, BackstoryQueryInterface } from 'components/BackstoryQuery'; import {
import Box from '@mui/material/Box'; BackstoryQuery,
import JsonView from '@uiw/react-json-view'; BackstoryQueryInterface,
import { vscodeTheme } from '@uiw/react-json-view/vscode'; } from "components/BackstoryQuery";
import { Mermaid } from 'components/Mermaid'; import Box from "@mui/material/Box";
import { Scrollable } from 'components/Scrollable'; import JsonView from "@uiw/react-json-view";
import { jsonrepair } from 'jsonrepair'; import { vscodeTheme } from "@uiw/react-json-view/vscode";
import { GenerateImage } from 'components/GenerateImage'; import { Mermaid } from "components/Mermaid";
import { Scrollable } from "components/Scrollable";
import { jsonrepair } from "jsonrepair";
import { GenerateImage } from "components/GenerateImage";
import './StyledMarkdown.css'; import "./StyledMarkdown.css";
import { BackstoryElementProps } from './BackstoryTab'; import { BackstoryElementProps } from "./BackstoryTab";
import { CandidateQuestion, ChatQuery, ChatSession } from 'types/types'; import { CandidateQuestion, ChatQuery, ChatSession } from "types/types";
import { ChatSubmitQueryInterface } from 'components/BackstoryQuery'; import { ChatSubmitQueryInterface } from "components/BackstoryQuery";
interface StyledMarkdownProps extends BackstoryElementProps { interface StyledMarkdownProps extends BackstoryElementProps {
className?: string, className?: string;
content: string, content: string;
streaming?: boolean, streaming?: boolean;
chatSession?: ChatSession, chatSession?: ChatSession;
submitQuery?: ChatSubmitQueryInterface submitQuery?: ChatSubmitQueryInterface;
}; }
const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProps) => { const StyledMarkdown: React.FC<StyledMarkdownProps> = (
props: StyledMarkdownProps
) => {
const { className, content, chatSession, submitQuery, sx, streaming } = props; const { className, content, chatSession, submitQuery, sx, streaming } = props;
const theme = useTheme(); const theme = useTheme();
const overrides: any = { const overrides: any = {
p: { component: (element: any) =>{ p: {
return <div>{element.children}</div> component: (element: any) => {
}}, return <div>{element.children}</div>;
},
},
pre: { pre: {
component: (element: any) => { component: (element: any) => {
const { className } = element.children.props; const { className } = element.children.props;
@ -44,8 +51,9 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
} }
if (className === "lang-json" && !streaming) { if (className === "lang-json" && !streaming) {
try { try {
let fixed = JSON.parse(jsonrepair(content)); const fixed = JSON.parse(jsonrepair(content));
return <Scrollable className="JsonViewScrollable"> return (
<Scrollable className="JsonViewScrollable">
<JsonView <JsonView
className="JsonView" className="JsonView"
style={{ style={{
@ -62,46 +70,70 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
objectSortKeys={false} objectSortKeys={false}
collapsed={1} collapsed={1}
shortenTextAfterLength={100} shortenTextAfterLength={100}
value={fixed}> value={fixed}
>
<JsonView.String <JsonView.String
render={({ children, ...reset }) => { render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) { if (
return <pre {...reset} style={{ display: "flex", border: "none", ...reset.style }}>{children}</pre> typeof children === "string" &&
children.match("\n")
) {
return (
<pre
{...reset}
style={{
display: "flex",
border: "none",
...reset.style,
}}
>
{children}
</pre>
);
} }
}} }}
/> />
</JsonView> </JsonView>
</Scrollable> </Scrollable>
);
} catch (e) { } catch (e) {
return <pre><code className="JsonRaw">{content}</code></pre> return (
}; <pre>
<code className="JsonRaw">{content}</code>
</pre>
);
} }
return <pre><code className={className || ''}>{element.children}</code></pre>; }
return (
<pre>
<code className={className || ""}>{element.children}</code>
</pre>
);
}, },
}, },
a: { a: {
component: Link, component: Link,
props: { props: {
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => { onClick: (event: React.MouseEvent<HTMLAnchorElement>) => {
const href = event.currentTarget.getAttribute('href'); const href = event.currentTarget.getAttribute("href");
console.log("StyledMarkdown onClick:", href); console.log("StyledMarkdown onClick:", href);
if (href) { if (href) {
if (href.match(/^\//)) { if (href.match(/^\//)) {
event.preventDefault(); event.preventDefault();
window.history.replaceState({}, '', `${href}`); window.history.replaceState({}, "", `${href}`);
} }
} }
}, },
sx: { sx: {
wordBreak: "break-all", wordBreak: "break-all",
color: theme.palette.secondary.main, color: theme.palette.secondary.main,
textDecoration: 'none', textDecoration: "none",
'&:hover': { "&:hover": {
color: theme.palette.custom.highlight, color: theme.palette.custom.highlight,
textDecoration: 'underline', textDecoration: "underline",
} },
} },
} },
}, },
BackstoryQuery: { BackstoryQuery: {
component: (props: { query: string }) => { component: (props: { query: string }) => {
@ -109,16 +141,20 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
try { try {
const query = JSON.parse(queryString); const query = JSON.parse(queryString);
const backstoryQuestion: CandidateQuestion = { const backstoryQuestion: CandidateQuestion = {
question: queryString question: queryString,
} };
return submitQuery ? <BackstoryQuery submitQuery={submitQuery} question={query} /> : query.question; return submitQuery ? (
<BackstoryQuery submitQuery={submitQuery} question={query} />
) : (
query.question
);
} catch (e) { } catch (e) {
console.log("StyledMarkdown error:", queryString, e); console.log("StyledMarkdown error:", queryString, e);
return props.query; return props.query;
} }
}, },
} },
}; };
if (chatSession) { if (chatSession) {
@ -126,16 +162,17 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
component: (props: { prompt: string }) => { component: (props: { prompt: string }) => {
const prompt = props.prompt.replace(/(\w+):/g, '"$1":'); const prompt = props.prompt.replace(/(\w+):/g, '"$1":');
try { try {
return <GenerateImage {...{ chatSession, prompt }} /> return <GenerateImage {...{ chatSession, prompt }} />;
} catch (e) { } catch (e) {
console.log("StyledMarkdown error:", prompt, e); console.log("StyledMarkdown error:", prompt, e);
return props.prompt; return props.prompt;
} }
} },
} };
} }
return <Box return (
<Box
className={`MuiMarkdown ${className || ""}`} className={`MuiMarkdown ${className || ""}`}
sx={{ sx={{
display: "flex", display: "flex",
@ -144,13 +181,12 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
boxSizing: "border-box", boxSizing: "border-box",
flexGrow: 1, flexGrow: 1,
height: "auto", height: "auto",
...sx ...sx,
}}> }}
<MuiMarkdown >
overrides={overrides} <MuiMarkdown overrides={overrides} children={content} />
children={content} </Box>
/> );
</Box>;
}; };
export { StyledMarkdown }; export { StyledMarkdown };

View File

@ -1,34 +1,34 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from "react";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import Paper from '@mui/material/Paper'; import Paper from "@mui/material/Paper";
import Plot from 'react-plotly.js'; import Plot from "react-plotly.js";
import TextField from '@mui/material/TextField'; import TextField from "@mui/material/TextField";
import Tooltip from '@mui/material/Tooltip'; import Tooltip from "@mui/material/Tooltip";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import SendIcon from '@mui/icons-material/Send'; import SendIcon from "@mui/icons-material/Send";
import FormControlLabel from '@mui/material/FormControlLabel'; import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from '@mui/material/Switch'; import Switch from "@mui/material/Switch";
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from "@mui/material/useMediaQuery";
import { SxProps, useTheme } from '@mui/material/styles'; import { SxProps, useTheme } from "@mui/material/styles";
import Table from '@mui/material/Table'; import Table from "@mui/material/Table";
import TableBody from '@mui/material/TableBody'; import TableBody from "@mui/material/TableBody";
import TableCell from '@mui/material/TableCell'; import TableCell from "@mui/material/TableCell";
import TableContainer from '@mui/material/TableContainer'; import TableContainer from "@mui/material/TableContainer";
import TableRow from '@mui/material/TableRow'; import TableRow from "@mui/material/TableRow";
import { Scrollable } from './Scrollable'; import { Scrollable } from "./Scrollable";
import './VectorVisualizer.css'; import "./VectorVisualizer.css";
import { BackstoryPageProps } from './BackstoryTab'; import { BackstoryPageProps } from "./BackstoryTab";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import * as Types from 'types/types'; import * as Types from "types/types";
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext'; import { useAppState, useSelectedCandidate } from "hooks/GlobalContext";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
interface VectorVisualizerProps extends BackstoryPageProps { interface VectorVisualizerProps extends BackstoryPageProps {
inline?: boolean; inline?: boolean;
rag?: Types.ChromaDBGetResponse; rag?: Types.ChromaDBGetResponse;
}; }
interface Metadata { interface Metadata {
id: string; id: string;
@ -46,7 +46,7 @@ const emptyQuerySet: Types.ChromaDBGetResponse = {
name: "Empty", name: "Empty",
size: 0, size: 0,
dimensions: 2, dimensions: 2,
query: "" query: "",
}; };
interface PlotData { interface PlotData {
@ -105,36 +105,34 @@ const config: Partial<Plotly.Config> = {
// | "hovercompare" // | "hovercompare"
// | "hoverclosest" // | "hoverclosest"
// | "v1hovermode"; // | "v1hovermode";
modeBarButtonsToRemove: [ modeBarButtonsToRemove: ["lasso2d", "select2d"],
'lasso2d', 'select2d',
]
}; };
const layout: Partial<Plotly.Layout> = { const layout: Partial<Plotly.Layout> = {
autosize: false, autosize: false,
clickmode: 'event+select', clickmode: "event+select",
paper_bgcolor: '#FFFFFF', // white paper_bgcolor: "#FFFFFF", // white
plot_bgcolor: '#FFFFFF', // white plot background plot_bgcolor: "#FFFFFF", // white plot background
font: { font: {
family: 'Roboto, sans-serif', family: "Roboto, sans-serif",
color: '#2E2E2E', // charcoal black color: "#2E2E2E", // charcoal black
}, },
hovermode: 'closest', hovermode: "closest",
scene: { scene: {
bgcolor: '#FFFFFF', // 3D plot background bgcolor: "#FFFFFF", // 3D plot background
zaxis: { title: 'Z', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' }, zaxis: { title: "Z", gridcolor: "#cccccc", zerolinecolor: "#aaaaaa" },
}, },
xaxis: { title: 'X', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' }, xaxis: { title: "X", gridcolor: "#cccccc", zerolinecolor: "#aaaaaa" },
yaxis: { title: 'Y', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' }, yaxis: { title: "Y", gridcolor: "#cccccc", zerolinecolor: "#aaaaaa" },
margin: { r: 0, b: 0, l: 0, t: 0 }, margin: { r: 0, b: 0, l: 0, t: 0 },
legend: { legend: {
x: 0.8, // Horizontal position (0 to 1, 0 is left, 1 is right) x: 0.8, // Horizontal position (0 to 1, 0 is left, 1 is right)
y: 0, // Vertical position (0 to 1, 0 is bottom, 1 is top) y: 0, // Vertical position (0 to 1, 0 is bottom, 1 is top)
xanchor: 'left', xanchor: "left",
yanchor: 'top', yanchor: "top",
orientation: 'h' // 'v' for horizontal legend orientation: "h", // 'v' for horizontal legend
}, },
showlegend: true // Show the legend showlegend: true, // Show the legend
}; };
const normalizeDimension = (arr: number[]): number[] => { const normalizeDimension = (arr: number[]): number[] => {
@ -142,64 +140,69 @@ const normalizeDimension = (arr: number[]): number[] => {
const max = Math.max(...arr); const max = Math.max(...arr);
const range = max - min; const range = max - min;
if (range === 0) return arr.map(() => 0.5); // flat dimension if (range === 0) return arr.map(() => 0.5); // flat dimension
return arr.map(v => (v - min) / range); return arr.map((v) => (v - min) / range);
}; };
const emojiMap: Record<string, string> = { const emojiMap: Record<string, string> = {
query: '🔍', query: "🔍",
resume: '📄', resume: "📄",
projects: '📁', projects: "📁",
jobs: '📁', jobs: "📁",
'performance-reviews': '📄', "performance-reviews": "📄",
news: '📰', news: "📰",
}; };
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
query: '#D4A017', // Golden Ochre — strong highlight query: "#D4A017", // Golden Ochre — strong highlight
resume: '#4A7A7D', // Dusty Teal — secondary theme color resume: "#4A7A7D", // Dusty Teal — secondary theme color
projects: '#1A2536', // Midnight Blue — rich and deep projects: "#1A2536", // Midnight Blue — rich and deep
news: '#D3CDBF', // Warm Gray — soft and neutral news: "#D3CDBF", // Warm Gray — soft and neutral
'performance-reviews': '#8FD0D0', // Light red "performance-reviews": "#8FD0D0", // Light red
'jobs': '#F3aD8F', // Warm Gray — soft and neutral jobs: "#F3aD8F", // Warm Gray — soft and neutral
}; };
const DEFAULT_SIZE = 6.; const DEFAULT_SIZE = 6;
const DEFAULT_UNFOCUS_SIZE = 2.; const DEFAULT_UNFOCUS_SIZE = 2;
type Node = { type Node = {
id: string, id: string;
content: string, // Portion of content that was used for embedding content: string; // Portion of content that was used for embedding
fullContent: string | undefined, // Portion of content plus/minus buffer fullContent: string | undefined; // Portion of content plus/minus buffer
emoji: string, emoji: string;
docType: string, docType: string;
source_file: string, source_file: string;
distance: number | undefined, distance: number | undefined;
path: string, path: string;
chunkBegin: number, chunkBegin: number;
lineBegin: number, lineBegin: number;
chunkEnd: number, chunkEnd: number;
lineEnd: number, lineEnd: number;
sx: SxProps, sx: SxProps;
}; };
const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => { const VectorVisualizer: React.FC<VectorVisualizerProps> = (
props: VectorVisualizerProps
) => {
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const { rag, inline, sx } = props; const { rag, inline, sx } = props;
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [plotData, setPlotData] = useState<PlotData | null>(null); const [plotData, setPlotData] = useState<PlotData | null>(null);
const [newQuery, setNewQuery] = useState<string>(''); const [newQuery, setNewQuery] = useState<string>("");
const [querySet, setQuerySet] = useState<Types.ChromaDBGetResponse>(rag || emptyQuerySet); const [querySet, setQuerySet] = useState<Types.ChromaDBGetResponse>(
rag || emptyQuerySet
);
const [result, setResult] = useState<Types.ChromaDBGetResponse | null>(null); const [result, setResult] = useState<Types.ChromaDBGetResponse | null>(null);
const [view2D, setView2D] = useState<boolean>(true); const [view2D, setView2D] = useState<boolean>(true);
const plotlyRef = useRef(null); const plotlyRef = useRef(null);
const boxRef = useRef<HTMLElement>(null); const boxRef = useRef<HTMLElement>(null);
const [node, setNode] = useState<Node | null>(null); const [node, setNode] = useState<Node | null>(null);
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [plotDimensions, setPlotDimensions] = useState({ width: 0, height: 0 }); const [plotDimensions, setPlotDimensions] = useState({ width: 0, height: 0 });
const navigate = useNavigate(); const navigate = useNavigate();
const candidate: Types.Candidate | null = user?.userType === 'candidate' ? user as Types.Candidate : null; const candidate: Types.Candidate | null =
user?.userType === "candidate" ? (user as Types.Candidate) : null;
/* Force resize of Plotly as it tends to not be the correct size if it is initially rendered /* Force resize of Plotly as it tends to not be the correct size if it is initially rendered
* off screen (eg., the VectorVisualizer is not on the tab the app loads to) */ * off screen (eg., the VectorVisualizer is not on the tab the app loads to) */
@ -209,18 +212,28 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
} }
const resize = () => { const resize = () => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const plotContainer = document.querySelector('.plot-container') as HTMLElement; const plotContainer = document.querySelector(
const svgContainer = document?.querySelector('.svg-container') as HTMLElement; ".plot-container"
) as HTMLElement;
const svgContainer = document?.querySelector(
".svg-container"
) as HTMLElement;
if (plotContainer && svgContainer) { if (plotContainer && svgContainer) {
const plotContainerRect = plotContainer.getBoundingClientRect(); const plotContainerRect = plotContainer.getBoundingClientRect();
svgContainer.style.width = `${plotContainerRect.width}px`; svgContainer.style.width = `${plotContainerRect.width}px`;
svgContainer.style.height = `${plotContainerRect.height}px`; svgContainer.style.height = `${plotContainerRect.height}px`;
if (plotDimensions.width !== plotContainerRect.width || plotDimensions.height !== plotContainerRect.height) { if (
setPlotDimensions({ width: plotContainerRect.width, height: plotContainerRect.height }); plotDimensions.width !== plotContainerRect.width ||
plotDimensions.height !== plotContainerRect.height
) {
setPlotDimensions({
width: plotContainerRect.width,
height: plotContainerRect.height,
});
} }
} }
}); });
} };
resize(); resize();
}); });
@ -237,13 +250,13 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
const result = await apiClient.getCandidateVectors(view2D ? 2 : 3); const result = await apiClient.getCandidateVectors(view2D ? 2 : 3);
setResult(result); setResult(result);
} catch (error) { } catch (error) {
console.error('Error obtaining collection information:', error); console.error("Error obtaining collection information:", error);
setSnack("Unable to obtain collection information.", "error"); setSnack("Unable to obtain collection information.", "error");
}; }
}; };
fetchCollection(); fetchCollection();
}, [result, setSnack, view2D]) }, [result, setSnack, view2D]);
useEffect(() => { useEffect(() => {
if (!result || !result.embeddings) return; if (!result || !result.embeddings) return;
@ -251,43 +264,43 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
const full: Types.ChromaDBGetResponse = { const full: Types.ChromaDBGetResponse = {
...result, ...result,
ids: [...result.ids || []], ids: [...(result.ids || [])],
documents: [...result.documents || []], documents: [...(result.documents || [])],
embeddings: [...result.embeddings], embeddings: [...result.embeddings],
metadatas: [...result.metadatas || []], metadatas: [...(result.metadatas || [])],
}; };
let is2D = full.embeddings.every((v: number[]) => v.length === 2); const is2D = full.embeddings.every((v: number[]) => v.length === 2);
let is3D = full.embeddings.every((v: number[]) => v.length === 3); const is3D = full.embeddings.every((v: number[]) => v.length === 3);
if ((view2D && !is2D) || (!view2D && !is3D)) { if ((view2D && !is2D) || (!view2D && !is3D)) {
return; return;
} }
if (!is2D && !is3D) { if (!is2D && !is3D) {
console.warn('Modified vectors are neither 2D nor 3D'); console.warn("Modified vectors are neither 2D nor 3D");
return; return;
} }
let query: Types.ChromaDBGetResponse = { const query: Types.ChromaDBGetResponse = {
ids: [], ids: [],
documents: [], documents: [],
embeddings: [], embeddings: [],
metadatas: [], metadatas: [],
distances: [], distances: [],
query: '', query: "",
size: 0, size: 0,
dimensions: 2, dimensions: 2,
name: '' name: "",
}; };
let filtered: Types.ChromaDBGetResponse = { const filtered: Types.ChromaDBGetResponse = {
ids: [], ids: [],
documents: [], documents: [],
embeddings: [], embeddings: [],
metadatas: [], metadatas: [],
distances: [], distances: [],
query: '', query: "",
size: 0, size: 0,
dimensions: 2, dimensions: 2,
name: '' name: "",
}; };
/* Loop through all items and divide into two groups: /* Loop through all items and divide into two groups:
@ -301,7 +314,9 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
full.metadatas[index].content = full.documents[index]; full.metadatas[index].content = full.documents[index];
if (foundIndex !== -1) { if (foundIndex !== -1) {
/* The query set will contain the distance to the query */ /* The query set will contain the distance to the query */
full.metadatas[index].distance = querySet.distances ? querySet.distances[foundIndex] : undefined; full.metadatas[index].distance = querySet.distances
? querySet.distances[foundIndex]
: undefined;
query.ids.push(id); query.ids.push(id);
query.documents.push(full.documents[index]); query.documents.push(full.documents[index]);
query.embeddings.push(full.embeddings[index]); query.embeddings.push(full.embeddings[index]);
@ -317,64 +332,104 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
}); });
if (view2D && querySet.umapEmbedding2D && querySet.umapEmbedding2D.length) { if (view2D && querySet.umapEmbedding2D && querySet.umapEmbedding2D.length) {
query.ids.unshift('query'); query.ids.unshift("query");
query.metadatas.unshift({ id: 'query', docType: 'query', content: querySet.query || '', distance: 0 }); query.metadatas.unshift({
id: "query",
docType: "query",
content: querySet.query || "",
distance: 0,
});
query.embeddings.unshift(querySet.umapEmbedding2D); query.embeddings.unshift(querySet.umapEmbedding2D);
} }
if (!view2D && querySet.umapEmbedding3D && querySet.umapEmbedding3D.length) { if (
query.ids.unshift('query'); !view2D &&
query.metadatas.unshift({ id: 'query', docType: 'query', content: querySet.query || '', distance: 0 }); querySet.umapEmbedding3D &&
querySet.umapEmbedding3D.length
) {
query.ids.unshift("query");
query.metadatas.unshift({
id: "query",
docType: "query",
content: querySet.query || "",
distance: 0,
});
query.embeddings.unshift(querySet.umapEmbedding3D); query.embeddings.unshift(querySet.umapEmbedding3D);
} }
const filtered_docTypes = filtered.metadatas.map(m => m.docType || 'unknown') const filtered_docTypes = filtered.metadatas.map(
const query_docTypes = query.metadatas.map(m => m.docType || 'unknown') (m) => m.docType || "unknown"
);
const query_docTypes = query.metadatas.map((m) => m.docType || "unknown");
const has_query = query.metadatas.length > 0; const has_query = query.metadatas.length > 0;
const filtered_sizes = filtered.metadatas.map(m => has_query ? DEFAULT_UNFOCUS_SIZE : DEFAULT_SIZE); const filtered_sizes = filtered.metadatas.map((m) =>
const filtered_colors = filtered_docTypes.map(type => colorMap[type] || '#4d4d4d'); has_query ? DEFAULT_UNFOCUS_SIZE : DEFAULT_SIZE
const filtered_x = normalizeDimension(filtered.embeddings.map((v: number[]) => v[0])); );
const filtered_y = normalizeDimension(filtered.embeddings.map((v: number[]) => v[1])); const filtered_colors = filtered_docTypes.map(
const filtered_z = is3D ? normalizeDimension(filtered.embeddings.map((v: number[]) => v[2])) : undefined; (type) => colorMap[type] || "#4d4d4d"
);
const filtered_x = normalizeDimension(
filtered.embeddings.map((v: number[]) => v[0])
);
const filtered_y = normalizeDimension(
filtered.embeddings.map((v: number[]) => v[1])
);
const filtered_z = is3D
? normalizeDimension(filtered.embeddings.map((v: number[]) => v[2]))
: undefined;
const query_sizes = query.metadatas.map(m => DEFAULT_SIZE + 2. * DEFAULT_SIZE * Math.pow((1. - (m.distance || 1.)), 3)); const query_sizes = query.metadatas.map(
const query_colors = query_docTypes.map(type => colorMap[type] || '#4d4d4d'); (m) =>
const query_x = normalizeDimension(query.embeddings.map((v: number[]) => v[0])); DEFAULT_SIZE + 2 * DEFAULT_SIZE * Math.pow(1 - (m.distance || 1), 3)
const query_y = normalizeDimension(query.embeddings.map((v: number[]) => v[1])); );
const query_z = is3D ? normalizeDimension(query.embeddings.map((v: number[]) => v[2])) : undefined; const query_colors = query_docTypes.map(
(type) => colorMap[type] || "#4d4d4d"
);
const query_x = normalizeDimension(
query.embeddings.map((v: number[]) => v[0])
);
const query_y = normalizeDimension(
query.embeddings.map((v: number[]) => v[1])
);
const query_z = is3D
? normalizeDimension(query.embeddings.map((v: number[]) => v[2]))
: undefined;
const data: any = [{ const data: any = [
name: 'All data', {
name: "All data",
x: filtered_x, x: filtered_x,
y: filtered_y, y: filtered_y,
mode: 'markers', mode: "markers",
marker: { marker: {
size: filtered_sizes, size: filtered_sizes,
symbol: 'circle', symbol: "circle",
color: filtered_colors, color: filtered_colors,
opacity: 1 opacity: 1,
}, },
text: filtered.ids, text: filtered.ids,
customdata: filtered.metadatas, customdata: filtered.metadatas,
type: is3D ? 'scatter3d' : 'scatter', type: is3D ? "scatter3d" : "scatter",
hovertemplate: '&nbsp;', hovertemplate: "&nbsp;",
}, { },
name: 'Query', {
name: "Query",
x: query_x, x: query_x,
y: query_y, y: query_y,
mode: 'markers', mode: "markers",
marker: { marker: {
size: query_sizes, size: query_sizes,
symbol: 'circle', symbol: "circle",
color: query_colors, color: query_colors,
opacity: 1 opacity: 1,
}, },
text: query.ids, text: query.ids,
customdata: query.metadatas, customdata: query.metadatas,
type: is3D ? 'scatter3d' : 'scatter', type: is3D ? "scatter3d" : "scatter",
hovertemplate: '%{text}', hovertemplate: "%{text}",
}]; },
];
if (is3D) { if (is3D) {
data[0].z = filtered_z; data[0].z = filtered_z;
@ -382,38 +437,59 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
} }
setPlotData(data); setPlotData(data);
}, [result, querySet, view2D]); }, [result, querySet, view2D]);
const handleKeyPress = (event: any) => { const handleKeyPress = (event: any) => {
if (event.key === 'Enter') { if (event.key === "Enter") {
sendQuery(newQuery); sendQuery(newQuery);
} }
}; };
const sendQuery = async (query: string) => { const sendQuery = async (query: string) => {
if (!query.trim()) return; if (!query.trim()) return;
setNewQuery(''); setNewQuery("");
try { try {
const result = await apiClient.getCandidateSimilarContent(query); const result = await apiClient.getCandidateSimilarContent(query);
console.log(result); console.log(result);
setQuerySet(result); setQuerySet(result);
} catch (error) { } catch (error) {
const msg = `Error obtaining similar content to ${query}.` const msg = `Error obtaining similar content to ${query}.`;
setSnack(msg, "error"); setSnack(msg, "error");
}; }
}; };
if (!result) return ( if (!result)
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}> return (
<Box
sx={{
display: "flex",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<div>Loading visualization...</div> <div>Loading visualization...</div>
</Box> </Box>
); );
if (!candidate) return ( if (!candidate)
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}> return (
<div>No candidate selected. Please <Button onClick={() => navigate('/find-a-candidate')}>select a candidate</Button> first.</div> <Box
sx={{
display: "flex",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<div>
No candidate selected. Please{" "}
<Button onClick={() => navigate("/find-a-candidate")}>
select a candidate
</Button>{" "}
first.
</div>
</Box> </Box>
); );
@ -422,39 +498,41 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
const result = await apiClient.getCandidateRAGContent(node.id); const result = await apiClient.getCandidateRAGContent(node.id);
const update: Node = { const update: Node = {
...node, ...node,
fullContent: result.content fullContent: result.content,
} };
setNode(update); setNode(update);
} catch (error) { } catch (error) {
const msg = `Error obtaining content for ${node.id}.` const msg = `Error obtaining content for ${node.id}.`;
console.error(msg, error); console.error(msg, error);
setSnack(msg, "error"); setSnack(msg, "error");
}; }
}; };
const onNodeSelected = (metadata: any) => { const onNodeSelected = (metadata: any) => {
let node: Node; let node: Node;
console.log(metadata); console.log(metadata);
if (metadata.docType === 'query') { if (metadata.docType === "query") {
node = { node = {
...metadata, ...metadata,
content: `Similarity results for the query **${querySet.query || ''}** content: `Similarity results for the query **${querySet.query || ""}**
The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '2' : '3'}-dimensional space. Larger dots represent relative similarity in N-dimensional space. The scatter graph shows the query in N-dimensional space, mapped to ${
view2D ? "2" : "3"
}-dimensional space. Larger dots represent relative similarity in N-dimensional space.
`, `,
emoji: emojiMap[metadata.docType], emoji: emojiMap[metadata.docType],
sx: { sx: {
m: 0.5, m: 0.5,
p: 2, p: 2,
width: '3rem', width: "3rem",
display: "flex", display: "flex",
alignContent: "center", alignContent: "center",
justifyContent: "center", justifyContent: "center",
flexGrow: 0, flexGrow: 0,
flexWrap: "wrap", flexWrap: "wrap",
backgroundColor: colorMap[metadata.docType] || '#ff8080', backgroundColor: colorMap[metadata.docType] || "#ff8080",
} },
} };
setNode(node); setNode(node);
return; return;
} }
@ -462,8 +540,8 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
node = { node = {
content: `Loading...`, content: `Loading...`,
...metadata, ...metadata,
emoji: emojiMap[metadata.docType] || '❓', emoji: emojiMap[metadata.docType] || "❓",
} };
setNode(node); setNode(node);
@ -471,100 +549,186 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
}; };
return ( return (
<Box className="VectorVisualizer" <Box
className="VectorVisualizer"
ref={boxRef} ref={boxRef}
sx={{ sx={{
...sx ...sx,
}}> }}
>
<Box sx={{ p: 0, m: 0, gap: 0 }}> <Box sx={{ p: 0, m: 0, gap: 0 }}>
<Paper sx={{ <Paper
p: 0.5, m: 0, sx={{
p: 0.5,
m: 0,
display: "flex", display: "flex",
flexGrow: 0, flexGrow: 0,
height: isMobile ? "auto" : "auto", //"320px", height: isMobile ? "auto" : "auto", //"320px",
minHeight: isMobile ? "auto" : "auto", //"320px", minHeight: isMobile ? "auto" : "auto", //"320px",
maxHeight: isMobile ? "auto" : "auto", //"320px", maxHeight: isMobile ? "auto" : "auto", //"320px",
position: "relative", position: "relative",
flexDirection: "column" flexDirection: "column",
}}> }}
>
<FormControlLabel <FormControlLabel
sx={{ sx={{
display: "flex", display: "flex",
position: "relative", position: "relative",
width: "fit-content", width: "fit-content",
ml: 1, ml: 1,
mb: '-2.5rem', mb: "-2.5rem",
zIndex: 100, zIndex: 100,
flexBasis: 0, flexBasis: 0,
flexGrow: 0 flexGrow: 0,
}} }}
control={<Switch checked={!view2D} />} onChange={() => { setView2D(!view2D); setResult(null); }} label="3D" /> control={<Switch checked={!view2D} />}
onChange={() => {
setView2D(!view2D);
setResult(null);
}}
label="3D"
/>
<Plot <Plot
ref={plotlyRef} ref={plotlyRef}
onClick={(event: any) => { onNodeSelected(event.points[0].customdata); }} onClick={(event: any) => {
onNodeSelected(event.points[0].customdata);
}}
data={plotData} data={plotData}
useResizeHandler={true} useResizeHandler={true}
config={config} config={config}
style={{ style={{
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
minHeight: '240px', minHeight: "240px",
padding: 0, padding: 0,
margin: 0, margin: 0,
width: "100%", width: "100%",
height: "100%", height: "100%",
overflow: "hidden", overflow: "hidden",
}} }}
layout={{...layout, width: plotDimensions.width, height: plotDimensions.height }} layout={{
...layout,
width: plotDimensions.width,
height: plotDimensions.height,
}}
/> />
</Paper> </Paper>
<Paper sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", mt: 0.5, p: 0.5, flexGrow: 1, minHeight: "fit-content" }}> <Paper
{node !== null && sx={{
<Box sx={{ display: "flex", fontSize: "0.75rem", flexDirection: "column", flexGrow: 1, maxWidth: "100%", flexBasis: 1, maxHeight: "min-content" }}> display: "flex",
<TableContainer component={Paper} sx={{ mb: isMobile ? 1 : 0, mr: isMobile ? 0 : 1 }}> flexDirection: isMobile ? "column" : "row",
<Table size="small" sx={{ tableLayout: 'fixed' }}> mt: 0.5,
<TableBody sx={{ '& td': { verticalAlign: "top", fontSize: "0.75rem", }, '& td:first-of-type': { whiteSpace: "nowrap", width: "1rem" } }}> p: 0.5,
flexGrow: 1,
minHeight: "fit-content",
}}
>
{node !== null && (
<Box
sx={{
display: "flex",
fontSize: "0.75rem",
flexDirection: "column",
flexGrow: 1,
maxWidth: "100%",
flexBasis: 1,
maxHeight: "min-content",
}}
>
<TableContainer
component={Paper}
sx={{ mb: isMobile ? 1 : 0, mr: isMobile ? 0 : 1 }}
>
<Table size="small" sx={{ tableLayout: "fixed" }}>
<TableBody
sx={{
"& td": { verticalAlign: "top", fontSize: "0.75rem" },
"& td:first-of-type": {
whiteSpace: "nowrap",
width: "1rem",
},
}}
>
<TableRow> <TableRow>
<TableCell>Type</TableCell> <TableCell>Type</TableCell>
<TableCell>{node.emoji} {node.docType}</TableCell> <TableCell>
{node.emoji} {node.docType}
</TableCell>
</TableRow> </TableRow>
{node.source_file !== undefined && <TableRow> {node.source_file !== undefined && (
<TableRow>
<TableCell>File</TableCell> <TableCell>File</TableCell>
<TableCell>{node.source_file.replace(/^.*\//, '')}</TableCell> <TableCell>
</TableRow>} {node.source_file.replace(/^.*\//, "")}
{node.path !== undefined && <TableRow> </TableCell>
</TableRow>
)}
{node.path !== undefined && (
<TableRow>
<TableCell>Section</TableCell> <TableCell>Section</TableCell>
<TableCell>{node.path}</TableCell> <TableCell>{node.path}</TableCell>
</TableRow>} </TableRow>
{node.distance !== undefined && <TableRow> )}
{node.distance !== undefined && (
<TableRow>
<TableCell>Distance</TableCell> <TableCell>Distance</TableCell>
<TableCell>{node.distance}</TableCell> <TableCell>{node.distance}</TableCell>
</TableRow>} </TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
{node.content !== "" && node.content !== undefined && {node.content !== "" && node.content !== undefined && (
<Paper elevation={6} sx={{ display: "flex", flexDirection: "column", border: "1px solid #808080", minHeight: "fit-content", mt: 1 }}> <Paper
<Box sx={{ display: "flex", background: "#404040", p: 1, color: "white" }}>Vector Embedded Content</Box> elevation={6}
<Box sx={{ display: "flex", p: 1, flexGrow: 1 }}>{node.content}</Box> sx={{
</Paper> display: "flex",
} flexDirection: "column",
border: "1px solid #808080",
minHeight: "fit-content",
mt: 1,
}}
>
<Box
sx={{
display: "flex",
background: "#404040",
p: 1,
color: "white",
}}
>
Vector Embedded Content
</Box>
<Box sx={{ display: "flex", p: 1, flexGrow: 1 }}>
{node.content}
</Box> </Box>
}
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 2, flexBasis: 0, flexShrink: 1 }}>
{node === null &&
<Paper sx={{ m: 0.5, p: 2, flexGrow: 1 }}>
Click a point in the scatter-graph to see information about that node.
</Paper> </Paper>
} )}
{node !== null && node.fullContent && </Box>
)}
<Box
sx={{
display: "flex",
flexDirection: "column",
flexGrow: 2,
flexBasis: 0,
flexShrink: 1,
}}
>
{node === null && (
<Paper sx={{ m: 0.5, p: 2, flexGrow: 1 }}>
Click a point in the scatter-graph to see information about that
node.
</Paper>
)}
{node !== null && node.fullContent && (
<Scrollable <Scrollable
autoscroll={false} autoscroll={false}
sx={{ sx={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
m: 0, m: 0,
p: 0.5, p: 0.5,
pl: 1, pl: 1,
@ -573,32 +737,87 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
maxWidth: "100%", maxWidth: "100%",
}} }}
> >
{ {node.fullContent.split("\n").map((line, index) => {
node.fullContent.split('\n').map((line, index) => {
index += 1 + node.chunkBegin; index += 1 + node.chunkBegin;
const bgColor = (index > node.lineBegin && index <= node.lineEnd) ? '#f0f0f0' : 'auto'; const bgColor =
return <Box key={index} sx={{ display: "flex", flexDirection: "row", borderBottom: '1px solid #d0d0d0', ':first-of-type': { borderTop: '1px solid #d0d0d0' }, backgroundColor: bgColor }}> index > node.lineBegin && index <= node.lineEnd
<Box sx={{ fontFamily: 'courier', fontSize: "0.8rem", minWidth: "2rem", pt: "0.1rem", align: "left", verticalAlign: "top" }}>{index}</Box> ? "#f0f0f0"
<pre style={{ margin: 0, padding: 0, border: "none", minHeight: "1rem", overflow: "hidden" }} >{line || " "}</pre> : "auto";
</Box>; return (
}) <Box
} key={index}
{!node.lineBegin && <pre style={{ margin: 0, padding: 0, border: "none" }}>{node.content}</pre>} sx={{
display: "flex",
flexDirection: "row",
borderBottom: "1px solid #d0d0d0",
":first-of-type": { borderTop: "1px solid #d0d0d0" },
backgroundColor: bgColor,
}}
>
<Box
sx={{
fontFamily: "courier",
fontSize: "0.8rem",
minWidth: "2rem",
pt: "0.1rem",
align: "left",
verticalAlign: "top",
}}
>
{index}
</Box>
<pre
style={{
margin: 0,
padding: 0,
border: "none",
minHeight: "1rem",
overflow: "hidden",
}}
>
{line || " "}
</pre>
</Box>
);
})}
{!node.lineBegin && (
<pre style={{ margin: 0, padding: 0, border: "none" }}>
{node.content}
</pre>
)}
</Scrollable> </Scrollable>
} )}
</Box> </Box>
</Paper> </Paper>
{!inline && querySet.query !== undefined && querySet.query !== '' && {!inline && querySet.query !== undefined && querySet.query !== "" && (
<Paper sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', flexGrow: 0, minHeight: '2.5rem', maxHeight: '2.5rem', height: '2.5rem', alignItems: 'center', mt: 1, pb: 0 }}> <Paper
{querySet.query !== undefined && querySet.query !== '' && `Query: ${querySet.query}`} sx={{
{querySet.ids.length === 0 && "Enter query below to perform a similarity search."} display: "flex",
flexDirection: "column",
justifyContent: "center",
flexGrow: 0,
minHeight: "2.5rem",
maxHeight: "2.5rem",
height: "2.5rem",
alignItems: "center",
mt: 1,
pb: 0,
}}
>
{querySet.query !== undefined &&
querySet.query !== "" &&
`Query: ${querySet.query}`}
{querySet.ids.length === 0 &&
"Enter query below to perform a similarity search."}
</Paper> </Paper>
} )}
{ {!inline && (
!inline && <Box
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}> className="Query"
sx={{ display: "flex", flexDirection: "row", p: 1 }}
>
<TextField <TextField
variant="outlined" variant="outlined"
fullWidth fullWidth
@ -610,10 +829,18 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
id="QueryInput" id="QueryInput"
/> />
<Tooltip title="Send"> <Tooltip title="Send">
<Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(newQuery); }}><SendIcon /></Button> <Button
sx={{ m: 1 }}
variant="contained"
onClick={() => {
sendQuery(newQuery);
}}
>
<SendIcon />
</Button>
</Tooltip> </Tooltip>
</Box> </Box>
} )}
</Box> </Box>
</Box> </Box>
); );
@ -621,6 +848,4 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
export type { VectorVisualizerProps }; export type { VectorVisualizerProps };
export { export { VectorVisualizer };
VectorVisualizer,
};

View File

@ -1,23 +1,20 @@
// components/layout/BackstoryLayout.tsx // components/layout/BackstoryLayout.tsx
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from "react";
import { Outlet, useLocation, Routes, Route } from "react-router-dom"; import { Outlet, useLocation, Routes, Route } from "react-router-dom";
import { Box, Container, Paper } from '@mui/material'; import { Box, Container, Paper } from "@mui/material";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from "@mui/material";
import { darken } from '@mui/material/styles'; import { darken } from "@mui/material/styles";
import { Header } from 'components/layout/Header'; import { Header } from "components/layout/Header";
import { Scrollable } from 'components/Scrollable'; import { Scrollable } from "components/Scrollable";
import { Footer } from 'components/layout/Footer'; import { Footer } from "components/layout/Footer";
import { Snack, SetSnackType } from 'components/Snack'; import { Snack, SetSnackType } from "components/Snack";
import { User } from 'types/types'; import { User } from "types/types";
import { LoadingComponent } from "components/LoadingComponent"; import { LoadingComponent } from "components/LoadingComponent";
import { AuthProvider, useAuth, ProtectedRoute } from 'hooks/AuthContext'; import { AuthProvider, useAuth, ProtectedRoute } from "hooks/AuthContext";
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext'; import { useAppState, useSelectedCandidate } from "hooks/GlobalContext";
import { import { getMainNavigationItems, getAllRoutes } from "config/navigationConfig";
getMainNavigationItems, import { NavigationItem } from "types/navigation";
getAllRoutes,
} from 'config/navigationConfig';
import { NavigationItem } from 'types/navigation';
// Legacy type for backward compatibility // Legacy type for backward compatibility
export type NavigationLinkType = { export type NavigationLinkType = {
@ -42,17 +39,20 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps) => {
flexGrow: 1, flexGrow: 1,
p: "0 !important", p: "0 !important",
m: "0 auto !important", m: "0 auto !important",
maxWidth: '1024px', maxWidth: "1024px",
height: "100%", height: "100%",
minHeight: 0, minHeight: 0,
...sx ...sx,
}}> }}
<Box sx={{ >
<Box
sx={{
display: "flex", display: "flex",
p: { xs: 0, sm: 0.5 }, p: { xs: 0, sm: 0.5 },
flexGrow: 1, flexGrow: 1,
minHeight: "min-content", minHeight: "min-content",
}}> }}
>
<Paper <Paper
elevation={2} elevation={2}
sx={{ sx={{
@ -61,11 +61,12 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps) => {
m: 0, m: 0,
p: 0.5, p: 0.5,
minHeight: "min-content", minHeight: "min-content",
backgroundColor: 'background.paper', backgroundColor: "background.paper",
borderRadius: 0.5, borderRadius: 0.5,
maxWidth: '100%', maxWidth: "100%",
flexDirection: "column", flexDirection: "column",
}}> }}
>
{children} {children}
</Paper> </Paper>
</Box> </Box>
@ -78,8 +79,10 @@ interface BackstoryLayoutProps {
chatRef: React.Ref<any>; chatRef: React.Ref<any>;
} }
const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutProps) => { const BackstoryLayout: React.FC<BackstoryLayoutProps> = (
const { page, chatRef, } = props; props: BackstoryLayoutProps
) => {
const { page, chatRef } = props;
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -88,7 +91,9 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
useEffect(() => { useEffect(() => {
const userType = user?.userType || null; const userType = user?.userType || null;
setNavigationItems(getMainNavigationItems(userType, user?.isAdmin ? true : false)); setNavigationItems(
getMainNavigationItems(userType, user?.isAdmin ? true : false)
);
}, [user]); }, [user]);
// Generate dynamic routes from navigation config // Generate dynamic routes from navigation config
@ -99,14 +104,18 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
const isAdmin = user?.isAdmin ? true : false; const isAdmin = user?.isAdmin ? true : false;
const routes = getAllRoutes(userType, isAdmin); const routes = getAllRoutes(userType, isAdmin);
return routes.map((route, index) => { return routes
.map((route, index) => {
if (!route.path || !route.component) return null; if (!route.path || !route.component) return null;
// Clone the component and pass necessary props if it's a page component // Clone the component and pass necessary props if it's a page component
const componentWithProps = React.cloneElement(route.component as ReactElement, { const componentWithProps = React.cloneElement(
...(route.id === 'chat' && { ref: chatRef }), route.component as ReactElement,
{
...(route.id === "chat" && { ref: chatRef }),
...(route.component.props || {}), ...(route.component.props || {}),
}); }
);
return ( return (
<Route <Route
@ -115,22 +124,26 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
element={componentWithProps} element={componentWithProps}
/> />
); );
}).filter(Boolean); })
.filter(Boolean);
}; };
return ( return (
<Box sx={{ <Box
sx={{
height: "100%", height: "100%",
maxHeight: "100%", maxHeight: "100%",
minHeight: "100%", minHeight: "100%",
flexDirection: "column" flexDirection: "column",
}}> }}
>
<Header <Header
currentPath={page} currentPath={page}
navigate={navigate} navigate={navigate}
navigationItems={navigationItems} navigationItems={navigationItems}
/> />
<Box sx={{ <Box
sx={{
display: "flex", display: "flex",
width: "100%", width: "100%",
maxHeight: "100%", maxHeight: "100%",
@ -139,17 +152,19 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
m: 0, m: 0,
p: 0, p: 0,
flexDirection: "column", flexDirection: "column",
backgroundColor: "#D3CDBF", /* Warm Gray */ backgroundColor: "#D3CDBF" /* Warm Gray */,
}}> }}
>
<Scrollable <Scrollable
className="BackstoryPageScrollable" className="BackstoryPageScrollable"
sx={{ sx={{
m: 0, m: 0,
p: 0, p: 0,
mt: "72px", /* Needs to be kept in sync with the height of Header */ mt: "72px" /* Needs to be kept in sync with the height of Header */,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
backgroundColor: (theme) => darken(theme.palette.background.default, 0.4), backgroundColor: (theme) =>
darken(theme.palette.background.default, 0.4),
height: "100%", height: "100%",
maxHeight: "100%", maxHeight: "100%",
minHeight: "100%", minHeight: "100%",
@ -170,9 +185,7 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
{(guest || user) && ( {(guest || user) && (
<> <>
<Outlet /> <Outlet />
<Routes> <Routes>{generateRoutes()}</Routes>
{generateRoutes()}
</Routes>
</> </>
)} )}
{location.pathname === "/" && <Footer />} {location.pathname === "/" && <Footer />}
@ -183,6 +196,4 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
); );
}; };
export { export { BackstoryLayout };
BackstoryLayout
};

View File

@ -2,11 +2,11 @@
import React, { Ref, ReactNode } from "react"; import React, { Ref, ReactNode } from "react";
import { Route } from "react-router-dom"; import { Route } from "react-router-dom";
import { BackstoryPageProps } from '../BackstoryTab'; import { BackstoryPageProps } from "../BackstoryTab";
import { ConversationHandle } from '../Conversation'; import { ConversationHandle } from "../Conversation";
import { User } from 'types/types'; import { User } from "types/types";
import { getAllRoutes } from 'config/navigationConfig'; import { getAllRoutes } from "config/navigationConfig";
import { NavigationItem } from 'types/navigation'; import { NavigationItem } from "types/navigation";
import { useAppState } from "hooks/GlobalContext"; import { useAppState } from "hooks/GlobalContext";
interface BackstoryDynamicRoutesProps extends BackstoryPageProps { interface BackstoryDynamicRoutesProps extends BackstoryPageProps {
@ -14,7 +14,9 @@ interface BackstoryDynamicRoutesProps extends BackstoryPageProps {
user?: User | null; user?: User | null;
} }
const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNode => { const getBackstoryDynamicRoutes = (
props: BackstoryDynamicRoutesProps
): ReactNode => {
const { user, chatRef } = props; const { user, chatRef } = props;
const userType = user?.userType || null; const userType = user?.userType || null;
const isAdmin = user?.isAdmin ? true : false; const isAdmin = user?.isAdmin ? true : false;
@ -22,16 +24,20 @@ const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNod
// Get all routes from navigation config // Get all routes from navigation config
const routes = getAllRoutes(userType, isAdmin); const routes = getAllRoutes(userType, isAdmin);
return routes.map((route: NavigationItem, index: number) => { return routes
.map((route: NavigationItem, index: number) => {
if (!route.path || !route.component) return null; if (!route.path || !route.component) return null;
// Clone the component and pass necessary props // Clone the component and pass necessary props
const componentWithProps = React.cloneElement(route.component as React.ReactElement, { const componentWithProps = React.cloneElement(
route.component as React.ReactElement,
{
// Special handling for chat component ref // Special handling for chat component ref
...(route.id === 'chat' && { ref: chatRef }), ...(route.id === "chat" && { ref: chatRef }),
// Preserve any existing props // Preserve any existing props
...(route.component.props || {}), ...(route.component.props || {}),
}); }
);
return ( return (
<Route <Route
@ -40,7 +46,8 @@ const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNod
element={componentWithProps} element={componentWithProps}
/> />
); );
}).filter(Boolean); })
.filter(Boolean);
}; };
export { getBackstoryDynamicRoutes }; export { getBackstoryDynamicRoutes };

View File

@ -1,4 +1,4 @@
import React from 'react'; import React from "react";
import { import {
Paper, Paper,
Box, Box,
@ -10,8 +10,8 @@ import {
IconButton, IconButton,
Stack, Stack,
useMediaQuery, useMediaQuery,
} from '@mui/material'; } from "@mui/material";
import { styled, useTheme } from '@mui/material/styles'; import { styled, useTheme } from "@mui/material/styles";
import { import {
Facebook, Facebook,
Twitter, Twitter,
@ -21,7 +21,7 @@ import {
Email, Email,
LocationOn, LocationOn,
Copyright, Copyright,
} from '@mui/icons-material'; } from "@mui/icons-material";
// Styled components // Styled components
const FooterContainer = styled(Paper)(({ theme }) => ({ const FooterContainer = styled(Paper)(({ theme }) => ({
@ -34,12 +34,12 @@ const FooterContainer = styled(Paper)(({ theme }) => ({
const FooterLink = styled(Link)(({ theme }) => ({ const FooterLink = styled(Link)(({ theme }) => ({
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
textDecoration: 'none', textDecoration: "none",
'&:hover': { "&:hover": {
textDecoration: 'underline', textDecoration: "underline",
color: theme.palette.action.active, color: theme.palette.action.active,
}, },
display: 'block', display: "block",
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
})); }));
@ -50,15 +50,15 @@ const FooterHeading = styled(Typography)(({ theme }) => ({
})); }));
const ContactItem = styled(Box)(({ theme }) => ({ const ContactItem = styled(Box)(({ theme }) => ({
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
marginBottom: theme.spacing(1.5), marginBottom: theme.spacing(1.5),
})); }));
// Footer component // Footer component
const Footer = () => { const Footer = () => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
return ( return (
@ -73,14 +73,15 @@ const Footer = () => {
component="div" component="div"
sx={{ sx={{
fontWeight: 700, fontWeight: 700,
letterSpacing: '.2rem', letterSpacing: ".2rem",
marginBottom: 2, marginBottom: 2,
}} }}
> >
BACKSTORY BACKSTORY
</Typography> </Typography>
<Typography variant="body2" sx={{ mb: 2, color: "white" }}> <Typography variant="body2" sx={{ mb: 2, color: "white" }}>
Helping candidates share their professional journey and connect with the right employers through compelling backstories. Helping candidates share their professional journey and connect
with the right employers through compelling backstories.
</Typography> </Typography>
<Stack direction="row"> <Stack direction="row">
{/* <IconButton {/* <IconButton
@ -119,12 +120,17 @@ const Footer = () => {
sx={{ sx={{
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
mr: 1, mr: 1,
'&:hover': { "&:hover": {
backgroundColor: 'rgba(211, 205, 191, 0.1)', backgroundColor: "rgba(211, 205, 191, 0.1)",
color: theme.palette.action.active, color: theme.palette.action.active,
} },
}} }}
onClick={() => window.open('https://www.linkedin.com/in/james-ketrenos/', '_blank')} onClick={() =>
window.open(
"https://www.linkedin.com/in/james-ketrenos/",
"_blank"
)
}
> >
<LinkedIn /> <LinkedIn />
</IconButton> </IconButton>
@ -163,38 +169,47 @@ const Footer = () => {
</Grid> </Grid>
{/* Quick Links */} {/* Quick Links */}
{false && <> {false && (
<>
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1"> <FooterHeading variant="subtitle1">
For Candidates For Candidates
</FooterHeading> </FooterHeading>
<FooterLink href="/create-profile">Create Profile</FooterLink> <FooterLink href="/create-profile">Create Profile</FooterLink>
<FooterLink href="/backstory-editor">Backstory Editor</FooterLink> <FooterLink href="/backstory-editor">
Backstory Editor
</FooterLink>
<FooterLink href="/resume-builder">Resume Builder</FooterLink> <FooterLink href="/resume-builder">Resume Builder</FooterLink>
<FooterLink href="/career-resources">Career Resources</FooterLink> <FooterLink href="/career-resources">
Career Resources
</FooterLink>
<FooterLink href="/interview-tips">Interview Tips</FooterLink> <FooterLink href="/interview-tips">Interview Tips</FooterLink>
</Grid> </Grid>
</>} </>
)}
{/* Quick Links */} {/* Quick Links */}
{false && <> {false && (
<>
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1"> <FooterHeading variant="subtitle1">For Employers</FooterHeading>
For Employers
</FooterHeading>
<FooterLink href="/post-job">Post a Job</FooterLink> <FooterLink href="/post-job">Post a Job</FooterLink>
<FooterLink href="/search-candidates">Search Candidates</FooterLink> <FooterLink href="/search-candidates">
Search Candidates
</FooterLink>
<FooterLink href="/company-profile">Company Profile</FooterLink> <FooterLink href="/company-profile">Company Profile</FooterLink>
<FooterLink href="/recruiting-tools">Recruiting Tools</FooterLink> <FooterLink href="/recruiting-tools">
Recruiting Tools
</FooterLink>
<FooterLink href="/pricing-plans">Pricing Plans</FooterLink> <FooterLink href="/pricing-plans">Pricing Plans</FooterLink>
</Grid> </Grid>
</>} </>
)}
{/* Contact */} {/* Contact */}
{false && <> {false && (
<>
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1"> <FooterHeading variant="subtitle1">Company</FooterHeading>
Company
</FooterHeading>
<FooterLink href="/about-us">About Us</FooterLink> <FooterLink href="/about-us">About Us</FooterLink>
<FooterLink href="/our-team">Our Team</FooterLink> <FooterLink href="/our-team">Our Team</FooterLink>
<FooterLink href="/blog">Blog</FooterLink> <FooterLink href="/blog">Blog</FooterLink>
@ -202,12 +217,15 @@ const Footer = () => {
<FooterLink href="/careers">Careers</FooterLink> <FooterLink href="/careers">Careers</FooterLink>
<FooterLink href="/contact-us">Contact Us</FooterLink> <FooterLink href="/contact-us">Contact Us</FooterLink>
</Grid> </Grid>
</>} </>
)}
{/* Newsletter */} {/* Newsletter */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<ContactItem> <ContactItem>
<Email sx={{ mr: 1, fontSize: 20 }} /> <Email sx={{ mr: 1, fontSize: 20 }} />
<FooterLink href="mailto:james_backstory@ketrenos.com">Email</FooterLink> <FooterLink href="mailto:james_backstory@ketrenos.com">
Email
</FooterLink>
</ContactItem> </ContactItem>
{/* <ContactItem> {/* <ContactItem>
<Phone sx={{ mr: 1, fontSize: 20 }} /> <Phone sx={{ mr: 1, fontSize: 20 }} />
@ -222,7 +240,7 @@ const Footer = () => {
</Grid> </Grid>
</Grid> </Grid>
<Divider sx={{ my: 4, backgroundColor: 'rgba(211, 205, 191, 0.2)' }} /> <Divider sx={{ my: 4, backgroundColor: "rgba(211, 205, 191, 0.2)" }} />
{/* Bottom Footer */} {/* Bottom Footer */}
<Grid container spacing={2} alignItems="center"> <Grid container spacing={2} alignItems="center">
@ -235,18 +253,28 @@ const Footer = () => {
</Box> </Box>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
{false && <> {false && (
<>
<Stack <Stack
direction={isMobile ? 'column' : 'row'} direction={isMobile ? "column" : "row"}
spacing={isMobile ? 1 : 3} spacing={isMobile ? 1 : 3}
sx={{ textAlign: { xs: 'left', md: 'right' } }} sx={{ textAlign: { xs: "left", md: "right" } }}
> >
<FooterLink href="/terms" sx={{ mb: 0 }}>Terms of Service</FooterLink> <FooterLink href="/terms" sx={{ mb: 0 }}>
<FooterLink href="/privacy" sx={{ mb: 0 }}>Privacy Policy</FooterLink> Terms of Service
<FooterLink href="/accessibility" sx={{ mb: 0 }}>Accessibility</FooterLink> </FooterLink>
<FooterLink href="/sitemap" sx={{ mb: 0 }}>Sitemap</FooterLink> <FooterLink href="/privacy" sx={{ mb: 0 }}>
Privacy Policy
</FooterLink>
<FooterLink href="/accessibility" sx={{ mb: 0 }}>
Accessibility
</FooterLink>
<FooterLink href="/sitemap" sx={{ mb: 0 }}>
Sitemap
</FooterLink>
</Stack> </Stack>
</>} </>
)}
</Grid> </Grid>
</Grid> </Grid>
</Container> </Container>
@ -254,6 +282,4 @@ const Footer = () => {
); );
}; };
export { export { Footer };
Footer
};

View File

@ -1,6 +1,6 @@
// components/layout/Header.tsx // components/layout/Header.tsx
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { NavigateFunction, useLocation } from 'react-router-dom'; import { NavigateFunction, useLocation } from "react-router-dom";
import { import {
AppBar, AppBar,
Toolbar, Toolbar,
@ -27,8 +27,8 @@ import {
ListItem, ListItem,
ListItemButton, ListItemButton,
SxProps, SxProps,
} from '@mui/material'; } from "@mui/material";
import { styled, useTheme } from '@mui/material/styles'; import { styled, useTheme } from "@mui/material/styles";
import { import {
Menu as MenuIcon, Menu as MenuIcon,
Dashboard, Dashboard,
@ -38,60 +38,60 @@ import {
ExpandMore, ExpandMore,
ExpandLess, ExpandLess,
KeyboardArrowDown, KeyboardArrowDown,
} from '@mui/icons-material'; } from "@mui/icons-material";
import FaceRetouchingNaturalIcon from '@mui/icons-material/FaceRetouchingNatural'; import FaceRetouchingNaturalIcon from "@mui/icons-material/FaceRetouchingNatural";
import { getUserMenuItemsByGroup } from 'config/navigationConfig'; import { getUserMenuItemsByGroup } from "config/navigationConfig";
import { NavigationItem } from 'types/navigation'; import { NavigationItem } from "types/navigation";
import { Beta } from 'components/ui/Beta'; import { Beta } from "components/ui/Beta";
import { Candidate, Employer } from 'types/types'; import { Candidate, Employer } from "types/types";
import { SetSnackType } from 'components/Snack'; import { SetSnackType } from "components/Snack";
import { CopyBubble } from 'components/CopyBubble'; import { CopyBubble } from "components/CopyBubble";
import 'components/layout/Header.css'; import "components/layout/Header.css";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
// Styled components // Styled components
const StyledAppBar = styled(AppBar, { const StyledAppBar = styled(AppBar, {
shouldForwardProp: (prop) => prop !== 'transparent', shouldForwardProp: (prop) => prop !== "transparent",
})<{ transparent?: boolean }>(({ theme, transparent }) => ({ })<{ transparent?: boolean }>(({ theme, transparent }) => ({
backgroundColor: transparent ? 'transparent' : theme.palette.primary.main, backgroundColor: transparent ? "transparent" : theme.palette.primary.main,
boxShadow: transparent ? 'none' : '', boxShadow: transparent ? "none" : "",
transition: 'background-color 0.3s ease', transition: "background-color 0.3s ease",
borderRadius: 0, borderRadius: 0,
padding: 0, padding: 0,
})); }));
const NavLinksContainer = styled(Box)(({ theme }) => ({ const NavLinksContainer = styled(Box)(({ theme }) => ({
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
flex: 1, flex: 1,
[theme.breakpoints.down('md')]: { [theme.breakpoints.down("md")]: {
display: 'none', display: "none",
}, },
})); }));
const UserActionsContainer = styled(Box)(({ theme }) => ({ const UserActionsContainer = styled(Box)(({ theme }) => ({
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
gap: theme.spacing(1), gap: theme.spacing(1),
})); }));
const UserButton = styled(Button)(({ theme }) => ({ const UserButton = styled(Button)(({ theme }) => ({
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
textTransform: 'none', textTransform: "none",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
gap: theme.spacing(1), gap: theme.spacing(1),
padding: theme.spacing(0.5, 1.5), padding: theme.spacing(0.5, 1.5),
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
'&:hover': { "&:hover": {
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
}, },
})); }));
const MobileDrawer = styled(Drawer)(({ theme }) => ({ const MobileDrawer = styled(Drawer)(({ theme }) => ({
'& .MuiDrawer-paper': { "& .MuiDrawer-paper": {
width: 320, width: 320,
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
}, },
@ -99,9 +99,9 @@ const MobileDrawer = styled(Drawer)(({ theme }) => ({
const DropdownButton = styled(Button)(({ theme }) => ({ const DropdownButton = styled(Button)(({ theme }) => ({
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
textTransform: 'none', textTransform: "none",
minHeight: 48, minHeight: 48,
'&:hover': { "&:hover": {
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
}, },
})); }));
@ -110,7 +110,7 @@ const UserMenuContainer = styled(Paper)(({ theme }) => ({
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[8], boxShadow: theme.shadows[8],
overflow: 'hidden', overflow: "hidden",
minWidth: 200, minWidth: 200,
})); }));
@ -136,23 +136,32 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
const theme = useTheme(); const theme = useTheme();
const location = useLocation(); const location = useLocation();
const name = (user?.firstName || user?.email || ''); const name = user?.firstName || user?.email || "";
// State for desktop dropdown menus // State for desktop dropdown menus
const [dropdownAnchors, setDropdownAnchors] = useState<{ [key: string]: HTMLElement | null }>({}); const [dropdownAnchors, setDropdownAnchors] = useState<{
[key: string]: HTMLElement | null;
}>({});
// State for mobile drawer // State for mobile drawer
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [mobileExpanded, setMobileExpanded] = useState<{ [key: string]: boolean }>({}); const [mobileExpanded, setMobileExpanded] = useState<{
[key: string]: boolean;
}>({});
// State for user menu // State for user menu
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null); const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(
null
);
const userMenuOpen = Boolean(userMenuAnchor); const userMenuOpen = Boolean(userMenuAnchor);
const isAdmin = user?.isAdmin || false; const isAdmin = user?.isAdmin || false;
// Get user menu items from navigation config // Get user menu items from navigation config
const userMenuGroups = getUserMenuItemsByGroup(user?.userType || null, isAdmin); const userMenuGroups = getUserMenuItemsByGroup(
user?.userType || null,
isAdmin
);
// Create user menu items array with proper actions // Create user menu items array with proper actions
const createUserMenuItems = () => { const createUserMenuItems = () => {
@ -165,14 +174,14 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
}> = []; }> = [];
// Add profile group items // Add profile group items
userMenuGroups.profile.forEach(item => { userMenuGroups.profile.forEach((item) => {
if (!item.divider) { if (!item.divider) {
items.push({ items.push({
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')), action: () => item.path && navigate(item.path.replace(/:.*$/, "")),
group: 'profile' group: "profile",
}); });
} }
}); });
@ -180,23 +189,23 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Add divider if we have items before system group // Add divider if we have items before system group
if (items.length > 0 && userMenuGroups.system.length > 0) { if (items.length > 0 && userMenuGroups.system.length > 0) {
items.push({ items.push({
id: 'divider', id: "divider",
label: '', label: "",
icon: null, icon: null,
action: () => {}, action: () => {},
group: 'divider' group: "divider",
}); });
} }
// Add account group items // Add account group items
userMenuGroups.account.forEach(item => { userMenuGroups.account.forEach((item) => {
if (!item.divider) { if (!item.divider) {
items.push({ items.push({
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')), action: () => item.path && navigate(item.path.replace(/:.*$/, "")),
group: 'account' group: "account",
}); });
} }
}); });
@ -204,23 +213,23 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Add divider if we have items before system group // Add divider if we have items before system group
if (items.length > 0 && userMenuGroups.system.length > 0) { if (items.length > 0 && userMenuGroups.system.length > 0) {
items.push({ items.push({
id: 'divider', id: "divider",
label: '', label: "",
icon: null, icon: null,
action: () => {}, action: () => {},
group: 'divider' group: "divider",
}); });
} }
// Add admin group items // Add admin group items
userMenuGroups.admin.forEach(item => { userMenuGroups.admin.forEach((item) => {
if (!item.divider) { if (!item.divider) {
items.push({ items.push({
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')), action: () => item.path && navigate(item.path.replace(/:.*$/, "")),
group: 'admin' group: "admin",
}); });
} }
}); });
@ -228,47 +237,47 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Add divider if we have items before system group // Add divider if we have items before system group
if (items.length > 0 && userMenuGroups.admin.length > 0) { if (items.length > 0 && userMenuGroups.admin.length > 0) {
items.push({ items.push({
id: 'divider', id: "divider",
label: '', label: "",
icon: null, icon: null,
action: () => {}, action: () => {},
group: 'divider' group: "divider",
}); });
} }
// Add system group items with special handling for logout // Add system group items with special handling for logout
userMenuGroups.system.forEach(item => { userMenuGroups.system.forEach((item) => {
if (item.id === 'logout') { if (item.id === "logout") {
items.push({ items.push({
id: 'logout', id: "logout",
label: 'Logout', label: "Logout",
icon: <Logout fontSize="small" />, icon: <Logout fontSize="small" />,
action: () => { action: () => {
logout(); logout();
navigate('/'); navigate("/");
}, },
group: 'system' group: "system",
}); });
} else if (!item.divider) { } else if (!item.divider) {
items.push({ items.push({
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')), action: () => item.path && navigate(item.path.replace(/:.*$/, "")),
group: 'system' group: "system",
}); });
} }
}); });
// Add other group items // Add other group items
userMenuGroups.other.forEach(item => { userMenuGroups.other.forEach((item) => {
if (!item.divider) { if (!item.divider) {
items.push({ items.push({
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')), action: () => item.path && navigate(item.path.replace(/:.*$/, "")),
group: 'other' group: "other",
}); });
} }
}); });
@ -290,21 +299,26 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Helper function to check if any child is current path // Helper function to check if any child is current path
const hasActiveChild = (item: NavigationItem): boolean => { const hasActiveChild = (item: NavigationItem): boolean => {
if (!item.children) return false; if (!item.children) return false;
return item.children.some(child => isCurrentPath(child) || hasActiveChild(child)); return item.children.some(
(child) => isCurrentPath(child) || hasActiveChild(child)
);
}; };
// Desktop dropdown handlers // Desktop dropdown handlers
const handleDropdownOpen = (event: React.MouseEvent<HTMLElement>, itemId: string) => { const handleDropdownOpen = (
setDropdownAnchors(prev => ({ ...prev, [itemId]: event.currentTarget })); event: React.MouseEvent<HTMLElement>,
itemId: string
) => {
setDropdownAnchors((prev) => ({ ...prev, [itemId]: event.currentTarget }));
}; };
const handleDropdownClose = (itemId: string) => { const handleDropdownClose = (itemId: string) => {
setDropdownAnchors(prev => ({ ...prev, [itemId]: null })); setDropdownAnchors((prev) => ({ ...prev, [itemId]: null }));
}; };
// Mobile accordion handlers // Mobile accordion handlers
const handleMobileToggle = (itemId: string) => { const handleMobileToggle = (itemId: string) => {
setMobileExpanded(prev => ({ ...prev, [itemId]: !prev[itemId] })); setMobileExpanded((prev) => ({ ...prev, [itemId]: !prev[itemId] }));
}; };
const handleDrawerToggle = () => { const handleDrawerToggle = () => {
@ -319,8 +333,14 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
setUserMenuAnchor(null); setUserMenuAnchor(null);
}; };
const handleUserMenuAction = (item: { id: string; label: string; icon: React.ReactElement | null; action: () => void; group?: string }) => { const handleUserMenuAction = (item: {
if (item.group !== 'divider') { id: string;
label: string;
icon: React.ReactElement | null;
action: () => void;
group?: string;
}) => {
if (item.group !== "divider") {
item.action(); item.action();
handleUserMenuClose(); handleUserMenuClose();
} }
@ -328,7 +348,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Navigation handlers // Navigation handlers
const handleNavigate = (path: string) => { const handleNavigate = (path: string) => {
navigate(path.replace(/:.*$/, '')); navigate(path.replace(/:.*$/, ""));
setMobileOpen(false); setMobileOpen(false);
// Close all dropdowns // Close all dropdowns
setDropdownAnchors({}); setDropdownAnchors({});
@ -337,42 +357,64 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Render desktop navigation with dropdowns // Render desktop navigation with dropdowns
const renderDesktopNavigation = () => { const renderDesktopNavigation = () => {
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', justifyContent: 'space-between' }}> <Box
sx={{
display: "flex",
alignItems: "center",
width: "100%",
justifyContent: "space-between",
}}
>
{navigationItems.map((item, index) => { {navigationItems.map((item, index) => {
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const isActive = isCurrentPath(item) || hasActiveChild(item); const isActive = isCurrentPath(item) || hasActiveChild(item);
if (hasChildren) { if (hasChildren) {
return ( return (
<Box key={item.id} sx={{ <Box
mr: (index === 0 || index === navigationItems.length - 1) ? "auto" : "unset", key={item.id}
}}> sx={{
mr:
index === 0 || index === navigationItems.length - 1
? "auto"
: "unset",
}}
>
<DropdownButton <DropdownButton
onClick={(e) => handleDropdownOpen(e, item.id)} onClick={(e) => handleDropdownOpen(e, item.id)}
endIcon={<KeyboardArrowDown />} endIcon={<KeyboardArrowDown />}
sx={{ sx={{
backgroundColor: isActive ? 'action.selected' : 'transparent', backgroundColor: isActive
color: isActive ? 'secondary.main' : 'primary.contrastText', ? "action.selected"
: "transparent",
color: isActive ? "secondary.main" : "primary.contrastText",
}} }}
> >
{item.icon && <Box sx={{ mr: 1, display: 'flex' }}>{item.icon}</Box>} {item.icon && (
<Box sx={{ mr: 1, display: "flex" }}>{item.icon}</Box>
)}
{item.label} {item.label}
</DropdownButton> </DropdownButton>
<Menu <Menu
anchorEl={dropdownAnchors[item.id]} anchorEl={dropdownAnchors[item.id]}
open={Boolean(dropdownAnchors[item.id])} open={Boolean(dropdownAnchors[item.id])}
onClose={() => handleDropdownClose(item.id)} onClose={() => handleDropdownClose(item.id)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: "top", horizontal: "left" }}
TransitionComponent={Fade} TransitionComponent={Fade}
> >
{item.children?.map(child => ( {item.children?.map((child) => (
<MenuItem <MenuItem
key={child.id} key={child.id}
onClick={() => child.path && handleNavigate(child.path)} onClick={() => child.path && handleNavigate(child.path)}
selected={isCurrentPath(child)} selected={isCurrentPath(child)}
disabled={!child.path} disabled={!child.path}
sx={{ display: 'flex', alignItems: 'center', "& *": { m: 0, p: 0 }, m: 0 }} sx={{
display: "flex",
alignItems: "center",
"& *": { m: 0, p: 0 },
m: 0,
}}
> >
{child.icon && <ListItemIcon>{child.icon}</ListItemIcon>} {child.icon && <ListItemIcon>{child.icon}</ListItemIcon>}
<ListItemText>{child.label}</ListItemText> <ListItemText>{child.label}</ListItemText>
@ -387,12 +429,17 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
key={item.id} key={item.id}
onClick={() => item.path && handleNavigate(item.path)} onClick={() => item.path && handleNavigate(item.path)}
sx={{ sx={{
backgroundColor: isActive ? 'action.selected' : 'transparent', backgroundColor: isActive ? "action.selected" : "transparent",
color: isActive ? 'secondary.main' : 'primary.contrastText', color: isActive ? "secondary.main" : "primary.contrastText",
mr: (index === 0 || index === navigationItems.length - 1) ? "auto" : "unset", mr:
index === 0 || index === navigationItems.length - 1
? "auto"
: "unset",
}} }}
> >
{item.icon && <Box sx={{ mr: 1, display: 'flex' }}>{item.icon}</Box>} {item.icon && (
<Box sx={{ mr: 1, display: "flex" }}>{item.icon}</Box>
)}
{item.label} {item.label}
</DropdownButton> </DropdownButton>
); );
@ -404,7 +451,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Render mobile accordion navigation // Render mobile accordion navigation
const renderMobileNavigation = () => { const renderMobileNavigation = () => {
const renderNavigationItem = (item: NavigationItem, depth: number = 0) => { const renderNavigationItem = (item: NavigationItem, depth = 0) => {
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const isActive = isCurrentPath(item) || hasActiveChild(item); const isActive = isCurrentPath(item) || hasActiveChild(item);
const isExpanded = mobileExpanded[item.id]; const isExpanded = mobileExpanded[item.id];
@ -422,28 +469,26 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
}} }}
selected={isActive} selected={isActive}
sx={{ sx={{
backgroundColor: isActive ? 'action.selected' : 'transparent', backgroundColor: isActive ? "action.selected" : "transparent",
'&.Mui-selected': { "&.Mui-selected": {
backgroundColor: 'primary.main', backgroundColor: "primary.main",
color: 'primary.contrastText', color: "primary.contrastText",
'& .MuiListItemIcon-root': { "& .MuiListItemIcon-root": {
color: 'primary.contrastText', color: "primary.contrastText",
}, },
}, },
}} }}
> >
{item.icon && ( {item.icon && (
<ListItemIcon sx={{ minWidth: 36 }}> <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>
{item.icon}
</ListItemIcon>
)} )}
<ListItemText <ListItemText
primary={item.label} primary={item.label}
sx={{ sx={{
'& .MuiTypography-root': { "& .MuiTypography-root": {
fontSize: depth > 0 ? '0.875rem' : '1rem', fontSize: depth > 0 ? "0.875rem" : "1rem",
fontWeight: depth === 0 ? 500 : 400, fontWeight: depth === 0 ? 500 : 400,
} },
}} }}
/> />
{hasChildren && ( {hasChildren && (
@ -456,7 +501,9 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
{hasChildren && ( {hasChildren && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit> <Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List disablePadding> <List disablePadding>
{item.children?.map(child => renderNavigationItem(child, depth + 1))} {item.children?.map((child) =>
renderNavigationItem(child, depth + 1)
)}
</List> </List>
</Collapse> </Collapse>
)} )}
@ -470,7 +517,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
<Divider sx={{ my: 1 }} /> <Divider sx={{ my: 1 }} />
{!user && ( {!user && (
<ListItem disablePadding> <ListItem disablePadding>
<ListItemButton onClick={() => handleNavigate('/login')}> <ListItemButton onClick={() => handleNavigate("/login")}>
<ListItemText primary="Login" /> <ListItemText primary="Login" />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
@ -484,18 +531,22 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
return ( return (
<UserMenuContainer> <UserMenuContainer>
<List dense> <List dense>
{userMenuItems.map((item, index) => ( {userMenuItems.map((item, index) =>
item.group === 'divider' ? ( item.group === "divider" ? (
<Divider key={`divider-${index}`} /> <Divider key={`divider-${index}`} />
) : ( ) : (
<ListItem key={item.id}> <ListItem key={item.id}>
<ListItemButton onClick={() => handleUserMenuAction(item)}> <ListItemButton onClick={() => handleUserMenuAction(item)}>
{item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>} {item.icon && (
<ListItemIcon sx={{ minWidth: 36 }}>
{item.icon}
</ListItemIcon>
)}
<ListItemText primary={item.label} /> <ListItemText primary={item.label} />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
) )
))} )}
</List> </List>
</UserMenuContainer> </UserMenuContainer>
); );
@ -510,7 +561,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
variant="contained" variant="contained"
onClick={() => navigate("/login")} onClick={() => navigate("/login")}
sx={{ sx={{
display: { xs: 'none', sm: 'block' }, display: { xs: "none", sm: "block" },
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
}} }}
> >
@ -523,20 +574,20 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
<> <>
<UserButton <UserButton
onClick={handleUserMenuOpen} onClick={handleUserMenuOpen}
aria-controls={userMenuOpen ? 'user-menu' : undefined} aria-controls={userMenuOpen ? "user-menu" : undefined}
aria-haspopup="true" aria-haspopup="true"
aria-expanded={userMenuOpen ? 'true' : undefined} aria-expanded={userMenuOpen ? "true" : undefined}
> >
<Avatar sx={{ <Avatar
sx={{
width: 32, width: 32,
height: 32, height: 32,
bgcolor: theme.palette.secondary.main, bgcolor: theme.palette.secondary.main,
}}> }}
>
{name.charAt(0).toUpperCase()} {name.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}> <Box sx={{ display: { xs: "none", sm: "block" } }}>{name}</Box>
{name}
</Box>
<ExpandMore fontSize="small" /> <ExpandMore fontSize="small" />
</UserButton> </UserButton>
@ -546,12 +597,12 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
anchorEl={userMenuAnchor} anchorEl={userMenuAnchor}
onClose={handleUserMenuClose} onClose={handleUserMenuClose}
anchorOrigin={{ anchorOrigin={{
vertical: 'bottom', vertical: "bottom",
horizontal: 'right', horizontal: "right",
}} }}
transformOrigin={{ transformOrigin={{
vertical: 'top', vertical: "top",
horizontal: 'right', horizontal: "right",
}} }}
TransitionComponent={Fade} TransitionComponent={Fade}
> >
@ -571,9 +622,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
<Container maxWidth="xl"> <Container maxWidth="xl">
<Toolbar disableGutters> <Toolbar disableGutters>
{/* Navigation Links - Desktop */} {/* Navigation Links - Desktop */}
<NavLinksContainer> <NavLinksContainer>{renderDesktopNavigation()}</NavLinksContainer>
{renderDesktopNavigation()}
</NavLinksContainer>
{/* User Actions Section */} {/* User Actions Section */}
<UserActionsContainer> <UserActionsContainer>
@ -586,7 +635,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
aria-label="open drawer" aria-label="open drawer"
edge="end" edge="end"
onClick={handleDrawerToggle} onClick={handleDrawerToggle}
sx={{ display: { md: 'none' } }} sx={{ display: { md: "none" } }}
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
@ -602,8 +651,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
width: 36, width: 36,
height: 36, height: 36,
opacity: 1, opacity: 1,
bgcolor: 'inherit', bgcolor: "inherit",
'&:hover': { bgcolor: 'action.hover', opacity: 1 }, "&:hover": { bgcolor: "action.hover", opacity: 1 },
}} }}
content={`${window.location.origin}${window.location.pathname}?id=${sessionId}`} content={`${window.location.origin}${window.location.pathname}?id=${sessionId}`}
onClick={() => { onClick={() => {
@ -631,7 +680,9 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
</Container> </Container>
<Beta <Beta
sx={{ left: "-90px", "& .mobile": { right: "-72px" } }} sx={{ left: "-90px", "& .mobile": { right: "-72px" } }}
onClick={() => { navigate('/docs/beta'); }} onClick={() => {
navigate("/docs/beta");
}}
/> />
</StyledAppBar> </StyledAppBar>
); );

View File

@ -1,33 +1,31 @@
import React, { useRef } from 'react'; import React, { useRef } from "react";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import { SxProps } from '@mui/material/styles'; import { SxProps } from "@mui/material/styles";
import './AIBanner.css'; import "./AIBanner.css";
import { useMediaQuery, useTheme } from '@mui/material'; import { useMediaQuery, useTheme } from "@mui/material";
type AIBannerProps = { type AIBannerProps = {
sx?: SxProps; sx?: SxProps;
variant?: "minimal" | "small" | "normal" | undefined; variant?: "minimal" | "small" | "normal" | undefined;
} };
const AIBanner: React.FC<AIBannerProps> = (props: AIBannerProps) => { const AIBanner: React.FC<AIBannerProps> = (props: AIBannerProps) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const { const { sx = {}, variant = isMobile ? "small" : "normal" } = props;
sx = {},
variant = isMobile ? "small" : "normal",
} = props;
const aibannerRef = useRef<HTMLElement | null>(null); const aibannerRef = useRef<HTMLElement | null>(null);
return ( return (
<Box sx={sx} className='aibanner-clipper'> <Box sx={sx} className="aibanner-clipper">
<Box ref={aibannerRef} className={` aibanner-label-${variant} aibanner-label`}> <Box
ref={aibannerRef}
className={` aibanner-label-${variant} aibanner-label`}
>
<Box>AI Generated</Box> <Box>AI Generated</Box>
</Box> </Box>
</Box> </Box>
); );
}; };
export { export { AIBanner };
AIBanner
};

View File

@ -1,24 +1,22 @@
import React from 'react'; import React from "react";
import { import { Typography, Avatar } from "@mui/material";
Typography, import { useTheme } from "@mui/material/styles";
Avatar,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import 'components/layout/Header.css'; import "components/layout/Header.css";
const BackstoryLogo = () => { const BackstoryLogo = () => {
const theme = useTheme(); const theme = useTheme();
return <Typography return (
<Typography
variant="h6" variant="h6"
className="BackstoryLogo" className="BackstoryLogo"
noWrap noWrap
sx={{ sx={{
cursor: "pointer", cursor: "pointer",
fontWeight: 700, fontWeight: 700,
letterSpacing: '.2rem', letterSpacing: ".2rem",
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
textDecoration: 'none', textDecoration: "none",
display: "inline-flex", display: "inline-flex",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@ -27,13 +25,15 @@ const BackstoryLogo = () => {
textTransform: "uppercase", textTransform: "uppercase",
}} }}
> >
<Avatar sx={{ width: 24, height: 24 }} <Avatar
sx={{ width: 24, height: 24 }}
variant="rounded" variant="rounded"
alt="Backstory logo" alt="Backstory logo"
src="/logo192.png" /> src="/logo192.png"
/>
Backstory Backstory
</Typography> </Typography>
);
}; };
export { BackstoryLogo }; export { BackstoryLogo };

View File

@ -1,21 +1,21 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from "react";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from "@mui/material/useMediaQuery";
import { SxProps, useTheme } from '@mui/material/styles'; import { SxProps, useTheme } from "@mui/material/styles";
import './Beta.css'; import "./Beta.css";
type BetaProps = { type BetaProps = {
adaptive?: boolean; adaptive?: boolean;
onClick?: (event?: React.MouseEvent<HTMLElement>) => void; onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
sx?: SxProps; sx?: SxProps;
} };
const Beta: React.FC<BetaProps> = (props: BetaProps) => { const Beta: React.FC<BetaProps> = (props: BetaProps) => {
const { onClick, adaptive = true, sx = {} } = props; const { onClick, adaptive = true, sx = {} } = props;
const betaRef = useRef<HTMLElement | null>(null); const betaRef = useRef<HTMLElement | null>(null);
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [animationKey, setAnimationKey] = useState<number>(0); const [animationKey, setAnimationKey] = useState<number>(0);
const [firstPass, setFirstPass] = useState<boolean>(true); const [firstPass, setFirstPass] = useState<boolean>(true);
@ -31,15 +31,24 @@ const Beta: React.FC<BetaProps> = (props : BetaProps) => {
if (!betaRef.current) return; if (!betaRef.current) return;
// Increment animation key to force React to recreate the element // Increment animation key to force React to recreate the element
setAnimationKey(prevKey => prevKey + 1); setAnimationKey((prevKey) => prevKey + 1);
// Ensure the animate class is present // Ensure the animate class is present
betaRef.current.classList.add('animate'); betaRef.current.classList.add("animate");
}; };
return ( return (
<Box sx={sx} className={`beta-clipper ${adaptive && isMobile && "mobile"}`} onClick={(e) => { onClick && onClick(e); }}> <Box
<Box ref={betaRef} className={`beta-label ${adaptive && isMobile && "mobile"}`}> sx={sx}
className={`beta-clipper ${adaptive && isMobile && "mobile"}`}
onClick={(e) => {
onClick && onClick(e);
}}
>
<Box
ref={betaRef}
className={`beta-label ${adaptive && isMobile && "mobile"}`}
>
<Box key={animationKey} className="particles"></Box> <Box key={animationKey} className="particles"></Box>
<Box>BETA</Box> <Box>BETA</Box>
</Box> </Box>
@ -47,6 +56,4 @@ const Beta: React.FC<BetaProps> = (props : BetaProps) => {
); );
}; };
export { export { Beta };
Beta
};

View File

@ -1,19 +1,23 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from "react";
import { Box, Link, Typography, Avatar, Grid, SxProps, Tooltip, IconButton } from '@mui/material';
import { import {
Card, Box,
CardContent, Link,
Divider, Typography,
useTheme, Avatar,
} from '@mui/material'; Grid,
import DeleteIcon from '@mui/icons-material/Delete'; SxProps,
import { useMediaQuery } from '@mui/material'; Tooltip,
import { Candidate, CandidateAI } from 'types/types'; IconButton,
} from "@mui/material";
import { Card, CardContent, Divider, useTheme } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { useMediaQuery } from "@mui/material";
import { Candidate, CandidateAI } from "types/types";
import { CopyBubble } from "components/CopyBubble"; import { CopyBubble } from "components/CopyBubble";
import { rest } from 'lodash'; import { rest } from "lodash";
import { AIBanner } from 'components/ui/AIBanner'; import { AIBanner } from "components/ui/AIBanner";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { DeleteConfirmation } from '../DeleteConfirmation'; import { DeleteConfirmation } from "../DeleteConfirmation";
interface CandidateInfoProps { interface CandidateInfoProps {
candidate: Candidate; candidate: Candidate;
@ -21,20 +25,19 @@ interface CandidateInfoProps {
action?: string; action?: string;
elevation?: number; elevation?: number;
variant?: "minimal" | "small" | "normal" | undefined; variant?: "minimal" | "small" | "normal" | undefined;
}; }
const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps) => { const CandidateInfo: React.FC<CandidateInfoProps> = (
props: CandidateInfoProps
) => {
const { candidate } = props; const { candidate } = props;
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const { const { sx, action = "", elevation = 1, variant = "normal" } = props;
sx,
action = '',
elevation = 1,
variant = "normal"
} = props;
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')) || variant === "minimal"; const isMobile =
const ai: CandidateAI | null = ('isAI' in candidate) ? candidate as CandidateAI : null; useMediaQuery(theme.breakpoints.down("md")) || variant === "minimal";
const ai: CandidateAI | null =
"isAI" in candidate ? (candidate as CandidateAI) : null;
const isAdmin = user?.isAdmin; const isAdmin = user?.isAdmin;
// State for description expansion // State for description expansion
@ -55,7 +58,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
if (candidateId) { if (candidateId) {
await apiClient.deleteCandidate(candidateId); await apiClient.deleteCandidate(candidateId);
} }
} };
if (!candidate) { if (!candidate) {
return <Box>No user loaded.</Box>; return <Box>No user loaded.</Box>;
@ -65,15 +68,15 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
transition: 'all 0.3s ease', transition: "all 0.3s ease",
flexGrow: 1, flexGrow: 1,
p: isMobile ? 1 : 2, p: isMobile ? 1 : 2,
height: '100%', height: "100%",
flexDirection: 'column', flexDirection: "column",
alignItems: 'stretch', alignItems: "stretch",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
...sx ...sx,
}} }}
{...rest} {...rest}
> >
@ -81,50 +84,66 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
<Box sx={{ display: "flex", flexDirection: "row" }}> <Box sx={{ display: "flex", flexDirection: "row" }}>
<Avatar <Avatar
src={candidate.profileImage ? `/api/1.0/candidates/profile/${candidate.username}` : ''} src={
candidate.profileImage
? `/api/1.0/candidates/profile/${candidate.username}`
: ""
}
alt={`${candidate.fullName}'s profile`} alt={`${candidate.fullName}'s profile`}
sx={{ sx={{
alignSelf: "flex-start", alignSelf: "flex-start",
width: isMobile ? 40 : 80, width: isMobile ? 40 : 80,
height: isMobile ? 40 : 80, height: isMobile ? 40 : 80,
border: '2px solid #e0e0e0', border: "2px solid #e0e0e0",
}} }}
/> />
<Box sx={{ ml: 1 }}> <Box sx={{ ml: 1 }}>
<Box <Box
sx={{ sx={{
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'flex-start', alignItems: "flex-start",
mb: 1 mb: 1,
}}> }}
>
<Box> <Box>
<Box sx={{ <Box
sx={{
display: "flex", display: "flex",
flexDirection: isMobile ? "column" : "row", flexDirection: isMobile ? "column" : "row",
alignItems: "left", alignItems: "left",
gap: 1, "& > .MuiTypography-root": { m: 0 } gap: 1,
}}> "& > .MuiTypography-root": { m: 0 },
{ }}
action !== '' && >
{action !== "" && (
<Typography variant="body1">{action}</Typography> <Typography variant="body1">{action}</Typography>
} )}
{action === '' && {action === "" && (
<Typography variant="h5" component="h1" <Typography
variant="h5"
component="h1"
sx={{ sx={{
fontWeight: 'bold', fontWeight: "bold",
whiteSpace: 'nowrap' whiteSpace: "nowrap",
}}> }}
>
{candidate.fullName} {candidate.fullName}
</Typography> </Typography>
} )}
</Box> </Box>
<Box sx={{ fontSize: "0.75rem", alignItems: "center" }}> <Box sx={{ fontSize: "0.75rem", alignItems: "center" }}>
<Link href={`/u/${candidate.username}`}>{`/u/${candidate.username}`}</Link> <Link
href={`/u/${candidate.username}`}
>{`/u/${candidate.username}`}</Link>
<CopyBubble <CopyBubble
onClick={(event: any) => { event.stopPropagation() }} onClick={(event: any) => {
tooltip="Copy link" content={`${window.location.origin}/u/{candidate.username}`} /> event.stopPropagation();
}}
tooltip="Copy link"
content={`${window.location.origin}/u/{candidate.username}`}
/>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@ -132,18 +151,18 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
</Box> </Box>
<Box> <Box>
{(!isMobile && variant === "normal") && ( {!isMobile && variant === "normal" && (
<Box sx={{ minHeight: "5rem" }}> <Box sx={{ minHeight: "5rem" }}>
<Typography <Typography
ref={descriptionRef} ref={descriptionRef}
variant="body1" variant="body1"
color="text.secondary" color="text.secondary"
sx={{ sx={{
display: '-webkit-box', display: "-webkit-box",
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3, WebkitLineClamp: isDescriptionExpanded ? "unset" : 3,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: "vertical",
overflow: 'hidden', overflow: "hidden",
textOverflow: 'ellipsis', textOverflow: "ellipsis",
lineHeight: 1.5, lineHeight: 1.5,
fontSize: "0.8rem !important", fontSize: "0.8rem !important",
}} }}
@ -161,15 +180,15 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
}} }}
sx={{ sx={{
color: theme.palette.primary.main, color: theme.palette.primary.main,
textDecoration: 'none', textDecoration: "none",
cursor: 'pointer', cursor: "pointer",
fontSize: '0.725rem', fontSize: "0.725rem",
fontWeight: 500, fontWeight: 500,
mt: 0.5, mt: 0.5,
display: 'block', display: "block",
'&:hover': { "&:hover": {
textDecoration: 'underline', textDecoration: "underline",
} },
}} }}
> >
[{isDescriptionExpanded ? "less" : "more"}] [{isDescriptionExpanded ? "less" : "more"}]
@ -178,37 +197,50 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
</Box> </Box>
)} )}
{(variant !== "small" && variant !== "minimal") && <> {variant !== "small" && variant !== "minimal" && (
<>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
{candidate.location && {candidate.location && (
<Typography variant="body2" sx={{ mb: 1 }}> <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country} <strong>Location:</strong> {candidate.location.city},{" "}
{candidate.location.state || candidate.location.country}
</Typography> </Typography>
} )}
{candidate.email && {candidate.email && (
<Typography variant="body2" sx={{ mb: 1 }}> <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email} <strong>Email:</strong> {candidate.email}
</Typography> </Typography>
} )}
{candidate.phone && <Typography variant="body2"> {candidate.phone && (
<Typography variant="body2">
<strong>Phone:</strong> {candidate.phone} <strong>Phone:</strong> {candidate.phone}
</Typography> </Typography>
} )}
</>} </>
)}
</Box> </Box>
{isAdmin && ai && {isAdmin && ai && (
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "flex-start" }}> <Box
sx={{
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
}}
>
<Tooltip title="Delete Job"> <Tooltip title="Delete Job">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { e.stopPropagation(); deleteCandidate(candidate.id); }} onClick={(e) => {
e.stopPropagation();
deleteCandidate(candidate.id);
}}
> >
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
} )}
</Box> </Box>
); );
}; };

View File

@ -1,18 +1,18 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import { BackstoryElementProps } from 'components/BackstoryTab'; import { BackstoryElementProps } from "components/BackstoryTab";
import { CandidateInfo } from 'components/ui/CandidateInfo'; import { CandidateInfo } from "components/ui/CandidateInfo";
import { Candidate, CandidateAI } from "types/types"; import { Candidate, CandidateAI } from "types/types";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext'; import { useAppState, useSelectedCandidate } from "hooks/GlobalContext";
import { Paper } from '@mui/material'; import { Paper } from "@mui/material";
interface CandidatePickerProps extends BackstoryElementProps { interface CandidatePickerProps extends BackstoryElementProps {
onSelect?: (candidate: Candidate) => void; onSelect?: (candidate: Candidate) => void;
}; }
const CandidatePicker = (props: CandidatePickerProps) => { const CandidatePicker = (props: CandidatePickerProps) => {
const { onSelect, sx } = props; const { onSelect, sx } = props;
@ -31,8 +31,8 @@ const CandidatePicker = (props: CandidatePickerProps) => {
const results = await apiClient.getCandidates(); const results = await apiClient.getCandidates();
const candidates: Candidate[] = results.data; const candidates: Candidate[] = results.data;
candidates.sort((a, b) => { candidates.sort((a, b) => {
const aIsAi = 'isAI' in a ? 1 : 0; const aIsAi = "isAI" in a ? 1 : 0;
const bIsAi = 'isAI' in b ? 1 : 0; const bIsAi = "isAI" in b ? 1 : 0;
let result = aIsAi - bIsAi; let result = aIsAi - bIsAi;
if (result === 0) { if (result === 0) {
result = a.lastName.localeCompare(b.lastName); result = a.lastName.localeCompare(b.lastName);
@ -56,32 +56,43 @@ const CandidatePicker = (props: CandidatePickerProps) => {
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", ...sx }}> <Box sx={{ display: "flex", flexDirection: "column", ...sx }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}> <Box
{candidates?.map((u, i) => sx={{
<Paper key={`${u.username}`} display: "flex",
onClick={() => { onSelect ? onSelect(u) : setSelectedCandidate(u); }} gap: 1,
sx={{ cursor: "pointer" }}> flexWrap: "wrap",
<CandidateInfo variant="small" justifyContent: "center",
}}
>
{candidates?.map((u, i) => (
<Paper
key={`${u.username}`}
onClick={() => {
onSelect ? onSelect(u) : setSelectedCandidate(u);
}}
sx={{ cursor: "pointer" }}
>
<CandidateInfo
variant="small"
sx={{ sx={{
maxWidth: "100%", maxWidth: "100%",
minWidth: "320px", minWidth: "320px",
width: "320px", width: "320px",
"cursor": "pointer", cursor: "pointer",
backgroundColor: (selectedCandidate?.id === u.id) ? "#f0f0f0" : "inherit", backgroundColor:
selectedCandidate?.id === u.id ? "#f0f0f0" : "inherit",
border: "2px solid transparent", border: "2px solid transparent",
"&:hover": { "&:hover": {
border: "2px solid orange" border: "2px solid orange",
} },
}} }}
candidate={u} candidate={u}
/> />
</Paper> </Paper>
)} ))}
</Box> </Box>
</Box> </Box>
); );
}; };
export { export { CandidatePicker };
CandidatePicker
};

View File

@ -1,13 +1,13 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from "react";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from "@mui/material/useMediaQuery";
import { SxProps, useTheme } from '@mui/material/styles'; import { SxProps, useTheme } from "@mui/material/styles";
import './ComingSoon.css'; import "./ComingSoon.css";
type ComingSoonProps = { type ComingSoonProps = {
children?: React.ReactNode; children?: React.ReactNode;
} };
const ComingSoon: React.FC<ComingSoonProps> = (props: ComingSoonProps) => { const ComingSoon: React.FC<ComingSoonProps> = (props: ComingSoonProps) => {
const { children } = props; const { children } = props;
@ -20,6 +20,4 @@ const ComingSoon: React.FC<ComingSoonProps> = (props : ComingSoonProps) => {
); );
}; };
export { export { ComingSoon };
ComingSoon
};

View File

@ -1,52 +1,69 @@
import React, { JSX, useActionState, useEffect, useRef, useState } from 'react'; import React, { JSX, useActionState, useEffect, useRef, useState } from "react";
import { Box, Link, Typography, Avatar, Grid, SxProps, CardActions, Chip, Stack, CardHeader, Button, styled, LinearProgress, IconButton, Tooltip } from '@mui/material';
import { import {
Card, Box,
CardContent, Link,
Divider, Typography,
useTheme, Avatar,
} from '@mui/material'; Grid,
import DeleteIcon from '@mui/icons-material/Delete'; SxProps,
import { useMediaQuery } from '@mui/material'; CardActions,
import { Job } from 'types/types'; Chip,
Stack,
CardHeader,
Button,
styled,
LinearProgress,
IconButton,
Tooltip,
} from "@mui/material";
import { Card, CardContent, Divider, useTheme } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { useMediaQuery } from "@mui/material";
import { Job } from "types/types";
import { CopyBubble } from "components/CopyBubble"; import { CopyBubble } from "components/CopyBubble";
import { rest } from 'lodash'; import { rest } from "lodash";
import { AIBanner } from 'components/ui/AIBanner'; import { AIBanner } from "components/ui/AIBanner";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { DeleteConfirmation } from '../DeleteConfirmation'; import { DeleteConfirmation } from "../DeleteConfirmation";
import { Build, CheckCircle, Description, Psychology, Star, Work } from '@mui/icons-material'; import {
import ModelTrainingIcon from '@mui/icons-material/ModelTraining'; Build,
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon'; CheckCircle,
import RestoreIcon from '@mui/icons-material/Restore'; Description,
import SaveIcon from '@mui/icons-material/Save'; Psychology,
Star,
Work,
} from "@mui/icons-material";
import ModelTrainingIcon from "@mui/icons-material/ModelTraining";
import { StatusIcon, StatusBox } from "components/ui/StatusIcon";
import RestoreIcon from "@mui/icons-material/Restore";
import SaveIcon from "@mui/icons-material/Save";
import * as Types from "types/types"; import * as Types from "types/types";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
import { StyledMarkdown } from 'components/StyledMarkdown'; import { StyledMarkdown } from "components/StyledMarkdown";
interface JobInfoProps { interface JobInfoProps {
job: Job; job: Job;
sx?: SxProps; sx?: SxProps;
action?: string; action?: string;
elevation?: number; elevation?: number;
variant?: "minimal" | "small" | "normal" | "all" | null variant?: "minimal" | "small" | "normal" | "all" | null;
}; }
const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => { const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const { job } = props; const { job } = props;
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const { const { sx, action = "", elevation = 1, variant = "normal" } = props;
sx,
action = '',
elevation = 1,
variant = "normal"
} = props;
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')) || variant === "minimal"; const isMobile =
useMediaQuery(theme.breakpoints.down("md")) || variant === "minimal";
const isAdmin = user?.isAdmin; const isAdmin = user?.isAdmin;
const [adminStatus, setAdminStatus] = useState<string | null>(null); const [adminStatus, setAdminStatus] = useState<string | null>(null);
const [adminStatusType, setAdminStatusType] = useState<Types.ApiActivityType | null>(null); const [adminStatusType, setAdminStatusType] =
const [activeJob, setActiveJob] = useState<Types.Job>({ ...job }); /* Copy of job */ useState<Types.ApiActivityType | null>(null);
const [activeJob, setActiveJob] = useState<Types.Job>({
...job,
}); /* Copy of job */
// State for description expansion // State for description expansion
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false); const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
@ -72,66 +89,87 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
if (jobId) { if (jobId) {
await apiClient.deleteJob(jobId); await apiClient.deleteJob(jobId);
} }
} };
const handleReset = async () => { const handleReset = async () => {
setActiveJob({ ...job }); setActiveJob({ ...job });
} };
if (!job) { if (!job) {
return <Box>No job provided.</Box>; return <Box>No job provided.</Box>;
} }
const handleSave = async () => { const handleSave = async () => {
const newJob = await apiClient.updateJob(job.id || '', { const newJob = await apiClient.updateJob(job.id || "", {
description: activeJob.description, description: activeJob.description,
requirements: activeJob.requirements, requirements: activeJob.requirements,
}); });
job.updatedAt = newJob.updatedAt; job.updatedAt = newJob.updatedAt;
setActiveJob(newJob) setActiveJob(newJob);
setSnack('Job updated.'); setSnack("Job updated.");
} };
const handleRefresh = () => { const handleRefresh = () => {
setAdminStatus("Re-extracting Job information..."); setAdminStatus("Re-extracting Job information...");
const jobStatusHandlers = { const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus) => { onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content); console.log("status:", status.content);
setAdminStatusType(status.activity); setAdminStatusType(status.activity);
setAdminStatus(status.content); setAdminStatus(status.content);
}, },
onMessage: async (jobMessage: Types.JobRequirementsMessage) => { onMessage: async (jobMessage: Types.JobRequirementsMessage) => {
const newJob: Types.Job = jobMessage.job const newJob: Types.Job = jobMessage.job;
console.log('onMessage - job', newJob); console.log("onMessage - job", newJob);
newJob.id = job.id; newJob.id = job.id;
newJob.createdAt = job.createdAt; newJob.createdAt = job.createdAt;
const updatedJob: Types.Job = await apiClient.updateJob(job.id || '', newJob); const updatedJob: Types.Job = await apiClient.updateJob(
job.id || "",
newJob
);
setActiveJob(updatedJob); setActiveJob(updatedJob);
}, },
onError: (error: Types.ChatMessageError) => { onError: (error: Types.ChatMessageError) => {
console.log('onError', error); console.log("onError", error);
setAdminStatusType(null); setAdminStatusType(null);
setAdminStatus(null); setAdminStatus(null);
}, },
onComplete: () => { onComplete: () => {
setAdminStatusType(null); setAdminStatusType(null);
setAdminStatus(null); setAdminStatus(null);
} },
}; };
apiClient.createJobFromDescription(activeJob.description, jobStatusHandlers); apiClient.createJobFromDescription(
activeJob.description,
jobStatusHandlers
);
}; };
const renderRequirementSection = (title: string, items: string[] | undefined, icon: JSX.Element, required = false) => { const renderRequirementSection = (
title: string,
items: string[] | undefined,
icon: JSX.Element,
required = false
) => {
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}> <Box sx={{ display: "flex", alignItems: "center", mb: 1.5 }}>
{icon} {icon}
<Typography variant="subtitle1" sx={{ ml: 1, fontWeight: 600, fontSize: '0.85rem !important'}}> <Typography
variant="subtitle1"
sx={{ ml: 1, fontWeight: 600, fontSize: "0.85rem !important" }}
>
{title} {title}
</Typography> </Typography>
{required && <Chip label="Required" size="small" color="error" sx={{ ml: 1, fontSize: '0.75rem !important' }} />} {required && (
<Chip
label="Required"
size="small"
color="error"
sx={{ ml: 1, fontSize: "0.75rem !important" }}
/>
)}
</Box> </Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{items.map((item, index) => ( {items.map((item, index) => (
@ -140,7 +178,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
label={item} label={item}
variant="outlined" variant="outlined"
size="small" size="small"
sx={{ mb: 1, fontSize: '0.75rem !important' }} sx={{ mb: 1, fontSize: "0.75rem !important" }}
/> />
))} ))}
</Stack> </Stack>
@ -152,7 +190,10 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
if (!activeJob.requirements) return null; if (!activeJob.requirements) return null;
return ( return (
<Card elevation={0} sx={{ m: 0, p: 0, mt: 2, background: "transparent !important" }}> <Card
elevation={0}
sx={{ m: 0, p: 0, mt: 2, background: "transparent !important" }}
>
<CardHeader <CardHeader
title="Job Requirements Analysis" title="Job Requirements Analysis"
avatar={<CheckCircle color="success" />} avatar={<CheckCircle color="success" />}
@ -215,53 +256,89 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
borderColor: 'transparent', borderColor: "transparent",
borderWidth: 2, borderWidth: 2,
borderStyle: 'solid', borderStyle: "solid",
transition: 'all 0.3s ease', transition: "all 0.3s ease",
flexDirection: "column", flexDirection: "column",
minWidth: 0, minWidth: 0,
opacity: deleted ? 0.5 : 1.0, opacity: deleted ? 0.5 : 1.0,
backgroundColor: deleted ? theme.palette.action.disabledBackground : theme.palette.background.paper, backgroundColor: deleted
? theme.palette.action.disabledBackground
: theme.palette.background.paper,
pointerEvents: deleted ? "none" : "auto", pointerEvents: deleted ? "none" : "auto",
...sx, ...sx,
}} }}
{...rest} {...rest}
> >
<Box sx={{ display: "flex", flexGrow: 1, p: 1, pb: 0, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}> <Box
<Box sx={{ sx={{
display: "flex", flexDirection: (isMobile || variant === "small") ? "column" : "row", display: "flex",
"& > div > div > :first-of-type": { fontWeight: "bold", whiteSpace: "nowrap" }, flexGrow: 1,
"& > div > div > :last-of-type": { mb: 0.75, mr: 1 } p: 1,
}}> pb: 0,
<Box sx={{ display: "flex", flexDirection: isMobile ? "row" : "column", flexGrow: 1, gap: 1 }}> height: "100%",
{activeJob.company && flexDirection: "column",
alignItems: "stretch",
position: "relative",
}}
>
<Box
sx={{
display: "flex",
flexDirection: isMobile || variant === "small" ? "column" : "row",
"& > div > div > :first-of-type": {
fontWeight: "bold",
whiteSpace: "nowrap",
},
"& > div > div > :last-of-type": { mb: 0.75, mr: 1 },
}}
>
<Box
sx={{
display: "flex",
flexDirection: isMobile ? "row" : "column",
flexGrow: 1,
gap: 1,
}}
>
{activeJob.company && (
<Box sx={{ fontSize: "0.8rem" }}> <Box sx={{ fontSize: "0.8rem" }}>
<Box>Company</Box> <Box>Company</Box>
<Box sx={{ whiteSpace: "nowrap" }}>{activeJob.company}</Box> <Box sx={{ whiteSpace: "nowrap" }}>{activeJob.company}</Box>
</Box> </Box>
} )}
{activeJob.title && {activeJob.title && (
<Box sx={{ fontSize: "0.8rem" }}> <Box sx={{ fontSize: "0.8rem" }}>
<Box>Title</Box> <Box>Title</Box>
<Box>{activeJob.title}</Box> <Box>{activeJob.title}</Box>
</Box> </Box>
} )}
</Box> </Box>
<Box sx={{ display: "flex", flexDirection: "column", width: (variant !== "small" && variant !== "minimal") ? "75%" : "100%" }}> <Box
{!isMobile && activeJob.summary && <Box sx={{ fontSize: "0.8rem" }}> sx={{
display: "flex",
flexDirection: "column",
width:
variant !== "small" && variant !== "minimal" ? "75%" : "100%",
}}
>
{!isMobile && activeJob.summary && (
<Box sx={{ fontSize: "0.8rem" }}>
<Box>Summary</Box> <Box>Summary</Box>
<Box sx={{ minHeight: variant === "small" ? "5rem" : "inherit" }}> <Box
sx={{ minHeight: variant === "small" ? "5rem" : "inherit" }}
>
<Typography <Typography
ref={descriptionRef} ref={descriptionRef}
variant="body1" variant="body1"
color="text.secondary" color="text.secondary"
sx={{ sx={{
display: '-webkit-box', display: "-webkit-box",
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3, WebkitLineClamp: isDescriptionExpanded ? "unset" : 3,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: "vertical",
overflow: 'hidden', overflow: "hidden",
textOverflow: 'ellipsis', textOverflow: "ellipsis",
lineHeight: 1.5, lineHeight: 1.5,
fontSize: "0.8rem !important", fontSize: "0.8rem !important",
}} }}
@ -279,63 +356,102 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
}} }}
sx={{ sx={{
color: theme.palette.primary.main, color: theme.palette.primary.main,
textDecoration: 'none', textDecoration: "none",
cursor: 'pointer', cursor: "pointer",
fontSize: '0.725rem', fontSize: "0.725rem",
fontWeight: 500, fontWeight: 500,
mt: 0.5, mt: 0.5,
display: 'block', display: "block",
'&:hover': { "&:hover": {
textDecoration: 'underline', textDecoration: "underline",
} },
}} }}
> >
[{isDescriptionExpanded ? "less" : "more"}] [{isDescriptionExpanded ? "less" : "more"}]
</Link> </Link>
)} )}
</Box> </Box>
</Box>} </Box>
)}
</Box> </Box>
</Box> </Box>
{(variant !== "small" && variant !== "minimal") && <> {variant !== "small" && variant !== "minimal" && (
{activeJob.details && <>
{activeJob.details && (
<Typography variant="body2" sx={{ mb: 1 }}> <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {activeJob.details.location.city}, {activeJob.details.location.state || activeJob.details.location.country} <strong>Location:</strong> {activeJob.details.location.city},{" "}
{activeJob.details.location.state ||
activeJob.details.location.country}
</Typography> </Typography>
} )}
{activeJob.owner && <Typography variant="body2"> {activeJob.owner && (
<Typography variant="body2">
<strong>Submitted by:</strong> {activeJob.owner.fullName} <strong>Submitted by:</strong> {activeJob.owner.fullName}
</Typography>} </Typography>
{activeJob.createdAt && )}
<Typography variant="caption">Created: {activeJob.createdAt.toISOString()}</Typography> {activeJob.createdAt && (
} <Typography variant="caption">
{activeJob.updatedAt && Created: {activeJob.createdAt.toISOString()}
<Typography variant="caption">Updated: {activeJob.updatedAt.toISOString()}</Typography> </Typography>
} )}
{activeJob.updatedAt && (
<Typography variant="caption">
Updated: {activeJob.updatedAt.toISOString()}
</Typography>
)}
<Typography variant="caption">Job ID: {job.id}</Typography> <Typography variant="caption">Job ID: {job.id}</Typography>
</>} </>
{variant === 'all' && <StyledMarkdown sx={{ display: "flex" }} content={activeJob.description} />} )}
{variant === "all" && (
<StyledMarkdown
sx={{ display: "flex" }}
content={activeJob.description}
/>
)}
{(variant !== 'small' && variant !== 'minimal') && <Box><Divider />{renderJobRequirements()}</Box>} {variant !== "small" && variant !== "minimal" && (
<Box>
<Divider />
{renderJobRequirements()}
</Box>
)}
{isAdmin && {isAdmin && (
<Box sx={{ display: "flex", flexDirection: "column", p: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}> <Box
{(job.updatedAt && job.updatedAt.toISOString()) !== (activeJob.updatedAt && activeJob.updatedAt.toISOString()) && sx={{
display: "flex",
flexDirection: "row",
pl: 1,
pr: 1,
gap: 1,
alignContent: "center",
height: "32px",
}}
>
{(job.updatedAt && job.updatedAt.toISOString()) !==
(activeJob.updatedAt && activeJob.updatedAt.toISOString()) && (
<Tooltip title="Save Job"> <Tooltip title="Save Job">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { e.stopPropagation(); handleSave(); }} onClick={(e) => {
e.stopPropagation();
handleSave();
}}
> >
<SaveIcon /> <SaveIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
} )}
<Tooltip title="Delete Job"> <Tooltip title="Delete Job">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { e.stopPropagation(); deleteJob(job.id); setDeleted(true) }} onClick={(e) => {
e.stopPropagation();
deleteJob(job.id);
setDeleted(true);
}}
> >
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
@ -343,7 +459,10 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
<Tooltip title="Reset Job"> <Tooltip title="Reset Job">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { e.stopPropagation(); handleReset(); }} onClick={(e) => {
e.stopPropagation();
handleReset();
}}
> >
<RestoreIcon /> <RestoreIcon />
</IconButton> </IconButton>
@ -351,25 +470,28 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
<Tooltip title="Reprocess Job"> <Tooltip title="Reprocess Job">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { e.stopPropagation(); handleRefresh(); }} onClick={(e) => {
e.stopPropagation();
handleRefresh();
}}
> >
<ModelTrainingIcon /> <ModelTrainingIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
{adminStatus && {adminStatus && (
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<StatusBox> <StatusBox>
{adminStatusType && <StatusIcon type={adminStatusType} />} {adminStatusType && <StatusIcon type={adminStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}> <Typography variant="body2" sx={{ ml: 1 }}>
{adminStatus || 'Processing...'} {adminStatus || "Processing..."}
</Typography> </Typography>
</StatusBox> </StatusBox>
{adminStatus && <LinearProgress sx={{ mt: 1 }} />} {adminStatus && <LinearProgress sx={{ mt: 1 }} />}
</Box> </Box>
} )}
</Box> </Box>
} )}
</Box> </Box>
</Box> </Box>
); );

View File

@ -1,18 +1,18 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import { BackstoryElementProps } from 'components/BackstoryTab'; import { BackstoryElementProps } from "components/BackstoryTab";
import { JobInfo } from 'components/ui/JobInfo'; import { JobInfo } from "components/ui/JobInfo";
import { Job } from "types/types"; import { Job } from "types/types";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useAppState, useSelectedJob } from 'hooks/GlobalContext'; import { useAppState, useSelectedJob } from "hooks/GlobalContext";
import { Paper } from '@mui/material'; import { Paper } from "@mui/material";
interface JobPickerProps extends BackstoryElementProps { interface JobPickerProps extends BackstoryElementProps {
onSelect?: (job: Job) => void onSelect?: (job: Job) => void;
}; }
const JobPicker = (props: JobPickerProps) => { const JobPicker = (props: JobPickerProps) => {
const { onSelect } = props; const { onSelect } = props;
@ -30,9 +30,9 @@ const JobPicker = (props: JobPickerProps) => {
const results = await apiClient.getJobs(); const results = await apiClient.getJobs();
const jobs: Job[] = results.data; const jobs: Job[] = results.data;
jobs.sort((a, b) => { jobs.sort((a, b) => {
let result = a.company?.localeCompare(b.company || ''); let result = a.company?.localeCompare(b.company || "");
if (result === 0) { if (result === 0) {
result = a.title?.localeCompare(b.title || ''); result = a.title?.localeCompare(b.title || "");
} }
return result || 0; return result || 0;
}); });
@ -47,32 +47,44 @@ const JobPicker = (props: JobPickerProps) => {
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", mb: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", mb: 1 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}> <Box
{jobs?.map((j, i) => sx={{
<Paper key={`${j.id}`} display: "flex",
onClick={() => { console.log('Selected job', j); onSelect && onSelect(j) }} gap: 1,
sx={{ cursor: "pointer" }}> flexWrap: "wrap",
<JobInfo variant="small" justifyContent: "center",
}}
>
{jobs?.map((j, i) => (
<Paper
key={`${j.id}`}
onClick={() => {
console.log("Selected job", j);
onSelect && onSelect(j);
}}
sx={{ cursor: "pointer" }}
>
<JobInfo
variant="small"
sx={{ sx={{
maxWidth: "100%", maxWidth: "100%",
minWidth: "320px", minWidth: "320px",
width: "320px", width: "320px",
"cursor": "pointer", cursor: "pointer",
backgroundColor: (selectedJob?.id === j.id) ? "#f0f0f0" : "inherit", backgroundColor:
selectedJob?.id === j.id ? "#f0f0f0" : "inherit",
border: "2px solid transparent", border: "2px solid transparent",
"&:hover": { "&:hover": {
border: "2px solid orange" border: "2px solid orange",
} },
}} }}
job={j} job={j}
/> />
</Paper> </Paper>
)} ))}
</Box> </Box>
</Box> </Box>
); );
}; };
export { export { JobPicker };
JobPicker
};

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { import {
Box, Box,
Paper, Paper,
@ -20,8 +20,8 @@ import {
Toolbar, Toolbar,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
Slide Slide,
} from '@mui/material'; } from "@mui/material";
import { import {
KeyboardArrowUp as ArrowUpIcon, KeyboardArrowUp as ArrowUpIcon,
KeyboardArrowDown as ArrowDownIcon, KeyboardArrowDown as ArrowDownIcon,
@ -29,17 +29,17 @@ import {
Work as WorkIcon, Work as WorkIcon,
Schedule as ScheduleIcon, Schedule as ScheduleIcon,
Close as CloseIcon, Close as CloseIcon,
ArrowBack as ArrowBackIcon ArrowBack as ArrowBackIcon,
} from '@mui/icons-material'; } from "@mui/icons-material";
import { TransitionProps } from '@mui/material/transitions'; import { TransitionProps } from "@mui/material/transitions";
import { JobInfo } from 'components/ui/JobInfo'; import { JobInfo } from "components/ui/JobInfo";
import { Job } from "types/types"; import { Job } from "types/types";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useAppState, useSelectedJob } from 'hooks/GlobalContext'; import { useAppState, useSelectedJob } from "hooks/GlobalContext";
import { Navigate, useNavigate, useParams } from 'react-router-dom'; import { Navigate, useNavigate, useParams } from "react-router-dom";
type SortField = 'updatedAt' | 'createdAt' | 'company' | 'title'; type SortField = "updatedAt" | "createdAt" | "company" | "title";
type SortOrder = 'asc' | 'desc'; type SortOrder = "asc" | "desc";
interface JobViewerProps { interface JobViewerProps {
onSelect?: (job: Job) => void; onSelect?: (job: Job) => void;
@ -49,7 +49,7 @@ const Transition = React.forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
children: React.ReactElement; children: React.ReactElement;
}, },
ref: React.Ref<unknown>, ref: React.Ref<unknown>
) { ) {
return <Slide direction="up" ref={ref} {...props} />; return <Slide direction="up" ref={ref} {...props} />;
}); });
@ -57,16 +57,16 @@ const Transition = React.forwardRef(function Transition(
const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => { const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const isSmall = useMediaQuery(theme.breakpoints.down('sm')); const isSmall = useMediaQuery(theme.breakpoints.down("sm"));
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { selectedJob, setSelectedJob } = useSelectedJob(); const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sortField, setSortField] = useState<SortField>('updatedAt'); const [sortField, setSortField] = useState<SortField>("updatedAt");
const [sortOrder, setSortOrder] = useState<SortOrder>('desc'); const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [mobileDialogOpen, setMobileDialogOpen] = useState(false); const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
const { jobId } = useParams<{ jobId?: string }>(); const { jobId } = useParams<{ jobId?: string }>();
@ -79,7 +79,7 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
setJobs(jobsData); setJobs(jobsData);
if (jobId) { if (jobId) {
const job = jobsData.find(j => j.id === jobId); const job = jobsData.find((j) => j.id === jobId);
if (job) { if (job) {
setSelectedJob(job); setSelectedJob(job);
onSelect?.(job); onSelect?.(job);
@ -104,44 +104,48 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
getJobs(); getJobs();
}, [apiClient, setSnack]); }, [apiClient, setSnack]);
const sortJobs = (jobsList: Job[], field: SortField, order: SortOrder): Job[] => { const sortJobs = (
jobsList: Job[],
field: SortField,
order: SortOrder
): Job[] => {
return [...jobsList].sort((a, b) => { return [...jobsList].sort((a, b) => {
let aValue: any; let aValue: any;
let bValue: any; let bValue: any;
switch (field) { switch (field) {
case 'updatedAt': case "updatedAt":
aValue = a.updatedAt?.getTime() || 0; aValue = a.updatedAt?.getTime() || 0;
bValue = b.updatedAt?.getTime() || 0; bValue = b.updatedAt?.getTime() || 0;
break; break;
case 'createdAt': case "createdAt":
aValue = a.createdAt?.getTime() || 0; aValue = a.createdAt?.getTime() || 0;
bValue = b.createdAt?.getTime() || 0; bValue = b.createdAt?.getTime() || 0;
break; break;
case 'company': case "company":
aValue = a.company?.toLowerCase() || ''; aValue = a.company?.toLowerCase() || "";
bValue = b.company?.toLowerCase() || ''; bValue = b.company?.toLowerCase() || "";
break; break;
case 'title': case "title":
aValue = a.title?.toLowerCase() || ''; aValue = a.title?.toLowerCase() || "";
bValue = b.title?.toLowerCase() || ''; bValue = b.title?.toLowerCase() || "";
break; break;
default: default:
return 0; return 0;
} }
if (aValue < bValue) return order === 'asc' ? -1 : 1; if (aValue < bValue) return order === "asc" ? -1 : 1;
if (aValue > bValue) return order === 'asc' ? 1 : -1; if (aValue > bValue) return order === "asc" ? 1 : -1;
return 0; return 0;
}); });
}; };
const handleSort = (field: SortField) => { const handleSort = (field: SortField) => {
if (sortField === field) { if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else { } else {
setSortField(field); setSortField(field);
setSortOrder('desc'); setSortOrder("desc");
} }
}; };
@ -159,37 +163,43 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const sortedJobs = sortJobs(jobs, sortField, sortOrder); const sortedJobs = sortJobs(jobs, sortField, sortOrder);
const formatDate = (date: Date | undefined) => { const formatDate = (date: Date | undefined) => {
if (!date) return 'N/A'; if (!date) return "N/A";
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat("en-US", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
...(isMobile ? {} : { year: 'numeric' }), ...(isMobile ? {} : { year: "numeric" }),
...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }) ...(isSmall ? {} : { hour: "2-digit", minute: "2-digit" }),
}).format(date); }).format(date);
}; };
const getSortIcon = (field: SortField) => { const getSortIcon = (field: SortField) => {
if (sortField !== field) return null; if (sortField !== field) return null;
return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />; return sortOrder === "asc" ? (
<ArrowUpIcon fontSize="small" />
) : (
<ArrowDownIcon fontSize="small" />
);
}; };
const JobList = () => ( const JobList = () => (
<Paper <Paper
elevation={isMobile ? 0 : 1} elevation={isMobile ? 0 : 1}
sx={{ sx={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
width: '100%', width: "100%",
boxShadow: 'none', boxShadow: "none",
backgroundColor: 'transparent' backgroundColor: "transparent",
}} }}
> >
<Box sx={{ <Box
sx={{
p: isMobile ? 0.5 : 1, p: isMobile ? 0.5 : 1,
borderBottom: 1, borderBottom: 1,
borderColor: 'divider', borderColor: "divider",
backgroundColor: isMobile ? 'background.paper' : 'inherit' backgroundColor: isMobile ? "background.paper" : "inherit",
}}> }}
>
<Typography <Typography
variant={isSmall ? "subtitle2" : isMobile ? "subtitle1" : "h6"} variant={isSmall ? "subtitle2" : isMobile ? "subtitle1" : "h6"}
gutterBottom gutterBottom
@ -198,13 +208,19 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
Jobs ({jobs.length}) Jobs ({jobs.length})
</Typography> </Typography>
<FormControl size="small" sx={{ minWidth: isSmall ? 120 : isMobile ? 150 : 200 }}> <FormControl
size="small"
sx={{ minWidth: isSmall ? 120 : isMobile ? 150 : 200 }}
>
<InputLabel>Sort by</InputLabel> <InputLabel>Sort by</InputLabel>
<Select <Select
value={`${sortField}-${sortOrder}`} value={`${sortField}-${sortOrder}`}
label="Sort by" label="Sort by"
onChange={(e) => { onChange={(e) => {
const [field, order] = e.target.value.split('-') as [SortField, SortOrder]; const [field, order] = e.target.value.split("-") as [
SortField,
SortOrder
];
setSortField(field); setSortField(field);
setSortOrder(order); setSortOrder(order);
}} }}
@ -221,78 +237,86 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
</FormControl> </FormControl>
</Box> </Box>
<TableContainer sx={{ <TableContainer
sx={{
flex: 1, flex: 1,
overflow: 'auto', overflow: "auto",
'& .MuiTable-root': { "& .MuiTable-root": {
tableLayout: isMobile ? 'fixed' : 'auto' tableLayout: isMobile ? "fixed" : "auto",
} },
}}> }}
>
<Table stickyHeader size="small"> <Table stickyHeader size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell <TableCell
sx={{ sx={{
cursor: 'pointer', cursor: "pointer",
userSelect: 'none', userSelect: "none",
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
width: isMobile ? '25%' : 'auto', width: isMobile ? "25%" : "auto",
backgroundColor: 'background.paper' backgroundColor: "background.paper",
}} }}
onClick={() => handleSort('company')} onClick={() => handleSort("company")}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<BusinessIcon fontSize={isMobile ? "small" : "medium"} /> <BusinessIcon fontSize={isMobile ? "small" : "medium"} />
<Typography variant="caption" fontWeight="bold" noWrap> <Typography variant="caption" fontWeight="bold" noWrap>
{isSmall ? 'Co.' : isMobile ? 'Company' : 'Company'} {isSmall ? "Co." : isMobile ? "Company" : "Company"}
</Typography> </Typography>
{getSortIcon('company')} {getSortIcon("company")}
</Box> </Box>
</TableCell> </TableCell>
<TableCell <TableCell
sx={{ sx={{
cursor: 'pointer', cursor: "pointer",
userSelect: 'none', userSelect: "none",
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
width: isMobile ? '45%' : 'auto', width: isMobile ? "45%" : "auto",
backgroundColor: 'background.paper' backgroundColor: "background.paper",
}} }}
onClick={() => handleSort('title')} onClick={() => handleSort("title")}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<WorkIcon fontSize={isMobile ? "small" : "medium"} /> <WorkIcon fontSize={isMobile ? "small" : "medium"} />
<Typography variant="caption" fontWeight="bold" noWrap>Title</Typography> <Typography variant="caption" fontWeight="bold" noWrap>
{getSortIcon('title')} Title
</Typography>
{getSortIcon("title")}
</Box> </Box>
</TableCell> </TableCell>
{!isMobile && ( {!isMobile && (
<TableCell <TableCell
sx={{ sx={{
cursor: 'pointer', cursor: "pointer",
userSelect: 'none', userSelect: "none",
py: 0.5, py: 0.5,
px: 1, px: 1,
backgroundColor: 'background.paper' backgroundColor: "background.paper",
}} }}
onClick={() => handleSort('updatedAt')} onClick={() => handleSort("updatedAt")}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<ScheduleIcon fontSize="medium" /> <ScheduleIcon fontSize="medium" />
<Typography variant="caption" fontWeight="bold">Updated</Typography> <Typography variant="caption" fontWeight="bold">
{getSortIcon('updatedAt')} Updated
</Typography>
{getSortIcon("updatedAt")}
</Box> </Box>
</TableCell> </TableCell>
)} )}
<TableCell sx={{ <TableCell
sx={{
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
width: isMobile ? '30%' : 'auto', width: isMobile ? "30%" : "auto",
backgroundColor: 'background.paper' backgroundColor: "background.paper",
}}> }}
>
<Typography variant="caption" fontWeight="bold" noWrap> <Typography variant="caption" fontWeight="bold" noWrap>
{isMobile ? 'Status' : 'Status'} {isMobile ? "Status" : "Status"}
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -305,52 +329,58 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
selected={selectedJob?.id === job.id} selected={selectedJob?.id === job.id}
onClick={() => handleJobSelect(job)} onClick={() => handleJobSelect(job)}
sx={{ sx={{
cursor: 'pointer', cursor: "pointer",
height: isMobile ? 48 : 'auto', height: isMobile ? 48 : "auto",
'&.Mui-selected': { "&.Mui-selected": {
backgroundColor: 'action.selected', backgroundColor: "action.selected",
},
"&:hover": {
backgroundColor: "action.hover",
}, },
'&:hover': {
backgroundColor: 'action.hover',
}
}} }}
> >
<TableCell sx={{ <TableCell
sx={{
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
overflow: 'hidden' overflow: "hidden",
}}> }}
>
<Typography <Typography
variant={isMobile ? "caption" : "body2"} variant={isMobile ? "caption" : "body2"}
fontWeight="medium" fontWeight="medium"
noWrap noWrap
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }} sx={{ fontSize: isMobile ? "0.75rem" : "0.875rem" }}
> >
{job.company || 'N/A'} {job.company || "N/A"}
</Typography> </Typography>
{!isMobile && job.details?.location && ( {!isMobile && job.details?.location && (
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
noWrap noWrap
sx={{ display: 'block', fontSize: '0.7rem' }} sx={{ display: "block", fontSize: "0.7rem" }}
> >
{job.details.location.city}, {job.details.location.state || job.details.location.country} {job.details.location.city},{" "}
{job.details.location.state ||
job.details.location.country}
</Typography> </Typography>
)} )}
</TableCell> </TableCell>
<TableCell sx={{ <TableCell
sx={{
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
overflow: 'hidden' overflow: "hidden",
}}> }}
>
<Typography <Typography
variant={isMobile ? "caption" : "body2"} variant={isMobile ? "caption" : "body2"}
fontWeight="medium" fontWeight="medium"
noWrap noWrap
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }} sx={{ fontSize: isMobile ? "0.75rem" : "0.875rem" }}
> >
{job.title || 'N/A'} {job.title || "N/A"}
</Typography> </Typography>
{!isMobile && job.details?.employmentType && ( {!isMobile && job.details?.employmentType && (
<Chip <Chip
@ -359,46 +389,48 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
variant="outlined" variant="outlined"
sx={{ sx={{
mt: 0.25, mt: 0.25,
fontSize: '0.6rem', fontSize: "0.6rem",
height: 16, height: 16,
'& .MuiChip-label': { px: 0.5 } "& .MuiChip-label": { px: 0.5 },
}} }}
/> />
)} )}
</TableCell> </TableCell>
{!isMobile && ( {!isMobile && (
<TableCell sx={{ py: 0.5, px: 1 }}> <TableCell sx={{ py: 0.5, px: 1 }}>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}> <Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{formatDate(job.updatedAt)} {formatDate(job.updatedAt)}
</Typography> </Typography>
{job.createdAt && ( {job.createdAt && (
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ display: 'block', fontSize: '0.7rem' }} sx={{ display: "block", fontSize: "0.7rem" }}
> >
Created: {formatDate(job.createdAt)} Created: {formatDate(job.createdAt)}
</Typography> </Typography>
)} )}
</TableCell> </TableCell>
)} )}
<TableCell sx={{ <TableCell
sx={{
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
overflow: 'hidden' overflow: "hidden",
}}> }}
>
<Chip <Chip
label={job.details?.isActive ? "Active" : "Inactive"} label={job.details?.isActive ? "Active" : "Inactive"}
color={job.details?.isActive ? "success" : "default"} color={job.details?.isActive ? "success" : "default"}
size="small" size="small"
variant="outlined" variant="outlined"
sx={{ sx={{
fontSize: isMobile ? '0.65rem' : '0.7rem', fontSize: isMobile ? "0.65rem" : "0.7rem",
height: isMobile ? 20 : 22, height: isMobile ? 20 : 22,
'& .MuiChip-label': { "& .MuiChip-label": {
px: isMobile ? 0.5 : 0.75, px: isMobile ? 0.5 : 0.75,
py: 0 py: 0,
} },
}} }}
/> />
</TableCell> </TableCell>
@ -411,49 +443,53 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
); );
const JobDetails = ({ inDialog = false }: { inDialog?: boolean }) => ( const JobDetails = ({ inDialog = false }: { inDialog?: boolean }) => (
<Box sx={{ <Box
sx={{
flex: 1, flex: 1,
overflow: 'auto', overflow: "auto",
p: inDialog ? 1.5 : 0.75, p: inDialog ? 1.5 : 0.75,
height: inDialog ? '100%' : 'auto' height: inDialog ? "100%" : "auto",
}}> }}
>
{selectedJob ? ( {selectedJob ? (
<JobInfo <JobInfo
job={selectedJob} job={selectedJob}
variant="all" variant="all"
sx={{ sx={{
border: 'none', border: "none",
boxShadow: 'none', boxShadow: "none",
backgroundColor: 'transparent', backgroundColor: "transparent",
'& .MuiTypography-h6': { "& .MuiTypography-h6": {
fontSize: inDialog ? '1.25rem' : '1.1rem' fontSize: inDialog ? "1.25rem" : "1.1rem",
} },
}} }}
/> />
) : ( ) : (
<Box sx={{ <Box
display: 'flex', sx={{
alignItems: 'center', display: "flex",
justifyContent: 'center', alignItems: "center",
height: '100%', justifyContent: "center",
color: 'text.secondary', height: "100%",
textAlign: 'center', color: "text.secondary",
p: 2 textAlign: "center",
}}> p: 2,
<Typography variant="body2"> }}
Select a job to view details >
</Typography> <Typography variant="body2">Select a job to view details</Typography>
</Box> </Box>
)} )}
</Box> </Box>
); );
return ( return (
<Box sx={{ <Box
height: '100%', sx={{
height: "100%",
p: 0.5, p: 0.5,
backgroundColor: 'background.default' backgroundColor: "background.default",
}}> }}
>
<JobList /> <JobList />
<Dialog <Dialog
@ -463,7 +499,7 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
TransitionComponent={Transition} TransitionComponent={Transition}
TransitionProps={{ timeout: 300 }} TransitionProps={{ timeout: 300 }}
> >
<AppBar sx={{ position: 'relative', elevation: 1 }}> <AppBar sx={{ position: "relative", elevation: 1 }}>
<Toolbar variant="dense" sx={{ minHeight: 48 }}> <Toolbar variant="dense" sx={{ minHeight: 48 }}>
<IconButton <IconButton
edge="start" edge="start"
@ -479,14 +515,14 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
variant="h6" variant="h6"
component="div" component="div"
noWrap noWrap
sx={{ fontSize: '1rem' }} sx={{ fontSize: "1rem" }}
> >
{selectedJob?.title} {selectedJob?.title}
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
component="div" component="div"
sx={{ color: 'rgba(255, 255, 255, 0.7)' }} sx={{ color: "rgba(255, 255, 255, 0.7)" }}
noWrap noWrap
> >
{selectedJob?.company} {selectedJob?.company}

View File

@ -1,11 +1,6 @@
import React from 'react'; import React from "react";
import { import { Button, Typography, Paper, Container } from "@mui/material";
Button, import { useNavigate } from "react-router-dom";
Typography,
Paper,
Container,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
interface LoginRequiredProps { interface LoginRequiredProps {
asset: string; asset: string;
@ -16,11 +11,18 @@ const LoginRequired = (props: LoginRequiredProps) => {
return ( return (
<Container maxWidth="md"> <Container maxWidth="md">
<Paper elevation={3} sx={{ p: 4, mt: 4, textAlign: 'center' }}> <Paper elevation={3} sx={{ p: 4, mt: 4, textAlign: "center" }}>
<Typography variant="h5" gutterBottom> <Typography variant="h5" gutterBottom>
Please log in to access {asset} Please log in to access {asset}
</Typography> </Typography>
<Button variant="contained" onClick={() => { navigate('/login'); }} color="primary" sx={{ mt: 2 }}> <Button
variant="contained"
onClick={() => {
navigate("/login");
}}
color="primary"
sx={{ mt: 2 }}
>
Log In Log In
</Button> </Button>
</Paper> </Paper>

View File

@ -1,8 +1,8 @@
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import './LoginRestricted.css'; import "./LoginRestricted.css";
interface LoginRestrictedProps { interface LoginRestrictedProps {
children?: React.ReactNode children?: React.ReactNode;
} }
const LoginRestricted = (props: LoginRestrictedProps) => { const LoginRestricted = (props: LoginRestrictedProps) => {
@ -17,6 +17,4 @@ const LoginRestricted = (props: LoginRestrictedProps) => {
); );
}; };
export { export { LoginRestricted };
LoginRestricted
};

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from "react";
import { import {
Box, Box,
Link, Link,
@ -25,9 +25,9 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
Tabs, Tabs,
Tab Tab,
} from '@mui/material'; } from "@mui/material";
import PrintIcon from '@mui/icons-material/Print'; import PrintIcon from "@mui/icons-material/Print";
import { import {
Delete as DeleteIcon, Delete as DeleteIcon,
Restore as RestoreIcon, Restore as RestoreIcon,
@ -38,19 +38,19 @@ import {
Person as PersonIcon, Person as PersonIcon,
Schedule as ScheduleIcon, Schedule as ScheduleIcon,
Visibility as VisibilityIcon, Visibility as VisibilityIcon,
VisibilityOff as VisibilityOffIcon VisibilityOff as VisibilityOffIcon,
} from '@mui/icons-material'; } from "@mui/icons-material";
import PreviewIcon from '@mui/icons-material/Preview'; import PreviewIcon from "@mui/icons-material/Preview";
import EditDocumentIcon from '@mui/icons-material/EditDocument'; import EditDocumentIcon from "@mui/icons-material/EditDocument";
import { useReactToPrint } from "react-to-print"; import { useReactToPrint } from "react-to-print";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
import { StyledMarkdown } from 'components/StyledMarkdown'; import { StyledMarkdown } from "components/StyledMarkdown";
import { Resume } from 'types/types'; import { Resume } from "types/types";
import { BackstoryTextField } from 'components/BackstoryTextField'; import { BackstoryTextField } from "components/BackstoryTextField";
import { JobInfo } from './JobInfo'; import { JobInfo } from "./JobInfo";
interface ResumeInfoProps { interface ResumeInfoProps {
resume: Resume; resume: Resume;
@ -64,14 +64,10 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const { resume } = props; const { resume } = props;
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const { const { sx, action = "", elevation = 1, variant = "normal" } = props;
sx,
action = '',
elevation = 1,
variant = "normal"
} = props;
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')) || variant === "minimal"; const isMobile =
useMediaQuery(theme.breakpoints.down("md")) || variant === "minimal";
const isAdmin = user?.isAdmin; const isAdmin = user?.isAdmin;
const [activeResume, setActiveResume] = useState<Resume>({ ...resume }); const [activeResume, setActiveResume] = useState<Resume>({ ...resume });
const [isContentExpanded, setIsContentExpanded] = useState(false); const [isContentExpanded, setIsContentExpanded] = useState(false);
@ -79,12 +75,15 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const [deleted, setDeleted] = useState<boolean>(false); const [deleted, setDeleted] = useState<boolean>(false);
const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false); const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false);
const [printDialogOpen, setPrintDialogOpen] = useState<boolean>(false); const [printDialogOpen, setPrintDialogOpen] = useState<boolean>(false);
const [editContent, setEditContent] = useState<string>(''); const [editContent, setEditContent] = useState<string>("");
const [saving, setSaving] = useState<boolean>(false); const [saving, setSaving] = useState<boolean>(false);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const [tabValue, setTabValue] = useState("markdown"); const [tabValue, setTabValue] = useState("markdown");
const printContentRef = useRef<HTMLDivElement>(null); const printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({ contentRef: printContentRef, pageStyle: '@page { margin: 10px; }' }); const reactToPrintFn = useReactToPrint({
contentRef: printContentRef,
pageStyle: "@page { margin: 10px; }",
});
useEffect(() => { useEffect(() => {
if (resume && resume.id !== activeResume?.id) { if (resume && resume.id !== activeResume?.id) {
@ -105,9 +104,9 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
try { try {
await apiClient.deleteResume(id); await apiClient.deleteResume(id);
setDeleted(true); setDeleted(true);
setSnack('Resume deleted successfully.'); setSnack("Resume deleted successfully.");
} catch (error) { } catch (error) {
setSnack('Failed to delete resume.'); setSnack("Failed to delete resume.");
} }
} }
}; };
@ -119,12 +118,19 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
const result = await apiClient.updateResume(activeResume.id || '', editContent); const result = await apiClient.updateResume(
const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() }; activeResume.id || "",
editContent
);
const updatedResume = {
...activeResume,
resume: editContent,
updatedAt: new Date(),
};
setActiveResume(updatedResume); setActiveResume(updatedResume);
setSnack('Resume updated successfully.'); setSnack("Resume updated successfully.");
} catch (error) { } catch (error) {
setSnack('Failed to update resume.'); setSnack("Failed to update resume.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -140,13 +146,13 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
} }
const formatDate = (date: Date | undefined) => { const formatDate = (date: Date | undefined) => {
if (!date) return 'N/A'; if (!date) return "N/A";
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat("en-US", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
year: 'numeric', year: "numeric",
hour: '2-digit', hour: "2-digit",
minute: '2-digit' minute: "2-digit",
}).format(date); }).format(date);
}; };
@ -162,32 +168,46 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
borderColor: 'transparent', borderColor: "transparent",
borderWidth: 2, borderWidth: 2,
borderStyle: 'solid', borderStyle: "solid",
transition: 'all 0.3s ease', transition: "all 0.3s ease",
flexDirection: "column", flexDirection: "column",
minWidth: 0, minWidth: 0,
opacity: deleted ? 0.5 : 1.0, opacity: deleted ? 0.5 : 1.0,
backgroundColor: deleted ? theme.palette.action.disabledBackground : theme.palette.background.paper, backgroundColor: deleted
? theme.palette.action.disabledBackground
: theme.palette.background.paper,
pointerEvents: deleted ? "none" : "auto", pointerEvents: deleted ? "none" : "auto",
...sx, ...sx,
}} }}
> >
<Box sx={{ display: "flex", flexGrow: 1, p: 1, pb: 0, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}> <Box
sx={{
display: "flex",
flexGrow: 1,
p: 1,
pb: 0,
height: "100%",
flexDirection: "column",
alignItems: "stretch",
position: "relative",
}}
>
{/* Header Information */} {/* Header Information */}
<Box sx={{ <Box
sx={{
display: "flex", display: "flex",
flexDirection: isMobile ? "column" : "row", flexDirection: isMobile ? "column" : "row",
gap: 2, gap: 2,
mb: 2 mb: 2,
}}> }}
>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Stack spacing={1}> <Stack spacing={1}>
{activeResume.candidate && ( {activeResume.candidate && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<PersonIcon color="primary" fontSize="small" /> <PersonIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold"> <Typography variant="subtitle2" fontWeight="bold">
Candidate Candidate
@ -200,7 +220,14 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
{activeResume.job && ( {activeResume.job && (
<> <>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}> <Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mt: 1,
}}
>
<WorkIcon color="primary" fontSize="small" /> <WorkIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold"> <Typography variant="subtitle2" fontWeight="bold">
Job Job
@ -216,7 +243,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Stack spacing={1}> <Stack spacing={1}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<ScheduleIcon color="primary" fontSize="small" /> <ScheduleIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold"> <Typography variant="subtitle2" fontWeight="bold">
Timeline Timeline
@ -240,7 +267,10 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
{/* Resume Content */} {/* Resume Content */}
{activeResume.resume && ( {activeResume.resume && (
<Card elevation={0} sx={{ m: 0, p: 0, background: "transparent !important" }}> <Card
elevation={0}
sx={{ m: 0, p: 0, background: "transparent !important" }}
>
<CardHeader <CardHeader
title="Resume Content" title="Resume Content"
avatar={<DescriptionIcon color="success" />} avatar={<DescriptionIcon color="success" />}
@ -256,21 +286,27 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
} }
/> />
<CardContent sx={{ p: 0 }}> <CardContent sx={{ p: 0 }}>
<Box sx={{ position: 'relative' }}> <Box sx={{ position: "relative" }}>
<Typography <Typography
ref={contentRef} ref={contentRef}
variant="body2" variant="body2"
component="div" component="div"
sx={{ sx={{
display: '-webkit-box', display: "-webkit-box",
WebkitLineClamp: isContentExpanded ? 'unset' : (variant === "small" ? 5 : variant === "minimal" ? 3 : 10), WebkitLineClamp: isContentExpanded
WebkitBoxOrient: 'vertical', ? "unset"
overflow: 'hidden', : variant === "small"
textOverflow: 'ellipsis', ? 5
: variant === "minimal"
? 3
: 10,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
lineHeight: 1.6, lineHeight: 1.6,
fontSize: "0.875rem !important", fontSize: "0.875rem !important",
whiteSpace: 'pre-wrap', whiteSpace: "pre-wrap",
fontFamily: 'monospace', fontFamily: "monospace",
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
p: 2, p: 2,
borderRadius: 1, borderRadius: 1,
@ -281,13 +317,21 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Typography> </Typography>
{shouldShowMoreButton && variant !== "all" && ( {shouldShowMoreButton && variant !== "all" && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}> <Box
sx={{ display: "flex", justifyContent: "center", mt: 1 }}
>
<Button <Button
variant="text" variant="text"
size="small" size="small"
onClick={() => setIsContentExpanded(!isContentExpanded)} onClick={() => setIsContentExpanded(!isContentExpanded)}
startIcon={isContentExpanded ? <VisibilityOffIcon /> : <VisibilityIcon />} startIcon={
sx={{ fontSize: '0.75rem' }} isContentExpanded ? (
<VisibilityOffIcon />
) : (
<VisibilityIcon />
)
}
sx={{ fontSize: "0.75rem" }}
> >
{isContentExpanded ? "Show Less" : "Show More"} {isContentExpanded ? "Show Less" : "Show More"}
</Button> </Button>
@ -298,22 +342,34 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Card> </Card>
)} )}
{variant === 'all' && activeResume.resume && ( {variant === "all" && activeResume.resume && (
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<StyledMarkdown content={activeResume.resume} /> <StyledMarkdown content={activeResume.resume} />
</Box> </Box>
)} )}
</Box> </Box>
{/* Admin Controls */} {/* Admin Controls */}
{isAdmin && ( {isAdmin && (
<Box sx={{ display: "flex", flexDirection: "column", p: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}> <Box
sx={{
display: "flex",
flexDirection: "row",
pl: 1,
pr: 1,
gap: 1,
alignContent: "center",
height: "32px",
}}
>
<Tooltip title="Edit Resume"> <Tooltip title="Edit Resume">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { e.stopPropagation(); handleEditOpen(); }} onClick={(e) => {
e.stopPropagation();
handleEditOpen();
}}
> >
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
@ -322,7 +378,10 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Tooltip title="Delete Resume"> <Tooltip title="Delete Resume">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { e.stopPropagation(); deleteResume(activeResume.id); }} onClick={(e) => {
e.stopPropagation();
deleteResume(activeResume.id);
}}
> >
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
@ -331,7 +390,10 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Tooltip title="Reset Resume"> <Tooltip title="Reset Resume">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { e.stopPropagation(); handleReset(); }} onClick={(e) => {
e.stopPropagation();
handleReset();
}}
> >
<RestoreIcon /> <RestoreIcon />
</IconButton> </IconButton>
@ -366,9 +428,10 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
width: "100%", width: "100%",
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
flex: 1, /* Take remaining space in some-container */ flex: 1 /* Take remaining space in some-container */,
overflowY: "auto", /* Scroll if content overflows */ overflowY: "auto" /* Scroll if content overflows */,
}} /> }}
/>
</Dialog> </Dialog>
{/* Edit Dialog */} {/* Edit Dialog */}
@ -383,35 +446,55 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<DialogTitle> <DialogTitle>
Edit Resume Content Edit Resume Content
<Typography variant="caption" display="block" color="text.secondary"> <Typography variant="caption" display="block" color="text.secondary">
Resume for {activeResume.candidate?.fullName || activeResume.candidateId}, {activeResume.job?.title || 'No Job Title Assigned'}, {activeResume.job?.company || 'No Company Assigned'} Resume for{" "}
{activeResume.candidate?.fullName || activeResume.candidateId},{" "}
{activeResume.job?.title || "No Job Title Assigned"},{" "}
{activeResume.job?.company || "No Company Assigned"}
</Typography> </Typography>
<Typography variant="caption" display="block" color="text.secondary"> <Typography variant="caption" display="block" color="text.secondary">
Resume ID: # {activeResume.id} Resume ID: # {activeResume.id}
</Typography> </Typography>
<Typography variant="caption" display="block" color="text.secondary"> <Typography variant="caption" display="block" color="text.secondary">
Last saved: {activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'} Last saved:{" "}
{activeResume.updatedAt
? new Date(activeResume.updatedAt).toLocaleString()
: "N/A"}
</Typography> </Typography>
</DialogTitle> </DialogTitle>
<DialogContent sx={{ position: "relative", display: "flex", flexDirection: "column", height: "100%" }}> <DialogContent
sx={{
position: "relative",
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<Tabs value={tabValue} onChange={handleTabChange} centered> <Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" /> <Tab
value="markdown"
icon={<EditDocumentIcon />}
label="Markdown"
/>
<Tab value="preview" icon={<PreviewIcon />} label="Preview" /> <Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="job" icon={<WorkIcon />} label="Job" /> <Tab value="job" icon={<WorkIcon />} label="Job" />
<Tab value="print" icon={<PrintIcon />} label="Print" /> <Tab value="print" icon={<PrintIcon />} label="Print" />
</Tabs> </Tabs>
<Box ref={printContentRef} sx={{ <Box
display: "flex", flexDirection: "column", ref={printContentRef}
height: "100%", /* Restrict to main-container's height */ sx={{
display: "flex",
flexDirection: "column",
height: "100%" /* Restrict to main-container's height */,
width: "100%", width: "100%",
minHeight: 0,/* Prevent flex overflow */ minHeight: 0 /* Prevent flex overflow */,
//maxHeight: "min-content", //maxHeight: "min-content",
"& > *:not(.Scrollable)": { "& > *:not(.Scrollable)": {
flexShrink: 0, /* Prevent shrinking */ flexShrink: 0 /* Prevent shrinking */,
}, },
position: "relative", position: "relative",
}}> }}
>
{tabValue === "markdown" && {tabValue === "markdown" && (
<BackstoryTextField <BackstoryTextField
value={editContent} value={editContent}
onChange={(value) => setEditContent(value)} onChange={(value) => setEditContent(value)}
@ -424,13 +507,14 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
minHeight: "100%", minHeight: "100%",
flexGrow: 1, flexGrow: 1,
flex: 1, /* Take remaining space in some-container */ flex: 1 /* Take remaining space in some-container */,
overflowY: "auto", /* Scroll if content overflows */ overflowY: "auto" /* Scroll if content overflows */,
}} }}
placeholder="Enter resume content..." placeholder="Enter resume content..."
/> />
} )}
{tabValue === "preview" && <> {tabValue === "preview" && (
<>
<StyledMarkdown <StyledMarkdown
sx={{ sx={{
p: 2, p: 2,
@ -439,14 +523,16 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
width: "100%", width: "100%",
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
flex: 1, /* Take remaining space in some-container */ flex: 1 /* Take remaining space in some-container */,
overflowY: "auto", /* Scroll if content overflows */ overflowY: "auto" /* Scroll if content overflows */,
}} }}
content={editContent}
content={editContent} /> />
<Box sx={{ pb: 2 }}></Box></> <Box sx={{ pb: 2 }}></Box>
} </>
{tabValue === "job" && activeResume.job && <JobInfo )}
{tabValue === "job" && activeResume.job && (
<JobInfo
variant="all" variant="all"
job={activeResume.job} job={activeResume.job}
sx={{ sx={{
@ -456,24 +542,22 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
width: "100%", width: "100%",
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
flex: 1, /* Take remaining space in some-container */ flex: 1 /* Take remaining space in some-container */,
overflowY: "auto", /* Scroll if content overflows */ overflowY: "auto" /* Scroll if content overflows */,
}} }}
/>} />
)}
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setEditDialogOpen(false)}> <Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
Cancel
</Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
variant="contained" variant="contained"
disabled={saving} disabled={saving}
startIcon={<SaveIcon />} startIcon={<SaveIcon />}
> >
{saving ? 'Saving...' : 'Save'} {saving ? "Saving..." : "Save"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { import {
Box, Box,
Paper, Paper,
@ -22,8 +22,8 @@ import {
useTheme, useTheme,
Slide, Slide,
TextField, TextField,
InputAdornment InputAdornment,
} from '@mui/material'; } from "@mui/material";
import { import {
KeyboardArrowUp as ArrowUpIcon, KeyboardArrowUp as ArrowUpIcon,
KeyboardArrowDown as ArrowDownIcon, KeyboardArrowDown as ArrowDownIcon,
@ -34,17 +34,17 @@ import {
Close as CloseIcon, Close as CloseIcon,
ArrowBack as ArrowBackIcon, ArrowBack as ArrowBackIcon,
Search as SearchIcon, Search as SearchIcon,
Clear as ClearIcon Clear as ClearIcon,
} from '@mui/icons-material'; } from "@mui/icons-material";
import { TransitionProps } from '@mui/material/transitions'; import { TransitionProps } from "@mui/material/transitions";
import { ResumeInfo } from 'components/ui/ResumeInfo'; import { ResumeInfo } from "components/ui/ResumeInfo";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assuming similar context exists import { useAppState, useSelectedResume } from "hooks/GlobalContext"; // Assuming similar context exists
import { Navigate, useNavigate, useParams } from 'react-router-dom'; import { Navigate, useNavigate, useParams } from "react-router-dom";
import { Resume } from 'types/types'; import { Resume } from "types/types";
type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId'; type SortField = "updatedAt" | "createdAt" | "candidateId" | "jobId";
type SortOrder = 'asc' | 'desc'; type SortOrder = "asc" | "desc";
interface ResumeViewerProps { interface ResumeViewerProps {
onSelect?: (resume: Resume) => void; onSelect?: (resume: Resume) => void;
@ -56,26 +56,30 @@ const Transition = React.forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
children: React.ReactElement; children: React.ReactElement;
}, },
ref: React.Ref<unknown>, ref: React.Ref<unknown>
) { ) {
return <Slide direction="up" ref={ref} {...props} />; return <Slide direction="up" ref={ref} {...props} />;
}); });
const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobId }) => { const ResumeViewer: React.FC<ResumeViewerProps> = ({
onSelect,
candidateId,
jobId,
}) => {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const isSmall = useMediaQuery(theme.breakpoints.down('sm')); const isSmall = useMediaQuery(theme.breakpoints.down("sm"));
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { selectedResume, setSelectedResume } = useSelectedResume(); // Assuming similar context const { selectedResume, setSelectedResume } = useSelectedResume(); // Assuming similar context
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [resumes, setResumes] = useState<Resume[]>([]); const [resumes, setResumes] = useState<Resume[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sortField, setSortField] = useState<SortField>('updatedAt'); const [sortField, setSortField] = useState<SortField>("updatedAt");
const [sortOrder, setSortOrder] = useState<SortOrder>('desc'); const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [mobileDialogOpen, setMobileDialogOpen] = useState(false); const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState("");
const [filteredResumes, setFilteredResumes] = useState<Resume[]>([]); const [filteredResumes, setFilteredResumes] = useState<Resume[]>([]);
const { resumeId } = useParams<{ resumeId?: string }>(); const { resumeId } = useParams<{ resumeId?: string }>();
@ -98,7 +102,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
setFilteredResumes(resumesData); setFilteredResumes(resumesData);
if (resumeId) { if (resumeId) {
const resume = resumesData.find(r => r.id === resumeId); const resume = resumesData.find((r) => r.id === resumeId);
if (resume) { if (resume) {
setSelectedResume(resume); setSelectedResume(resume);
onSelect?.(resume); onSelect?.(resume);
@ -114,7 +118,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
} }
} catch (err) { } catch (err) {
console.error("Failed to load resumes:", err); console.error("Failed to load resumes:", err);
setSnack("Failed to load resumes: " + err, 'error'); setSnack("Failed to load resumes: " + err, "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -128,10 +132,17 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
setFilteredResumes(resumes); setFilteredResumes(resumes);
} else { } else {
const filtered = resumes.filter(resume => const filtered = resumes.filter(
resume.candidate?.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) || (resume) =>
resume.job?.title?.toLowerCase().includes(searchQuery.toLowerCase()) || resume.candidate?.fullName
resume.job?.company?.toLowerCase().includes(searchQuery.toLowerCase()) || ?.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
resume.job?.title
?.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
resume.job?.company
?.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
resume.resume?.toLowerCase().includes(searchQuery.toLowerCase()) || resume.resume?.toLowerCase().includes(searchQuery.toLowerCase()) ||
resume.id?.toLowerCase().includes(searchQuery.toLowerCase()) resume.id?.toLowerCase().includes(searchQuery.toLowerCase())
); );
@ -139,44 +150,54 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
} }
}, [searchQuery, resumes]); }, [searchQuery, resumes]);
const sortResumes = (resumesList: Resume[], field: SortField, order: SortOrder): Resume[] => { const sortResumes = (
resumesList: Resume[],
field: SortField,
order: SortOrder
): Resume[] => {
return [...resumesList].sort((a, b) => { return [...resumesList].sort((a, b) => {
let aValue: any; let aValue: any;
let bValue: any; let bValue: any;
switch (field) { switch (field) {
case 'updatedAt': case "updatedAt":
aValue = a.updatedAt?.getTime() || 0; aValue = a.updatedAt?.getTime() || 0;
bValue = b.updatedAt?.getTime() || 0; bValue = b.updatedAt?.getTime() || 0;
break; break;
case 'createdAt': case "createdAt":
aValue = a.createdAt?.getTime() || 0; aValue = a.createdAt?.getTime() || 0;
bValue = b.createdAt?.getTime() || 0; bValue = b.createdAt?.getTime() || 0;
break; break;
case 'candidateId': case "candidateId":
aValue = a.candidate?.fullName?.toLowerCase() || a.candidateId?.toLowerCase() || ''; aValue =
bValue = b.candidate?.fullName?.toLowerCase() || b.candidateId?.toLowerCase() || ''; a.candidate?.fullName?.toLowerCase() ||
a.candidateId?.toLowerCase() ||
"";
bValue =
b.candidate?.fullName?.toLowerCase() ||
b.candidateId?.toLowerCase() ||
"";
break; break;
case 'jobId': case "jobId":
aValue = a.job?.title?.toLowerCase() || a.jobId?.toLowerCase() || ''; aValue = a.job?.title?.toLowerCase() || a.jobId?.toLowerCase() || "";
bValue = b.job?.title?.toLowerCase() || b.jobId?.toLowerCase() || ''; bValue = b.job?.title?.toLowerCase() || b.jobId?.toLowerCase() || "";
break; break;
default: default:
return 0; return 0;
} }
if (aValue < bValue) return order === 'asc' ? -1 : 1; if (aValue < bValue) return order === "asc" ? -1 : 1;
if (aValue > bValue) return order === 'asc' ? 1 : -1; if (aValue > bValue) return order === "asc" ? 1 : -1;
return 0; return 0;
}); });
}; };
const handleSort = (field: SortField) => { const handleSort = (field: SortField) => {
if (sortField === field) { if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else { } else {
setSortField(field); setSortField(field);
setSortOrder('desc'); setSortOrder("desc");
} }
}; };
@ -195,24 +216,28 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
}; };
const handleSearchClear = () => { const handleSearchClear = () => {
setSearchQuery(''); setSearchQuery("");
}; };
const sortedResumes = sortResumes(filteredResumes, sortField, sortOrder); const sortedResumes = sortResumes(filteredResumes, sortField, sortOrder);
const formatDate = (date: Date | undefined) => { const formatDate = (date: Date | undefined) => {
if (!date) return 'N/A'; if (!date) return "N/A";
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat("en-US", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
...(isMobile ? {} : { year: 'numeric' }), ...(isMobile ? {} : { year: "numeric" }),
...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }) ...(isSmall ? {} : { hour: "2-digit", minute: "2-digit" }),
}).format(date); }).format(date);
}; };
const getSortIcon = (field: SortField) => { const getSortIcon = (field: SortField) => {
if (sortField !== field) return null; if (sortField !== field) return null;
return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />; return sortOrder === "asc" ? (
<ArrowUpIcon fontSize="small" />
) : (
<ArrowDownIcon fontSize="small" />
);
}; };
const getDisplayTitle = () => { const getDisplayTitle = () => {
@ -225,21 +250,25 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
<Paper <Paper
elevation={isMobile ? 0 : 1} elevation={isMobile ? 0 : 1}
sx={{ sx={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
...(isMobile ? { ...(isMobile
width: '100%', ? {
boxShadow: 'none', width: "100%",
backgroundColor: 'transparent' boxShadow: "none",
} : { width: '50%' }) backgroundColor: "transparent",
}
: { width: "50%" }),
}} }}
> >
<Box sx={{ <Box
sx={{
p: isMobile ? 0.5 : 1, p: isMobile ? 0.5 : 1,
borderBottom: 1, borderBottom: 1,
borderColor: 'divider', borderColor: "divider",
backgroundColor: isMobile ? 'background.paper' : 'inherit' backgroundColor: isMobile ? "background.paper" : "inherit",
}}> }}
>
<Typography <Typography
variant={isSmall ? "subtitle2" : isMobile ? "subtitle1" : "h6"} variant={isSmall ? "subtitle2" : isMobile ? "subtitle1" : "h6"}
gutterBottom gutterBottom
@ -248,7 +277,14 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
{getDisplayTitle()} ({sortedResumes.length}) {getDisplayTitle()} ({sortedResumes.length})
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, flexDirection: isSmall ? 'column' : 'row', alignItems: isSmall ? 'stretch' : 'center' }}> <Box
sx={{
display: "flex",
gap: 1,
flexDirection: isSmall ? "column" : "row",
alignItems: isSmall ? "stretch" : "center",
}}
>
<TextField <TextField
size="small" size="small"
placeholder="Search resumes..." placeholder="Search resumes..."
@ -266,18 +302,21 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
<ClearIcon fontSize="small" /> <ClearIcon fontSize="small" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
) ),
}} }}
sx={{ flexGrow: 1, minWidth: isSmall ? '100%' : 200 }} sx={{ flexGrow: 1, minWidth: isSmall ? "100%" : 200 }}
/> />
<FormControl size="small" sx={{ minWidth: isSmall ? '100%' : 180 }}> <FormControl size="small" sx={{ minWidth: isSmall ? "100%" : 180 }}>
<InputLabel>Sort by</InputLabel> <InputLabel>Sort by</InputLabel>
<Select <Select
value={`${sortField}-${sortOrder}`} value={`${sortField}-${sortOrder}`}
label="Sort by" label="Sort by"
onChange={(e) => { onChange={(e) => {
const [field, order] = e.target.value.split('-') as [SortField, SortOrder]; const [field, order] = e.target.value.split("-") as [
SortField,
SortOrder
];
setSortField(field); setSortField(field);
setSortOrder(order); setSortOrder(order);
}} }}
@ -295,76 +334,84 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
</Box> </Box>
</Box> </Box>
<TableContainer sx={{ <TableContainer
sx={{
flex: 1, flex: 1,
overflow: 'auto', overflow: "auto",
'& .MuiTable-root': { "& .MuiTable-root": {
tableLayout: isMobile ? 'fixed' : 'auto' tableLayout: isMobile ? "fixed" : "auto",
} },
}}> }}
>
<Table stickyHeader size="small"> <Table stickyHeader size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell <TableCell
sx={{ sx={{
cursor: 'pointer', cursor: "pointer",
userSelect: 'none', userSelect: "none",
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
width: isMobile ? '35%' : 'auto', width: isMobile ? "35%" : "auto",
backgroundColor: 'background.paper' backgroundColor: "background.paper",
}} }}
onClick={() => handleSort('candidateId')} onClick={() => handleSort("candidateId")}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<PersonIcon fontSize={isMobile ? "small" : "medium"} /> <PersonIcon fontSize={isMobile ? "small" : "medium"} />
<Typography variant="caption" fontWeight="bold" noWrap> <Typography variant="caption" fontWeight="bold" noWrap>
{isSmall ? 'Candidate' : 'Candidate'} {isSmall ? "Candidate" : "Candidate"}
</Typography> </Typography>
{getSortIcon('candidateId')} {getSortIcon("candidateId")}
</Box> </Box>
</TableCell> </TableCell>
<TableCell <TableCell
sx={{ sx={{
cursor: 'pointer', cursor: "pointer",
userSelect: 'none', userSelect: "none",
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
width: isMobile ? '35%' : 'auto', width: isMobile ? "35%" : "auto",
backgroundColor: 'background.paper' backgroundColor: "background.paper",
}} }}
onClick={() => handleSort('jobId')} onClick={() => handleSort("jobId")}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<WorkIcon fontSize={isMobile ? "small" : "medium"} /> <WorkIcon fontSize={isMobile ? "small" : "medium"} />
<Typography variant="caption" fontWeight="bold" noWrap>Job</Typography> <Typography variant="caption" fontWeight="bold" noWrap>
{getSortIcon('jobId')} Job
</Typography>
{getSortIcon("jobId")}
</Box> </Box>
</TableCell> </TableCell>
{!isMobile && ( {!isMobile && (
<TableCell <TableCell
sx={{ sx={{
cursor: 'pointer', cursor: "pointer",
userSelect: 'none', userSelect: "none",
py: 0.5, py: 0.5,
px: 1, px: 1,
backgroundColor: 'background.paper' backgroundColor: "background.paper",
}} }}
onClick={() => handleSort('updatedAt')} onClick={() => handleSort("updatedAt")}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<ScheduleIcon fontSize="medium" /> <ScheduleIcon fontSize="medium" />
<Typography variant="caption" fontWeight="bold">Updated</Typography> <Typography variant="caption" fontWeight="bold">
{getSortIcon('updatedAt')} Updated
</Typography>
{getSortIcon("updatedAt")}
</Box> </Box>
</TableCell> </TableCell>
)} )}
<TableCell sx={{ <TableCell
sx={{
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
width: isMobile ? '30%' : 'auto', width: isMobile ? "30%" : "auto",
backgroundColor: 'background.paper' backgroundColor: "background.paper",
}}> }}
>
<Typography variant="caption" fontWeight="bold" noWrap> <Typography variant="caption" fontWeight="bold" noWrap>
ID ID
</Typography> </Typography>
@ -379,26 +426,28 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
selected={selectedResume?.id === resume.id} selected={selectedResume?.id === resume.id}
onClick={() => handleResumeSelect(resume)} onClick={() => handleResumeSelect(resume)}
sx={{ sx={{
cursor: 'pointer', cursor: "pointer",
height: isMobile ? 48 : 'auto', height: isMobile ? 48 : "auto",
'&.Mui-selected': { "&.Mui-selected": {
backgroundColor: 'action.selected', backgroundColor: "action.selected",
},
"&:hover": {
backgroundColor: "action.hover",
}, },
'&:hover': {
backgroundColor: 'action.hover',
}
}} }}
> >
<TableCell sx={{ <TableCell
sx={{
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
overflow: 'hidden' overflow: "hidden",
}}> }}
>
<Typography <Typography
variant={isMobile ? "caption" : "body2"} variant={isMobile ? "caption" : "body2"}
fontWeight="medium" fontWeight="medium"
noWrap noWrap
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }} sx={{ fontSize: isMobile ? "0.75rem" : "0.875rem" }}
> >
{resume.candidate?.fullName || resume.candidateId} {resume.candidate?.fullName || resume.candidateId}
</Typography> </Typography>
@ -407,31 +456,33 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
noWrap noWrap
sx={{ display: 'block', fontSize: '0.7rem' }} sx={{ display: "block", fontSize: "0.7rem" }}
> >
{formatDate(resume.updatedAt)} {formatDate(resume.updatedAt)}
</Typography> </Typography>
)} )}
</TableCell> </TableCell>
<TableCell sx={{ <TableCell
sx={{
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
overflow: 'hidden' overflow: "hidden",
}}> }}
>
<Typography <Typography
variant={isMobile ? "caption" : "body2"} variant={isMobile ? "caption" : "body2"}
fontWeight="medium" fontWeight="medium"
noWrap noWrap
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }} sx={{ fontSize: isMobile ? "0.75rem" : "0.875rem" }}
> >
{resume.job?.title || 'Unknown Job'} {resume.job?.title || "Unknown Job"}
</Typography> </Typography>
{!isMobile && resume.job?.company && ( {!isMobile && resume.job?.company && (
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
noWrap noWrap
sx={{ display: 'block', fontSize: '0.7rem' }} sx={{ display: "block", fontSize: "0.7rem" }}
> >
{resume.job.company} {resume.job.company}
</Typography> </Typography>
@ -439,30 +490,32 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
</TableCell> </TableCell>
{!isMobile && ( {!isMobile && (
<TableCell sx={{ py: 0.5, px: 1 }}> <TableCell sx={{ py: 0.5, px: 1 }}>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}> <Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{formatDate(resume.updatedAt)} {formatDate(resume.updatedAt)}
</Typography> </Typography>
{resume.createdAt && ( {resume.createdAt && (
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ display: 'block', fontSize: '0.7rem' }} sx={{ display: "block", fontSize: "0.7rem" }}
> >
Created: {formatDate(resume.createdAt)} Created: {formatDate(resume.createdAt)}
</Typography> </Typography>
)} )}
</TableCell> </TableCell>
)} )}
<TableCell sx={{ <TableCell
sx={{
py: isMobile ? 0.25 : 0.5, py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1, px: isMobile ? 0.5 : 1,
overflow: 'hidden' overflow: "hidden",
}}> }}
>
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
noWrap noWrap
sx={{ fontSize: isMobile ? '0.65rem' : '0.7rem' }} sx={{ fontSize: isMobile ? "0.65rem" : "0.7rem" }}
> >
{resume.id} {resume.id}
</Typography> </Typography>
@ -476,35 +529,39 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
); );
const ResumeDetails = ({ inDialog = false }: { inDialog?: boolean }) => ( const ResumeDetails = ({ inDialog = false }: { inDialog?: boolean }) => (
<Box sx={{ <Box
sx={{
flex: 1, flex: 1,
overflow: 'auto', overflow: "auto",
p: inDialog ? 1.5 : 0.75, p: inDialog ? 1.5 : 0.75,
height: inDialog ? '100%' : 'auto' height: inDialog ? "100%" : "auto",
}}> }}
>
{selectedResume ? ( {selectedResume ? (
<ResumeInfo <ResumeInfo
resume={selectedResume} resume={selectedResume}
variant="all" variant="all"
sx={{ sx={{
border: 'none', border: "none",
boxShadow: 'none', boxShadow: "none",
backgroundColor: 'transparent', backgroundColor: "transparent",
'& .MuiTypography-h6': { "& .MuiTypography-h6": {
fontSize: inDialog ? '1.25rem' : '1.1rem' fontSize: inDialog ? "1.25rem" : "1.1rem",
} },
}} }}
/> />
) : ( ) : (
<Box sx={{ <Box
display: 'flex', sx={{
alignItems: 'center', display: "flex",
justifyContent: 'center', alignItems: "center",
height: '100%', justifyContent: "center",
color: 'text.secondary', height: "100%",
textAlign: 'center', color: "text.secondary",
p: 2 textAlign: "center",
}}> p: 2,
}}
>
<Typography variant="body2"> <Typography variant="body2">
Select a resume to view details Select a resume to view details
</Typography> </Typography>
@ -515,11 +572,13 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
if (isMobile) { if (isMobile) {
return ( return (
<Box sx={{ <Box
height: '100%', sx={{
height: "100%",
p: 0.5, p: 0.5,
backgroundColor: 'background.default' backgroundColor: "background.default",
}}> }}
>
<ResumeList /> <ResumeList />
<Dialog <Dialog
@ -529,7 +588,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
TransitionComponent={Transition} TransitionComponent={Transition}
TransitionProps={{ timeout: 300 }} TransitionProps={{ timeout: 300 }}
> >
<AppBar sx={{ position: 'relative', elevation: 1 }}> <AppBar sx={{ position: "relative", elevation: 1 }}>
<Toolbar variant="dense" sx={{ minHeight: 48 }}> <Toolbar variant="dense" sx={{ minHeight: 48 }}>
<IconButton <IconButton
edge="start" edge="start"
@ -545,17 +604,18 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
variant="h6" variant="h6"
component="div" component="div"
noWrap noWrap
sx={{ fontSize: '1rem' }} sx={{ fontSize: "1rem" }}
> >
Resume Details Resume Details
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
component="div" component="div"
sx={{ color: 'rgba(255, 255, 255, 0.7)' }} sx={{ color: "rgba(255, 255, 255, 0.7)" }}
noWrap noWrap
> >
{selectedResume?.candidate?.fullName || selectedResume?.candidateId} {selectedResume?.candidate?.fullName ||
selectedResume?.candidateId}
</Typography> </Typography>
</Box> </Box>
</Toolbar> </Toolbar>
@ -567,28 +627,34 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
} }
return ( return (
<Box sx={{ <Box
display: 'flex', sx={{
height: '100%', display: "flex",
height: "100%",
gap: 0.75, gap: 0.75,
p: 0.75, p: 0.75,
backgroundColor: 'background.default' backgroundColor: "background.default",
}}> }}
>
<ResumeList /> <ResumeList />
<Paper sx={{ <Paper
width: '50%', sx={{
display: 'flex', width: "50%",
flexDirection: 'column', display: "flex",
elevation: 1 flexDirection: "column",
}}> elevation: 1,
<Box sx={{ }}
>
<Box
sx={{
p: 0.75, p: 0.75,
borderBottom: 1, borderBottom: 1,
borderColor: 'divider', borderColor: "divider",
backgroundColor: 'background.paper' backgroundColor: "background.paper",
}}> }}
<Typography variant="h6" sx={{ fontSize: '1.1rem', fontWeight: 600 }}> >
<Typography variant="h6" sx={{ fontSize: "1.1rem", fontWeight: 600 }}>
Resume Details Resume Details
</Typography> </Typography>
</Box> </Box>

View File

@ -1,4 +1,4 @@
import React from 'react'; import React from "react";
import { import {
SyncAlt, SyncAlt,
Favorite, Favorite,
@ -9,18 +9,18 @@ import {
Image, Image,
Psychology, Psychology,
Build, Build,
} from '@mui/icons-material'; } from "@mui/icons-material";
import { styled } from '@mui/material/styles'; import { styled } from "@mui/material/styles";
import * as Types from 'types/types'; import * as Types from "types/types";
import { Box } from '@mui/material'; import { Box } from "@mui/material";
interface StatusIconProps { interface StatusIconProps {
type: Types.ApiActivityType; type: Types.ApiActivityType;
} }
const StatusBox = styled(Box)(({ theme }) => ({ const StatusBox = styled(Box)(({ theme }) => ({
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
gap: theme.spacing(1), gap: theme.spacing(1),
padding: theme.spacing(1, 2), padding: theme.spacing(1, 2),
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
@ -33,23 +33,23 @@ const StatusIcon = (props: StatusIconProps) => {
const { type } = props; const { type } = props;
switch (type) { switch (type) {
case 'converting': case "converting":
return <SyncAlt color="primary" />; return <SyncAlt color="primary" />;
case 'heartbeat': case "heartbeat":
return <Favorite color="error" />; return <Favorite color="error" />;
case 'system': case "system":
return <Settings color="action" />; return <Settings color="action" />;
case 'info': case "info":
return <Info color="info" />; return <Info color="info" />;
case 'searching': case "searching":
return <Search color="primary" />; return <Search color="primary" />;
case 'generating': case "generating":
return <AutoFixHigh color="secondary" />; return <AutoFixHigh color="secondary" />;
case 'generating_image': case "generating_image":
return <Image color="primary" />; return <Image color="primary" />;
case 'thinking': case "thinking":
return <Psychology color="secondary" />; return <Psychology color="secondary" />;
case 'tooling': case "tooling":
return <Build color="action" />; return <Build color="action" />;
default: default:
return <Info color="action" />; return <Info color="action" />;

View File

@ -19,7 +19,7 @@ import {
BubbleChart, BubbleChart,
AutoFixHigh, AutoFixHigh,
} from "@mui/icons-material"; } from "@mui/icons-material";
import EditDocumentIcon from '@mui/icons-material/EditDocument'; import EditDocumentIcon from "@mui/icons-material/EditDocument";
import { BackstoryLogo } from "components/ui/BackstoryLogo"; import { BackstoryLogo } from "components/ui/BackstoryLogo";
import { HomePage } from "pages/HomePage"; import { HomePage } from "pages/HomePage";
@ -91,10 +91,8 @@ const LogoutPage = () => {
logout().then(() => { logout().then(() => {
navigate("/"); navigate("/");
}); });
return ( return <Typography variant="h4">Logging out...</Typography>;
<Typography variant="h4">Logging out...</Typography> };
);
}
const AnalyticsPage = () => ( const AnalyticsPage = () => (
<BetaPage> <BetaPage>
<Typography variant="h4">Analytics</Typography> <Typography variant="h4">Analytics</Typography>
@ -191,9 +189,7 @@ export const navigationConfig: NavigationConfig = {
label: "Jobs", label: "Jobs",
path: "/candidate/jobs/:jobId?", path: "/candidate/jobs/:jobId?",
icon: <WorkIcon />, icon: <WorkIcon />,
component: ( component: <JobViewer />,
<JobViewer />
),
userTypes: ["candidate", "guest", "employer"], userTypes: ["candidate", "guest", "employer"],
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
@ -204,9 +200,7 @@ export const navigationConfig: NavigationConfig = {
label: "Resumes", label: "Resumes",
path: "/candidate/resumes/:resumeId?", path: "/candidate/resumes/:resumeId?",
icon: <EditDocumentIcon />, icon: <EditDocumentIcon />,
component: ( component: <ResumeViewer />,
<ResumeViewer />
),
userTypes: ["candidate", "guest", "employer"], userTypes: ["candidate", "guest", "employer"],
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
@ -346,14 +340,18 @@ export const getNavigationItemsForUser = (
return items return items
.filter( .filter(
(item) => (item) =>
!item.userTypes || item.userTypes.includes(currentUserType) || (item.userTypes.includes("admin") && isAdmin) !item.userTypes ||
item.userTypes.includes(currentUserType) ||
(item.userTypes.includes("admin") && isAdmin)
) )
.filter((item) => item.showInNavigation !== false) // Default to true if not specified .filter((item) => item.showInNavigation !== false) // Default to true if not specified
.map((item) => ({ .map((item) => ({
...item, ...item,
children: item.children ? filterItems(item.children) : undefined, children: item.children ? filterItems(item.children) : undefined,
})) }))
.filter((item) => item.path || (item.children && item.children.length > 0)); .filter(
(item) => item.path || (item.children && item.children.length > 0)
);
}; };
return filterItems(navigationConfig.items); return filterItems(navigationConfig.items);
@ -369,7 +367,11 @@ export const getAllRoutes = (
const routes: NavigationItem[] = []; const routes: NavigationItem[] = [];
items.forEach((item) => { items.forEach((item) => {
if (!item.userTypes || item.userTypes.includes(currentUserType) || (item.userTypes.includes("admin") && isAdmin)) { if (
!item.userTypes ||
item.userTypes.includes(currentUserType) ||
(item.userTypes.includes("admin") && isAdmin)
) {
if (item.path && item.component) { if (item.path && item.component) {
routes.push(item); routes.push(item);
} }
@ -398,7 +400,10 @@ export const getMainNavigationItems = (
); );
}; };
export const getUserMenuItems = (userType: "candidate" | "employer" | "guest" | null, isAdmin: boolean): NavigationItem[] => { export const getUserMenuItems = (
userType: "candidate" | "employer" | "guest" | null,
isAdmin: boolean
): NavigationItem[] => {
if (!userType) return []; if (!userType) return [];
const extractUserMenuItems = (items: NavigationItem[]): NavigationItem[] => { const extractUserMenuItems = (items: NavigationItem[]): NavigationItem[] => {

View File

@ -1 +1 @@
declare module 'react-plotly.js'; declare module "react-plotly.js";

View File

@ -1,15 +1,21 @@
import React from 'react'; import React from "react";
import { Box, Typography, Paper, Container } from '@mui/material'; import { Box, Typography, Paper, Container } from "@mui/material";
// Import the backstoryTheme // Import the backstoryTheme
// BackstoryAnalysisDisplay component // BackstoryAnalysisDisplay component
const BackstoryAppAnalysisPage = () => { const BackstoryAppAnalysisPage = () => {
return ( return (
<Box sx={{ backgroundColor: 'background.default', minHeight: '100%', py: 4 }}> <Box
sx={{ backgroundColor: "background.default", minHeight: "100%", py: 4 }}
>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Paper sx={{ p: 4, boxShadow: 2 }}> <Paper sx={{ p: 4, boxShadow: 2 }}>
<Typography variant="h1" component="h1" sx={{ mb: 3, color: 'primary.main' }}> <Typography
variant="h1"
component="h1"
sx={{ mb: 3, color: "primary.main" }}
>
Backstory Application Analysis Backstory Application Analysis
</Typography> </Typography>
@ -17,8 +23,9 @@ const BackstoryAppAnalysisPage = () => {
Core Concept Core Concept
</Typography> </Typography>
<Typography variant="body1"> <Typography variant="body1">
Backstory is a dual-purpose platform designed to bridge the gap between job candidates and Backstory is a dual-purpose platform designed to bridge the gap
employers/recruiters with an AI-powered approach to professional profiles and resume generation. between job candidates and employers/recruiters with an AI-powered
approach to professional profiles and resume generation.
</Typography> </Typography>
<Typography variant="h3" component="h3" sx={{ mt: 3 }}> <Typography variant="h3" component="h3" sx={{ mt: 3 }}>
@ -27,14 +34,16 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ol" sx={{ pl: 4 }}> <Box component="ol" sx={{ pl: 4 }}>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Job Candidates</strong> - Upload and manage comprehensive professional histories <strong>Job Candidates</strong> - Upload and manage
and generate tailored resumes for specific positions comprehensive professional histories and generate tailored
resumes for specific positions
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Employers/Recruiters</strong> - Search for candidates, directly interact with AI <strong>Employers/Recruiters</strong> - Search for candidates,
assistants about candidate experiences, and generate position-specific resumes directly interact with AI assistants about candidate
experiences, and generate position-specific resumes
</Typography> </Typography>
</li> </li>
</Box> </Box>
@ -49,27 +58,32 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}> <Box component="ul" sx={{ pl: 4 }}>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Complete Profile Management</strong> - Create detailed professional histories beyond typical resume constraints <strong>Complete Profile Management</strong> - Create detailed
professional histories beyond typical resume constraints
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>AI-Assisted Q&A Setup</strong> - Configure an AI assistant to answer employer questions about your experience <strong>AI-Assisted Q&A Setup</strong> - Configure an AI
assistant to answer employer questions about your experience
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Smart Resume Generator</strong> - Create tailored resumes for specific positions using AI <strong>Smart Resume Generator</strong> - Create tailored
resumes for specific positions using AI
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Analytics Dashboard</strong> - Track profile views, resume downloads, and employer engagement <strong>Analytics Dashboard</strong> - Track profile views,
resume downloads, and employer engagement
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Privacy Controls</strong> - Manage visibility and access to your professional information <strong>Privacy Controls</strong> - Manage visibility and access
to your professional information
</Typography> </Typography>
</li> </li>
</Box> </Box>
@ -80,27 +94,32 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}> <Box component="ul" sx={{ pl: 4 }}>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Advanced Candidate Search</strong> - Find candidates with specific skills, experience levels, and qualifications <strong>Advanced Candidate Search</strong> - Find candidates
with specific skills, experience levels, and qualifications
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Interactive Q&A</strong> - Ask questions directly to candidate AI assistants to learn more about their experience <strong>Interactive Q&A</strong> - Ask questions directly to
candidate AI assistants to learn more about their experience
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Resume Generation</strong> - Generate candidate resumes tailored to specific job requirements <strong>Resume Generation</strong> - Generate candidate resumes
tailored to specific job requirements
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Talent Pool Management</strong> - Create and manage groups of candidates for different positions <strong>Talent Pool Management</strong> - Create and manage
groups of candidates for different positions
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Job Posting Management</strong> - Create, manage, and track applications for job postings <strong>Job Posting Management</strong> - Create, manage, and
track applications for job postings
</Typography> </Typography>
</li> </li>
</Box> </Box>
@ -118,17 +137,20 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}> <Box component="ul" sx={{ pl: 4 }}>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Public Navigation</strong> - Home, Docs, Pricing, Login/Register accessible to all users <strong>Public Navigation</strong> - Home, Docs, Pricing,
Login/Register accessible to all users
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Candidate Dashboard Navigation</strong> - Profile, Backstory, Resumes, Q&A Setup, Analytics, Settings <strong>Candidate Dashboard Navigation</strong> - Profile,
Backstory, Resumes, Q&A Setup, Analytics, Settings
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Employer Dashboard Navigation</strong> - Dashboard, Search, Saved, Jobs, Company, Analytics, Settings <strong>Employer Dashboard Navigation</strong> - Dashboard,
Search, Saved, Jobs, Company, Analytics, Settings
</Typography> </Typography>
</li> </li>
</Box> </Box>
@ -139,32 +161,38 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ol" sx={{ pl: 4 }}> <Box component="ol" sx={{ pl: 4 }}>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Dashboard Cards</strong> - Both user types have dashboards with card-based information displays <strong>Dashboard Cards</strong> - Both user types have
dashboards with card-based information displays
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Tab-Based Content Organization</strong> - Many screens use horizontal tabs to organize related content <strong>Tab-Based Content Organization</strong> - Many screens
use horizontal tabs to organize related content
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Form-Based Editors</strong> - Profile and content editors use structured forms with varied input types <strong>Form-Based Editors</strong> - Profile and content
editors use structured forms with varied input types
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Three-Column Layouts</strong> - Many screens follow a left sidebar, main content, right sidebar pattern <strong>Three-Column Layouts</strong> - Many screens follow a
left sidebar, main content, right sidebar pattern
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Preview/Edit Toggle</strong> - Resume and profile editing screens offer both editing and preview modes <strong>Preview/Edit Toggle</strong> - Resume and profile
editing screens offer both editing and preview modes
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Filter-Based Search</strong> - Employer search uses multiple filter categories to refine candidate results <strong>Filter-Based Search</strong> - Employer search uses
multiple filter categories to refine candidate results
</Typography> </Typography>
</li> </li>
</Box> </Box>
@ -173,8 +201,9 @@ const BackstoryAppAnalysisPage = () => {
Mobile Adaptations Mobile Adaptations
</Typography> </Typography>
<Typography variant="body1"> <Typography variant="body1">
The mobile designs show a simplified navigation structure with bottom tabs and a hamburger menu, The mobile designs show a simplified navigation structure with
maintaining the core functionality while adapting to smaller screens. bottom tabs and a hamburger menu, maintaining the core functionality
while adapting to smaller screens.
</Typography> </Typography>
<Typography variant="h2" component="h2" sx={{ mt: 4 }}> <Typography variant="h2" component="h2" sx={{ mt: 4 }}>
@ -187,22 +216,26 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}> <Box component="ul" sx={{ pl: 4 }}>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>LLM Integration</strong> - Supports multiple AI models (Claude, GPT-4, self-hosted models) <strong>LLM Integration</strong> - Supports multiple AI models
(Claude, GPT-4, self-hosted models)
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Candidate AI Assistant</strong> - Personalized AI chatbot that answers questions about candidate experience <strong>Candidate AI Assistant</strong> - Personalized AI
chatbot that answers questions about candidate experience
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Resume Generation</strong> - AI-powered resume creation based on job requirements <strong>Resume Generation</strong> - AI-powered resume creation
based on job requirements
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Skills Matching</strong> - Automated matching between candidate skills and job requirements <strong>Skills Matching</strong> - Automated matching between
candidate skills and job requirements
</Typography> </Typography>
</li> </li>
</Box> </Box>
@ -213,22 +246,26 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}> <Box component="ul" sx={{ pl: 4 }}>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Authentication</strong> - OAuth with Google, LinkedIn, GitHub <strong>Authentication</strong> - OAuth with Google, LinkedIn,
GitHub
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Data Import</strong> - LinkedIn profile import, resume parsing (PDF, DOCX), CSV/JSON import <strong>Data Import</strong> - LinkedIn profile import, resume
parsing (PDF, DOCX), CSV/JSON import
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>ATS Compatibility</strong> - Integration with employer Applicant Tracking Systems <strong>ATS Compatibility</strong> - Integration with employer
Applicant Tracking Systems
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Vector Databases</strong> - Semantic search capabilities for candidate matching <strong>Vector Databases</strong> - Semantic search capabilities
for candidate matching
</Typography> </Typography>
</li> </li>
</Box> </Box>
@ -239,27 +276,32 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ol" sx={{ pl: 4 }}> <Box component="ol" sx={{ pl: 4 }}>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Beyond the Resume</strong> - Focuses on comprehensive professional stories rather than just resume highlights <strong>Beyond the Resume</strong> - Focuses on comprehensive
professional stories rather than just resume highlights
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>AI-Mediated Communication</strong> - Uses AI to facilitate deeper understanding of candidate experiences <strong>AI-Mediated Communication</strong> - Uses AI to
facilitate deeper understanding of candidate experiences
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Bidirectional Resume Generation</strong> - Both candidates and employers can generate tailored resumes <strong>Bidirectional Resume Generation</strong> - Both
candidates and employers can generate tailored resumes
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Configurable AI Personalities</strong> - Candidates can customize how their AI assistant responds to questions <strong>Configurable AI Personalities</strong> - Candidates can
customize how their AI assistant responds to questions
</Typography> </Typography>
</li> </li>
<li> <li>
<Typography variant="body1" component="div"> <Typography variant="body1" component="div">
<strong>Deep Analytics</strong> - Both candidates and employers receive insights about their engagement <strong>Deep Analytics</strong> - Both candidates and employers
receive insights about their engagement
</Typography> </Typography>
</li> </li>
</Box> </Box>
@ -278,7 +320,9 @@ const BackstoryAppAnalysisPage = () => {
<Typography variant="body1">Cloud-hosted (SaaS model)</Typography> <Typography variant="body1">Cloud-hosted (SaaS model)</Typography>
</li> </li>
<li> <li>
<Typography variant="body1">Hybrid deployment (mixed cloud/on-premises)</Typography> <Typography variant="body1">
Hybrid deployment (mixed cloud/on-premises)
</Typography>
</li> </li>
</Box> </Box>
@ -287,13 +331,19 @@ const BackstoryAppAnalysisPage = () => {
</Typography> </Typography>
<Box component="ul" sx={{ pl: 4 }}> <Box component="ul" sx={{ pl: 4 }}>
<li> <li>
<Typography variant="body1">Granular candidate privacy controls</Typography> <Typography variant="body1">
Granular candidate privacy controls
</Typography>
</li> </li>
<li> <li>
<Typography variant="body1">Role-based access for employer teams</Typography> <Typography variant="body1">
Role-based access for employer teams
</Typography>
</li> </li>
<li> <li>
<Typography variant="body1">Data management options for compliance requirements</Typography> <Typography variant="body1">
Data management options for compliance requirements
</Typography>
</li> </li>
</Box> </Box>
</Paper> </Paper>
@ -302,7 +352,4 @@ const BackstoryAppAnalysisPage = () => {
); );
}; };
export { BackstoryAppAnalysisPage };
export {
BackstoryAppAnalysisPage
}

View File

@ -1,14 +1,15 @@
import React from 'react'; import React from "react";
import { backstoryTheme } from '../BackstoryTheme'; import { backstoryTheme } from "../BackstoryTheme";
import { Box, Paper, Container } from '@mui/material'; import { Box, Paper, Container } from "@mui/material";
// This component provides a visual demonstration of the theme colors // This component provides a visual demonstration of the theme colors
const BackstoryThemeVisualizerPage = () => { const BackstoryThemeVisualizerPage = () => {
const colorSwatch = (color: string, name: string, textColor = '#fff') => ( const colorSwatch = (color: string, name: string, textColor = "#fff") => (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className="w-20 h-20 rounded-lg shadow-md flex items-center justify-center mb-2" className="w-20 h-20 rounded-lg shadow-md flex items-center justify-center mb-2"
style={{ backgroundColor: color, color: textColor }}> style={{ backgroundColor: color, color: textColor }}
>
{name} {name}
</div> </div>
<span className="text-xs">{color}</span> <span className="text-xs">{color}</span>
@ -16,70 +17,125 @@ const BackstoryThemeVisualizerPage = () => {
); );
return ( return (
<Box sx={{ backgroundColor: 'background.default', minHeight: '100%', py: 4 }}> <Box
sx={{ backgroundColor: "background.default", minHeight: "100%", py: 4 }}
>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Paper sx={{ p: 4, boxShadow: 2 }}> <Paper sx={{ p: 4, boxShadow: 2 }}>
<div className="p-8"> <div className="p-8">
<h1 className="text-2xl font-bold mb-6" style={{ color: backstoryTheme.palette.text.primary }}> <h1
className="text-2xl font-bold mb-6"
style={{ color: backstoryTheme.palette.text.primary }}
>
Backstory Theme Visualization Backstory Theme Visualization
</h1> </h1>
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}> <h2
className="text-xl mb-4"
style={{ color: backstoryTheme.palette.text.primary }}
>
Primary Colors Primary Colors
</h2> </h2>
<div className="flex space-x-4"> <div className="flex space-x-4">
{colorSwatch(backstoryTheme.palette.primary.main, 'Primary', backstoryTheme.palette.primary.contrastText)} {colorSwatch(
{colorSwatch(backstoryTheme.palette.secondary.main, 'Secondary', backstoryTheme.palette.secondary.contrastText)} backstoryTheme.palette.primary.main,
{colorSwatch(backstoryTheme.palette.custom.highlight, 'Highlight', '#fff')} "Primary",
backstoryTheme.palette.primary.contrastText
)}
{colorSwatch(
backstoryTheme.palette.secondary.main,
"Secondary",
backstoryTheme.palette.secondary.contrastText
)}
{colorSwatch(
backstoryTheme.palette.custom.highlight,
"Highlight",
"#fff"
)}
</div> </div>
</div> </div>
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}> <h2
className="text-xl mb-4"
style={{ color: backstoryTheme.palette.text.primary }}
>
Background Colors Background Colors
</h2> </h2>
<div className="flex space-x-4"> <div className="flex space-x-4">
{colorSwatch(backstoryTheme.palette.background.default, 'Default', '#000')} {colorSwatch(
{colorSwatch(backstoryTheme.palette.background.paper, 'Paper', '#000')} backstoryTheme.palette.background.default,
"Default",
"#000"
)}
{colorSwatch(
backstoryTheme.palette.background.paper,
"Paper",
"#000"
)}
</div> </div>
</div> </div>
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}> <h2
className="text-xl mb-4"
style={{ color: backstoryTheme.palette.text.primary }}
>
Text Colors Text Colors
</h2> </h2>
<div className="flex space-x-4"> <div className="flex space-x-4">
{colorSwatch(backstoryTheme.palette.text.primary, 'Primary', '#fff')} {colorSwatch(
{colorSwatch(backstoryTheme.palette.text.secondary, 'Secondary', '#fff')} backstoryTheme.palette.text.primary,
"Primary",
"#fff"
)}
{colorSwatch(
backstoryTheme.palette.text.secondary,
"Secondary",
"#fff"
)}
</div> </div>
</div> </div>
<div className="mb-8 border p-6 rounded-lg" style={{ backgroundColor: backstoryTheme.palette.background.paper }}> <div
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}> className="mb-8 border p-6 rounded-lg"
style={{
backgroundColor: backstoryTheme.palette.background.paper,
}}
>
<h2
className="text-xl mb-4"
style={{ color: backstoryTheme.palette.text.primary }}
>
Typography Examples Typography Examples
</h2> </h2>
<div className="mb-4"> <div className="mb-4">
<h1 style={{ <h1
style={{
fontFamily: backstoryTheme.typography.fontFamily, fontFamily: backstoryTheme.typography.fontFamily,
fontSize: backstoryTheme.typography.h1.fontSize, fontSize: backstoryTheme.typography.h1.fontSize,
fontWeight: backstoryTheme.typography.h1.fontWeight, fontWeight: backstoryTheme.typography.h1.fontWeight,
color: backstoryTheme.typography.h1.color, color: backstoryTheme.typography.h1.color,
}}> }}
>
Heading 1 - Backstory Application Heading 1 - Backstory Application
</h1> </h1>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<p style={{ <p
style={{
fontFamily: backstoryTheme.typography.fontFamily, fontFamily: backstoryTheme.typography.fontFamily,
fontSize: backstoryTheme.typography.body1.fontSize, fontSize: backstoryTheme.typography.body1.fontSize,
color: backstoryTheme.typography.body1.color, color: backstoryTheme.typography.body1.color,
}}> }}
Body Text - This is how the regular text content will appear in the Backstory application. >
The application uses Roboto as its primary font family, with carefully selected sizing and colors. Body Text - This is how the regular text content will appear
in the Backstory application. The application uses Roboto as
its primary font family, with carefully selected sizing and
colors.
</p> </p>
</div> </div>
@ -94,109 +150,253 @@ const BackstoryThemeVisualizerPage = () => {
</div> </div>
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}> <h2
className="text-xl mb-4"
style={{ color: backstoryTheme.palette.text.primary }}
>
UI Component Examples UI Component Examples
</h2> </h2>
<div className="p-4 mb-4 rounded-lg" style={{ backgroundColor: backstoryTheme.palette.background.paper }}> <div
<div className="p-2 mb-4 rounded" style={{ backgroundColor: backstoryTheme.palette.primary.main }}> className="p-4 mb-4 rounded-lg"
<span style={{ color: backstoryTheme.palette.primary.contrastText }}> style={{
backgroundColor: backstoryTheme.palette.background.paper,
}}
>
<div
className="p-2 mb-4 rounded"
style={{
backgroundColor: backstoryTheme.palette.primary.main,
}}
>
<span
style={{
color: backstoryTheme.palette.primary.contrastText,
}}
>
AppBar Background AppBar Background
</span> </span>
</div> </div>
<div style={{ <div
padding: '8px 16px', style={{
padding: "8px 16px",
backgroundColor: backstoryTheme.palette.primary.main, backgroundColor: backstoryTheme.palette.primary.main,
color: backstoryTheme.palette.primary.contrastText, color: backstoryTheme.palette.primary.contrastText,
display: 'inline-block', display: "inline-block",
borderRadius: '4px', borderRadius: "4px",
cursor: 'pointer', cursor: "pointer",
fontFamily: backstoryTheme.typography.fontFamily, fontFamily: backstoryTheme.typography.fontFamily,
}}> }}
>
Primary Button Primary Button
</div> </div>
<div className="mt-4" style={{ <div
padding: '8px 16px', className="mt-4"
style={{
padding: "8px 16px",
backgroundColor: backstoryTheme.palette.secondary.main, backgroundColor: backstoryTheme.palette.secondary.main,
color: backstoryTheme.palette.secondary.contrastText, color: backstoryTheme.palette.secondary.contrastText,
display: 'inline-block', display: "inline-block",
borderRadius: '4px', borderRadius: "4px",
cursor: 'pointer', cursor: "pointer",
fontFamily: backstoryTheme.typography.fontFamily, fontFamily: backstoryTheme.typography.fontFamily,
}}> }}
>
Secondary Button Secondary Button
</div> </div>
<div className="mt-4" style={{ <div
padding: '8px 16px', className="mt-4"
style={{
padding: "8px 16px",
backgroundColor: backstoryTheme.palette.action.active, backgroundColor: backstoryTheme.palette.action.active,
color: '#fff', color: "#fff",
display: 'inline-block', display: "inline-block",
borderRadius: '4px', borderRadius: "4px",
cursor: 'pointer', cursor: "pointer",
fontFamily: backstoryTheme.typography.fontFamily, fontFamily: backstoryTheme.typography.fontFamily,
}}> }}
>
Action Button Action Button
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}> <h2
className="text-xl mb-4"
style={{ color: backstoryTheme.palette.text.primary }}
>
Theme Color Breakdown Theme Color Breakdown
</h2> </h2>
<table className="border-collapse"> <table className="border-collapse">
<thead> <thead>
<tr> <tr>
<th className="border p-2 text-left" <th
style={{ backgroundColor: backstoryTheme.palette.background.default, color: backstoryTheme.palette.text.primary }}>Color Name</th> className="border p-2 text-left"
<th className="border p-2 text-left" style={{
style={{ backgroundColor: backstoryTheme.palette.background.default, color: backstoryTheme.palette.text.primary }}>Hex Value</th> backgroundColor:
<th className="border p-2 text-left" backstoryTheme.palette.background.default,
style={{ backgroundColor: backstoryTheme.palette.background.default, color: backstoryTheme.palette.text.primary }}>Description</th> color: backstoryTheme.palette.text.primary,
}}
>
Color Name
</th>
<th
className="border p-2 text-left"
style={{
backgroundColor:
backstoryTheme.palette.background.default,
color: backstoryTheme.palette.text.primary,
}}
>
Hex Value
</th>
<th
className="border p-2 text-left"
style={{
backgroundColor:
backstoryTheme.palette.background.default,
color: backstoryTheme.palette.text.primary,
}}
>
Description
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Primary Main</td> <td
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.primary.main}</td> className="border p-2"
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Midnight Blue - Used for main headers and primary UI elements</td> style={{ color: backstoryTheme.palette.text.primary }}
>
Primary Main
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
{backstoryTheme.palette.primary.main}
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
Midnight Blue - Used for main headers and primary UI
elements
</td>
</tr> </tr>
<tr> <tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Primary Contrast</td> <td
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.primary.contrastText}</td> className="border p-2"
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Warm Gray - Text that appears on primary color backgrounds</td> style={{ color: backstoryTheme.palette.text.primary }}
>
Primary Contrast
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
{backstoryTheme.palette.primary.contrastText}
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
Warm Gray - Text that appears on primary color backgrounds
</td>
</tr> </tr>
<tr> <tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Secondary Main</td> <td
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.secondary.main}</td> className="border p-2"
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Dusty Teal - Used for secondary actions and accents</td> style={{ color: backstoryTheme.palette.text.primary }}
>
Secondary Main
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
{backstoryTheme.palette.secondary.main}
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
Dusty Teal - Used for secondary actions and accents
</td>
</tr> </tr>
<tr> <tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Highlight</td> <td
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.custom.highlight}</td> className="border p-2"
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Golden Ochre - Used for highlights, accents, and important actions</td> style={{ color: backstoryTheme.palette.text.primary }}
>
Highlight
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
{backstoryTheme.palette.custom.highlight}
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
Golden Ochre - Used for highlights, accents, and important
actions
</td>
</tr> </tr>
<tr> <tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Background Default</td> <td
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.background.default}</td> className="border p-2"
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Warm Gray - Main background color for the application</td> style={{ color: backstoryTheme.palette.text.primary }}
>
Background Default
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
{backstoryTheme.palette.background.default}
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
Warm Gray - Main background color for the application
</td>
</tr> </tr>
<tr> <tr>
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Text Primary</td> <td
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>{backstoryTheme.palette.text.primary}</td> className="border p-2"
<td className="border p-2" style={{ color: backstoryTheme.palette.text.primary }}>Charcoal Black - Primary text color throughout the app</td> style={{ color: backstoryTheme.palette.text.primary }}
>
Text Primary
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
{backstoryTheme.palette.text.primary}
</td>
<td
className="border p-2"
style={{ color: backstoryTheme.palette.text.primary }}
>
Charcoal Black - Primary text color throughout the app
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</Paper></Container></Box> </Paper>
</Container>
</Box>
); );
}; };
export { export { BackstoryThemeVisualizerPage };
BackstoryThemeVisualizerPage
};

View File

@ -1,48 +1,85 @@
import React from 'react'; import React from "react";
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from "@mui/material/styles";
import { Box, Container, Paper, Typography, Grid, CssBaseline } from '@mui/material'; import {
import { backstoryTheme } from 'BackstoryTheme'; Box,
Container,
Paper,
Typography,
Grid,
CssBaseline,
} from "@mui/material";
import { backstoryTheme } from "BackstoryTheme";
const BackstoryUIOverviewPage: React.FC = () => { const BackstoryUIOverviewPage: React.FC = () => {
return ( return (
<ThemeProvider theme={backstoryTheme}> <ThemeProvider theme={backstoryTheme}>
<CssBaseline /> <CssBaseline />
<Box sx={{ bgcolor: 'background.default', overflow: "hidden", py: 4 }}> <Box sx={{ bgcolor: "background.default", overflow: "hidden", py: 4 }}>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Paper sx={{ p: 4, borderRadius: 2, boxShadow: 2 }}> <Paper sx={{ p: 4, borderRadius: 2, boxShadow: 2 }}>
{/* Header */} {/* Header */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}> <Box
<Typography variant="h4" component="h1" sx={{ fontWeight: 'bold', color: 'primary.main', mb: 1 }}> sx={{
p: 3,
bgcolor: "background.paper",
borderRadius: 2,
mb: 4,
boxShadow: 1,
}}
>
<Typography
variant="h4"
component="h1"
sx={{ fontWeight: "bold", color: "primary.main", mb: 1 }}
>
Backstory UI Architecture Backstory UI Architecture
</Typography> </Typography>
<Typography variant="body1" color="text.secondary"> <Typography variant="body1" color="text.secondary">
A visual overview of the dual-purpose application serving candidates and employers A visual overview of the dual-purpose application serving
candidates and employers
</Typography> </Typography>
</Box> </Box>
{/* User Types */} {/* User Types */}
<Grid container spacing={3} sx={{ mb: 4 }}> <Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ <Box
sx={{
p: 3, p: 3,
bgcolor: 'rgba(74, 122, 125, 0.1)', bgcolor: "rgba(74, 122, 125, 0.1)",
borderRadius: 2, borderRadius: 2,
border: '1px solid', border: "1px solid",
borderColor: 'rgba(74, 122, 125, 0.3)', borderColor: "rgba(74, 122, 125, 0.3)",
height: '100%' height: "100%",
}}> }}
<Typography variant="h6" sx={{ color: 'secondary.main', mb: 2, fontWeight: 'bold' }}> >
<Typography
variant="h6"
sx={{ color: "secondary.main", mb: 2, fontWeight: "bold" }}
>
Candidate Experience Candidate Experience
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{[ {[
'Create comprehensive professional profiles', "Create comprehensive professional profiles",
'Configure AI assistant for employer Q&A', "Configure AI assistant for employer Q&A",
'Generate tailored resumes for specific jobs', "Generate tailored resumes for specific jobs",
'Track profile engagement metrics' "Track profile engagement metrics",
].map((item, index) => ( ].map((item, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'secondary.main' }} /> key={index}
sx={{ display: "flex", alignItems: "center", gap: 1.5 }}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: "secondary.main",
}}
/>
<Typography variant="body2">{item}</Typography> <Typography variant="body2">{item}</Typography>
</Box> </Box>
))} ))}
@ -51,26 +88,43 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ <Box
sx={{
p: 3, p: 3,
bgcolor: 'rgba(26, 37, 54, 0.1)', bgcolor: "rgba(26, 37, 54, 0.1)",
borderRadius: 2, borderRadius: 2,
border: '1px solid', border: "1px solid",
borderColor: 'rgba(26, 37, 54, 0.3)', borderColor: "rgba(26, 37, 54, 0.3)",
height: '100%' height: "100%",
}}> }}
<Typography variant="h6" sx={{ color: 'primary.main', mb: 2, fontWeight: 'bold' }}> >
<Typography
variant="h6"
sx={{ color: "primary.main", mb: 2, fontWeight: "bold" }}
>
Employer Experience Employer Experience
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{[ {[
'Search for candidates with specific skills', "Search for candidates with specific skills",
'Interact with candidate AI assistants', "Interact with candidate AI assistants",
'Generate position-specific candidate resumes', "Generate position-specific candidate resumes",
'Manage talent pools and job listings' "Manage talent pools and job listings",
].map((item, index) => ( ].map((item, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'primary.main' }} /> key={index}
sx={{ display: "flex", alignItems: "center", gap: 1.5 }}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: "primary.main",
}}
/>
<Typography variant="body2">{item}</Typography> <Typography variant="body2">{item}</Typography>
</Box> </Box>
))} ))}
@ -80,35 +134,79 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Grid> </Grid>
{/* UI Components */} {/* UI Components */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}> <Box
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}> sx={{
p: 3,
bgcolor: "background.paper",
borderRadius: 2,
mb: 4,
boxShadow: 1,
}}
>
<Typography
variant="h5"
sx={{ color: "text.primary", mb: 3, fontWeight: "bold" }}
>
Key UI Components Key UI Components
</Typography> </Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
{[ {[
{ title: 'Dashboards', description: 'Role-specific dashboards with card-based metrics and action items' }, {
{ title: 'Profile Editors', description: 'Comprehensive forms for managing professional information' }, title: "Dashboards",
{ title: 'Resume Builder', description: 'AI-powered tools for creating tailored resumes' }, description:
{ title: 'Q&A Interface', description: 'Chat-like interface for employer-candidate AI interaction' }, "Role-specific dashboards with card-based metrics and action items",
{ title: 'Search & Filters', description: 'Advanced search with multiple filter categories' }, },
{ title: 'Analytics Dashboards', description: 'Visual metrics for tracking engagement and performance' } {
title: "Profile Editors",
description:
"Comprehensive forms for managing professional information",
},
{
title: "Resume Builder",
description:
"AI-powered tools for creating tailored resumes",
},
{
title: "Q&A Interface",
description:
"Chat-like interface for employer-candidate AI interaction",
},
{
title: "Search & Filters",
description:
"Advanced search with multiple filter categories",
},
{
title: "Analytics Dashboards",
description:
"Visual metrics for tracking engagement and performance",
},
].map((component, index) => ( ].map((component, index) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}> <Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<Box sx={{ <Box
sx={{
p: 2, p: 2,
border: '1px solid', border: "1px solid",
borderColor: 'divider', borderColor: "divider",
borderRadius: 1, borderRadius: 1,
height: '100%', height: "100%",
transition: 'all 0.2s ease-in-out', transition: "all 0.2s ease-in-out",
'&:hover': { "&:hover": {
bgcolor: 'rgba(212, 160, 23, 0.05)', bgcolor: "rgba(212, 160, 23, 0.05)",
borderColor: 'action.active', borderColor: "action.active",
transform: 'translateY(-2px)', transform: "translateY(-2px)",
boxShadow: 1 boxShadow: 1,
} },
}}> }}
<Typography variant="h6" sx={{ color: 'secondary.main', mb: 1, fontWeight: 'medium' }}> >
<Typography
variant="h6"
sx={{
color: "secondary.main",
mb: 1,
fontWeight: "medium",
}}
>
{component.title} {component.title}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@ -124,46 +222,72 @@ const BackstoryUIOverviewPage: React.FC = () => {
<Grid container spacing={3} sx={{ mb: 4 }}> <Grid container spacing={3} sx={{ mb: 4 }}>
{[ {[
{ {
title: 'Candidate Navigation', title: "Candidate Navigation",
items: ['Dashboard', 'Profile', 'Backstory', 'Resumes', 'Q&A Setup', 'Analytics', 'Settings'], items: [
color: 'secondary.main', "Dashboard",
borderColor: 'secondary.main' "Profile",
"Backstory",
"Resumes",
"Q&A Setup",
"Analytics",
"Settings",
],
color: "secondary.main",
borderColor: "secondary.main",
}, },
{ {
title: 'Employer Navigation', title: "Employer Navigation",
items: ['Dashboard', 'Search', 'Saved', 'Jobs', 'Company', 'Analytics', 'Settings'], items: [
color: 'primary.main', "Dashboard",
borderColor: 'primary.main' "Search",
"Saved",
"Jobs",
"Company",
"Analytics",
"Settings",
],
color: "primary.main",
borderColor: "primary.main",
}, },
{ {
title: 'Public Navigation', title: "Public Navigation",
items: ['Home', 'Docs', 'Pricing', 'Login', 'Register'], items: ["Home", "Docs", "Pricing", "Login", "Register"],
color: 'custom.highlight', color: "custom.highlight",
borderColor: 'custom.highlight' borderColor: "custom.highlight",
} },
].map((nav, index) => ( ].map((nav, index) => (
<Grid size={{ xs: 12, md: 4 }} key={index}> <Grid size={{ xs: 12, md: 4 }} key={index}>
<Box sx={{ <Box
sx={{
p: 3, p: 3,
bgcolor: 'background.paper', bgcolor: "background.paper",
borderRadius: 2, borderRadius: 2,
boxShadow: 1, boxShadow: 1,
height: '100%' height: "100%",
}}> }}
<Typography variant="h6" sx={{ color: 'text.primary', mb: 2, fontWeight: 'bold' }}> >
<Typography
variant="h6"
sx={{ color: "text.primary", mb: 2, fontWeight: "bold" }}
>
{nav.title} {nav.title}
</Typography> </Typography>
<Box sx={{ <Box
sx={{
borderLeft: 3, borderLeft: 3,
borderColor: nav.borderColor, borderColor: nav.borderColor,
pl: 2, pl: 2,
py: 1, py: 1,
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
gap: 1.5 gap: 1.5,
}}> }}
>
{nav.items.map((item, idx) => ( {nav.items.map((item, idx) => (
<Typography key={idx} sx={{ color: nav.color, fontWeight: 'medium' }}> <Typography
key={idx}
sx={{ color: nav.color, fontWeight: "medium" }}
>
{item} {item}
</Typography> </Typography>
))} ))}
@ -174,84 +298,106 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Grid> </Grid>
{/* Connection Points */} {/* Connection Points */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}> <Box
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}> sx={{
p: 3,
bgcolor: "background.paper",
borderRadius: 2,
mb: 4,
boxShadow: 1,
}}
>
<Typography
variant="h5"
sx={{ color: "text.primary", mb: 3, fontWeight: "bold" }}
>
System Connection Points System Connection Points
</Typography> </Typography>
<Box sx={{ position: 'relative', py: 2 }}> <Box sx={{ position: "relative", py: 2 }}>
{/* Connection line */} {/* Connection line */}
<Box sx={{ <Box
position: 'absolute', sx={{
left: '50%', position: "absolute",
left: "50%",
top: 0, top: 0,
bottom: 0, bottom: 0,
width: 1, width: 1,
borderColor: 'divider', borderColor: "divider",
zIndex: 0, zIndex: 0,
borderLeft: "1px solid", borderLeft: "1px solid",
overflow: "hidden", overflow: "hidden",
}} /> }}
/>
{/* Connection points */} {/* Connection points */}
{[ {[
{ left: 'Candidate Profile', right: 'Employer Search' }, { left: "Candidate Profile", right: "Employer Search" },
{ left: 'Q&A Setup', right: 'Q&A Interface' }, { left: "Q&A Setup", right: "Q&A Interface" },
{ left: 'Resume Generator', right: 'Job Posts' } { left: "Resume Generator", right: "Job Posts" },
].map((connection, index) => ( ].map((connection, index) => (
<Box <Box
key={index} key={index}
sx={{ sx={{
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
mb: index < 2 ? 5 : 0, mb: index < 2 ? 5 : 0,
position: 'relative', position: "relative",
zIndex: 1, zIndex: 1,
}} }}
> >
<Box sx={{ <Box
sx={{
flex: 1, flex: 1,
display: 'flex', display: "flex",
justifyContent: 'flex-end', justifyContent: "flex-end",
pr: 3 pr: 3,
}}> }}
<Box sx={{ >
display: 'inline-block', <Box
bgcolor: 'rgba(74, 122, 125, 0.1)', sx={{
display: "inline-block",
bgcolor: "rgba(74, 122, 125, 0.1)",
p: 2, p: 2,
borderRadius: 2, borderRadius: 2,
color: 'secondary.main', color: "secondary.main",
fontWeight: 'medium', fontWeight: "medium",
border: '1px solid', border: "1px solid",
borderColor: 'rgba(74, 122, 125, 0.3)' borderColor: "rgba(74, 122, 125, 0.3)",
}}> }}
>
{connection.left} {connection.left}
</Box> </Box>
</Box> </Box>
<Box sx={{ <Box
sx={{
width: 16, width: 16,
height: 16, height: 16,
borderRadius: '50%', borderRadius: "50%",
bgcolor: 'custom.highlight', bgcolor: "custom.highlight",
zIndex: 2, zIndex: 2,
boxShadow: 2, boxShadow: 2,
}} /> }}
/>
<Box sx={{ <Box
sx={{
flex: 1, flex: 1,
pl: 3, pl: 3,
}}> }}
<Box sx={{ >
display: 'inline-block', <Box
bgcolor: 'rgba(26, 37, 54, 0.1)', sx={{
display: "inline-block",
bgcolor: "rgba(26, 37, 54, 0.1)",
p: 2, p: 2,
borderRadius: 2, borderRadius: 2,
color: 'primary.main', color: "primary.main",
fontWeight: 'medium', fontWeight: "medium",
border: '1px solid', border: "1px solid",
borderColor: 'rgba(26, 37, 54, 0.3)', borderColor: "rgba(26, 37, 54, 0.3)",
}}> }}
>
{connection.right} {connection.right}
</Box> </Box>
</Box> </Box>
@ -261,89 +407,159 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Box> </Box>
{/* Mobile Adaptation */} {/* Mobile Adaptation */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, boxShadow: 1 }}> <Box
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}> sx={{
p: 3,
bgcolor: "background.paper",
borderRadius: 2,
boxShadow: 1,
}}
>
<Typography
variant="h5"
sx={{ color: "text.primary", mb: 3, fontWeight: "bold" }}
>
Mobile Adaptation Mobile Adaptation
</Typography> </Typography>
<Box sx={{ display: 'flex', justifyContent: 'center' }}> <Box sx={{ display: "flex", justifyContent: "center" }}>
<Box sx={{ <Box
sx={{
width: 200, width: 200,
height: 400, height: 400,
border: '4px solid', border: "4px solid",
borderColor: 'text.primary', borderColor: "text.primary",
borderRadius: 5, borderRadius: 5,
p: 1, p: 1,
bgcolor: 'background.default' bgcolor: "background.default",
}}> }}
<Box sx={{ >
height: '100%', <Box
display: 'flex', sx={{
flexDirection: 'column', height: "100%",
border: '1px solid', display: "flex",
borderColor: 'divider', flexDirection: "column",
border: "1px solid",
borderColor: "divider",
borderRadius: 4, borderRadius: 4,
overflow: 'hidden' overflow: "hidden",
}}> }}
>
{/* Mobile header */} {/* Mobile header */}
<Box sx={{ <Box
bgcolor: 'primary.main', sx={{
color: 'primary.contrastText', bgcolor: "primary.main",
color: "primary.contrastText",
p: 1, p: 1,
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center' alignItems: "center",
}}> }}
<Typography sx={{ fontWeight: 'bold', fontSize: '0.875rem' }}>BACKSTORY</Typography> >
<Typography
sx={{ fontWeight: "bold", fontSize: "0.875rem" }}
>
BACKSTORY
</Typography>
<Box></Box> <Box></Box>
</Box> </Box>
{/* Mobile content */} {/* Mobile content */}
<Box sx={{ <Box
sx={{
flex: 1, flex: 1,
p: 1.5, p: 1.5,
overflow: 'auto', overflow: "auto",
fontSize: '0.75rem' fontSize: "0.75rem",
}}> }}
<Typography sx={{ mb: 1, fontWeight: 'medium' }}>Welcome back, [Name]!</Typography> >
<Typography sx={{ fontSize: '0.675rem', mb: 2 }}>Profile: 75% complete</Typography> <Typography sx={{ mb: 1, fontWeight: "medium" }}>
Welcome back, [Name]!
</Typography>
<Typography sx={{ fontSize: "0.675rem", mb: 2 }}>
Profile: 75% complete
</Typography>
<Box sx={{ <Box
border: '1px solid', sx={{
borderColor: 'divider', border: "1px solid",
borderColor: "divider",
borderRadius: 1, borderRadius: 1,
p: 1.5, p: 1.5,
mb: 2, mb: 2,
bgcolor: 'background.paper' bgcolor: "background.paper",
}}> }}
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', mb: 0.5 }}>Resume Builder</Typography> >
<Typography sx={{ fontSize: '0.675rem' }}>3 custom resumes</Typography> <Typography
sx={{
fontWeight: "bold",
fontSize: "0.75rem",
mb: 0.5,
}}
>
Resume Builder
</Typography>
<Typography sx={{ fontSize: "0.675rem" }}>
3 custom resumes
</Typography>
</Box> </Box>
<Box sx={{ <Box
border: '1px solid', sx={{
borderColor: 'divider', border: "1px solid",
borderColor: "divider",
borderRadius: 1, borderRadius: 1,
p: 1.5, p: 1.5,
bgcolor: 'background.paper' bgcolor: "background.paper",
}}> }}
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', mb: 0.5 }}>Recent Activity</Typography> >
<Typography sx={{ fontSize: '0.675rem' }}> 5 profile views</Typography> <Typography
<Typography sx={{ fontSize: '0.675rem' }}> 2 downloads</Typography> sx={{
fontWeight: "bold",
fontSize: "0.75rem",
mb: 0.5,
}}
>
Recent Activity
</Typography>
<Typography sx={{ fontSize: "0.675rem" }}>
5 profile views
</Typography>
<Typography sx={{ fontSize: "0.675rem" }}>
2 downloads
</Typography>
</Box> </Box>
</Box> </Box>
{/* Mobile footer */} {/* Mobile footer */}
<Box sx={{ <Box
bgcolor: 'background.default', sx={{
bgcolor: "background.default",
p: 1, p: 1,
display: 'flex', display: "flex",
justifyContent: 'space-around', justifyContent: "space-around",
borderTop: '1px solid', borderTop: "1px solid",
borderColor: 'divider' borderColor: "divider",
}}> }}
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', color: 'secondary.main' }}>Home</Typography> >
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>Profile</Typography> <Typography
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>More</Typography> sx={{
fontWeight: "bold",
fontSize: "0.75rem",
color: "secondary.main",
}}
>
Home
</Typography>
<Typography
sx={{ fontSize: "0.75rem", color: "text.secondary" }}
>
Profile
</Typography>
<Typography
sx={{ fontSize: "0.75rem", color: "text.secondary" }}
>
More
</Typography>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@ -356,6 +572,4 @@ const BackstoryUIOverviewPage: React.FC = () => {
); );
}; };
export { export { BackstoryUIOverviewPage };
BackstoryUIOverviewPage
};

View File

@ -1,16 +1,37 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { import {
AppBar, Box, Button, Chip, Drawer, AppBar,
IconButton, List, ListItem, ListItemButton, ListItemIcon, Box,
ListItemText, Paper, Tab, Tabs, TextField, Typography, Button,
useMediaQuery, useTheme Chip,
} from '@mui/material'; Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
Tab,
Tabs,
TextField,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { import {
Menu as MenuIcon, Search as SearchIcon, Description as FileTextIcon, Menu as MenuIcon,
Person as UserIcon, Settings as SettingsIcon, Add as PlusIcon, Search as SearchIcon,
Edit as EditIcon, Visibility as EyeIcon, Save as SaveIcon, Description as FileTextIcon,
Delete as TrashIcon, AccessTime as ClockIcon Person as UserIcon,
} from '@mui/icons-material'; Settings as SettingsIcon,
Add as PlusIcon,
Edit as EditIcon,
Visibility as EyeIcon,
Save as SaveIcon,
Delete as TrashIcon,
AccessTime as ClockIcon,
} from "@mui/icons-material";
interface Resume { interface Resume {
id: number; id: number;
@ -21,49 +42,91 @@ interface Resume {
const MockupPage = () => { const MockupPage = () => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [activeTab, setActiveTab] = useState<string>("resume"); const [activeTab, setActiveTab] = useState<string>("resume");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState<boolean>(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState<boolean>(false);
const [selectedResume, setSelectedResume] = useState<number | null>(null); const [selectedResume, setSelectedResume] = useState<number | null>(null);
// Mock data // Mock data
const savedResumes: Resume[] = [ const savedResumes: Resume[] = [
{ id: 1, name: "Software Engineer - Tech Co", date: "May 15, 2025", isRecent: true }, {
{ id: 2, name: "Product Manager - StartupX", date: "May 10, 2025", isRecent: false }, id: 1,
{ id: 3, name: "Data Scientist - AI Corp", date: "May 5, 2025", isRecent: false }, name: "Software Engineer - Tech Co",
date: "May 15, 2025",
isRecent: true,
},
{
id: 2,
name: "Product Manager - StartupX",
date: "May 10, 2025",
isRecent: false,
},
{
id: 3,
name: "Data Scientist - AI Corp",
date: "May 5, 2025",
isRecent: false,
},
]; ];
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', bgcolor: 'background.default' }}> <Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
bgcolor: "background.default",
}}
>
{/* Header */} {/* Header */}
<AppBar position="static" color="default" elevation={1} sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}> <AppBar
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 2, py: 1 }}> position="static"
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> color="default"
<Typography variant="h6" component="h1" fontWeight="bold" color="text.primary">Backstory</Typography> elevation={1}
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
px: 2,
py: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography
variant="h6"
component="h1"
fontWeight="bold"
color="text.primary"
>
Backstory
</Typography>
{isMobile && ( {isMobile && (
<IconButton edge="start" color="inherit" onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}> <IconButton
edge="start"
color="inherit"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
)} )}
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{!isMobile && ( {!isMobile && (
<Button <Button startIcon={<PlusIcon />} color="primary" size="small">
startIcon={<PlusIcon />}
color="primary"
size="small"
>
New Resume New Resume
</Button> </Button>
)} )}
<IconButton sx={{ bgcolor: 'action.hover', borderRadius: '50%' }}> <IconButton sx={{ bgcolor: "action.hover", borderRadius: "50%" }}>
<UserIcon /> <UserIcon />
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
</AppBar> </AppBar>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}> <Box sx={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Sidebar - hidden on mobile */} {/* Sidebar - hidden on mobile */}
{!isMobile && ( {!isMobile && (
<Drawer <Drawer
@ -71,12 +134,29 @@ const MockupPage = () => {
sx={{ sx={{
width: 240, width: 240,
flexShrink: 0, flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: 240, boxSizing: 'border-box', position: 'relative' }, [`& .MuiDrawer-paper`]: {
width: 240,
boxSizing: "border-box",
position: "relative",
},
}}
>
<Box
sx={{
p: 2,
display: "flex",
flexDirection: "column",
height: "100%",
}} }}
> >
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>Main</Typography> <Typography
variant="overline"
color="text.secondary"
gutterBottom
>
Main
</Typography>
<List disablePadding> <List disablePadding>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemButton sx={{ borderRadius: 1 }}> <ListItemButton sx={{ borderRadius: 1 }}>
@ -88,9 +168,15 @@ const MockupPage = () => {
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemButton <ListItemButton
sx={{ borderRadius: 1, bgcolor: 'primary.lighter', color: 'primary.main' }} sx={{
borderRadius: 1,
bgcolor: "primary.lighter",
color: "primary.main",
}}
>
<ListItemIcon
sx={{ minWidth: 36, color: "primary.main" }}
> >
<ListItemIcon sx={{ minWidth: 36, color: 'primary.main' }}>
<FileTextIcon fontSize="small" /> <FileTextIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Resume Builder" /> <ListItemText primary="Resume Builder" />
@ -100,7 +186,13 @@ const MockupPage = () => {
</Box> </Box>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>My Content</Typography> <Typography
variant="overline"
color="text.secondary"
gutterBottom
>
My Content
</Typography>
<List disablePadding> <List disablePadding>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemButton sx={{ borderRadius: 1 }}> <ListItemButton sx={{ borderRadius: 1 }}>
@ -126,16 +218,33 @@ const MockupPage = () => {
</List> </List>
</Box> </Box>
<Box sx={{ mt: 'auto' }}> <Box sx={{ mt: "auto" }}>
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'background.default' }}> <Paper
<Typography variant="subtitle2" color="text.primary" gutterBottom>Recent Activity</Typography> variant="outlined"
sx={{ p: 2, bgcolor: "background.default" }}
>
<Typography
variant="subtitle2"
color="text.primary"
gutterBottom
>
Recent Activity
</Typography>
<List dense disablePadding> <List dense disablePadding>
{savedResumes.filter(r => r.isRecent).map(resume => ( {savedResumes
<ListItem key={resume.id} disablePadding sx={{ mb: 0.5 }}> .filter((r) => r.isRecent)
.map((resume) => (
<ListItem
key={resume.id}
disablePadding
sx={{ mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 24 }}> <ListItemIcon sx={{ minWidth: 24 }}>
<ClockIcon fontSize="small" /> <ClockIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>
<Typography variant="body2" noWrap>{resume.name}</Typography> <Typography variant="body2" noWrap>
{resume.name}
</Typography>
</ListItem> </ListItem>
))} ))}
</List> </List>
@ -151,13 +260,26 @@ const MockupPage = () => {
open={isMobileMenuOpen} open={isMobileMenuOpen}
onClose={() => setIsMobileMenuOpen(false)} onClose={() => setIsMobileMenuOpen(false)}
sx={{ sx={{
display: { xs: 'block', md: 'none' }, display: { xs: "block", md: "none" },
'& .MuiDrawer-paper': { width: 240 } "& .MuiDrawer-paper": { width: 240 },
}}
>
<Box
sx={{
p: 2,
display: "flex",
flexDirection: "column",
height: "100%",
}} }}
> >
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>Main</Typography> <Typography
variant="overline"
color="text.secondary"
gutterBottom
>
Main
</Typography>
<List disablePadding> <List disablePadding>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemButton onClick={() => setIsMobileMenuOpen(false)}> <ListItemButton onClick={() => setIsMobileMenuOpen(false)}>
@ -170,9 +292,9 @@ const MockupPage = () => {
<ListItem disablePadding> <ListItem disablePadding>
<ListItemButton <ListItemButton
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
sx={{ bgcolor: 'primary.lighter', color: 'primary.main' }} sx={{ bgcolor: "primary.lighter", color: "primary.main" }}
> >
<ListItemIcon sx={{ color: 'primary.main' }}> <ListItemIcon sx={{ color: "primary.main" }}>
<FileTextIcon fontSize="small" /> <FileTextIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Resume Builder" /> <ListItemText primary="Resume Builder" />
@ -182,7 +304,13 @@ const MockupPage = () => {
</Box> </Box>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>My Content</Typography> <Typography
variant="overline"
color="text.secondary"
gutterBottom
>
My Content
</Typography>
<List disablePadding> <List disablePadding>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemButton onClick={() => setIsMobileMenuOpen(false)}> <ListItemButton onClick={() => setIsMobileMenuOpen(false)}>
@ -211,15 +339,23 @@ const MockupPage = () => {
</Drawer> </Drawer>
{/* Main content */} {/* Main content */}
<Box sx={{ flex: 1, overflow: 'auto', p: 3 }}> <Box sx={{ flex: 1, overflow: "auto", p: 3 }}>
{/* Resume Builder content */} {/* Resume Builder content */}
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h5" component="h2" fontWeight="bold" gutterBottom>Resume Builder</Typography> <Typography
<Typography variant="body2" color="text.secondary">Generate and customize resumes based on job descriptions</Typography> variant="h5"
component="h2"
fontWeight="bold"
gutterBottom
>
Resume Builder
</Typography>
<Typography variant="body2" color="text.secondary">
Generate and customize resumes based on job descriptions
</Typography>
</Box> </Box>
{/* Tabs */} {/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> <Box sx={{ borderBottom: 1, borderColor: "divider", mb: 3 }}>
<Tabs <Tabs
value={activeTab} value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)} onChange={(_, newValue) => setActiveTab(newValue)}
@ -233,11 +369,12 @@ const MockupPage = () => {
<Tab label="Saved Resumes" value="saved" /> <Tab label="Saved Resumes" value="saved" />
</Tabs> </Tabs>
</Box> </Box>
{/* Tab content */} {/* Tab content */}
{activeTab === 'job' && ( {activeTab === "job" && (
<Paper variant="outlined" sx={{ p: 3 }}> <Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Job Description</Typography> <Typography variant="h6" gutterBottom>
Job Description
</Typography>
<TextField <TextField
fullWidth fullWidth
multiline multiline
@ -252,12 +389,18 @@ const MockupPage = () => {
</Box> </Box>
</Paper> </Paper>
)} )}
{activeTab === "resume" && (
{activeTab === 'resume' && (
<Paper variant="outlined" sx={{ p: 3 }}> <Paper variant="outlined" sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> <Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 3,
}}
>
<Typography variant="h6">Resume Editor</Typography> <Typography variant="h6">Resume Editor</Typography>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: "flex", gap: 1 }}>
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
@ -276,35 +419,77 @@ const MockupPage = () => {
</Box> </Box>
{/* Resume content editor with sections */} {/* Resume content editor with sections */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{/* Contact information */} {/* Contact information */}
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
<Typography variant="subtitle1" fontWeight="medium">Contact Information</Typography> sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Contact Information
</Typography>
<IconButton size="small" color="default"> <IconButton size="small" color="default">
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}> <Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
gap: 2,
}}
>
<Box> <Box>
<Typography variant="caption" color="text.secondary" display="block" gutterBottom> <Typography
variant="caption"
color="text.secondary"
display="block"
gutterBottom
>
Full Name Full Name
</Typography> </Typography>
<TextField size="small" fullWidth defaultValue="John Doe" /> <TextField
size="small"
fullWidth
defaultValue="John Doe"
/>
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary" display="block" gutterBottom> <Typography
variant="caption"
color="text.secondary"
display="block"
gutterBottom
>
Email Email
</Typography> </Typography>
<TextField size="small" fullWidth defaultValue="john@example.com" /> <TextField
size="small"
fullWidth
defaultValue="john@example.com"
/>
</Box> </Box>
</Box> </Box>
</Paper> </Paper>
{/* Professional Summary */} {/* Professional Summary */}
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
<Typography variant="subtitle1" fontWeight="medium">Professional Summary</Typography> sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Professional Summary
</Typography>
<IconButton size="small" color="default"> <IconButton size="small" color="default">
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
@ -320,8 +505,17 @@ const MockupPage = () => {
{/* Work Experience */} {/* Work Experience */}
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
<Typography variant="subtitle1" fontWeight="medium">Work Experience</Typography> sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Work Experience
</Typography>
<Button <Button
startIcon={<PlusIcon />} startIcon={<PlusIcon />}
color="primary" color="primary"
@ -332,10 +526,17 @@ const MockupPage = () => {
</Box> </Box>
{/* Job entry */} {/* Job entry */}
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: 'background.default' }}> <Paper
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}> variant="outlined"
<Typography variant="subtitle2" fontWeight="medium">Senior Developer</Typography> sx={{ p: 2, mb: 2, bgcolor: "background.default" }}
<Box sx={{ display: 'flex', gap: 0.5 }}> >
<Box
sx={{ display: "flex", justifyContent: "space-between" }}
>
<Typography variant="subtitle2" fontWeight="medium">
Senior Developer
</Typography>
<Box sx={{ display: "flex", gap: 0.5 }}>
<IconButton size="small"> <IconButton size="small">
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
@ -344,10 +545,16 @@ const MockupPage = () => {
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
<Typography variant="body2" color="text.secondary">Tech Company Inc. 2020-Present</Typography> <Typography variant="body2" color="text.secondary">
Tech Company Inc. 2020-Present
</Typography>
<Box component="ul" sx={{ pl: 2, mt: 1 }}> <Box component="ul" sx={{ pl: 2, mt: 1 }}>
<Typography component="li" variant="body2">Led development of company's flagship product</Typography> <Typography component="li" variant="body2">
<Typography component="li" variant="body2">Improved performance by 40% through code optimization</Typography> Led development of company's flagship product
</Typography>
<Typography component="li" variant="body2">
Improved performance by 40% through code optimization
</Typography>
</Box> </Box>
</Paper> </Paper>
</Paper> </Paper>
@ -357,10 +564,10 @@ const MockupPage = () => {
variant="outlined" variant="outlined"
fullWidth fullWidth
sx={{ sx={{
borderStyle: 'dashed', borderStyle: "dashed",
p: 1.5, p: 1.5,
color: 'text.secondary', color: "text.secondary",
'&:hover': { bgcolor: 'background.default' } "&:hover": { bgcolor: "background.default" },
}} }}
startIcon={<PlusIcon />} startIcon={<PlusIcon />}
> >
@ -368,9 +575,17 @@ const MockupPage = () => {
</Button> </Button>
</Box> </Box>
</Paper> </Paper>
)} {activeTab === 'saved' && ( )}{" "}
{activeTab === "saved" && (
<Paper variant="outlined" sx={{ p: 3 }}> <Paper variant="outlined" sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> <Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 3,
}}
>
<Typography variant="h6">Saved Resumes</Typography> <Typography variant="h6">Saved Resumes</Typography>
<Button <Button
variant="contained" variant="contained"
@ -383,28 +598,41 @@ const MockupPage = () => {
</Box> </Box>
{/* Resume list */} {/* Resume list */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{savedResumes.map(resume => ( {savedResumes.map((resume) => (
<Paper <Paper
key={resume.id} key={resume.id}
variant="outlined" variant="outlined"
sx={{ sx={{
p: 2, p: 2,
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
cursor: 'pointer', cursor: "pointer",
bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'background.paper', bgcolor:
borderColor: selectedResume === resume.id ? 'primary.light' : 'divider', selectedResume === resume.id
'&:hover': { bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'action.hover' } ? "primary.lighter"
: "background.paper",
borderColor:
selectedResume === resume.id
? "primary.light"
: "divider",
"&:hover": {
bgcolor:
selectedResume === resume.id
? "primary.lighter"
: "action.hover",
},
}} }}
onClick={() => setSelectedResume(resume.id)} onClick={() => setSelectedResume(resume.id)}
> >
<Box> <Box>
<Typography variant="subtitle2">{resume.name}</Typography> <Typography variant="subtitle2">{resume.name}</Typography>
<Typography variant="caption" color="text.secondary">Last edited: {resume.date}</Typography> <Typography variant="caption" color="text.secondary">
Last edited: {resume.date}
</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: "flex", gap: 1 }}>
<IconButton size="small"> <IconButton size="small">
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
@ -417,17 +645,26 @@ const MockupPage = () => {
</Box> </Box>
</Paper> </Paper>
)} )}
{activeTab === "fact" && (
{activeTab === 'fact' && (
<Paper variant="outlined" sx={{ p: 3 }}> <Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Fact Check</Typography> <Typography variant="h6" gutterBottom>
Fact Check
</Typography>
<Typography variant="body2" color="text.secondary" paragraph> <Typography variant="body2" color="text.secondary" paragraph>
This tab shows how your resume content compares to your employment history data. This tab shows how your resume content compares to your
employment history data.
</Typography> </Typography>
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: 'success.lighter' }}> <Paper
<Typography variant="subtitle2" fontWeight="medium" color="success.dark"> variant="outlined"
sx={{ p: 2, mb: 2, bgcolor: "success.lighter" }}
>
<Typography
variant="subtitle2"
fontWeight="medium"
color="success.dark"
>
Work History Verification Work History Verification
</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2">
@ -435,12 +672,20 @@ const MockupPage = () => {
</Typography> </Typography>
</Paper> </Paper>
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: 'warning.lighter' }}> <Paper
<Typography variant="subtitle2" fontWeight="medium" color="warning.dark"> variant="outlined"
sx={{ p: 2, mb: 2, bgcolor: "warning.lighter" }}
>
<Typography
variant="subtitle2"
fontWeight="medium"
color="warning.dark"
>
Skills Verification Skills Verification
</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2">
Some skills listed (React Native, Flutter) are not strongly supported by your experience documents. Some skills listed (React Native, Flutter) are not strongly
supported by your experience documents.
</Typography> </Typography>
</Paper> </Paper>
</Box> </Box>
@ -453,26 +698,26 @@ const MockupPage = () => {
{isMobile && ( {isMobile && (
<Paper <Paper
sx={{ sx={{
position: 'fixed', position: "fixed",
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
display: 'flex', display: "flex",
justifyContent: 'space-around', justifyContent: "space-around",
borderTop: 1, borderTop: 1,
borderColor: 'divider', borderColor: "divider",
zIndex: 1100 zIndex: 1100,
}} }}
elevation={3} elevation={3}
> >
<Box <Box
sx={{ sx={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
py: 1, py: 1,
px: 2, px: 2,
color: 'text.secondary' color: "text.secondary",
}} }}
component="button" component="button"
> >
@ -481,12 +726,12 @@ const MockupPage = () => {
</Box> </Box>
<Box <Box
sx={{ sx={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
py: 1, py: 1,
px: 2, px: 2,
color: 'primary.main' color: "primary.main",
}} }}
component="button" component="button"
> >
@ -495,12 +740,12 @@ const MockupPage = () => {
</Box> </Box>
<Box <Box
sx={{ sx={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
py: 1, py: 1,
px: 2, px: 2,
color: 'text.secondary' color: "text.secondary",
}} }}
component="button" component="button"
> >
@ -511,8 +756,6 @@ const MockupPage = () => {
)} )}
</Box> </Box>
); );
}
export {
MockupPage
}; };
export { MockupPage };

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { import {
Box, Box,
Typography, Typography,
@ -22,9 +22,9 @@ import {
Select, Select,
FormControl, FormControl,
InputLabel, InputLabel,
Grid Grid,
} from '@mui/material'; } from "@mui/material";
import { Person, Business, AssignmentInd } from '@mui/icons-material'; import { Person, Business, AssignmentInd } from "@mui/icons-material";
// Interfaces from the data model // Interfaces from the data model
interface BaseUser { interface BaseUser {
@ -37,7 +37,7 @@ interface BaseUser {
} }
interface Candidate extends BaseUser { interface Candidate extends BaseUser {
type: 'candidate'; type: "candidate";
firstName: string; firstName: string;
lastName: string; lastName: string;
skills: { id: string; name: string; level: string }[]; skills: { id: string; name: string; level: string }[];
@ -45,7 +45,7 @@ interface Candidate extends BaseUser {
} }
interface Employer extends BaseUser { interface Employer extends BaseUser {
type: 'employer'; type: "employer";
companyName: string; companyName: string;
industry: string; industry: string;
companySize: string; companySize: string;
@ -58,59 +58,59 @@ type User = Candidate | Employer;
// Mock data // Mock data
const mockUsers: User[] = [ const mockUsers: User[] = [
{ {
id: '1', id: "1",
email: 'john.doe@example.com', email: "john.doe@example.com",
createdAt: new Date('2023-08-15'), createdAt: new Date("2023-08-15"),
lastLogin: new Date('2023-10-22'), lastLogin: new Date("2023-10-22"),
isActive: true, isActive: true,
type: 'candidate', type: "candidate",
firstName: 'John', firstName: "John",
lastName: 'Doe', lastName: "Doe",
skills: [ skills: [
{ id: 's1', name: 'React', level: 'advanced' }, { id: "s1", name: "React", level: "advanced" },
{ id: 's2', name: 'TypeScript', level: 'intermediate' } { id: "s2", name: "TypeScript", level: "intermediate" },
], ],
location: { city: 'Austin', country: 'USA' } location: { city: "Austin", country: "USA" },
}, },
{ {
id: '2', id: "2",
email: 'sarah.smith@example.com', email: "sarah.smith@example.com",
createdAt: new Date('2023-09-10'), createdAt: new Date("2023-09-10"),
lastLogin: new Date('2023-10-24'), lastLogin: new Date("2023-10-24"),
isActive: true, isActive: true,
type: 'candidate', type: "candidate",
firstName: 'Sarah', firstName: "Sarah",
lastName: 'Smith', lastName: "Smith",
skills: [ skills: [
{ id: 's3', name: 'Python', level: 'expert' }, { id: "s3", name: "Python", level: "expert" },
{ id: 's4', name: 'Data Science', level: 'advanced' } { id: "s4", name: "Data Science", level: "advanced" },
], ],
location: { city: 'Seattle', country: 'USA', remote: true } location: { city: "Seattle", country: "USA", remote: true },
}, },
{ {
id: '3', id: "3",
email: 'tech@acme.com', email: "tech@acme.com",
createdAt: new Date('2023-07-05'), createdAt: new Date("2023-07-05"),
lastLogin: new Date('2023-10-23'), lastLogin: new Date("2023-10-23"),
isActive: true, isActive: true,
type: 'employer', type: "employer",
companyName: 'Acme Tech', companyName: "Acme Tech",
industry: 'Software', industry: "Software",
companySize: '50-200', companySize: "50-200",
location: { city: 'San Francisco', country: 'USA' } location: { city: "San Francisco", country: "USA" },
}, },
{ {
id: '4', id: "4",
email: 'careers@globex.com', email: "careers@globex.com",
createdAt: new Date('2023-08-20'), createdAt: new Date("2023-08-20"),
lastLogin: new Date('2023-10-20'), lastLogin: new Date("2023-10-20"),
isActive: false, isActive: false,
type: 'employer', type: "employer",
companyName: 'Globex Corporation', companyName: "Globex Corporation",
industry: 'Manufacturing', industry: "Manufacturing",
companySize: '1000+', companySize: "1000+",
location: { city: 'Chicago', country: 'USA' } location: { city: "Chicago", country: "USA" },
} },
]; ];
// Component for User Management // Component for User Management
@ -127,10 +127,10 @@ const UserManagement: React.FC = () => {
}; };
// Filter users based on tab value // Filter users based on tab value
const filteredUsers = users.filter(user => { const filteredUsers = users.filter((user) => {
if (tabValue === 0) return true; if (tabValue === 0) return true;
if (tabValue === 1) return user.type === 'candidate'; if (tabValue === 1) return user.type === "candidate";
if (tabValue === 2) return user.type === 'employer'; if (tabValue === 2) return user.type === "employer";
return false; return false;
}); });
@ -159,7 +159,7 @@ const UserManagement: React.FC = () => {
// Helper function to get user's name for display // Helper function to get user's name for display
const getUserDisplayName = (user: User) => { const getUserDisplayName = (user: User) => {
if (user.type === 'candidate') { if (user.type === "candidate") {
return `${user.firstName} ${user.lastName}`; return `${user.firstName} ${user.lastName}`;
} else { } else {
return user.companyName; return user.companyName;
@ -172,8 +172,8 @@ const UserManagement: React.FC = () => {
}; };
return ( return (
<Box sx={{ width: '100%', p: 3 }}> <Box sx={{ width: "100%", p: 3 }}>
<Paper sx={{ width: '100%', mb: 2 }}> <Paper sx={{ width: "100%", mb: 2 }}>
<Tabs <Tabs
value={tabValue} value={tabValue}
onChange={handleTabChange} onChange={handleTabChange}
@ -201,16 +201,29 @@ const UserManagement: React.FC = () => {
</TableHead> </TableHead>
<TableBody> <TableBody>
{filteredUsers.map((user) => ( {filteredUsers.map((user) => (
<TableRow key={user.id} sx={{ "& > td": { whiteSpace: "nowrap"}}}> <TableRow
key={user.id}
sx={{ "& > td": { whiteSpace: "nowrap" } }}
>
<TableCell> <TableCell>
<Box sx={{ display: 'flex', alignItems: 'flex-start', flexDirection: "column" }}> <Box
sx={{
display: "flex",
alignItems: "flex-start",
flexDirection: "column",
}}
>
<Typography>{getUserDisplayName(user)}</Typography> <Typography>{getUserDisplayName(user)}</Typography>
</Box> </Box>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Chip <Chip
label={user.type === 'candidate' ? 'Candidate' : 'Employer'} label={
color={user.type === 'candidate' ? 'primary' : 'secondary'} user.type === "candidate" ? "Candidate" : "Employer"
}
color={
user.type === "candidate" ? "primary" : "secondary"
}
size="small" size="small"
/> />
</TableCell> </TableCell>
@ -224,8 +237,8 @@ const UserManagement: React.FC = () => {
<TableCell>{formatDate(user.lastLogin)}</TableCell> <TableCell>{formatDate(user.lastLogin)}</TableCell>
<TableCell> <TableCell>
<Chip <Chip
label={user.isActive ? 'Active' : 'Inactive'} label={user.isActive ? "Active" : "Inactive"}
color={user.isActive ? 'success' : 'error'} color={user.isActive ? "success" : "error"}
size="small" size="small"
/> />
</TableCell> </TableCell>
@ -255,17 +268,26 @@ const UserManagement: React.FC = () => {
</Paper> </Paper>
{/* User Details Dialog */} {/* User Details Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth> <Dialog
open={openDialog}
onClose={handleCloseDialog}
maxWidth="md"
fullWidth
>
{selectedUser && ( {selectedUser && (
<> <>
<DialogTitle> <DialogTitle>
{selectedUser.type === 'candidate' ? 'Candidate Details' : 'Employer Details'} {selectedUser.type === "candidate"
? "Candidate Details"
: "Employer Details"}
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
{selectedUser.type === 'candidate' ? ( {selectedUser.type === "candidate" ? (
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">Personal Information</Typography> <Typography variant="subtitle1">
Personal Information
</Typography>
<TextField <TextField
label="First Name" label="First Name"
value={selectedUser.firstName} value={selectedUser.firstName}
@ -304,7 +326,9 @@ const UserManagement: React.FC = () => {
) : ( ) : (
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">Company Information</Typography> <Typography variant="subtitle1">
Company Information
</Typography>
<TextField <TextField
label="Company Name" label="Company Name"
value={selectedUser.companyName} value={selectedUser.companyName}
@ -328,7 +352,9 @@ const UserManagement: React.FC = () => {
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">Contact Information</Typography> <Typography variant="subtitle1">
Contact Information
</Typography>
<TextField <TextField
label="Email" label="Email"
value={selectedUser.email} value={selectedUser.email}
@ -355,7 +381,12 @@ const UserManagement: React.FC = () => {
</Dialog> </Dialog>
{/* AI Config Dialog */} {/* AI Config Dialog */}
<Dialog open={aiConfigOpen} onClose={handleCloseAiConfig} maxWidth="md" fullWidth> <Dialog
open={aiConfigOpen}
onClose={handleCloseAiConfig}
maxWidth="md"
fullWidth
>
{selectedUser && ( {selectedUser && (
<> <>
<DialogTitle> <DialogTitle>
@ -367,7 +398,9 @@ const UserManagement: React.FC = () => {
</Typography> </Typography>
<FormControl fullWidth margin="normal"> <FormControl fullWidth margin="normal">
<InputLabel id="embedding-model-label">Embedding Model</InputLabel> <InputLabel id="embedding-model-label">
Embedding Model
</InputLabel>
<Select <Select
labelId="embedding-model-label" labelId="embedding-model-label"
label="Embedding Model" label="Embedding Model"
@ -448,7 +481,11 @@ const UserManagement: React.FC = () => {
rows={4} rows={4}
fullWidth fullWidth
margin="normal" margin="normal"
defaultValue={`You are an AI assistant helping ${selectedUser.type === 'candidate' ? 'job candidates find relevant positions' : 'employers find qualified candidates'}. Be professional, helpful, and concise in your responses.`} defaultValue={`You are an AI assistant helping ${
selectedUser.type === "candidate"
? "job candidates find relevant positions"
: "employers find qualified candidates"
}. Be professional, helpful, and concise in your responses.`}
/> />
<Typography variant="subtitle1" gutterBottom sx={{ mt: 2 }}> <Typography variant="subtitle1" gutterBottom sx={{ mt: 2 }}>
@ -496,7 +533,9 @@ const UserManagement: React.FC = () => {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleCloseAiConfig}>Cancel</Button> <Button onClick={handleCloseAiConfig}>Cancel</Button>
<Button variant="contained" color="primary">Save Configuration</Button> <Button variant="contained" color="primary">
Save Configuration
</Button>
</DialogActions> </DialogActions>
</> </>
)} )}

View File

@ -1,9 +1,21 @@
// Replace the existing AuthContext.tsx with these enhancements // Replace the existing AuthContext.tsx with these enhancements
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; import React, {
import * as Types from '../types/types'; createContext,
import { ApiClient, CreateCandidateRequest, CreateEmployerRequest, GuestConversionRequest } from 'services/api-client'; useContext,
import { formatApiRequest, toCamelCase } from 'types/conversion'; useState,
useCallback,
useEffect,
useRef,
} from "react";
import * as Types from "../types/types";
import {
ApiClient,
CreateCandidateRequest,
CreateEmployerRequest,
GuestConversionRequest,
} from "services/api-client";
import { formatApiRequest, toCamelCase } from "types/conversion";
// ============================ // ============================
// Enhanced Types and Interfaces // Enhanced Types and Interfaces
@ -42,13 +54,13 @@ interface PasswordResetRequest {
// ============================ // ============================
const TOKEN_STORAGE = { const TOKEN_STORAGE = {
ACCESS_TOKEN: 'accessToken', ACCESS_TOKEN: "accessToken",
REFRESH_TOKEN: 'refreshToken', REFRESH_TOKEN: "refreshToken",
USER_DATA: 'userData', USER_DATA: "userData",
TOKEN_EXPIRY: 'tokenExpiry', TOKEN_EXPIRY: "tokenExpiry",
USER_TYPE: 'userType', USER_TYPE: "userType",
IS_GUEST: 'isGuest', IS_GUEST: "isGuest",
PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail' PENDING_VERIFICATION_EMAIL: "pendingVerificationEmail",
} as const; } as const;
// ============================ // ============================
@ -57,17 +69,17 @@ const TOKEN_STORAGE = {
function parseJwtPayload(token: string): any { function parseJwtPayload(token: string): any {
try { try {
const base64Url = token.split('.')[1]; const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent( const jsonPayload = decodeURIComponent(
atob(base64) atob(base64)
.split('') .split("")
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join('') .join("")
); );
return JSON.parse(jsonPayload); return JSON.parse(jsonPayload);
} catch (error) { } catch (error) {
console.error('Failed to parse JWT token:', error); console.error("Failed to parse JWT token:", error);
return null; return null;
} }
} }
@ -83,7 +95,7 @@ function isTokenExpired(token: string): boolean {
const bufferTime = 5 * 60 * 1000; // 5 minutes const bufferTime = 5 * 60 * 1000; // 5 minutes
const currentTime = Date.now(); const currentTime = Date.now();
return currentTime >= (expiryTime - bufferTime); return currentTime >= expiryTime - bufferTime;
} }
// ============================ // ============================
@ -105,7 +117,7 @@ function prepareUserDataForStorage(user: Types.User): string {
const userForStorage = formatApiRequest(user); const userForStorage = formatApiRequest(user);
return JSON.stringify(userForStorage); return JSON.stringify(userForStorage);
} catch (error) { } catch (error) {
console.error('Failed to prepare user data for storage:', error); console.error("Failed to prepare user data for storage:", error);
return JSON.stringify(user); // Fallback to direct serialization return JSON.stringify(user); // Fallback to direct serialization
} }
} }
@ -119,24 +131,36 @@ function parseStoredUserData(userDataStr: string): Types.User | null {
return convertedData; return convertedData;
} catch (error) { } catch (error) {
console.error('Failed to parse stored user data:', error); console.error("Failed to parse stored user data:", error);
return null; return null;
} }
} }
function updateStoredUserData(user: Types.User): void { function updateStoredUserData(user: Types.User): void {
try { try {
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(user)); localStorage.setItem(
TOKEN_STORAGE.USER_DATA,
prepareUserDataForStorage(user)
);
} catch (error) { } catch (error) {
console.error('Failed to update stored user data:', error); console.error("Failed to update stored user data:", error);
} }
} }
function storeAuthData(authResponse: Types.AuthResponse, isGuest: boolean = false): void { function storeAuthData(
authResponse: Types.AuthResponse,
isGuest = false
): void {
localStorage.setItem(TOKEN_STORAGE.ACCESS_TOKEN, authResponse.accessToken); localStorage.setItem(TOKEN_STORAGE.ACCESS_TOKEN, authResponse.accessToken);
localStorage.setItem(TOKEN_STORAGE.REFRESH_TOKEN, authResponse.refreshToken); localStorage.setItem(TOKEN_STORAGE.REFRESH_TOKEN, authResponse.refreshToken);
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(authResponse.user)); localStorage.setItem(
localStorage.setItem(TOKEN_STORAGE.TOKEN_EXPIRY, authResponse.expiresAt.toString()); TOKEN_STORAGE.USER_DATA,
prepareUserDataForStorage(authResponse.user)
);
localStorage.setItem(
TOKEN_STORAGE.TOKEN_EXPIRY,
authResponse.expiresAt.toString()
);
localStorage.setItem(TOKEN_STORAGE.USER_TYPE, authResponse.user.userType); localStorage.setItem(TOKEN_STORAGE.USER_TYPE, authResponse.user.userType);
localStorage.setItem(TOKEN_STORAGE.IS_GUEST, isGuest.toString()); localStorage.setItem(TOKEN_STORAGE.IS_GUEST, isGuest.toString());
} }
@ -167,7 +191,7 @@ function getStoredAuthData(): {
expiresAt = parseInt(expiryStr, 10); expiresAt = parseInt(expiryStr, 10);
} }
} catch (error) { } catch (error) {
console.error('Failed to parse stored auth data:', error); console.error("Failed to parse stored auth data:", error);
clearStoredAuth(); clearStoredAuth();
} }
@ -177,7 +201,7 @@ function getStoredAuthData(): {
userData, userData,
expiresAt, expiresAt,
userType, userType,
isGuest: isGuestStr === 'true' isGuest: isGuestStr === "true",
}; };
} }
@ -202,15 +226,18 @@ function useAuthenticationLogic() {
const guestCreationAttempted = useRef(false); const guestCreationAttempted = useRef(false);
// Token refresh function // Token refresh function
const refreshAccessToken = useCallback(async (refreshToken: string): Promise<Types.AuthResponse | null> => { const refreshAccessToken = useCallback(
async (refreshToken: string): Promise<Types.AuthResponse | null> => {
try { try {
const response = await apiClient.refreshToken(refreshToken); const response = await apiClient.refreshToken(refreshToken);
return response; return response;
} catch (error) { } catch (error) {
console.error('Token refresh failed:', error); console.error("Token refresh failed:", error);
return null; return null;
} }
}, [apiClient]); },
[apiClient]
);
// Create guest session // Create guest session
const createGuestSession = useCallback(async (): Promise<boolean> => { const createGuestSession = useCallback(async (): Promise<boolean> => {
@ -221,10 +248,10 @@ function useAuthenticationLogic() {
guestCreationAttempted.current = true; guestCreationAttempted.current = true;
try { try {
console.log('🔄 Creating guest session...'); console.log("🔄 Creating guest session...");
const guestAuth = await apiClient.createGuestSession(); const guestAuth = await apiClient.createGuestSession();
if (guestAuth && guestAuth.user && guestAuth.user.userType === 'guest') { if (guestAuth && guestAuth.user && guestAuth.user.userType === "guest") {
storeAuthData(guestAuth, true); storeAuthData(guestAuth, true);
apiClient.setAuthToken(guestAuth.accessToken); apiClient.setAuthToken(guestAuth.accessToken);
@ -239,17 +266,17 @@ function useAuthenticationLogic() {
mfaResponse: null, mfaResponse: null,
}); });
console.log('👤 Guest session created successfully:', guestAuth.user); console.log("👤 Guest session created successfully:", guestAuth.user);
return true; return true;
} }
return false; return false;
} catch (error) { } catch (error) {
console.error('❌ Failed to create guest session:', error); console.error("❌ Failed to create guest session:", error);
guestCreationAttempted.current = false; guestCreationAttempted.current = false;
// Set to unauthenticated state if guest creation fails // Set to unauthenticated state if guest creation fails
setAuthState(prev => ({ setAuthState((prev) => ({
...prev, ...prev,
user: null, user: null,
guest: null, guest: null,
@ -257,7 +284,7 @@ function useAuthenticationLogic() {
isGuest: false, isGuest: false,
isLoading: false, isLoading: false,
isInitializing: false, isInitializing: false,
error: 'Failed to create guest session', error: "Failed to create guest session",
})); }));
return false; return false;
@ -275,28 +302,35 @@ function useAuthenticationLogic() {
// If no stored tokens, create guest session // If no stored tokens, create guest session
if (!stored.accessToken || !stored.refreshToken || !stored.userData) { if (!stored.accessToken || !stored.refreshToken || !stored.userData) {
console.log('🔄 No stored auth found, creating guest session...'); console.log("🔄 No stored auth found, creating guest session...");
await createGuestSession(); await createGuestSession();
return; return;
} }
// For guests, always verify the session exists on server // For guests, always verify the session exists on server
if (stored.userType === 'guest' && stored.userData) { if (stored.userType === "guest" && stored.userData) {
console.log(stored.userData); console.log(stored.userData);
try { try {
// Make a quick API call to verify guest still exists // Make a quick API call to verify guest still exists
const response = await fetch(`${apiClient.getBaseUrl()}/users/${stored.userData.id}`, { const response = await fetch(
headers: { 'Authorization': `Bearer ${stored.accessToken}` } `${apiClient.getBaseUrl()}/users/${stored.userData.id}`,
}); {
headers: { Authorization: `Bearer ${stored.accessToken}` },
}
);
if (!response.ok) { if (!response.ok) {
console.log('🔄 Guest session invalid, creating new guest session...'); console.log(
"🔄 Guest session invalid, creating new guest session..."
);
clearStoredAuth(); clearStoredAuth();
await createGuestSession(); await createGuestSession();
return; return;
} }
} catch (error) { } catch (error) {
console.log('🔄 Guest verification failed, creating new guest session...'); console.log(
"🔄 Guest verification failed, creating new guest session..."
);
clearStoredAuth(); clearStoredAuth();
await createGuestSession(); await createGuestSession();
return; return;
@ -305,29 +339,29 @@ function useAuthenticationLogic() {
// Check if access token is expired // Check if access token is expired
if (isTokenExpired(stored.accessToken)) { if (isTokenExpired(stored.accessToken)) {
console.log('🔄 Access token expired, attempting refresh...'); console.log("🔄 Access token expired, attempting refresh...");
const refreshResult = await refreshAccessToken(stored.refreshToken); const refreshResult = await refreshAccessToken(stored.refreshToken);
if (refreshResult) { if (refreshResult) {
const isGuest = stored.userType === 'guest'; const isGuest = stored.userType === "guest";
storeAuthData(refreshResult, isGuest); storeAuthData(refreshResult, isGuest);
apiClient.setAuthToken(refreshResult.accessToken); apiClient.setAuthToken(refreshResult.accessToken);
setAuthState({ setAuthState({
user: isGuest ? null : refreshResult.user, user: isGuest ? null : refreshResult.user,
guest: isGuest ? refreshResult.user as Types.Guest : null, guest: isGuest ? (refreshResult.user as Types.Guest) : null,
isAuthenticated: true, isAuthenticated: true,
isGuest, isGuest,
isLoading: false, isLoading: false,
isInitializing: false, isInitializing: false,
error: null, error: null,
mfaResponse: null mfaResponse: null,
}); });
console.log('✅ Token refreshed successfully'); console.log("✅ Token refreshed successfully");
} else { } else {
console.log('❌ Token refresh failed, creating new guest session...'); console.log("❌ Token refresh failed, creating new guest session...");
clearStoredAuth(); clearStoredAuth();
apiClient.clearAuthToken(); apiClient.clearAuthToken();
await createGuestSession(); await createGuestSession();
@ -335,23 +369,23 @@ function useAuthenticationLogic() {
} else { } else {
// Access token is still valid // Access token is still valid
apiClient.setAuthToken(stored.accessToken); apiClient.setAuthToken(stored.accessToken);
const isGuest = stored.userType === 'guest'; const isGuest = stored.userType === "guest";
setAuthState({ setAuthState({
user: isGuest ? null : stored.userData, user: isGuest ? null : stored.userData,
guest: isGuest ? stored.userData as Types.Guest : null, guest: isGuest ? (stored.userData as Types.Guest) : null,
isAuthenticated: true, isAuthenticated: true,
isGuest, isGuest,
isLoading: false, isLoading: false,
isInitializing: false, isInitializing: false,
error: null, error: null,
mfaResponse: null mfaResponse: null,
}); });
console.log('✅ Restored authentication from stored tokens'); console.log("✅ Restored authentication from stored tokens");
} }
} catch (error) { } catch (error) {
console.error('❌ Error initializing auth:', error); console.error("❌ Error initializing auth:", error);
clearStoredAuth(); clearStoredAuth();
apiClient.clearAuthToken(); apiClient.clearAuthToken();
await createGuestSession(); await createGuestSession();
@ -378,7 +412,7 @@ function useAuthenticationLogic() {
const expiryTime = stored.expiresAt * 1000; const expiryTime = stored.expiresAt * 1000;
const currentTime = Date.now(); const currentTime = Date.now();
const timeUntilExpiry = expiryTime - currentTime - (5 * 60 * 1000); // 5 minute buffer const timeUntilExpiry = expiryTime - currentTime - 5 * 60 * 1000; // 5 minute buffer
if (timeUntilExpiry <= 0) { if (timeUntilExpiry <= 0) {
initializeAuth(); initializeAuth();
@ -386,7 +420,7 @@ function useAuthenticationLogic() {
} }
const refreshTimer = setTimeout(() => { const refreshTimer = setTimeout(() => {
console.log('🔄 Auto-refreshing token before expiry...'); console.log("🔄 Auto-refreshing token before expiry...");
initializeAuth(); initializeAuth();
}, timeUntilExpiry); }, timeUntilExpiry);
@ -394,8 +428,14 @@ function useAuthenticationLogic() {
}, [authState.isAuthenticated, initializeAuth]); }, [authState.isAuthenticated, initializeAuth]);
// Enhanced login with MFA support // Enhanced login with MFA support
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => { const login = useCallback(
setAuthState(prev => ({ ...prev, isLoading: true, error: null, mfaResponse: null })); async (loginData: LoginRequest): Promise<boolean> => {
setAuthState((prev) => ({
...prev,
isLoading: true,
error: null,
mfaResponse: null,
}));
try { try {
const result = await apiClient.login({ const result = await apiClient.login({
@ -403,9 +443,9 @@ function useAuthenticationLogic() {
password: loginData.password, password: loginData.password,
}); });
if ('mfaRequired' in result) { if ("mfaRequired" in result) {
// MFA required for new device // MFA required for new device
setAuthState(prev => ({ setAuthState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
mfaResponse: result, mfaResponse: result,
@ -417,7 +457,7 @@ function useAuthenticationLogic() {
storeAuthData(authResponse, false); storeAuthData(authResponse, false);
apiClient.setAuthToken(authResponse.accessToken); apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({ setAuthState((prev) => ({
...prev, ...prev,
user: authResponse.user, user: authResponse.user,
guest: null, guest: null,
@ -428,12 +468,17 @@ function useAuthenticationLogic() {
mfaResponse: null, mfaResponse: null,
})); }));
console.log('✅ Login successful, converted from guest to authenticated user'); console.log(
"✅ Login successful, converted from guest to authenticated user"
);
return true; return true;
} }
} catch (error: any) { } catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Network error occurred. Please try again.'; const errorMessage =
setAuthState(prev => ({ error instanceof Error
? error.message
: "Network error occurred. Please try again.";
setAuthState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: errorMessage, error: errorMessage,
@ -441,15 +486,18 @@ function useAuthenticationLogic() {
})); }));
return false; return false;
} }
}, [apiClient]); },
[apiClient]
);
// Convert guest to permanent user // Convert guest to permanent user
const convertGuestToUser = useCallback(async (registrationData: GuestConversionRequest): Promise<boolean> => { const convertGuestToUser = useCallback(
async (registrationData: GuestConversionRequest): Promise<boolean> => {
if (!authState.isGuest || !authState.guest) { if (!authState.isGuest || !authState.guest) {
throw new Error('Not currently a guest user'); throw new Error("Not currently a guest user");
} }
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try { try {
const result = await apiClient.convertGuestToUser(registrationData); const result = await apiClient.convertGuestToUser(registrationData);
@ -458,7 +506,7 @@ function useAuthenticationLogic() {
storeAuthData(result.auth, false); storeAuthData(result.auth, false);
apiClient.setAuthToken(result.auth.accessToken); apiClient.setAuthToken(result.auth.accessToken);
setAuthState(prev => ({ setAuthState((prev) => ({
...prev, ...prev,
user: result.auth.user, user: result.auth.user,
guest: null, guest: null,
@ -468,22 +516,28 @@ function useAuthenticationLogic() {
error: null, error: null,
})); }));
console.log('✅ Guest successfully converted to permanent user'); console.log("✅ Guest successfully converted to permanent user");
return true; return true;
} catch (error: any) { } catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Failed to convert guest account'; const errorMessage =
setAuthState(prev => ({ error instanceof Error
? error.message
: "Failed to convert guest account";
setAuthState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: errorMessage, error: errorMessage,
})); }));
return false; return false;
} }
}, [apiClient, authState.isGuest, authState.guest]); },
[apiClient, authState.isGuest, authState.guest]
);
// MFA verification // MFA verification
const verifyMFA = useCallback(async (mfaData: Types.MFAVerifyRequest): Promise<boolean> => { const verifyMFA = useCallback(
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); async (mfaData: Types.MFAVerifyRequest): Promise<boolean> => {
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try { try {
const result = await apiClient.verifyMFA(mfaData); const result = await apiClient.verifyMFA(mfaData);
@ -493,7 +547,7 @@ function useAuthenticationLogic() {
storeAuthData(authResponse, false); storeAuthData(authResponse, false);
apiClient.setAuthToken(authResponse.accessToken); apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({ setAuthState((prev) => ({
...prev, ...prev,
user: authResponse.user, user: authResponse.user,
guest: null, guest: null,
@ -504,21 +558,24 @@ function useAuthenticationLogic() {
mfaResponse: null, mfaResponse: null,
})); }));
console.log('✅ MFA verification successful, converted from guest'); console.log("✅ MFA verification successful, converted from guest");
return true; return true;
} }
return false; return false;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'MFA verification failed'; const errorMessage =
setAuthState(prev => ({ error instanceof Error ? error.message : "MFA verification failed";
setAuthState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: errorMessage error: errorMessage,
})); }));
return false; return false;
} }
}, [apiClient]); },
[apiClient]
);
// Logout - returns to guest session // Logout - returns to guest session
const logout = useCallback(async () => { const logout = useCallback(async () => {
@ -530,12 +587,14 @@ function useAuthenticationLogic() {
try { try {
await apiClient.logout(stored.accessToken, stored.refreshToken); await apiClient.logout(stored.accessToken, stored.refreshToken);
} catch (error) { } catch (error) {
console.warn('Logout request failed, proceeding with local cleanup'); console.warn(
"Logout request failed, proceeding with local cleanup"
);
} }
} }
} }
} catch (error) { } catch (error) {
console.warn('Error during logout:', error); console.warn("Error during logout:", error);
} finally { } finally {
// Always clear stored auth and create new guest session // Always clear stored auth and create new guest session
clearStoredAuth(); clearStoredAuth();
@ -545,61 +604,79 @@ function useAuthenticationLogic() {
// Create new guest session // Create new guest session
await createGuestSession(); await createGuestSession();
console.log('🔄 Logged out, created new guest session'); console.log("🔄 Logged out, created new guest session");
} }
}, [apiClient, authState.isAuthenticated, authState.isGuest, createGuestSession]); }, [
apiClient,
authState.isAuthenticated,
authState.isGuest,
createGuestSession,
]);
// Update user data // Update user data
const updateUserData = useCallback((updatedUser: Types.User) => { const updateUserData = useCallback(
(updatedUser: Types.User) => {
updateStoredUserData(updatedUser); updateStoredUserData(updatedUser);
setAuthState(prev => ({ setAuthState((prev) => ({
...prev, ...prev,
user: authState.isGuest ? null : updatedUser, user: authState.isGuest ? null : updatedUser,
guest: authState.isGuest ? updatedUser as Types.Guest : prev.guest guest: authState.isGuest ? (updatedUser as Types.Guest) : prev.guest,
})); }));
console.log('✅ User data updated'); console.log("✅ User data updated");
}, [authState.isGuest]); },
[authState.isGuest]
);
// Email verification functions (unchanged) // Email verification functions (unchanged)
const verifyEmail = useCallback(async (verificationData: EmailVerificationRequest) => { const verifyEmail = useCallback(
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); async (verificationData: EmailVerificationRequest) => {
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try { try {
const result = await apiClient.verifyEmail(verificationData); const result = await apiClient.verifyEmail(verificationData);
setAuthState(prev => ({ ...prev, isLoading: false })); setAuthState((prev) => ({ ...prev, isLoading: false }));
return { return {
message: result.message || 'Email verified successfully', message: result.message || "Email verified successfully",
userType: result.userType || 'user' userType: result.userType || "user",
}; };
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Email verification failed'; const errorMessage =
setAuthState(prev => ({ error instanceof Error ? error.message : "Email verification failed";
setAuthState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: errorMessage error: errorMessage,
})); }));
return null; return null;
} }
}, [apiClient]); },
[apiClient]
);
// Other existing methods remain the same... // Other existing methods remain the same...
const resendEmailVerification = useCallback(async (email: string): Promise<boolean> => { const resendEmailVerification = useCallback(
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); async (email: string): Promise<boolean> => {
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try { try {
await apiClient.resendVerificationEmail({ email }); await apiClient.resendVerificationEmail({ email });
setAuthState(prev => ({ ...prev, isLoading: false })); setAuthState((prev) => ({ ...prev, isLoading: false }));
return true; return true;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to resend verification email'; const errorMessage =
setAuthState(prev => ({ error instanceof Error
? error.message
: "Failed to resend verification email";
setAuthState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: errorMessage error: errorMessage,
})); }));
return false; return false;
} }
}, [apiClient]); },
[apiClient]
);
const setPendingVerificationEmail = useCallback((email: string) => { const setPendingVerificationEmail = useCallback((email: string) => {
localStorage.setItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL, email); localStorage.setItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL, email);
@ -609,45 +686,55 @@ function useAuthenticationLogic() {
return localStorage.getItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL); return localStorage.getItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL);
}, []); }, []);
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => { const createEmployerAccount = useCallback(
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); async (employerData: CreateEmployerRequest): Promise<boolean> => {
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try { try {
const employer = await apiClient.createEmployer(employerData); const employer = await apiClient.createEmployer(employerData);
console.log('✅ Employer created:', employer); console.log("✅ Employer created:", employer);
setPendingVerificationEmail(employerData.email); setPendingVerificationEmail(employerData.email);
setAuthState(prev => ({ ...prev, isLoading: false })); setAuthState((prev) => ({ ...prev, isLoading: false }));
return true; return true;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; const errorMessage =
setAuthState(prev => ({ error instanceof Error ? error.message : "Account creation failed";
setAuthState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: errorMessage error: errorMessage,
})); }));
return false; return false;
} }
}, [apiClient, setPendingVerificationEmail]); },
[apiClient, setPendingVerificationEmail]
);
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => { const requestPasswordReset = useCallback(
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); async (email: string): Promise<boolean> => {
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try { try {
await apiClient.requestPasswordReset({ email }); await apiClient.requestPasswordReset({ email });
setAuthState(prev => ({ ...prev, isLoading: false })); setAuthState((prev) => ({ ...prev, isLoading: false }));
return true; return true;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Password reset request failed'; const errorMessage =
setAuthState(prev => ({ error instanceof Error
? error.message
: "Password reset request failed";
setAuthState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: errorMessage error: errorMessage,
})); }));
return false; return false;
} }
}, [apiClient]); },
[apiClient]
);
const refreshAuth = useCallback(async (): Promise<boolean> => { const refreshAuth = useCallback(async (): Promise<boolean> => {
const stored = getStoredAuthData(); const stored = getStoredAuthData();
@ -655,23 +742,23 @@ function useAuthenticationLogic() {
return false; return false;
} }
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
const refreshResult = await refreshAccessToken(stored.refreshToken); const refreshResult = await refreshAccessToken(stored.refreshToken);
if (refreshResult) { if (refreshResult) {
const isGuest = stored.userType === 'guest'; const isGuest = stored.userType === "guest";
storeAuthData(refreshResult, isGuest); storeAuthData(refreshResult, isGuest);
apiClient.setAuthToken(refreshResult.accessToken); apiClient.setAuthToken(refreshResult.accessToken);
setAuthState(prev => ({ setAuthState((prev) => ({
...prev, ...prev,
user: isGuest ? null : refreshResult.user, user: isGuest ? null : refreshResult.user,
guest: isGuest ? refreshResult.user as Types.Guest : null, guest: isGuest ? (refreshResult.user as Types.Guest) : null,
isAuthenticated: true, isAuthenticated: true,
isGuest, isGuest,
isLoading: false, isLoading: false,
error: null error: null,
})); }));
return true; return true;
@ -682,36 +769,44 @@ function useAuthenticationLogic() {
}, [refreshAccessToken, logout]); }, [refreshAccessToken, logout]);
// Resend MFA code // Resend MFA code
const resendMFACode = useCallback(async (email: string, deviceId: string, deviceName: string): Promise<boolean> => { const resendMFACode = useCallback(
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); async (
email: string,
deviceId: string,
deviceName: string
): Promise<boolean> => {
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try { try {
await apiClient.requestMFA({ await apiClient.requestMFA({
email, email,
password: '', // This would need to be stored securely or re-entered password: "", // This would need to be stored securely or re-entered
deviceId, deviceId,
deviceName, deviceName,
}); });
setAuthState(prev => ({ ...prev, isLoading: false })); setAuthState((prev) => ({ ...prev, isLoading: false }));
return true; return true;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to resend MFA code'; const errorMessage =
setAuthState(prev => ({ error instanceof Error ? error.message : "Failed to resend MFA code";
setAuthState((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: errorMessage error: errorMessage,
})); }));
return false; return false;
} }
}, [apiClient]); },
[apiClient]
);
// Clear MFA state // Clear MFA state
const clearMFA = useCallback(() => { const clearMFA = useCallback(() => {
setAuthState(prev => ({ setAuthState((prev) => ({
...prev, ...prev,
mfaResponse: null, mfaResponse: null,
error: null error: null,
})); }));
}, []); }, []);
@ -732,7 +827,7 @@ function useAuthenticationLogic() {
refreshAuth, refreshAuth,
updateUserData, updateUserData,
convertGuestToUser, convertGuestToUser,
createGuestSession createGuestSession,
}; };
} }
@ -740,22 +835,20 @@ function useAuthenticationLogic() {
// Enhanced Context Provider // Enhanced Context Provider
// ============================ // ============================
const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | null>(null); const AuthContext = createContext<ReturnType<
typeof useAuthenticationLogic
> | null>(null);
function AuthProvider({ children }: { children: React.ReactNode }) { function AuthProvider({ children }: { children: React.ReactNode }) {
const auth = useAuthenticationLogic(); const auth = useAuthenticationLogic();
return ( return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
);
} }
function useAuth() { function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { if (!context) {
throw new Error('useAuth must be used within an AuthProvider'); throw new Error("useAuth must be used within an AuthProvider");
} }
return context; return context;
} }
@ -775,7 +868,7 @@ function ProtectedRoute({
children, children,
fallback = <div>Please log in to access this page.</div>, fallback = <div>Please log in to access this page.</div>,
requiredUserType, requiredUserType,
allowGuests = false allowGuests = false,
}: ProtectedRouteProps) { }: ProtectedRouteProps) {
const { isAuthenticated, isInitializing, user, isGuest } = useAuth(); const { isAuthenticated, isInitializing, user, isGuest } = useAuth();
@ -808,14 +901,12 @@ export type {
EmailVerificationRequest, EmailVerificationRequest,
ResendVerificationRequest, ResendVerificationRequest,
PasswordResetRequest, PasswordResetRequest,
GuestConversionRequest GuestConversionRequest,
} };
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client'; export type {
CreateCandidateRequest,
CreateEmployerRequest,
} from "../services/api-client";
export { export { useAuthenticationLogic, AuthProvider, useAuth, ProtectedRoute };
useAuthenticationLogic,
AuthProvider,
useAuth,
ProtectedRoute
}

View File

@ -1,23 +1,30 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; import React, {
import * as Types from 'types/types'; createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
} from "react";
import * as Types from "types/types";
// Assuming you're using React Router // Assuming you're using React Router
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { SetSnackType, SeverityType, Snack } from 'components/Snack'; import { SetSnackType, SeverityType, Snack } from "components/Snack";
// ============================ // ============================
// Storage Keys // Storage Keys
// ============================ // ============================
const STORAGE_KEYS = { const STORAGE_KEYS = {
SELECTED_CANDIDATE_ID: 'selectedCandidateId', SELECTED_CANDIDATE_ID: "selectedCandidateId",
SELECTED_JOB_ID: 'selectedJobId', SELECTED_JOB_ID: "selectedJobId",
SELECTED_EMPLOYER_ID: 'selectedEmployerId', SELECTED_EMPLOYER_ID: "selectedEmployerId",
LAST_ROUTE: 'lastVisitedRoute', LAST_ROUTE: "lastVisitedRoute",
ROUTE_STATE: 'routeState', ROUTE_STATE: "routeState",
ACTIVE_TAB: 'activeTab', ACTIVE_TAB: "activeTab",
APPLIED_FILTERS: 'appliedFilters', APPLIED_FILTERS: "appliedFilters",
SIDEBAR_COLLAPSED: 'sidebarCollapsed' SIDEBAR_COLLAPSED: "sidebarCollapsed",
} as const; } as const;
// ============================ // ============================
@ -74,7 +81,7 @@ function getStoredId(key: string): string | null {
try { try {
return localStorage.getItem(key); return localStorage.getItem(key);
} catch (error) { } catch (error) {
console.warn('Failed to read from localStorage:', error); console.warn("Failed to read from localStorage:", error);
return null; return null;
} }
} }
@ -87,7 +94,7 @@ function setStoredId(key: string, id: string | null): void {
localStorage.removeItem(key); localStorage.removeItem(key);
} }
} catch (error) { } catch (error) {
console.warn('Failed to write to localStorage:', error); console.warn("Failed to write to localStorage:", error);
} }
} }
@ -118,7 +125,7 @@ function getInitialRouteState(): RouteState {
lastRoute: getStoredId(STORAGE_KEYS.LAST_ROUTE), lastRoute: getStoredId(STORAGE_KEYS.LAST_ROUTE),
activeTab: getStoredId(STORAGE_KEYS.ACTIVE_TAB), activeTab: getStoredId(STORAGE_KEYS.ACTIVE_TAB),
appliedFilters: getStoredObject(STORAGE_KEYS.APPLIED_FILTERS, {}), appliedFilters: getStoredObject(STORAGE_KEYS.APPLIED_FILTERS, {}),
sidebarCollapsed: getStoredObject(STORAGE_KEYS.SIDEBAR_COLLAPSED, false) sidebarCollapsed: getStoredObject(STORAGE_KEYS.SIDEBAR_COLLAPSED, false),
}; };
} }
@ -139,14 +146,19 @@ export function useAppStateLogic(): AppStateContextType {
const { apiClient } = useAuth(); const { apiClient } = useAuth();
// Entity state // Entity state
const [selectedCandidate, setSelectedCandidateState] = useState<Types.Candidate | null>(null); const [selectedCandidate, setSelectedCandidateState] =
useState<Types.Candidate | null>(null);
const [selectedJob, setSelectedJobState] = useState<Types.Job | null>(null); const [selectedJob, setSelectedJobState] = useState<Types.Job | null>(null);
const [selectedEmployer, setSelectedEmployerState] = useState<Types.Employer | null>(null); const [selectedEmployer, setSelectedEmployerState] =
const [selectedResume, setSelectedResume] = useState<Types.Resume | null>(null); useState<Types.Employer | null>(null);
const [selectedResume, setSelectedResume] = useState<Types.Resume | null>(
null
);
const [isInitializing, setIsInitializing] = useState<boolean>(true); const [isInitializing, setIsInitializing] = useState<boolean>(true);
// Route state // Route state
const [routeState, setRouteStateState] = useState<RouteState>(getInitialRouteState); const [routeState, setRouteStateState] =
useState<RouteState>(getInitialRouteState);
// ============================ // ============================
// Initialization Effect // Initialization Effect
@ -172,13 +184,13 @@ export function useAppStateLogic(): AppStateContextType {
const candidate = await apiClient.getCandidate(candidateId); const candidate = await apiClient.getCandidate(candidateId);
if (candidate) { if (candidate) {
setSelectedCandidateState(candidate); setSelectedCandidateState(candidate);
console.log('Restored candidate from storage:', candidate); console.log("Restored candidate from storage:", candidate);
} else { } else {
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null); setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null);
console.log('Candidate not found, cleared from storage'); console.log("Candidate not found, cleared from storage");
} }
} catch (error) { } catch (error) {
console.warn('Failed to restore candidate:', error); console.warn("Failed to restore candidate:", error);
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null); setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null);
} }
})() })()
@ -192,13 +204,13 @@ export function useAppStateLogic(): AppStateContextType {
const job = await apiClient.getJob(jobId); const job = await apiClient.getJob(jobId);
if (job) { if (job) {
setSelectedJobState(job); setSelectedJobState(job);
console.log('Restored job from storage:', job); console.log("Restored job from storage:", job);
} else { } else {
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null); setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
console.log('Job not found, cleared from storage'); console.log("Job not found, cleared from storage");
} }
} catch (error) { } catch (error) {
console.warn('Failed to restore job:', error); console.warn("Failed to restore job:", error);
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null); setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
} }
})() })()
@ -212,13 +224,13 @@ export function useAppStateLogic(): AppStateContextType {
const employer = await apiClient.getEmployer(employerId); const employer = await apiClient.getEmployer(employerId);
if (employer) { if (employer) {
setSelectedEmployerState(employer); setSelectedEmployerState(employer);
console.log('Restored employer from storage:', employer); console.log("Restored employer from storage:", employer);
} else { } else {
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null); setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
console.log('Employer not found, cleared from storage'); console.log("Employer not found, cleared from storage");
} }
} catch (error) { } catch (error) {
console.warn('Failed to restore employer:', error); console.warn("Failed to restore employer:", error);
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null); setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
} }
})() })()
@ -227,9 +239,8 @@ export function useAppStateLogic(): AppStateContextType {
// Wait for all restoration attempts to complete // Wait for all restoration attempts to complete
await Promise.all(promises); await Promise.all(promises);
} catch (error) { } catch (error) {
console.error('Error during app state initialization:', error); console.error("Error during app state initialization:", error);
} finally { } finally {
setIsInitializing(false); setIsInitializing(false);
} }
@ -244,11 +255,18 @@ export function useAppStateLogic(): AppStateContextType {
useEffect(() => { useEffect(() => {
// Don't save certain routes (login, register, etc.) // Don't save certain routes (login, register, etc.)
const excludedRoutes = ['/login', '/register', '/verify-email', '/reset-password']; const excludedRoutes = [
const shouldSaveRoute = !excludedRoutes.some(route => location.pathname.startsWith(route)); "/login",
"/register",
"/verify-email",
"/reset-password",
];
const shouldSaveRoute = !excludedRoutes.some((route) =>
location.pathname.startsWith(route)
);
if (shouldSaveRoute && !isInitializing) { if (shouldSaveRoute && !isInitializing) {
setRouteStateState(prev => { setRouteStateState((prev) => {
const newState = { ...prev, lastRoute: location.pathname }; const newState = { ...prev, lastRoute: location.pathname };
persistRouteState(newState); persistRouteState(newState);
return newState; return newState;
@ -260,25 +278,28 @@ export function useAppStateLogic(): AppStateContextType {
// Entity State Setters with Persistence // Entity State Setters with Persistence
// ============================ // ============================
const setSelectedCandidate = useCallback((candidate: Types.Candidate | null) => { const setSelectedCandidate = useCallback(
(candidate: Types.Candidate | null) => {
setSelectedCandidateState(candidate); setSelectedCandidateState(candidate);
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, candidate?.id || null); setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, candidate?.id || null);
if (candidate) { if (candidate) {
console.log('Selected candidate:', candidate); console.log("Selected candidate:", candidate);
} else { } else {
console.log('Cleared selected candidate'); console.log("Cleared selected candidate");
} }
}, []); },
[]
);
const setSelectedJob = useCallback((job: Types.Job | null) => { const setSelectedJob = useCallback((job: Types.Job | null) => {
setSelectedJobState(job); setSelectedJobState(job);
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, job?.id || null); setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, job?.id || null);
if (job) { if (job) {
console.log('Selected job:', job); console.log("Selected job:", job);
} else { } else {
console.log('Cleared selected job'); console.log("Cleared selected job");
} }
}, []); }, []);
@ -287,9 +308,9 @@ export function useAppStateLogic(): AppStateContextType {
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, employer?.id || null); setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, employer?.id || null);
if (employer) { if (employer) {
console.log('Selected employer:', employer); console.log("Selected employer:", employer);
} else { } else {
console.log('Cleared selected employer'); console.log("Cleared selected employer");
} }
}, []); }, []);
@ -302,7 +323,7 @@ export function useAppStateLogic(): AppStateContextType {
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null); setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null); setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
console.log('Cleared all selections'); console.log("Cleared all selections");
}, []); }, []);
// ============================ // ============================
@ -310,7 +331,7 @@ export function useAppStateLogic(): AppStateContextType {
// ============================ // ============================
const saveCurrentRoute = useCallback(() => { const saveCurrentRoute = useCallback(() => {
setRouteStateState(prev => { setRouteStateState((prev) => {
const newState = { ...prev, lastRoute: location.pathname }; const newState = { ...prev, lastRoute: location.pathname };
persistRouteState(newState); persistRouteState(newState);
return newState; return newState;
@ -324,7 +345,7 @@ export function useAppStateLogic(): AppStateContextType {
}, [routeState.lastRoute, location.pathname, navigate]); }, [routeState.lastRoute, location.pathname, navigate]);
const setActiveTab = useCallback((tab: string) => { const setActiveTab = useCallback((tab: string) => {
setRouteStateState(prev => { setRouteStateState((prev) => {
const newState = { ...prev, activeTab: tab }; const newState = { ...prev, activeTab: tab };
persistRouteState(newState); persistRouteState(newState);
return newState; return newState;
@ -332,7 +353,7 @@ export function useAppStateLogic(): AppStateContextType {
}, []); }, []);
const setFilters = useCallback((filters: Record<string, any>) => { const setFilters = useCallback((filters: Record<string, any>) => {
setRouteStateState(prev => { setRouteStateState((prev) => {
const newState = { ...prev, appliedFilters: filters }; const newState = { ...prev, appliedFilters: filters };
persistRouteState(newState); persistRouteState(newState);
return newState; return newState;
@ -340,7 +361,7 @@ export function useAppStateLogic(): AppStateContextType {
}, []); }, []);
const setSidebarCollapsed = useCallback((collapsed: boolean) => { const setSidebarCollapsed = useCallback((collapsed: boolean) => {
setRouteStateState(prev => { setRouteStateState((prev) => {
const newState = { ...prev, sidebarCollapsed: collapsed }; const newState = { ...prev, sidebarCollapsed: collapsed };
persistRouteState(newState); persistRouteState(newState);
return newState; return newState;
@ -352,7 +373,7 @@ export function useAppStateLogic(): AppStateContextType {
lastRoute: null, lastRoute: null,
activeTab: null, activeTab: null,
appliedFilters: {}, appliedFilters: {},
sidebarCollapsed: false sidebarCollapsed: false,
}; };
setRouteStateState(clearedState); setRouteStateState(clearedState);
@ -363,10 +384,13 @@ export function useAppStateLogic(): AppStateContextType {
localStorage.removeItem(STORAGE_KEYS.APPLIED_FILTERS); localStorage.removeItem(STORAGE_KEYS.APPLIED_FILTERS);
localStorage.removeItem(STORAGE_KEYS.SIDEBAR_COLLAPSED); localStorage.removeItem(STORAGE_KEYS.SIDEBAR_COLLAPSED);
console.log('Cleared all route state'); console.log("Cleared all route state");
}, []); }, []);
const emptySetSnack: SetSnackType = (message: string, severity?: SeverityType) => { const emptySetSnack: SetSnackType = (
message: string,
severity?: SeverityType
) => {
return; return;
}; };
@ -388,7 +412,7 @@ export function useAppStateLogic(): AppStateContextType {
setActiveTab, setActiveTab,
setFilters, setFilters,
setSidebarCollapsed, setSidebarCollapsed,
clearRouteState clearRouteState,
}; };
} }
@ -403,9 +427,12 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
const snackRef = useRef<any>(null); const snackRef = useRef<any>(null);
// Global UI components // Global UI components
appState.setSnack = useCallback((message: string, severity?: SeverityType) => { appState.setSnack = useCallback(
(message: string, severity?: SeverityType) => {
snackRef.current?.setSnack(message, severity); snackRef.current?.setSnack(message, severity);
}, [snackRef]); },
[snackRef]
);
return ( return (
<AppStateContext.Provider value={appState}> <AppStateContext.Provider value={appState}>
@ -418,7 +445,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
export function useAppState() { export function useAppState() {
const context = useContext(AppStateContext); const context = useContext(AppStateContext);
if (!context) { if (!context) {
throw new Error('useAppState must be used within an AppStateProvider'); throw new Error("useAppState must be used within an AppStateProvider");
} }
return context; return context;
} }
@ -454,7 +481,7 @@ export function useRouteState() {
setFilters, setFilters,
setSidebarCollapsed, setSidebarCollapsed,
restoreLastRoute, restoreLastRoute,
clearRouteState clearRouteState,
} = useAppState(); } = useAppState();
return { return {
@ -463,7 +490,7 @@ export function useRouteState() {
setFilters, setFilters,
setSidebarCollapsed, setSidebarCollapsed,
restoreLastRoute, restoreLastRoute,
clearRouteState clearRouteState,
}; };
} }

View File

@ -1,13 +1,13 @@
import { useEffect, useRef, RefObject, useCallback } from 'react'; import { useEffect, useRef, RefObject, useCallback } from "react";
const debug: boolean = false; const debug = false;
type ResizeCallback = () => void; type ResizeCallback = () => void;
// Define the debounce function with cancel capability // Define the debounce function with cancel capability
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) { function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
let timeout: NodeJS.Timeout | null = null; let timeout: NodeJS.Timeout | null = null;
let lastCall: number = 0; let lastCall = 0;
const debounced = function (...args: Parameters<T>) { const debounced = function (...args: Parameters<T>) {
const now = Date.now(); const now = Date.now();
@ -68,8 +68,12 @@ const useResizeObserverAndMutationObserver = (
requestAnimationFrame(() => callbackRef.current()); requestAnimationFrame(() => callbackRef.current());
}, 500); }, 500);
const resizeObserver = new ResizeObserver((e: any) => { debouncedCallback("resize"); }); const resizeObserver = new ResizeObserver((e: any) => {
const mutationObserver = new MutationObserver((e: any) => { debouncedCallback("mutation"); }); debouncedCallback("resize");
});
const mutationObserver = new MutationObserver((e: any) => {
debouncedCallback("mutation");
});
// Observe container size // Observe container size
resizeObserver.observe(container); resizeObserver.observe(container);
@ -102,8 +106,8 @@ const useResizeObserverAndMutationObserver = (
*/ */
const useAutoScrollToBottom = ( const useAutoScrollToBottom = (
scrollToRef: RefObject<HTMLElement | null>, scrollToRef: RefObject<HTMLElement | null>,
smooth: boolean = true, smooth = true,
fallbackThreshold: number = 0.33, fallbackThreshold = 0.33,
contentUpdateTrigger?: any contentUpdateTrigger?: any
): RefObject<HTMLDivElement | null> => { ): RefObject<HTMLDivElement | null> => {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -111,7 +115,8 @@ const useAutoScrollToBottom = (
const scrollTimeout = useRef<NodeJS.Timeout | null>(null); const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
const isUserScrollingUpRef = useRef(false); const isUserScrollingUpRef = useRef(false);
const checkAndScrollToBottom = useCallback((isPasteEvent: boolean = false) => { const checkAndScrollToBottom = useCallback(
(isPasteEvent = false) => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
@ -131,13 +136,16 @@ const useAutoScrollToBottom = (
// Check if TextField is fully or partially visible (for non-paste events) // Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible = const isTextFieldVisible =
scrollToRect.top < containerBottom && scrollToRect.bottom > containerTop; scrollToRect.top < containerBottom &&
scrollToRect.bottom > containerTop;
// Scroll on paste or if TextField is visible and user isn't scrolling up // Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll = isPasteEvent || (isTextFieldVisible && !isUserScrollingUpRef.current); shouldScroll =
isPasteEvent || (isTextFieldVisible && !isUserScrollingUpRef.current);
if (shouldScroll) { if (shouldScroll) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
debug && console.debug('Scrolling to container bottom:', { debug &&
console.debug("Scrolling to container bottom:", {
scrollHeight: container.scrollHeight, scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height, scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight, containerHeight: container.clientHeight,
@ -147,7 +155,7 @@ const useAutoScrollToBottom = (
}); });
container.scrollTo({ container.scrollTo({
top: container.scrollHeight, top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto', behavior: smooth ? "smooth" : "auto",
}); });
}); });
} }
@ -161,15 +169,20 @@ const useAutoScrollToBottom = (
if (shouldScroll) { if (shouldScroll) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
debug && console.debug('Scrolling to container bottom (fallback):', { scrollHeight }); debug &&
console.debug("Scrolling to container bottom (fallback):", {
scrollHeight,
});
container.scrollTo({ container.scrollTo({
top: container.scrollHeight, top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto', behavior: smooth ? "smooth" : "auto",
}); });
}); });
} }
} }
}, [fallbackThreshold, smooth, scrollToRef]); },
[fallbackThreshold, smooth, scrollToRef]
);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@ -180,26 +193,34 @@ const useAutoScrollToBottom = (
const currentScrollTop = container.scrollTop; const currentScrollTop = container.scrollTop;
/* If the user is scrolling up *or* they used the scroll wheel and didn't scroll, /* If the user is scrolling up *or* they used the scroll wheel and didn't scroll,
* they may be zooming in a region; pause scrolling */ * they may be zooming in a region; pause scrolling */
isUserScrollingUpRef.current = (currentScrollTop <= lastScrollTop.current) || pause ? true : false; isUserScrollingUpRef.current =
debug && console.debug(`Scrolling up or paused: ${isUserScrollingUpRef.current} ${pause}`); currentScrollTop <= lastScrollTop.current || pause ? true : false;
debug &&
console.debug(
`Scrolling up or paused: ${isUserScrollingUpRef.current} ${pause}`
);
lastScrollTop.current = currentScrollTop; lastScrollTop.current = currentScrollTop;
if (scrollTimeout.current) clearTimeout(scrollTimeout.current); if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
scrollTimeout.current = setTimeout(() => { scrollTimeout.current = setTimeout(
() => {
isUserScrollingUpRef.current = false; isUserScrollingUpRef.current = false;
debug && console.debug(`Scrolling up: ${isUserScrollingUpRef.current}`); debug &&
}, pause ? pause : 500); console.debug(`Scrolling up: ${isUserScrollingUpRef.current}`);
},
pause ? pause : 500
);
}; };
const pauseScroll = (ev: Event) => { const pauseScroll = (ev: Event) => {
debug && console.log("Pausing for mouse movement"); debug && console.log("Pausing for mouse movement");
handleScroll(ev, 500); handleScroll(ev, 500);
} };
const pauseClick = (ev: Event) => { const pauseClick = (ev: Event) => {
debug && console.log("Pausing for mouse click"); debug && console.log("Pausing for mouse click");
handleScroll(ev, 1000); handleScroll(ev, 1000);
} };
const handlePaste = () => { const handlePaste = () => {
console.log("handlePaste"); console.log("handlePaste");
@ -210,33 +231,40 @@ const useAutoScrollToBottom = (
}, 100); }, 100);
}; };
window.addEventListener('mousemove', pauseScroll); window.addEventListener("mousemove", pauseScroll);
window.addEventListener('mousedown', pauseClick); window.addEventListener("mousedown", pauseClick);
container.addEventListener('scroll', handleScroll); container.addEventListener("scroll", handleScroll);
if (scrollTo) { if (scrollTo) {
scrollTo.addEventListener('paste', handlePaste); scrollTo.addEventListener("paste", handlePaste);
} }
checkAndScrollToBottom(); checkAndScrollToBottom();
return () => { return () => {
window.removeEventListener('mousedown', pauseClick); window.removeEventListener("mousedown", pauseClick);
window.removeEventListener('mousemove', pauseScroll); window.removeEventListener("mousemove", pauseScroll);
container.removeEventListener('scroll', handleScroll); container.removeEventListener("scroll", handleScroll);
if (scrollTo) { if (scrollTo) {
scrollTo.removeEventListener('paste', handlePaste); scrollTo.removeEventListener("paste", handlePaste);
} }
if (scrollTimeout.current) clearTimeout(scrollTimeout.current); if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
}; };
}, [smooth, scrollToRef, fallbackThreshold, contentUpdateTrigger, checkAndScrollToBottom]); }, [
smooth,
scrollToRef,
fallbackThreshold,
contentUpdateTrigger,
checkAndScrollToBottom,
]);
// Observe container and TextField size, plus DOM changes // Observe container and TextField size, plus DOM changes
useResizeObserverAndMutationObserver(containerRef, scrollToRef, checkAndScrollToBottom); useResizeObserverAndMutationObserver(
containerRef,
scrollToRef,
checkAndScrollToBottom
);
return containerRef; return containerRef;
}; };
export { export { useResizeObserverAndMutationObserver, useAutoScrollToBottom };
useResizeObserverAndMutationObserver,
useAutoScrollToBottom
}

View File

@ -1,15 +1,15 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom/client'; import ReactDOM from "react-dom/client";
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from "@mui/material/styles";
import { backstoryTheme } from './BackstoryTheme'; import { backstoryTheme } from "./BackstoryTheme";
import { BrowserRouter as Router } from "react-router-dom"; import { BrowserRouter as Router } from "react-router-dom";
import { BackstoryApp } from './BackstoryApp'; import { BackstoryApp } from "./BackstoryApp";
// import { BackstoryTestApp } from 'TestApp'; // import { BackstoryTestApp } from 'TestApp';
import './index.css'; import "./index.css";
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById("root") as HTMLElement
); );
root.render( root.render(

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from "react-router-dom";
import { import {
Box, Box,
Container, Container,
@ -8,12 +8,12 @@ import {
Grid, Grid,
Button, Button,
alpha, alpha,
GlobalStyles GlobalStyles,
} from '@mui/material'; } from "@mui/material";
import { useTheme } from '@mui/material/styles'; import { useTheme } from "@mui/material/styles";
import ConstructionIcon from '@mui/icons-material/Construction'; import ConstructionIcon from "@mui/icons-material/Construction";
import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; import RocketLaunchIcon from "@mui/icons-material/RocketLaunch";
import { Beta } from '../components/ui/Beta'; import { Beta } from "../components/ui/Beta";
interface BetaPageProps { interface BetaPageProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -38,11 +38,19 @@ const BetaPage: React.FC<BetaPageProps> = ({
const location = useLocation(); const location = useLocation();
if (!children) { if (!children) {
children = (<Box sx={{ width: "100%", display: "flex", justifyContent: "center" }}><Typography>The page you requested (<b>{location.pathname.replace(/^\//, '')}</b>) is not yet ready.</Typography></Box>); children = (
<Box sx={{ width: "100%", display: "flex", justifyContent: "center" }}>
<Typography>
The page you requested (<b>{location.pathname.replace(/^\//, "")}</b>)
is not yet ready.
</Typography>
</Box>
);
} }
// Enhanced sparkle effect for background elements // Enhanced sparkle effect for background elements
const [sparkles, setSparkles] = useState<Array<{ const [sparkles, setSparkles] = useState<
Array<{
id: number; id: number;
x: number; x: number;
y: number; y: number;
@ -50,7 +58,8 @@ const BetaPage: React.FC<BetaPageProps> = ({
opacity: number; opacity: number;
duration: number; duration: number;
delay: number; delay: number;
}>>([]); }>
>([]);
useEffect(() => { useEffect(() => {
// Generate sparkle elements with random properties // Generate sparkle elements with random properties
@ -85,47 +94,65 @@ const BetaPage: React.FC<BetaPageProps> = ({
return ( return (
<Box <Box
sx={{ sx={{
minHeight: '100%', minHeight: "100%",
width: "100%", width: "100%",
position: 'relative', position: "relative",
overflow: 'hidden', overflow: "hidden",
bgcolor: theme.palette.background.default, bgcolor: theme.palette.background.default,
pt: 8, pt: 8,
pb: 6, pb: 6,
}} }}
> >
{/* Animated background elements */} {/* Animated background elements */}
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 0, overflow: 'hidden' }}> <Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 0,
overflow: "hidden",
}}
>
{sparkles.map((sparkle) => ( {sparkles.map((sparkle) => (
<Box <Box
key={sparkle.id} key={sparkle.id}
sx={{ sx={{
position: 'absolute', position: "absolute",
left: `${sparkle.x}%`, left: `${sparkle.x}%`,
top: `${sparkle.y}%`, top: `${sparkle.y}%`,
width: sparkle.size, width: sparkle.size,
height: sparkle.size, height: sparkle.size,
borderRadius: '50%', borderRadius: "50%",
bgcolor: alpha(theme.palette.primary.main, sparkle.opacity), bgcolor: alpha(theme.palette.primary.main, sparkle.opacity),
boxShadow: `0 0 ${sparkle.size * 2}px ${alpha(theme.palette.primary.main, sparkle.opacity)}`, boxShadow: `0 0 ${sparkle.size * 2}px ${alpha(
theme.palette.primary.main,
sparkle.opacity
)}`,
animation: `float ${sparkle.duration}s ease-in-out ${sparkle.delay}s infinite alternate`, animation: `float ${sparkle.duration}s ease-in-out ${sparkle.delay}s infinite alternate`,
}} }}
/> />
))} ))}
</Box> </Box>
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 2 }}> <Container maxWidth="lg" sx={{ position: "relative", zIndex: 2 }}>
<Grid container spacing={4} direction="column" alignItems="center"> <Grid container spacing={4} direction="column" alignItems="center">
<Grid size={{xs: 12}} sx={{ textAlign: 'center', mb: 2 }}> <Grid size={{ xs: 12 }} sx={{ textAlign: "center", mb: 2 }}>
<Typography <Typography
variant="h2" variant="h2"
component="h1" component="h1"
gutterBottom gutterBottom
sx={{ sx={{
fontWeight: 'bold', fontWeight: "bold",
color: theme.palette.primary.main, color: theme.palette.primary.main,
textShadow: `0 0 10px ${alpha(theme.palette.primary.main, 0.3)}`, textShadow: `0 0 10px ${alpha(
animation: showSparkle ? 'titleGlow 3s ease-in-out infinite alternate' : 'none', theme.palette.primary.main,
0.3
)}`,
animation: showSparkle
? "titleGlow 3s ease-in-out infinite alternate"
: "none",
}} }}
> >
{title} {title}
@ -148,25 +175,28 @@ const BetaPage: React.FC<BetaPageProps> = ({
p: { xs: 3, md: 5 }, p: { xs: 3, md: 5 },
borderRadius: 2, borderRadius: 2,
bgcolor: alpha(theme.palette.background.paper, 0.8), bgcolor: alpha(theme.palette.background.paper, 0.8),
backdropFilter: 'blur(8px)', backdropFilter: "blur(8px)",
boxShadow: `0 8px 32px ${alpha(theme.palette.primary.main, 0.15)}`, boxShadow: `0 8px 32px ${alpha(
theme.palette.primary.main,
0.15
)}`,
border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`, border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`,
position: 'relative', position: "relative",
overflow: 'hidden', overflow: "hidden",
}} }}
> >
{/* Construction icon */} {/* Construction icon */}
<Box <Box
sx={{ sx={{
position: 'absolute', position: "absolute",
top: -15, top: -15,
right: -15, right: -15,
bgcolor: theme.palette.warning.main, bgcolor: theme.palette.warning.main,
color: theme.palette.warning.contrastText, color: theme.palette.warning.contrastText,
borderRadius: '50%', borderRadius: "50%",
p: 2, p: 2,
boxShadow: 3, boxShadow: 3,
transform: 'rotate(15deg)', transform: "rotate(15deg)",
}} }}
> >
<ConstructionIcon fontSize="large" /> <ConstructionIcon fontSize="large" />
@ -175,14 +205,14 @@ const BetaPage: React.FC<BetaPageProps> = ({
{/* Content */} {/* Content */}
<Box sx={{ mt: 3, mb: 3 }}> <Box sx={{ mt: 3, mb: 3 }}>
{children || ( {children || (
<Box sx={{ textAlign: 'center', py: 4 }}> <Box sx={{ textAlign: "center", py: 4 }}>
<RocketLaunchIcon <RocketLaunchIcon
fontSize="large" fontSize="large"
color="primary" color="primary"
sx={{ sx={{
fontSize: 80, fontSize: 80,
mb: 2, mb: 2,
animation: 'rocketWobble 3s ease-in-out infinite' animation: "rocketWobble 3s ease-in-out infinite",
}} }}
/> />
<Typography> <Typography>
@ -193,11 +223,25 @@ const BetaPage: React.FC<BetaPageProps> = ({
</Typography> </Typography>
</Box> </Box>
)} )}
<Beta adaptive={false} sx={{ opacity: 0.5, left: "-72px", "& > div": { paddingRight: "30px", background: "gold", color: "#808080" } }} onClick={() => { navigate('/docs/beta'); }} /> <Beta
adaptive={false}
sx={{
opacity: 0.5,
left: "-72px",
"& > div": {
paddingRight: "30px",
background: "gold",
color: "#808080",
},
}}
onClick={() => {
navigate("/docs/beta");
}}
/>
</Box> </Box>
{/* Return button */} {/* Return button */}
<Box sx={{ mt: 4, textAlign: 'center' }}> <Box sx={{ mt: 4, textAlign: "center" }}>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@ -207,10 +251,16 @@ const BetaPage: React.FC<BetaPageProps> = ({
px: 4, px: 4,
py: 1, py: 1,
borderRadius: 4, borderRadius: 4,
boxShadow: `0 4px 14px ${alpha(theme.palette.primary.main, 0.4)}`, boxShadow: `0 4px 14px ${alpha(
'&:hover': { theme.palette.primary.main,
boxShadow: `0 6px 20px ${alpha(theme.palette.primary.main, 0.6)}`, 0.4
} )}`,
"&:hover": {
boxShadow: `0 6px 20px ${alpha(
theme.palette.primary.main,
0.6
)}`,
},
}} }}
> >
{returnLabel} {returnLabel}
@ -224,44 +274,47 @@ const BetaPage: React.FC<BetaPageProps> = ({
{/* Global styles added with MUI's GlobalStyles component */} {/* Global styles added with MUI's GlobalStyles component */}
<GlobalStyles <GlobalStyles
styles={{ styles={{
'@keyframes float': { "@keyframes float": {
'0%': { "0%": {
transform: 'translateY(0) scale(1)', transform: "translateY(0) scale(1)",
}, },
'100%': { "100%": {
transform: 'translateY(-20px) scale(1.1)', transform: "translateY(-20px) scale(1.1)",
}, },
}, },
'@keyframes sparkleFloat': { "@keyframes sparkleFloat": {
'0%': { "0%": {
transform: 'translateY(0) scale(1)', transform: "translateY(0) scale(1)",
opacity: 0.7, opacity: 0.7,
}, },
'50%': { "50%": {
opacity: 1, opacity: 1,
}, },
'100%': { "100%": {
transform: 'translateY(-15px) scale(1.2)', transform: "translateY(-15px) scale(1.2)",
opacity: 0.7, opacity: 0.7,
}, },
}, },
'@keyframes titleGlow': { "@keyframes titleGlow": {
'0%': { "0%": {
textShadow: `0 0 10px ${alpha(theme.palette.primary.main, 0.3)}`, textShadow: `0 0 10px ${alpha(theme.palette.primary.main, 0.3)}`,
}, },
'100%': { "100%": {
textShadow: `0 0 25px ${alpha(theme.palette.primary.main, 0.7)}, 0 0 40px ${alpha(theme.palette.primary.main, 0.4)}`, textShadow: `0 0 25px ${alpha(
theme.palette.primary.main,
0.7
)}, 0 0 40px ${alpha(theme.palette.primary.main, 0.4)}`,
}, },
}, },
'@keyframes rocketWobble': { "@keyframes rocketWobble": {
'0%': { "0%": {
transform: 'translateY(0) rotate(0deg)', transform: "translateY(0) rotate(0deg)",
}, },
'50%': { "50%": {
transform: 'translateY(-10px) rotate(3deg)', transform: "translateY(-10px) rotate(3deg)",
}, },
'100%': { "100%": {
transform: 'translateY(0) rotate(-2deg)', transform: "translateY(0) rotate(-2deg)",
}, },
}, },
}} }}
@ -270,6 +323,4 @@ const BetaPage: React.FC<BetaPageProps> = ({
); );
}; };
export { export { BetaPage };
BetaPage
}

View File

@ -1,4 +1,4 @@
import React, { forwardRef, useState, useEffect, useRef } from 'react'; import React, { forwardRef, useState, useEffect, useRef } from "react";
import { import {
Box, Box,
Paper, Paper,
@ -7,36 +7,54 @@ import {
useTheme, useTheme,
useMediaQuery, useMediaQuery,
Tooltip, Tooltip,
} from '@mui/material'; } from "@mui/material";
import { Send as SendIcon } from "@mui/icons-material";
import { useAuth } from "hooks/AuthContext";
import { import {
Send as SendIcon ChatMessage,
} from '@mui/icons-material'; ChatSession,
import { useAuth } from 'hooks/AuthContext'; ChatMessageUser,
import { ChatMessage, ChatSession, ChatMessageUser, ChatMessageError, ChatMessageStreaming, ChatMessageStatus } from 'types/types'; ChatMessageError,
import { ConversationHandle } from 'components/Conversation'; ChatMessageStreaming,
import { BackstoryPageProps } from 'components/BackstoryTab'; ChatMessageStatus,
import { Message } from 'components/Message'; } from "types/types";
import { DeleteConfirmation } from 'components/DeleteConfirmation'; import { ConversationHandle } from "components/Conversation";
import { CandidateInfo } from 'components/ui/CandidateInfo'; import { BackstoryPageProps } from "components/BackstoryTab";
import { useNavigate } from 'react-router-dom'; import { Message } from "components/Message";
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext'; import { DeleteConfirmation } from "components/DeleteConfirmation";
import PropagateLoader from 'react-spinners/PropagateLoader'; import { CandidateInfo } from "components/ui/CandidateInfo";
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import { useNavigate } from "react-router-dom";
import { BackstoryQuery } from 'components/BackstoryQuery'; import { useAppState, useSelectedCandidate } from "hooks/GlobalContext";
import { CandidatePicker } from 'components/ui/CandidatePicker'; import PropagateLoader from "react-spinners/PropagateLoader";
import { Scrollable } from 'components/Scrollable'; import {
BackstoryTextField,
BackstoryTextFieldRef,
} from "components/BackstoryTextField";
import { BackstoryQuery } from "components/BackstoryQuery";
import { CandidatePicker } from "components/ui/CandidatePicker";
import { Scrollable } from "components/Scrollable";
const defaultMessage: ChatMessage = { const defaultMessage: ChatMessage = {
status: "done", type: "text", sessionId: "", timestamp: new Date(), content: "", role: "user", metadata: null as any status: "done",
type: "text",
sessionId: "",
timestamp: new Date(),
content: "",
role: "user",
metadata: null as any,
}; };
const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => { const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
(props: BackstoryPageProps, ref) => {
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate() const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const theme = useTheme(); const theme = useTheme();
const [processingMessage, setProcessingMessage] = useState<ChatMessageStatus | ChatMessageError | null>(null); const [processingMessage, setProcessingMessage] = useState<
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null); ChatMessageStatus | ChatMessageError | null
>(null);
const [streamingMessage, setStreamingMessage] =
useState<ChatMessage | null>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null); const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const { setSnack } = useAppState(); const { setSnack } = useAppState();
@ -57,9 +75,12 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
setMessages(chatMessages); setMessages(chatMessages);
setProcessingMessage(null); setProcessingMessage(null);
setStreamingMessage(null); setStreamingMessage(null);
console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages); console.log(
`getChatMessages returned ${chatMessages.length} messages.`,
chatMessages
);
} catch (error) { } catch (error) {
console.error('Failed to load messages:', error); console.error("Failed to load messages:", error);
} }
}; };
@ -71,16 +92,22 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
await apiClient.resetChatSession(session.id); await apiClient.resetChatSession(session.id);
// If we're deleting the currently selected session, clear it // If we're deleting the currently selected session, clear it
setMessages([]); setMessages([]);
setSnack('Session reset succeeded', 'success'); setSnack("Session reset succeeded", "success");
} catch (error) { } catch (error) {
console.error('Failed to delete session:', error); console.error("Failed to delete session:", error);
setSnack('Failed to delete session', 'error'); setSnack("Failed to delete session", "error");
} }
}; };
// Send message // Send message
const sendMessage = async (message: string) => { const sendMessage = async (message: string) => {
if (!message.trim() || !chatSession?.id || streaming || !selectedCandidate) return; if (
!message.trim() ||
!chatSession?.id ||
streaming ||
!selectedCandidate
)
return;
const messageContent = message; const messageContent = message;
setStreaming(true); setStreaming(true);
@ -91,12 +118,17 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
content: messageContent, content: messageContent,
status: "done", status: "done",
type: "text", type: "text",
timestamp: new Date() timestamp: new Date(),
}; };
setProcessingMessage({ ...defaultMessage, status: 'status', activity: "info", content: `Establishing connection with ${selectedCandidate.firstName}'s chat session.` }); setProcessingMessage({
...defaultMessage,
status: "status",
activity: "info",
content: `Establishing connection with ${selectedCandidate.firstName}'s chat session.`,
});
setMessages(prev => { setMessages((prev) => {
const filtered = prev.filter((m: any) => m.id !== chatMessage.id); const filtered = prev.filter((m: any) => m.id !== chatMessage.id);
return [...filtered, chatMessage] as any; return [...filtered, chatMessage] as any;
}); });
@ -104,7 +136,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
try { try {
apiClient.sendMessageStream(chatMessage, { apiClient.sendMessageStream(chatMessage, {
onMessage: (msg: ChatMessage) => { onMessage: (msg: ChatMessage) => {
setMessages(prev => { setMessages((prev) => {
const filtered = prev.filter((m: any) => m.id !== msg.id); const filtered = prev.filter((m: any) => m.id !== msg.id);
return [...filtered, msg] as any; return [...filtered, msg] as any;
}); });
@ -115,17 +147,29 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
console.log("onError:", error); console.log("onError:", error);
let message: string; let message: string;
// Type-guard to determine if this is a ChatMessageBase or a string // Type-guard to determine if this is a ChatMessageBase or a string
if (typeof error === "object" && error !== null && "content" in error) { if (
typeof error === "object" &&
error !== null &&
"content" in error
) {
setProcessingMessage(error); setProcessingMessage(error);
message = error.content as string; message = error.content as string;
} else { } else {
setProcessingMessage({ ...defaultMessage, status: "error", content: error }) setProcessingMessage({
...defaultMessage,
status: "error",
content: error,
});
} }
setStreaming(false); setStreaming(false);
}, },
onStreaming: (chunk: ChatMessageStreaming) => { onStreaming: (chunk: ChatMessageStreaming) => {
// console.log("onStreaming:", chunk); // console.log("onStreaming:", chunk);
setStreamingMessage({ ...chunk, role: 'assistant', metadata: null as any }); setStreamingMessage({
...chunk,
role: "assistant",
metadata: null as any,
});
}, },
onStatus: (status: ChatMessageStatus) => { onStatus: (status: ChatMessageStatus) => {
setProcessingMessage(status); setProcessingMessage(status);
@ -135,17 +179,17 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
setStreamingMessage(null); setStreamingMessage(null);
setProcessingMessage(null); setProcessingMessage(null);
setStreaming(false); setStreaming(false);
} },
}); });
} catch (error) { } catch (error) {
console.error('Failed to send message:', error); console.error("Failed to send message:", error);
setStreaming(false); setStreaming(false);
} }
}; };
// Auto-scroll to bottom when new messages arrive // Auto-scroll to bottom when new messages arrive
useEffect(() => { useEffect(() => {
(messagesEndRef.current as any)?.scrollIntoView({ behavior: 'smooth' }); (messagesEndRef.current as any)?.scrollIntoView({ behavior: "smooth" });
}, [messages]); }, [messages]);
// Load sessions when username changes // Load sessions when username changes
@ -153,13 +197,18 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
if (!selectedCandidate) return; if (!selectedCandidate) return;
try { try {
setLoading(true); setLoading(true);
apiClient.getOrCreateChatSession(selectedCandidate, `Backstory chat with ${selectedCandidate.fullName}`, 'candidate_chat') apiClient
.then(session => { .getOrCreateChatSession(
selectedCandidate,
`Backstory chat with ${selectedCandidate.fullName}`,
"candidate_chat"
)
.then((session) => {
setChatSession(session); setChatSession(session);
setLoading(false); setLoading(false);
}); });
} catch (error) { } catch (error) {
setSnack('Unable to load chat session', 'error'); setSnack("Unable to load chat session", "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -177,28 +226,31 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
} }
const welcomeMessage: ChatMessage = { const welcomeMessage: ChatMessage = {
sessionId: chatSession?.id || '', sessionId: chatSession?.id || "",
role: "information", role: "information",
type: "text", type: "text",
status: "done", status: "done",
timestamp: new Date(), timestamp: new Date(),
content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}. Ask any questions you have about ${selectedCandidate.firstName}.`, content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}. Ask any questions you have about ${selectedCandidate.firstName}.`,
metadata: null as any metadata: null as any,
}; };
return ( return (
<Box ref={ref} <Box
ref={ref}
sx={{ sx={{
display: "flex", flexDirection: "column", display: "flex",
height: "100%", /* Restrict to main-container's height */ flexDirection: "column",
height: "100%" /* Restrict to main-container's height */,
width: "100%", width: "100%",
minHeight: 0,/* Prevent flex overflow */ minHeight: 0 /* Prevent flex overflow */,
maxHeight: "min-content", maxHeight: "min-content",
"& > *:not(.Scrollable)": { "& > *:not(.Scrollable)": {
flexShrink: 0, /* Prevent shrinking */ flexShrink: 0 /* Prevent shrinking */,
}, },
position: "relative", position: "relative",
}}> }}
>
<Paper elevation={2} sx={{ m: 1, p: 1 }}> <Paper elevation={2} sx={{ m: 1, p: 1 }}>
<CandidateInfo <CandidateInfo
key={selectedCandidate.username} key={selectedCandidate.username}
@ -206,27 +258,44 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
elevation={4} elevation={4}
candidate={selectedCandidate} candidate={selectedCandidate}
variant="small" variant="small"
sx={{ flexShrink: 1, width: "100%", maxHeight: 0, minHeight: "min-content" }} // Prevent header from shrinking sx={{
flexShrink: 1,
width: "100%",
maxHeight: 0,
minHeight: "min-content",
}} // Prevent header from shrinking
/> />
<Button sx={{ maxWidth: "max-content" }} onClick={() => { setSelectedCandidate(null); }} variant="contained">Change Candidates</Button> <Button
sx={{ maxWidth: "max-content" }}
onClick={() => {
setSelectedCandidate(null);
}}
variant="contained"
>
Change Candidates
</Button>
</Paper> </Paper>
{/* Chat Interface */} {/* Chat Interface */}
{/* Scrollable Messages Area */} {/* Scrollable Messages Area */}
{chatSession && {chatSession && (
<Scrollable <Scrollable
sx={{ sx={{
position: "relative", position: "relative",
maxHeight: "100%", maxHeight: "100%",
width: "100%", width: "100%",
display: "flex", flexGrow: 1, display: "flex",
flex: 1, /* Take remaining space in some-container */ flexGrow: 1,
overflowY: "auto", /* Scroll if content overflows */ flex: 1 /* Take remaining space in some-container */,
overflowY: "auto" /* Scroll if content overflows */,
pt: 2, pt: 2,
pl: 1, pl: 1,
pr: 1, pr: 1,
pb: 2, pb: 2,
}}> }}
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage, }} />} >
{messages.length === 0 && (
<Message {...{ chatSession, message: welcomeMessage }} />
)}
{messages.map((message: ChatMessage) => ( {messages.map((message: ChatMessage) => (
<Message key={message.id} {...{ chatSession, message }} /> <Message key={message.id} {...{ chatSession, message }} />
))} ))}
@ -237,13 +306,15 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
<Message {...{ chatSession, message: streamingMessage }} /> <Message {...{ chatSession, message: streamingMessage }} />
)} )}
{streaming && ( {streaming && (
<Box sx={{ <Box
sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
m: 1, m: 1,
}}> }}
>
<PropagateLoader <PropagateLoader
size="10px" size="10px"
loading={streaming} loading={streaming}
@ -254,14 +325,19 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</Scrollable> </Scrollable>
} )}
{selectedCandidate.questions?.length !== 0 && selectedCandidate.questions?.map(q => <BackstoryQuery question={q} />)} {selectedCandidate.questions?.length !== 0 &&
selectedCandidate.questions?.map((q) => (
<BackstoryQuery question={q} />
))}
{/* Fixed Message Input */} {/* Fixed Message Input */}
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}> <Box sx={{ display: "flex", flexShrink: 1, gap: 1 }}>
<DeleteConfirmation <DeleteConfirmation
onDelete={() => { chatSession && onDelete(chatSession); }} onDelete={() => {
chatSession && onDelete(chatSession);
}}
disabled={!chatSession} disabled={!chatSession}
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content" }} sx={{ minWidth: "auto", px: 2, maxHeight: "min-content" }}
action="reset" action="reset"
label="chat session" label="chat session"
title="Reset Chat Session" title="Reset Chat Session"
@ -274,21 +350,32 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
disabled={streaming || loading} disabled={streaming || loading}
/> />
<Tooltip title="Send"> <Tooltip title="Send">
<span style={{ minWidth: 'auto', maxHeight: "min-content", alignSelf: "center" }} <span
style={{
minWidth: "auto",
maxHeight: "min-content",
alignSelf: "center",
}}
> >
<Button <Button
variant="contained" variant="contained"
onClick={() => { sendMessage((backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ""); }} onClick={() => {
sendMessage(
(backstoryTextRef.current &&
backstoryTextRef.current.getAndResetValue()) ||
""
);
}}
disabled={streaming || loading} disabled={streaming || loading}
> >
<SendIcon /> <SendIcon />
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
</Box> </Box>
</Box> </Box>
); );
}); }
);
export { CandidateChatPage }; export { CandidateChatPage };

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { useNavigate, useLocation, useParams } from 'react-router-dom'; import { useNavigate, useLocation, useParams } from "react-router-dom";
import { import {
Box, Box,
Drawer, Drawer,
@ -18,28 +18,28 @@ import {
CardContent, CardContent,
CardActionArea, CardActionArea,
useTheme, useTheme,
useMediaQuery useMediaQuery,
} from '@mui/material'; } from "@mui/material";
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from "@mui/icons-material/Menu";
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from "@mui/icons-material/Person";
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from "@mui/icons-material/Close";
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DescriptionIcon from '@mui/icons-material/Description'; import DescriptionIcon from "@mui/icons-material/Description";
import CodeIcon from '@mui/icons-material/Code'; import CodeIcon from "@mui/icons-material/Code";
import LayersIcon from '@mui/icons-material/Layers'; import LayersIcon from "@mui/icons-material/Layers";
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from "@mui/icons-material/Dashboard";
import PaletteIcon from '@mui/icons-material/Palette'; import PaletteIcon from "@mui/icons-material/Palette";
import AnalyticsIcon from '@mui/icons-material/Analytics'; import AnalyticsIcon from "@mui/icons-material/Analytics";
import ViewQuiltIcon from '@mui/icons-material/ViewQuilt'; import ViewQuiltIcon from "@mui/icons-material/ViewQuilt";
import { Document } from '../components/Document'; import { Document } from "../components/Document";
import { BackstoryPageProps } from '../components/BackstoryTab'; import { BackstoryPageProps } from "../components/BackstoryTab";
import { BackstoryUIOverviewPage } from 'documents/BackstoryUIOverviewPage'; import { BackstoryUIOverviewPage } from "documents/BackstoryUIOverviewPage";
import { BackstoryAppAnalysisPage } from 'documents/BackstoryAppAnalysisPage'; import { BackstoryAppAnalysisPage } from "documents/BackstoryAppAnalysisPage";
import { BackstoryThemeVisualizerPage } from 'documents/BackstoryThemeVisualizerPage'; import { BackstoryThemeVisualizerPage } from "documents/BackstoryThemeVisualizerPage";
import { UserManagement } from 'documents/UserManagement'; import { UserManagement } from "documents/UserManagement";
import { MockupPage } from 'documents/MockupPage'; import { MockupPage } from "documents/MockupPage";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
// Sidebar navigation component using MUI components // Sidebar navigation component using MUI components
const Sidebar: React.FC<{ const Sidebar: React.FC<{
@ -60,15 +60,17 @@ const Sidebar: React.FC<{
}; };
return ( return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}> <Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box sx={{ <Box
sx={{
p: 2, p: 2,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'space-between', justifyContent: "space-between",
borderBottom: 1, borderBottom: 1,
borderColor: 'divider', borderColor: "divider",
}}> }}
>
<Typography variant="h6" component="h2" fontWeight="bold"> <Typography variant="h6" component="h2" fontWeight="bold">
Documentation Documentation
</Typography> </Typography>
@ -83,34 +85,44 @@ const Sidebar: React.FC<{
)} )}
</Box> </Box>
<Box sx={{ <Box
sx={{
flexGrow: 1, flexGrow: 1,
overflow: 'auto', overflow: "auto",
p: 1 p: 1,
}}> }}
>
<List> <List>
{documents.map((doc, index) => ( {documents.map((doc, index) => (
<ListItem key={index} disablePadding> <ListItem key={index} disablePadding>
<ListItemButton <ListItemButton
onClick={() => doc.route ? handleItemClick(doc.route) : navigate('/')} onClick={() =>
doc.route ? handleItemClick(doc.route) : navigate("/")
}
selected={currentPage === doc.route} selected={currentPage === doc.route}
sx={{ sx={{
borderRadius: 1, borderRadius: 1,
mb: 0.5 mb: 0.5,
}}
>
<ListItemIcon
sx={{
color:
currentPage === doc.route
? "primary.main"
: "text.secondary",
minWidth: 40,
}} }}
> >
<ListItemIcon sx={{
color: currentPage === doc.route ? 'primary.main' : 'text.secondary',
minWidth: 40
}}>
{getDocumentIcon(doc.title)} {getDocumentIcon(doc.title)}
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={doc.title} primary={doc.title}
slotProps={{ slotProps={{
primary: { primary: {
fontWeight: currentPage === doc.route ? 'medium' : 'regular', fontWeight:
} currentPage === doc.route ? "medium" : "regular",
},
}} }}
/> />
</ListItemButton> </ListItemButton>
@ -123,12 +135,14 @@ const Sidebar: React.FC<{
}; };
const getDocumentIcon = (title: string): React.ReactNode => { const getDocumentIcon = (title: string): React.ReactNode => {
const item = documents.find(d => d.title.toLocaleLowerCase() === title.toLocaleLowerCase()); const item = documents.find(
(d) => d.title.toLocaleLowerCase() === title.toLocaleLowerCase()
);
if (!item) { if (!item) {
throw Error(`${title} does not exist in documents`); throw Error(`${title} does not exist in documents`);
} }
return item.icon || <ViewQuiltIcon />; return item.icon || <ViewQuiltIcon />;
} };
type DocType = { type DocType = {
title: string; title: string;
@ -138,25 +152,90 @@ type DocType = {
}; };
const documents: DocType[] = [ const documents: DocType[] = [
{ title: "Backstory", route: null, description: "Backstory", icon: <ArrowBackIcon /> }, {
{ title: "About", route: "about", description: "General information about the application and its purpose", icon: <DescriptionIcon /> }, title: "Backstory",
{ title: "BETA", route: "beta", description: "Details about the current beta version and upcoming features", icon: <CodeIcon /> }, route: null,
{ title: "Resume Generation Architecture", route: "resume-generation", description: "Technical overview of how resumes are processed and generated", icon: <LayersIcon /> }, description: "Backstory",
{ title: "Application Architecture", route: "about-app", description: "System design and technical stack information", icon: <LayersIcon /> }, icon: <ArrowBackIcon />,
{ title: "Authentication Architecture", route: "authentication.md", description: "Complete authentication architecture", icon: <LayersIcon /> }, },
{ title: "UI Overview", route: "ui-overview", description: "Guide to the user interface components and interactions", icon: <DashboardIcon /> }, {
{ title: "UI Mockup", route: "ui-mockup", description: "Visual previews of interfaces and layout concepts", icon: <DashboardIcon /> }, title: "About",
{ title: "Theme Visualizer", route: "theme-visualizer", description: "Explore and customize application themes and visual styles", icon: <PaletteIcon /> }, route: "about",
{ title: "App Analysis", route: "app-analysis", description: "Statistics and performance metrics of the application", icon: <AnalyticsIcon /> }, description: "General information about the application and its purpose",
{ title: 'Text Mockups', route: "backstory-ui-mockups", description: "Early text mockups of many of the interaction points." }, icon: <DescriptionIcon />,
{ title: 'User Management', route: "user-management", description: "User management.", icon: <PersonIcon /> }, },
{ title: 'Type Safety', route: "type-safety", description: "Overview of front/back-end type synchronization.", icon: <CodeIcon /> }, {
title: "BETA",
route: "beta",
description: "Details about the current beta version and upcoming features",
icon: <CodeIcon />,
},
{
title: "Resume Generation Architecture",
route: "resume-generation",
description:
"Technical overview of how resumes are processed and generated",
icon: <LayersIcon />,
},
{
title: "Application Architecture",
route: "about-app",
description: "System design and technical stack information",
icon: <LayersIcon />,
},
{
title: "Authentication Architecture",
route: "authentication.md",
description: "Complete authentication architecture",
icon: <LayersIcon />,
},
{
title: "UI Overview",
route: "ui-overview",
description: "Guide to the user interface components and interactions",
icon: <DashboardIcon />,
},
{
title: "UI Mockup",
route: "ui-mockup",
description: "Visual previews of interfaces and layout concepts",
icon: <DashboardIcon />,
},
{
title: "Theme Visualizer",
route: "theme-visualizer",
description: "Explore and customize application themes and visual styles",
icon: <PaletteIcon />,
},
{
title: "App Analysis",
route: "app-analysis",
description: "Statistics and performance metrics of the application",
icon: <AnalyticsIcon />,
},
{
title: "Text Mockups",
route: "backstory-ui-mockups",
description: "Early text mockups of many of the interaction points.",
},
{
title: "User Management",
route: "user-management",
description: "User management.",
icon: <PersonIcon />,
},
{
title: "Type Safety",
route: "type-safety",
description: "Overview of front/back-end type synchronization.",
icon: <CodeIcon />,
},
]; ];
const documentFromRoute = (route: string): DocType | null => { const documentFromRoute = (route: string): DocType | null => {
const index = documents.findIndex(v => v.route === route); const index = documents.findIndex((v) => v.route === route);
if (index === -1) { if (index === -1) {
return null return null;
} }
return documents[index]; return documents[index];
}; };
@ -165,29 +244,29 @@ const documentFromRoute = (route: string) : DocType | null => {
const documentTitleFromRoute = (route: string): string => { const documentTitleFromRoute = (route: string): string => {
const doc = documentFromRoute(route); const doc = documentFromRoute(route);
if (doc === null) { if (doc === null) {
return 'Documentation' return "Documentation";
} }
return doc.title; return doc.title;
} };
const DocsPage = (props: BackstoryPageProps) => { const DocsPage = (props: BackstoryPageProps) => {
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { paramPage = '' } = useParams(); const { paramPage = "" } = useParams();
const [page, setPage] = useState<string>(paramPage); const [page, setPage] = useState<string>(paramPage);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
// Track location changes // Track location changes
useEffect(() => { useEffect(() => {
const parts = location.pathname.split('/'); const parts = location.pathname.split("/");
if (parts.length > 2) { if (parts.length > 2) {
setPage(parts[2]); setPage(parts[2]);
} else { } else {
setPage(''); setPage("");
} }
}, [location]); }, [location]);
@ -202,19 +281,19 @@ const DocsPage = (props: BackstoryPageProps) => {
const onDocumentExpand = (docName: string, open: boolean) => { const onDocumentExpand = (docName: string, open: boolean) => {
console.log("Document expanded:", { docName, open, location }); console.log("Document expanded:", { docName, open, location });
if (open) { if (open) {
const parts = location.pathname.split('/'); const parts = location.pathname.split("/");
if (docName === "backstory") { if (docName === "backstory") {
navigate('/'); navigate("/");
return; return;
} }
if (parts.length > 2) { if (parts.length > 2) {
const basePath = parts.slice(0, -1).join('/'); const basePath = parts.slice(0, -1).join("/");
navigate(`${basePath}/${docName}`); navigate(`${basePath}/${docName}`);
} else { } else {
navigate(docName); navigate(docName);
} }
} else { } else {
const basePath = location.pathname.split('/').slice(0, -1).join('/'); const basePath = location.pathname.split("/").slice(0, -1).join("/");
navigate(`${basePath}`); navigate(`${basePath}`);
} }
}; };
@ -230,23 +309,32 @@ const DocsPage = (props: BackstoryPageProps) => {
}; };
interface DocViewProps { interface DocViewProps {
page: string page: string;
}; }
const DocView = (props: DocViewProps) => { const DocView = (props: DocViewProps) => {
const { page = 'about' } = props; const { page = "about" } = props;
const title = documentTitleFromRoute(page); const title = documentTitleFromRoute(page);
const icon = getDocumentIcon(title); const icon = getDocumentIcon(title);
return ( return (
<Card> <Card>
<CardContent> <CardContent>
<Box sx={{ color: 'inherit', fontSize: "1.75rem", fontWeight: "bold", display: "flex", flexDirection: "row", gap: 1, alignItems: "center", mr: 1.5 }}> <Box
sx={{
color: "inherit",
fontSize: "1.75rem",
fontWeight: "bold",
display: "flex",
flexDirection: "row",
gap: 1,
alignItems: "center",
mr: 1.5,
}}
>
{icon} {icon}
{title} {title}
</Box> </Box>
{page && <Document {page && <Document filepath={`/docs/${page}.md`} />}
filepath={`/docs/${page}.md`}
/>}
</CardContent> </CardContent>
</Card> </Card>
); );
@ -255,19 +343,23 @@ const DocsPage = (props: BackstoryPageProps) => {
// Render the appropriate content based on current page // Render the appropriate content based on current page
function renderContent() { function renderContent() {
switch (page) { switch (page) {
case 'ui-overview': case "ui-overview":
return (<BackstoryUIOverviewPage />); return <BackstoryUIOverviewPage />;
case 'theme-visualizer': case "theme-visualizer":
return (<Paper sx={{ m: 0, p: 1 }}><BackstoryThemeVisualizerPage /></Paper>); return (
case 'app-analysis': <Paper sx={{ m: 0, p: 1 }}>
return (<BackstoryAppAnalysisPage />); <BackstoryThemeVisualizerPage />
case 'ui-mockup': </Paper>
return (<MockupPage />); );
case 'user-management': case "app-analysis":
return (<UserManagement />); return <BackstoryAppAnalysisPage />;
case "ui-mockup":
return <MockupPage />;
case "user-management":
return <UserManagement />;
default: default:
if (documentFromRoute(page)) { if (documentFromRoute(page)) {
return <DocView page={page}/> return <DocView page={page} />;
} }
// Document grid for landing page // Document grid for landing page
return ( return (
@ -277,19 +369,47 @@ const DocsPage = (props: BackstoryPageProps) => {
Documentation Documentation
</Typography> </Typography>
<Typography variant="body1" color="text.secondary"> <Typography variant="body1" color="text.secondary">
Select a document from the sidebar to view detailed technical information about the application. Select a document from the sidebar to view detailed technical
information about the application.
</Typography> </Typography>
</Box> </Box>
<Grid container spacing={1}> <Grid container spacing={1}>
{documents.map((doc, index) => { {documents.map((doc, index) => {
if (doc.route === null) return (<></>); if (doc.route === null) return <></>;
return (<Grid sx={{ minWidth: "164px" }} size={{ xs: 12, sm: 6, md: 4 }} key={index}> return (
<Grid
sx={{ minWidth: "164px" }}
size={{ xs: 12, sm: 6, md: 4 }}
key={index}
>
<Card sx={{ minHeight: "180px" }}> <Card sx={{ minHeight: "180px" }}>
<CardActionArea onClick={() => doc.route ? onDocumentExpand(doc.route, true) : navigate('/')}> <CardActionArea
<CardContent sx={{ display: "flex", flexDirection: "column", m: 0, p: 1 }}> onClick={() =>
<Box sx={{ display: 'flex', flexDirection: "row", gap: 1, verticalAlign: 'top' }}> doc.route
? onDocumentExpand(doc.route, true)
: navigate("/")
}
>
<CardContent
sx={{
display: "flex",
flexDirection: "column",
m: 0,
p: 1,
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
gap: 1,
verticalAlign: "top",
}}
>
{getDocumentIcon(doc.title)} {getDocumentIcon(doc.title)}
<Typography variant="h3" sx={{ m: "0 !important" }}>{doc.title}</Typography> <Typography variant="h3" sx={{ m: "0 !important" }}>
{doc.title}
</Typography>
</Box> </Box>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{doc.description} {doc.description}
@ -298,7 +418,7 @@ const DocsPage = (props: BackstoryPageProps) => {
</CardActionArea> </CardActionArea>
</Card> </Card>
</Grid> </Grid>
) );
})} })}
</Grid> </Grid>
</Paper> </Paper>
@ -310,7 +430,7 @@ const DocsPage = (props: BackstoryPageProps) => {
const drawerWidth = 240; const drawerWidth = 240;
return ( return (
<Box sx={{ display: 'flex', height: '100%' }}> <Box sx={{ display: "flex", height: "100%" }}>
{/* Mobile App Bar */} {/* Mobile App Bar */}
{isMobile && ( {isMobile && (
<AppBar <AppBar
@ -318,7 +438,7 @@ const DocsPage = (props: BackstoryPageProps) => {
sx={{ sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` }, width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` }, ml: { sm: `${drawerWidth}px` },
display: { md: 'none' } display: { md: "none" },
}} }}
elevation={0} elevation={0}
color="default" color="default"
@ -332,7 +452,12 @@ const DocsPage = (props: BackstoryPageProps) => {
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Typography variant="h6" noWrap component="div" sx={{ color: "white" }}> <Typography
variant="h6"
noWrap
component="div"
sx={{ color: "white" }}
>
{page ? documentTitleFromRoute(page) : "Documentation"} {page ? documentTitleFromRoute(page) : "Documentation"}
</Typography> </Typography>
</Toolbar> </Toolbar>
@ -344,7 +469,7 @@ const DocsPage = (props: BackstoryPageProps) => {
component="nav" component="nav"
sx={{ sx={{
width: { md: drawerWidth }, width: { md: drawerWidth },
flexShrink: { md: 0 } flexShrink: { md: 0 },
}} }}
> >
{/* Mobile drawer (temporary) */} {/* Mobile drawer (temporary) */}
@ -357,10 +482,10 @@ const DocsPage = (props: BackstoryPageProps) => {
keepMounted: true, // Better open performance on mobile keepMounted: true, // Better open performance on mobile
}} }}
sx={{ sx={{
display: { xs: 'block', md: 'none' }, display: { xs: "block", md: "none" },
'& .MuiDrawer-paper': { "& .MuiDrawer-paper": {
boxSizing: 'border-box', boxSizing: "border-box",
width: drawerWidth width: drawerWidth,
}, },
}} }}
> >
@ -376,12 +501,12 @@ const DocsPage = (props: BackstoryPageProps) => {
<Drawer <Drawer
variant="permanent" variant="permanent"
sx={{ sx={{
display: { xs: 'none', md: 'block' }, display: { xs: "none", md: "block" },
'& .MuiDrawer-paper': { "& .MuiDrawer-paper": {
boxSizing: 'border-box', boxSizing: "border-box",
width: drawerWidth, width: drawerWidth,
position: 'relative', position: "relative",
height: '100%' height: "100%",
}, },
}} }}
open open
@ -403,8 +528,8 @@ const DocsPage = (props: BackstoryPageProps) => {
p: 3, p: 3,
width: { md: `calc(100% - ${drawerWidth}px)` }, width: { md: `calc(100% - ${drawerWidth}px)` },
pt: isMobile ? { xs: 8, sm: 9 } : 3, // Add padding top on mobile to account for AppBar pt: isMobile ? { xs: 8, sm: 9 } : 3, // Add padding top on mobile to account for AppBar
height: '100%', height: "100%",
overflow: 'auto' overflow: "auto",
}} }}
> >
{renderContent()} {renderContent()}

View File

@ -1,12 +1,10 @@
import React, { } from 'react'; import React from "react";
import { BackstoryPageProps } from '../components/BackstoryTab'; import { BackstoryPageProps } from "../components/BackstoryTab";
import { CandidatePicker } from 'components/ui/CandidatePicker'; import { CandidatePicker } from "components/ui/CandidatePicker";
const CandidateListingPage = (props: BackstoryPageProps) => { const CandidateListingPage = (props: BackstoryPageProps) => {
return <CandidatePicker {...props} />; return <CandidatePicker {...props} />;
}; };
export { export { CandidateListingPage };
CandidateListingPage
};

View File

@ -1,42 +1,60 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, useCallback } from "react";
import Avatar from '@mui/material/Avatar'; import Avatar from "@mui/material/Avatar";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import Tooltip from '@mui/material/Tooltip'; import Tooltip from "@mui/material/Tooltip";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import Paper from '@mui/material/Paper'; import Paper from "@mui/material/Paper";
import IconButton from '@mui/material/IconButton'; import IconButton from "@mui/material/IconButton";
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from "@mui/icons-material/Cancel";
import SendIcon from '@mui/icons-material/Send'; import SendIcon from "@mui/icons-material/Send";
import PropagateLoader from 'react-spinners/PropagateLoader'; import PropagateLoader from "react-spinners/PropagateLoader";
import { CandidateInfo } from '../components/ui/CandidateInfo'; import { CandidateInfo } from "../components/ui/CandidateInfo";
import { Quote } from 'components/Quote'; import { Quote } from "components/Quote";
import { BackstoryElementProps } from 'components/BackstoryTab'; import { BackstoryElementProps } from "components/BackstoryTab";
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import {
import { StyledMarkdown } from 'components/StyledMarkdown'; BackstoryTextField,
import { Scrollable } from '../components/Scrollable'; BackstoryTextFieldRef,
import { Pulse } from 'components/Pulse'; } from "components/BackstoryTextField";
import { StreamingResponse } from 'services/api-client'; import { StyledMarkdown } from "components/StyledMarkdown";
import { ChatMessage, ChatMessageUser, ChatSession, CandidateAI, ChatMessageStatus, ChatMessageError } from 'types/types'; import { Scrollable } from "../components/Scrollable";
import { useAuth } from 'hooks/AuthContext'; import { Pulse } from "components/Pulse";
import { Message } from 'components/Message'; import { StreamingResponse } from "services/api-client";
import { useAppState } from 'hooks/GlobalContext'; import {
ChatMessage,
ChatMessageUser,
ChatSession,
CandidateAI,
ChatMessageStatus,
ChatMessageError,
} from "types/types";
import { useAuth } from "hooks/AuthContext";
import { Message } from "components/Message";
import { useAppState } from "hooks/GlobalContext";
const defaultMessage: ChatMessage = { const defaultMessage: ChatMessage = {
status: "done", type: "text", sessionId: "", timestamp: new Date(), content: "", role: "user", metadata: null as any status: "done",
type: "text",
sessionId: "",
timestamp: new Date(),
content: "",
role: "user",
metadata: null as any,
}; };
const GenerateCandidate = (props: BackstoryElementProps) => { const GenerateCandidate = (props: BackstoryElementProps) => {
const { apiClient, user } = useAuth(); const { apiClient, user } = useAuth();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [processingMessage, setProcessingMessage] = useState<ChatMessage | null>(null); const [processingMessage, setProcessingMessage] =
useState<ChatMessage | null>(null);
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [generatedUser, setGeneratedUser] = useState<CandidateAI | null>(null); const [generatedUser, setGeneratedUser] = useState<CandidateAI | null>(null);
const [prompt, setPrompt] = useState<string>(''); const [prompt, setPrompt] = useState<string>("");
const [resume, setResume] = useState<string | null>(null); const [resume, setResume] = useState<string | null>(null);
const [canGenImage, setCanGenImage] = useState<boolean>(false); const [canGenImage, setCanGenImage] = useState<boolean>(false);
const [timestamp, setTimestamp] = useState<string>(''); const [timestamp, setTimestamp] = useState<string>("");
const [shouldGenerateProfile, setShouldGenerateProfile] = useState<boolean>(false); const [shouldGenerateProfile, setShouldGenerateProfile] =
useState<boolean>(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null); const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
@ -52,17 +70,30 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
try { try {
setLoading(true); setLoading(true);
apiClient.getOrCreateChatSession(generatedUser, `Profile image generator for ${generatedUser.fullName}`, 'generate_image') apiClient
.then(session => { .getOrCreateChatSession(
generatedUser,
`Profile image generator for ${generatedUser.fullName}`,
"generate_image"
)
.then((session) => {
setChatSession(session); setChatSession(session);
setLoading(false); setLoading(false);
}); });
} catch (error) { } catch (error) {
setSnack('Unable to load chat session', 'error'); setSnack("Unable to load chat session", "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [generatedUser, chatSession, loading, setChatSession, setLoading, setSnack, apiClient]); }, [
generatedUser,
chatSession,
loading,
setChatSession,
setLoading,
setSnack,
apiClient,
]);
const cancelQuery = useCallback(() => { const cancelQuery = useCallback(() => {
if (controllerRef.current) { if (controllerRef.current) {
@ -72,7 +103,8 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
} }
}, []); }, []);
const onEnter = useCallback((value: string) => { const onEnter = useCallback(
(value: string) => {
if (processing) { if (processing) {
return; return;
} }
@ -84,11 +116,14 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
content: prompt, content: prompt,
sessionId: "", sessionId: "",
status: "done", status: "done",
timestamp: new Date() timestamp: new Date(),
}; };
setPrompt(prompt || ''); setPrompt(prompt || "");
setProcessing(true); setProcessing(true);
setProcessingMessage({ ...defaultMessage, content: "Generating persona..." }); setProcessingMessage({
...defaultMessage,
content: "Generating persona...",
});
try { try {
const result = await apiClient.createCandidateAI(userMessage); const result = await apiClient.createCandidateAI(userMessage);
console.log(result.message, result); console.log(result.message, result);
@ -98,7 +133,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
setShouldGenerateProfile(true); // Reset the flag setShouldGenerateProfile(true); // Reset the flag
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setPrompt(''); setPrompt("");
setResume(null); setResume(null);
setProcessing(false); setProcessing(false);
setProcessingMessage(null); setProcessingMessage(null);
@ -107,10 +142,15 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
}; };
generatePersona(value); generatePersona(value);
}, [processing, apiClient, setSnack]); },
[processing, apiClient, setSnack]
);
const handleSendClick = useCallback(() => { const handleSendClick = useCallback(() => {
const value = (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ""; const value =
(backstoryTextRef.current &&
backstoryTextRef.current.getAndResetValue()) ||
"";
onEnter(value); onEnter(value);
}, [onEnter]); }, [onEnter]);
@ -121,7 +161,11 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
} }
const username = generatedUser.username; const username = generatedUser.username;
if (!shouldGenerateProfile || username === "[blank]" || generatedUser?.firstName === "[blank]") { if (
!shouldGenerateProfile ||
username === "[blank]" ||
generatedUser?.firstName === "[blank]"
) {
return; return;
} }
@ -130,17 +174,20 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
return; return;
} }
setProcessingMessage({ ...defaultMessage, content: 'Starting image generation...' }); setProcessingMessage({
...defaultMessage,
content: "Starting image generation...",
});
setProcessing(true); setProcessing(true);
setCanGenImage(false); setCanGenImage(false);
const chatMessage: ChatMessageUser = { const chatMessage: ChatMessageUser = {
sessionId: chatSession.id || '', sessionId: chatSession.id || "",
role: "user", role: "user",
status: "done", status: "done",
type: "text", type: "text",
timestamp: new Date(), timestamp: new Date(),
content: prompt content: prompt,
}; };
controllerRef.current = apiClient.sendMessageStream(chatMessage, { controllerRef.current = apiClient.sendMessageStream(chatMessage, {
@ -148,25 +195,39 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
console.log(`onMessage: ${msg.type} ${msg.content}`, msg); console.log(`onMessage: ${msg.type} ${msg.content}`, msg);
controllerRef.current = null; controllerRef.current = null;
try { try {
await apiClient.updateCandidate(generatedUser.id || '', { profileImage: "profile.png" }); await apiClient.updateCandidate(generatedUser.id || "", {
const { success, message } = await apiClient.deleteChatSession(chatSession.id || ''); profileImage: "profile.png",
console.log(`Profile generated for ${username} and chat session was ${!success ? 'not ' : ''} deleted: ${message}}`); });
const { success, message } = await apiClient.deleteChatSession(
chatSession.id || ""
);
console.log(
`Profile generated for ${username} and chat session was ${
!success ? "not " : ""
} deleted: ${message}}`
);
setGeneratedUser({ setGeneratedUser({
...generatedUser, ...generatedUser,
profileImage: "profile.png" profileImage: "profile.png",
} as CandidateAI); } as CandidateAI);
setCanGenImage(true); setCanGenImage(true);
setShouldGenerateProfile(false); setShouldGenerateProfile(false);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setSnack(`Unable to update ${username} to indicate they have a profile picture.`, "error"); setSnack(
`Unable to update ${username} to indicate they have a profile picture.`,
"error"
);
} }
}, },
onError: (error: string | ChatMessageError) => { onError: (error: string | ChatMessageError) => {
console.log("onError:", error); console.log("onError:", error);
// Type-guard to determine if this is a ChatMessageBase or a string // Type-guard to determine if this is a ChatMessageBase or a string
if (typeof error === "object" && error !== null && "content" in error) { if (typeof error === "object" && error !== null && "content" in error) {
setSnack(error.content || "Unknown error generating profile image", "error"); setSnack(
error.content || "Unknown error generating profile image",
"error"
);
} else { } else {
setSnack(error as string, "error"); setSnack(error as string, "error");
} }
@ -185,43 +246,56 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
}, },
onStatus: (status: ChatMessageStatus) => { onStatus: (status: ChatMessageStatus) => {
if (status.activity === "heartbeat" && status.content) { if (status.activity === "heartbeat" && status.content) {
setTimestamp(status.timestamp?.toISOString() || ''); setTimestamp(status.timestamp?.toISOString() || "");
} else if (status.content) { } else if (status.content) {
setProcessingMessage({ ...defaultMessage, content: status.content }); setProcessingMessage({ ...defaultMessage, content: status.content });
} }
console.log(`onStatusChange: ${status}`); console.log(`onStatusChange: ${status}`);
}, },
}); });
}, [chatSession, shouldGenerateProfile, generatedUser, prompt, setSnack, apiClient]); }, [
chatSession,
shouldGenerateProfile,
generatedUser,
prompt,
setSnack,
apiClient,
]);
if (!user?.isAdmin) { if (!user?.isAdmin) {
return (<Box>You must be logged in as an admin to generate AI candidates.</Box>); return (
<Box>You must be logged in as an admin to generate AI candidates.</Box>
);
} }
return ( return (
<Box className="GenerateCandidate" sx={{ <Box
className="GenerateCandidate"
sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
flexGrow: 1, flexGrow: 1,
gap: 1, gap: 1,
maxWidth: { xs: '100%', md: '700px', lg: '1024px' }, maxWidth: { xs: "100%", md: "700px", lg: "1024px" },
}}> }}
{generatedUser && <CandidateInfo >
candidate={generatedUser} {generatedUser && (
sx={{flexShrink: 1}}/> <CandidateInfo candidate={generatedUser} sx={{ flexShrink: 1 }} />
} )}
{ prompt && {prompt && <Quote quote={prompt} />}
<Quote quote={prompt}/> {processing && (
} <Box
{processing && sx={{
<Box sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
m: 2, m: 2,
}}> }}
{processingMessage && chatSession && <Message message={processingMessage} {...{ chatSession }} />} >
{processingMessage && chatSession && (
<Message message={processingMessage} {...{ chatSession }} />
)}
<PropagateLoader <PropagateLoader
size="10px" size="10px"
loading={processing} loading={processing}
@ -229,48 +303,84 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
data-testid="loader" data-testid="loader"
/> />
</Box> </Box>
} )}
<Box sx={{ display: "flex", flexDirection: "column" }}> <Box sx={{ display: "flex", flexDirection: "column" }}>
<Box sx={{ <Box
sx={{
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
position: "relative" position: "relative",
}}> }}
<Box sx={{ display: "flex", position: "relative", width: "min-content", height: "min-content" }}> >
<Box
sx={{
display: "flex",
position: "relative",
width: "min-content",
height: "min-content",
}}
>
<Avatar <Avatar
src={generatedUser?.profileImage ? `/api/1.0/candidates/profile/${generatedUser.username}` : ''} src={
generatedUser?.profileImage
? `/api/1.0/candidates/profile/${generatedUser.username}`
: ""
}
alt={`${generatedUser?.fullName}'s profile`} alt={`${generatedUser?.fullName}'s profile`}
sx={{ sx={{
width: 80, width: 80,
height: 80, height: 80,
border: '2px solid #e0e0e0', border: "2px solid #e0e0e0",
}} }}
/> />
{processing && <Pulse sx={{ position: "relative", left: "-80px", top: "0px", mr: "-80px" }} timestamp={timestamp} />} {processing && (
<Pulse
sx={{
position: "relative",
left: "-80px",
top: "0px",
mr: "-80px",
}}
timestamp={timestamp}
/>
)}
</Box> </Box>
<Tooltip title={`${generatedUser?.profileImage ? 'Re-' : ''}Generate Picture`}> <Tooltip
title={`${
generatedUser?.profileImage ? "Re-" : ""
}Generate Picture`}
>
<span style={{ display: "flex", flexGrow: 1 }}> <span style={{ display: "flex", flexGrow: 1 }}>
<Button <Button
sx={{ m: 1, gap: 1, justifySelf: "flex-start", alignSelf: "center", flexGrow: 0, maxHeight: "min-content" }} sx={{
m: 1,
gap: 1,
justifySelf: "flex-start",
alignSelf: "center",
flexGrow: 0,
maxHeight: "min-content",
}}
variant="contained" variant="contained"
disabled={ disabled={processing || !canGenImage}
processing || !canGenImage onClick={() => {
} setShouldGenerateProfile(true);
onClick={() => { setShouldGenerateProfile(true); }}> }}
{generatedUser?.profileImage ? 'Re-' : ''}Generate Picture<SendIcon /> >
{generatedUser?.profileImage ? "Re-" : ""}Generate Picture
<SendIcon />
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
</Box> </Box>
</Box> </Box>
{resume && {resume && (
<Paper sx={{ pt: 1, pb: 1, pl: 2, pr: 2 }}> <Paper sx={{ pt: 1, pb: 1, pl: 2, pr: 2 }}>
<Scrollable sx={{ flexGrow: 1 }}> <Scrollable sx={{ flexGrow: 1 }}>
<StyledMarkdown content={resume} /> <StyledMarkdown content={resume} />
</Scrollable> </Scrollable>
</Paper> </Paper>
} )}
<BackstoryTextField <BackstoryTextField
style={{ flexGrow: 0, flexShrink: 1 }} style={{ flexGrow: 0, flexShrink: 1 }}
ref={backstoryTextRef} ref={backstoryTextRef}
@ -278,24 +388,30 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
onEnter={onEnter} onEnter={onEnter}
placeholder='Specify any characteristics you would like the persona to have. For example, "This person likes yo-yos."' placeholder='Specify any characteristics you would like the persona to have. For example, "This person likes yo-yos."'
/> />
<Box sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}> <Box
sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}
>
<Tooltip title={"Send"}> <Tooltip title={"Send"}>
<span style={{ display: "flex", flexGrow: 1 }}> <span style={{ display: "flex", flexGrow: 1 }}>
<Button <Button
sx={{ m: 1, gap: 1, flexGrow: 1 }} sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained" variant="contained"
disabled={processing} disabled={processing}
onClick={handleSendClick}> onClick={handleSendClick}
Generate New Persona<SendIcon /> >
Generate New Persona
<SendIcon />
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
<Tooltip title="Cancel"> <Tooltip title="Cancel">
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */} <span style={{ display: "flex" }}>
{" "}
{/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton <IconButton
aria-label="cancel" aria-label="cancel"
onClick={cancelQuery} onClick={cancelQuery}
sx={{ display: "flex", margin: 'auto 0px' }} sx={{ display: "flex", margin: "auto 0px" }}
size="large" size="large"
edge="start" edge="start"
disabled={controllerRef.current === null || processing === false} disabled={controllerRef.current === null || processing === false}
@ -306,9 +422,8 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
</Tooltip> </Tooltip>
</Box> </Box>
<Box sx={{ display: "flex", flexGrow: 1 }} /> <Box sx={{ display: "flex", flexGrow: 1 }} />
</Box>); </Box>
);
}; };
export { export { GenerateCandidate };
GenerateCandidate
};

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { import {
Box, Box,
Button, Button,
@ -11,16 +11,16 @@ import {
Card, Card,
CardContent, CardContent,
CardActions, CardActions,
} from '@mui/material'; } from "@mui/material";
import { styled } from '@mui/material/styles'; import { styled } from "@mui/material/styles";
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import PersonSearchIcon from '@mui/icons-material/PersonSearch'; import PersonSearchIcon from "@mui/icons-material/PersonSearch";
import WorkHistoryIcon from '@mui/icons-material/WorkHistory'; import WorkHistoryIcon from "@mui/icons-material/WorkHistory";
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer'; import QuestionAnswerIcon from "@mui/icons-material/QuestionAnswer";
import DescriptionIcon from '@mui/icons-material/Description'; import DescriptionIcon from "@mui/icons-material/Description";
import professionalConversationPng from 'assets/Conversation.png'; import professionalConversationPng from "assets/Conversation.png";
import { ComingSoon } from 'components/ui/ComingSoon'; import { ComingSoon } from "components/ui/ComingSoon";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
// Placeholder for Testimonials component // Placeholder for Testimonials component
const Testimonials = () => { const Testimonials = () => {
@ -36,7 +36,7 @@ const HeroSection = styled(Box)(({ theme }) => ({
padding: theme.spacing(8, 0), padding: theme.spacing(8, 0),
backgroundColor: theme.palette.primary.main, backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
[theme.breakpoints.down('md')]: { [theme.breakpoints.down("md")]: {
padding: theme.spacing(6, 0), padding: theme.spacing(6, 0),
}, },
})); }));
@ -50,7 +50,7 @@ const HeroButton = (props: HeroButtonProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = () => { const handleClick = () => {
const path = children?.replace(/ /g, '-').toLocaleLowerCase() || '/'; const path = children?.replace(/ /g, "-").toLocaleLowerCase() || "/";
navigate(path); navigate(path);
}; };
@ -60,16 +60,17 @@ const HeroButton = (props: HeroButtonProps) => {
fontWeight: 500, fontWeight: 500,
backgroundColor: theme.palette.action.active, backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper, color: theme.palette.background.paper,
'&:hover': { "&:hover": {
backgroundColor: theme.palette.action.active, backgroundColor: theme.palette.action.active,
opacity: 0.9, opacity: 0.9,
}, },
})); }));
return <HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}> return (
<HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{children} {children}
</HeroStyledButton> </HeroStyledButton>
} );
};
interface ActionButtonProps extends ButtonProps { interface ActionButtonProps extends ButtonProps {
children?: string; children?: string;
@ -80,23 +81,25 @@ const ActionButton = (props: ActionButtonProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = () => { const handleClick = () => {
const path = children?.replace(/ /g, '-').toLocaleLowerCase() || '/'; const path = children?.replace(/ /g, "-").toLocaleLowerCase() || "/";
navigate(path); navigate(path);
}; };
return <Button onClick={onClick ? onClick : handleClick} {...rest}> return (
<Button onClick={onClick ? onClick : handleClick} {...rest}>
{children} {children}
</Button> </Button>
} );
};
const FeatureIcon = styled(Box)(({ theme }) => ({ const FeatureIcon = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.action.active, backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper, color: theme.palette.background.paper,
borderRadius: '50%', borderRadius: "50%",
padding: theme.spacing(2), padding: theme.spacing(2),
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
width: 64, width: 64,
height: 64, height: 64,
@ -106,14 +109,14 @@ const FeatureIcon = styled(Box)(({ theme }) => ({
const FeatureCard = ({ const FeatureCard = ({
icon, icon,
title, title,
description description,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
title: string; title: string;
description: string; description: string;
}) => { }) => {
return ( return (
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}> <Card sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Box display="flex" justifyContent="center" mb={2}> <Box display="flex" justifyContent="center" mb={2}>
{icon} {icon}
@ -125,7 +128,7 @@ const FeatureCard = ({
{description} {description}
</Typography> </Typography>
</CardContent> </CardContent>
<CardActions sx={{ justifyContent: 'center', pb: 2 }}> <CardActions sx={{ justifyContent: "center", pb: 2 }}>
<Button size="small" endIcon={<ArrowForwardIcon />}> <Button size="small" endIcon={<ArrowForwardIcon />}>
Learn more Learn more
</Button> </Button>
@ -140,69 +143,75 @@ const HomePage = () => {
if (isGuest) { if (isGuest) {
// Show guest-specific UI // Show guest-specific UI
console.log('Guest session:', guest?.sessionId || "No guest"); console.log("Guest session:", guest?.sessionId || "No guest");
} else { } else {
// Show authenticated user UI // Show authenticated user UI
console.log('Authenticated user:', user?.email || "No user"); console.log("Authenticated user:", user?.email || "No user");
} }
return (<Box sx={{display: "flex", flexDirection: "column"}}> return (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{/* Hero Section */} {/* Hero Section */}
<HeroSection> <HeroSection>
<Container> <Container>
<Box sx={{ <Box
display: 'flex', sx={{
flexDirection: { xs: 'column', md: 'row' }, display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 4, gap: 4,
alignItems: 'center', alignItems: "center",
flexGrow: 1, flexGrow: 1,
maxWidth: "1024px" maxWidth: "1024px",
}}> }}
>
<Box sx={{ flex: 1, flexGrow: 1 }}> <Box sx={{ flex: 1, flexGrow: 1 }}>
<Typography <Typography
variant="h2" variant="h2"
component="h1" component="h1"
sx={{ sx={{
fontWeight: 700, fontWeight: 700,
fontSize: { xs: '2rem', md: '3rem' }, fontSize: { xs: "2rem", md: "3rem" },
mb: 2, mb: 2,
color: "white" color: "white",
}} }}
> >
Your complete professional story, beyond a single page Your complete professional story, beyond a single page
</Typography> </Typography>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}> <Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}>
Let potential employers discover the depth of your experience through interactive Q&A and tailored resumes Let potential employers discover the depth of your experience
through interactive Q&A and tailored resumes
</Typography> </Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}> <Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<HeroButton <HeroButton variant="contained" size="large">
variant="contained"
size="large"
>
Get Started as Candidate Get Started as Candidate
</HeroButton> </HeroButton>
<HeroButton <HeroButton
variant="outlined" variant="outlined"
size="large" size="large"
sx={{ sx={{
backgroundColor: 'transparent', backgroundColor: "transparent",
border: '2px solid', border: "2px solid",
borderColor: 'action.active' borderColor: "action.active",
}} }}
> >
Recruit Talent Recruit Talent
</HeroButton> </HeroButton>
</Stack> </Stack>
</Box> </Box>
<Box sx={{ justifyContent: "center", display: { xs: 'none', md: 'block' } }}> <Box
sx={{
justifyContent: "center",
display: { xs: "none", md: "block" },
}}
>
<Box <Box
component="img" component="img"
src={professionalConversationPng} src={professionalConversationPng}
alt="Professional conversation" alt="Professional conversation"
sx={{ sx={{
width: '100%', width: "100%",
maxWidth: 200, maxWidth: 200,
height: 'auto', height: "auto",
borderRadius: 2, borderRadius: 2,
boxShadow: 3, boxShadow: 3,
}} }}
@ -224,33 +233,48 @@ const HomePage = () => {
How Backstory Works How Backstory Works
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', md: 'row' }, gap: 4 }}> <Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 4,
}}
>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="h4" component="h3" gutterBottom sx={{ color: 'primary.main' }}> <Typography
variant="h4"
component="h3"
gutterBottom
sx={{ color: "primary.main" }}
>
For Job Seekers For Job Seekers
</Typography> </Typography>
<Box sx={{ my: 3 }}> <Box sx={{ my: 3 }}>
<Typography variant="body1" paragraph> <Typography variant="body1" paragraph>
Backstory helps you tell your complete professional story, highlight your achievements, and showcase your skills beyond what fits on a traditional resume. Backstory helps you tell your complete professional story,
highlight your achievements, and showcase your skills beyond
what fits on a traditional resume.
</Typography> </Typography>
</Box> </Box>
<Stack spacing={3}> <Stack spacing={3}>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Box sx={{ <Box
backgroundColor: 'primary.main', sx={{
color: 'primary.contrastText', backgroundColor: "primary.main",
borderRadius: '50%', color: "primary.contrastText",
borderRadius: "50%",
width: 40, width: 40,
height: 40, height: 40,
minWidth: 40, minWidth: 40,
minHeight: 40, minHeight: 40,
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
mr: 2, mr: 2,
fontWeight: 'bold' fontWeight: "bold",
}}> }}
>
1 1
</Box> </Box>
<Typography variant="body1"> <Typography variant="body1">
@ -259,42 +283,47 @@ const HomePage = () => {
</Box> </Box>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Box sx={{ <Box
backgroundColor: 'primary.main', sx={{
color: 'primary.contrastText', backgroundColor: "primary.main",
borderRadius: '50%', color: "primary.contrastText",
borderRadius: "50%",
width: 40, width: 40,
height: 40, height: 40,
minWidth: 40, minWidth: 40,
minHeight: 40, minHeight: 40,
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
mr: 2, mr: 2,
fontWeight: 'bold' fontWeight: "bold",
}}> }}
>
2 2
</Box> </Box>
<Typography variant="body1"> <Typography variant="body1">
Configure your AI assistant to answer questions about your experience Configure your AI assistant to answer questions about your
experience
</Typography> </Typography>
</Box> </Box>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Box sx={{ <Box
backgroundColor: 'primary.main', sx={{
color: 'primary.contrastText', backgroundColor: "primary.main",
borderRadius: '50%', color: "primary.contrastText",
borderRadius: "50%",
width: 40, width: 40,
height: 40, height: 40,
minWidth: 40, minWidth: 40,
minHeight: 40, minHeight: 40,
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
mr: 2, mr: 2,
fontWeight: 'bold' fontWeight: "bold",
}}> }}
>
3 3
</Box> </Box>
<Typography variant="body1"> <Typography variant="body1">
@ -315,75 +344,90 @@ const HomePage = () => {
<ComingSoon> <ComingSoon>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="h4" component="h3" gutterBottom sx={{ color: 'primary.main' }}> <Typography
variant="h4"
component="h3"
gutterBottom
sx={{ color: "primary.main" }}
>
For Employers For Employers
</Typography> </Typography>
<Box sx={{ my: 3 }}> <Box sx={{ my: 3 }}>
<Typography variant="body1" paragraph> <Typography variant="body1" paragraph>
Discover candidates with the perfect skills and experience for your positions by engaging in meaningful Q&A to learn more about their background. Discover candidates with the perfect skills and experience for
your positions by engaging in meaningful Q&A to learn more
about their background.
</Typography> </Typography>
</Box> </Box>
<Stack spacing={3}> <Stack spacing={3}>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Box sx={{ <Box
backgroundColor: 'secondary.main', sx={{
color: 'secondary.contrastText', backgroundColor: "secondary.main",
borderRadius: '50%', color: "secondary.contrastText",
borderRadius: "50%",
width: 40, width: 40,
height: 40, height: 40,
minWidth: 40, minWidth: 40,
minHeight: 40, minHeight: 40,
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
mr: 2, mr: 2,
fontWeight: 'bold' fontWeight: "bold",
}}> }}
>
1 1
</Box> </Box>
<Typography variant="body1"> <Typography variant="body1">
Search the candidate pool based on skills, experience, and location Search the candidate pool based on skills, experience, and
location
</Typography> </Typography>
</Box> </Box>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Box sx={{ <Box
backgroundColor: 'secondary.main', sx={{
color: 'secondary.contrastText', backgroundColor: "secondary.main",
borderRadius: '50%', color: "secondary.contrastText",
borderRadius: "50%",
width: 40, width: 40,
height: 40, height: 40,
minWidth: 40, minWidth: 40,
minHeight: 40, minHeight: 40,
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
mr: 2, mr: 2,
fontWeight: 'bold' fontWeight: "bold",
}}> }}
>
2 2
</Box> </Box>
<Typography variant="body1"> <Typography variant="body1">
Ask personalized questions about candidates' experience and skills Ask personalized questions about candidates' experience and
skills
</Typography> </Typography>
</Box> </Box>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Box sx={{ <Box
backgroundColor: 'secondary.main', sx={{
color: 'secondary.contrastText', backgroundColor: "secondary.main",
borderRadius: '50%', color: "secondary.contrastText",
borderRadius: "50%",
width: 40, width: 40,
height: 40, height: 40,
minWidth: 40, minWidth: 40,
minHeight: 40, minHeight: 40,
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
mr: 2, mr: 2,
fontWeight: 'bold' fontWeight: "bold",
}}> }}
>
3 3
</Box> </Box>
<Typography variant="body1"> <Typography variant="body1">
@ -406,7 +450,7 @@ const HomePage = () => {
</Container> </Container>
{/* Features Section */} {/* Features Section */}
<Box sx={{ backgroundColor: 'background.paper', py: 8 }}> <Box sx={{ backgroundColor: "background.paper", py: 8 }}>
<Container> <Container>
<Typography <Typography
variant="h3" variant="h3"
@ -418,8 +462,17 @@ const HomePage = () => {
Key Features Key Features
</Typography> </Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}> <Box sx={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}> <Box
sx={{
flex: "1 1 250px",
minWidth: {
xs: "100%",
sm: "calc(50% - 16px)",
md: "calc(25% - 16px)",
},
}}
>
<FeatureCard <FeatureCard
icon={ icon={
<FeatureIcon> <FeatureIcon>
@ -430,7 +483,16 @@ const HomePage = () => {
description="Find the perfect candidates based on skills, experience, and fit for your specific requirements." description="Find the perfect candidates based on skills, experience, and fit for your specific requirements."
/> />
</Box> </Box>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}> <Box
sx={{
flex: "1 1 250px",
minWidth: {
xs: "100%",
sm: "calc(50% - 16px)",
md: "calc(25% - 16px)",
},
}}
>
<FeatureCard <FeatureCard
icon={ icon={
<FeatureIcon> <FeatureIcon>
@ -441,7 +503,16 @@ const HomePage = () => {
description="Share your full professional journey beyond the limitations of a traditional resume." description="Share your full professional journey beyond the limitations of a traditional resume."
/> />
</Box> </Box>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}> <Box
sx={{
flex: "1 1 250px",
minWidth: {
xs: "100%",
sm: "calc(50% - 16px)",
md: "calc(25% - 16px)",
},
}}
>
<FeatureCard <FeatureCard
icon={ icon={
<FeatureIcon> <FeatureIcon>
@ -452,7 +523,16 @@ const HomePage = () => {
description="Ask detailed questions about a candidate's experience and get immediate answers." description="Ask detailed questions about a candidate's experience and get immediate answers."
/> />
</Box> </Box>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}> <Box
sx={{
flex: "1 1 250px",
minWidth: {
xs: "100%",
sm: "calc(50% - 16px)",
md: "calc(25% - 16px)",
},
}}
>
<FeatureCard <FeatureCard
icon={ icon={
<FeatureIcon> <FeatureIcon>
@ -468,7 +548,7 @@ const HomePage = () => {
</Box> </Box>
{/* Testimonials Section */} {/* Testimonials Section */}
{testimonials && {testimonials && (
<Container sx={{ py: 8 }}> <Container sx={{ py: 8 }}>
<Typography <Typography
variant="h3" variant="h3"
@ -482,54 +562,62 @@ const HomePage = () => {
<Typography <Typography
variant="body1" variant="body1"
align="center" align="center"
sx={{ mb: 6, maxWidth: 800, mx: 'auto' }} sx={{ mb: 6, maxWidth: 800, mx: "auto" }}
> >
See how Backstory has transformed the hiring process for both candidates and employers. See how Backstory has transformed the hiring process for both
candidates and employers.
</Typography> </Typography>
<Testimonials /> <Testimonials />
</Container> </Container>
} )}
{/* CTA Section */} {/* CTA Section */}
<Box sx={{ <Box
backgroundColor: 'primary.main', sx={{
color: 'primary.contrastText', backgroundColor: "primary.main",
py: 8 color: "primary.contrastText",
}}> py: 8,
}}
>
<Container> <Container>
<Box sx={{ <Box
display: 'flex', sx={{
flexDirection: 'column', display: "flex",
alignItems: 'center', flexDirection: "column",
textAlign: 'center', alignItems: "center",
textAlign: "center",
maxWidth: 800, maxWidth: 800,
mx: 'auto' mx: "auto",
}}> }}
<Typography variant="h3" component="h2" gutterBottom sx={{ color: "white" }}> >
<Typography
variant="h3"
component="h2"
gutterBottom
sx={{ color: "white" }}
>
Ready to transform your hiring process? Ready to transform your hiring process?
</Typography> </Typography>
<Typography variant="h6" sx={{ mb: 4 }}> <Typography variant="h6" sx={{ mb: 4 }}>
Join Backstory today and discover a better way to connect talent with opportunity. Join Backstory today and discover a better way to connect talent
with opportunity.
</Typography> </Typography>
<Stack <Stack
direction={{ xs: 'column', sm: 'row' }} direction={{ xs: "column", sm: "row" }}
spacing={2} spacing={2}
justifyContent="center" justifyContent="center"
> >
<HeroButton <HeroButton variant="contained" size="large">
variant="contained"
size="large"
>
Sign Up as Candidate Sign Up as Candidate
</HeroButton> </HeroButton>
<HeroButton <HeroButton
variant="outlined" variant="outlined"
size="large" size="large"
sx={{ sx={{
backgroundColor: 'transparent', backgroundColor: "transparent",
border: '2px solid', border: "2px solid",
borderColor: 'action.active' borderColor: "action.active",
}} }}
> >
Sign Up as Employer Sign Up as Employer
@ -542,7 +630,4 @@ const HomePage = () => {
); );
}; };
export { export { HomePage };
HomePage
};

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { import {
Box, Box,
Button, Button,
@ -17,37 +17,37 @@ import {
ButtonProps, ButtonProps,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from '@mui/material'; } from "@mui/material";
import { styled } from '@mui/material/styles'; import { styled } from "@mui/material/styles";
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from "@mui/icons-material/Assessment";
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from "@mui/icons-material/Person";
import WorkIcon from '@mui/icons-material/Work'; import WorkIcon from "@mui/icons-material/Work";
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import DescriptionIcon from '@mui/icons-material/Description'; import DescriptionIcon from "@mui/icons-material/Description";
import professionalConversationPng from 'assets/Conversation.png'; import professionalConversationPng from "assets/Conversation.png";
import selectAJobPng from 'assets/select-a-job.png'; import selectAJobPng from "assets/select-a-job.png";
import selectJobAnalysisPng from 'assets/select-job-analysis.png'; import selectJobAnalysisPng from "assets/select-job-analysis.png";
import selectACandidatePng from 'assets/select-a-candidate.png'; import selectACandidatePng from "assets/select-a-candidate.png";
import selectStartAnalysisPng from 'assets/select-start-analysis.png'; import selectStartAnalysisPng from "assets/select-start-analysis.png";
import waitPng from 'assets/wait.png'; import waitPng from "assets/wait.png";
import finalResumePng from 'assets/final-resume.png'; import finalResumePng from "assets/final-resume.png";
import { Beta } from 'components/ui/Beta'; import { Beta } from "components/ui/Beta";
// Styled components matching HomePage patterns // Styled components matching HomePage patterns
const HeroSection = styled(Box)(({ theme }) => ({ const HeroSection = styled(Box)(({ theme }) => ({
padding: theme.spacing(3, 0), padding: theme.spacing(3, 0),
backgroundColor: theme.palette.primary.main, backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
[theme.breakpoints.down('md')]: { [theme.breakpoints.down("md")]: {
padding: theme.spacing(2, 0), padding: theme.spacing(2, 0),
}, },
})); }));
const StepSection = styled(Box)(({ theme }) => ({ const StepSection = styled(Box)(({ theme }) => ({
padding: theme.spacing(6, 0), padding: theme.spacing(6, 0),
'&:nth-of-type(even)': { "&:nth-of-type(even)": {
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
}, },
})); }));
@ -55,25 +55,25 @@ const StepSection = styled(Box)(({ theme }) => ({
const StepNumber = styled(Box)(({ theme }) => ({ const StepNumber = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.action.active, backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper, color: theme.palette.background.paper,
borderRadius: '50%', borderRadius: "50%",
width: 60, width: 60,
height: 60, height: 60,
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
fontSize: '1.5rem', fontSize: "1.5rem",
fontWeight: 'bold', fontWeight: "bold",
margin: '0 auto 1rem auto', margin: "0 auto 1rem auto",
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
margin: 0, margin: 0,
}, },
})); }));
const ImageContainer = styled(Box)(({ theme }) => ({ const ImageContainer = styled(Box)(({ theme }) => ({
textAlign: 'center', textAlign: "center",
'& img': { "& img": {
maxWidth: '100%', maxWidth: "100%",
height: 'auto', height: "auto",
borderRadius: theme.spacing(1), borderRadius: theme.spacing(1),
boxShadow: theme.shadows[3], boxShadow: theme.shadows[3],
border: `2px solid ${theme.palette.action.active}`, border: `2px solid ${theme.palette.action.active}`,
@ -81,22 +81,22 @@ const ImageContainer = styled(Box)(({ theme }) => ({
})); }));
const StepCard = styled(Card)(({ theme }) => ({ const StepCard = styled(Card)(({ theme }) => ({
height: '100%', height: "100%",
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
border: `1px solid ${theme.palette.action.active}`, border: `1px solid ${theme.palette.action.active}`,
'&:hover': { "&:hover": {
boxShadow: theme.shadows[4], boxShadow: theme.shadows[4],
}, },
})); }));
const steps = [ const steps = [
'Select Job Analysis', "Select Job Analysis",
'Choose a Job', "Choose a Job",
'Select a Candidate', "Select a Candidate",
'Start Assessment', "Start Assessment",
'Review Results', "Review Results",
'Generate Resume' "Generate Resume",
]; ];
interface StepContentProps { interface StepContentProps {
@ -122,17 +122,30 @@ const StepContent: React.FC<StepContentProps> = ({
imageAlt, imageAlt,
note, note,
success, success,
reversed = false reversed = false,
}) => { }) => {
const textContent = ( const textContent = (
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}> <Box sx={{ display: "flex", alignItems: "center", mb: 3 }}>
<StepNumber>{stepNumber}</StepNumber> <StepNumber>{stepNumber}</StepNumber>
<Box sx={{ ml: { xs: 0, md: 3 }, textAlign: { xs: 'center', md: 'left' } }}> <Box
<Typography variant="h3" component="h2" sx={{ color: 'primary.main', mb: 1 }}> sx={{ ml: { xs: 0, md: 3 }, textAlign: { xs: "center", md: "left" } }}
>
<Typography
variant="h3"
component="h2"
sx={{ color: "primary.main", mb: 1 }}
>
{title} {title}
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', justifyContent: { xs: 'center', md: 'flex-start' } }}> <Box
sx={{
display: "flex",
gap: 1,
alignItems: "center",
justifyContent: { xs: "center", md: "flex-start" },
}}
>
{icon} {icon}
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{subtitle} {subtitle}
@ -146,15 +159,30 @@ const StepContent: React.FC<StepContentProps> = ({
</Typography> </Typography>
))} ))}
{note && ( {note && (
<Paper sx={{ p: 2, backgroundColor: 'action.hover', border: '1px solid', borderColor: 'action.active', mt: 2 }}> <Paper
<Typography variant="body2" sx={{ fontStyle: 'italic' }}> sx={{
p: 2,
backgroundColor: "action.hover",
border: "1px solid",
borderColor: "action.active",
mt: 2,
}}
>
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
<strong>Note:</strong> {note} <strong>Note:</strong> {note}
</Typography> </Typography>
</Paper> </Paper>
)} )}
{success && ( {success && (
<Paper sx={{ p: 2, backgroundColor: 'secondary.main', color: 'secondary.contrastText', mt: 2 }}> <Paper
<Typography variant="body1" sx={{ fontWeight: 'bold' }}> sx={{
p: 2,
backgroundColor: "secondary.main",
color: "secondary.contrastText",
mt: 2,
}}
>
<Typography variant="body1" sx={{ fontWeight: "bold" }}>
🎉 {success} 🎉 {success}
</Typography> </Typography>
</Paper> </Paper>
@ -205,23 +233,25 @@ const HeroButton = (props: HeroButtonProps) => {
fontWeight: 500, fontWeight: 500,
backgroundColor: theme.palette.action.active, backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper, color: theme.palette.background.paper,
'&:hover': { "&:hover": {
backgroundColor: theme.palette.action.active, backgroundColor: theme.palette.action.active,
opacity: 0.9, opacity: 0.9,
}, },
})); }));
return <HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}> return (
<HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{children} {children}
</HeroStyledButton> </HeroStyledButton>
} );
};
const HowItWorks: React.FC = () => { const HowItWorks: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const handleGetStarted = () => { const handleGetStarted = () => {
navigate('/job-analysis'); navigate("/job-analysis");
}; };
return ( return (
@ -230,31 +260,34 @@ const HowItWorks: React.FC = () => {
{/* Hero Section */} {/* Hero Section */}
<HeroSection> <HeroSection>
<Container> <Container>
<Box sx={{ <Box
display: 'flex', sx={{
flexDirection: { xs: 'column', md: 'row' }, display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 4, gap: 4,
alignItems: 'center', alignItems: "center",
flexGrow: 1, flexGrow: 1,
maxWidth: "1024px" maxWidth: "1024px",
}}> }}
>
<Box sx={{ flex: 1, flexGrow: 1 }}> <Box sx={{ flex: 1, flexGrow: 1 }}>
<Typography <Typography
variant="h2" variant="h2"
component="h1" component="h1"
sx={{ sx={{
fontWeight: 700, fontWeight: 700,
fontSize: { xs: '2rem', md: '3rem' }, fontSize: { xs: "2rem", md: "3rem" },
mb: 2, mb: 2,
color: "white" color: "white",
}} }}
> >
Your complete professional story, beyond a single page Your complete professional story, beyond a single page
</Typography> </Typography>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}> <Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}>
Let potential employers discover the depth of your experience through interactive Q&A and tailored resumes Let potential employers discover the depth of your experience
through interactive Q&A and tailored resumes
</Typography> </Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}> <Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<HeroButton <HeroButton
variant="contained" variant="contained"
size="large" size="large"
@ -275,15 +308,20 @@ const HowItWorks: React.FC = () => {
</HeroButton> */} </HeroButton> */}
</Stack> </Stack>
</Box> </Box>
<Box sx={{ justifyContent: "center", display: { xs: 'none', md: 'block' } }}> <Box
sx={{
justifyContent: "center",
display: { xs: "none", md: "block" },
}}
>
<Box <Box
component="img" component="img"
src={professionalConversationPng} src={professionalConversationPng}
alt="Professional conversation" alt="Professional conversation"
sx={{ sx={{
width: '100%', width: "100%",
maxWidth: 200, maxWidth: 200,
height: 'auto', height: "auto",
borderRadius: 2, borderRadius: 2,
boxShadow: 3, boxShadow: 3,
}} }}
@ -292,18 +330,34 @@ const HowItWorks: React.FC = () => {
</Box> </Box>
</Container> </Container>
</HeroSection> </HeroSection>
<HeroSection sx={{ display: "flex", position: "relative", overflow: "hidden", border: "2px solid orange" }}> <HeroSection
sx={{
display: "flex",
position: "relative",
overflow: "hidden",
border: "2px solid orange",
}}
>
<Beta adaptive={false} sx={{ left: "-90px" }} /> <Beta adaptive={false} sx={{ left: "-90px" }} />
<Container sx={{ display: "flex", position: "relative" }}> <Container sx={{ display: "flex", position: "relative" }}>
<Box sx={{ display: "flex", flexDirection: "column", textAlign: 'center', maxWidth: 800, mx: 'auto', position: "relative" }}> <Box
sx={{
display: "flex",
flexDirection: "column",
textAlign: "center",
maxWidth: 800,
mx: "auto",
position: "relative",
}}
>
<Typography <Typography
variant="h2" variant="h2"
component="h1" component="h1"
sx={{ sx={{
fontWeight: 700, fontWeight: 700,
fontSize: { xs: '2rem', md: '2.5rem' }, fontSize: { xs: "2rem", md: "2.5rem" },
mb: 2, mb: 2,
color: "white" color: "white",
}} }}
> >
Welcome to the Backstory Beta! Welcome to the Backstory Beta!
@ -317,7 +371,7 @@ const HowItWorks: React.FC = () => {
{/* Progress Overview */} {/* Progress Overview */}
<Container sx={{ py: 4 }}> <Container sx={{ py: 4 }}>
<Box sx={{ display: { xs: 'none', md: 'block' } }}> <Box sx={{ display: { xs: "none", md: "block" } }}>
<Stepper alternativeLabel sx={{ mb: 4 }}> <Stepper alternativeLabel sx={{ mb: 4 }}>
{steps.map((label) => ( {steps.map((label) => (
<Step key={label}> <Step key={label}>
@ -335,9 +389,9 @@ const HowItWorks: React.FC = () => {
stepNumber={1} stepNumber={1}
title="Select Job Analysis" title="Select Job Analysis"
subtitle="Navigate to the main feature" subtitle="Navigate to the main feature"
icon={<AssessmentIcon sx={{ color: 'action.active' }} />} icon={<AssessmentIcon sx={{ color: "action.active" }} />}
description={[ description={[
"Select 'Job Analysis' from the menu. This takes you to the interactive Job Analysis page, where you will get to evaluate a candidate for a selected job." "Select 'Job Analysis' from the menu. This takes you to the interactive Job Analysis page, where you will get to evaluate a candidate for a selected job.",
]} ]}
imageSrc={selectJobAnalysisPng} imageSrc={selectJobAnalysisPng}
imageAlt="Select Job Analysis from menu" imageAlt="Select Job Analysis from menu"
@ -352,9 +406,9 @@ const HowItWorks: React.FC = () => {
stepNumber={2} stepNumber={2}
title="Choose a Job" title="Choose a Job"
subtitle="Pick from existing job postings" subtitle="Pick from existing job postings"
icon={<WorkIcon sx={{ color: 'action.active' }} />} icon={<WorkIcon sx={{ color: "action.active" }} />}
description={[ description={[
"Once on the Job Analysis Page, explore a little bit and then select one of the jobs. The requirements and information provided on Backstory are extracted from job postings that users have pasted as a job description or uploaded from a PDF." "Once on the Job Analysis Page, explore a little bit and then select one of the jobs. The requirements and information provided on Backstory are extracted from job postings that users have pasted as a job description or uploaded from a PDF.",
]} ]}
imageSrc={selectAJobPng} imageSrc={selectAJobPng}
imageAlt="Select a job from the available options" imageAlt="Select a job from the available options"
@ -371,9 +425,9 @@ const HowItWorks: React.FC = () => {
stepNumber={3} stepNumber={3}
title="Select a Candidate" title="Select a Candidate"
subtitle="Choose from available profiles" subtitle="Choose from available profiles"
icon={<PersonIcon sx={{ color: 'action.active' }} />} icon={<PersonIcon sx={{ color: "action.active" }} />}
description={[ description={[
"Now that you have a Job selected, you need to select a candidate. In addition to myself (James), there are several candidates which AI has generated. Each has a unique skillset and can be used to test out the system." "Now that you have a Job selected, you need to select a candidate. In addition to myself (James), there are several candidates which AI has generated. Each has a unique skillset and can be used to test out the system.",
]} ]}
imageSrc={selectACandidatePng} imageSrc={selectACandidatePng}
imageAlt="Select a candidate from the available profiles" imageAlt="Select a candidate from the available profiles"
@ -389,11 +443,11 @@ const HowItWorks: React.FC = () => {
stepNumber={4} stepNumber={4}
title="Start Assessment" title="Start Assessment"
subtitle="Begin the AI analysis" subtitle="Begin the AI analysis"
icon={<PlayArrowIcon sx={{ color: 'action.active' }} />} icon={<PlayArrowIcon sx={{ color: "action.active" }} />}
description={[ description={[
"After selecting a candidate, you are ready to have Backstory perform the Job Analysis. During this phase, Backstory will take each of requirements extracted from the Job and match it against information about the selected candidate.", "After selecting a candidate, you are ready to have Backstory perform the Job Analysis. During this phase, Backstory will take each of requirements extracted from the Job and match it against information about the selected candidate.",
"This could be as little as a simple resume, or as complete as a full work history. Backstory performs similarity searches to identify key elements from the candidate that pertain to a given skill and provides a graded response.", "This could be as little as a simple resume, or as complete as a full work history. Backstory performs similarity searches to identify key elements from the candidate that pertain to a given skill and provides a graded response.",
"To see that in action, click the \"Start Skill Assessment\" button." 'To see that in action, click the "Start Skill Assessment" button.',
]} ]}
imageSrc={selectStartAnalysisPng} imageSrc={selectStartAnalysisPng}
imageAlt="Start the skill assessment process" imageAlt="Start the skill assessment process"
@ -409,10 +463,10 @@ const HowItWorks: React.FC = () => {
stepNumber={5} stepNumber={5}
title="Review Results" title="Review Results"
subtitle="Watch the magic happen" subtitle="Watch the magic happen"
icon={<AutoAwesomeIcon sx={{ color: 'action.active' }} />} icon={<AutoAwesomeIcon sx={{ color: "action.active" }} />}
description={[ description={[
"Once you begin that action, the Start Skill Assessment button will grey out and the page will begin updating as it collates information about the candidate. As Backstory performs its magic, you can monitor the progress and explore the different identified skills to see how or why a candidate does or does not have that skill.", "Once you begin that action, the Start Skill Assessment button will grey out and the page will begin updating as it collates information about the candidate. As Backstory performs its magic, you can monitor the progress and explore the different identified skills to see how or why a candidate does or does not have that skill.",
"Once it is done, you can see the final Overall Match. This is a weighted score based on amount of evidence a skill had, whether the skill was required or preferred, and other metrics." "Once it is done, you can see the final Overall Match. This is a weighted score based on amount of evidence a skill had, whether the skill was required or preferred, and other metrics.",
]} ]}
imageSrc={waitPng} imageSrc={waitPng}
imageAlt="Wait for the analysis to complete and review results" imageAlt="Wait for the analysis to complete and review results"
@ -427,10 +481,10 @@ const HowItWorks: React.FC = () => {
stepNumber={6} stepNumber={6}
title="Generate Resume" title="Generate Resume"
subtitle="Create your tailored resume" subtitle="Create your tailored resume"
icon={<DescriptionIcon sx={{ color: 'action.active' }} />} icon={<DescriptionIcon sx={{ color: "action.active" }} />}
description={[ description={[
"The final step is creating the custom resume for the Candidate tailored to the particular Job. On the bottom right you can click \"Next\" to have Backstory generate the custom resume.", 'The final step is creating the custom resume for the Candidate tailored to the particular Job. On the bottom right you can click "Next" to have Backstory generate the custom resume.',
"Note that the resume focuses on identifying key areas from the Candidate's work history that align with skills which were extracted from the original job posting." "Note that the resume focuses on identifying key areas from the Candidate's work history that align with skills which were extracted from the original job posting.",
]} ]}
imageSrc={finalResumePng} imageSrc={finalResumePng}
imageAlt="Generated custom resume tailored to the job" imageAlt="Generated custom resume tailored to the job"
@ -441,21 +495,30 @@ const HowItWorks: React.FC = () => {
</StepSection> </StepSection>
{/* CTA Section */} {/* CTA Section */}
<Box sx={{ <Box
backgroundColor: 'primary.main', sx={{
color: 'primary.contrastText', backgroundColor: "primary.main",
py: 6 color: "primary.contrastText",
}}> py: 6,
}}
>
<Container> <Container>
<Box sx={{ <Box
display: 'flex', sx={{
flexDirection: 'column', display: "flex",
alignItems: 'center', flexDirection: "column",
textAlign: 'center', alignItems: "center",
textAlign: "center",
maxWidth: 600, maxWidth: 600,
mx: 'auto' mx: "auto",
}}> }}
<Typography variant="h3" component="h2" gutterBottom sx={{ color: "white" }}> >
<Typography
variant="h3"
component="h2"
gutterBottom
sx={{ color: "white" }}
>
Ready to try Backstory? Ready to try Backstory?
</Typography> </Typography>
<Typography variant="h6" sx={{ mb: 4 }}> <Typography variant="h6" sx={{ mb: 4 }}>
@ -467,13 +530,13 @@ const HowItWorks: React.FC = () => {
startIcon={<PlayArrowIcon />} startIcon={<PlayArrowIcon />}
onClick={handleGetStarted} onClick={handleGetStarted}
sx={{ sx={{
backgroundColor: 'action.active', backgroundColor: "action.active",
color: 'background.paper', color: "background.paper",
fontWeight: 'bold', fontWeight: "bold",
px: 4, px: 4,
py: 1.5, py: 1.5,
'&:hover': { "&:hover": {
backgroundColor: 'action.active', backgroundColor: "action.active",
opacity: 0.9, opacity: 0.9,
}, },
}} }}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from "react";
import { import {
Box, Box,
Stepper, Stepper,
@ -15,35 +15,38 @@ import {
Avatar, Avatar,
useMediaQuery, useMediaQuery,
Divider, Divider,
} from '@mui/material'; } from "@mui/material";
import { import { Add, WorkOutline } from "@mui/icons-material";
Add, import PersonIcon from "@mui/icons-material/Person";
WorkOutline, import WorkIcon from "@mui/icons-material/Work";
} from '@mui/icons-material'; import AssessmentIcon from "@mui/icons-material/Assessment";
import PersonIcon from '@mui/icons-material/Person'; import { JobMatchAnalysis } from "components/JobMatchAnalysis";
import WorkIcon from '@mui/icons-material/Work';
import AssessmentIcon from '@mui/icons-material/Assessment';
import { JobMatchAnalysis } from 'components/JobMatchAnalysis';
import { Candidate, Job, SkillAssessment } from "types/types"; import { Candidate, Job, SkillAssessment } from "types/types";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { BackstoryPageProps } from 'components/BackstoryTab'; import { BackstoryPageProps } from "components/BackstoryTab";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useAppState, useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext'; import {
import { CandidateInfo } from 'components/ui/CandidateInfo'; useAppState,
import { ComingSoon } from 'components/ui/ComingSoon'; useSelectedCandidate,
import { LoginRequired } from 'components/ui/LoginRequired'; useSelectedJob,
import { Scrollable } from 'components/Scrollable'; } from "hooks/GlobalContext";
import { CandidatePicker } from 'components/ui/CandidatePicker'; import { CandidateInfo } from "components/ui/CandidateInfo";
import { JobPicker } from 'components/ui/JobPicker'; import { ComingSoon } from "components/ui/ComingSoon";
import { JobCreator } from 'components/JobCreator'; import { LoginRequired } from "components/ui/LoginRequired";
import { LoginRestricted } from 'components/ui/LoginRestricted'; import { Scrollable } from "components/Scrollable";
import JsonView from '@uiw/react-json-view'; import { CandidatePicker } from "components/ui/CandidatePicker";
import { ResumeGenerator } from 'components/ResumeGenerator'; import { JobPicker } from "components/ui/JobPicker";
import { JobInfo } from 'components/ui/JobInfo'; import { JobCreator } from "components/JobCreator";
import { LoginRestricted } from "components/ui/LoginRestricted";
import JsonView from "@uiw/react-json-view";
import { ResumeGenerator } from "components/ResumeGenerator";
import { JobInfo } from "components/ui/JobInfo";
function WorkAddIcon() { function WorkAddIcon() {
return ( return (
<Box position="relative" display="inline-flex" <Box
position="relative"
display="inline-flex"
sx={{ sx={{
lineHeight: "30px", lineHeight: "30px",
mb: "6px", mb: "6px",
@ -52,12 +55,12 @@ function WorkAddIcon() {
<WorkOutline sx={{ fontSize: 24 }} /> <WorkOutline sx={{ fontSize: 24 }} />
<Add <Add
sx={{ sx={{
position: 'absolute', position: "absolute",
bottom: -2, bottom: -2,
right: -2, right: -2,
fontSize: 14, fontSize: 14,
bgcolor: 'background.paper', bgcolor: "background.paper",
borderRadius: '50%', borderRadius: "50%",
boxShadow: 1, boxShadow: 1,
}} }}
color="primary" color="primary"
@ -71,7 +74,7 @@ interface AnalysisState {
candidate: Candidate | null; candidate: Candidate | null;
analysis: SkillAssessment[] | null; analysis: SkillAssessment[] | null;
resume: string | null; resume: string | null;
}; }
interface Step { interface Step {
index: number; index: number;
@ -79,7 +82,7 @@ interface Step {
requiredState: string[]; requiredState: string[];
title: string; title: string;
icon: React.ReactNode; icon: React.ReactNode;
}; }
const initialState: AnalysisState = { const initialState: AnalysisState = {
job: null, job: null,
@ -90,18 +93,30 @@ const initialState: AnalysisState = {
// Steps in our process // Steps in our process
const steps: Step[] = [ const steps: Step[] = [
{ requiredState: [], title: 'Job Selection', icon: <WorkIcon /> }, { requiredState: [], title: "Job Selection", icon: <WorkIcon /> },
{ requiredState: ['job'], title: 'Select Candidate', icon: <PersonIcon /> }, { requiredState: ["job"], title: "Select Candidate", icon: <PersonIcon /> },
{ requiredState: ['job', 'candidate'], title: 'Job Analysis', icon: <WorkIcon /> }, {
{ requiredState: ['job', 'candidate', 'analysis'], title: 'Generated Resume', icon: <AssessmentIcon /> } requiredState: ["job", "candidate"],
].map((item, index) => { return { ...item, index, label: item.title.toLowerCase().replace(/ /g, '-') } }); title: "Job Analysis",
icon: <WorkIcon />,
},
{
requiredState: ["job", "candidate", "analysis"],
title: "Generated Resume",
icon: <AssessmentIcon />,
},
].map((item, index) => {
return { ...item, index, label: item.title.toLowerCase().replace(/ /g, "-") };
});
const capitalize = (str: string) => { const capitalize = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1); return str.charAt(0).toUpperCase() + str.slice(1);
} };
// Main component // Main component
const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => { const JobAnalysisPage: React.FC<BackstoryPageProps> = (
props: BackstoryPageProps
) => {
const theme = useTheme(); const theme = useTheme();
const { user, guest } = useAuth(); const { user, guest } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate(); const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
@ -109,35 +124,52 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
const [activeStep, setActiveStep] = useState<Step>(steps[0]); const [activeStep, setActiveStep] = useState<Step>(steps[0]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [jobTab, setJobTab] = useState<string>('select'); const [jobTab, setJobTab] = useState<string>("select");
const [analysisState, setAnalysisState] = useState<AnalysisState | null>(null); const [analysisState, setAnalysisState] = useState<AnalysisState | null>(
null
);
const [canAdvance, setCanAdvance] = useState<boolean>(false); const [canAdvance, setCanAdvance] = useState<boolean>(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const canAccessStep = useCallback((step: Step) => { const canAccessStep = useCallback(
(step: Step) => {
if (!analysisState) { if (!analysisState) {
return; return;
} }
const missing = step.requiredState.find(f => !(analysisState as any)[f]) const missing = step.requiredState.find(
(f) => !(analysisState as any)[f]
);
return missing; return missing;
}, [analysisState]); },
[analysisState]
);
useEffect(() => { useEffect(() => {
if (analysisState !== null) { if (analysisState !== null) {
return; return;
} }
const analysis = { ...initialState, candidate: selectedCandidate, job: selectedJob } const analysis = {
...initialState,
candidate: selectedCandidate,
job: selectedJob,
};
setAnalysisState(analysis); setAnalysisState(analysis);
for (let i = steps.length - 1; i >= 0; i--) { for (let i = steps.length - 1; i >= 0; i--) {
const missing = steps[i].requiredState.find(f => !(analysis as any)[f]) const missing = steps[i].requiredState.find((f) => !(analysis as any)[f]);
if (!missing) { if (!missing) {
setActiveStep(steps[i]); setActiveStep(steps[i]);
return; return;
} }
} }
}, [analysisState, selectedCandidate, selectedJob, setActiveStep, canAccessStep]); }, [
analysisState,
selectedCandidate,
selectedJob,
setActiveStep,
canAccessStep,
]);
useEffect(() => { useEffect(() => {
if (activeStep.index === steps.length - 1) { if (activeStep.index === steps.length - 1) {
@ -188,7 +220,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
return; return;
} }
setActiveStep(steps[step]); setActiveStep(steps[step]);
} };
const onCandidateSelect = (candidate: Candidate) => { const onCandidateSelect = (candidate: Candidate) => {
if (!analysisState) { if (!analysisState) {
@ -198,7 +230,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setAnalysisState({ ...analysisState }); setAnalysisState({ ...analysisState });
setSelectedCandidate(candidate); setSelectedCandidate(candidate);
handleNext(); handleNext();
} };
const onJobSelect = (job: Job) => { const onJobSelect = (job: Job) => {
if (!analysisState) { if (!analysisState) {
@ -208,7 +240,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setAnalysisState({ ...analysisState }); setAnalysisState({ ...analysisState });
setSelectedJob(job); setSelectedJob(job);
handleNext(); handleNext();
} };
// Render function for the candidate selection step // Render function for the candidate selection step
const renderCandidateSelection = () => ( const renderCandidateSelection = () => (
@ -221,28 +253,25 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
// Render function for the job description step // Render function for the job description step
const renderJobDescription = () => { const renderJobDescription = () => {
return (<Box sx={{ mt: 3, width: "100%" }}> return (
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> <Box sx={{ mt: 3, width: "100%" }}>
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 3 }}>
<Tabs value={jobTab} onChange={handleTabChange} centered> <Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value='select' icon={<WorkOutline />} label="Select Job" /> <Tab value="select" icon={<WorkOutline />} label="Select Job" />
<Tab value='create' icon={<WorkAddIcon />} label="Create Job" /> <Tab value="create" icon={<WorkAddIcon />} label="Create Job" />
</Tabs> </Tabs>
</Box> </Box>
{jobTab === 'select' && {jobTab === "select" && <JobPicker onSelect={onJobSelect} />}
<JobPicker onSelect={onJobSelect} /> {jobTab === "create" && user && <JobCreator onSave={onJobSelect} />}
} {jobTab === "create" && guest && (
{jobTab === 'create' && user && <LoginRestricted>
<JobCreator <JobCreator onSave={onJobSelect} />
onSave={onJobSelect} </LoginRestricted>
/>} )}
{jobTab === 'create' && guest &&
<LoginRestricted><JobCreator
onSave={onJobSelect}
/></LoginRestricted>}
</Box> </Box>
); );
} };
const onAnalysisComplete = (skills: SkillAssessment[]) => { const onAnalysisComplete = (skills: SkillAssessment[]) => {
if (!analysisState) { if (!analysisState) {
@ -258,63 +287,93 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
return; return;
} }
if (!analysisState.job || !analysisState.candidate) { if (!analysisState.job || !analysisState.candidate) {
return <Box>{JSON.stringify({ job: analysisState.job, candidate: analysisState.candidate })}</Box> return (
<Box>
{JSON.stringify({
job: analysisState.job,
candidate: analysisState.candidate,
})}
</Box>
);
} }
return (<Box sx={{ mt: 3 }}> return (
<Box sx={{ mt: 3 }}>
<JobMatchAnalysis <JobMatchAnalysis
variant="small" variant="small"
job={analysisState.job} job={analysisState.job}
candidate={analysisState.candidate} candidate={analysisState.candidate}
onAnalysisComplete={onAnalysisComplete} onAnalysisComplete={onAnalysisComplete}
/> />
</Box>); </Box>
);
}; };
const renderResume = () => { const renderResume = () => {
if (!analysisState) { if (!analysisState) {
return; return;
} }
if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) { if (
!analysisState.job ||
!analysisState.candidate ||
!analysisState.analysis
) {
return <></>; return <></>;
} }
return (<Box sx={{ mt: 3 }}> return (
<Box sx={{ mt: 3 }}>
<ResumeGenerator <ResumeGenerator
job={analysisState.job} job={analysisState.job}
candidate={analysisState.candidate} candidate={analysisState.candidate}
skills={analysisState.analysis} skills={analysisState.analysis}
/> />
</Box>); </Box>
);
}; };
return ( return (
<Box sx={{ <Box
display: "flex", flexDirection: "column", sx={{
height: "100%", /* Restrict to main-container's height */ display: "flex",
flexDirection: "column",
height: "100%" /* Restrict to main-container's height */,
width: "100%", width: "100%",
minHeight: 0,/* Prevent flex overflow */ minHeight: 0 /* Prevent flex overflow */,
maxHeight: "min-content", maxHeight: "min-content",
"& > *:not(.Scrollable)": { "& > *:not(.Scrollable)": {
flexShrink: 0, /* Prevent shrinking */ flexShrink: 0 /* Prevent shrinking */,
}, },
position: "relative", position: "relative",
}}> }}
>
<Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0, gap: 1 }}> <Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0, gap: 1 }}>
<Stepper activeStep={activeStep.index} alternativeLabel sx={{ mt: 2, mb: 2 }}> <Stepper
activeStep={activeStep.index}
alternativeLabel
sx={{ mt: 2, mb: 2 }}
>
{steps.map((step, index) => ( {steps.map((step, index) => (
<Step> <Step>
<StepLabel sx={{ cursor: "pointer" }} onClick={() => { moveToStep(index); }} <StepLabel
sx={{ cursor: "pointer" }}
onClick={() => {
moveToStep(index);
}}
slots={{ slots={{
stepIcon: () => ( stepIcon: () => (
<Avatar key={step.index} <Avatar
key={step.index}
sx={{ sx={{
bgcolor: activeStep.index >= step.index ? theme.palette.primary.main : theme.palette.grey[300], bgcolor:
color: 'white' activeStep.index >= step.index
? theme.palette.primary.main
: theme.palette.grey[300],
color: "white",
}} }}
> >
{step.icon} {step.icon}
</Avatar> </Avatar>
) ),
}} }}
> >
{step.title} {step.title}
@ -322,30 +381,43 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
</Step> </Step>
))} ))}
</Stepper> </Stepper>
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}> <Box
{analysisState && analysisState.job && sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}
>
{analysisState && analysisState.job && (
<Box sx={{ display: "flex", flexDirection: "row", width: "100%" }}> <Box sx={{ display: "flex", flexDirection: "row", width: "100%" }}>
{!isMobile && {!isMobile && (
<Avatar <Avatar
sx={{ sx={{
ml: 1, mt: 1, ml: 1,
mt: 1,
bgcolor: theme.palette.primary.main, bgcolor: theme.palette.primary.main,
color: 'white' color: "white",
}} }}
> >
<WorkIcon /> <WorkIcon />
</Avatar> </Avatar>
} )}
<JobInfo variant="minimal" job={analysisState.job} /> <JobInfo variant="minimal" job={analysisState.job} />
</Box> </Box>
} )}
{isMobile && <Box sx={{ display: "flex", borderBottom: "1px solid lightgrey" }} />} {isMobile && (
{!isMobile && <Box sx={{ display: "flex", borderLeft: "1px solid lightgrey" }} />} <Box
{analysisState && analysisState.candidate && sx={{ display: "flex", borderBottom: "1px solid lightgrey" }}
/>
)}
{!isMobile && (
<Box sx={{ display: "flex", borderLeft: "1px solid lightgrey" }} />
)}
{analysisState && analysisState.candidate && (
<Box sx={{ display: "flex", flexDirection: "row", width: "100%" }}> <Box sx={{ display: "flex", flexDirection: "row", width: "100%" }}>
<CandidateInfo variant="minimal" candidate={analysisState.candidate} sx={{}} /> <CandidateInfo
variant="minimal"
candidate={analysisState.candidate}
sx={{}}
/>
</Box> </Box>
} )}
</Box> </Box>
</Paper> </Paper>
<Scrollable <Scrollable
@ -354,17 +426,19 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
position: "relative", position: "relative",
maxHeight: "100%", maxHeight: "100%",
width: "100%", width: "100%",
display: "flex", flexGrow: 1, display: "flex",
flex: 1, /* Take remaining space in some-container */ flexGrow: 1,
overflowY: "auto", /* Scroll if content overflows */ flex: 1 /* Take remaining space in some-container */,
}}> overflowY: "auto" /* Scroll if content overflows */,
{activeStep.label === 'job-selection' && renderJobDescription()} }}
{activeStep.label === 'select-candidate' && renderCandidateSelection()} >
{activeStep.label === 'job-analysis' && renderAnalysis()} {activeStep.label === "job-selection" && renderJobDescription()}
{activeStep.label === 'generated-resume' && renderResume()} {activeStep.label === "select-candidate" && renderCandidateSelection()}
{activeStep.label === "job-analysis" && renderAnalysis()}
{activeStep.label === "generated-resume" && renderResume()}
</Scrollable> </Scrollable>
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}> <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
<Button <Button
color="inherit" color="inherit"
disabled={activeStep.index === steps[0].index} disabled={activeStep.index === steps[0].index}
@ -373,15 +447,25 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
> >
Back Back
</Button> </Button>
<Box sx={{ flex: '1 1 auto' }} /> <Box sx={{ flex: "1 1 auto" }} />
{activeStep.index === steps[steps.length - 1].index ? ( {activeStep.index === steps[steps.length - 1].index ? (
<Button disabled={!canAdvance} onClick={() => { moveToStep(0) }} variant="outlined"> <Button
disabled={!canAdvance}
onClick={() => {
moveToStep(0);
}}
variant="outlined"
>
Start New Analysis Start New Analysis
</Button> </Button>
) : ( ) : (
<Button disabled={!canAdvance} onClick={handleNext} variant="contained"> <Button
{activeStep.index === steps.length - 1 ? 'Done' : 'Next'} disabled={!canAdvance}
onClick={handleNext}
variant="contained"
>
{activeStep.index === steps.length - 1 ? "Done" : "Next"}
</Button> </Button>
)} )}
</Box> </Box>
@ -391,13 +475,18 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
open={!!error} open={!!error}
autoHideDuration={6000} autoHideDuration={6000}
onClose={() => setError(null)} onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert
onClose={() => setError(null)}
severity="error"
sx={{ width: "100%" }}
> >
<Alert onClose={() => setError(null)} severity="error" sx={{ width: '100%' }}>
{error} {error}
</Alert> </Alert>
</Snackbar> </Snackbar>
</Box>); </Box>
);
}; };
export { JobAnalysisPage }; export { JobAnalysisPage };

View File

@ -1,24 +1,31 @@
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import { BackstoryPageProps } from '../components/BackstoryTab'; import { BackstoryPageProps } from "../components/BackstoryTab";
import { Message } from '../components/Message'; import { Message } from "../components/Message";
import { ChatMessage } from 'types/types'; import { ChatMessage } from "types/types";
const LoadingPage = (props: BackstoryPageProps) => { const LoadingPage = (props: BackstoryPageProps) => {
const preamble: ChatMessage = { const preamble: ChatMessage = {
role: 'assistant', role: "assistant",
type: 'text', type: "text",
status: 'done', status: "done",
sessionId: '', sessionId: "",
content: 'Please wait while connecting to Backstory...', content: "Please wait while connecting to Backstory...",
timestamp: new Date(), timestamp: new Date(),
metadata: null as any metadata: null as any,
} };
return <Box sx={{display: "flex", flexGrow: 1, maxWidth: "1024px", margin: "0 auto"}}> return (
<Box
sx={{
display: "flex",
flexGrow: 1,
maxWidth: "1024px",
margin: "0 auto",
}}
>
<Message message={preamble} {...props} /> <Message message={preamble} {...props} />
</Box> </Box>
);
}; };
export { export { LoadingPage };
LoadingPage
};

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Box, Box,
Container, Container,
@ -11,37 +11,37 @@ import {
CardContent, CardContent,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from '@mui/material'; } from "@mui/material";
import { import { Person, PersonAdd } from "@mui/icons-material";
Person, import "react-phone-number-input/style.css";
PersonAdd, import "./LoginPage.css";
} from '@mui/icons-material';
import 'react-phone-number-input/style.css';
import './LoginPage.css';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { BackstoryLogo } from 'components/ui/BackstoryLogo'; import { BackstoryLogo } from "components/ui/BackstoryLogo";
import { BackstoryPageProps } from 'components/BackstoryTab'; import { BackstoryPageProps } from "components/BackstoryTab";
import { LoginForm } from "components/EmailVerificationComponents"; import { LoginForm } from "components/EmailVerificationComponents";
import { CandidateRegistrationForm } from "pages/candidate/RegistrationForms"; import { CandidateRegistrationForm } from "pages/candidate/RegistrationForms";
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from "react-router-dom";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
import * as Types from 'types/types'; import * as Types from "types/types";
const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => { const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [tabValue, setTabValue] = useState<string>('login'); const [tabValue, setTabValue] = useState<string>("login");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const { guest, user, login, isLoading, error } = useAuth(); const { guest, user, login, isLoading, error } = useAuth();
const name = (user?.userType === 'candidate') ? (user as Types.Candidate).username : user?.email || ''; const name =
user?.userType === "candidate"
? (user as Types.Candidate).username
: user?.email || "";
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const showGuest: boolean = false; const showGuest = false;
const { tab } = useParams(); const { tab } = useParams();
useEffect(() => { useEffect(() => {
@ -50,7 +50,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
} }
if (loading && error) { if (loading && error) {
/* Remove 'HTTP .*: ' from error string */ /* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, ''); const jsonStr = error.replace(/^[^{]*/, "");
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message); setErrorMessage(data.error.message);
setSnack(data.error.message, "error"); setSnack(data.error.message, "error");
@ -62,7 +62,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
}, [error, loading]); }, [error, loading]);
useEffect(() => { useEffect(() => {
if (tab === 'register') { if (tab === "register") {
setTabValue(tab); setTabValue(tab);
} }
}, [tab, setTabValue]); }, [tab, setTabValue]);
@ -74,8 +74,8 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
// If user is logged in, navigate to the profile page // If user is logged in, navigate to the profile page
if (user) { if (user) {
navigate('/candidate/profile'); navigate("/candidate/profile");
return (<></>); return <></>;
} }
return ( return (
@ -83,7 +83,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
<BackstoryLogo /> <BackstoryLogo />
{showGuest && guest && ( {showGuest && guest && (
<Card sx={{ mb: 3, bgcolor: 'grey.50' }} elevation={1}> <Card sx={{ mb: 3, bgcolor: "grey.50" }} elevation={1}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom color="primary"> <Typography variant="h6" gutterBottom color="primary">
Guest Session Active Guest Session Active
@ -98,7 +98,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
</Card> </Card>
)} )}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}> <Box sx={{ borderBottom: 1, borderColor: "divider", mb: 2 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered> <Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab value="login" icon={<Person />} label="Login" /> <Tab value="login" icon={<Person />} label="Login" />
<Tab value="register" icon={<PersonAdd />} label="Register" /> <Tab value="register" icon={<PersonAdd />} label="Register" />
@ -117,13 +117,9 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
</Alert> </Alert>
)} )}
{tabValue === "login" && ( {tabValue === "login" && <LoginForm />}
<LoginForm />
)}
{tabValue === "register" && ( {tabValue === "register" && <CandidateRegistrationForm />}
<CandidateRegistrationForm />
)}
</Paper> </Paper>
); );
}; };

View File

@ -1,25 +1,27 @@
import React, { forwardRef, useEffect, useState } from 'react'; import React, { forwardRef, useEffect, useState } from "react";
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from "@mui/material/useMediaQuery";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
import { useTheme } from '@mui/material/styles'; import { useTheme } from "@mui/material/styles";
import MuiMarkdown from 'mui-markdown'; import MuiMarkdown from "mui-markdown";
import { BackstoryPageProps } from '../components/BackstoryTab'; import { BackstoryPageProps } from "../components/BackstoryTab";
import { Conversation, ConversationHandle } from '../components/Conversation'; import { Conversation, ConversationHandle } from "../components/Conversation";
import { BackstoryQuery } from '../components/BackstoryQuery'; import { BackstoryQuery } from "../components/BackstoryQuery";
import { CandidateInfo } from 'components/ui/CandidateInfo'; import { CandidateInfo } from "components/ui/CandidateInfo";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { Candidate } from 'types/types'; import { Candidate } from "types/types";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
import * as Types from 'types/types'; import * as Types from "types/types";
const ChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => { const ChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
(props: BackstoryPageProps, ref) => {
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const { user } = useAuth(); const { user } = useAuth();
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [questions, setQuestions] = useState<React.ReactElement[]>([]); const [questions, setQuestions] = useState<React.ReactElement[]>([]);
const candidate: Candidate | null = user?.userType === 'candidate' ? user as Types.Candidate : null; const candidate: Candidate | null =
user?.userType === "candidate" ? (user as Types.Candidate) : null;
// console.log("ChatPage candidate =>", candidate); // console.log("ChatPage candidate =>", candidate);
useEffect(() => { useEffect(() => {
@ -28,24 +30,30 @@ const ChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: Back
} }
setQuestions([ setQuestions([
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}> <Box
{candidate.questions?.map((q, i: number) => sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}
>
{candidate.questions?.map((q, i: number) => (
<BackstoryQuery key={i} question={q} /> <BackstoryQuery key={i} question={q} />
)} ))}
</Box>, </Box>,
<Box sx={{ p: 1 }}> <Box sx={{ p: 1 }}>
<MuiMarkdown> <MuiMarkdown>
{`As with all LLM interactions, the results may not be 100% accurate. Please contact **${candidate.fullName}** if you have any questions.`} {`As with all LLM interactions, the results may not be 100% accurate. Please contact **${candidate.fullName}** if you have any questions.`}
</MuiMarkdown> </MuiMarkdown>
</Box>]); </Box>,
]);
}, [candidate, isMobile]); }, [candidate, isMobile]);
if (!candidate) { if (!candidate) {
return (<></>); return <></>;
} }
return ( return (
<Box> <Box>
<CandidateInfo candidate={candidate} action="Chat with Backstory AI about " /> <CandidateInfo
candidate={candidate}
action="Chat with Backstory AI about "
/>
<Conversation <Conversation
ref={ref} ref={ref}
{...{ {...{
@ -54,10 +62,11 @@ const ChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: Back
placeholder: `What would you like to know about ${candidate?.firstName}?`, placeholder: `What would you like to know about ${candidate?.firstName}?`,
resetLabel: "chat", resetLabel: "chat",
defaultPrompts: questions, defaultPrompts: questions,
}} /> }}
</Box>); />
}); </Box>
);
}
);
export { export { ChatPage };
ChatPage
};

View File

@ -1,21 +1,21 @@
import React from 'react'; import React from "react";
import { VectorVisualizer } from '../components/VectorVisualizer'; import { VectorVisualizer } from "../components/VectorVisualizer";
import { BackstoryPageProps } from '../components/BackstoryTab'; import { BackstoryPageProps } from "../components/BackstoryTab";
import './VectorVisualizerPage.css'; import "./VectorVisualizerPage.css";
interface VectorVisualizerProps extends BackstoryPageProps { interface VectorVisualizerProps extends BackstoryPageProps {
inline?: boolean; inline?: boolean;
rag?: any; rag?: any;
}; }
const VectorVisualizerPage: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => { const VectorVisualizerPage: React.FC<VectorVisualizerProps> = (
props: VectorVisualizerProps
) => {
return <VectorVisualizer inline={false} {...props} />; return <VectorVisualizer inline={false} {...props} />;
}; };
export type { VectorVisualizerProps }; export type { VectorVisualizerProps };
export { export { VectorVisualizerPage };
VectorVisualizerPage
};

View File

@ -1,4 +1,4 @@
import React from 'react'; import React from "react";
import { import {
Box, Box,
Card, Card,
@ -6,8 +6,8 @@ import {
Typography, Typography,
Button, Button,
LinearProgress, LinearProgress,
Stack Stack,
} from '@mui/material'; } from "@mui/material";
import { import {
Add as AddIcon, Add as AddIcon,
Visibility as VisibilityIcon, Visibility as VisibilityIcon,
@ -15,16 +15,15 @@ import {
ContactMail as ContactMailIcon, ContactMail as ContactMailIcon,
Edit as EditIcon, Edit as EditIcon,
TipsAndUpdates as TipsIcon, TipsAndUpdates as TipsIcon,
} from '@mui/icons-material'; } from "@mui/icons-material";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { LoginRequired } from 'components/ui/LoginRequired'; import { LoginRequired } from "components/ui/LoginRequired";
import { BackstoryElementProps } from 'components/BackstoryTab'; import { BackstoryElementProps } from "components/BackstoryTab";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { ComingSoon } from 'components/ui/ComingSoon'; import { ComingSoon } from "components/ui/ComingSoon";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
interface CandidateDashboardProps extends BackstoryElementProps { type CandidateDashboardProps = BackstoryElementProps;
};
const CandidateDashboard = (props: CandidateDashboardProps) => { const CandidateDashboard = (props: CandidateDashboardProps) => {
const { setSnack } = useAppState(); const { setSnack } = useAppState();
@ -36,19 +35,23 @@ const CandidateDashboard = (props: CandidateDashboardProps) => {
return <LoginRequired asset="candidate dashboard" />; return <LoginRequired asset="candidate dashboard" />;
} }
if (user?.userType !== 'candidate') { if (user?.userType !== "candidate") {
setSnack(`The page you were on is only available for candidates (you are a ${user.userType}`, 'warning'); setSnack(
navigate('/'); `The page you were on is only available for candidates (you are a ${user.userType}`,
return (<></>); "warning"
);
navigate("/");
return <></>;
} }
return (<> return (
<>
{/* Main Content */} {/* Main Content */}
<ComingSoon> <ComingSoon>
<Box sx={{ flex: 1, p: 3 }}> <Box sx={{ flex: 1, p: 3 }}>
{/* Welcome Section */} {/* Welcome Section */}
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold' }}> <Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
Welcome back, {user.firstName}! Welcome back, {user.firstName}!
</Typography> </Typography>
@ -62,9 +65,9 @@ const CandidateDashboard = (props: CandidateDashboardProps) => {
sx={{ sx={{
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
backgroundColor: '#e0e0e0', backgroundColor: "#e0e0e0",
'& .MuiLinearProgress-bar': { "& .MuiLinearProgress-bar": {
backgroundColor: '#4caf50', backgroundColor: "#4caf50",
}, },
}} }}
/> />
@ -74,36 +77,35 @@ const CandidateDashboard = (props: CandidateDashboardProps) => {
variant="contained" variant="contained"
color="primary" color="primary"
sx={{ mt: 1 }} sx={{ mt: 1 }}
onClick={(e) => { e.stopPropagation(); navigate('/candidate/profile'); }} onClick={(e) => {
e.stopPropagation();
navigate("/candidate/profile");
}}
> >
Complete Your Profile Complete Your Profile
</Button> </Button>
</Box> </Box>
{/* Cards Grid */} {/* Cards Grid */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{/* Top Row */} {/* Top Row */}
<Box sx={{ display: 'flex', gap: 3 }}> <Box sx={{ display: "flex", gap: 3 }}>
{/* Resume Builder Card */} {/* Resume Builder Card */}
<Card sx={{ flex: 1, minHeight: 200 }}> <Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent> <CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}> <Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>
Resume Builder Resume Builder
</Typography> </Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#666' }}> <Typography variant="body2" sx={{ mb: 1, color: "#666" }}>
3 custom resumes 3 custom resumes
</Typography> </Typography>
<Typography variant="body2" sx={{ mb: 3, color: '#666' }}> <Typography variant="body2" sx={{ mb: 3, color: "#666" }}>
Last created: May 15, 2025 Last created: May 15, 2025
</Typography> </Typography>
<Button <Button variant="outlined" startIcon={<AddIcon />} fullWidth>
variant="outlined"
startIcon={<AddIcon />}
fullWidth
>
Create New Create New
</Button> </Button>
</CardContent> </CardContent>
@ -112,31 +114,30 @@ const CandidateDashboard = (props: CandidateDashboardProps) => {
{/* Recent Activity Card */} {/* Recent Activity Card */}
<Card sx={{ flex: 1, minHeight: 200 }}> <Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent> <CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}> <Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>
Recent Activity Recent Activity
</Typography> </Typography>
<Stack spacing={1} sx={{ mb: 3 }}> <Stack spacing={1} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<VisibilityIcon sx={{ fontSize: 16, color: '#666' }} /> <VisibilityIcon sx={{ fontSize: 16, color: "#666" }} />
<Typography variant="body2">5 profile views</Typography> <Typography variant="body2">5 profile views</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<DownloadIcon sx={{ fontSize: 16, color: '#666' }} /> <DownloadIcon sx={{ fontSize: 16, color: "#666" }} />
<Typography variant="body2">2 resume downloads</Typography> <Typography variant="body2">
2 resume downloads
</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<ContactMailIcon sx={{ fontSize: 16, color: '#666' }} /> <ContactMailIcon sx={{ fontSize: 16, color: "#666" }} />
<Typography variant="body2">1 direct contact</Typography> <Typography variant="body2">1 direct contact</Typography>
</Box> </Box>
</Stack> </Stack>
<Button <Button variant="outlined" fullWidth>
variant="outlined"
fullWidth
>
View All Activity View All Activity
</Button> </Button>
</CardContent> </CardContent>
@ -144,31 +145,36 @@ const CandidateDashboard = (props: CandidateDashboardProps) => {
</Box> </Box>
{/* Bottom Row */} {/* Bottom Row */}
<Box sx={{ display: 'flex', gap: 3 }}> <Box sx={{ display: "flex", gap: 3 }}>
{/* Complete Your Backstory Card */} {/* Complete Your Backstory Card */}
<Card sx={{ flex: 1, minHeight: 200 }}> <Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent> <CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}> <Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>
Complete Your Backstory Complete Your Backstory
</Typography> </Typography>
<Stack spacing={1} sx={{ mb: 3 }}> <Stack spacing={1} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}> <Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Add projects Add projects
</Typography> </Typography>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}> <Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Detail skills Detail skills
</Typography> </Typography>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}> <Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Work history Work history
</Typography> </Typography>
</Stack> </Stack>
<Button <Button variant="outlined" startIcon={<EditIcon />} fullWidth>
variant="outlined"
startIcon={<EditIcon />}
fullWidth
>
Edit Backstory Edit Backstory
</Button> </Button>
</CardContent> </CardContent>
@ -177,24 +183,26 @@ const CandidateDashboard = (props: CandidateDashboardProps) => {
{/* Improvement Suggestions Card */} {/* Improvement Suggestions Card */}
<Card sx={{ flex: 1, minHeight: 200 }}> <Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent> <CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}> <Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>
Improvement Suggestions Improvement Suggestions
</Typography> </Typography>
<Stack spacing={1} sx={{ mb: 3 }}> <Stack spacing={1} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}> <Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Add certifications Add certifications
</Typography> </Typography>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}> <Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Enhance your project details Enhance your project details
</Typography> </Typography>
</Stack> </Stack>
<Button <Button variant="outlined" startIcon={<TipsIcon />} fullWidth>
variant="outlined"
startIcon={<TipsIcon />}
fullWidth
>
View All Tips View All Tips
</Button> </Button>
</CardContent> </CardContent>

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { import {
Box, Box,
Button, Button,
@ -15,11 +15,11 @@ import {
useMediaQuery, useMediaQuery,
CircularProgress, CircularProgress,
Snackbar, Snackbar,
Alert Alert,
} from '@mui/material'; } from "@mui/material";
import { styled } from '@mui/material/styles'; import { styled } from "@mui/material/styles";
import { CloudUpload, PhotoCamera } from '@mui/icons-material'; import { CloudUpload, PhotoCamera } from "@mui/icons-material";
import { useTheme } from '@mui/material/styles'; import { useTheme } from "@mui/material/styles";
// import { Beta } from '../components/Beta'; // import { Beta } from '../components/Beta';
// Interfaces // Interfaces
@ -34,45 +34,53 @@ interface ProfileFormData {
} }
// Styled components // Styled components
const VisuallyHiddenInput = styled('input')({ const VisuallyHiddenInput = styled("input")({
clip: 'rect(0 0 0 0)', clip: "rect(0 0 0 0)",
clipPath: 'inset(50%)', clipPath: "inset(50%)",
height: 1, height: 1,
overflow: 'hidden', overflow: "hidden",
position: 'absolute', position: "absolute",
bottom: 0, bottom: 0,
left: 0, left: 0,
whiteSpace: 'nowrap', whiteSpace: "nowrap",
width: 1, width: 1,
}); });
const CreateProfilePage: React.FC = () => { const CreateProfilePage: React.FC = () => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
// State management // State management
const [activeStep, setActiveStep] = useState<number>(0); const [activeStep, setActiveStep] = useState<number>(0);
const [profileImage, setProfileImage] = useState<string | null>(null); const [profileImage, setProfileImage] = useState<string | null>(null);
const [resumeFile, setResumeFile] = useState<File | null>(null); const [resumeFile, setResumeFile] = useState<File | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [snackbar, setSnackbar] = useState<{open: boolean, message: string, severity: "success" | "error"}>({ const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
severity: "success" | "error";
}>({
open: false, open: false,
message: '', message: "",
severity: 'success' severity: "success",
}); });
const [formData, setFormData] = useState<ProfileFormData>({ const [formData, setFormData] = useState<ProfileFormData>({
firstName: '', firstName: "",
lastName: '', lastName: "",
email: '', email: "",
phoneNumber: '', phoneNumber: "",
jobTitle: '', jobTitle: "",
location: '', location: "",
bio: '', bio: "",
}); });
// Steps for the profile creation process // Steps for the profile creation process
const steps = ['Personal Information', 'Professional Details', 'Resume Upload']; const steps = [
"Personal Information",
"Professional Details",
"Resume Upload",
];
// Handle form input changes // Handle form input changes
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -103,7 +111,7 @@ const CreateProfilePage: React.FC = () => {
setSnackbar({ setSnackbar({
open: true, open: true,
message: `Resume uploaded: ${e.target.files[0].name}`, message: `Resume uploaded: ${e.target.files[0].name}`,
severity: 'success' severity: "success",
}); });
} }
}; };
@ -130,8 +138,8 @@ const CreateProfilePage: React.FC = () => {
setLoading(false); setLoading(false);
setSnackbar({ setSnackbar({
open: true, open: true,
message: 'Profile created successfully! Redirecting to dashboard...', message: "Profile created successfully! Redirecting to dashboard...",
severity: 'success' severity: "success",
}); });
// Redirect would happen here in a real application // Redirect would happen here in a real application
@ -143,11 +151,13 @@ const CreateProfilePage: React.FC = () => {
const isStepValid = () => { const isStepValid = () => {
switch (activeStep) { switch (activeStep) {
case 0: case 0:
return formData.firstName.trim() !== '' && return (
formData.lastName.trim() !== '' && formData.firstName.trim() !== "" &&
formData.email.trim() !== ''; formData.lastName.trim() !== "" &&
formData.email.trim() !== ""
);
case 1: case 1:
return formData.jobTitle.trim() !== ''; return formData.jobTitle.trim() !== "";
case 2: case 2:
return resumeFile !== null; return resumeFile !== null;
default: default:
@ -161,15 +171,21 @@ const CreateProfilePage: React.FC = () => {
case 0: case 0:
return ( return (
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{xs: 12}} sx={{ textAlign: 'center', mb: 2 }}> <Grid size={{ xs: 12 }} sx={{ textAlign: "center", mb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}> <Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar <Avatar
src={profileImage || ''} src={profileImage || ""}
sx={{ sx={{
width: 120, width: 120,
height: 120, height: 120,
mb: 2, mb: 2,
border: `2px solid ${theme.palette.primary.main}` border: `2px solid ${theme.palette.primary.main}`,
}} }}
/> />
<IconButton <IconButton
@ -280,10 +296,11 @@ const CreateProfilePage: React.FC = () => {
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<Typography variant="body1" component="p"> <Typography variant="body1" component="p">
Upload your resume to complete your profile. We'll analyze it to better understand your skills and experience. Upload your resume to complete your profile. We'll analyze it to
(Supported formats: .pdf, .docx, .md, and .txt) better understand your skills and experience. (Supported
formats: .pdf, .docx, .md, and .txt)
</Typography> </Typography>
<Box sx={{ textAlign: 'center', mt: 2 }}> <Box sx={{ textAlign: "center", mt: 2 }}>
<Button <Button
component="label" component="label"
variant="contained" variant="contained"
@ -299,7 +316,11 @@ const CreateProfilePage: React.FC = () => {
</Button> </Button>
{resumeFile && ( {resumeFile && (
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}> <Typography
variant="body2"
color="textSecondary"
sx={{ mt: 1 }}
>
File uploaded: {resumeFile.name} File uploaded: {resumeFile.name}
</Typography> </Typography>
)} )}
@ -308,7 +329,7 @@ const CreateProfilePage: React.FC = () => {
</Grid> </Grid>
); );
default: default:
return 'Unknown step'; return "Unknown step";
} }
}; };
@ -329,7 +350,7 @@ const CreateProfilePage: React.FC = () => {
<Stepper <Stepper
activeStep={activeStep} activeStep={activeStep}
alternativeLabel={!isMobile} alternativeLabel={!isMobile}
orientation={isMobile ? 'vertical' : 'horizontal'} orientation={isMobile ? "vertical" : "horizontal"}
sx={{ mt: 3, mb: 5 }} sx={{ mt: 3, mb: 5 }}
> >
{steps.map((label) => ( {steps.map((label) => (
@ -339,11 +360,9 @@ const CreateProfilePage: React.FC = () => {
))} ))}
</Stepper> </Stepper>
<Box sx={{ mt: 2, mb: 4 }}> <Box sx={{ mt: 2, mb: 4 }}>{getStepContent(activeStep)}</Box>
{getStepContent(activeStep)}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}> <Box sx={{ display: "flex", justifyContent: "space-between", mt: 4 }}>
<Button <Button
disabled={activeStep === 0} disabled={activeStep === 0}
onClick={handleBack} onClick={handleBack}
@ -355,9 +374,11 @@ const CreateProfilePage: React.FC = () => {
variant="contained" variant="contained"
onClick={handleNext} onClick={handleNext}
disabled={!isStepValid() || loading} disabled={!isStepValid() || loading}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : null} startIcon={
loading ? <CircularProgress size={20} color="inherit" /> : null
}
> >
{activeStep === steps.length - 1 ? 'Create Profile' : 'Next'} {activeStep === steps.length - 1 ? "Create Profile" : "Next"}
</Button> </Button>
</Box> </Box>
</Paper> </Paper>
@ -370,7 +391,7 @@ const CreateProfilePage: React.FC = () => {
<Alert <Alert
onClose={() => setSnackbar({ ...snackbar, open: false })} onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity} severity={snackbar.severity}
sx={{ width: '100%' }} sx={{ width: "100%" }}
> >
{snackbar.message} {snackbar.message}
</Alert> </Alert>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { import {
Paper, Paper,
Box, Box,
@ -21,29 +21,29 @@ import {
useMediaQuery, useMediaQuery,
useTheme, useTheme,
IconButton, IconButton,
InputAdornment InputAdornment,
} from '@mui/material'; } from "@mui/material";
import { Visibility, VisibilityOff } from '@mui/icons-material'; import { Visibility, VisibilityOff } from "@mui/icons-material";
import { ApiClient } from 'services/api-client'; import { ApiClient } from "services/api-client";
import { RegistrationSuccessDialog } from 'components/EmailVerificationComponents'; import { RegistrationSuccessDialog } from "components/EmailVerificationComponents";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
// Candidate Registration Form // Candidate Registration Form
const CandidateRegistrationForm = () => { const CandidateRegistrationForm = () => {
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: '', email: "",
username: '', username: "",
password: '', password: "",
confirmPassword: '', confirmPassword: "",
firstName: '', firstName: "",
lastName: '', lastName: "",
phone: '' phone: "",
}); });
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
@ -60,46 +60,50 @@ const CandidateRegistrationForm = () => {
// Email validation // Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email) { if (!formData.email) {
newErrors.email = 'Email is required'; newErrors.email = "Email is required";
} else if (!emailRegex.test(formData.email)) { } else if (!emailRegex.test(formData.email)) {
newErrors.email = 'Please enter a valid email address'; newErrors.email = "Please enter a valid email address";
} }
// Username validation // Username validation
if (!formData.username) { if (!formData.username) {
newErrors.username = 'Username is required'; newErrors.username = "Username is required";
} else if (formData.username.length < 3) { } else if (formData.username.length < 3) {
newErrors.username = 'Username must be at least 3 characters'; newErrors.username = "Username must be at least 3 characters";
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) { } else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
newErrors.username = 'Username can only contain letters, numbers, and underscores'; newErrors.username =
"Username can only contain letters, numbers, and underscores";
} }
// Password validation // Password validation
if (!formData.password) { if (!formData.password) {
newErrors.password = 'Password is required'; newErrors.password = "Password is required";
} else { } else {
const passwordErrors = validatePassword(formData.password); const passwordErrors = validatePassword(formData.password);
if (passwordErrors.length > 0) { if (passwordErrors.length > 0) {
newErrors.password = passwordErrors.join(', '); newErrors.password = passwordErrors.join(", ");
} }
} }
// Confirm password // Confirm password
if (formData.password !== formData.confirmPassword) { if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match'; newErrors.confirmPassword = "Passwords do not match";
} }
// Name validation // Name validation
if (!formData.firstName.trim()) { if (!formData.firstName.trim()) {
newErrors.firstName = 'First name is required'; newErrors.firstName = "First name is required";
} }
if (!formData.lastName.trim()) { if (!formData.lastName.trim()) {
newErrors.lastName = 'Last name is required'; newErrors.lastName = "Last name is required";
} }
// Phone validation (optional but must be valid if provided) // Phone validation (optional but must be valid if provided)
if (formData.phone && !/^[+]?[1-9][\d]{0,15}$/.test(formData.phone.replace(/\s/g, ''))) { if (
newErrors.phone = 'Please enter a valid phone number'; formData.phone &&
!/^[+]?[1-9][\d]{0,15}$/.test(formData.phone.replace(/\s/g, ""))
) {
newErrors.phone = "Please enter a valid phone number";
} }
setErrors(newErrors); setErrors(newErrors);
@ -110,30 +114,32 @@ const CandidateRegistrationForm = () => {
const errors: string[] = []; const errors: string[] = [];
if (password.length < 8) { if (password.length < 8) {
errors.push('at least 8 characters'); errors.push("at least 8 characters");
} }
if (!/[a-z]/.test(password)) { if (!/[a-z]/.test(password)) {
errors.push('one lowercase letter'); errors.push("one lowercase letter");
} }
if (!/[A-Z]/.test(password)) { if (!/[A-Z]/.test(password)) {
errors.push('one uppercase letter'); errors.push("one uppercase letter");
} }
if (!/\d/.test(password)) { if (!/\d/.test(password)) {
errors.push('one number'); errors.push("one number");
} }
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) { if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
errors.push('one special character'); errors.push("one special character");
} }
return errors.length > 0 ? [`Password must contain ${errors.join(', ')}`] : []; return errors.length > 0
? [`Password must contain ${errors.join(", ")}`]
: [];
}; };
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when user starts typing // Clear error when user starts typing
if (errors[field]) { if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' })); setErrors((prev) => ({ ...prev, [field]: "" }));
} }
}; };
@ -150,7 +156,7 @@ const CandidateRegistrationForm = () => {
password: formData.password, password: formData.password,
firstName: formData.firstName, firstName: formData.firstName,
lastName: formData.lastName, lastName: formData.lastName,
phone: formData.phone || undefined phone: formData.phone || undefined,
}); });
// Set pending verification // Set pending verification
@ -158,16 +164,17 @@ const CandidateRegistrationForm = () => {
setRegistrationResult(result); setRegistrationResult(result);
setShowSuccess(true); setShowSuccess(true);
} catch (error: any) { } catch (error: any) {
if (error.message.includes('already exists')) { if (error.message.includes("already exists")) {
if (error.message.includes('email')) { if (error.message.includes("email")) {
setErrors({ email: 'An account with this email already exists' }); setErrors({ email: "An account with this email already exists" });
} else if (error.message.includes('username')) { } else if (error.message.includes("username")) {
setErrors({ username: 'This username is already taken' }); setErrors({ username: "This username is already taken" });
} }
} else { } else {
setErrors({ general: error.message || 'Registration failed. Please try again.' }); setErrors({
general: error.message || "Registration failed. Please try again.",
});
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -180,21 +187,23 @@ const CandidateRegistrationForm = () => {
/[a-z]/.test(password), /[a-z]/.test(password),
/[A-Z]/.test(password), /[A-Z]/.test(password),
/\d/.test(password), /\d/.test(password),
/[!@#$%^&*(),.?":{}|<>]/.test(password) /[!@#$%^&*(),.?":{}|<>]/.test(password),
]; ];
const strength = validations.filter(Boolean).length; const strength = validations.filter(Boolean).length;
if (strength < 2) return { level: 'weak', color: 'error', value: 20 }; if (strength < 2) return { level: "weak", color: "error", value: 20 };
if (strength < 4) return { level: 'medium', color: 'warning', value: 60 }; if (strength < 4) return { level: "medium", color: "warning", value: 60 };
return { level: 'strong', color: 'success', value: 100 }; return { level: "strong", color: "success", value: 100 };
}; };
const passwordStrength = formData.password ? getPasswordStrength(formData.password) : null; const passwordStrength = formData.password
? getPasswordStrength(formData.password)
: null;
return ( return (
<Box sx={{ p: isMobile ? 1 : 5 }}> <Box sx={{ p: isMobile ? 1 : 5 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}> <Box sx={{ textAlign: "center", mb: 4 }}>
<Typography variant="h4" component="h1" sx={{ mb: 1 }}> <Typography variant="h4" component="h1" sx={{ mb: 1 }}>
Join as a Candidate Join as a Candidate
</Typography> </Typography>
@ -209,7 +218,7 @@ const CandidateRegistrationForm = () => {
label="Email Address" label="Email Address"
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)} onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="your.email@example.com" placeholder="your.email@example.com"
error={!!errors.email} error={!!errors.email}
helperText={errors.email} helperText={errors.email}
@ -220,7 +229,9 @@ const CandidateRegistrationForm = () => {
fullWidth fullWidth
label="Username" label="Username"
value={formData.username} value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value.toLowerCase())} onChange={(e) =>
handleInputChange("username", e.target.value.toLowerCase())
}
placeholder="johndoe123" placeholder="johndoe123"
error={!!errors.username} error={!!errors.username}
helperText={errors.username} helperText={errors.username}
@ -232,7 +243,7 @@ const CandidateRegistrationForm = () => {
fullWidth fullWidth
label="First Name" label="First Name"
value={formData.firstName} value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)} onChange={(e) => handleInputChange("firstName", e.target.value)}
placeholder="John" placeholder="John"
error={!!errors.firstName} error={!!errors.firstName}
helperText={errors.firstName} helperText={errors.firstName}
@ -242,7 +253,7 @@ const CandidateRegistrationForm = () => {
fullWidth fullWidth
label="Last Name" label="Last Name"
value={formData.lastName} value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)} onChange={(e) => handleInputChange("lastName", e.target.value)}
placeholder="Doe" placeholder="Doe"
error={!!errors.lastName} error={!!errors.lastName}
helperText={errors.lastName} helperText={errors.lastName}
@ -255,7 +266,7 @@ const CandidateRegistrationForm = () => {
label="Phone Number" label="Phone Number"
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)} onChange={(e) => handleInputChange("phone", e.target.value)}
placeholder="+1 (555) 123-4567" placeholder="+1 (555) 123-4567"
error={!!errors.phone} error={!!errors.phone}
helperText={errors.phone || "Optional"} helperText={errors.phone || "Optional"}
@ -265,9 +276,9 @@ const CandidateRegistrationForm = () => {
<TextField <TextField
fullWidth fullWidth
label="Password" label="Password"
type={showPassword ? 'text' : 'password'} type={showPassword ? "text" : "password"}
value={formData.password} value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)} onChange={(e) => handleInputChange("password", e.target.value)}
placeholder="Create a strong password" placeholder="Create a strong password"
error={!!errors.password} error={!!errors.password}
helperText={errors.password} helperText={errors.password}
@ -295,7 +306,11 @@ const CandidateRegistrationForm = () => {
color={passwordStrength.color as any} color={passwordStrength.color as any}
sx={{ height: 6, borderRadius: 3 }} sx={{ height: 6, borderRadius: 3 }}
/> />
<Typography variant="caption" color={`${passwordStrength.color}.main`} sx={{ mt: 0.5, display: 'block', textTransform: 'capitalize' }}> <Typography
variant="caption"
color={`${passwordStrength.color}.main`}
sx={{ mt: 0.5, display: "block", textTransform: "capitalize" }}
>
Password strength: {passwordStrength.level} Password strength: {passwordStrength.level}
</Typography> </Typography>
</Box> </Box>
@ -305,9 +320,9 @@ const CandidateRegistrationForm = () => {
<TextField <TextField
fullWidth fullWidth
label="Confirm Password" label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'} type={showConfirmPassword ? "text" : "password"}
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)} onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
placeholder="Confirm your password" placeholder="Confirm your password"
error={!!errors.confirmPassword} error={!!errors.confirmPassword}
helperText={errors.confirmPassword} helperText={errors.confirmPassword}
@ -328,11 +343,7 @@ const CandidateRegistrationForm = () => {
}} }}
/> />
{errors.general && ( {errors.general && <Alert severity="error">{errors.general}</Alert>}
<Alert severity="error">
{errors.general}
</Alert>
)}
<Button <Button
fullWidth fullWidth
@ -348,16 +359,19 @@ const CandidateRegistrationForm = () => {
<Typography>Creating Account...</Typography> <Typography>Creating Account...</Typography>
</Stack> </Stack>
) : ( ) : (
'Create Account' "Create Account"
)} )}
</Button> </Button>
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: "center" }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Already have an account?{' '} Already have an account?{" "}
<Link <Link
component="button" component="button"
onClick={(e) => { e.preventDefault(); navigate('/login'); }} onClick={(e) => {
e.preventDefault();
navigate("/login");
}}
sx={{ fontWeight: 600 }} sx={{ fontWeight: 600 }}
> >
Sign in here Sign in here
@ -380,16 +394,16 @@ const CandidateRegistrationForm = () => {
// Employer Registration Form // Employer Registration Form
const EmployerRegistrationForm = () => { const EmployerRegistrationForm = () => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: '', email: "",
username: '', username: "",
password: '', password: "",
confirmPassword: '', confirmPassword: "",
companyName: '', companyName: "",
industry: '', industry: "",
companySize: '', companySize: "",
companyDescription: '', companyDescription: "",
websiteUrl: '', websiteUrl: "",
phone: '' phone: "",
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -404,13 +418,26 @@ const EmployerRegistrationForm = () => {
const apiClient = new ApiClient(); const apiClient = new ApiClient();
const industryOptions = [ const industryOptions = [
'Technology', 'Healthcare', 'Finance', 'Education', 'Manufacturing', "Technology",
'Retail', 'Consulting', 'Media', 'Non-profit', 'Government', 'Other' "Healthcare",
"Finance",
"Education",
"Manufacturing",
"Retail",
"Consulting",
"Media",
"Non-profit",
"Government",
"Other",
]; ];
const companySizeOptions = [ const companySizeOptions = [
'1-10 employees', '11-50 employees', '51-200 employees', "1-10 employees",
'201-500 employees', '501-1000 employees', '1000+ employees' "11-50 employees",
"51-200 employees",
"201-500 employees",
"501-1000 employees",
"1000+ employees",
]; ];
const validateForm = () => { const validateForm = () => {
@ -419,47 +446,48 @@ const EmployerRegistrationForm = () => {
// Email validation // Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email) { if (!formData.email) {
newErrors.email = 'Email is required'; newErrors.email = "Email is required";
} else if (!emailRegex.test(formData.email)) { } else if (!emailRegex.test(formData.email)) {
newErrors.email = 'Please enter a valid email address'; newErrors.email = "Please enter a valid email address";
} }
// Username validation // Username validation
if (!formData.username) { if (!formData.username) {
newErrors.username = 'Username is required'; newErrors.username = "Username is required";
} else if (formData.username.length < 3) { } else if (formData.username.length < 3) {
newErrors.username = 'Username must be at least 3 characters'; newErrors.username = "Username must be at least 3 characters";
} }
// Password validation // Password validation
if (!formData.password) { if (!formData.password) {
newErrors.password = 'Password is required'; newErrors.password = "Password is required";
} else { } else {
const passwordErrors = validatePassword(formData.password); const passwordErrors = validatePassword(formData.password);
if (passwordErrors.length > 0) { if (passwordErrors.length > 0) {
newErrors.password = passwordErrors.join(', '); newErrors.password = passwordErrors.join(", ");
} }
} }
// Confirm password // Confirm password
if (formData.password !== formData.confirmPassword) { if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match'; newErrors.confirmPassword = "Passwords do not match";
} }
// Company validation // Company validation
if (!formData.companyName.trim()) { if (!formData.companyName.trim()) {
newErrors.companyName = 'Company name is required'; newErrors.companyName = "Company name is required";
} }
if (!formData.industry) { if (!formData.industry) {
newErrors.industry = 'Industry is required'; newErrors.industry = "Industry is required";
} }
if (!formData.companySize) { if (!formData.companySize) {
newErrors.companySize = 'Company size is required'; newErrors.companySize = "Company size is required";
} }
if (!formData.companyDescription.trim()) { if (!formData.companyDescription.trim()) {
newErrors.companyDescription = 'Company description is required'; newErrors.companyDescription = "Company description is required";
} else if (formData.companyDescription.length < 50) { } else if (formData.companyDescription.length < 50) {
newErrors.companyDescription = 'Company description must be at least 50 characters'; newErrors.companyDescription =
"Company description must be at least 50 characters";
} }
// Website URL validation (optional but must be valid if provided) // Website URL validation (optional but must be valid if provided)
@ -467,7 +495,7 @@ const EmployerRegistrationForm = () => {
try { try {
new URL(formData.websiteUrl); new URL(formData.websiteUrl);
} catch { } catch {
newErrors.websiteUrl = 'Please enter a valid website URL'; newErrors.websiteUrl = "Please enter a valid website URL";
} }
} }
@ -479,30 +507,32 @@ const EmployerRegistrationForm = () => {
const errors: string[] = []; const errors: string[] = [];
if (password.length < 8) { if (password.length < 8) {
errors.push('at least 8 characters'); errors.push("at least 8 characters");
} }
if (!/[a-z]/.test(password)) { if (!/[a-z]/.test(password)) {
errors.push('one lowercase letter'); errors.push("one lowercase letter");
} }
if (!/[A-Z]/.test(password)) { if (!/[A-Z]/.test(password)) {
errors.push('one uppercase letter'); errors.push("one uppercase letter");
} }
if (!/\d/.test(password)) { if (!/\d/.test(password)) {
errors.push('one number'); errors.push("one number");
} }
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) { if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
errors.push('one special character'); errors.push("one special character");
} }
return errors.length > 0 ? [`Password must contain ${errors.join(', ')}`] : []; return errors.length > 0
? [`Password must contain ${errors.join(", ")}`]
: [];
}; };
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when user starts typing // Clear error when user starts typing
if (errors[field]) { if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' })); setErrors((prev) => ({ ...prev, [field]: "" }));
} }
}; };
@ -522,7 +552,7 @@ const EmployerRegistrationForm = () => {
companySize: formData.companySize, companySize: formData.companySize,
companyDescription: formData.companyDescription, companyDescription: formData.companyDescription,
websiteUrl: formData.websiteUrl || undefined, websiteUrl: formData.websiteUrl || undefined,
phone: formData.phone || undefined phone: formData.phone || undefined,
}); });
// Set pending verification // Set pending verification
@ -530,16 +560,17 @@ const EmployerRegistrationForm = () => {
setRegistrationResult(result); setRegistrationResult(result);
setShowSuccess(true); setShowSuccess(true);
} catch (error: any) { } catch (error: any) {
if (error.message.includes('already exists')) { if (error.message.includes("already exists")) {
if (error.message.includes('email')) { if (error.message.includes("email")) {
setErrors({ email: 'An account with this email already exists' }); setErrors({ email: "An account with this email already exists" });
} else if (error.message.includes('username')) { } else if (error.message.includes("username")) {
setErrors({ username: 'This username is already taken' }); setErrors({ username: "This username is already taken" });
} }
} else { } else {
setErrors({ general: error.message || 'Registration failed. Please try again.' }); setErrors({
general: error.message || "Registration failed. Please try again.",
});
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -549,7 +580,7 @@ const EmployerRegistrationForm = () => {
return ( return (
<Paper elevation={3}> <Paper elevation={3}>
<Box sx={{ p: 5 }}> <Box sx={{ p: 5 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}> <Box sx={{ textAlign: "center", mb: 4 }}>
<Typography variant="h4" component="h1" sx={{ mb: 1 }}> <Typography variant="h4" component="h1" sx={{ mb: 1 }}>
Join as an Employer Join as an Employer
</Typography> </Typography>
@ -560,7 +591,7 @@ const EmployerRegistrationForm = () => {
<Stack spacing={4}> <Stack spacing={4}>
{/* Account Information Section */} {/* Account Information Section */}
<Box sx={{ bgcolor: 'grey.50', p: 3, borderRadius: 2 }}> <Box sx={{ bgcolor: "grey.50", p: 3, borderRadius: 2 }}>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Account Information Account Information
</Typography> </Typography>
@ -572,7 +603,7 @@ const EmployerRegistrationForm = () => {
label="Email Address" label="Email Address"
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)} onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="company@example.com" placeholder="company@example.com"
error={!!errors.email} error={!!errors.email}
helperText={errors.email} helperText={errors.email}
@ -582,7 +613,9 @@ const EmployerRegistrationForm = () => {
fullWidth fullWidth
label="Username" label="Username"
value={formData.username} value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value.toLowerCase())} onChange={(e) =>
handleInputChange("username", e.target.value.toLowerCase())
}
placeholder="company123" placeholder="company123"
error={!!errors.username} error={!!errors.username}
helperText={errors.username} helperText={errors.username}
@ -594,9 +627,11 @@ const EmployerRegistrationForm = () => {
<TextField <TextField
fullWidth fullWidth
label="Password" label="Password"
type={showPassword ? 'text' : 'password'} type={showPassword ? "text" : "password"}
value={formData.password} value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)} onChange={(e) =>
handleInputChange("password", e.target.value)
}
placeholder="Create a strong password" placeholder="Create a strong password"
error={!!errors.password} error={!!errors.password}
helperText={errors.password} helperText={errors.password}
@ -619,9 +654,11 @@ const EmployerRegistrationForm = () => {
<TextField <TextField
fullWidth fullWidth
label="Confirm Password" label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'} type={showConfirmPassword ? "text" : "password"}
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)} onChange={(e) =>
handleInputChange("confirmPassword", e.target.value)
}
placeholder="Confirm your password" placeholder="Confirm your password"
error={!!errors.confirmPassword} error={!!errors.confirmPassword}
helperText={errors.confirmPassword} helperText={errors.confirmPassword}
@ -631,11 +668,17 @@ const EmployerRegistrationForm = () => {
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton <IconButton
aria-label="toggle confirm password visibility" aria-label="toggle confirm password visibility"
onClick={() => setShowConfirmPassword(!showConfirmPassword)} onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
edge="end" edge="end"
> >
{showConfirmPassword ? <VisibilityOff /> : <Visibility />} {showConfirmPassword ? (
<VisibilityOff />
) : (
<Visibility />
)}
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
), ),
@ -646,7 +689,7 @@ const EmployerRegistrationForm = () => {
</Box> </Box>
{/* Company Information Section */} {/* Company Information Section */}
<Box sx={{ bgcolor: 'primary.50', p: 3, borderRadius: 2 }}> <Box sx={{ bgcolor: "primary.50", p: 3, borderRadius: 2 }}>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Company Information Company Information
</Typography> </Typography>
@ -656,7 +699,9 @@ const EmployerRegistrationForm = () => {
fullWidth fullWidth
label="Company Name" label="Company Name"
value={formData.companyName} value={formData.companyName}
onChange={(e) => handleInputChange('companyName', e.target.value)} onChange={(e) =>
handleInputChange("companyName", e.target.value)
}
placeholder="Your Company Inc." placeholder="Your Company Inc."
error={!!errors.companyName} error={!!errors.companyName}
helperText={errors.companyName} helperText={errors.companyName}
@ -668,28 +713,40 @@ const EmployerRegistrationForm = () => {
<InputLabel>Industry</InputLabel> <InputLabel>Industry</InputLabel>
<Select <Select
value={formData.industry} value={formData.industry}
onChange={(e) => handleInputChange('industry', e.target.value)} onChange={(e) =>
handleInputChange("industry", e.target.value)
}
label="Industry" label="Industry"
> >
{industryOptions.map(industry => ( {industryOptions.map((industry) => (
<MenuItem key={industry} value={industry}>{industry}</MenuItem> <MenuItem key={industry} value={industry}>
{industry}
</MenuItem>
))} ))}
</Select> </Select>
{errors.industry && <FormHelperText>{errors.industry}</FormHelperText>} {errors.industry && (
<FormHelperText>{errors.industry}</FormHelperText>
)}
</FormControl> </FormControl>
<FormControl fullWidth error={!!errors.companySize} required> <FormControl fullWidth error={!!errors.companySize} required>
<InputLabel>Company Size</InputLabel> <InputLabel>Company Size</InputLabel>
<Select <Select
value={formData.companySize} value={formData.companySize}
onChange={(e) => handleInputChange('companySize', e.target.value)} onChange={(e) =>
handleInputChange("companySize", e.target.value)
}
label="Company Size" label="Company Size"
> >
{companySizeOptions.map(size => ( {companySizeOptions.map((size) => (
<MenuItem key={size} value={size}>{size}</MenuItem> <MenuItem key={size} value={size}>
{size}
</MenuItem>
))} ))}
</Select> </Select>
{errors.companySize && <FormHelperText>{errors.companySize}</FormHelperText>} {errors.companySize && (
<FormHelperText>{errors.companySize}</FormHelperText>
)}
</FormControl> </FormControl>
</Stack> </Stack>
@ -700,10 +757,15 @@ const EmployerRegistrationForm = () => {
multiline multiline
rows={4} rows={4}
value={formData.companyDescription} value={formData.companyDescription}
onChange={(e) => handleInputChange('companyDescription', e.target.value)} onChange={(e) =>
handleInputChange("companyDescription", e.target.value)
}
placeholder="Tell us about your company, what you do, your mission, and what makes you unique..." placeholder="Tell us about your company, what you do, your mission, and what makes you unique..."
error={!!errors.companyDescription} error={!!errors.companyDescription}
helperText={errors.companyDescription || `${formData.companyDescription.length}/50 characters minimum`} helperText={
errors.companyDescription ||
`${formData.companyDescription.length}/50 characters minimum`
}
required required
/> />
</Box> </Box>
@ -714,7 +776,9 @@ const EmployerRegistrationForm = () => {
label="Website URL" label="Website URL"
type="url" type="url"
value={formData.websiteUrl} value={formData.websiteUrl}
onChange={(e) => handleInputChange('websiteUrl', e.target.value)} onChange={(e) =>
handleInputChange("websiteUrl", e.target.value)
}
placeholder="https://www.yourcompany.com" placeholder="https://www.yourcompany.com"
error={!!errors.websiteUrl} error={!!errors.websiteUrl}
helperText={errors.websiteUrl || "Optional"} helperText={errors.websiteUrl || "Optional"}
@ -724,7 +788,7 @@ const EmployerRegistrationForm = () => {
label="Phone Number" label="Phone Number"
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)} onChange={(e) => handleInputChange("phone", e.target.value)}
placeholder="+1 (555) 123-4567" placeholder="+1 (555) 123-4567"
error={!!errors.phone} error={!!errors.phone}
helperText={errors.phone || "Optional"} helperText={errors.phone || "Optional"}
@ -733,11 +797,7 @@ const EmployerRegistrationForm = () => {
</Stack> </Stack>
</Box> </Box>
{errors.general && ( {errors.general && <Alert severity="error">{errors.general}</Alert>}
<Alert severity="error">
{errors.general}
</Alert>
)}
<Button <Button
fullWidth fullWidth
@ -753,13 +813,13 @@ const EmployerRegistrationForm = () => {
<Typography>Creating Company Account...</Typography> <Typography>Creating Company Account...</Typography>
</Stack> </Stack>
) : ( ) : (
'Create Company Account' "Create Company Account"
)} )}
</Button> </Button>
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: "center" }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Already have an account?{' '} Already have an account?{" "}
<Link href="/login" sx={{ fontWeight: 600 }}> <Link href="/login" sx={{ fontWeight: 600 }}>
Sign in here Sign in here
</Link> </Link>
@ -785,7 +845,7 @@ export function RegistrationTypeSelector() {
return ( return (
<Paper elevation={3}> <Paper elevation={3}>
<Box sx={{ p: 5 }}> <Box sx={{ p: 5 }}>
<Box sx={{ textAlign: 'center', mb: 5 }}> <Box sx={{ textAlign: "center", mb: 5 }}>
<Typography variant="h3" component="h1" sx={{ mb: 2 }}> <Typography variant="h3" component="h1" sx={{ mb: 2 }}>
Join Backstory Join Backstory
</Typography> </Typography>
@ -799,19 +859,21 @@ export function RegistrationTypeSelector() {
<Card <Card
sx={{ sx={{
flex: 1, flex: 1,
cursor: 'pointer', cursor: "pointer",
transition: 'all 0.3s ease', transition: "all 0.3s ease",
border: '2px solid transparent', border: "2px solid transparent",
'&:hover': { "&:hover": {
transform: 'translateY(-4px)', transform: "translateY(-4px)",
boxShadow: 6, boxShadow: 6,
borderColor: 'primary.main' borderColor: "primary.main",
} },
}} }}
onClick={() => window.location.href = '/register/candidate'} onClick={() => (window.location.href = "/register/candidate")}
> >
<CardContent sx={{ textAlign: 'center', py: 4 }}> <CardContent sx={{ textAlign: "center", py: 4 }}>
<Typography variant="h1" sx={{ mb: 2 }}>👤</Typography> <Typography variant="h1" sx={{ mb: 2 }}>
👤
</Typography>
<Typography variant="h5" component="h3" sx={{ mb: 1.5 }}> <Typography variant="h5" component="h3" sx={{ mb: 1.5 }}>
I'm looking for work I'm looking for work
</Typography> </Typography>
@ -819,7 +881,7 @@ export function RegistrationTypeSelector() {
Create a candidate profile to find your next opportunity Create a candidate profile to find your next opportunity
</Typography> </Typography>
</CardContent> </CardContent>
<CardActions sx={{ justifyContent: 'center', pb: 3 }}> <CardActions sx={{ justifyContent: "center", pb: 3 }}>
<Button variant="contained" size="large"> <Button variant="contained" size="large">
Join as Candidate Join as Candidate
</Button> </Button>
@ -830,19 +892,21 @@ export function RegistrationTypeSelector() {
<Card <Card
sx={{ sx={{
flex: 1, flex: 1,
cursor: 'pointer', cursor: "pointer",
transition: 'all 0.3s ease', transition: "all 0.3s ease",
border: '2px solid transparent', border: "2px solid transparent",
'&:hover': { "&:hover": {
transform: 'translateY(-4px)', transform: "translateY(-4px)",
boxShadow: 6, boxShadow: 6,
borderColor: 'primary.main' borderColor: "primary.main",
} },
}} }}
onClick={() => window.location.href = '/register/employer'} onClick={() => (window.location.href = "/register/employer")}
> >
<CardContent sx={{ textAlign: 'center', py: 4 }}> <CardContent sx={{ textAlign: "center", py: 4 }}>
<Typography variant="h1" sx={{ mb: 2 }}>🏢</Typography> <Typography variant="h1" sx={{ mb: 2 }}>
🏢
</Typography>
<Typography variant="h5" component="h3" sx={{ mb: 1.5 }}> <Typography variant="h5" component="h3" sx={{ mb: 1.5 }}>
I'm hiring I'm hiring
</Typography> </Typography>
@ -850,7 +914,7 @@ export function RegistrationTypeSelector() {
Create a company account to find and hire talent Create a company account to find and hire talent
</Typography> </Typography>
</CardContent> </CardContent>
<CardActions sx={{ justifyContent: 'center', pb: 3 }}> <CardActions sx={{ justifyContent: "center", pb: 3 }}>
<Button variant="contained" size="large"> <Button variant="contained" size="large">
Join as Employer Join as Employer
</Button> </Button>
@ -858,9 +922,9 @@ export function RegistrationTypeSelector() {
</Card> </Card>
</Stack> </Stack>
<Box sx={{ textAlign: 'center', mt: 4 }}> <Box sx={{ textAlign: "center", mt: 4 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Already have an account?{' '} Already have an account?{" "}
<Link href="/login" sx={{ fontWeight: 600 }}> <Link href="/login" sx={{ fontWeight: 600 }}>
Sign in here Sign in here
</Link> </Link>

View File

@ -1,23 +1,23 @@
import React, { useState, useEffect, ReactElement } from 'react'; import React, { useState, useEffect, ReactElement } from "react";
// import FormGroup from '@mui/material/FormGroup'; // import FormGroup from '@mui/material/FormGroup';
// import FormControlLabel from '@mui/material/FormControlLabel'; // import FormControlLabel from '@mui/material/FormControlLabel';
// import Switch from '@mui/material/Switch'; // import Switch from '@mui/material/Switch';
// import Divider from '@mui/material/Divider'; // import Divider from '@mui/material/Divider';
// import TextField from '@mui/material/TextField'; // import TextField from '@mui/material/TextField';
import Accordion from '@mui/material/Accordion'; import Accordion from "@mui/material/Accordion";
import AccordionActions from '@mui/material/AccordionActions'; import AccordionActions from "@mui/material/AccordionActions";
import AccordionSummary from '@mui/material/AccordionSummary'; import AccordionSummary from "@mui/material/AccordionSummary";
import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionDetails from "@mui/material/AccordionDetails";
import Typography from '@mui/material/Typography'; import Typography from "@mui/material/Typography";
// import Button from '@mui/material/Button'; // import Button from '@mui/material/Button';
// import Box from '@mui/material/Box'; // import Box from '@mui/material/Box';
// import ResetIcon from '@mui/icons-material/History'; // import ResetIcon from '@mui/icons-material/History';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { BackstoryPageProps } from '../../components/BackstoryTab'; import { BackstoryPageProps } from "../../components/BackstoryTab";
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from "hooks/GlobalContext";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from "hooks/AuthContext";
import * as Types from 'types/types'; import * as Types from "types/types";
// interface ServerTunables { // interface ServerTunables {
// system_prompt: string, // system_prompt: string,
@ -34,14 +34,16 @@ import * as Types from 'types/types';
// returns?: any // returns?: any
// }; // };
const SystemInfoComponent: React.FC<{ systemInfo: Types.SystemInfo | undefined }> = ({ systemInfo }) => { const SystemInfoComponent: React.FC<{
systemInfo: Types.SystemInfo | undefined;
}> = ({ systemInfo }) => {
const [systemElements, setSystemElements] = useState<ReactElement[]>([]); const [systemElements, setSystemElements] = useState<ReactElement[]>([]);
const convertToSymbols = (text: string) => { const convertToSymbols = (text: string) => {
return text return text
.replace(/\(R\)/g, '®') // Replace (R) with the ® symbol .replace(/\(R\)/g, "®") // Replace (R) with the ® symbol
.replace(/\(C\)/g, '©') // Replace (C) with the © symbol .replace(/\(C\)/g, "©") // Replace (C) with the © symbol
.replace(/\(TM\)/g, '™'); // Replace (TM) with the ™ symbol .replace(/\(TM\)/g, "™"); // Replace (TM) with the ™ symbol
}; };
useEffect(() => { useEffect(() => {
@ -53,8 +55,15 @@ const SystemInfoComponent: React.FC<{ systemInfo: Types.SystemInfo | undefined }
if (Array.isArray(v)) { if (Array.isArray(v)) {
return v.map((card, index) => ( return v.map((card, index) => (
<div key={index} className="SystemInfoItem"> <div key={index} className="SystemInfoItem">
<div>{convertToSymbols(k)} {index}</div> <div>
<div>{convertToSymbols(card.name)} {card.discrete ? `w/ ${Math.round(card.memory / (1024 * 1024 * 1024))}GB RAM` : "(integrated)"}</div> {convertToSymbols(k)} {index}
</div>
<div>
{convertToSymbols(card.name)}{" "}
{card.discrete
? `w/ ${Math.round(card.memory / (1024 * 1024 * 1024))}GB RAM`
: "(integrated)"}
</div>
</div> </div>
)); ));
} }
@ -78,7 +87,9 @@ const Settings = (props: BackstoryPageProps) => {
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
// const [editSystemPrompt, setEditSystemPrompt] = useState<string>(""); // const [editSystemPrompt, setEditSystemPrompt] = useState<string>("");
const [systemInfo, setSystemInfo] = useState<Types.SystemInfo | undefined>(undefined); const [systemInfo, setSystemInfo] = useState<Types.SystemInfo | undefined>(
undefined
);
// const [tools, setTools] = useState<Tool[]>([]); // const [tools, setTools] = useState<Tool[]>([]);
// const [rags, setRags] = useState<Tool[]>([]); // const [rags, setRags] = useState<Tool[]>([]);
// const [systemPrompt, setSystemPrompt] = useState<string>(""); // const [systemPrompt, setSystemPrompt] = useState<string>("");
@ -172,13 +183,12 @@ const Settings = (props: BackstoryPageProps) => {
const response: Types.SystemInfo = await apiClient.getSystemInfo(); const response: Types.SystemInfo = await apiClient.getSystemInfo();
setSystemInfo(response); setSystemInfo(response);
} catch (error) { } catch (error) {
console.error('Error obtaining system information:', error); console.error("Error obtaining system information:", error);
setSnack("Unable to obtain system information.", "error"); setSnack("Unable to obtain system information.", "error");
};
} }
};
fetchSystemInfo(); fetchSystemInfo();
}, [systemInfo, setSystemInfo, setSnack, apiClient]); }, [systemInfo, setSystemInfo, setSnack, apiClient]);
// useEffect(() => { // useEffect(() => {
@ -284,7 +294,8 @@ const Settings = (props: BackstoryPageProps) => {
// } // }
// }; // };
return (<div className="Controls"> return (
<div className="Controls">
{/* <Typography component="span" sx={{ mb: 1 }}> {/* <Typography component="span" sx={{ mb: 1 }}>
You can change the information available to the LLM by adjusting the following settings: You can change the information available to the LLM by adjusting the following settings:
</Typography> </Typography>
@ -396,9 +407,8 @@ const Settings = (props: BackstoryPageProps) => {
{/* <Button startIcon={<ResetIcon />} onClick={() => { reset(["history"], "History cleared."); }}>Delete Backstory History</Button> {/* <Button startIcon={<ResetIcon />} onClick={() => { reset(["history"], "History cleared."); }}>Delete Backstory History</Button>
<Button onClick={() => { reset(["rags", "tools", "system_prompt"], "Default settings restored.") }}>Reset system prompt, tunables, and RAG to defaults</Button> */} <Button onClick={() => { reset(["rags", "tools", "system_prompt"], "Default settings restored.") }}>Reset system prompt, tunables, and RAG to defaults</Button> */}
</div>); </div>
} );
export {
Settings
}; };
export { Settings };

View File

@ -2,22 +2,23 @@ import React, { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { SetSnackType } from '../components/Snack'; import { SetSnackType } from "../components/Snack";
import { LoadingComponent } from "../components/LoadingComponent"; import { LoadingComponent } from "../components/LoadingComponent";
import { User, Guest, Candidate } from 'types/types'; import { User, Guest, Candidate } from "types/types";
import { useAuth } from "hooks/AuthContext"; import { useAuth } from "hooks/AuthContext";
import { useSelectedCandidate } from "hooks/GlobalContext"; import { useAppState, useSelectedCandidate } from "hooks/GlobalContext";
interface CandidateRouteProps { interface CandidateRouteProps {
guest?: Guest | null; guest?: Guest | null;
user?: User | null; user?: User | null;
setSnack: SetSnackType, }
};
const CandidateRoute: React.FC<CandidateRouteProps> = (props: CandidateRouteProps) => { const CandidateRoute: React.FC<CandidateRouteProps> = (
props: CandidateRouteProps
) => {
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate(); const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const { setSnack } = props; const { setSnack } = useAppState();
const { username } = useParams<{ username: string }>(); const { username } = useParams<{ username: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@ -25,30 +26,40 @@ const CandidateRoute: React.FC<CandidateRouteProps> = (props: CandidateRouteProp
if (selectedCandidate?.username === username || !username) { if (selectedCandidate?.username === username || !username) {
return; return;
} }
const getCandidate = async (reference: string) => { const getCandidate = async (reference: string): Promise<void> => {
try { try {
const result: Candidate = await apiClient.getCandidate(reference); const result: Candidate = await apiClient.getCandidate(reference);
setSelectedCandidate(result); setSelectedCandidate(result);
navigate('/chat'); navigate("/chat");
} catch { } catch {
setSnack(`Unable to obtain information for ${username}.`, "error"); setSnack(`Unable to obtain information for ${username}.`, "error");
navigate('/'); navigate("/");
}
} }
};
getCandidate(username); getCandidate(username);
}, [setSelectedCandidate, selectedCandidate, username, navigate, setSnack, apiClient]); }, [
setSelectedCandidate,
selectedCandidate,
username,
navigate,
setSnack,
apiClient,
]);
if (selectedCandidate?.username !== username) { if (selectedCandidate?.username !== username) {
return (<Box> return (
<Box>
<LoadingComponent <LoadingComponent
loadingText="Fetching candidate information..." loadingText="Fetching candidate information..."
loaderType="linear" loaderType="linear"
withFade={true} withFade={true}
fadeDuration={1200} /> fadeDuration={1200}
</Box>); />
</Box>
);
} else { } else {
return (<></>); return <></>;
} }
}; };

File diff suppressed because it is too large Load Diff

View File

@ -12,11 +12,13 @@
/** /**
* Converts a camelCase object to snake_case for sending to the Python backend * Converts a camelCase object to snake_case for sending to the Python backend
*/ */
export function toSnakeCase<T extends Record<string, any>>(obj: T): Record<string, any> { export function toSnakeCase<T extends Record<string, any>>(
if (!obj || typeof obj !== 'object') return obj; obj: T
): Record<string, any> {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map(item => toSnakeCase(item)); return obj.map((item) => toSnakeCase(item));
} }
const result: Record<string, any> = {}; const result: Record<string, any> = {};
@ -27,13 +29,13 @@ export function toSnakeCase<T extends Record<string, any>>(obj: T): Record<strin
if (value === null || value === undefined) { if (value === null || value === undefined) {
result[snakeCaseKey] = value; result[snakeCaseKey] = value;
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
result[snakeCaseKey] = value.map(item => result[snakeCaseKey] = value.map((item) =>
typeof item === 'object' && item !== null ? toSnakeCase(item) : item typeof item === "object" && item !== null ? toSnakeCase(item) : item
); );
} else if (value instanceof Date) { } else if (value instanceof Date) {
// Convert Date to ISO string for Python datetime // Convert Date to ISO string for Python datetime
result[snakeCaseKey] = value.toISOString(); result[snakeCaseKey] = value.toISOString();
} else if (typeof value === 'object') { } else if (typeof value === "object") {
result[snakeCaseKey] = toSnakeCase(value); result[snakeCaseKey] = toSnakeCase(value);
} else { } else {
result[snakeCaseKey] = value; result[snakeCaseKey] = value;
@ -47,10 +49,10 @@ export function toSnakeCase<T extends Record<string, any>>(obj: T): Record<strin
* Converts a snake_case object to camelCase for TypeScript/JavaScript * Converts a snake_case object to camelCase for TypeScript/JavaScript
*/ */
export function toCamelCase<T>(obj: Record<string, any>): T { export function toCamelCase<T>(obj: Record<string, any>): T {
if (!obj || typeof obj !== 'object') return obj as T; if (!obj || typeof obj !== "object") return obj as T;
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map(item => toCamelCase(item)) as T; return obj.map((item) => toCamelCase(item)) as T;
} }
const result: Record<string, any> = {}; const result: Record<string, any> = {};
@ -61,13 +63,13 @@ export function toCamelCase<T>(obj: Record<string, any>): T {
if (value === null || value === undefined) { if (value === null || value === undefined) {
result[camelCaseKey] = value; result[camelCaseKey] = value;
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
result[camelCaseKey] = value.map(item => result[camelCaseKey] = value.map((item) =>
typeof item === 'object' && item !== null ? toCamelCase(item) : item typeof item === "object" && item !== null ? toCamelCase(item) : item
); );
} else if (typeof value === 'string' && isIsoDateString(value)) { } else if (typeof value === "string" && isIsoDateString(value)) {
// Convert ISO date string to Date object // Convert ISO date string to Date object
result[camelCaseKey] = new Date(value); result[camelCaseKey] = new Date(value);
} else if (typeof value === 'object') { } else if (typeof value === "object") {
result[camelCaseKey] = toCamelCase(value); result[camelCaseKey] = toCamelCase(value);
} else { } else {
result[camelCaseKey] = value; result[camelCaseKey] = value;
@ -81,7 +83,7 @@ export function toCamelCase<T>(obj: Record<string, any>): T {
* Helper function to convert camelCase to snake_case * Helper function to convert camelCase to snake_case
*/ */
function camelToSnake(str: string): string { function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
} }
/** /**
@ -95,8 +97,10 @@ function snakeToCamel(str: string): string {
* Checks if a string is an ISO date format * Checks if a string is an ISO date format
*/ */
function isIsoDateString(value: string): boolean { function isIsoDateString(value: string): boolean {
if (typeof value !== 'string') return false; if (typeof value !== "string") return false;
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(value); return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(
value
);
} }
// ============================ // ============================
@ -106,7 +110,9 @@ function isIsoDateString(value: string): boolean {
/** /**
* Format data for API requests (converts to format expected by Python backend) * Format data for API requests (converts to format expected by Python backend)
*/ */
export function formatApiRequest<T extends Record<string, any>>(data: T): Record<string, any> { export function formatApiRequest<T extends Record<string, any>>(
data: T
): Record<string, any> {
if (!data) return data; if (!data) return data;
// Create a new object to avoid mutating the original // Create a new object to avoid mutating the original
@ -117,15 +123,15 @@ export function formatApiRequest<T extends Record<string, any>>(data: T): Record
if (value instanceof Date) { if (value instanceof Date) {
formatted[key] = value.toISOString(); formatted[key] = value.toISOString();
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
formatted[key] = value.map(item => { formatted[key] = value.map((item) => {
if (item instanceof Date) { if (item instanceof Date) {
return item.toISOString(); return item.toISOString();
} else if (typeof item === 'object' && item !== null) { } else if (typeof item === "object" && item !== null) {
return formatApiRequest(item); return formatApiRequest(item);
} }
return item; return item;
}); });
} else if (typeof value === 'object' && value !== null) { } else if (typeof value === "object" && value !== null) {
formatted[key] = formatApiRequest(value); formatted[key] = formatApiRequest(value);
} else { } else {
formatted[key] = value; formatted[key] = value;
@ -139,13 +145,13 @@ export function formatApiRequest<T extends Record<string, any>>(data: T): Record
* Parse API responses and convert to TypeScript format * Parse API responses and convert to TypeScript format
*/ */
export function parseApiResponse<T>(data: any): ApiResponse<T> { export function parseApiResponse<T>(data: any): ApiResponse<T> {
if (!data || typeof data !== 'object') { if (!data || typeof data !== "object") {
return { return {
success: false, success: false,
error: { error: {
code: 'INVALID_RESPONSE', code: "INVALID_RESPONSE",
message: 'Invalid response format' message: "Invalid response format",
} },
}; };
} }
@ -176,8 +182,8 @@ export function parsePaginatedResponse<T>(
...apiResponse, ...apiResponse,
data: { data: {
...paginatedData, ...paginatedData,
data: paginatedData.data.map(itemParser) data: paginatedData.data.map(itemParser),
} },
}; };
} }
@ -198,12 +204,12 @@ export function toUrlParams(obj: Record<string, any>): URLSearchParams {
if (value !== null && value !== undefined) { if (value !== null && value !== undefined) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
// Handle arrays by adding multiple params with same key // Handle arrays by adding multiple params with same key
value.forEach(item => { value.forEach((item) => {
params.append(key, String(item)); params.append(key, String(item));
}); });
} else if (value instanceof Date) { } else if (value instanceof Date) {
params.append(key, value.toISOString()); params.append(key, value.toISOString());
} else if (typeof value === 'object') { } else if (typeof value === "object") {
// For nested objects, we could flatten or JSON stringify // For nested objects, we could flatten or JSON stringify
params.append(key, JSON.stringify(value)); params.append(key, JSON.stringify(value));
} else { } else {
@ -222,15 +228,17 @@ export function toUrlParams(obj: Record<string, any>): URLSearchParams {
/** /**
* Check if response is a successful API response * Check if response is a successful API response
*/ */
export function isSuccessResponse<T>(response: any): response is SuccessApiResponse<T> { export function isSuccessResponse<T>(
return response && typeof response === 'object' && response.success === true; response: any
): response is SuccessApiResponse<T> {
return response && typeof response === "object" && response.success === true;
} }
/** /**
* Check if response is an error API response * Check if response is an error API response
*/ */
export function isErrorResponse(response: any): response is ErrorApiResponse { export function isErrorResponse(response: any): response is ErrorApiResponse {
return response && typeof response === 'object' && response.success === false; return response && typeof response === "object" && response.success === false;
} }
/** /**
@ -242,8 +250,8 @@ export function extractApiData<T>(response: ApiResponse<T>): T {
} }
const errorMessage = isErrorResponse(response) const errorMessage = isErrorResponse(response)
? response.error?.message || 'Unknown API error' ? response.error?.message || "Unknown API error"
: 'Invalid API response format'; : "Invalid API response format";
throw new Error(errorMessage); throw new Error(errorMessage);
} }
@ -290,7 +298,7 @@ export interface PaginatedRequest {
page?: number; page?: number;
limit?: number; limit?: number;
sortBy?: string; sortBy?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: "asc" | "desc";
filters?: Record<string, any>; filters?: Record<string, any>;
} }
@ -301,12 +309,14 @@ export interface PaginatedRequest {
/** /**
* Create a paginated request with defaults * Create a paginated request with defaults
*/ */
export function createPaginatedRequest(params: Partial<PaginatedRequest> = {}): PaginatedRequest { export function createPaginatedRequest(
params: Partial<PaginatedRequest> = {}
): PaginatedRequest {
return { return {
page: 1, page: 1,
limit: 20, limit: 20,
sortOrder: 'desc', sortOrder: "desc",
...params ...params,
}; };
} }
@ -350,12 +360,12 @@ export async function handlePaginatedApiResponse<T>(
/** /**
* Log conversion for debugging * Log conversion for debugging
*/ */
export function debugConversion<T>(obj: T, label: string = 'Object'): T { export function debugConversion<T>(obj: T, label = "Object"): T {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.group(`🔄 ${label} Conversion`); console.group(`🔄 ${label} Conversion`);
console.log('Original:', obj); console.log("Original:", obj);
if (typeof obj === 'object' && obj !== null) { if (typeof obj === "object" && obj !== null) {
console.log('Formatted for API:', formatApiRequest(obj as any)); console.log("Formatted for API:", formatApiRequest(obj as any));
} }
console.groupEnd(); console.groupEnd();
} }
@ -375,7 +385,7 @@ const exports = {
createPaginatedRequest, createPaginatedRequest,
handleApiResponse, handleApiResponse,
handlePaginatedApiResponse, handlePaginatedApiResponse,
debugConversion debugConversion,
} };
export default exports; export default exports;

View File

@ -1,4 +1,4 @@
import { ReactElement } from 'react'; import { ReactElement } from "react";
export interface NavigationItem { export interface NavigationItem {
id: string; id: string;
@ -7,12 +7,12 @@ export interface NavigationItem {
icon?: ReactElement; icon?: ReactElement;
children?: NavigationItem[]; children?: NavigationItem[];
component?: ReactElement; component?: ReactElement;
userTypes?: ('candidate' | 'employer' | 'guest' | 'admin')[]; userTypes?: ("candidate" | "employer" | "guest" | "admin")[];
exact?: boolean; exact?: boolean;
divider?: boolean; divider?: boolean;
showInNavigation?: boolean; // Controls if item appears in main navigation showInNavigation?: boolean; // Controls if item appears in main navigation
showInUserMenu?: boolean; // Controls if item appears in user menu showInUserMenu?: boolean; // Controls if item appears in user menu
userMenuGroup?: 'profile' | 'account' | 'system' | 'admin'; // Groups items in user menu userMenuGroup?: "profile" | "account" | "system" | "admin"; // Groups items in user menu
} }
export interface NavigationConfig { export interface NavigationConfig {

View File

@ -1,6 +1,6 @@
import { Palette, PaletteOptions } from '@mui/material/styles'; import { Palette, PaletteOptions } from "@mui/material/styles";
declare module '@mui/material/styles' { declare module "@mui/material/styles" {
interface Palette { interface Palette {
custom: { custom: {
highlight: string; highlight: string;

View File

@ -9,41 +9,115 @@
export type AIModelType = "qwen2.5" | "flux-schnell"; export type AIModelType = "qwen2.5" | "flux-schnell";
export type ActivityType = "login" | "search" | "view_job" | "apply_job" | "message" | "update_profile" | "chat"; export type ActivityType =
| "login"
| "search"
| "view_job"
| "apply_job"
| "message"
| "update_profile"
| "chat";
export type ApiActivityType = "system" | "info" | "searching" | "thinking" | "generating" | "converting" | "generating_image" | "tooling" | "heartbeat"; export type ApiActivityType =
| "system"
| "info"
| "searching"
| "thinking"
| "generating"
| "converting"
| "generating_image"
| "tooling"
| "heartbeat";
export type ApiMessageType = "binary" | "text" | "json"; export type ApiMessageType = "binary" | "text" | "json";
export type ApiStatusType = "streaming" | "status" | "done" | "error"; export type ApiStatusType = "streaming" | "status" | "done" | "error";
export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn"; export type ApplicationStatus =
| "applied"
| "reviewing"
| "interview"
| "offer"
| "rejected"
| "accepted"
| "withdrawn";
export type ChatContextType = "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match"; export type ChatContextType =
| "job_search"
| "job_requirements"
| "candidate_chat"
| "interview_prep"
| "resume_review"
| "general"
| "generate_persona"
| "generate_profile"
| "generate_resume"
| "generate_image"
| "rag_search"
| "skill_match";
export type ChatSenderType = "user" | "assistant" | "system" | "information" | "warning" | "error"; export type ChatSenderType =
| "user"
| "assistant"
| "system"
| "information"
| "warning"
| "error";
export type ColorBlindMode = "protanopia" | "deuteranopia" | "tritanopia" | "none"; export type ColorBlindMode =
| "protanopia"
| "deuteranopia"
| "tritanopia"
| "none";
export type DataSourceType = "document" | "website" | "api" | "database" | "internal"; export type DataSourceType =
| "document"
| "website"
| "api"
| "database"
| "internal";
export type DocumentType = "pdf" | "docx" | "txt" | "markdown" | "image"; export type DocumentType = "pdf" | "docx" | "txt" | "markdown" | "image";
export type EmploymentType = "full-time" | "part-time" | "contract" | "internship" | "freelance"; export type EmploymentType =
| "full-time"
| "part-time"
| "contract"
| "internship"
| "freelance";
export type FontSize = "small" | "medium" | "large"; export type FontSize = "small" | "medium" | "large";
export type InterviewRecommendation = "strong_hire" | "hire" | "no_hire" | "strong_no_hire"; export type InterviewRecommendation =
| "strong_hire"
| "hire"
| "no_hire"
| "strong_no_hire";
export type InterviewType = "phone" | "video" | "onsite" | "technical" | "behavioral"; export type InterviewType =
| "phone"
| "video"
| "onsite"
| "technical"
| "behavioral";
export type LanguageProficiency = "basic" | "conversational" | "fluent" | "native"; export type LanguageProficiency =
| "basic"
| "conversational"
| "fluent"
| "native";
export type MFAMethod = "app" | "sms" | "email"; export type MFAMethod = "app" | "sms" | "email";
export type NotificationType = "email" | "push" | "in_app"; export type NotificationType = "email" | "push" | "in_app";
export type ProcessingStepType = "extract" | "transform" | "chunk" | "embed" | "filter" | "summarize"; export type ProcessingStepType =
| "extract"
| "transform"
| "chunk"
| "embed"
| "filter"
| "summarize";
export type SalaryPeriod = "hour" | "day" | "month" | "year"; export type SalaryPeriod = "hour" | "day" | "month" | "year";
@ -55,7 +129,14 @@ export type SkillStatus = "pending" | "complete" | "waiting" | "error";
export type SkillStrength = "strong" | "moderate" | "weak" | "none"; export type SkillStrength = "strong" | "moderate" | "weak" | "none";
export type SocialPlatform = "linkedin" | "twitter" | "github" | "dribbble" | "behance" | "website" | "other"; export type SocialPlatform =
| "linkedin"
| "twitter"
| "github"
| "dribbble"
| "behance"
| "website"
| "other";
export type SortOrder = "asc" | "desc"; export type SortOrder = "asc" | "desc";
@ -198,7 +279,9 @@ export interface Candidate {
experience?: Array<WorkExperience>; experience?: Array<WorkExperience>;
questions?: Array<CandidateQuestion>; questions?: Array<CandidateQuestion>;
education?: Array<Education>; education?: Array<Education>;
preferredJobTypes?: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">; preferredJobTypes?: Array<
"full-time" | "part-time" | "contract" | "internship" | "freelance"
>;
desiredSalary?: DesiredSalary; desiredSalary?: DesiredSalary;
availabilityDate?: Date; availabilityDate?: Date;
summary?: string; summary?: string;
@ -233,7 +316,9 @@ export interface CandidateAI {
experience?: Array<WorkExperience>; experience?: Array<WorkExperience>;
questions?: Array<CandidateQuestion>; questions?: Array<CandidateQuestion>;
education?: Array<Education>; education?: Array<Education>;
preferredJobTypes?: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">; preferredJobTypes?: Array<
"full-time" | "part-time" | "contract" | "internship" | "freelance"
>;
desiredSalary?: DesiredSalary; desiredSalary?: DesiredSalary;
availabilityDate?: Date; availabilityDate?: Date;
summary?: string; summary?: string;
@ -284,7 +369,19 @@ export interface Certification {
} }
export interface ChatContext { export interface ChatContext {
type: "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match"; type:
| "job_search"
| "job_requirements"
| "candidate_chat"
| "interview_prep"
| "resume_review"
| "general"
| "generate_persona"
| "generate_profile"
| "generate_resume"
| "generate_image"
| "rag_search"
| "skill_match";
relatedEntityId?: string; relatedEntityId?: string;
relatedEntityType?: "job" | "candidate" | "employer"; relatedEntityType?: "job" | "candidate" | "employer";
additionalContext?: Record<string, any>; additionalContext?: Record<string, any>;
@ -380,7 +477,16 @@ export interface ChatMessageStatus {
status: "streaming" | "status" | "done" | "error"; status: "streaming" | "status" | "done" | "error";
type: "binary" | "text" | "json"; type: "binary" | "text" | "json";
timestamp?: Date; timestamp?: Date;
activity: "system" | "info" | "searching" | "thinking" | "generating" | "converting" | "generating_image" | "tooling" | "heartbeat"; activity:
| "system"
| "info"
| "searching"
| "thinking"
| "generating"
| "converting"
| "generating_image"
| "tooling"
| "heartbeat";
content: any; content: any;
} }
@ -726,7 +832,14 @@ export interface JobApplication {
id?: string; id?: string;
jobId: string; jobId: string;
candidateId: string; candidateId: string;
status: "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn"; status:
| "applied"
| "reviewing"
| "interview"
| "offer"
| "rejected"
| "accepted"
| "withdrawn";
appliedDate: Date; appliedDate: Date;
updatedDate: Date; updatedDate: Date;
resumeVersion: string; resumeVersion: string;
@ -741,7 +854,12 @@ export interface JobApplication {
export interface JobDetails { export interface JobDetails {
location: Location; location: Location;
salaryRange?: SalaryRange; salaryRange?: SalaryRange;
employmentType: "full-time" | "part-time" | "contract" | "internship" | "freelance"; employmentType:
| "full-time"
| "part-time"
| "contract"
| "internship"
| "freelance";
datePosted?: Date; datePosted?: Date;
applicationDeadline?: Date; applicationDeadline?: Date;
isActive: boolean; isActive: boolean;
@ -1040,7 +1158,14 @@ export interface SkillAssessment {
} }
export interface SocialLink { export interface SocialLink {
platform: "linkedin" | "twitter" | "github" | "dribbble" | "behance" | "website" | "other"; platform:
| "linkedin"
| "twitter"
| "github"
| "dribbble"
| "behance"
| "website"
| "other";
url: string; url: string;
} }
@ -1063,7 +1188,14 @@ export interface UserActivity {
id?: string; id?: string;
userId?: string; userId?: string;
guestId?: string; guestId?: string;
activityType: "login" | "search" | "view_job" | "apply_job" | "message" | "update_profile" | "chat"; activityType:
| "login"
| "search"
| "view_job"
| "apply_job"
| "message"
| "update_profile"
| "chat";
timestamp: Date; timestamp: Date;
metadata: Record<string, any>; metadata: Record<string, any>;
ipAddress?: string; ipAddress?: string;
@ -1132,7 +1264,9 @@ export function convertApiMessageFromApi(data: any): ApiMessage {
* Convert ApplicationDecision from API response * Convert ApplicationDecision from API response
* Date fields: date * Date fields: date
*/ */
export function convertApplicationDecisionFromApi(data: any): ApplicationDecision { export function convertApplicationDecisionFromApi(
data: any
): ApplicationDecision {
if (!data) return data; if (!data) return data;
return { return {
@ -1178,13 +1312,17 @@ export function convertAuthenticationFromApi(data: any): Authentication {
return { return {
...data, ...data,
// Convert resetPasswordExpiry from ISO string to Date // Convert resetPasswordExpiry from ISO string to Date
resetPasswordExpiry: data.resetPasswordExpiry ? new Date(data.resetPasswordExpiry) : undefined, resetPasswordExpiry: data.resetPasswordExpiry
? new Date(data.resetPasswordExpiry)
: undefined,
// Convert lastPasswordChange from ISO string to Date // Convert lastPasswordChange from ISO string to Date
lastPasswordChange: new Date(data.lastPasswordChange), lastPasswordChange: new Date(data.lastPasswordChange),
// Convert lockedUntil from ISO string to Date // Convert lockedUntil from ISO string to Date
lockedUntil: data.lockedUntil ? new Date(data.lockedUntil) : undefined, lockedUntil: data.lockedUntil ? new Date(data.lockedUntil) : undefined,
// Convert nested RefreshToken model // Convert nested RefreshToken model
refreshTokens: data.refreshTokens.map((item: any) => convertRefreshTokenFromApi(item)), refreshTokens: data.refreshTokens.map((item: any) =>
convertRefreshTokenFromApi(item)
),
}; };
} }
/** /**
@ -1238,15 +1376,25 @@ export function convertCandidateFromApi(data: any): Candidate {
// Convert lastLogin from ISO string to Date // Convert lastLogin from ISO string to Date
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined, lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
// Convert availabilityDate from ISO string to Date // Convert availabilityDate from ISO string to Date
availabilityDate: data.availabilityDate ? new Date(data.availabilityDate) : undefined, availabilityDate: data.availabilityDate
? new Date(data.availabilityDate)
: undefined,
// Convert nested WorkExperience model // Convert nested WorkExperience model
experience: data.experience ? convertWorkExperienceFromApi(data.experience) : undefined, experience: data.experience
? convertWorkExperienceFromApi(data.experience)
: undefined,
// Convert nested Education model // Convert nested Education model
education: data.education ? convertEducationFromApi(data.education) : undefined, education: data.education
? convertEducationFromApi(data.education)
: undefined,
// Convert nested Certification model // Convert nested Certification model
certifications: data.certifications ? convertCertificationFromApi(data.certifications) : undefined, certifications: data.certifications
? convertCertificationFromApi(data.certifications)
: undefined,
// Convert nested JobApplication model // Convert nested JobApplication model
jobApplications: data.jobApplications ? convertJobApplicationFromApi(data.jobApplications) : undefined, jobApplications: data.jobApplications
? convertJobApplicationFromApi(data.jobApplications)
: undefined,
}; };
} }
/** /**
@ -1268,20 +1416,30 @@ export function convertCandidateAIFromApi(data: any): CandidateAI {
// Convert lastLogin from ISO string to Date // Convert lastLogin from ISO string to Date
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined, lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
// Convert availabilityDate from ISO string to Date // Convert availabilityDate from ISO string to Date
availabilityDate: data.availabilityDate ? new Date(data.availabilityDate) : undefined, availabilityDate: data.availabilityDate
? new Date(data.availabilityDate)
: undefined,
// Convert nested WorkExperience model // Convert nested WorkExperience model
experience: data.experience ? convertWorkExperienceFromApi(data.experience) : undefined, experience: data.experience
? convertWorkExperienceFromApi(data.experience)
: undefined,
// Convert nested Education model // Convert nested Education model
education: data.education ? convertEducationFromApi(data.education) : undefined, education: data.education
? convertEducationFromApi(data.education)
: undefined,
// Convert nested Certification model // Convert nested Certification model
certifications: data.certifications ? convertCertificationFromApi(data.certifications) : undefined, certifications: data.certifications
? convertCertificationFromApi(data.certifications)
: undefined,
}; };
} }
/** /**
* Convert CandidateListResponse from API response * Convert CandidateListResponse from API response
* Nested models: data (Candidate) * Nested models: data (Candidate)
*/ */
export function convertCandidateListResponseFromApi(data: any): CandidateListResponse { export function convertCandidateListResponseFromApi(
data: any
): CandidateListResponse {
if (!data) return data; if (!data) return data;
return { return {
@ -1315,7 +1473,9 @@ export function convertCertificationFromApi(data: any): Certification {
// Convert issueDate from ISO string to Date // Convert issueDate from ISO string to Date
issueDate: new Date(data.issueDate), issueDate: new Date(data.issueDate),
// Convert expirationDate from ISO string to Date // Convert expirationDate from ISO string to Date
expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined, expirationDate: data.expirationDate
? new Date(data.expirationDate)
: undefined,
}; };
} }
/** /**
@ -1348,7 +1508,9 @@ export function convertChatMessageErrorFromApi(data: any): ChatMessageError {
* Convert ChatMessageRagSearch from API response * Convert ChatMessageRagSearch from API response
* Date fields: timestamp * Date fields: timestamp
*/ */
export function convertChatMessageRagSearchFromApi(data: any): ChatMessageRagSearch { export function convertChatMessageRagSearchFromApi(
data: any
): ChatMessageRagSearch {
if (!data) return data; if (!data) return data;
return { return {
@ -1375,7 +1537,9 @@ export function convertChatMessageResumeFromApi(data: any): ChatMessageResume {
* Date fields: timestamp * Date fields: timestamp
* Nested models: skillAssessment (SkillAssessment) * Nested models: skillAssessment (SkillAssessment)
*/ */
export function convertChatMessageSkillAssessmentFromApi(data: any): ChatMessageSkillAssessment { export function convertChatMessageSkillAssessmentFromApi(
data: any
): ChatMessageSkillAssessment {
if (!data) return data; if (!data) return data;
return { return {
@ -1403,7 +1567,9 @@ export function convertChatMessageStatusFromApi(data: any): ChatMessageStatus {
* Convert ChatMessageStreaming from API response * Convert ChatMessageStreaming from API response
* Date fields: timestamp * Date fields: timestamp
*/ */
export function convertChatMessageStreamingFromApi(data: any): ChatMessageStreaming { export function convertChatMessageStreamingFromApi(
data: any
): ChatMessageStreaming {
if (!data) return data; if (!data) return data;
return { return {
@ -1444,13 +1610,17 @@ export function convertChatSessionFromApi(data: any): ChatSession {
* Convert DataSourceConfiguration from API response * Convert DataSourceConfiguration from API response
* Date fields: lastRefreshed * Date fields: lastRefreshed
*/ */
export function convertDataSourceConfigurationFromApi(data: any): DataSourceConfiguration { export function convertDataSourceConfigurationFromApi(
data: any
): DataSourceConfiguration {
if (!data) return data; if (!data) return data;
return { return {
...data, ...data,
// Convert lastRefreshed from ISO string to Date // Convert lastRefreshed from ISO string to Date
lastRefreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : undefined, lastRefreshed: data.lastRefreshed
? new Date(data.lastRefreshed)
: undefined,
}; };
} }
/** /**
@ -1470,7 +1640,9 @@ export function convertDocumentFromApi(data: any): Document {
* Convert DocumentListResponse from API response * Convert DocumentListResponse from API response
* Nested models: documents (Document) * Nested models: documents (Document)
*/ */
export function convertDocumentListResponseFromApi(data: any): DocumentListResponse { export function convertDocumentListResponseFromApi(
data: any
): DocumentListResponse {
if (!data) return data; if (!data) return data;
return { return {
@ -1581,7 +1753,9 @@ export function convertGuestFromApi(data: any): Guest {
* Convert GuestSessionResponse from API response * Convert GuestSessionResponse from API response
* Nested models: user (Guest) * Nested models: user (Guest)
*/ */
export function convertGuestSessionResponseFromApi(data: any): GuestSessionResponse { export function convertGuestSessionResponseFromApi(
data: any
): GuestSessionResponse {
if (!data) return data; if (!data) return data;
return { return {
@ -1605,7 +1779,9 @@ export function convertInterviewFeedbackFromApi(data: any): InterviewFeedback {
// Convert updatedAt from ISO string to Date // Convert updatedAt from ISO string to Date
updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined, updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
// Convert nested SkillAssessment model // Convert nested SkillAssessment model
skillAssessments: data.skillAssessments ? convertSkillAssessmentFromApi(data.skillAssessments) : undefined, skillAssessments: data.skillAssessments
? convertSkillAssessmentFromApi(data.skillAssessments)
: undefined,
}; };
} }
/** /**
@ -1623,7 +1799,9 @@ export function convertInterviewScheduleFromApi(data: any): InterviewSchedule {
// Convert endDate from ISO string to Date // Convert endDate from ISO string to Date
endDate: new Date(data.endDate), endDate: new Date(data.endDate),
// Convert nested InterviewFeedback model // Convert nested InterviewFeedback model
feedback: data.feedback ? convertInterviewFeedbackFromApi(data.feedback) : undefined, feedback: data.feedback
? convertInterviewFeedbackFromApi(data.feedback)
: undefined,
}; };
} }
/** /**
@ -1661,9 +1839,13 @@ export function convertJobApplicationFromApi(data: any): JobApplication {
// Convert updatedDate from ISO string to Date // Convert updatedDate from ISO string to Date
updatedDate: new Date(data.updatedDate), updatedDate: new Date(data.updatedDate),
// Convert nested InterviewSchedule model // Convert nested InterviewSchedule model
interviewSchedules: data.interviewSchedules ? convertInterviewScheduleFromApi(data.interviewSchedules) : undefined, interviewSchedules: data.interviewSchedules
? convertInterviewScheduleFromApi(data.interviewSchedules)
: undefined,
// Convert nested ApplicationDecision model // Convert nested ApplicationDecision model
decision: data.decision ? convertApplicationDecisionFromApi(data.decision) : undefined, decision: data.decision
? convertApplicationDecisionFromApi(data.decision)
: undefined,
}; };
} }
/** /**
@ -1678,9 +1860,13 @@ export function convertJobDetailsFromApi(data: any): JobDetails {
// Convert datePosted from ISO string to Date // Convert datePosted from ISO string to Date
datePosted: data.datePosted ? new Date(data.datePosted) : undefined, datePosted: data.datePosted ? new Date(data.datePosted) : undefined,
// Convert applicationDeadline from ISO string to Date // Convert applicationDeadline from ISO string to Date
applicationDeadline: data.applicationDeadline ? new Date(data.applicationDeadline) : undefined, applicationDeadline: data.applicationDeadline
? new Date(data.applicationDeadline)
: undefined,
// Convert featuredUntil from ISO string to Date // Convert featuredUntil from ISO string to Date
featuredUntil: data.featuredUntil ? new Date(data.featuredUntil) : undefined, featuredUntil: data.featuredUntil
? new Date(data.featuredUntil)
: undefined,
}; };
} }
/** /**
@ -1701,7 +1887,9 @@ export function convertJobListResponseFromApi(data: any): JobListResponse {
* Date fields: timestamp * Date fields: timestamp
* Nested models: job (Job) * Nested models: job (Job)
*/ */
export function convertJobRequirementsMessageFromApi(data: any): JobRequirementsMessage { export function convertJobRequirementsMessageFromApi(
data: any
): JobRequirementsMessage {
if (!data) return data; if (!data) return data;
return { return {
@ -1753,7 +1941,9 @@ export function convertRAGConfigurationFromApi(data: any): RAGConfiguration {
// Convert updatedAt from ISO string to Date // Convert updatedAt from ISO string to Date
updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined, updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
// Convert nested DataSourceConfiguration model // Convert nested DataSourceConfiguration model
dataSourceConfigurations: data.dataSourceConfigurations.map((item: any) => convertDataSourceConfigurationFromApi(item)), dataSourceConfigurations: data.dataSourceConfigurations.map((item: any) =>
convertDataSourceConfigurationFromApi(item)
),
}; };
} }
/** /**
@ -1812,7 +2002,9 @@ export function convertResumeFromApi(data: any): Resume {
// Convert nested Job model // Convert nested Job model
job: data.job ? convertJobFromApi(data.job) : undefined, job: data.job ? convertJobFromApi(data.job) : undefined,
// Convert nested Candidate model // Convert nested Candidate model
candidate: data.candidate ? convertCandidateFromApi(data.candidate) : undefined, candidate: data.candidate
? convertCandidateFromApi(data.candidate)
: undefined,
}; };
} }
/** /**
@ -1883,105 +2075,105 @@ export function convertFromApi<T>(data: any, modelType: string): T {
if (!data) return data; if (!data) return data;
switch (modelType) { switch (modelType) {
case 'Analytics': case "Analytics":
return convertAnalyticsFromApi(data) as T; return convertAnalyticsFromApi(data) as T;
case 'ApiMessage': case "ApiMessage":
return convertApiMessageFromApi(data) as T; return convertApiMessageFromApi(data) as T;
case 'ApplicationDecision': case "ApplicationDecision":
return convertApplicationDecisionFromApi(data) as T; return convertApplicationDecisionFromApi(data) as T;
case 'Attachment': case "Attachment":
return convertAttachmentFromApi(data) as T; return convertAttachmentFromApi(data) as T;
case 'AuthResponse': case "AuthResponse":
return convertAuthResponseFromApi(data) as T; return convertAuthResponseFromApi(data) as T;
case 'Authentication': case "Authentication":
return convertAuthenticationFromApi(data) as T; return convertAuthenticationFromApi(data) as T;
case 'BaseUser': case "BaseUser":
return convertBaseUserFromApi(data) as T; return convertBaseUserFromApi(data) as T;
case 'BaseUserWithType': case "BaseUserWithType":
return convertBaseUserWithTypeFromApi(data) as T; return convertBaseUserWithTypeFromApi(data) as T;
case 'Candidate': case "Candidate":
return convertCandidateFromApi(data) as T; return convertCandidateFromApi(data) as T;
case 'CandidateAI': case "CandidateAI":
return convertCandidateAIFromApi(data) as T; return convertCandidateAIFromApi(data) as T;
case 'CandidateListResponse': case "CandidateListResponse":
return convertCandidateListResponseFromApi(data) as T; return convertCandidateListResponseFromApi(data) as T;
case 'CandidateResponse': case "CandidateResponse":
return convertCandidateResponseFromApi(data) as T; return convertCandidateResponseFromApi(data) as T;
case 'Certification': case "Certification":
return convertCertificationFromApi(data) as T; return convertCertificationFromApi(data) as T;
case 'ChatMessage': case "ChatMessage":
return convertChatMessageFromApi(data) as T; return convertChatMessageFromApi(data) as T;
case 'ChatMessageError': case "ChatMessageError":
return convertChatMessageErrorFromApi(data) as T; return convertChatMessageErrorFromApi(data) as T;
case 'ChatMessageRagSearch': case "ChatMessageRagSearch":
return convertChatMessageRagSearchFromApi(data) as T; return convertChatMessageRagSearchFromApi(data) as T;
case 'ChatMessageResume': case "ChatMessageResume":
return convertChatMessageResumeFromApi(data) as T; return convertChatMessageResumeFromApi(data) as T;
case 'ChatMessageSkillAssessment': case "ChatMessageSkillAssessment":
return convertChatMessageSkillAssessmentFromApi(data) as T; return convertChatMessageSkillAssessmentFromApi(data) as T;
case 'ChatMessageStatus': case "ChatMessageStatus":
return convertChatMessageStatusFromApi(data) as T; return convertChatMessageStatusFromApi(data) as T;
case 'ChatMessageStreaming': case "ChatMessageStreaming":
return convertChatMessageStreamingFromApi(data) as T; return convertChatMessageStreamingFromApi(data) as T;
case 'ChatMessageUser': case "ChatMessageUser":
return convertChatMessageUserFromApi(data) as T; return convertChatMessageUserFromApi(data) as T;
case 'ChatSession': case "ChatSession":
return convertChatSessionFromApi(data) as T; return convertChatSessionFromApi(data) as T;
case 'DataSourceConfiguration': case "DataSourceConfiguration":
return convertDataSourceConfigurationFromApi(data) as T; return convertDataSourceConfigurationFromApi(data) as T;
case 'Document': case "Document":
return convertDocumentFromApi(data) as T; return convertDocumentFromApi(data) as T;
case 'DocumentListResponse': case "DocumentListResponse":
return convertDocumentListResponseFromApi(data) as T; return convertDocumentListResponseFromApi(data) as T;
case 'DocumentMessage': case "DocumentMessage":
return convertDocumentMessageFromApi(data) as T; return convertDocumentMessageFromApi(data) as T;
case 'EditHistory': case "EditHistory":
return convertEditHistoryFromApi(data) as T; return convertEditHistoryFromApi(data) as T;
case 'Education': case "Education":
return convertEducationFromApi(data) as T; return convertEducationFromApi(data) as T;
case 'Employer': case "Employer":
return convertEmployerFromApi(data) as T; return convertEmployerFromApi(data) as T;
case 'EmployerResponse': case "EmployerResponse":
return convertEmployerResponseFromApi(data) as T; return convertEmployerResponseFromApi(data) as T;
case 'Guest': case "Guest":
return convertGuestFromApi(data) as T; return convertGuestFromApi(data) as T;
case 'GuestSessionResponse': case "GuestSessionResponse":
return convertGuestSessionResponseFromApi(data) as T; return convertGuestSessionResponseFromApi(data) as T;
case 'InterviewFeedback': case "InterviewFeedback":
return convertInterviewFeedbackFromApi(data) as T; return convertInterviewFeedbackFromApi(data) as T;
case 'InterviewSchedule': case "InterviewSchedule":
return convertInterviewScheduleFromApi(data) as T; return convertInterviewScheduleFromApi(data) as T;
case 'Job': case "Job":
return convertJobFromApi(data) as T; return convertJobFromApi(data) as T;
case 'JobApplication': case "JobApplication":
return convertJobApplicationFromApi(data) as T; return convertJobApplicationFromApi(data) as T;
case 'JobDetails': case "JobDetails":
return convertJobDetailsFromApi(data) as T; return convertJobDetailsFromApi(data) as T;
case 'JobListResponse': case "JobListResponse":
return convertJobListResponseFromApi(data) as T; return convertJobListResponseFromApi(data) as T;
case 'JobRequirementsMessage': case "JobRequirementsMessage":
return convertJobRequirementsMessageFromApi(data) as T; return convertJobRequirementsMessageFromApi(data) as T;
case 'JobResponse': case "JobResponse":
return convertJobResponseFromApi(data) as T; return convertJobResponseFromApi(data) as T;
case 'MessageReaction': case "MessageReaction":
return convertMessageReactionFromApi(data) as T; return convertMessageReactionFromApi(data) as T;
case 'RAGConfiguration': case "RAGConfiguration":
return convertRAGConfigurationFromApi(data) as T; return convertRAGConfigurationFromApi(data) as T;
case 'RateLimitResult': case "RateLimitResult":
return convertRateLimitResultFromApi(data) as T; return convertRateLimitResultFromApi(data) as T;
case 'RateLimitStatus': case "RateLimitStatus":
return convertRateLimitStatusFromApi(data) as T; return convertRateLimitStatusFromApi(data) as T;
case 'RefreshToken': case "RefreshToken":
return convertRefreshTokenFromApi(data) as T; return convertRefreshTokenFromApi(data) as T;
case 'Resume': case "Resume":
return convertResumeFromApi(data) as T; return convertResumeFromApi(data) as T;
case 'ResumeMessage': case "ResumeMessage":
return convertResumeMessageFromApi(data) as T; return convertResumeMessageFromApi(data) as T;
case 'SkillAssessment': case "SkillAssessment":
return convertSkillAssessmentFromApi(data) as T; return convertSkillAssessmentFromApi(data) as T;
case 'UserActivity': case "UserActivity":
return convertUserActivityFromApi(data) as T; return convertUserActivityFromApi(data) as T;
case 'WorkExperience': case "WorkExperience":
return convertWorkExperienceFromApi(data) as T; return convertWorkExperienceFromApi(data) as T;
default: default:
return data as T; return data as T;
@ -1993,7 +2185,7 @@ export function convertFromApi<T>(data: any, modelType: string): T {
*/ */
export function convertArrayFromApi<T>(data: any[], modelType: string): T[] { export function convertArrayFromApi<T>(data: any[], modelType: string): T[] {
if (!data || !Array.isArray(data)) return data; if (!data || !Array.isArray(data)) return data;
return data.map(item => convertFromApi<T>(item, modelType)); return data.map((item) => convertFromApi<T>(item, modelType));
} }
// ============================ // ============================
// Union Types // Union Types