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;
@ -181,7 +180,7 @@ button {
.user-message .MuiCardContent-root:last-child, .user-message .MuiCardContent-root:last-child,
.assistant-message .MuiCardContent-root:last-child, .assistant-message .MuiCardContent-root:last-child,
.Docs .MuiCardContent-root:last-child { .Docs .MuiCardContent-root:last-child {
padding: 16px; padding: 16px;
} }
.users > div { .users > div {
@ -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;
} }
@ -220,7 +219,7 @@ button {
/* Reduce space in lists */ /* Reduce space in lists */
* ul.MuiTypography-root, * ul.MuiTypography-root,
* ol.MuiTypography-root { * ol.MuiTypography-root {
margin-top: 0.5rem; margin-top: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -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,
size="small" onClick={(e: any) => { submitQuery(question); }}> m: 1,
}}
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,53 +1,50 @@
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;
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,395 +67,482 @@ 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>(
const { (props: ConversationProps, ref) => {
actionLabel, const {
defaultPrompts, actionLabel,
hideDefaultPrompts, defaultPrompts,
hidePreamble, hideDefaultPrompts,
messageFilter, hidePreamble,
messages, messageFilter,
onResponse, messages,
placeholder, onResponse,
preamble, placeholder,
resetAction, preamble,
resetLabel, resetAction,
sx, resetLabel,
type, sx,
} = props; type,
const { apiClient } = useAuth() } = props;
const [processing, setProcessing] = useState<boolean>(false); const { apiClient } = useAuth();
const [countdown, setCountdown] = useState<number>(0); const [processing, setProcessing] = useState<boolean>(false);
const [conversation, setConversation] = useState<ChatMessage[]>([]); const [countdown, setCountdown] = useState<number>(0);
const conversationRef = useRef<ChatMessage[]>([]); const [conversation, setConversation] = useState<ChatMessage[]>([]);
const [filteredConversation, setFilteredConversation] = useState<ChatMessage[]>([]); const conversationRef = useRef<ChatMessage[]>([]);
const [processingMessage, setProcessingMessage] = useState<ChatMessage | undefined>(undefined); const [filteredConversation, setFilteredConversation] = useState<
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | undefined>(undefined); ChatMessage[]
const [noInteractions, setNoInteractions] = useState<boolean>(true); >([]);
const viewableElementRef = useRef<HTMLDivElement>(null); const [processingMessage, setProcessingMessage] = useState<
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null); ChatMessage | undefined
const stopRef = useRef(false); >(undefined);
const controllerRef = useRef<StreamingResponse>(null); const [streamingMessage, setStreamingMessage] = useState<
const [chatSession, setChatSession] = useState<ChatSession | null>(null); ChatMessage | undefined
const { setSnack } = useAppState(); >(undefined);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const viewableElementRef = useRef<HTMLDivElement>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const stopRef = useRef(false);
const controllerRef = useRef<StreamingResponse>(null);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const { setSnack } = useAppState();
// Keep the ref updated whenever items changes // Keep the ref updated whenever items changes
useEffect(() => { useEffect(() => {
conversationRef.current = conversation; conversationRef.current = conversation;
}, [conversation]); }, [conversation]);
// Update the context status // Update the context status
/* Transform the 'Conversation' by filtering via callback, then adding /* Transform the 'Conversation' by filtering via callback, then adding
* preamble and messages based on whether the conversation * preamble and messages based on whether the conversation
* has any elements yet */ * has any elements yet */
useEffect(() => { useEffect(() => {
let filtered = []; let filtered = [];
if (messageFilter === undefined) { if (messageFilter === undefined) {
filtered = conversation; filtered = conversation;
// 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 =
//console.log(`${conversation.length - filtered.length} messages filtered out.`); messageFilter(
} conversation
if (filtered.length === 0) { ); /* Do not copy conversation or useEffect will loop forever */
setFilteredConversation([ //console.log(`${conversation.length - filtered.length} messages filtered out.`);
...(preamble || []),
...(messages || []),
]);
} else {
setFilteredConversation([
...(hidePreamble ? [] : (preamble || [])),
...(messages || []),
...filtered,
]);
};
}, [conversation, setFilteredConversation, messageFilter, preamble, messages, hidePreamble]);
useEffect(() => {
if (chatSession) {
return;
}
const createChatSession = async () => {
try {
const chatContext: ChatContext = { type: "general" };
const response: ChatSession = await apiClient.createChatSession(chatContext);
setChatSession(response);
} catch (e) {
console.error(e);
setSnack("Unable to create chat session.", "error");
} }
}; if (filtered.length === 0) {
setFilteredConversation([...(preamble || []), ...(messages || [])]);
} else {
setFilteredConversation([
...(hidePreamble ? [] : preamble || []),
...(messages || []),
...filtered,
]);
}
}, [
conversation,
setFilteredConversation,
messageFilter,
preamble,
messages,
hidePreamble,
]);
createChatSession(); useEffect(() => {
if (chatSession) {
return;
}
const createChatSession = async () => {
try {
const chatContext: ChatContext = { type: "general" };
const response: ChatSession = await apiClient.createChatSession(
chatContext
);
setChatSession(response);
} catch (e) {
console.error(e);
setSnack("Unable to create chat session.", "error");
}
};
}, [chatSession, setChatSession]); createChatSession();
}, [chatSession, setChatSession]);
const getChatMessages = useCallback(async () => { const getChatMessages = useCallback(async () => {
if (!chatSession || !chatSession.id) { if (!chatSession || !chatSession.id) {
return; return;
} }
try { try {
const response: PaginatedResponse<ChatMessage> = await apiClient.getChatMessages(chatSession.id); const response: PaginatedResponse<ChatMessage> =
const messages: ChatMessage[] = response.data; await apiClient.getChatMessages(chatSession.id);
const messages: ChatMessage[] = response.data;
setProcessingMessage(undefined);
setStreamingMessage(undefined);
if (messages.length === 0) {
console.log(`History returned with 0 entries`);
setConversation([]);
setNoInteractions(true);
} else {
console.log(
`History returned with ${messages.length} entries:`,
messages
);
setConversation(messages);
setNoInteractions(false);
}
} catch (error) {
console.error("Unable to obtain chat history", error);
setProcessingMessage({
...defaultMessage,
status: "error",
content: `Unable to obtain history from server.`,
});
setTimeout(() => {
setProcessingMessage(undefined);
setNoInteractions(true);
}, 3000);
setSnack("Unable to obtain chat history.", "error");
}
}, [chatSession]);
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (!chatSession) {
setProcessingMessage(loadingMessage);
return;
}
setProcessingMessage(undefined); setProcessingMessage(undefined);
setStreamingMessage(undefined); setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
if (messages.length === 0) { getChatMessages();
console.log(`History returned with 0 entries`) }, [chatSession]);
setConversation([])
setNoInteractions(true);
} else {
console.log(`History returned with ${messages.length} entries:`, messages)
setConversation(messages);
setNoInteractions(false);
}
} catch (error) {
console.error('Unable to obtain chat history', error);
setProcessingMessage({ ...defaultMessage, status: "error", content: `Unable to obtain history from server.` });
setTimeout(() => {
setProcessingMessage(undefined);
setNoInteractions(true);
}, 3000);
setSnack("Unable to obtain chat history.", "error");
}
}, [chatSession]);
const handleEnter = (value: string) => {
// Set the initial chat history to "loading" or the welcome message if loaded. const query: ChatQuery = {
useEffect(() => { prompt: value,
if (!chatSession) { };
setProcessingMessage(loadingMessage);
return;
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
getChatMessages();
}, [chatSession]);
const handleEnter = (value: string) => {
const query: ChatQuery = {
prompt: value
}
processQuery(query);
};
useImperativeHandle(ref, () => ({
submitQuery: (query: ChatQuery) => {
processQuery(query); processQuery(query);
},
fetchHistory: () => { getChatMessages(); }
}));
// const reset = async () => {
// try {
// const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// 'Accept': 'application/json',
// },
// body: JSON.stringify({ reset: ['history'] })
// });
// if (!response.ok) {
// throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
// }
// if (!response.body) {
// throw new Error('Response body is null');
// }
// setProcessingMessage(undefined);
// setStreamingMessage(undefined);
// setConversation([]);
// setNoInteractions(true);
// } catch (e) {
// setSnack("Error resetting history", "error")
// console.error('Error resetting history:', e);
// }
// };
const cancelQuery = () => {
console.log("Stop query");
if (controllerRef.current) {
controllerRef.current.cancel();
}
controllerRef.current = null;
};
const processQuery = (query: ChatQuery) => {
if (controllerRef.current || !chatSession || !chatSession.id) {
return;
}
const sessionId: string = chatSession.id;
setNoInteractions(false);
setConversation([
...conversationRef.current,
{
...defaultMessage,
type: 'text',
content: query.prompt,
}
]);
setProcessing(true);
setProcessingMessage(
{ ...defaultMessage, content: 'Submitting request...' }
);
const chatMessage: ChatMessageUser = {
role: "user",
sessionId: chatSession.id,
content: query.prompt,
tunables: query.tunables,
status: "done",
type: "text",
timestamp: new Date()
}; };
controllerRef.current = apiClient.sendMessageStream(chatMessage, { useImperativeHandle(ref, () => ({
onMessage: (msg: ChatMessage) => { submitQuery: (query: ChatQuery) => {
console.log("onMessage:", msg); processQuery(query);
setConversation([
...conversationRef.current,
msg
]);
setStreamingMessage(undefined);
setProcessingMessage(undefined);
setProcessing(false);
if (onResponse) {
onResponse(msg);
}
}, },
onError: (error: string | ChatMessageError) => { fetchHistory: () => {
console.log("onError:", error); getChatMessages();
// Type-guard to determine if this is a ChatMessageBase or a string
if (typeof error === "object" && error !== null && "content" in error) {
setProcessingMessage(error as ChatMessage);
setProcessing(false);
controllerRef.current = null;
} else {
setProcessingMessage({ ...defaultMessage, content: error as string });
}
}, },
onStreaming: (chunk: ChatMessageStreaming) => { }));
console.log("onStreaming:", chunk);
setStreamingMessage({ ...defaultMessage, ...chunk });
},
onStatus: (status: ChatMessageStatus) => {
console.log("onStatus:", status);
},
onComplete: () => {
console.log("onComplete");
controllerRef.current = null;
}
});
};
if (!chatSession) { // const reset = async () => {
return (<></>); // try {
} // const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, {
return ( // method: 'PUT',
// <Scrollable // headers: {
// className={`${className || ""} Conversation`} // 'Content-Type': 'application/json',
// autoscroll // 'Accept': 'application/json',
// textFieldRef={viewableElementRef} // },
// fallbackThreshold={0.5} // body: JSON.stringify({ reset: ['history'] })
// sx={{ // });
// p: 1,
// mt: 0, // if (!response.ok) {
// ...sx // throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
// }} // }
// >
<Box className="Conversation" sx={{ flexGrow: 1, minHeight: "max-content", height: "max-content", maxHeight: "max-content", overflow: "hidden" }}> // if (!response.body) {
<Box sx={{ p: 1, mt: 0, ...sx }}> // throw new Error('Response body is null');
{ // }
filteredConversation.map((message, index) =>
<Message key={index} {...{ chatSession, sendQuery: processQuery, message, }} /> // setProcessingMessage(undefined);
) // setStreamingMessage(undefined);
// setConversation([]);
// setNoInteractions(true);
// } catch (e) {
// setSnack("Error resetting history", "error")
// console.error('Error resetting history:', e);
// }
// };
const cancelQuery = () => {
console.log("Stop query");
if (controllerRef.current) {
controllerRef.current.cancel();
} }
{ controllerRef.current = null;
processingMessage !== undefined && };
<Message {...{ chatSession, sendQuery: processQuery, message: processingMessage, }} />
const processQuery = (query: ChatQuery) => {
if (controllerRef.current || !chatSession || !chatSession.id) {
return;
} }
{ const sessionId: string = chatSession.id;
streamingMessage !== undefined &&
<Message {...{ chatSession, sendQuery: processQuery, message: streamingMessage }} /> setNoInteractions(false);
} setConversation([
<Box sx={{ ...conversationRef.current,
display: "flex", {
flexDirection: "column", ...defaultMessage,
alignItems: "center", type: "text",
justifyContent: "center", content: query.prompt,
m: 1, },
}}> ]);
<PropagateLoader setProcessing(true);
size="10px" setProcessingMessage({
loading={processing} ...defaultMessage,
aria-label="Loading Spinner" content: "Submitting request...",
data-testid="loader" });
/>
{processing === true && countdown > 0 && ( const chatMessage: ChatMessageUser = {
role: "user",
sessionId: chatSession.id,
content: query.prompt,
tunables: query.tunables,
status: "done",
type: "text",
timestamp: new Date(),
};
controllerRef.current = apiClient.sendMessageStream(chatMessage, {
onMessage: (msg: ChatMessage) => {
console.log("onMessage:", msg);
setConversation([...conversationRef.current, msg]);
setStreamingMessage(undefined);
setProcessingMessage(undefined);
setProcessing(false);
if (onResponse) {
onResponse(msg);
}
},
onError: (error: string | ChatMessageError) => {
console.log("onError:", error);
// Type-guard to determine if this is a ChatMessageBase or a string
if (
typeof error === "object" &&
error !== null &&
"content" in error
) {
setProcessingMessage(error as ChatMessage);
setProcessing(false);
controllerRef.current = null;
} else {
setProcessingMessage({
...defaultMessage,
content: error as string,
});
}
},
onStreaming: (chunk: ChatMessageStreaming) => {
console.log("onStreaming:", chunk);
setStreamingMessage({ ...defaultMessage, ...chunk });
},
onStatus: (status: ChatMessageStatus) => {
console.log("onStatus:", status);
},
onComplete: () => {
console.log("onComplete");
controllerRef.current = null;
},
});
};
if (!chatSession) {
return <></>;
}
return (
// <Scrollable
// className={`${className || ""} Conversation`}
// autoscroll
// textFieldRef={viewableElementRef}
// fallbackThreshold={0.5}
// sx={{
// p: 1,
// mt: 0,
// ...sx
// }}
// >
<Box
className="Conversation"
sx={{
flexGrow: 1,
minHeight: "max-content",
height: "max-content",
maxHeight: "max-content",
overflow: "hidden",
}}
>
<Box sx={{ p: 1, mt: 0, ...sx }}>
{filteredConversation.map((message, index) => (
<Message
key={index}
{...{ chatSession, sendQuery: processQuery, message }}
/>
))}
{processingMessage !== undefined && (
<Message
{...{
chatSession,
sendQuery: processQuery,
message: processingMessage,
}}
/>
)}
{streamingMessage !== undefined && (
<Message
{...{
chatSession,
sendQuery: processQuery,
message: streamingMessage,
}}
/>
)}
<Box <Box
sx={{ sx={{
pt: 1, display: "flex",
fontSize: "0.7rem", flexDirection: "column",
color: "darkgrey" alignItems: "center",
justifyContent: "center",
m: 1,
}} }}
>Response will be stopped in: {countdown}s</Box> >
)} <PropagateLoader
</Box> size="10px"
<Box className="Query" sx={{ display: "flex", flexDirection: "column", p: 1, flexGrow: 1 }}> loading={processing}
{placeholder && aria-label="Loading Spinner"
<Box sx={{ display: "flex", flexGrow: 1, p: 0, m: 0, flexDirection: "column" }} data-testid="loader"
ref={viewableElementRef}>
<BackstoryTextField
ref={backstoryTextRef}
disabled={processing}
onEnter={handleEnter}
placeholder={placeholder}
/> />
</Box> {processing === true && countdown > 0 && (
} <Box
sx={{
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}> pt: 1,
<DeleteConfirmation fontSize: "0.7rem",
label={resetLabel || "all data"} color: "darkgrey",
disabled={!chatSession || processingMessage !== undefined || noInteractions} }}
onDelete={() => { /*reset(); resetAction && resetAction(); */ }} />
<Tooltip title={actionLabel || "Send"}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={!chatSession || processingMessage !== undefined}
onClick={() => { processQuery({ prompt: (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || "" }); }}>
{actionLabel}<SendIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Cancel">
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={() => { cancelQuery(); }}
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
disabled={stopRef.current || !chatSession || processing === false}
> >
<CancelIcon /> Response will be stopped in: {countdown}s
</IconButton> </Box>
</span> )}
</Tooltip> </Box>
<Box
className="Query"
sx={{ display: "flex", flexDirection: "column", p: 1, flexGrow: 1 }}
>
{placeholder && (
<Box
sx={{
display: "flex",
flexGrow: 1,
p: 0,
m: 0,
flexDirection: "column",
}}
ref={viewableElementRef}
>
<BackstoryTextField
ref={backstoryTextRef}
disabled={processing}
onEnter={handleEnter}
placeholder={placeholder}
/>
</Box>
)}
<Box
key="jobActions"
sx={{
display: "flex",
justifyContent: "center",
flexDirection: "row",
}}
>
<DeleteConfirmation
label={resetLabel || "all data"}
disabled={
!chatSession ||
processingMessage !== undefined ||
noInteractions
}
onDelete={() => {
/*reset(); resetAction && resetAction(); */
}}
/>
<Tooltip title={actionLabel || "Send"}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={!chatSession || processingMessage !== undefined}
onClick={() => {
processQuery({
prompt:
(backstoryTextRef.current &&
backstoryTextRef.current.getAndResetValue()) ||
"",
});
}}
>
{actionLabel}
<SendIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Cancel">
<span style={{ display: "flex" }}>
{" "}
{/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={() => {
cancelQuery();
}}
sx={{ display: "flex", margin: "auto 0px" }}
size="large"
edge="start"
disabled={
stopRef.current || !chatSession || processing === false
}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
{(noInteractions || !hideDefaultPrompts) &&
defaultPrompts !== undefined &&
defaultPrompts.length !== 0 && (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{defaultPrompts.map((element, index) => {
return <Box key={index}>{element}</Box>;
})}
</Box>
)}
<Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box> </Box>
</Box> </Box>
{(noInteractions || !hideDefaultPrompts) && defaultPrompts !== undefined && defaultPrompts.length !== 0 && );
<Box sx={{ display: "flex", flexDirection: "column" }}> }
{ );
defaultPrompts.map((element, index) => {
return (<Box key={index}>{element}</Box>);
})
}
</Box>
}
<Box sx={{ display: "flex", flexGrow: 1 }}></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;
} }
@ -17,7 +17,7 @@ const CopyBubble = ({
tooltip = "Copy to clipboard", tooltip = "Copy to clipboard",
onClick, onClick,
...rest ...rest
} : CopyBubbleProps) => { }: CopyBubbleProps) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = (e: any) => { const handleCopy = (e: any) => {
@ -35,28 +35,32 @@ const CopyBubble = ({
} }
}; };
return ( return (
<Tooltip title={tooltip} placement="top" arrow> <Tooltip title={tooltip} placement="top" arrow>
<IconButton <IconButton
onClick={(e) => { handleCopy(e) }} onClick={(e) => {
sx={{ handleCopy(e);
width: 24, }}
height: 24, sx={{
opacity: 0.75, width: 24,
bgcolor: 'background.paper', height: 24,
'&:hover': { bgcolor: 'action.hover', opacity: 1 }, opacity: 0.75,
...sx, bgcolor: "background.paper",
}} "&:hover": { bgcolor: "action.hover", opacity: 1 },
size="small" ...sx,
color={copied ? "success" : "default"} }}
{...rest} size="small"
> color={copied ? "success" : "default"}
{copied ? <CheckIcon sx={{ width: 16, height: 16 }} /> : <ContentCopyIcon sx={{ width: 16, height: 16 }} />} {...rest}
</IconButton> >
</Tooltip> {copied ? (
<CheckIcon sx={{ width: 16, height: 16 }} />
) : (
<ContentCopyIcon sx={{ width: 16, height: 16 }} />
)}
</IconButton>
</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(() => {
@ -76,59 +80,68 @@ const DocumentManager = (props: BackstoryElementProps) => {
const loadDocuments = async () => { const loadDocuments = async () => {
try { try {
const results = await apiClient.getCandidateDocuments(); const results = await apiClient.getCandidateDocuments();
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":
docType = "pdf"; docType = "pdf";
break; break;
case "docx": case "docx":
docType = "docx"; docType = "docx";
break; break;
case "md": case "md":
docType = "markdown"; docType = "markdown";
break; break;
case "txt": case "txt":
docType = "txt"; docType = "txt";
break; break;
} }
if (!docType) { if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error'); setSnack(
return; "Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.",
} "error"
);
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(
onError: (error) => { file,
console.error(error); { includeInRag: true, isJobDocument: false },
setSnack(error.content, 'error'); {
onError: (error) => {
console.error(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,150 +243,189 @@ 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
<Typography variant={isMobile ? "subtitle2" : "h6"}> sx={{
Documents display: "flex",
</Typography> justifyContent: "space-between",
<Button alignItems: "center",
component="label" mb: 2,
variant="contained" width: "100%",
startIcon={<CloudUpload />} verticalAlign: "center",
size={isMobile ? "small" : "medium"}> }}
Upload Document >
<VisuallyHiddenInput <Typography variant={isMobile ? "subtitle2" : "h6"}>
type="file" Documents
accept=".txt,.md,.docx,.pdf" </Typography>
onChange={handleDocumentUpload} <Button
/> component="label"
</Button> variant="contained"
startIcon={<CloudUpload />}
size={isMobile ? "small" : "medium"}
>
Upload Document
<VisuallyHiddenInput
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleDocumentUpload}
/>
</Button>
</Box> </Box>
<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 } }}>
{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" },
No additional documents uploaded textAlign: "center",
</Typography> py: 3,
) : ( }}
<List sx={{ width: '100%' }}> >
{documents.map((doc, index) => ( No additional documents uploaded
<React.Fragment key={doc.id}> </Typography>
{index > 0 && <Divider />} ) : (
<ListItem sx={{ px: 0 }}> <List sx={{ width: "100%" }}>
<ListItemText {documents.map((doc, index) => (
primary={ <React.Fragment key={doc.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> {index > 0 && <Divider />}
<Typography variant="body1" sx={{ <ListItem sx={{ px: 0 }}>
wordBreak: 'break-word', <ListItemText
fontSize: { xs: '0.9rem', sm: '1rem' } primary={
}}> <Box
{doc.filename} sx={{
</Typography> display: "flex",
<Chip alignItems: "center",
label={doc.type.toUpperCase()} gap: 1,
size="small" flexWrap: "wrap",
color={getFileTypeColor(doc.type)} }}
/> >
{doc.options?.includeInRag && ( <Typography
<Chip variant="body1"
label="RAG" sx={{
size="small" wordBreak: "break-word",
color="success" fontSize: { xs: "0.9rem", sm: "1rem" },
variant="outlined" }}
/> >
)} {doc.filename}
</Box> </Typography>
} <Chip
secondary={ label={doc.type.toUpperCase()}
<Box sx={{ mt: 0.5 }}> size="small"
<Typography variant="caption" color="text.secondary"> color={getFileTypeColor(doc.type)}
{formatFileSize(doc.size)} {doc?.uploadDate?.toLocaleDateString()} />
</Typography> {doc.options?.includeInRag && (
<Box sx={{ mt: 1 }}> <Chip
<FormControlLabel label="RAG"
control={ size="small"
<Switch color="success"
checked={doc.options?.includeInRag} variant="outlined"
onChange={(e) => handleRAGToggle(doc, e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption">
Include in RAG
</Typography>
}
/>
</Box>
</Box>
}
/> />
<ListItemSecondaryAction> )}
<Box sx={{ display: 'flex', gap: 0.5 }}> </Box>
<IconButton }
edge="end" secondary={
size="small" <Box sx={{ mt: 0.5 }}>
onClick={() => handleViewDocument(doc)} <Typography
title="View content" variant="caption"
> color="text.secondary"
<Visibility /> >
</IconButton> {formatFileSize(doc.size)} {" "}
<IconButton {doc?.uploadDate?.toLocaleDateString()}
edge="end" </Typography>
size="small" <Box sx={{ mt: 1 }}>
onClick={() => startRename(doc, doc.filename)} <FormControlLabel
title="Rename" control={
> <Switch
<Edit /> checked={doc.options?.includeInRag}
</IconButton> onChange={(e) =>
<IconButton handleRAGToggle(doc, e.target.checked)
edge="end" }
size="small" size="small"
onClick={() => handleDeleteDocument(doc)} />
title="Delete" }
color="error" label={
> <Typography variant="caption">
<Delete /> Include in RAG
</IconButton> </Typography>
</Box> }
</ListItemSecondaryAction> />
</ListItem> </Box>
</React.Fragment> </Box>
))} }
</List> />
)} <ListItemSecondaryAction>
</CardContent> <Box sx={{ display: "flex", gap: 0.5 }}>
</Card> <IconButton
edge="end"
size="small"
onClick={() => handleViewDocument(doc)}
title="View content"
>
<Visibility />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => startRename(doc, doc.filename)}
title="Rename"
>
<Edit />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => handleDeleteDocument(doc)}
title="Delete"
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</React.Fragment>
))}
</List>
)}
</CardContent>
</Card>
</Grid> </Grid>
{/* Document Content Viewer */} {/* Document Content Viewer */}
@ -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
margin: 0, style={{
fontFamily: 'monospace', margin: 0,
fontSize: isMobile ? '0.75rem' : '0.875rem', fontFamily: "monospace",
whiteSpace: 'pre-wrap', fontSize: isMobile ? "0.75rem" : "0.875rem",
wordBreak: 'break-word' whiteSpace: "pre-wrap",
}}> wordBreak: "break-word",
{documentContent || 'Loading content...'} }}
>
{documentContent || "Loading content..."}
</pre> </pre>
</Paper> </Paper>
</CardContent> </CardContent>
@ -415,44 +481,45 @@ const DocumentManager = (props: BackstoryElementProps) => {
</Grid> </Grid>
)} )}
{/* Rename Dialog */} {/* Rename Dialog */}
<Dialog <Dialog
open={isRenameDialogOpen} open={isRenameDialogOpen}
onClose={() => setIsRenameDialogOpen(false)} onClose={() => setIsRenameDialogOpen(false)}
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
> >
<DialogTitle>Rename Document</DialogTitle> <DialogTitle>Rename Document</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
label="Document Name" label="Document Name"
fullWidth fullWidth
variant="outlined" variant="outlined"
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>
<DialogActions>
<Button onClick={() => setIsRenameDialogOpen(false)}>Cancel</Button>
<Button
onClick={() =>
editingDocument &&
handleRenameDocument(editingDocument, editingName)
} }
}} variant="contained"
/> disabled={!editingName.trim()}
</DialogContent> >
<DialogActions> Rename
<Button onClick={() => setIsRenameDialogOpen(false)}> </Button>
Cancel </DialogActions>
</Button> </Dialog>
<Button </Grid>
onClick={() => editingDocument && handleRenameDocument(editingDocument, editingName)}
variant="contained"
disabled={!editingName.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
</Grid>
</> </>
); );
}; };

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 {
const navigate = useNavigate(); verifyEmail,
const [verificationToken, setVerificationToken] = useState(''); resendEmailVerification,
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending'); getPendingVerificationEmail,
const [message, setMessage] = useState(''); isLoading,
const [userType, setUserType] = useState<string>(''); error,
} = useAuth();
const navigate = useNavigate();
const [verificationToken, setVerificationToken] = useState("");
const [status, setStatus] = useState<"pending" | "success" | "error">(
"pending"
);
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,67 +64,69 @@ 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;
} }
try { try {
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>
@ -151,29 +163,35 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
)} )}
</Box> </Box>
{isLoading && ( {isLoading && (
<Box display="flex" justifyContent="center" my={3}> <Box display="flex" justifyContent="center" my={3}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
)} )}
{(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,12 +199,12 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Box> </Box>
)} )}
{status === 'error' && ( {status === "error" && (
<Box mt={3}> <Box mt={3}>
<Button <Button
variant="outlined" variant="outlined"
onClick={handleResendVerification} onClick={handleResendVerification}
disabled={isLoading} disabled={isLoading}
startIcon={<RefreshIcon />} startIcon={<RefreshIcon />}
fullWidth fullWidth
sx={{ mb: 2 }} sx={{ mb: 2 }}
@ -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,37 +224,33 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Card> </Card>
</Box> </Box>
); );
} };
// MFA Verification Component // MFA Verification Component
interface MFAVerificationDialogProps { interface MFAVerificationDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
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);
useEffect(() => { useEffect(() => {
if (!error) { if (!error) {
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(() => {
if (!open) return; if (!open) return;
@ -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,85 +272,88 @@ 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({
email: mfaResponse.mfaData.email, email: mfaResponse.mfaData.email,
code, code,
deviceId: mfaResponse.mfaData.deviceId, deviceId: mfaResponse.mfaData.deviceId,
rememberDevice, rememberDevice,
}); });
if (success) { if (success) {
onVerificationSuccess({ success: true }); onVerificationSuccess({ success: true });
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(
if (success) { mfaResponse.mfaData.email,
mfaResponse.mfaData.deviceId,
mfaResponse.mfaData.deviceName
);
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");
} }
}; };
const handleClose = () => { const handleClose = () => {
clearMFA(); clearMFA();
onClose(); onClose();
}; };
if (!mfaResponse || !mfaResponse.mfaData) return null; if (!mfaResponse || !mfaResponse.mfaData) return null;
return ( return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<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>
We've sent a 6-digit verification code to: We've sent a 6-digit verification code to:
</Typography> </Typography>
<Typography variant="h6" color="primary" gutterBottom> <Typography variant="h6" color="primary" gutterBottom>
{mfaResponse.mfaData.email} {mfaResponse.mfaData.email}
</Typography> </Typography>
<TextField <TextField
@ -344,32 +361,37 @@ 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>
<Button <Button
size="small" size="small"
onClick={handleResendCode} onClick={handleResendCode}
disabled={isLoading || timeLeft > 540} // Allow resend after 1 minute disabled={isLoading || timeLeft > 540} // Allow resend after 1 minute
> >
Resend Code Resend Code
</Button> </Button>
@ -387,57 +409,58 @@ 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>
<DialogActions sx={{ p: 3 }}> <DialogActions sx={{ p: 3 }}>
<Button onClick={handleClose} disabled={isLoading}> <Button onClick={handleClose} disabled={isLoading}>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
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;
email: string; email: string;
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,55 +512,54 @@ 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);
useEffect(() => { useEffect(() => {
if (!error) { if (!error) {
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) => {
e.preventDefault();
const handleLogin = async (e: React.FormEvent) => { const success = await login({
e.preventDefault(); login: email,
password,
});
const success = await login({ console.log(`login success: ${success}`);
login: email, if (success) {
password // Redirect based on user type - this could be handled in AuthContext
}); // or by a higher-level component that listens to auth state changes
handleLoginSuccess();
console.log(`login success: ${success}`); }
if (success) {
// Redirect based on user type - this could be handled in AuthContext
// or by a higher-level component that listens to auth state changes
handleLoginSuccess();
}
}; };
const handleMFASuccess = (authData: any) => { const handleMFASuccess = (authData: any) => {
handleLoginSuccess(); handleLoginSuccess();
}; };
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"
@ -575,9 +599,9 @@ const LoginForm = () => {
}} }}
/> />
{errorMessage && ( {errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}> <Alert severity="error" sx={{ mt: 2 }}>
{errorMessage} {errorMessage}
</Alert> </Alert>
)} )}
@ -585,21 +609,21 @@ const LoginForm = () => {
type="submit" type="submit"
fullWidth fullWidth
variant="contained" variant="contained"
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 */}
<MFAVerificationDialog <MFAVerificationDialog
open={mfaResponse?.mfaRequired || false} open={mfaResponse?.mfaRequired || false}
onClose={() => { }} // This will be handled by clearMFA in the dialog onClose={() => {}} // This will be handled by clearMFA in the dialog
onVerificationSuccess={handleMFASuccess} onVerificationSuccess={handleMFASuccess}
/> />
</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,135 +1,146 @@
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 =
// Only keep refs that are truly necessary (user?.userType === "candidate"
const controllerRef = useRef<string>(null); ? (user as Candidate).username
: user?.email) || "";
// Only keep refs that are truly necessary
const controllerRef = useRef<string>(null);
// Effect to trigger profile generation when user data is ready // Effect to trigger profile generation when user data is ready
useEffect(() => { useEffect(() => {
if (controllerRef.current) { if (controllerRef.current) {
console.log("Controller already active, skipping profile generation"); console.log("Controller already active, skipping profile generation");
return; return;
}
if (!prompt) {
return;
}
setStatus('Starting image generation...');
setProcessing(true);
const start = Date.now();
// controllerRef.current = streamQueryResponse({
// query: {
// prompt: prompt,
// agentOptions: {
// username: name,
// }
// },
// type: "image",
// onComplete: (msg) => {
// switch (msg.status) {
// case "partial":
// case "done":
// if (msg.status === "done") {
// if (!msg.response) {
// setSnack("Image generation failed", "error");
// } else {
// setImage(msg.response);
// }
// setProcessing(false);
// controllerRef.current = null;
// }
// break;
// case "error":
// console.log(`Error generating profile: ${msg.response} after ${Date.now() - start}`);
// setSnack(msg.response || "", "error");
// setProcessing(false);
// controllerRef.current = null;
// break;
// default:
// let data: any = {};
// try {
// data = typeof msg.response === 'string' ? JSON.parse(msg.response) : msg.response;
// } catch (e) {
// data = { message: msg.response };
// }
// if (msg.status !== "heartbeat") {
// console.log(data);
// }
// if (data.message) {
// setStatus(data.message);
// }
// break;
// }
// }
// });
}, [user, prompt, setSnack]);
if (!chatSession) {
return <></>;
} }
if (!prompt) {
return;
}
setStatus("Starting image generation...");
setProcessing(true);
const start = Date.now();
return ( // controllerRef.current = streamQueryResponse({
<Box className="GenerateImage" sx={{ // query: {
// prompt: prompt,
// agentOptions: {
// username: name,
// }
// },
// type: "image",
// onComplete: (msg) => {
// switch (msg.status) {
// case "partial":
// case "done":
// if (msg.status === "done") {
// if (!msg.response) {
// setSnack("Image generation failed", "error");
// } else {
// setImage(msg.response);
// }
// setProcessing(false);
// controllerRef.current = null;
// }
// break;
// case "error":
// console.log(`Error generating profile: ${msg.response} after ${Date.now() - start}`);
// setSnack(msg.response || "", "error");
// setProcessing(false);
// controllerRef.current = null;
// break;
// default:
// let data: any = {};
// try {
// data = typeof msg.response === 'string' ? JSON.parse(msg.response) : msg.response;
// } catch (e) {
// data = { message: msg.response };
// }
// if (msg.status !== "heartbeat") {
// console.log(data);
// }
// if (data.message) {
// setStatus(data.message);
// }
// break;
// }
// }
// });
}, [user, prompt, setSnack]);
if (!chatSession) {
return <></>;
}
return (
<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}
display: "flex", sx={{ "& *": { color: "#2E2E2E !important" } }}
flexDirection: "column", />
alignItems: "center", )}
justifyContent: "center", {processing && (
m: 0, <Box
gap: 1, sx={{
minHeight: "min-content", display: "flex",
mb: 2 flexDirection: "column",
}}> alignItems: "center",
{ status && justifyContent: "center",
<Box sx={{ display: "flex", flexDirection: "column"}}> m: 0,
<Box sx={{ fontSize: "0.5rem"}}>Generation status</Box> gap: 1,
<Box sx={{ fontWeight: "bold"}}>{status}</Box> minHeight: "min-content",
</Box> mb: 2,
} }}
<PropagateLoader >
size="10px" {status && (
loading={processing} <Box sx={{ display: "flex", flexDirection: "column" }}>
color="white" <Box sx={{ fontSize: "0.5rem" }}>Generation status</Box>
aria-label="Loading Spinner" <Box sx={{ fontWeight: "bold" }}>{status}</Box>
data-testid="loader" </Box>
/> )}
</Box> <PropagateLoader
} size="10px"
</Box>); loading={processing}
color="white"
aria-label="Loading Spinner"
data-testid="loader"
/>
</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,73 +62,75 @@ 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,
}, },
})); }));
interface JobCreatorProps extends BackstoryElementProps { interface JobCreatorProps extends BackstoryElementProps {
onSave?: (job: Types.Job) => void; onSave?: (job: Types.Job) => void;
} }
const JobCreator = (props: JobCreatorProps) => { const JobCreator = (props: JobCreatorProps) => {
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const { onSave } = props; const { onSave } = props;
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(
return; "Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.",
"error"
);
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,141 +314,161 @@ 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);
}; };
const renderJobCreation = () => { const renderJobCreation = () => {
return ( return (
<Box sx={{ <Box
sx={{
width: "100%", width: "100%",
p: 1 p: 1,
}}> }}
{/* Upload Section */} >
<Card elevation={3} sx={{ mb: 4 }}> {/* Upload Section */}
<CardHeader <Card elevation={3} sx={{ mb: 4 }}>
title="Job Information" <CardHeader
subheader="Upload a job description or enter details manually" title="Job Information"
avatar={<Work color="primary" />} subheader="Upload a job description or enter details manually"
/> avatar={<Work color="primary" />}
<CardContent> />
<Grid container spacing={3}> <CardContent>
<Grid size={{ xs: 12, md: 6 }}> <Grid container spacing={3}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}> <Grid size={{ xs: 12, md: 6 }}>
<CloudUpload sx={{ mr: 1 }} /> <Typography
Upload Job Description variant="h6"
</Typography> gutterBottom
<UploadBox onClick={handleUploadClick}> sx={{ display: "flex", alignItems: "center" }}
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} /> >
<Typography variant="h6" gutterBottom> <CloudUpload sx={{ mr: 1 }} />
Drop your job description here Upload Job Description
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <UploadBox onClick={handleUploadClick}>
Supported formats: PDF, DOCX, TXT, MD <CloudUpload
</Typography> sx={{ fontSize: 48, color: "primary.main", mb: 2 }}
<Button
variant="contained"
startIcon={<FileUploadIcon />}
disabled={isProcessing}
// onClick={handleUploadClick}
>
Choose File
</Button>
</UploadBox>
<VisuallyHiddenInput
ref={fileInputRef}
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleJobUpload}
/> />
</Grid> <Typography variant="h6" gutterBottom>
Drop your job description here
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<Description sx={{ mr: 1 }} />
Or Enter Manually
</Typography> </Typography>
<TextField <Typography
fullWidth variant="body2"
multiline color="text.secondary"
rows={isMobile ? 8 : 12}
placeholder="Paste or type the job description here..."
variant="outlined"
value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
disabled={isProcessing}
sx={{ mb: 2 }} sx={{ mb: 2 }}
/> >
{jobRequirements === null && jobDescription && ( Supported formats: PDF, DOCX, TXT, MD
<Button </Typography>
variant="outlined" <Button
onClick={handleExtractRequirements} variant="contained"
startIcon={<AutoFixHigh />} startIcon={<FileUploadIcon />}
disabled={isProcessing} disabled={isProcessing}
fullWidth={isMobile} // onClick={handleUploadClick}
> >
Extract Requirements Choose File
</Button> </Button>
)} </UploadBox>
</Grid> <VisuallyHiddenInput
ref={fileInputRef}
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleJobUpload}
/>
</Grid> </Grid>
{(jobStatus || isProcessing) && ( <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ mt: 3 }}> <Typography
<StatusBox> variant="h6"
{jobStatusType && <StatusIcon type={jobStatusType} />} gutterBottom
<Typography variant="body2" sx={{ ml: 1 }}> sx={{ display: "flex", alignItems: "center" }}
{jobStatus || 'Processing...'} >
</Typography> <Description sx={{ mr: 1 }} />
</StatusBox> Or Enter Manually
{isProcessing && <LinearProgress sx={{ mt: 1 }} />} </Typography>
</Box> <TextField
)} fullWidth
</CardContent> multiline
</Card> rows={isMobile ? 8 : 12}
placeholder="Paste or type the job description here..."
{/* Job Details Section */} variant="outlined"
<Card elevation={3} sx={{ mb: 4 }}> value={jobDescription}
<CardHeader onChange={(e) => setJobDescription(e.target.value)}
title="Job Details" disabled={isProcessing}
subheader="Enter specific information about the position" sx={{ mb: 2 }}
avatar={<Business color="primary" />} />
/> {jobRequirements === null && jobDescription && (
<CardContent> <Button
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Title"
variant="outlined" variant="outlined"
value={jobTitle} onClick={handleExtractRequirements}
onChange={(e) => setJobTitle(e.target.value)} startIcon={<AutoFixHigh />}
required
disabled={isProcessing} disabled={isProcessing}
InputProps={{ fullWidth={isMobile}
startAdornment: <Work sx={{ mr: 1, color: 'text.secondary' }} /> >
}} Extract Requirements
/> </Button>
</Grid> )}
</Grid>
</Grid>
<Grid size={{ xs: 12, md: 6 }}> {(jobStatus || isProcessing) && (
<TextField <Box sx={{ mt: 3 }}>
fullWidth <StatusBox>
label="Company" {jobStatusType && <StatusIcon type={jobStatusType} />}
variant="outlined" <Typography variant="body2" sx={{ ml: 1 }}>
value={company} {jobStatus || "Processing..."}
onChange={(e) => setCompany(e.target.value)} </Typography>
required </StatusBox>
disabled={isProcessing} {isProcessing && <LinearProgress sx={{ mt: 1 }} />}
InputProps={{ </Box>
startAdornment: <Business sx={{ mr: 1, color: 'text.secondary' }} /> )}
}} </CardContent>
/> </Card>
</Grid>
{/* <Grid size={{ xs: 12, md: 6 }}> {/* Job Details Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Details"
subheader="Enter specific information about the position"
avatar={<Business color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Title"
variant="outlined"
value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)}
required
disabled={isProcessing}
InputProps={{
startAdornment: (
<Work sx={{ mr: 1, color: "text.secondary" }} />
),
}}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Company"
variant="outlined"
value={company}
onChange={(e) => setCompany(e.target.value)}
required
disabled={isProcessing}
InputProps={{
startAdornment: (
<Business sx={{ mr: 1, color: "text.secondary" }} />
),
}}
/>
</Grid>
{/* <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Job Location" label="Job Location"
@ -446,83 +481,107 @@ const JobCreator = (props: JobCreatorProps) => {
}} }}
/> />
</Grid> */} </Grid> */}
</Grid> </Grid>
</CardContent> </CardContent>
</Card>
{/* Job Summary */}
{summary !== "" && (
<Card elevation={2} sx={{ mt: 3 }}>
<CardHeader
title="Job Summary"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>{summary}</CardContent>
</Card> </Card>
)}
{/* Job Summary */} {/* Requirements Display */}
{summary !== '' && {renderJobRequirements()}
<Card elevation={2} sx={{ mt: 3 }}> </Box>
<CardHeader );
title="Job Summary" };
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>
{summary}
</CardContent>
</Card>
}
{/* Requirements Display */} return (
{renderJobRequirements()} <Box
className="JobManagement"
</Box> sx={{
); background: "white",
}; p: 0,
width: "100%",
return ( display: "flex",
<Box className="JobManagement" flexDirection: "column",
sx={{ }}
background: "white", >
p: 0, {job === null && renderJobCreation()}
width: "100%", {job && (
display: "flex", flexDirection: "column" <Box
}}> sx={{
{job === null && renderJobCreation()} display: "flex",
{job && flexDirection: "column",
<Box 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
</Box> sx={{
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}> display: "flex",
<Button flexGrow: 1,
variant="contained" position: "relative",
onClick={handleSave} maxHeight: "30rem",
disabled={!jobTitle || !company || !jobDescription || isProcessing} }}
fullWidth={isMobile} >
size="large" <JobInfo job={job} />
startIcon={<CheckCircle />} </Scrollable>
> <Scrollable
Save Job sx={{
</Button> display: "flex",
</Box> flexGrow: 1,
position: "relative",
maxHeight: "30rem",
}}
>
<StyledMarkdown content={job.description} />
</Scrollable>
</Box> </Box>
} <Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
</Box> <Button
); variant="contained"
onClick={handleSave}
disabled={
!jobTitle || !company || !jobDescription || isProcessing
}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
Save Job
</Button>
</Box>
</Box>
)}
</Box>
);
}; };
export { JobCreator }; export { JobCreator };

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,71 +69,101 @@ 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 =
setExpanded(isExpanded ? panel : false); (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
}; setExpanded(isExpanded ? panel : false);
};
const initializeRequirements = (job: Job) => { const initializeRequirements = (job: Job) => {
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;
}); });
@ -217,8 +290,15 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
setAnalyzing(false); setAnalyzing(false);
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,29 +324,46 @@ 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',
display: 'inline-flex',
mr: 2
}}>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{ sx={{
color: getMatchColor(overallScore), position: "relative",
display: "inline-flex",
mr: 2,
}} }}
>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{
color: getMatchColor(overallScore),
}}
/> />
<Box <Box
sx={{ sx={{
@ -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"
sx={{ : overallScore >= 40
? "Partial Match"
: "Low Match"
}
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:
? getMatchColor(match.matchScore) match.status === "complete"
: theme.palette.divider ? getMatchColor(match.matchScore)
: theme.palette.divider,
}} }}
> >
<AccordionSummary <AccordionSummary
@ -335,77 +447,112 @@ 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:
? `${getMatchColor(match.matchScore)}22` // Add transparency match.status === "complete"
: 'inherit' ? `${getMatchColor(match.matchScore)}22` // Add transparency
: "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>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Assessment Assessment
</Typography> </Typography>
<Typography paragraph sx={{ mb: 3 }}> <Typography paragraph sx={{ mb: 3 }}>
@ -413,29 +560,47 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
</Typography> </Typography>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Supporting Evidence Supporting Evidence
</Typography> </Typography>
{match.evidenceDetails && match.evidenceDetails.length > 0 ? ( {match.evidenceDetails &&
match.evidenceDetails.map((evidence, evndex) => ( match.evidenceDetails.length > 0 ? (
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
"{evidence.quote}" variant="body1"
component="div"
sx={{ mb: 1, fontStyle: "italic" }}
>
"{evidence.quote}"
</Typography> </Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexDirection: "column" }}> <Box
<Typography variant="body2" color="text.secondary"> sx={{
Relevance: {evidence.context} display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
flexDirection: "column",
}}
>
<Typography
variant="body2"
color="text.secondary"
>
Relevance: {evidence.context}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography
Source: {evidence.source} variant="caption"
color="text.secondary"
>
Source: {evidence.source}
</Typography> </Typography>
{/* <Chip {/* <Chip
size="small" size="small"
@ -453,20 +618,17 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
No specific evidence found in candidate's profile. No specific evidence found in candidate's profile.
</Typography> </Typography>
)} )}
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Skill description Skill description
</Typography> </Typography>
<Typography paragraph> <Typography paragraph>{match.description}</Typography>
{match.description} {/* { match.ragResults && match.ragResults.length !== 0 && <>
</Typography>
{/* { match.ragResults && match.ragResults.length !== 0 && <>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
RAG Information RAG Information
</Typography> </Typography>
<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>
@ -65,4 +72,4 @@ const LoadingComponent: React.FC<LoadingComponentProps> = ({
); );
}; };
export { LoadingComponent}; export { LoadingComponent };

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 =
? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode) selectedCountry && selectedState
: []; ? 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"
p: 2, sx={{
borderRadius: 1, bgcolor: "grey.100",
overflow: 'auto', p: 2,
fontSize: '0.875rem' borderRadius: 1,
}}> overflow: "auto",
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 {
@ -19,7 +19,7 @@ interface MermaidProps {
const Mermaid: React.FC<MermaidProps> = (props: MermaidProps) => { const Mermaid: React.FC<MermaidProps> = (props: MermaidProps) => {
const { chart, sx, className, mermaidConfig } = props; const { chart, sx, className, mermaidConfig } = props;
const [ visible, setVisible] = useState<boolean>(false); const [visible, setVisible] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const checkVisible = useCallback(() => { const checkVisible = useCallback(() => {
@ -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 (
display: "flex", <Box
flexGrow: 1, className={className || "Mermaid"}
...sx ref={containerRef}
}}> sx={{
{chart} display: "flex",
</Box>; flexGrow: 1,
...sx,
}}
>
{chart}
</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,162 +227,296 @@ 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 }}> <>
<Table aria-label="prompt stats" size="small"> <TableContainer
<TableHead> component={Card}
<TableRow> className="PromptStats"
<TableCell></TableCell> sx={{ mb: 1 }}
<TableCell align="right" >Tokens</TableCell> >
<TableCell align="right">Time (s)</TableCell> <Table aria-label="prompt stats" size="small">
<TableCell align="right">TPS</TableCell> <TableHead>
</TableRow> <TableRow>
</TableHead> <TableCell></TableCell>
<TableBody> <TableCell align="right">Tokens</TableCell>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> <TableCell align="right">Time (s)</TableCell>
<TableCell component="th" scope="row">Prompt</TableCell> <TableCell align="right">TPS</TableCell>
<TableCell align="right">{promptEvalCount}</TableCell> </TableRow>
<TableCell align="right">{Math.round(promptEvalDuration / 10 ** 7) / 100}</TableCell> </TableHead>
<TableCell align="right">{Math.round(promptEvalCount * 10 ** 9 / promptEvalDuration)}</TableCell> <TableBody>
</TableRow> <TableRow
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> key="prompt"
<TableCell component="th" scope="row">Response</TableCell> sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
<TableCell align="right">{evalCount}</TableCell> >
<TableCell align="right">{Math.round(evalDuration / 10 ** 7) / 100}</TableCell> <TableCell component="th" scope="row">
<TableCell align="right">{Math.round(evalCount * 10 ** 9 / evalDuration)}</TableCell> Prompt
</TableRow> </TableCell>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> <TableCell align="right">{promptEvalCount}</TableCell>
<TableCell component="th" scope="row">Total</TableCell> <TableCell align="right">
<TableCell align="right">{promptEvalCount + evalCount}</TableCell> {Math.round(promptEvalDuration / 10 ** 7) / 100}
<TableCell align="right">{Math.round((promptEvalDuration + evalDuration) / 10 ** 7) / 100}</TableCell> </TableCell>
<TableCell align="right">{Math.round((promptEvalCount + evalCount) * 10 ** 9 / (promptEvalDuration + evalDuration))}</TableCell> <TableCell align="right">
</TableRow> {Math.round(
</TableBody> (promptEvalCount * 10 ** 9) / promptEvalDuration
</Table> )}
</TableContainer> </TableCell>
</> </TableRow>
} <TableRow
{ key="response"
tools && tools.tool_calls && tools.tool_calls.length !== 0 && sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
<Accordion sx={{ boxSizing: "border-box" }}> >
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <TableCell component="th" scope="row">
<Box sx={{ fontSize: "0.8rem" }}> Response
Tools queried </TableCell>
</Box> <TableCell align="right">{evalCount}</TableCell>
</AccordionSummary> <TableCell align="right">
<AccordionDetails> {Math.round(evalDuration / 10 ** 7) / 100}
{ </TableCell>
tools.tool_calls.map((tool: any, index: number) => <TableCell align="right">
<Box key={index} sx={{ m: 0, p: 1, pt: 0, display: "flex", flexDirection: "column", border: "1px solid #e0e0e0" }}> {Math.round((evalCount * 10 ** 9) / evalDuration)}
{index !== 0 && <Divider />} </TableCell>
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "column", mt: 1, mb: 1, fontWeight: "bold" }}> </TableRow>
{tool.name} <TableRow
</Box> key="total"
{tool.content !== "null" && sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
<JsonView >
displayDataTypes={false} <TableCell component="th" scope="row">
objectSortKeys={true} Total
collapsed={1} value={JSON.parse(tool.content)} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}> </TableCell>
<JsonView.String <TableCell align="right">
render={({ children, ...reset }) => { {promptEvalCount + evalCount}
if (typeof (children) === "string" && children.match("\n")) { </TableCell>
return <pre {...reset} style={{ display: "flex", border: "none", ...reset.style }}>{children}</pre> <TableCell align="right">
} {Math.round((promptEvalDuration + evalDuration) / 10 ** 7) /
}} 100}
/> </TableCell>
</JsonView> <TableCell align="right">
} {Math.round(
{tool.content === "null" && "No response from tool call"} ((promptEvalCount + evalCount) * 10 ** 9) /
</Box>) (promptEvalDuration + evalDuration)
} )}
</AccordionDetails> </TableCell>
</Accordion> </TableRow>
} </TableBody>
{ </Table>
ragResults.map((collection: ChromaDBGetResponse) => ( </TableContainer>
</>
)}
{tools && tools.tool_calls && tools.tool_calls.length !== 0 && (
<Accordion sx={{ boxSizing: "border-box" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>Tools queried</Box>
</AccordionSummary>
<AccordionDetails>
{tools.tool_calls.map((tool: any, index: number) => (
<Box
key={index}
sx={{
m: 0,
p: 1,
pt: 0,
display: "flex",
flexDirection: "column",
border: "1px solid #e0e0e0",
}}
>
{index !== 0 && <Divider />}
<Box
sx={{
fontSize: "0.75rem",
display: "flex",
flexDirection: "column",
mt: 1,
mb: 1,
fontWeight: "bold",
}}
>
{tool.name}
</Box>
{tool.content !== "null" && (
<JsonView
displayDataTypes={false}
objectSortKeys={true}
collapsed={1}
value={JSON.parse(tool.content)}
style={{
fontSize: "0.8rem",
maxHeight: "20rem",
overflow: "auto",
}}
>
<JsonView.String
render={({ children, ...reset }) => {
if (
typeof children === "string" &&
children.match("\n")
) {
return (
<pre
{...reset}
style={{
display: "flex",
border: "none",
...reset.style,
}}
>
{children}
</pre>
);
}
}}
/>
</JsonView>
)}
{tool.content === "null" && "No response from tool call"}
</Box>
))}
</AccordionDetails>
</Accordion>
)}
{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 </AccordionSummary>
</Box> <AccordionDetails>
</AccordionSummary> <Box sx={{ pb: 1 }}>
<AccordionDetails> Copy LLM submission: <CopyBubble content={llm_submission} />
<Box sx={{ pb: 1 }}>Copy LLM submission: <CopyBubble content={llm_submission} /></Box> </Box>
<JsonView displayDataTypes={false} objectSortKeys={true} collapsed={1} value={message} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}> <JsonView
<JsonView.String displayDataTypes={false}
render={({ children, ...reset }) => { objectSortKeys={true}
if (typeof (children) === "string" && children.match("\n")) { collapsed={1}
return <pre {...reset} style={{ display: "inline", border: "none", ...reset.style }}>{children.trim()}</pre> value={message}
} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}
}} >
/> <JsonView.String
</JsonView> render={({ children, ...reset }) => {
</AccordionDetails> if (typeof children === "string" && children.match("\n")) {
</Accordion> return (
</>); <pre
{...reset}
style={{
display: "inline",
border: "none",
...reset.style,
}}
>
{children.trim()}
</pre>
);
}
}}
/>
</JsonView>
</AccordionDetails>
</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 (
className={`Message Message-${type}`} <Box
sx={{ className={`Message Message-${type}`}
display: "flex", sx={{
flexDirection: "column", display: "flex",
m: 0, flexDirection: "column",
mt: 1, m: 0,
marginBottom: "0px !important", // Remove whitespace from expanded Accordion mt: 1,
gap: 1, marginBottom: "0px !important", // Remove whitespace from expanded Accordion
...sx, gap: 1,
}}> ...sx,
<Box sx={{ display: "flex", flexDirection: 'row', alignItems: 'center', gap: 1 }}> }}
{icon !== null && icon} >
{messageView} <Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 1,
}}
>
{icon !== null && icon}
{messageView}
</Box>
<Box
flex={{
display: "flex",
position: "relative",
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
}}
>
{copyContent && (
<CopyBubble
content={copyContent}
sx={{ position: "absolute", top: "11px", left: 0 }}
/>
)}
{metadataView}
</Box>
</Box> </Box>
<Box flex={{ display: "flex", position: "relative", flexDirection: "row", justifyContent: "flex-end", alignItems: "center" }}> );
{copyContent && <CopyBubble content={copyContent} sx={{ position: "absolute", top: "11px", left: 0 }} />}
{metadataView}
</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 (
@ -136,7 +136,7 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
`} `}
</style> </style>
<Box sx={{...containerStyle, ...sx}}> <Box sx={{ ...containerStyle, ...sx }}>
{/* Base circle */} {/* Base circle */}
<div style={coreStyle} /> <div style={coreStyle} />
@ -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,11 +159,9 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
/> />
</> </>
)} )}
</Box> </Box>
</> </>
); );
}; };
export { Pulse } ; export { Pulse };

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,75 +101,128 @@ 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]);
return ( return (
<Box <Box
className="ResumeGenerator" className="ResumeGenerator"
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}}> }}
{user?.isAdmin && <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}> >
<Tabs value={tabValue} onChange={handleTabChange} centered> {user?.isAdmin && (
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" /> <Box sx={{ borderBottom: 1, borderColor: "divider", mb: 1 }}>
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" /> <Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" /> <Tab
</Tabs> disabled={systemPrompt === ""}
</Box>} 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>
</Box>
)}
{status && <Box sx={{ mt: 0, mb: 1 }}> {status && (
<StatusBox> <Box sx={{ mt: 0, mb: 1 }}>
{statusType && <StatusIcon type={statusType} />} <StatusBox>
<Typography variant="body2" sx={{ ml: 1 }}> {statusType && <StatusIcon type={statusType} />}
{status || 'Processing...'} <Typography variant="body2" sx={{ ml: 1 }}>
</Typography> {status || "Processing..."}
</StatusBox> </Typography>
{status && !error && <LinearProgress sx={{ mt: 1 }} />} </StatusBox>
</Box>} {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" }}> <Paper elevation={3} sx={{ p: 3, m: 1, mt: 0 }}>
{tabValue === 'system' && <pre>{systemPrompt}</pre>} <Scrollable
{tabValue === 'prompt' && <pre>{prompt}</pre>} autoscroll
{tabValue === 'resume' && <><CopyBubble onClick={() => { setSnack('Resume copied to clipboard!'); }} sx={{ position: "absolute", top: 0, right: 0 }} content={resume} /><StyledMarkdown content={resume} /></>} sx={{ display: "flex", flexGrow: 1, position: "relative" }}
</Scrollable></Paper> >
{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 }}> {resume && !status && !error && (
Save Resume <Button
</Button>} onClick={handleSave}
variant="contained"
color="primary"
sx={{ mt: 2 }}
>
Save Resume
</Button>
)}
</Box> </Box>
) );
};
export {
ResumeGenerator
}; };
export { 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,80 +1,85 @@
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 const [open, setOpen] = useState(false);
}: SnackProps, ref) => { const [message, setMessage] = useState("");
const [open, setOpen] = useState(false); const [severity, setSeverity] = useState<SeverityType>("success");
const [message, setMessage] = useState("");
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>(
setTimeout(() => { (message: string, severity: SeverityType = "success") => {
setMessage(message); setTimeout(() => {
setSeverity(severity); setMessage(message);
setOpen(true); setSeverity(severity);
}); 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;
} }
setOpen(false); setOpen(false);
}; };
return ( return (
<Snackbar <Snackbar
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
<Alert }
onClose={handleSnackClose} onClose={handleSnackClose}
severity={severity}
variant="filled"
sx={{ width: '100%' }}
> >
{message} <Alert
</Alert> onClose={handleSnackClose}
</Snackbar> severity={severity}
) variant="filled"
}); sx={{ width: "100%" }}
>
{message}
</Alert>
</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,64 +51,89 @@ 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 (
<JsonView <Scrollable className="JsonViewScrollable">
className="JsonView" <JsonView
style={{ className="JsonView"
...vscodeTheme, style={{
fontSize: "0.8rem", ...vscodeTheme,
maxHeight: "10rem", fontSize: "0.8rem",
padding: "14px 0", maxHeight: "10rem",
overflow: "hidden", padding: "14px 0",
width: "100%", overflow: "hidden",
minHeight: "max-content", width: "100%",
backgroundColor: "transparent", minHeight: "max-content",
}} backgroundColor: "transparent",
displayDataTypes={false}
objectSortKeys={false}
collapsed={1}
shortenTextAfterLength={100}
value={fixed}>
<JsonView.String
render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) {
return <pre {...reset} style={{ display: "flex", border: "none", ...reset.style }}>{children}</pre>
}
}} }}
/> displayDataTypes={false}
</JsonView> objectSortKeys={false}
</Scrollable> collapsed={1}
shortenTextAfterLength={100}
value={fixed}
>
<JsonView.String
render={({ children, ...reset }) => {
if (
typeof children === "string" &&
children.match("\n")
) {
return (
<pre
{...reset}
style={{
display: "flex",
border: "none",
...reset.style,
}}
>
{children}
</pre>
);
}
}}
/>
</JsonView>
</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,31 +162,31 @@ 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 (
className={`MuiMarkdown ${className || ""}`} <Box
sx={{ className={`MuiMarkdown ${className || ""}`}
display: "flex", sx={{
m: 0, display: "flex",
p: 0, m: 0,
boxSizing: "border-box", p: 0,
flexGrow: 1, boxSizing: "border-box",
height: "auto", flexGrow: 1,
...sx height: "auto",
}}> ...sx,
<MuiMarkdown }}
overrides={overrides} >
children={content} <MuiMarkdown overrides={overrides} 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,49 +264,49 @@ 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:
* filtered is for any item not in the querySet * filtered is for any item not in the querySet
* query is for any item that is in the querySet * query is for any item that is in the querySet
*/ */
full.ids.forEach((id, index) => { full.ids.forEach((id, index) => {
const foundIndex = querySet.ids.indexOf(id); const foundIndex = querySet.ids.indexOf(id);
/* Update metadata to hold the doc content and id */ /* Update metadata to hold the doc content and id */
@ -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', {
x: filtered_x, name: "All data",
y: filtered_y, x: filtered_x,
mode: 'markers', y: filtered_y,
marker: { mode: "markers",
size: filtered_sizes, marker: {
symbol: 'circle', size: filtered_sizes,
color: filtered_colors, symbol: "circle",
opacity: 1 color: filtered_colors,
opacity: 1,
},
text: filtered.ids,
customdata: filtered.metadatas,
type: is3D ? "scatter3d" : "scatter",
hovertemplate: "&nbsp;",
}, },
text: filtered.ids, {
customdata: filtered.metadatas, name: "Query",
type: is3D ? 'scatter3d' : 'scatter',
hovertemplate: '&nbsp;',
}, {
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,79 +437,102 @@ 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 (
<div>Loading visualization...</div> <Box
</Box> sx={{
); display: "flex",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<div>Loading visualization...</div>
</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
</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>
);
const fetchRAGMeta = async (node: Node) => { const fetchRAGMeta = async (node: Node) => {
try { try {
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={{
display: "flex", p: 0.5,
flexGrow: 0, m: 0,
height: isMobile ? "auto" : "auto", //"320px", display: "flex",
minHeight: isMobile ? "auto" : "auto", //"320px", flexGrow: 0,
maxHeight: isMobile ? "auto" : "auto", //"320px", height: isMobile ? "auto" : "auto", //"320px",
position: "relative", minHeight: isMobile ? "auto" : "auto", //"320px",
flexDirection: "column" maxHeight: isMobile ? "auto" : "auto", //"320px",
}}> position: "relative",
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 && (
<TableCell>File</TableCell> <TableRow>
<TableCell>{node.source_file.replace(/^.*\//, '')}</TableCell> <TableCell>File</TableCell>
</TableRow>} <TableCell>
{node.path !== undefined && <TableRow> {node.source_file.replace(/^.*\//, "")}
<TableCell>Section</TableCell> </TableCell>
<TableCell>{node.path}</TableCell> </TableRow>
</TableRow>} )}
{node.distance !== undefined && <TableRow> {node.path !== undefined && (
<TableCell>Distance</TableCell> <TableRow>
<TableCell>{node.distance}</TableCell> <TableCell>Section</TableCell>
</TableRow>} <TableCell>{node.path}</TableCell>
</TableRow>
)}
{node.distance !== undefined && (
<TableRow>
<TableCell>Distance</TableCell>
<TableCell>{node.distance}</TableCell>
</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={{
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>
</Paper> </Paper>
} )}
</Box> </Box>
} )}
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 2, flexBasis: 0, flexShrink: 1 }}> <Box
{node === null && sx={{
display: "flex",
flexDirection: "column",
flexGrow: 2,
flexBasis: 0,
flexShrink: 1,
}}
>
{node === null && (
<Paper sx={{ m: 0.5, p: 2, flexGrow: 1 }}> <Paper sx={{ m: 0.5, p: 2, flexGrow: 1 }}>
Click a point in the scatter-graph to see information about that node. Click a point in the scatter-graph to see information about that
node.
</Paper> </Paper>
} )}
{node !== null && node.fullContent && {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 =
const bgColor = (index > node.lineBegin && index <= node.lineEnd) ? '#f0f0f0' : 'auto'; index > node.lineBegin && index <= node.lineEnd
return <Box key={index} sx={{ display: "flex", flexDirection: "row", borderBottom: '1px solid #d0d0d0', ':first-of-type': { borderTop: '1px solid #d0d0d0' }, backgroundColor: bgColor }}> ? "#f0f0f0"
<Box sx={{ fontFamily: 'courier', fontSize: "0.8rem", minWidth: "2rem", pt: "0.1rem", align: "left", verticalAlign: "top" }}>{index}</Box> : "auto";
<pre style={{ margin: 0, padding: 0, border: "none", minHeight: "1rem", overflow: "hidden" }} >{line || " "}</pre> return (
</Box>; <Box
}) key={index}
} sx={{
{!node.lineBegin && <pre style={{ margin: 0, padding: 0, border: "none" }}>{node.content}</pre>} 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={{ >
display: "flex", <Box
p: { xs: 0, sm: 0.5 }, sx={{
flexGrow: 1, display: "flex",
minHeight: "min-content", p: { xs: 0, sm: 0.5 },
}}> flexGrow: 1,
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,57 +104,67 @@ 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
if (!route.path || !route.component) return null; .map((route, index) => {
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.component.props || {}), {
}); ...(route.id === "chat" && { ref: chatRef }),
...(route.component.props || {}),
}
);
return ( return (
<Route <Route
key={`${route.id}-${index}`} key={`${route.id}-${index}`}
path={route.path} path={route.path}
element={componentWithProps} element={componentWithProps}
/> />
); );
}).filter(Boolean); })
.filter(Boolean);
}; };
return ( return (
<Box sx={{ <Box
height: "100%", sx={{
maxHeight: "100%", height: "100%",
minHeight: "100%", maxHeight: "100%",
flexDirection: "column" minHeight: "100%",
}}> flexDirection: "column",
}}
>
<Header <Header
currentPath={page} currentPath={page}
navigate={navigate} navigate={navigate}
navigationItems={navigationItems} navigationItems={navigationItems}
/> />
<Box sx={{ <Box
display: "flex", sx={{
width: "100%", display: "flex",
maxHeight: "100%", width: "100%",
minHeight: "100%", maxHeight: "100%",
flex: 1, minHeight: "100%",
m: 0, flex: 1,
p: 0, m: 0,
flexDirection: "column", p: 0,
backgroundColor: "#D3CDBF", /* Warm Gray */ flexDirection: "column",
}}> 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,25 +24,30 @@ 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
if (!route.path || !route.component) return null; .map((route: NavigationItem, index: number) => {
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(
// Special handling for chat component ref route.component as React.ReactElement,
...(route.id === 'chat' && { ref: chatRef }), {
// Preserve any existing props // Special handling for chat component ref
...(route.component.props || {}), ...(route.id === "chat" && { ref: chatRef }),
}); // Preserve any existing props
...(route.component.props || {}),
}
);
return ( return (
<Route <Route
key={`${route.id}-${index}`} key={`${route.id}-${index}`}
path={route.path} path={route.path}
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,19 +50,19 @@ 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 (
<FooterContainer elevation={0} > <FooterContainer elevation={0}>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Grid container spacing={4} justifyContent="space-between"> <Grid container spacing={4} justifyContent="space-between">
{/* About Company */} {/* About Company */}
@ -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,51 +169,63 @@ const Footer = () => {
</Grid> </Grid>
{/* Quick Links */} {/* Quick Links */}
{false && <> {false && (
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <>
<FooterHeading variant="subtitle1"> <Grid size={{ xs: 12, sm: 6, md: 2 }}>
For Candidates <FooterHeading variant="subtitle1">
</FooterHeading> For Candidates
<FooterLink href="/create-profile">Create Profile</FooterLink> </FooterHeading>
<FooterLink href="/backstory-editor">Backstory Editor</FooterLink> <FooterLink href="/create-profile">Create Profile</FooterLink>
<FooterLink href="/resume-builder">Resume Builder</FooterLink> <FooterLink href="/backstory-editor">
<FooterLink href="/career-resources">Career Resources</FooterLink> Backstory Editor
<FooterLink href="/interview-tips">Interview Tips</FooterLink> </FooterLink>
</Grid> <FooterLink href="/resume-builder">Resume Builder</FooterLink>
</>} <FooterLink href="/career-resources">
Career Resources
</FooterLink>
<FooterLink href="/interview-tips">Interview Tips</FooterLink>
</Grid>
</>
)}
{/* Quick Links */} {/* Quick Links */}
{false && <> {false && (
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <>
<FooterHeading variant="subtitle1"> <Grid size={{ xs: 12, sm: 6, md: 2 }}>
For Employers <FooterHeading variant="subtitle1">For Employers</FooterHeading>
</FooterHeading> <FooterLink href="/post-job">Post a Job</FooterLink>
<FooterLink href="/post-job">Post a Job</FooterLink> <FooterLink href="/search-candidates">
<FooterLink href="/search-candidates">Search Candidates</FooterLink> Search Candidates
<FooterLink href="/company-profile">Company Profile</FooterLink> </FooterLink>
<FooterLink href="/recruiting-tools">Recruiting Tools</FooterLink> <FooterLink href="/company-profile">Company Profile</FooterLink>
<FooterLink href="/pricing-plans">Pricing Plans</FooterLink> <FooterLink href="/recruiting-tools">
</Grid> Recruiting Tools
</>} </FooterLink>
<FooterLink href="/pricing-plans">Pricing Plans</FooterLink>
</Grid>
</>
)}
{/* Contact */} {/* Contact */}
{false && <> {false && (
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <>
<FooterHeading variant="subtitle1"> <Grid size={{ xs: 12, sm: 6, md: 2 }}>
Company <FooterHeading variant="subtitle1">Company</FooterHeading>
</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> <FooterLink href="/press">Press</FooterLink>
<FooterLink href="/press">Press</FooterLink> <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 <>
direction={isMobile ? 'column' : 'row'} <Stack
spacing={isMobile ? 1 : 3} direction={isMobile ? "column" : "row"}
sx={{ textAlign: { xs: 'left', md: 'right' } }} spacing={isMobile ? 1 : 3}
> sx={{ textAlign: { xs: "left", md: "right" } }}
<FooterLink href="/terms" sx={{ mb: 0 }}>Terms of Service</FooterLink> >
<FooterLink href="/privacy" sx={{ mb: 0 }}>Privacy Policy</FooterLink> <FooterLink href="/terms" sx={{ mb: 0 }}>
<FooterLink href="/accessibility" sx={{ mb: 0 }}>Accessibility</FooterLink> Terms of Service
<FooterLink href="/sitemap" sx={{ mb: 0 }}>Sitemap</FooterLink> </FooterLink>
</Stack> <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>
</>
)}
</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 && (
<ListItemText primary={item.label} /> <ListItemIcon sx={{ minWidth: 36 }}>
</ListItemButton> {item.icon}
</ListItem> </ListItemIcon>
)}
<ListItemText primary={item.label} />
</ListItemButton>
</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
width: 32, sx={{
height: 32, width: 32,
bgcolor: theme.palette.secondary.main, height: 32,
}}> 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 theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const {
sx = {},
variant = isMobile ? "small" : "normal",
} = props;
const aibannerRef = useRef<HTMLElement | null>(null);
return (
<Box sx={sx} className='aibanner-clipper'>
<Box ref={aibannerRef} className={` aibanner-label-${variant} aibanner-label`}>
<Box>AI Generated</Box>
</Box>
</Box>
);
}; };
export { const AIBanner: React.FC<AIBannerProps> = (props: AIBannerProps) => {
AIBanner const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const { sx = {}, variant = isMobile ? "small" : "normal" } = props;
const aibannerRef = useRef<HTMLElement | null>(null);
return (
<Box sx={sx} className="aibanner-clipper">
<Box
ref={aibannerRef}
className={` aibanner-label-${variant} aibanner-label`}
>
<Box>AI Generated</Box>
</Box>
</Box>
);
}; };
export { AIBanner };

View File

@ -1,39 +1,39 @@
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 (
variant="h6" <Typography
className="BackstoryLogo" variant="h6"
noWrap className="BackstoryLogo"
sx={{ noWrap
cursor: "pointer", sx={{
fontWeight: 700, cursor: "pointer",
letterSpacing: '.2rem', fontWeight: 700,
color: theme.palette.primary.contrastText, letterSpacing: ".2rem",
textDecoration: 'none', color: theme.palette.primary.contrastText,
display: "inline-flex", textDecoration: "none",
flexDirection: "row", display: "inline-flex",
alignItems: "center", flexDirection: "row",
verticalAlign: "center", alignItems: "center",
gap: 1, verticalAlign: "center",
textTransform: "uppercase", gap: 1,
}} textTransform: "uppercase",
> }}
<Avatar sx={{ width: 24, height: 24 }} >
variant="rounded" <Avatar
alt="Backstory logo" sx={{ width: 24, height: 24 }}
src="/logo192.png" /> variant="rounded"
Backstory alt="Backstory logo"
</Typography> src="/logo192.png"
/>
Backstory
</Typography>
);
}; };
export { BackstoryLogo }; export { BackstoryLogo };

View File

@ -1,52 +1,59 @@
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 { onClick, adaptive = true, sx = {} } = props;
const betaRef = useRef<HTMLElement | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [animationKey, setAnimationKey] = useState<number>(0);
const [firstPass, setFirstPass] = useState<boolean>(true);
useEffect(() => {
// Initial animation trigger
if (firstPass && betaRef.current) {
triggerAnimation();
setFirstPass(false);
}
}, [firstPass]);
const triggerAnimation = (): void => {
if (!betaRef.current) return;
// Increment animation key to force React to recreate the element
setAnimationKey(prevKey => prevKey + 1);
// Ensure the animate class is present
betaRef.current.classList.add('animate');
};
return (
<Box 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>BETA</Box>
</Box>
</Box>
);
}; };
export { const Beta: React.FC<BetaProps> = (props: BetaProps) => {
Beta const { onClick, adaptive = true, sx = {} } = props;
const betaRef = useRef<HTMLElement | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [animationKey, setAnimationKey] = useState<number>(0);
const [firstPass, setFirstPass] = useState<boolean>(true);
useEffect(() => {
// Initial animation trigger
if (firstPass && betaRef.current) {
triggerAnimation();
setFirstPass(false);
}
}, [firstPass]);
const triggerAnimation = (): void => {
if (!betaRef.current) return;
// Increment animation key to force React to recreate the element
setAnimationKey((prevKey) => prevKey + 1);
// Ensure the animate class is present
betaRef.current.classList.add("animate");
};
return (
<Box
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>BETA</Box>
</Box>
</Box>
);
}; };
export { 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
display: "flex", sx={{
flexDirection: isMobile ? "column" : "row", display: "flex",
alignItems: "left", flexDirection: isMobile ? "column" : "row",
gap: 1, "& > .MuiTypography-root": { m: 0 } alignItems: "left",
}}> 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 && (
<strong>Phone:</strong> {candidate.phone} <Typography variant="body2">
</Typography> <strong>Phone:</strong> {candidate.phone}
} </Typography>
</>} )}
</Box> </>
{isAdmin && ai && )}
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "flex-start" }}> </Box>
{isAdmin && ai && (
<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,87 +1,98 @@
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;
const { apiClient, user } = useAuth(); const { apiClient, user } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate(); const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const navigate = useNavigate(); const navigate = useNavigate();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [candidates, setCandidates] = useState<Candidate[] | null>(null); const [candidates, setCandidates] = useState<Candidate[] | null>(null);
useEffect(() => { useEffect(() => {
if (candidates !== null) { if (candidates !== null) {
return; return;
} }
const getCandidates = async () => { const getCandidates = async () => {
try { try {
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);
} }
if (result === 0) { if (result === 0) {
result = a.firstName.localeCompare(b.firstName); result = a.firstName.localeCompare(b.firstName);
} }
if (result === 0) { if (result === 0) {
result = a.username.localeCompare(b.username); result = a.username.localeCompare(b.username);
} }
return result; return result;
}); });
setCandidates(candidates); setCandidates(candidates);
} catch (err) { } catch (err) {
setSnack("" + err); setSnack("" + err);
} }
}; };
getCandidates(); getCandidates();
}, [candidates, setSnack]); }, [candidates, setSnack]);
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",
sx={{ }}
maxWidth: "100%", >
minWidth: "320px", {candidates?.map((u, i) => (
width: "320px", <Paper
"cursor": "pointer", key={`${u.username}`}
backgroundColor: (selectedCandidate?.id === u.id) ? "#f0f0f0" : "inherit", onClick={() => {
border: "2px solid transparent", onSelect ? onSelect(u) : setSelectedCandidate(u);
"&:hover": { }}
border: "2px solid orange" sx={{ cursor: "pointer" }}
} >
}} <CandidateInfo
candidate={u} variant="small"
/> sx={{
</Paper> maxWidth: "100%",
)} minWidth: "320px",
</Box> width: "320px",
</Box> cursor: "pointer",
); backgroundColor:
selectedCandidate?.id === u.id ? "#f0f0f0" : "inherit",
border: "2px solid transparent",
"&:hover": {
border: "2px solid orange",
},
}}
candidate={u}
/>
</Paper>
))}
</Box>
</Box>
);
}; };
export { export { CandidatePicker };
CandidatePicker
};

View File

@ -1,25 +1,23 @@
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 { children } = props;
const theme = useTheme();
return (
<Box className="ComingSoon">
<Box className="ComingSoon-label">Coming Soon</Box>
{children}
</Box>
);
}; };
export { const ComingSoon: React.FC<ComingSoonProps> = (props: ComingSoonProps) => {
ComingSoon const { children } = props;
const theme = useTheme();
return (
<Box className="ComingSoon">
<Box className="ComingSoon-label">Coming Soon</Box>
{children}
</Box>
);
}; };
export { 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,163 +256,244 @@ 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={{
<Box>Summary</Box> display: "flex",
<Box sx={{ minHeight: variant === "small" ? "5rem" : "inherit" }}> flexDirection: "column",
<Typography width:
ref={descriptionRef} variant !== "small" && variant !== "minimal" ? "75%" : "100%",
variant="body1" }}
color="text.secondary" >
sx={{ {!isMobile && activeJob.summary && (
display: '-webkit-box', <Box sx={{ fontSize: "0.8rem" }}>
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3, <Box>Summary</Box>
WebkitBoxOrient: 'vertical', <Box
overflow: 'hidden', sx={{ minHeight: variant === "small" ? "5rem" : "inherit" }}
textOverflow: 'ellipsis',
lineHeight: 1.5,
fontSize: "0.8rem !important",
}}
> >
{activeJob.summary} <Typography
</Typography> ref={descriptionRef}
{shouldShowMoreButton && ( variant="body1"
<Link color="text.secondary"
component="button"
variant="body2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
sx={{ sx={{
color: theme.palette.primary.main, display: "-webkit-box",
textDecoration: 'none', WebkitLineClamp: isDescriptionExpanded ? "unset" : 3,
cursor: 'pointer', WebkitBoxOrient: "vertical",
fontSize: '0.725rem', overflow: "hidden",
fontWeight: 500, textOverflow: "ellipsis",
mt: 0.5, lineHeight: 1.5,
display: 'block', fontSize: "0.8rem !important",
'&:hover': {
textDecoration: 'underline',
}
}} }}
> >
[{isDescriptionExpanded ? "less" : "more"}] {activeJob.summary}
</Link> </Typography>
)} {shouldShowMoreButton && (
<Link
component="button"
variant="body2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
sx={{
color: theme.palette.primary.main,
textDecoration: "none",
cursor: "pointer",
fontSize: "0.725rem",
fontWeight: 500,
mt: 0.5,
display: "block",
"&:hover": {
textDecoration: "underline",
},
}}
>
[{isDescriptionExpanded ? "less" : "more"}]
</Link>
)}
</Box>
</Box> </Box>
</Box>} )}
</Box> </Box>
</Box> </Box>
{(variant !== "small" && variant !== "minimal") && <> {variant !== "small" && variant !== "minimal" && (
{activeJob.details && <>
<Typography variant="body2" sx={{ mb: 1 }}> {activeJob.details && (
<strong>Location:</strong> {activeJob.details.location.city}, {activeJob.details.location.state || activeJob.details.location.country} <Typography variant="body2" sx={{ mb: 1 }}>
</Typography> <strong>Location:</strong> {activeJob.details.location.city},{" "}
} {activeJob.details.location.state ||
{activeJob.owner && <Typography variant="body2"> activeJob.details.location.country}
<strong>Submitted by:</strong> {activeJob.owner.fullName} </Typography>
</Typography>} )}
{activeJob.createdAt && {activeJob.owner && (
<Typography variant="caption">Created: {activeJob.createdAt.toISOString()}</Typography> <Typography variant="body2">
} <strong>Submitted by:</strong> {activeJob.owner.fullName}
{activeJob.updatedAt && </Typography>
<Typography variant="caption">Updated: {activeJob.updatedAt.toISOString()}</Typography> )}
} {activeJob.createdAt && (
<Typography variant="caption">Job ID: {job.id}</Typography> <Typography variant="caption">
</>} Created: {activeJob.createdAt.toISOString()}
{variant === 'all' && <StyledMarkdown sx={{ display: "flex" }} content={activeJob.description} />} </Typography>
)}
{activeJob.updatedAt && (
<Typography variant="caption">
Updated: {activeJob.updatedAt.toISOString()}
</Typography>
)}
<Typography variant="caption">Job ID: {job.id}</Typography>
</>
)}
{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={{
<Tooltip title="Save Job"> 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">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleSave();
}}
>
<SaveIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete Job">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { e.stopPropagation(); handleSave(); }} onClick={(e) => {
e.stopPropagation();
deleteJob(job.id);
setDeleted(true);
}}
> >
<SaveIcon /> <DeleteIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reset Job">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleReset();
}}
>
<RestoreIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reprocess Job">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleRefresh();
}}
>
<ModelTrainingIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
}
<Tooltip title="Delete Job">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); deleteJob(job.id); setDeleted(true) }}
>
<DeleteIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reset Job">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); handleReset(); }}
>
<RestoreIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reprocess Job">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); handleRefresh(); }}
>
<ModelTrainingIcon />
</IconButton>
</Tooltip>
</Box>
{adminStatus &&
<Box sx={{ mt: 3 }}>
<StatusBox>
{adminStatusType && <StatusIcon type={adminStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{adminStatus || 'Processing...'}
</Typography>
</StatusBox>
{adminStatus && <LinearProgress sx={{ mt: 1 }} />}
</Box> </Box>
} {adminStatus && (
</Box> <Box sx={{ mt: 3 }}>
} <StatusBox>
</Box > {adminStatusType && <StatusIcon type={adminStatusType} />}
</Box > <Typography variant="body2" sx={{ ml: 1 }}>
{adminStatus || "Processing..."}
</Typography>
</StatusBox>
{adminStatus && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
</Box>
)}
</Box>
</Box>
); );
}; };

View File

@ -1,78 +1,90 @@
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;
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[] | null>(null); const [jobs, setJobs] = useState<Job[] | null>(null);
useEffect(() => { useEffect(() => {
if (jobs !== null) { if (jobs !== null) {
return; return;
} }
const getJobs = async () => { const getJobs = async () => {
try { try {
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;
}); });
setJobs(jobs); setJobs(jobs);
} catch (err) { } catch (err) {
setSnack("" + err); setSnack("" + err);
} }
}; };
getJobs(); getJobs();
}, [jobs, setSnack]); }, [jobs, setSnack]);
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",
sx={{ }}
maxWidth: "100%", >
minWidth: "320px", {jobs?.map((j, i) => (
width: "320px", <Paper
"cursor": "pointer", key={`${j.id}`}
backgroundColor: (selectedJob?.id === j.id) ? "#f0f0f0" : "inherit", onClick={() => {
border: "2px solid transparent", console.log("Selected job", j);
"&:hover": { onSelect && onSelect(j);
border: "2px solid orange" }}
} sx={{ cursor: "pointer" }}
}} >
job={j} <JobInfo
/> variant="small"
</Paper> sx={{
)} maxWidth: "100%",
</Box> minWidth: "320px",
</Box> width: "320px",
); cursor: "pointer",
backgroundColor:
selectedJob?.id === j.id ? "#f0f0f0" : "inherit",
border: "2px solid transparent",
"&:hover": {
border: "2px solid orange",
},
}}
job={j}
/>
</Paper>
))}
</Box>
</Box>
);
}; };
export { export { JobPicker };
JobPicker
};

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +1,33 @@
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;
} }
const LoginRequired = (props: LoginRequiredProps) => { const LoginRequired = (props: LoginRequiredProps) => {
const { asset } = props; const { asset } = props;
const navigate = useNavigate(); const navigate = useNavigate();
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
Log In variant="contained"
</Button> onClick={() => {
</Paper> navigate("/login");
</Container> }}
); color="primary"
sx={{ mt: 2 }}
>
Log In
</Button>
</Paper>
</Container>
);
}; };
export { LoginRequired }; export { LoginRequired };

View File

@ -1,22 +1,20 @@
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) => {
const { children } = props; const { children } = props;
return ( return (
<Box className="LoginRestricted"> <Box className="LoginRestricted">
<Box className="LoginRestricted-label"> <Box className="LoginRestricted-label">
You must login to access this feature You must login to access this feature
</Box> </Box>
{children} {children}
</Box> </Box>
); );
}; };
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,
@ -23,11 +23,11 @@ import {
Dialog, Dialog,
DialogTitle, DialogTitle,
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,27 +64,26 @@ 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);
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false); const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
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) {
@ -100,14 +99,14 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
} }
}, [resume.resume]); }, [resume.resume]);
const deleteResume = async (id: string | undefined) => { const deleteResume = async (id: string | undefined) => {
if (id) { if (id) {
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 || "",
setActiveResume(updatedResume); editContent
setSnack('Resume updated successfully.'); );
const updatedResume = {
...activeResume,
resume: editContent,
updatedAt: new Date(),
};
setActiveResume(updatedResume);
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,54 +146,68 @@ 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);
}; };
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
if (newValue === "print") { if (newValue === "print") {
reactToPrintFn(); reactToPrintFn();
return; return;
} }
setTabValue(newValue); setTabValue(newValue);
}; };
return ( return (
<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={{
{/* Header Information */}
<Box sx={{
display: "flex", display: "flex",
flexDirection: isMobile ? "column" : "row", flexGrow: 1,
gap: 2, p: 1,
mb: 2 pb: 0,
}}> height: "100%",
flexDirection: "column",
alignItems: "stretch",
position: "relative",
}}
>
{/* Header Information */}
<Box
sx={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
gap: 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
@ -229,7 +256,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
Updated: {formatDate(activeResume.updatedAt)} Updated: {formatDate(activeResume.updatedAt)}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Resume ID: {activeResume.id} Resume ID: {activeResume.id}
</Typography> </Typography>
</Stack> </Stack>
</Grid> </Grid>
@ -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>
@ -349,27 +411,28 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Box> </Box>
)} )}
{/* Print Dialog */} {/* Print Dialog */}
<Dialog <Dialog
open={printDialogOpen} open={printDialogOpen}
onClose={() => { }}//setPrintDialogOpen(false)} onClose={() => {}} //setPrintDialogOpen(false)}
maxWidth="lg" maxWidth="lg"
fullWidth fullWidth
fullScreen={true} fullScreen={true}
> >
<StyledMarkdown <StyledMarkdown
content={activeResume.resume} content={activeResume.resume}
sx={{ sx={{
p: 2, p: 2,
position: "relative", position: "relative",
maxHeight: "100%", maxHeight: "100%",
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 */}
<Dialog <Dialog
@ -377,103 +440,124 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
onClose={() => setEditDialogOpen(false)} onClose={() => setEditDialogOpen(false)}
maxWidth="lg" maxWidth="lg"
fullWidth fullWidth
disableEscapeKeyDown={true} disableEscapeKeyDown={true}
fullScreen={true} fullScreen={true}
> >
<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{" "}
</Typography> {activeResume.candidate?.fullName || activeResume.candidateId},{" "}
<Typography variant="caption" display="block" color="text.secondary"> {activeResume.job?.title || "No Job Title Assigned"},{" "}
Resume ID: # {activeResume.id} {activeResume.job?.company || "No Company Assigned"}
</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'} Resume ID: # {activeResume.id}
</Typography> </Typography>
<Typography variant="caption" display="block" color="text.secondary">
Last saved:{" "}
{activeResume.updatedAt
? new Date(activeResume.updatedAt).toLocaleString()
: "N/A"}
</Typography>
</DialogTitle> </DialogTitle>
<DialogContent sx={{ position: "relative", display: "flex", flexDirection: "column", height: "100%" }}> <DialogContent
<Tabs value={tabValue} onChange={handleTabChange} centered> sx={{
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" /> position: "relative",
<Tab value="preview" icon={<PreviewIcon />} label="Preview" /> display: "flex",
<Tab value="job" icon={<WorkIcon />} label="Job" /> flexDirection: "column",
<Tab value="print" icon={<PrintIcon />} label="Print" /> height: "100%",
</Tabs> }}
<Box ref={printContentRef} sx={{ >
display: "flex", flexDirection: "column", <Tabs value={tabValue} onChange={handleTabChange} centered>
height: "100%", /* Restrict to main-container's height */ <Tab
width: "100%", value="markdown"
minHeight: 0,/* Prevent flex overflow */ icon={<EditDocumentIcon />}
//maxHeight: "min-content", label="Markdown"
"& > *:not(.Scrollable)": { />
flexShrink: 0, /* Prevent shrinking */ <Tab value="preview" icon={<PreviewIcon />} label="Preview" />
}, <Tab value="job" icon={<WorkIcon />} label="Job" />
position: "relative", <Tab value="print" icon={<PrintIcon />} label="Print" />
}}> </Tabs>
<Box
{tabValue === "markdown" && ref={printContentRef}
<BackstoryTextField sx={{
value={editContent} display: "flex",
onChange={(value) => setEditContent(value)} flexDirection: "column",
style={{ height: "100%" /* Restrict to main-container's height */,
position: "relative", width: "100%",
// maxHeight: "100%", minHeight: 0 /* Prevent flex overflow */,
height: "100%", //maxHeight: "min-content",
width: "100%", "& > *:not(.Scrollable)": {
display: "flex", flexShrink: 0 /* Prevent shrinking */,
minHeight: "100%", },
position: "relative",
flexGrow: 1, }}
flex: 1, /* Take remaining space in some-container */ >
overflowY: "auto", /* Scroll if content overflows */ {tabValue === "markdown" && (
}} <BackstoryTextField
placeholder="Enter resume content..." value={editContent}
/> onChange={(value) => setEditContent(value)}
} style={{
{tabValue === "preview" && <> position: "relative",
<StyledMarkdown // maxHeight: "100%",
sx={{ height: "100%",
p: 2, width: "100%",
position: "relative", display: "flex",
maxHeight: "100%", minHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}}
content={editContent} />
<Box sx={{ pb: 2 }}></Box></>
}
{tabValue === "job" && activeResume.job && <JobInfo
variant="all"
job={activeResume.job}
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}}
/>}
</Box>
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: "auto" /* Scroll if content overflows */,
}}
placeholder="Enter resume content..."
/>
)}
{tabValue === "preview" && (
<>
<StyledMarkdown
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: "auto" /* Scroll if content overflows */,
}}
content={editContent}
/>
<Box sx={{ pb: 2 }}></Box>
</>
)}
{tabValue === "job" && activeResume.job && (
<JobInfo
variant="all"
job={activeResume.job}
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: "auto" /* Scroll if content overflows */,
}}
/>
)}
</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>

File diff suppressed because it is too large Load Diff

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,
@ -30,26 +30,26 @@ const StatusBox = styled(Box)(({ theme }) => ({
})); }));
const StatusIcon = (props: StatusIconProps) => { 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>
@ -138,16 +136,16 @@ export const navigationConfig: NavigationConfig = {
// icon: <SearchIcon />, // icon: <SearchIcon />,
// userTypes: ["candidate", "guest", "employer"], // userTypes: ["candidate", "guest", "employer"],
// children: [ // children: [
// { // {
// id: "explore-candidates", // id: "explore-candidates",
// label: "Candidates", // label: "Candidates",
// path: "/candidate/candidates", // path: "/candidate/candidates",
// icon: <SearchIcon />, // icon: <SearchIcon />,
// component: ( // component: (
// <CandidatePicker /> // <CandidatePicker />
// ), // ),
// userTypes: ["candidate", "guest", "employer"], // userTypes: ["candidate", "guest", "employer"],
// }, // },
// ], // ],
// showInNavigation: true, // showInNavigation: true,
// }, // },
@ -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,74 +17,129 @@ 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
<h1 className="text-2xl font-bold mb-6" style={{ color: backstoryTheme.palette.text.primary }}> className="text-2xl font-bold mb-6"
Backstory Theme Visualization style={{ color: backstoryTheme.palette.text.primary }}
</h1> >
Backstory Theme Visualization
<div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
Primary Colors
</h2>
<div className="flex space-x-4">
{colorSwatch(backstoryTheme.palette.primary.main, '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 className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
Background Colors
</h2>
<div className="flex space-x-4">
{colorSwatch(backstoryTheme.palette.background.default, 'Default', '#000')}
{colorSwatch(backstoryTheme.palette.background.paper, 'Paper', '#000')}
</div>
</div>
<div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
Text Colors
</h2>
<div className="flex space-x-4">
{colorSwatch(backstoryTheme.palette.text.primary, 'Primary', '#fff')}
{colorSwatch(backstoryTheme.palette.text.secondary, 'Secondary', '#fff')}
</div>
</div>
<div 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
</h2>
<div className="mb-4">
<h1 style={{
fontFamily: backstoryTheme.typography.fontFamily,
fontSize: backstoryTheme.typography.h1.fontSize,
fontWeight: backstoryTheme.typography.h1.fontWeight,
color: backstoryTheme.typography.h1.color,
}}>
Heading 1 - Backstory Application
</h1> </h1>
</div>
<div className="mb-4"> <div className="mb-8">
<p style={{ <h2
fontFamily: backstoryTheme.typography.fontFamily, className="text-xl mb-4"
fontSize: backstoryTheme.typography.body1.fontSize, style={{ color: backstoryTheme.palette.text.primary }}
color: backstoryTheme.typography.body1.color, >
}}> Primary Colors
Body Text - This is how the regular text content will appear in the Backstory application. </h2>
The application uses Roboto as its primary font family, with carefully selected sizing and colors. <div className="flex space-x-4">
</p> {colorSwatch(
</div> backstoryTheme.palette.primary.main,
"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 className="mt-6"> <div className="mb-8">
<h2
className="text-xl mb-4"
style={{ color: backstoryTheme.palette.text.primary }}
>
Background Colors
</h2>
<div className="flex space-x-4">
{colorSwatch(
backstoryTheme.palette.background.default,
"Default",
"#000"
)}
{colorSwatch(
backstoryTheme.palette.background.paper,
"Paper",
"#000"
)}
</div>
</div>
<div className="mb-8">
<h2
className="text-xl mb-4"
style={{ color: backstoryTheme.palette.text.primary }}
>
Text Colors
</h2>
<div className="flex space-x-4">
{colorSwatch(
backstoryTheme.palette.text.primary,
"Primary",
"#fff"
)}
{colorSwatch(
backstoryTheme.palette.text.secondary,
"Secondary",
"#fff"
)}
</div>
</div>
<div
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
</h2>
<div className="mb-4">
<h1
style={{
fontFamily: backstoryTheme.typography.fontFamily,
fontSize: backstoryTheme.typography.h1.fontSize,
fontWeight: backstoryTheme.typography.h1.fontWeight,
color: backstoryTheme.typography.h1.color,
}}
>
Heading 1 - Backstory Application
</h1>
</div>
<div className="mb-4">
<p
style={{
fontFamily: backstoryTheme.typography.fontFamily,
fontSize: backstoryTheme.typography.body1.fontSize,
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.
</p>
</div>
{/* <div className="mt-6">
<a href="#" style={{ <a href="#" style={{
color: backstoryTheme.components?.MuiLink?.styleOverrides.root.color || "inherit", color: backstoryTheme.components?.MuiLink?.styleOverrides.root.color || "inherit",
textDecoration: backstoryTheme.components.MuiLink.styleOverrides.root.textDecoration, textDecoration: backstoryTheme.components.MuiLink.styleOverrides.root.textDecoration,
@ -91,112 +147,256 @@ const BackstoryThemeVisualizerPage = () => {
This is how links will appear by default This is how links will appear by default
</a> </a>
</div> */} </div> */}
</div>
<div className="mb-8">
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
UI Component Examples
</h2>
<div className="p-4 mb-4 rounded-lg" 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
</span>
</div> </div>
<div style={{ <div className="mb-8">
padding: '8px 16px', <h2
backgroundColor: backstoryTheme.palette.primary.main, className="text-xl mb-4"
color: backstoryTheme.palette.primary.contrastText, style={{ color: backstoryTheme.palette.text.primary }}
display: 'inline-block', >
borderRadius: '4px', UI Component Examples
cursor: 'pointer', </h2>
fontFamily: backstoryTheme.typography.fontFamily,
}}> <div
Primary Button className="p-4 mb-4 rounded-lg"
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
</span>
</div>
<div
style={{
padding: "8px 16px",
backgroundColor: backstoryTheme.palette.primary.main,
color: backstoryTheme.palette.primary.contrastText,
display: "inline-block",
borderRadius: "4px",
cursor: "pointer",
fontFamily: backstoryTheme.typography.fontFamily,
}}
>
Primary Button
</div>
<div
className="mt-4"
style={{
padding: "8px 16px",
backgroundColor: backstoryTheme.palette.secondary.main,
color: backstoryTheme.palette.secondary.contrastText,
display: "inline-block",
borderRadius: "4px",
cursor: "pointer",
fontFamily: backstoryTheme.typography.fontFamily,
}}
>
Secondary Button
</div>
<div
className="mt-4"
style={{
padding: "8px 16px",
backgroundColor: backstoryTheme.palette.action.active,
color: "#fff",
display: "inline-block",
borderRadius: "4px",
cursor: "pointer",
fontFamily: backstoryTheme.typography.fontFamily,
}}
>
Action Button
</div>
</div>
</div> </div>
<div className="mt-4" style={{ <div>
padding: '8px 16px', <h2
backgroundColor: backstoryTheme.palette.secondary.main, className="text-xl mb-4"
color: backstoryTheme.palette.secondary.contrastText, style={{ color: backstoryTheme.palette.text.primary }}
display: 'inline-block', >
borderRadius: '4px', Theme Color Breakdown
cursor: 'pointer', </h2>
fontFamily: backstoryTheme.typography.fontFamily, <table className="border-collapse">
}}> <thead>
Secondary Button <tr>
</div> <th
className="border p-2 text-left"
<div className="mt-4" style={{ style={{
padding: '8px 16px', backgroundColor:
backgroundColor: backstoryTheme.palette.action.active, backstoryTheme.palette.background.default,
color: '#fff', color: backstoryTheme.palette.text.primary,
display: 'inline-block', }}
borderRadius: '4px', >
cursor: 'pointer', Color Name
fontFamily: backstoryTheme.typography.fontFamily, </th>
}}> <th
Action Button 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>
</thead>
<tbody>
<tr>
<td
className="border p-2"
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>
<td
className="border p-2"
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>
<td
className="border p-2"
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>
<td
className="border p-2"
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>
<td
className="border p-2"
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>
<td
className="border p-2"
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>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </Paper>
</Container>
<div> </Box>
<h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
Theme Color Breakdown
</h2>
<table className="border-collapse">
<thead>
<tr>
<th className="border p-2 text-left"
style={{ backgroundColor: backstoryTheme.palette.background.default, 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>
</thead>
<tbody>
<tr>
<td className="border p-2" 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>
<td className="border p-2" 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>
<td className="border p-2" 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>
<td className="border p-2" 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>
<td className="border p-2" 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>
<td className="border p-2" 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>
</tbody>
</table>
</div>
</div>
</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
p: 3, sx={{
bgcolor: 'rgba(74, 122, 125, 0.1)', p: 3,
borderRadius: 2, bgcolor: "rgba(74, 122, 125, 0.1)",
border: '1px solid', borderRadius: 2,
borderColor: 'rgba(74, 122, 125, 0.3)', border: "1px solid",
height: '100%' borderColor: "rgba(74, 122, 125, 0.3)",
}}> 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>
))} ))}
@ -50,27 +87,44 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Box> </Box>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6}}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ <Box
p: 3, sx={{
bgcolor: 'rgba(26, 37, 54, 0.1)', p: 3,
borderRadius: 2, bgcolor: "rgba(26, 37, 54, 0.1)",
border: '1px solid', borderRadius: 2,
borderColor: 'rgba(26, 37, 54, 0.3)', border: "1px solid",
height: '100%' borderColor: "rgba(26, 37, 54, 0.3)",
}}> 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
p: 2, sx={{
border: '1px solid', p: 2,
borderColor: 'divider', border: "1px solid",
borderRadius: 1, borderColor: "divider",
height: '100%', borderRadius: 1,
transition: 'all 0.2s ease-in-out', height: "100%",
'&:hover': { transition: "all 0.2s ease-in-out",
bgcolor: 'rgba(212, 160, 23, 0.05)', "&:hover": {
borderColor: 'action.active', bgcolor: "rgba(212, 160, 23, 0.05)",
transform: 'translateY(-2px)', borderColor: "action.active",
boxShadow: 1 transform: "translateY(-2px)",
} 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
p: 3, sx={{
bgcolor: 'background.paper', p: 3,
borderRadius: 2, bgcolor: "background.paper",
boxShadow: 1, borderRadius: 2,
height: '100%' boxShadow: 1,
}}> 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
borderLeft: 3, sx={{
borderColor: nav.borderColor, borderLeft: 3,
pl: 2, borderColor: nav.borderColor,
py: 1, pl: 2,
display: 'flex', py: 1,
flexDirection: 'column', display: "flex",
gap: 1.5 flexDirection: "column",
}}> 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",
top: 0, left: "50%",
bottom: 0, top: 0,
width: 1, bottom: 0,
borderColor: 'divider', width: 1,
zIndex: 0, borderColor: "divider",
borderLeft: "1px solid", zIndex: 0,
overflow: "hidden", borderLeft: "1px solid",
}} /> 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
flex: 1, sx={{
display: 'flex', flex: 1,
justifyContent: 'flex-end', display: "flex",
pr: 3 justifyContent: "flex-end",
}}> pr: 3,
<Box sx={{ }}
display: 'inline-block', >
bgcolor: 'rgba(74, 122, 125, 0.1)', <Box
p: 2, sx={{
borderRadius: 2, display: "inline-block",
color: 'secondary.main', bgcolor: "rgba(74, 122, 125, 0.1)",
fontWeight: 'medium', p: 2,
border: '1px solid', borderRadius: 2,
borderColor: 'rgba(74, 122, 125, 0.3)' color: "secondary.main",
}}> fontWeight: "medium",
border: "1px solid",
borderColor: "rgba(74, 122, 125, 0.3)",
}}
>
{connection.left} {connection.left}
</Box> </Box>
</Box> </Box>
<Box sx={{ <Box
width: 16, sx={{
height: 16, width: 16,
borderRadius: '50%', height: 16,
bgcolor: 'custom.highlight', borderRadius: "50%",
zIndex: 2, bgcolor: "custom.highlight",
boxShadow: 2, zIndex: 2,
}} /> boxShadow: 2,
}}
/>
<Box sx={{ <Box
flex: 1, sx={{
pl: 3, flex: 1,
}}> pl: 3,
<Box sx={{ }}
display: 'inline-block', >
bgcolor: 'rgba(26, 37, 54, 0.1)', <Box
p: 2, sx={{
borderRadius: 2, display: "inline-block",
color: 'primary.main', bgcolor: "rgba(26, 37, 54, 0.1)",
fontWeight: 'medium', p: 2,
border: '1px solid', borderRadius: 2,
borderColor: 'rgba(26, 37, 54, 0.3)', color: "primary.main",
}}> fontWeight: "medium",
border: "1px solid",
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
width: 200, sx={{
height: 400, width: 200,
border: '4px solid', height: 400,
borderColor: 'text.primary', border: "4px solid",
borderRadius: 5, borderColor: "text.primary",
p: 1, borderRadius: 5,
bgcolor: 'background.default' p: 1,
}}> bgcolor: "background.default",
<Box sx={{ }}
height: '100%', >
display: 'flex', <Box
flexDirection: 'column', sx={{
border: '1px solid', height: "100%",
borderColor: 'divider', display: "flex",
borderRadius: 4, flexDirection: "column",
overflow: 'hidden' border: "1px solid",
}}> borderColor: "divider",
borderRadius: 4,
overflow: "hidden",
}}
>
{/* Mobile header */} {/* Mobile header */}
<Box sx={{ <Box
bgcolor: 'primary.main', sx={{
color: 'primary.contrastText', bgcolor: "primary.main",
p: 1, color: "primary.contrastText",
display: 'flex', p: 1,
justifyContent: 'space-between', display: "flex",
alignItems: 'center' justifyContent: "space-between",
}}> 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
flex: 1, sx={{
p: 1.5, flex: 1,
overflow: 'auto',
fontSize: '0.75rem'
}}>
<Typography sx={{ mb: 1, fontWeight: 'medium' }}>Welcome back, [Name]!</Typography>
<Typography sx={{ fontSize: '0.675rem', mb: 2 }}>Profile: 75% complete</Typography>
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5, p: 1.5,
mb: 2, overflow: "auto",
bgcolor: 'background.paper' fontSize: "0.75rem",
}}> }}
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', mb: 0.5 }}>Resume Builder</Typography> >
<Typography sx={{ fontSize: '0.675rem' }}>3 custom resumes</Typography> <Typography sx={{ mb: 1, fontWeight: "medium" }}>
Welcome back, [Name]!
</Typography>
<Typography sx={{ fontSize: "0.675rem", mb: 2 }}>
Profile: 75% complete
</Typography>
<Box
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
p: 1.5,
mb: 2,
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>
</Box> </Box>
<Box sx={{ <Box
border: '1px solid', sx={{
borderColor: 'divider', border: "1px solid",
borderRadius: 1, borderColor: "divider",
p: 1.5, borderRadius: 1,
bgcolor: 'background.paper' p: 1.5,
}}> 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 sx={{ fontSize: '0.675rem' }}> 2 downloads</Typography> <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={{
p: 1, bgcolor: "background.default",
display: 'flex', p: 1,
justifyContent: 'space-around', display: "flex",
borderTop: '1px solid', justifyContent: "space-around",
borderColor: 'divider' borderTop: "1px solid",
}}> 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 sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>More</Typography> <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,18 +218,35 @@ 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)
<ListItemIcon sx={{ minWidth: 24 }}> .map((resume) => (
<ClockIcon fontSize="small" /> <ListItem
</ListItemIcon> key={resume.id}
<Typography variant="body2" noWrap>{resume.name}</Typography> disablePadding
</ListItem> sx={{ mb: 0.5 }}
))} >
<ListItemIcon sx={{ minWidth: 24 }}>
<ClockIcon fontSize="small" />
</ListItemIcon>
<Typography variant="body2" noWrap>
{resume.name}
</Typography>
</ListItem>
))}
</List> </List>
</Paper> </Paper>
</Box> </Box>
@ -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}
@ -288,7 +310,7 @@ const UserManagement: React.FC = () => {
InputProps={{ readOnly: true }} InputProps={{ readOnly: true }}
/> />
</Grid> </Grid>
<Grid size={{xs: 12, md: 6}}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">Skills</Typography> <Typography variant="subtitle1">Skills</Typography>
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
{selectedUser.skills.map((skill) => ( {selectedUser.skills.map((skill) => (
@ -303,8 +325,10 @@ const UserManagement: React.FC = () => {
</Grid> </Grid>
) : ( ) : (
<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}
@ -327,8 +351,10 @@ const UserManagement: React.FC = () => {
InputProps={{ readOnly: true }} InputProps={{ readOnly: true }}
/> />
</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"
@ -397,7 +430,7 @@ const UserManagement: React.FC = () => {
</Typography> </Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{xs: 12, md: 6}}> <Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth margin="normal"> <FormControl fullWidth margin="normal">
<InputLabel id="model-label">AI Model</InputLabel> <InputLabel id="model-label">AI Model</InputLabel>
<Select <Select
@ -411,7 +444,7 @@ const UserManagement: React.FC = () => {
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
<Grid size={{xs: 12, md: 6}}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="Temperature" label="Temperature"
type="number" type="number"
@ -421,7 +454,7 @@ const UserManagement: React.FC = () => {
InputProps={{ inputProps: { min: 0, max: 1, step: 0.1 } }} InputProps={{ inputProps: { min: 0, max: 1, step: 0.1 } }}
/> />
</Grid> </Grid>
<Grid size={{xs: 12, md: 6}}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="Max Tokens" label="Max Tokens"
type="number" type="number"
@ -430,7 +463,7 @@ const UserManagement: React.FC = () => {
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid size={{xs: 12, md: 6}}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="Top P" label="Top P"
type="number" type="number"
@ -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(
try { async (refreshToken: string): Promise<Types.AuthResponse | null> => {
const response = await apiClient.refreshToken(refreshToken); try {
return response; const response = await apiClient.refreshToken(refreshToken);
} catch (error) { return response;
console.error('Token refresh failed:', error); } catch (error) {
return null; console.error("Token refresh failed:", error);
} 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,131 +428,154 @@ 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) => ({
try {
const result = await apiClient.login({
login: loginData.login,
password: loginData.password,
});
if ('mfaRequired' in result) {
// MFA required for new device
setAuthState(prev => ({
...prev,
isLoading: false,
mfaResponse: result,
}));
return false; // Login not complete yet
} else {
// Normal login success - convert from guest to authenticated user
const authResponse: Types.AuthResponse = result;
storeAuthData(authResponse, false);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({
...prev,
user: authResponse.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log('✅ Login successful, converted from guest to authenticated user');
return true;
}
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Network error occurred. Please try again.';
setAuthState(prev => ({
...prev, ...prev,
isLoading: false, isLoading: true,
error: errorMessage, error: null,
mfaResponse: null, mfaResponse: null,
})); }));
return false;
} try {
}, [apiClient]); const result = await apiClient.login({
login: loginData.login,
password: loginData.password,
});
if ("mfaRequired" in result) {
// MFA required for new device
setAuthState((prev) => ({
...prev,
isLoading: false,
mfaResponse: result,
}));
return false; // Login not complete yet
} else {
// Normal login success - convert from guest to authenticated user
const authResponse: Types.AuthResponse = result;
storeAuthData(authResponse, false);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState((prev) => ({
...prev,
user: authResponse.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log(
"✅ Login successful, converted from guest to authenticated user"
);
return true;
}
} catch (error: any) {
const errorMessage =
error instanceof Error
? error.message
: "Network error occurred. Please try again.";
setAuthState((prev) => ({
...prev,
isLoading: false,
error: errorMessage,
mfaResponse: null,
}));
return false;
}
},
[apiClient]
);
// Convert guest to permanent user // Convert guest to permanent user
const convertGuestToUser = useCallback(async (registrationData: GuestConversionRequest): Promise<boolean> => { const convertGuestToUser = useCallback(
if (!authState.isGuest || !authState.guest) { async (registrationData: GuestConversionRequest): Promise<boolean> => {
throw new Error('Not currently a guest user'); if (!authState.isGuest || !authState.guest) {
} 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);
// Store new authentication // Store new authentication
storeAuthData(result.auth, false); storeAuthData(result.auth, false);
apiClient.setAuthToken(result.auth.accessToken); apiClient.setAuthToken(result.auth.accessToken);
setAuthState(prev => ({ setAuthState((prev) => ({
...prev,
user: result.auth.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
}));
console.log('✅ Guest successfully converted to permanent user');
return true;
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Failed to convert guest account';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return false;
}
}, [apiClient, authState.isGuest, authState.guest]);
// MFA verification
const verifyMFA = useCallback(async (mfaData: Types.MFAVerifyRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const result = await apiClient.verifyMFA(mfaData);
if (result.accessToken) {
const authResponse: Types.AuthResponse = result;
storeAuthData(authResponse, false);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({
...prev, ...prev,
user: authResponse.user, user: result.auth.user,
guest: null, guest: null,
isAuthenticated: true, isAuthenticated: true,
isGuest: false, isGuest: false,
isLoading: false, isLoading: false,
error: null, error: null,
mfaResponse: null,
})); }));
console.log('✅ MFA verification successful, converted from guest'); console.log("✅ Guest successfully converted to permanent user");
return true; return true;
} catch (error: any) {
const errorMessage =
error instanceof Error
? error.message
: "Failed to convert guest account";
setAuthState((prev) => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return false;
} }
},
[apiClient, authState.isGuest, authState.guest]
);
return false; // MFA verification
} catch (error) { const verifyMFA = useCallback(
const errorMessage = error instanceof Error ? error.message : 'MFA verification failed'; async (mfaData: Types.MFAVerifyRequest): Promise<boolean> => {
setAuthState(prev => ({ setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
...prev,
isLoading: false, try {
error: errorMessage const result = await apiClient.verifyMFA(mfaData);
}));
return false; if (result.accessToken) {
} const authResponse: Types.AuthResponse = result;
}, [apiClient]); storeAuthData(authResponse, false);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState((prev) => ({
...prev,
user: authResponse.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log("✅ MFA verification successful, converted from guest");
return true;
}
return false;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "MFA verification failed";
setAuthState((prev) => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return false;
}
},
[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(
updateStoredUserData(updatedUser); (updatedUser: Types.User) => {
setAuthState(prev => ({ updateStoredUserData(updatedUser);
...prev, setAuthState((prev) => ({
user: authState.isGuest ? null : updatedUser, ...prev,
guest: authState.isGuest ? updatedUser as Types.Guest : prev.guest user: authState.isGuest ? null : updatedUser,
})); guest: authState.isGuest ? (updatedUser as Types.Guest) : prev.guest,
console.log('✅ User data updated'); }));
}, [authState.isGuest]); console.log("✅ User data updated");
},
[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";
...prev, setAuthState((prev) => ({
isLoading: false, ...prev,
error: errorMessage isLoading: false,
})); 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
...prev, ? error.message
isLoading: false, : "Failed to resend verification email";
error: errorMessage setAuthState((prev) => ({
})); ...prev,
return false; isLoading: false,
} error: errorMessage,
}, [apiClient]); }));
return false;
}
},
[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";
...prev, setAuthState((prev) => ({
isLoading: false, ...prev,
error: errorMessage isLoading: false,
})); 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
...prev, ? error.message
isLoading: false, : "Password reset request failed";
error: errorMessage setAuthState((prev) => ({
})); ...prev,
return false; isLoading: false,
} error: errorMessage,
}, [apiClient]); }));
return false;
}
},
[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";
...prev, setAuthState((prev) => ({
isLoading: false, ...prev,
error: errorMessage isLoading: false,
})); 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(
setSelectedCandidateState(candidate); (candidate: Types.Candidate | null) => {
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, candidate?.id || null); setSelectedCandidateState(candidate);
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(
snackRef.current?.setSnack(message, severity); (message: string, severity?: SeverityType) => {
}, [snackRef]); snackRef.current?.setSnack(message, severity);
},
[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,65 +115,74 @@ 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(
const container = containerRef.current; (isPasteEvent = false) => {
if (!container) return; const container = containerRef.current;
if (!container) return;
let shouldScroll = false; let shouldScroll = false;
const scrollTo = scrollToRef.current; const scrollTo = scrollToRef.current;
if (isPasteEvent && !scrollTo) { if (isPasteEvent && !scrollTo) {
console.error("Paste Event triggered without scrollTo"); console.error("Paste Event triggered without scrollTo");
}
if (scrollTo) {
// Get positions
const containerRect = container.getBoundingClientRect();
const scrollToRect = scrollTo.getBoundingClientRect();
const containerTop = containerRect.top;
const containerBottom = containerTop + container.clientHeight;
// Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible =
scrollToRect.top < containerBottom && scrollToRect.bottom > containerTop;
// Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll = isPasteEvent || (isTextFieldVisible && !isUserScrollingUpRef.current);
if (shouldScroll) {
requestAnimationFrame(() => {
debug && console.debug('Scrolling to container bottom:', {
scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight,
isPasteEvent,
isTextFieldVisible,
isUserScrollingUp: isUserScrollingUpRef.current,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
});
} }
} else {
// Fallback to threshold-based check
const scrollHeight = container.scrollHeight;
const isNearBottom =
scrollHeight - container.scrollTop - container.clientHeight <=
container.clientHeight * fallbackThreshold;
shouldScroll = isNearBottom && !isUserScrollingUpRef.current;
if (shouldScroll) { if (scrollTo) {
requestAnimationFrame(() => { // Get positions
debug && console.debug('Scrolling to container bottom (fallback):', { scrollHeight }); const containerRect = container.getBoundingClientRect();
container.scrollTo({ const scrollToRect = scrollTo.getBoundingClientRect();
top: container.scrollHeight, const containerTop = containerRect.top;
behavior: smooth ? 'smooth' : 'auto', const containerBottom = containerTop + container.clientHeight;
// Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible =
scrollToRect.top < containerBottom &&
scrollToRect.bottom > containerTop;
// Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll =
isPasteEvent || (isTextFieldVisible && !isUserScrollingUpRef.current);
if (shouldScroll) {
requestAnimationFrame(() => {
debug &&
console.debug("Scrolling to container bottom:", {
scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight,
isPasteEvent,
isTextFieldVisible,
isUserScrollingUp: isUserScrollingUpRef.current,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
}); });
}); }
} else {
// Fallback to threshold-based check
const scrollHeight = container.scrollHeight;
const isNearBottom =
scrollHeight - container.scrollTop - container.clientHeight <=
container.clientHeight * fallbackThreshold;
shouldScroll = isNearBottom && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
debug &&
console.debug("Scrolling to container bottom (fallback):", {
scrollHeight,
});
container.scrollTo({
top: container.scrollHeight,
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; () => {
debug && console.debug(`Scrolling up: ${isUserScrollingUpRef.current}`); isUserScrollingUpRef.current = false;
}, pause ? pause : 500); debug &&
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,19 +38,28 @@ 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<
id: number; Array<{
x: number; id: number;
y: number; x: number;
size: number; y: number;
opacity: number; size: number;
duration: number; opacity: number;
delay: number; duration: 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}
@ -141,32 +168,35 @@ const BetaPage: React.FC<BetaPageProps> = ({
</Typography> </Typography>
</Grid> </Grid>
<Grid size={{xs: 12, md: 10, lg: 8}} sx={{ mb: 4 }}> <Grid size={{ xs: 12, md: 10, lg: 8 }} sx={{ mb: 4 }}>
<Paper <Paper
elevation={8} elevation={8}
sx={{ sx={{
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,226 +7,295 @@ 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>(
const { apiClient } = useAuth(); (props: BackstoryPageProps, ref) => {
const navigate = useNavigate(); const { apiClient } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate() const navigate = useNavigate();
const theme = useTheme(); const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const [processingMessage, setProcessingMessage] = useState<ChatMessageStatus | ChatMessageError | null>(null); const theme = useTheme();
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null); const [processingMessage, setProcessingMessage] = useState<
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null); ChatMessageStatus | ChatMessageError | null
>(null);
const [streamingMessage, setStreamingMessage] =
useState<ChatMessage | null>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [chatSession, setChatSession] = useState<ChatSession | null>(null); const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [streaming, setStreaming] = useState<boolean>(false); const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
// Load messages for current session // Load messages for current session
const loadMessages = async () => { const loadMessages = async () => {
if (!chatSession?.id) return; if (!chatSession?.id) return;
try { try {
const result = await apiClient.getChatMessages(chatSession.id); const result = await apiClient.getChatMessages(chatSession.id);
const chatMessages: ChatMessage[] = result.data; const chatMessages: ChatMessage[] = result.data;
setMessages(chatMessages); setMessages(chatMessages);
setProcessingMessage(null); setProcessingMessage(null);
setStreamingMessage(null); setStreamingMessage(null);
console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages); console.log(
} catch (error) { `getChatMessages returned ${chatMessages.length} messages.`,
console.error('Failed to load messages:', error); chatMessages
} );
}; } catch (error) {
console.error("Failed to load messages:", error);
const onDelete = async (session: ChatSession) => { }
if (!session.id) {
return;
}
try {
await apiClient.resetChatSession(session.id);
// If we're deleting the currently selected session, clear it
setMessages([]);
setSnack('Session reset succeeded', 'success');
} catch (error) {
console.error('Failed to delete session:', error);
setSnack('Failed to delete session', 'error');
}
};
// Send message
const sendMessage = async (message: string) => {
if (!message.trim() || !chatSession?.id || streaming || !selectedCandidate) return;
const messageContent = message;
setStreaming(true);
const chatMessage: ChatMessageUser = {
sessionId: chatSession.id,
role: "user",
content: messageContent,
status: "done",
type: "text",
timestamp: new Date()
}; };
setProcessingMessage({ ...defaultMessage, status: 'status', activity: "info", content: `Establishing connection with ${selectedCandidate.firstName}'s chat session.` }); const onDelete = async (session: ChatSession) => {
if (!session.id) {
return;
}
try {
await apiClient.resetChatSession(session.id);
// If we're deleting the currently selected session, clear it
setMessages([]);
setSnack("Session reset succeeded", "success");
} catch (error) {
console.error("Failed to delete session:", error);
setSnack("Failed to delete session", "error");
}
};
setMessages(prev => { // Send message
const filtered = prev.filter((m: any) => m.id !== chatMessage.id); const sendMessage = async (message: string) => {
return [...filtered, chatMessage] as any; if (
}); !message.trim() ||
!chatSession?.id ||
streaming ||
!selectedCandidate
)
return;
try { const messageContent = message;
apiClient.sendMessageStream(chatMessage, { setStreaming(true);
onMessage: (msg: ChatMessage) => {
setMessages(prev => { const chatMessage: ChatMessageUser = {
const filtered = prev.filter((m: any) => m.id !== msg.id); sessionId: chatSession.id,
return [...filtered, msg] as any; role: "user",
}); content: messageContent,
setStreamingMessage(null); status: "done",
setProcessingMessage(null); type: "text",
}, timestamp: new Date(),
onError: (error: string | ChatMessageError) => { };
console.log("onError:", error);
let message: string; setProcessingMessage({
// Type-guard to determine if this is a ChatMessageBase or a string ...defaultMessage,
if (typeof error === "object" && error !== null && "content" in error) { status: "status",
setProcessingMessage(error); activity: "info",
message = error.content as string; content: `Establishing connection with ${selectedCandidate.firstName}'s chat session.`,
} else {
setProcessingMessage({ ...defaultMessage, status: "error", content: error })
}
setStreaming(false);
},
onStreaming: (chunk: ChatMessageStreaming) => {
// console.log("onStreaming:", chunk);
setStreamingMessage({ ...chunk, role: 'assistant', metadata: null as any });
},
onStatus: (status: ChatMessageStatus) => {
setProcessingMessage(status);
},
onComplete: () => {
console.log("onComplete");
setStreamingMessage(null);
setProcessingMessage(null);
setStreaming(false);
}
}); });
} catch (error) {
console.error('Failed to send message:', error);
setStreaming(false);
}
};
// Auto-scroll to bottom when new messages arrive setMessages((prev) => {
useEffect(() => { const filtered = prev.filter((m: any) => m.id !== chatMessage.id);
(messagesEndRef.current as any)?.scrollIntoView({ behavior: 'smooth' }); return [...filtered, chatMessage] as any;
}, [messages]); });
// Load sessions when username changes try {
useEffect(() => { apiClient.sendMessageStream(chatMessage, {
if (!selectedCandidate) return; onMessage: (msg: ChatMessage) => {
try { setMessages((prev) => {
setLoading(true); const filtered = prev.filter((m: any) => m.id !== msg.id);
apiClient.getOrCreateChatSession(selectedCandidate, `Backstory chat with ${selectedCandidate.fullName}`, 'candidate_chat') return [...filtered, msg] as any;
.then(session => { });
setChatSession(session); setStreamingMessage(null);
setLoading(false); setProcessingMessage(null);
},
onError: (error: string | ChatMessageError) => {
console.log("onError:", error);
let message: string;
// Type-guard to determine if this is a ChatMessageBase or a string
if (
typeof error === "object" &&
error !== null &&
"content" in error
) {
setProcessingMessage(error);
message = error.content as string;
} else {
setProcessingMessage({
...defaultMessage,
status: "error",
content: error,
});
}
setStreaming(false);
},
onStreaming: (chunk: ChatMessageStreaming) => {
// console.log("onStreaming:", chunk);
setStreamingMessage({
...chunk,
role: "assistant",
metadata: null as any,
});
},
onStatus: (status: ChatMessageStatus) => {
setProcessingMessage(status);
},
onComplete: () => {
console.log("onComplete");
setStreamingMessage(null);
setProcessingMessage(null);
setStreaming(false);
},
}); });
} catch (error) { } catch (error) {
setSnack('Unable to load chat session', 'error'); console.error("Failed to send message:", error);
} finally { setStreaming(false);
setLoading(false); }
};
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
(messagesEndRef.current as any)?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Load sessions when username changes
useEffect(() => {
if (!selectedCandidate) return;
try {
setLoading(true);
apiClient
.getOrCreateChatSession(
selectedCandidate,
`Backstory chat with ${selectedCandidate.fullName}`,
"candidate_chat"
)
.then((session) => {
setChatSession(session);
setLoading(false);
});
} catch (error) {
setSnack("Unable to load chat session", "error");
} finally {
setLoading(false);
}
}, [selectedCandidate]);
// Load messages when session changes
useEffect(() => {
if (chatSession?.id) {
loadMessages();
}
}, [chatSession]);
if (!selectedCandidate) {
return <CandidatePicker />;
} }
}, [selectedCandidate]);
// Load messages when session changes const welcomeMessage: ChatMessage = {
useEffect(() => { sessionId: chatSession?.id || "",
if (chatSession?.id) { role: "information",
loadMessages(); type: "text",
} status: "done",
}, [chatSession]); timestamp: new Date(),
content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}. Ask any questions you have about ${selectedCandidate.firstName}.`,
metadata: null as any,
};
if (!selectedCandidate) { return (
return <CandidatePicker />; <Box
} ref={ref}
sx={{
const welcomeMessage: ChatMessage = { display: "flex",
sessionId: chatSession?.id || '', flexDirection: "column",
role: "information", height: "100%" /* Restrict to main-container's height */,
type: "text", width: "100%",
status: "done", minHeight: 0 /* Prevent flex overflow */,
timestamp: new Date(), maxHeight: "min-content",
content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}. Ask any questions you have about ${selectedCandidate.firstName}.`, "& > *:not(.Scrollable)": {
metadata: null as any flexShrink: 0 /* Prevent shrinking */,
}; },
position: "relative",
return ( }}
<Box ref={ref} >
sx={{ <Paper elevation={2} sx={{ m: 1, p: 1 }}>
display: "flex", flexDirection: "column", <CandidateInfo
height: "100%", /* Restrict to main-container's height */ key={selectedCandidate.username}
width: "100%", action={`Chat with Backstory about ${selectedCandidate.firstName}`}
minHeight: 0,/* Prevent flex overflow */ elevation={4}
maxHeight: "min-content", candidate={selectedCandidate}
"& > *:not(.Scrollable)": { variant="small"
flexShrink: 0, /* Prevent shrinking */ sx={{
}, flexShrink: 1,
position: "relative", width: "100%",
}}> maxHeight: 0,
<Paper elevation={2} sx={{ m: 1, p: 1 }}> minHeight: "min-content",
<CandidateInfo }} // Prevent header from shrinking
key={selectedCandidate.username} />
action={`Chat with Backstory about ${selectedCandidate.firstName}`} <Button
elevation={4} sx={{ maxWidth: "max-content" }}
candidate={selectedCandidate} onClick={() => {
variant="small" setSelectedCandidate(null);
sx={{ flexShrink: 1, width: "100%", maxHeight: 0, minHeight: "min-content" }} // Prevent header from shrinking }}
/> variant="contained"
<Button sx={{ maxWidth: "max-content" }} onClick={() => { setSelectedCandidate(null); }} variant="contained">Change Candidates</Button> >
</Paper> Change Candidates
{/* Chat Interface */} </Button>
{/* Scrollable Messages Area */} </Paper>
{chatSession && {/* Chat Interface */}
<Scrollable {/* Scrollable Messages Area */}
sx={{ {chatSession && (
position: "relative", <Scrollable
maxHeight: "100%", sx={{
width: "100%", position: "relative",
display: "flex", flexGrow: 1, maxHeight: "100%",
flex: 1, /* Take remaining space in some-container */ width: "100%",
overflowY: "auto", /* Scroll if content overflows */ display: "flex",
pt: 2, flexGrow: 1,
pl: 1, flex: 1 /* Take remaining space in some-container */,
pr: 1, overflowY: "auto" /* Scroll if content overflows */,
pb: 2, pt: 2,
}}> pl: 1,
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage, }} />} pr: 1,
pb: 2,
}}
>
{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
display: "flex", sx={{
flexDirection: "column", display: "flex",
alignItems: "center", flexDirection: "column",
justifyContent: "center", alignItems: "center",
m: 1, justifyContent: "center",
}}> m: 1,
}}
>
<PropagateLoader <PropagateLoader
size="10px" size="10px"
loading={streaming} loading={streaming}
@ -253,42 +324,58 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
</Box> </Box>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</Scrollable> </Scrollable>
} )}
{selectedCandidate.questions?.length !== 0 && selectedCandidate.questions?.map(q => <BackstoryQuery question={q} />)} {selectedCandidate.questions?.length !== 0 &&
{/* Fixed Message Input */} selectedCandidate.questions?.map((q) => (
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}> <BackstoryQuery question={q} />
<DeleteConfirmation ))}
onDelete={() => { chatSession && onDelete(chatSession); }} {/* Fixed Message Input */}
disabled={!chatSession} <Box sx={{ display: "flex", flexShrink: 1, gap: 1 }}>
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content" }} <DeleteConfirmation
action="reset" onDelete={() => {
label="chat session" chatSession && onDelete(chatSession);
title="Reset Chat Session" }}
message={`Are you sure you want to reset the session? This action cannot be undone.`} disabled={!chatSession}
/> sx={{ minWidth: "auto", px: 2, maxHeight: "min-content" }}
<BackstoryTextField action="reset"
placeholder="Type your message about the candidate..." label="chat session"
ref={backstoryTextRef} title="Reset Chat Session"
onEnter={sendMessage} message={`Are you sure you want to reset the session? This action cannot be undone.`}
disabled={streaming || loading} />
/> <BackstoryTextField
<Tooltip title="Send"> placeholder="Type your message about the candidate..."
<span style={{ minWidth: 'auto', maxHeight: "min-content", alignSelf: "center" }} ref={backstoryTextRef}
> onEnter={sendMessage}
<Button disabled={streaming || loading}
variant="contained" />
onClick={() => { sendMessage((backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ""); }} <Tooltip title="Send">
disabled={streaming || loading} <span
> style={{
<SendIcon /> minWidth: "auto",
maxHeight: "min-content",
</Button> alignSelf: "center",
</span> }}
</Tooltip> >
<Button
variant="contained"
onClick={() => {
sendMessage(
(backstoryTextRef.current &&
backstoryTextRef.current.getAndResetValue()) ||
""
);
}}
disabled={streaming || loading}
>
<SendIcon />
</Button>
</span>
</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
p: 2, sx={{
display: 'flex', p: 2,
alignItems: 'center', display: "flex",
justifyContent: 'space-between', alignItems: "center",
borderBottom: 1, justifyContent: "space-between",
borderColor: 'divider', borderBottom: 1,
}}> 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
flexGrow: 1, sx={{
overflow: 'auto', flexGrow: 1,
p: 1 overflow: "auto",
}}> 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={{ <ListItemIcon
color: currentPage === doc.route ? 'primary.main' : 'text.secondary', sx={{
minWidth: 40 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;
@ -137,26 +151,91 @@ type DocType = {
icon?: React.ReactNode; icon?: React.ReactNode;
}; };
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,28 +369,56 @@ 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 (
<Card sx={{ minHeight: "180px" }}> <Grid
<CardActionArea onClick={() => doc.route ? onDocumentExpand(doc.route, true) : navigate('/')}> sx={{ minWidth: "164px" }}
<CardContent sx={{ display: "flex", flexDirection: "column", m: 0, p: 1 }}> size={{ xs: 12, sm: 6, md: 4 }}
<Box sx={{ display: 'flex', flexDirection: "row", gap: 1, verticalAlign: 'top' }}> key={index}
{getDocumentIcon(doc.title)} >
<Typography variant="h3" sx={{ m: "0 !important" }}>{doc.title}</Typography> <Card sx={{ minHeight: "180px" }}>
</Box> <CardActionArea
<Typography variant="body2" color="text.secondary"> onClick={() =>
{doc.description} doc.route
</Typography> ? onDocumentExpand(doc.route, true)
</CardContent> : navigate("/")
</CardActionArea> }
</Card> >
</Grid> <CardContent
) sx={{
display: "flex",
flexDirection: "column",
m: 0,
p: 1,
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
gap: 1,
verticalAlign: "top",
}}
>
{getDocumentIcon(doc.title)}
<Typography variant="h3" sx={{ m: "0 !important" }}>
{doc.title}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{doc.description}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</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,314 +1,429 @@
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] =
const [processing, setProcessing] = useState<boolean>(false); useState<ChatMessage | null>(null);
const [generatedUser, setGeneratedUser] = useState<CandidateAI | null>(null); const [processing, setProcessing] = useState<boolean>(false);
const [prompt, setPrompt] = useState<string>(''); const [generatedUser, setGeneratedUser] = useState<CandidateAI | null>(null);
const [resume, setResume] = useState<string | null>(null); const [prompt, setPrompt] = useState<string>("");
const [canGenImage, setCanGenImage] = useState<boolean>(false); const [resume, setResume] = useState<string | null>(null);
const [timestamp, setTimestamp] = useState<string>(''); const [canGenImage, setCanGenImage] = useState<boolean>(false);
const [shouldGenerateProfile, setShouldGenerateProfile] = useState<boolean>(false); const [timestamp, setTimestamp] = useState<string>("");
const [chatSession, setChatSession] = useState<ChatSession | null>(null); const [shouldGenerateProfile, setShouldGenerateProfile] =
const [loading, setLoading] = useState<boolean>(false); useState<boolean>(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [loading, setLoading] = useState<boolean>(false);
// Only keep refs that are truly necessary // Only keep refs that are truly necessary
const controllerRef = useRef<StreamingResponse>(null); const controllerRef = useRef<StreamingResponse>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null); const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
/* Create the chat session */ /* Create the chat session */
useEffect(() => { useEffect(() => {
if (chatSession || loading || !generatedUser) { if (chatSession || loading || !generatedUser) {
return; return;
}
try {
setLoading(true);
apiClient.getOrCreateChatSession(generatedUser, `Profile image generator for ${generatedUser.fullName}`, 'generate_image')
.then(session => {
setChatSession(session);
setLoading(false);
});
} catch (error) {
setSnack('Unable to load chat session', 'error');
} finally {
setLoading(false);
}
}, [generatedUser, chatSession, loading, setChatSession, setLoading, setSnack, apiClient]);
const cancelQuery = useCallback(() => {
if (controllerRef.current) {
controllerRef.current.cancel();
controllerRef.current = null;
setProcessing(false);
}
}, []);
const onEnter = useCallback((value: string) => {
if (processing) {
return;
}
const generatePersona = async (prompt: string) => {
const userMessage: ChatMessageUser = {
type: "text",
role: "user",
content: prompt,
sessionId: "",
status: "done",
timestamp: new Date()
};
setPrompt(prompt || '');
setProcessing(true);
setProcessingMessage({ ...defaultMessage, content: "Generating persona..." });
try {
const result = await apiClient.createCandidateAI(userMessage);
console.log(result.message, result);
setGeneratedUser(result.candidate);
setResume(result.resume);
setCanGenImage(true);
setShouldGenerateProfile(true); // Reset the flag
} catch (error) {
console.error(error);
setPrompt('');
setResume(null);
setProcessing(false);
setProcessingMessage(null);
setSnack("Unable to generate AI persona", "error");
}
};
generatePersona(value);
}, [processing, apiClient, setSnack]);
const handleSendClick = useCallback(() => {
const value = (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || "";
onEnter(value);
}, [onEnter]);
// Effect to trigger profile image generation when user data is ready
useEffect(() => {
if (!chatSession || !generatedUser?.username) {
return;
}
const username = generatedUser.username;
if (!shouldGenerateProfile || username === "[blank]" || generatedUser?.firstName === "[blank]") {
return;
}
if (controllerRef.current) {
console.log("Controller already active, skipping profile generation");
return;
}
setProcessingMessage({ ...defaultMessage, content: 'Starting image generation...' });
setProcessing(true);
setCanGenImage(false);
const chatMessage: ChatMessageUser = {
sessionId: chatSession.id || '',
role: "user",
status: "done",
type: "text",
timestamp: new Date(),
content: prompt
};
controllerRef.current = apiClient.sendMessageStream(chatMessage, {
onMessage: async (msg: ChatMessage) => {
console.log(`onMessage: ${msg.type} ${msg.content}`, msg);
controllerRef.current = null;
try {
await apiClient.updateCandidate(generatedUser.id || '', { profileImage: "profile.png" });
const { success, message } = await apiClient.deleteChatSession(chatSession.id || '');
console.log(`Profile generated for ${username} and chat session was ${!success ? 'not ' : ''} deleted: ${message}}`);
setGeneratedUser({
...generatedUser,
profileImage: "profile.png"
} as CandidateAI);
setCanGenImage(true);
setShouldGenerateProfile(false);
} catch (error) {
console.error(error);
setSnack(`Unable to update ${username} to indicate they have a profile picture.`, "error");
}
},
onError: (error: string | ChatMessageError) => {
console.log("onError:", error);
// Type-guard to determine if this is a ChatMessageBase or a string
if (typeof error === "object" && error !== null && "content" in error) {
setSnack(error.content || "Unknown error generating profile image", "error");
} else {
setSnack(error as string, "error");
}
setProcessingMessage(null);
setProcessing(false);
controllerRef.current = null;
setCanGenImage(true);
setShouldGenerateProfile(false);
},
onComplete: () => {
setProcessingMessage(null);
setProcessing(false);
controllerRef.current = null;
setCanGenImage(true);
setShouldGenerateProfile(false);
},
onStatus: (status: ChatMessageStatus) => {
if (status.activity === "heartbeat" && status.content) {
setTimestamp(status.timestamp?.toISOString() || '');
} else if (status.content) {
setProcessingMessage({ ...defaultMessage, content: status.content });
}
console.log(`onStatusChange: ${status}`);
},
});
}, [chatSession, shouldGenerateProfile, generatedUser, prompt, setSnack, apiClient]);
if (!user?.isAdmin) {
return (<Box>You must be logged in as an admin to generate AI candidates.</Box>);
} }
try {
setLoading(true);
apiClient
.getOrCreateChatSession(
generatedUser,
`Profile image generator for ${generatedUser.fullName}`,
"generate_image"
)
.then((session) => {
setChatSession(session);
setLoading(false);
});
} catch (error) {
setSnack("Unable to load chat session", "error");
} finally {
setLoading(false);
}
}, [
generatedUser,
chatSession,
loading,
setChatSession,
setLoading,
setSnack,
apiClient,
]);
const cancelQuery = useCallback(() => {
if (controllerRef.current) {
controllerRef.current.cancel();
controllerRef.current = null;
setProcessing(false);
}
}, []);
const onEnter = useCallback(
(value: string) => {
if (processing) {
return;
}
const generatePersona = async (prompt: string) => {
const userMessage: ChatMessageUser = {
type: "text",
role: "user",
content: prompt,
sessionId: "",
status: "done",
timestamp: new Date(),
};
setPrompt(prompt || "");
setProcessing(true);
setProcessingMessage({
...defaultMessage,
content: "Generating persona...",
});
try {
const result = await apiClient.createCandidateAI(userMessage);
console.log(result.message, result);
setGeneratedUser(result.candidate);
setResume(result.resume);
setCanGenImage(true);
setShouldGenerateProfile(true); // Reset the flag
} catch (error) {
console.error(error);
setPrompt("");
setResume(null);
setProcessing(false);
setProcessingMessage(null);
setSnack("Unable to generate AI persona", "error");
}
};
generatePersona(value);
},
[processing, apiClient, setSnack]
);
const handleSendClick = useCallback(() => {
const value =
(backstoryTextRef.current &&
backstoryTextRef.current.getAndResetValue()) ||
"";
onEnter(value);
}, [onEnter]);
// Effect to trigger profile image generation when user data is ready
useEffect(() => {
if (!chatSession || !generatedUser?.username) {
return;
}
const username = generatedUser.username;
if (
!shouldGenerateProfile ||
username === "[blank]" ||
generatedUser?.firstName === "[blank]"
) {
return;
}
if (controllerRef.current) {
console.log("Controller already active, skipping profile generation");
return;
}
setProcessingMessage({
...defaultMessage,
content: "Starting image generation...",
});
setProcessing(true);
setCanGenImage(false);
const chatMessage: ChatMessageUser = {
sessionId: chatSession.id || "",
role: "user",
status: "done",
type: "text",
timestamp: new Date(),
content: prompt,
};
controllerRef.current = apiClient.sendMessageStream(chatMessage, {
onMessage: async (msg: ChatMessage) => {
console.log(`onMessage: ${msg.type} ${msg.content}`, msg);
controllerRef.current = null;
try {
await apiClient.updateCandidate(generatedUser.id || "", {
profileImage: "profile.png",
});
const { success, message } = await apiClient.deleteChatSession(
chatSession.id || ""
);
console.log(
`Profile generated for ${username} and chat session was ${
!success ? "not " : ""
} deleted: ${message}}`
);
setGeneratedUser({
...generatedUser,
profileImage: "profile.png",
} as CandidateAI);
setCanGenImage(true);
setShouldGenerateProfile(false);
} catch (error) {
console.error(error);
setSnack(
`Unable to update ${username} to indicate they have a profile picture.`,
"error"
);
}
},
onError: (error: string | ChatMessageError) => {
console.log("onError:", error);
// Type-guard to determine if this is a ChatMessageBase or a string
if (typeof error === "object" && error !== null && "content" in error) {
setSnack(
error.content || "Unknown error generating profile image",
"error"
);
} else {
setSnack(error as string, "error");
}
setProcessingMessage(null);
setProcessing(false);
controllerRef.current = null;
setCanGenImage(true);
setShouldGenerateProfile(false);
},
onComplete: () => {
setProcessingMessage(null);
setProcessing(false);
controllerRef.current = null;
setCanGenImage(true);
setShouldGenerateProfile(false);
},
onStatus: (status: ChatMessageStatus) => {
if (status.activity === "heartbeat" && status.content) {
setTimestamp(status.timestamp?.toISOString() || "");
} else if (status.content) {
setProcessingMessage({ ...defaultMessage, content: status.content });
}
console.log(`onStatusChange: ${status}`);
},
});
}, [
chatSession,
shouldGenerateProfile,
generatedUser,
prompt,
setSnack,
apiClient,
]);
if (!user?.isAdmin) {
return ( return (
<Box className="GenerateCandidate" sx={{ <Box>You must be logged in as an admin to generate AI candidates.</Box>
);
}
return (
<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 && (
<PropagateLoader <Message message={processingMessage} {...{ chatSession }} />
size="10px" )}
loading={processing} <PropagateLoader
aria-label="Loading Spinner" size="10px"
data-testid="loader" loading={processing}
/> aria-label="Loading Spinner"
</Box> data-testid="loader"
} />
<Box sx={{display: "flex", flexDirection: "column"}}>
<Box sx={{
display: "flex",
flexDirection: "row",
position: "relative"
}}>
<Box sx={{ display: "flex", position: "relative", width: "min-content", height: "min-content" }}>
<Avatar
src={generatedUser?.profileImage ? `/api/1.0/candidates/profile/${generatedUser.username}` : ''}
alt={`${generatedUser?.fullName}'s profile`}
sx={{
width: 80,
height: 80,
border: '2px solid #e0e0e0',
}}
/>
{processing && <Pulse sx={{ position: "relative", left: "-80px", top: "0px", mr: "-80px" }} timestamp={timestamp} />}
</Box>
<Tooltip title={`${generatedUser?.profileImage ? 'Re-' : ''}Generate Picture`}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, justifySelf: "flex-start", alignSelf: "center", flexGrow: 0, maxHeight: "min-content" }}
variant="contained"
disabled={
processing || !canGenImage
}
onClick={() => { setShouldGenerateProfile(true); }}>
{generatedUser?.profileImage ? 'Re-' : ''}Generate Picture<SendIcon />
</Button>
</span>
</Tooltip>
</Box>
</Box> </Box>
{resume && )}
<Paper sx={{pt: 1, pb: 1, pl: 2, pr: 2}}> <Box sx={{ display: "flex", flexDirection: "column" }}>
<Scrollable sx={{flexGrow: 1}}> <Box
<StyledMarkdown content={resume} /> sx={{
</Scrollable> display: "flex",
</Paper> flexDirection: "row",
} position: "relative",
<BackstoryTextField }}
style={{ flexGrow: 0, flexShrink: 1 }} >
ref={backstoryTextRef} <Box
disabled={processing} sx={{
onEnter={onEnter} display: "flex",
placeholder='Specify any characteristics you would like the persona to have. For example, "This person likes yo-yos."' position: "relative",
/> width: "min-content",
<Box sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}> height: "min-content",
<Tooltip title={"Send"}> }}
>
<Avatar
src={
generatedUser?.profileImage
? `/api/1.0/candidates/profile/${generatedUser.username}`
: ""
}
alt={`${generatedUser?.fullName}'s profile`}
sx={{
width: 80,
height: 80,
border: "2px solid #e0e0e0",
}}
/>
{processing && (
<Pulse
sx={{
position: "relative",
left: "-80px",
top: "0px",
mr: "-80px",
}}
timestamp={timestamp}
/>
)}
</Box>
<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, flexGrow: 1 }} sx={{
m: 1,
gap: 1,
justifySelf: "flex-start",
alignSelf: "center",
flexGrow: 0,
maxHeight: "min-content",
}}
variant="contained" variant="contained"
disabled={processing} disabled={processing || !canGenImage}
onClick={handleSendClick}> onClick={() => {
Generate New Persona<SendIcon /> setShouldGenerateProfile(true);
}}
>
{generatedUser?.profileImage ? "Re-" : ""}Generate Picture
<SendIcon />
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
<Tooltip title="Cancel">
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={cancelQuery}
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
disabled={controllerRef.current === null || processing === false}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
</Box> </Box>
<Box sx={{display: "flex", flexGrow: 1}}/> </Box>
</Box>); {resume && (
<Paper sx={{ pt: 1, pb: 1, pl: 2, pr: 2 }}>
<Scrollable sx={{ flexGrow: 1 }}>
<StyledMarkdown content={resume} />
</Scrollable>
</Paper>
)}
<BackstoryTextField
style={{ flexGrow: 0, flexShrink: 1 }}
ref={backstoryTextRef}
disabled={processing}
onEnter={onEnter}
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" }}
>
<Tooltip title={"Send"}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={processing}
onClick={handleSendClick}
>
Generate New Persona
<SendIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Cancel">
<span style={{ display: "flex" }}>
{" "}
{/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={cancelQuery}
sx={{ display: "flex", margin: "auto 0px" }}
size="large"
edge="start"
disabled={controllerRef.current === null || processing === false}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
</Box>
<Box sx={{ display: "flex", flexGrow: 1 }} />
</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 (
{children} <HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
</HeroStyledButton> {children}
} </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 (
{children} <Button onClick={onClick ? onClick : handleClick} {...rest}>
</Button> {children}
} </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",
gap: 4, flexDirection: { xs: "column", md: "row" },
alignItems: 'center', gap: 4,
flexGrow: 1, alignItems: "center",
maxWidth: "1024px" flexGrow: 1,
}}> 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",
width: 40, borderRadius: "50%",
height: 40, width: 40,
minWidth: 40, height: 40,
minHeight: 40, minWidth: 40,
display: 'flex', minHeight: 40,
justifyContent: 'center', display: "flex",
alignItems: 'center', justifyContent: "center",
mr: 2, alignItems: "center",
fontWeight: 'bold' mr: 2,
}}> 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",
width: 40, borderRadius: "50%",
height: 40, width: 40,
minWidth: 40, height: 40,
minHeight: 40, minWidth: 40,
display: 'flex', minHeight: 40,
justifyContent: 'center', display: "flex",
alignItems: 'center', justifyContent: "center",
mr: 2, alignItems: "center",
fontWeight: 'bold' mr: 2,
}}> 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",
width: 40, borderRadius: "50%",
height: 40, width: 40,
minWidth: 40, height: 40,
minHeight: 40, minWidth: 40,
display: 'flex', minHeight: 40,
justifyContent: 'center', display: "flex",
alignItems: 'center', justifyContent: "center",
mr: 2, alignItems: "center",
fontWeight: 'bold' mr: 2,
}}> fontWeight: "bold",
}}
>
3 3
</Box> </Box>
<Typography variant="body1"> <Typography variant="body1">
@ -303,110 +332,125 @@ const HomePage = () => {
</Box> </Box>
</Stack> </Stack>
<ActionButton
variant="contained"
color="secondary"
sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />}
>
Create Your Profile
</ActionButton>
</Box>
<ComingSoon>
<Box sx={{ flex: 1 }}>
<Typography variant="h4" component="h3" gutterBottom sx={{ color: 'primary.main' }}>
For Employers
</Typography>
<Box sx={{ my: 3 }}>
<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.
</Typography>
</Box>
<Stack spacing={3}>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
1
</Box>
<Typography variant="body1">
Search the candidate pool based on skills, experience, and location
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
2
</Box>
<Typography variant="body1">
Ask personalized questions about candidates' experience and skills
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
3
</Box>
<Typography variant="body1">
Generate targeted resumes that match your job requirements
</Typography>
</Box>
</Stack>
<ActionButton <ActionButton
variant="contained" variant="contained"
color="secondary" color="secondary"
sx={{ mt: 4 }} sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />} endIcon={<ArrowForwardIcon />}
> >
Start Recruiting Create Your Profile
</ActionButton> </ActionButton>
</Box> </Box>
</ComingSoon>
<ComingSoon>
<Box sx={{ flex: 1 }}>
<Typography
variant="h4"
component="h3"
gutterBottom
sx={{ color: "primary.main" }}
>
For Employers
</Typography>
<Box sx={{ my: 3 }}>
<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.
</Typography>
</Box>
<Stack spacing={3}>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: "secondary.main",
color: "secondary.contrastText",
borderRadius: "50%",
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
mr: 2,
fontWeight: "bold",
}}
>
1
</Box>
<Typography variant="body1">
Search the candidate pool based on skills, experience, and
location
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: "secondary.main",
color: "secondary.contrastText",
borderRadius: "50%",
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
mr: 2,
fontWeight: "bold",
}}
>
2
</Box>
<Typography variant="body1">
Ask personalized questions about candidates' experience and
skills
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: "secondary.main",
color: "secondary.contrastText",
borderRadius: "50%",
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
mr: 2,
fontWeight: "bold",
}}
>
3
</Box>
<Typography variant="body1">
Generate targeted resumes that match your job requirements
</Typography>
</Box>
</Stack>
<ActionButton
variant="contained"
color="secondary"
sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />}
>
Start Recruiting
</ActionButton>
</Box>
</ComingSoon>
</Box> </Box>
</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,68 +548,76 @@ const HomePage = () => {
</Box> </Box>
{/* Testimonials Section */} {/* Testimonials Section */}
{testimonials && {testimonials && (
<Container sx={{ py: 8 }}> <Container sx={{ py: 8 }}>
<Typography <Typography
variant="h3" variant="h3"
component="h2" component="h2"
align="center" align="center"
gutterBottom gutterBottom
sx={{ mb: 2, fontWeight: 600 }} sx={{ mb: 2, fontWeight: 600 }}
> >
Success Stories Success Stories
</Typography> </Typography>
<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
</Typography> candidates and employers.
</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",
maxWidth: 800, textAlign: "center",
mx: 'auto' maxWidth: 800,
}}> 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
@ -538,11 +626,8 @@ const HomePage = () => {
</Box> </Box>
</Container> </Container>
</Box> </Box>
</Box> </Box>
); );
}; };
export { export { HomePage };
HomePage
};

View File

@ -1,268 +1,301 @@
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,
Container, Container,
Paper, Paper,
Typography, Typography,
Grid, Grid,
Card, Card,
CardContent, CardContent,
Chip, Chip,
Step, Step,
StepLabel, StepLabel,
Stepper, Stepper,
Stack, Stack,
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,
}, },
})); }));
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}`,
}, },
})); }));
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 {
stepNumber: number; stepNumber: number;
title: string; title: string;
subtitle: string; subtitle: string;
icon: React.ReactNode; icon: React.ReactNode;
description: string[]; description: string[];
imageSrc: string; imageSrc: string;
imageAlt: string; imageAlt: string;
note?: string; note?: string;
success?: string; success?: string;
reversed?: boolean; reversed?: boolean;
} }
const StepContent: React.FC<StepContentProps> = ({ const StepContent: React.FC<StepContentProps> = ({
stepNumber, stepNumber,
title, title,
subtitle, subtitle,
icon, icon,
description, description,
imageSrc, imageSrc,
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" } }}
{title} >
</Typography> <Typography
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', justifyContent: { xs: 'center', md: 'flex-start' } }}> variant="h3"
{icon} component="h2"
<Typography variant="body2" color="text.secondary"> sx={{ color: "primary.main", mb: 1 }}
{subtitle} >
</Typography> {title}
</Box> </Typography>
</Box> <Box
</Box> sx={{
{description.map((paragraph, index) => ( display: "flex",
<Typography key={index} variant="body1" paragraph> gap: 1,
{paragraph} alignItems: "center",
</Typography> justifyContent: { xs: "center", md: "flex-start" },
))} }}
{note && ( >
<Paper sx={{ p: 2, backgroundColor: 'action.hover', border: '1px solid', borderColor: 'action.active', mt: 2 }}> {icon}
<Typography variant="body2" sx={{ fontStyle: 'italic' }}> <Typography variant="body2" color="text.secondary">
<strong>Note:</strong> {note} {subtitle}
</Typography> </Typography>
</Paper> </Box>
)} </Box>
{success && ( </Box>
<Paper sx={{ p: 2, backgroundColor: 'secondary.main', color: 'secondary.contrastText', mt: 2 }}> {description.map((paragraph, index) => (
<Typography variant="body1" sx={{ fontWeight: 'bold' }}> <Typography key={index} variant="body1" paragraph>
🎉 {success} {paragraph}
</Typography> </Typography>
</Paper> ))}
)} {note && (
</Grid> <Paper
); sx={{
p: 2,
backgroundColor: "action.hover",
border: "1px solid",
borderColor: "action.active",
mt: 2,
}}
>
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
<strong>Note:</strong> {note}
</Typography>
</Paper>
)}
{success && (
<Paper
sx={{
p: 2,
backgroundColor: "secondary.main",
color: "secondary.contrastText",
mt: 2,
}}
>
<Typography variant="body1" sx={{ fontWeight: "bold" }}>
🎉 {success}
</Typography>
</Paper>
)}
</Grid>
);
const imageContent = ( const imageContent = (
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<ImageContainer> <ImageContainer>
<img src={imageSrc} alt={imageAlt} /> <img src={imageSrc} alt={imageAlt} />
</ImageContainer> </ImageContainer>
</Grid> </Grid>
); );
return ( return (
<Grid container spacing={4} alignItems="center"> <Grid container spacing={4} alignItems="center">
{reversed ? ( {reversed ? (
<> <>
{imageContent} {imageContent}
{textContent} {textContent}
</> </>
) : ( ) : (
<> <>
{textContent} {textContent}
{imageContent} {imageContent}
</> </>
)} )}
</Grid> </Grid>
); );
}; };
interface HeroButtonProps extends ButtonProps { interface HeroButtonProps extends ButtonProps {
children?: string; children?: string;
path: string; path: string;
} }
const HeroButton = (props: HeroButtonProps) => { const HeroButton = (props: HeroButtonProps) => {
const { children, onClick, path, ...rest } = props; const { children, onClick, path, ...rest } = props;
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = () => { const handleClick = () => {
navigate(path); navigate(path);
}; };
const HeroStyledButton = styled(Button)(({ theme }) => ({ const HeroStyledButton = styled(Button)(({ theme }) => ({
marginTop: theme.spacing(2), marginTop: theme.spacing(2),
padding: theme.spacing(1, 3), padding: theme.spacing(1, 3),
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 (
{children} <HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{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 (
<Box sx={{ display: "flex", flexDirection: "column" }}> <Box sx={{ display: "flex", flexDirection: "column" }}>
{/* Hero Section */} {/* Hero Section */}
{/* Hero Section */} {/* Hero Section */}
<HeroSection> <HeroSection>
<Container> <Container>
<Box sx={{ <Box
display: 'flex', sx={{
flexDirection: { xs: 'column', md: 'row' }, display: "flex",
gap: 4, flexDirection: { xs: "column", md: "row" },
alignItems: 'center', gap: 4,
flexGrow: 1, alignItems: "center",
maxWidth: "1024px" flexGrow: 1,
}}> maxWidth: "1024px",
<Box sx={{ flex: 1, flexGrow: 1 }}> }}
<Typography >
variant="h2" <Box sx={{ flex: 1, flexGrow: 1 }}>
component="h1" <Typography
sx={{ variant="h2"
fontWeight: 700, component="h1"
fontSize: { xs: '2rem', md: '3rem' }, sx={{
mb: 2, fontWeight: 700,
color: "white" fontSize: { xs: "2rem", md: "3rem" },
}} mb: 2,
> color: "white",
Your complete professional story, beyond a single page }}
</Typography> >
<Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}> Your complete professional story, beyond a single page
Let potential employers discover the depth of your experience through interactive Q&A and tailored resumes </Typography>
</Typography> <Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}> Let potential employers discover the depth of your experience
<HeroButton through interactive Q&A and tailored resumes
variant="contained" </Typography>
size="large" <Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
path="/login/register" <HeroButton
> variant="contained"
Get Started as Candidate size="large"
</HeroButton> path="/login/register"
{/* <HeroButton >
Get Started as Candidate
</HeroButton>
{/* <HeroButton
variant="outlined" variant="outlined"
size="large" size="large"
sx={{ sx={{
@ -273,218 +306,248 @@ const HowItWorks: React.FC = () => {
> >
Recruit Talent Recruit Talent
</HeroButton> */} </HeroButton> */}
</Stack> </Stack>
</Box>
<Box sx={{ justifyContent: "center", display: { xs: 'none', md: 'block' } }}>
<Box
component="img"
src={professionalConversationPng}
alt="Professional conversation"
sx={{
width: '100%',
maxWidth: 200,
height: 'auto',
borderRadius: 2,
boxShadow: 3,
}}
/>
</Box>
</Box>
</Container>
</HeroSection>
<HeroSection sx={{ display: "flex", position: "relative", overflow: "hidden", border: "2px solid orange" }}>
<Beta adaptive={false} sx={{ left: "-90px" }} />
<Container sx={{ display: "flex", position: "relative" }}>
<Box sx={{ display: "flex", flexDirection: "column", textAlign: 'center', maxWidth: 800, mx: 'auto', position: "relative" }}>
<Typography
variant="h2"
component="h1"
sx={{
fontWeight: 700,
fontSize: { xs: '2rem', md: '2.5rem' },
mb: 2,
color: "white"
}}
>
Welcome to the Backstory Beta!
</Typography>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}>
Here are your steps from zero-to-hero to see Backstory in action
</Typography>
</Box>
</Container>
</HeroSection>
{/* Progress Overview */}
<Container sx={{ py: 4 }}>
<Box sx={{ display: { xs: 'none', md: 'block' } }}>
<Stepper alternativeLabel sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
</Box>
</Container>
{/* Step 1: Select Job Analysis */}
<StepSection>
<Container>
<StepContent
stepNumber={1}
title="Select Job Analysis"
subtitle="Navigate to the main feature"
icon={<AssessmentIcon sx={{ color: 'action.active' }} />}
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."
]}
imageSrc={selectJobAnalysisPng}
imageAlt="Select Job Analysis from menu"
/>
</Container>
</StepSection>
{/* Step 2: Select a Job */}
<StepSection>
<Container>
<StepContent
stepNumber={2}
title="Choose a Job"
subtitle="Pick from existing job postings"
icon={<WorkIcon sx={{ color: 'action.active' }} />}
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."
]}
imageSrc={selectAJobPng}
imageAlt="Select a job from the available options"
note="You can create your own job postings once you create an account. Until then, you need to select one that already exists."
reversed={true}
/>
</Container>
</StepSection>
{/* Step 3: Select a Candidate */}
<StepSection>
<Container>
<StepContent
stepNumber={3}
title="Select a Candidate"
subtitle="Choose from available profiles"
icon={<PersonIcon sx={{ color: 'action.active' }} />}
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."
]}
imageSrc={selectACandidatePng}
imageAlt="Select a candidate from the available profiles"
note="If you create an account, you can opt-in to have your account show up for others to view as well, or keep it private for just your own resume generation and job research."
/>
</Container>
</StepSection>
{/* Step 4: Start Assessment */}
<StepSection>
<Container>
<StepContent
stepNumber={4}
title="Start Assessment"
subtitle="Begin the AI analysis"
icon={<PlayArrowIcon sx={{ color: 'action.active' }} />}
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.",
"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."
]}
imageSrc={selectStartAnalysisPng}
imageAlt="Start the skill assessment process"
reversed={true}
/>
</Container>
</StepSection>
{/* Step 5: Wait and Review */}
<StepSection>
<Container>
<StepContent
stepNumber={5}
title="Review Results"
subtitle="Watch the magic happen"
icon={<AutoAwesomeIcon sx={{ color: 'action.active' }} />}
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 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}
imageAlt="Wait for the analysis to complete and review results"
/>
</Container>
</StepSection>
{/* Step 6: Generate Resume */}
<StepSection>
<Container>
<StepContent
stepNumber={6}
title="Generate Resume"
subtitle="Create your tailored resume"
icon={<DescriptionIcon sx={{ color: 'action.active' }} />}
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.",
"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}
imageAlt="Generated custom resume tailored to the job"
success="Success! You can then click the Copy button to copy the resume into your editor, adjust, and apply for your dream job!"
reversed={true}
/>
</Container>
</StepSection>
{/* CTA Section */}
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
py: 6
}}>
<Container>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
maxWidth: 600,
mx: 'auto'
}}>
<Typography variant="h3" component="h2" gutterBottom sx={{ color: "white" }}>
Ready to try Backstory?
</Typography>
<Typography variant="h6" sx={{ mb: 4 }}>
Experience the future of job matching and resume generation today.
</Typography>
<Button
variant="contained"
size="large"
startIcon={<PlayArrowIcon />}
onClick={handleGetStarted}
sx={{
backgroundColor: 'action.active',
color: 'background.paper',
fontWeight: 'bold',
px: 4,
py: 1.5,
'&:hover': {
backgroundColor: 'action.active',
opacity: 0.9,
},
}}
>
Get Started Now
</Button>
</Box>
</Container>
</Box> </Box>
<Box
sx={{
justifyContent: "center",
display: { xs: "none", md: "block" },
}}
>
<Box
component="img"
src={professionalConversationPng}
alt="Professional conversation"
sx={{
width: "100%",
maxWidth: 200,
height: "auto",
borderRadius: 2,
boxShadow: 3,
}}
/>
</Box>
</Box>
</Container>
</HeroSection>
<HeroSection
sx={{
display: "flex",
position: "relative",
overflow: "hidden",
border: "2px solid orange",
}}
>
<Beta adaptive={false} sx={{ left: "-90px" }} />
<Container sx={{ display: "flex", position: "relative" }}>
<Box
sx={{
display: "flex",
flexDirection: "column",
textAlign: "center",
maxWidth: 800,
mx: "auto",
position: "relative",
}}
>
<Typography
variant="h2"
component="h1"
sx={{
fontWeight: 700,
fontSize: { xs: "2rem", md: "2.5rem" },
mb: 2,
color: "white",
}}
>
Welcome to the Backstory Beta!
</Typography>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}>
Here are your steps from zero-to-hero to see Backstory in action
</Typography>
</Box>
</Container>
</HeroSection>
{/* Progress Overview */}
<Container sx={{ py: 4 }}>
<Box sx={{ display: { xs: "none", md: "block" } }}>
<Stepper alternativeLabel sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
</Box> </Box>
); </Container>
{/* Step 1: Select Job Analysis */}
<StepSection>
<Container>
<StepContent
stepNumber={1}
title="Select Job Analysis"
subtitle="Navigate to the main feature"
icon={<AssessmentIcon sx={{ color: "action.active" }} />}
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.",
]}
imageSrc={selectJobAnalysisPng}
imageAlt="Select Job Analysis from menu"
/>
</Container>
</StepSection>
{/* Step 2: Select a Job */}
<StepSection>
<Container>
<StepContent
stepNumber={2}
title="Choose a Job"
subtitle="Pick from existing job postings"
icon={<WorkIcon sx={{ color: "action.active" }} />}
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.",
]}
imageSrc={selectAJobPng}
imageAlt="Select a job from the available options"
note="You can create your own job postings once you create an account. Until then, you need to select one that already exists."
reversed={true}
/>
</Container>
</StepSection>
{/* Step 3: Select a Candidate */}
<StepSection>
<Container>
<StepContent
stepNumber={3}
title="Select a Candidate"
subtitle="Choose from available profiles"
icon={<PersonIcon sx={{ color: "action.active" }} />}
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.",
]}
imageSrc={selectACandidatePng}
imageAlt="Select a candidate from the available profiles"
note="If you create an account, you can opt-in to have your account show up for others to view as well, or keep it private for just your own resume generation and job research."
/>
</Container>
</StepSection>
{/* Step 4: Start Assessment */}
<StepSection>
<Container>
<StepContent
stepNumber={4}
title="Start Assessment"
subtitle="Begin the AI analysis"
icon={<PlayArrowIcon sx={{ color: "action.active" }} />}
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.",
"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.',
]}
imageSrc={selectStartAnalysisPng}
imageAlt="Start the skill assessment process"
reversed={true}
/>
</Container>
</StepSection>
{/* Step 5: Wait and Review */}
<StepSection>
<Container>
<StepContent
stepNumber={5}
title="Review Results"
subtitle="Watch the magic happen"
icon={<AutoAwesomeIcon sx={{ color: "action.active" }} />}
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 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}
imageAlt="Wait for the analysis to complete and review results"
/>
</Container>
</StepSection>
{/* Step 6: Generate Resume */}
<StepSection>
<Container>
<StepContent
stepNumber={6}
title="Generate Resume"
subtitle="Create your tailored resume"
icon={<DescriptionIcon sx={{ color: "action.active" }} />}
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.',
"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}
imageAlt="Generated custom resume tailored to the job"
success="Success! You can then click the Copy button to copy the resume into your editor, adjust, and apply for your dream job!"
reversed={true}
/>
</Container>
</StepSection>
{/* CTA Section */}
<Box
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
py: 6,
}}
>
<Container>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
maxWidth: 600,
mx: "auto",
}}
>
<Typography
variant="h3"
component="h2"
gutterBottom
sx={{ color: "white" }}
>
Ready to try Backstory?
</Typography>
<Typography variant="h6" sx={{ mb: 4 }}>
Experience the future of job matching and resume generation today.
</Typography>
<Button
variant="contained"
size="large"
startIcon={<PlayArrowIcon />}
onClick={handleGetStarted}
sx={{
backgroundColor: "action.active",
color: "background.paper",
fontWeight: "bold",
px: 4,
py: 1.5,
"&:hover": {
backgroundColor: "action.active",
opacity: 0.9,
},
}}
>
Get Started Now
</Button>
</Box>
</Container>
</Box>
</Box>
);
}; };
export { HowItWorks }; export { HowItWorks };

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(
if (!analysisState) { (step: Step) => {
return; if (!analysisState) {
} return;
const missing = step.requiredState.find(f => !(analysisState as any)[f]) }
return missing; const missing = step.requiredState.find(
}, [analysisState]); (f) => !(analysisState as any)[f]
);
return missing;
},
[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%" }}>
<Tabs value={jobTab} onChange={handleTabChange} centered> <Box sx={{ borderBottom: 1, borderColor: "divider", mb: 3 }}>
<Tab value='select' icon={<WorkOutline />} label="Select Job" /> <Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value='create' icon={<WorkAddIcon />} label="Create Job" /> <Tab value="select" icon={<WorkOutline />} label="Select Job" />
</Tabs> <Tab value="create" icon={<WorkAddIcon />} label="Create Job" />
</Box> </Tabs>
</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 && </Box>
<LoginRestricted><JobCreator
onSave={onJobSelect}
/></LoginRestricted>}
</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 (
<JobMatchAnalysis <Box sx={{ mt: 3 }}>
variant="small" <JobMatchAnalysis
job={analysisState.job} variant="small"
candidate={analysisState.candidate} job={analysisState.job}
onAnalysisComplete={onAnalysisComplete} candidate={analysisState.candidate}
/> 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 (
<ResumeGenerator <Box sx={{ mt: 3 }}>
job={analysisState.job} <ResumeGenerator
candidate={analysisState.candidate} job={analysisState.job}
skills={analysisState.analysis} candidate={analysisState.candidate}
/> 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",
width: "100%", flexDirection: "column",
minHeight: 0,/* Prevent flex overflow */ height: "100%" /* Restrict to main-container's height */,
maxHeight: "min-content", width: "100%",
"& > *:not(.Scrollable)": { minHeight: 0 /* Prevent flex overflow */,
flexShrink: 0, /* Prevent shrinking */ maxHeight: "min-content",
}, "& > *:not(.Scrollable)": {
position: "relative", flexShrink: 0 /* Prevent shrinking */,
}}> },
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,50 +426,67 @@ 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}
onClick={handleBack} onClick={handleBack}
sx={{ mr: 1 }} sx={{ mr: 1 }}
> >
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
Start New Analysis disabled={!canAdvance}
</Button> onClick={() => {
) : ( moveToStep(0);
<Button disabled={!canAdvance} onClick={handleNext} variant="contained"> }}
{activeStep.index === steps.length - 1 ? 'Done' : 'Next'} variant="outlined"
</Button> >
)} Start New Analysis
</Box> </Button>
) : (
<Button
disabled={!canAdvance}
onClick={handleNext}
variant="contained"
>
{activeStep.index === steps.length - 1 ? "Done" : "Next"}
</Button>
)}
</Box>
{/* Error Snackbar */} {/* Error Snackbar */}
<Snackbar <Snackbar
open={!!error} open={!!error}
autoHideDuration={6000} autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert
onClose={() => setError(null)} onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} 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 (
<Message message={preamble} {...props} /> <Box
sx={{
display: "flex",
flexGrow: 1,
maxWidth: "1024px",
margin: "0 auto",
}}
>
<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,56 +74,52 @@ 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 (
<Paper elevation={3} sx={{ p: isMobile ? 0 : 4 }}> <Paper elevation={3} sx={{ p: isMobile ? 0 : 4 }}>
<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
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
Session ID: {guest.sessionId} Session ID: {guest.sessionId}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Created: {guest.createdAt?.toLocaleString()} Created: {guest.createdAt?.toLocaleString()}
</Typography> </Typography>
</CardContent> </CardContent>
</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" />
</Tabs> </Tabs>
</Box> </Box>
{errorMessage && ( {errorMessage && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }}>
{errorMessage} {errorMessage}
</Alert> </Alert>
)} )}
{success && ( {success && (
<Alert severity="success" sx={{ mb: 2 }}> <Alert severity="success" sx={{ mb: 2 }}>
{success} {success}
</Alert> </Alert>
)} )}
{tabValue === "login" && ( {tabValue === "login" && <LoginForm />}
<LoginForm />
)}
{tabValue === "register" && ( {tabValue === "register" && <CandidateRegistrationForm />}
<CandidateRegistrationForm />
)}
</Paper> </Paper>
); );
}; };

View File

@ -1,63 +1,72 @@
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>(
const { setSnack } = useAppState(); (props: BackstoryPageProps, ref) => {
const { user } = useAuth(); const { setSnack } = useAppState();
const theme = useTheme(); const { user } = useAuth();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const theme = useTheme();
const [questions, setQuestions] = useState<React.ReactElement[]>([]); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const candidate: Candidate | null = user?.userType === 'candidate' ? user as Types.Candidate : null; const [questions, setQuestions] = useState<React.ReactElement[]>([]);
const candidate: Candidate | null =
user?.userType === "candidate" ? (user as Types.Candidate) : null;
// console.log("ChatPage candidate =>", candidate);
useEffect(() => {
if (!candidate) {
return;
}
setQuestions([
<Box
sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}
>
{candidate.questions?.map((q, i: number) => (
<BackstoryQuery key={i} question={q} />
))}
</Box>,
<Box sx={{ p: 1 }}>
<MuiMarkdown>
{`As with all LLM interactions, the results may not be 100% accurate. Please contact **${candidate.fullName}** if you have any questions.`}
</MuiMarkdown>
</Box>,
]);
}, [candidate, isMobile]);
// console.log("ChatPage candidate =>", candidate);
useEffect(() => {
if (!candidate) { if (!candidate) {
return; return <></>;
} }
setQuestions([
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
{candidate.questions?.map((q, i: number) =>
<BackstoryQuery key={i} question={q} />
)}
</Box>,
<Box sx={{ p: 1 }}>
<MuiMarkdown>
{`As with all LLM interactions, the results may not be 100% accurate. Please contact **${candidate.fullName}** if you have any questions.`}
</MuiMarkdown>
</Box>]);
}, [candidate, isMobile]);
if (!candidate) {
return (<></>);
}
return ( return (
<Box> <Box>
<CandidateInfo candidate={candidate} action="Chat with Backstory AI about " /> <CandidateInfo
<Conversation candidate={candidate}
ref={ref} action="Chat with Backstory AI about "
{...{ />
multiline: true, <Conversation
type: "chat", ref={ref}
{...{
multiline: true,
type: "chat",
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();
@ -33,177 +32,186 @@ const CandidateDashboard = (props: CandidateDashboardProps) => {
const profileCompletion = 75; const profileCompletion = 75;
if (!user) { if (!user) {
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>
<Box sx={{ mb: 2 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
Your profile is {profileCompletion}% complete
</Typography> </Typography>
<LinearProgress
variant="determinate" <Box sx={{ mb: 2 }}>
value={profileCompletion} <Typography variant="body1" sx={{ mb: 1 }}>
sx={{ Your profile is {profileCompletion}% complete
height: 8, </Typography>
borderRadius: 4, <LinearProgress
backgroundColor: '#e0e0e0', variant="determinate"
'& .MuiLinearProgress-bar': { value={profileCompletion}
backgroundColor: '#4caf50', sx={{
}, height: 8,
borderRadius: 4,
backgroundColor: "#e0e0e0",
"& .MuiLinearProgress-bar": {
backgroundColor: "#4caf50",
},
}}
/>
</Box>
<Button
variant="contained"
color="primary"
sx={{ mt: 1 }}
onClick={(e) => {
e.stopPropagation();
navigate("/candidate/profile");
}} }}
/> >
Complete Your Profile
</Button>
</Box> </Box>
<Button {/* Cards Grid */}
variant="contained" <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
color="primary" {/* Top Row */}
sx={{ mt: 1 }} <Box sx={{ display: "flex", gap: 3 }}>
onClick={(e) => { e.stopPropagation(); navigate('/candidate/profile'); }} {/* Resume Builder Card */}
> <Card sx={{ flex: 1, minHeight: 200 }}>
Complete Your Profile <CardContent>
</Button> <Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>
</Box> Resume Builder
{/* Cards Grid */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Top Row */}
<Box sx={{ display: 'flex', gap: 3 }}>
{/* Resume Builder Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Resume Builder
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#666' }}>
3 custom resumes
</Typography>
<Typography variant="body2" sx={{ mb: 3, color: '#666' }}>
Last created: May 15, 2025
</Typography>
<Button
variant="outlined"
startIcon={<AddIcon />}
fullWidth
>
Create New
</Button>
</CardContent>
</Card>
{/* Recent Activity Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Recent Activity
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VisibilityIcon sx={{ fontSize: 16, color: '#666' }} />
<Typography variant="body2">5 profile views</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DownloadIcon sx={{ fontSize: 16, color: '#666' }} />
<Typography variant="body2">2 resume downloads</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ContactMailIcon sx={{ fontSize: 16, color: '#666' }} />
<Typography variant="body2">1 direct contact</Typography>
</Box>
</Stack>
<Button
variant="outlined"
fullWidth
>
View All Activity
</Button>
</CardContent>
</Card>
</Box>
{/* Bottom Row */}
<Box sx={{ display: 'flex', gap: 3 }}>
{/* Complete Your Backstory Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Complete Your Backstory
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Add projects
</Typography> </Typography>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Detail skills
</Typography>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Work history
</Typography>
</Stack>
<Button <Typography variant="body2" sx={{ mb: 1, color: "#666" }}>
variant="outlined" 3 custom resumes
startIcon={<EditIcon />}
fullWidth
>
Edit Backstory
</Button>
</CardContent>
</Card>
{/* Improvement Suggestions Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Improvement Suggestions
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Add certifications
</Typography> </Typography>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Enhance your project details
</Typography>
</Stack>
<Button <Typography variant="body2" sx={{ mb: 3, color: "#666" }}>
variant="outlined" Last created: May 15, 2025
startIcon={<TipsIcon />} </Typography>
fullWidth
> <Button variant="outlined" startIcon={<AddIcon />} fullWidth>
View All Tips Create New
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{/* Recent Activity Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>
Recent Activity
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<VisibilityIcon sx={{ fontSize: 16, color: "#666" }} />
<Typography variant="body2">5 profile views</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<DownloadIcon sx={{ fontSize: 16, color: "#666" }} />
<Typography variant="body2">
2 resume downloads
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<ContactMailIcon sx={{ fontSize: 16, color: "#666" }} />
<Typography variant="body2">1 direct contact</Typography>
</Box>
</Stack>
<Button variant="outlined" fullWidth>
View All Activity
</Button>
</CardContent>
</Card>
</Box>
{/* Bottom Row */}
<Box sx={{ display: "flex", gap: 3 }}>
{/* Complete Your Backstory Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>
Complete Your Backstory
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Add projects
</Typography>
<Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Detail skills
</Typography>
<Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Work history
</Typography>
</Stack>
<Button variant="outlined" startIcon={<EditIcon />} fullWidth>
Edit Backstory
</Button>
</CardContent>
</Card>
{/* Improvement Suggestions Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: "bold" }}>
Improvement Suggestions
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Add certifications
</Typography>
<Typography
variant="body2"
sx={{ display: "flex", alignItems: "center" }}
>
Enhance your project details
</Typography>
</Stack>
<Button variant="outlined" startIcon={<TipsIcon />} fullWidth>
View All Tips
</Button>
</CardContent>
</Card>
</Box>
</Box> </Box>
</Box> </Box>
</Box>
</ComingSoon> </ComingSoon>
</> </>
); );
}; };

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
@ -189,7 +205,7 @@ const CreateProfilePage: React.FC = () => {
</Typography> </Typography>
</Box> </Box>
</Grid> </Grid>
<Grid size={{xs: 12, sm: 6}}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
required required
fullWidth fullWidth
@ -200,7 +216,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined" variant="outlined"
/> />
</Grid> </Grid>
<Grid size={{xs: 12, sm: 6}}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
required required
fullWidth fullWidth
@ -211,7 +227,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined" variant="outlined"
/> />
</Grid> </Grid>
<Grid size={{xs: 12}}> <Grid size={{ xs: 12 }}>
<TextField <TextField
required required
fullWidth fullWidth
@ -223,7 +239,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined" variant="outlined"
/> />
</Grid> </Grid>
<Grid size={{xs:12}}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Phone Number" label="Phone Number"
@ -238,7 +254,7 @@ const CreateProfilePage: React.FC = () => {
case 1: case 1:
return ( return (
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{xs:12}}> <Grid size={{ xs: 12 }}>
<TextField <TextField
required required
fullWidth fullWidth
@ -249,7 +265,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined" variant="outlined"
/> />
</Grid> </Grid>
<Grid size={{xs: 12}}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Location" label="Location"
@ -260,7 +276,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined" variant="outlined"
/> />
</Grid> </Grid>
<Grid size={{xs:12}}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
multiline multiline
@ -278,12 +294,13 @@ const CreateProfilePage: React.FC = () => {
case 2: case 2:
return ( return (
<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,8 +294,9 @@ const Settings = (props: BackstoryPageProps) => {
// } // }
// }; // };
return (<div className="Controls"> return (
{/* <Typography component="span" sx={{ mb: 1 }}> <div className="Controls">
{/* <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>
<Accordion> <Accordion>
@ -382,23 +393,22 @@ const Settings = (props: BackstoryPageProps) => {
</AccordionActions> </AccordionActions>
</Accordion> */} </Accordion> */}
<Accordion> <Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography component="span">System Information</Typography> <Typography component="span">System Information</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
The server is running on the following hardware: The server is running on the following hardware:
</AccordionDetails> </AccordionDetails>
<AccordionActions> <AccordionActions>
<SystemInfoComponent systemInfo={systemInfo} /> <SystemInfoComponent systemInfo={systemInfo} />
</AccordionActions> </AccordionActions>
</Accordion> </Accordion>
{/* <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 (
<LoadingComponent <Box>
loadingText="Fetching candidate information..." <LoadingComponent
loaderType="linear" loadingText="Fetching candidate information..."
withFade={true} loaderType="linear"
fadeDuration={1200} /> withFade={true}
</Box>); fadeDuration={1200}
/>
</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
@ -2002,4 +2194,4 @@ export function convertArrayFromApi<T>(data: any[], modelType: string): T[] {
export type User = Candidate | Employer; export type User = Candidate | Employer;
// Export all types // Export all types
export type { }; export type {};