Compare commits

...

2 Commits

Author SHA1 Message Date
66b68270cd Prettier / eslint reformatting 2025-06-18 14:34:52 -07:00
f9307070a3 Reformatting tsx 2025-06-18 14:26:07 -07:00
84 changed files with 10737 additions and 8310 deletions

4
frontend/.eslintignore Normal file
View File

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

42
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,42 @@
{
"env": {
"browser": 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": {}
}
}
}

10
frontend/.prettierrc Normal file
View File

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

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} --no-color",
"lint:fix": "eslint src/**/*.{ts,tsx} --fix --no-color",
"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"
} }
} }

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;
@ -254,4 +253,4 @@ button {
#SystemPromptInput { #SystemPromptInput {
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.25rem; line-height: 1.25rem;
} }

View File

@ -1,9 +1,8 @@
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';
@ -17,47 +16,38 @@ 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(`handleSubmitChatQuery:`, query, chatRef.current ? ' sending' : 'no handler');
snackRef.current?.setSnack(message, severity); chatRef.current?.submitQuery(query);
}, [snackRef]); navigate('/chat');
const submitQuery = (query: ChatQuery) => { };
console.log(`handleSubmitChatQuery:`, query, chatRef.current ? ' sending' : 'no handler'); const [page, setPage] = useState<string>('');
chatRef.current?.submitQuery(query);
navigate('/chat');
};
const [page, setPage] = useState<string>("");
useEffect(() => { useEffect(() => {
const currentRoute = location.pathname.split("/")[1] ? `/${location.pathname.split("/")[1]}` : "/"; const currentRoute = location.pathname.split('/')[1]
setPage(currentRoute); ? `/${location.pathname.split('/')[1]}`
}, [location.pathname]); : '/';
setPage(currentRoute);
}, [location.pathname]);
// Render appropriate routes based on user type // Render appropriate routes based on user type
return ( return (
<ThemeProvider theme={backstoryTheme}> <ThemeProvider theme={backstoryTheme}>
<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="/*" element={<BackstoryLayout {...{ page, chatRef, submitQuery }} />} />
path="/*" </Routes>
element={ </AppStateProvider>
<BackstoryLayout {...{ setSnack, page, chatRef, snackRef, submitQuery }} /> </AuthProvider>
} </ThemeProvider>
/> );
</Routes>
</AppStateProvider>
</AuthProvider>
</ThemeProvider>
);
}; };
export { export { BackstoryApp };
BackstoryApp
};

View File

@ -120,4 +120,4 @@ const backstoryTheme = createTheme({
}, },
}); });
export { backstoryTheme }; export { backstoryTheme };

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

@ -7,47 +7,41 @@ 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?: string | ReactElement<unknown, string | JSXElementConstructor<any>> | undefined;
iconPosition?: "bottom" | "top" | "start" | "end" | 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,4 +1,11 @@
import React, { useRef, useEffect, CSSProperties, KeyboardEvent, useState, useImperativeHandle } from 'react'; import React, {
useRef,
useEffect,
CSSProperties,
KeyboardEvent,
useState,
useImperativeHandle,
} from 'react';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import './BackstoryTextField.css'; import './BackstoryTextField.css';
@ -18,134 +25,134 @@ interface BackstoryTextFieldProps {
style?: CSSProperties; style?: CSSProperties;
} }
const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryTextFieldProps>((props, ref) => { const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryTextFieldProps>(
const { (props, ref) => {
value = '', const { value = '', disabled = false, placeholder, onEnter, onChange, style } = props;
disabled = false, const theme = useTheme();
placeholder, const textareaRef = useRef<HTMLTextAreaElement>(null);
onEnter, const shadowRef = useRef<HTMLTextAreaElement>(null);
onChange, const [editValue, setEditValue] = useState<string>(value);
style,
} = props;
const theme = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const shadowRef = useRef<HTMLTextAreaElement>(null);
const [editValue, setEditValue] = useState<string>(value);
// Sync editValue with prop value if it changes externally // Sync editValue with prop value if it changes externally
useEffect(() => { useEffect(() => {
setEditValue(value || ""); setEditValue(value || '');
}, [value]); }, [value]);
// Adjust textarea height based on content // Adjust textarea height based on content
useEffect(() => { useEffect(() => {
if (!textareaRef.current || !shadowRef.current) { if (!textareaRef.current || !shadowRef.current) {
return; return;
} }
const textarea = textareaRef.current; const textarea = textareaRef.current;
const shadow = shadowRef.current; const shadow = shadowRef.current;
// Set shadow value to editValue or placeholder if editValue is empty // Set shadow value to editValue or placeholder if editValue is empty
shadow.value = editValue || placeholder; shadow.value = editValue || placeholder;
// Ensure shadow textarea has same content-relevant styles // Ensure shadow textarea has same content-relevant styles
const computed = getComputedStyle(textarea); const computed = getComputedStyle(textarea);
shadow.style.width = computed.width; // Match width for accurate wrapping shadow.style.width = computed.width; // Match width for accurate wrapping
shadow.style.fontSize = computed.fontSize; shadow.style.fontSize = computed.fontSize;
shadow.style.lineHeight = computed.lineHeight; shadow.style.lineHeight = computed.lineHeight;
shadow.style.fontFamily = computed.fontFamily; shadow.style.fontFamily = computed.fontFamily;
shadow.style.letterSpacing = computed.letterSpacing; shadow.style.letterSpacing = computed.letterSpacing;
shadow.style.wordSpacing = computed.wordSpacing; shadow.style.wordSpacing = computed.wordSpacing;
// 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`;
}); });
// Cleanup RAF to prevent memory leaks // Cleanup RAF to prevent memory leaks
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, [editValue, placeholder]); }, [editValue, placeholder]);
// Expose getValue method via ref // Expose getValue method via ref
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,
}; };
return ( return (
<> <>
<textarea <textarea
className="BackstoryTextField" className="BackstoryTextField"
ref={textareaRef} ref={textareaRef}
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 => {
onKeyDown={handleKeyDown} setEditValue(e.target.value);
style={fullStyle} onChange && onChange(e.target.value);
/> }}
<textarea onKeyDown={handleKeyDown}
className="BackgroundTextField" style={fullStyle}
ref={shadowRef} />
aria-hidden="true" <textarea
style={{ className="BackgroundTextField"
...fullStyle, ref={shadowRef}
position: 'absolute', aria-hidden="true"
top: '-9999px', style={{
left: '-9999px', ...fullStyle,
visibility: 'hidden', position: 'absolute',
padding: '0px', // No padding to match content height top: '-9999px',
margin: '0px', left: '-9999px',
border: '0px', // Remove border to avoid extra height visibility: 'hidden',
height: 'auto', // Allow natural height padding: '0px', // No padding to match content height
minHeight: '0px', margin: '0px',
}} border: '0px', // Remove border to avoid extra height
readOnly height: 'auto', // Allow natural height
tabIndex={-1} minHeight: '0px',
/> }}
</> readOnly
); tabIndex={-1}
}); />
</>
);
}
);
export type { export type { BackstoryTextFieldRef };
BackstoryTextFieldRef
};
export { BackstoryTextField }; export { BackstoryTextField };

View File

@ -1,4 +1,11 @@
import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react'; import React, {
useState,
useImperativeHandle,
forwardRef,
useEffect,
useRef,
useCallback,
} from 'react';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
@ -6,25 +13,43 @@ import Box from '@mui/material/Box';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { SxProps, Theme } from '@mui/material'; 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 { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
import { BackstoryElementProps } from './BackstoryTab'; 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 {
ChatMessage,
ChatContext,
ChatSession,
ChatQuery,
ChatMessageUser,
ChatMessageError,
ChatMessageStreaming,
ChatMessageStatus,
} from 'types/types';
import { PaginatedResponse } from 'types/conversion'; 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';
@ -34,395 +59,450 @@ 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<ChatMessage[]>([]);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | undefined>(undefined); const [processingMessage, setProcessingMessage] = useState<ChatMessage | undefined>(undefined);
const [noInteractions, setNoInteractions] = useState<boolean>(true); const [streamingMessage, setStreamingMessage] = useState<ChatMessage | undefined>(undefined);
const viewableElementRef = useRef<HTMLDivElement>(null); const [noInteractions, setNoInteractions] = useState<boolean>(true);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null); const viewableElementRef = useRef<HTMLDivElement>(null);
const stopRef = useRef(false); const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const controllerRef = useRef<StreamingResponse>(null); const stopRef = useRef(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null); const controllerRef = useRef<StreamingResponse>(null);
const { setSnack } = useAppState(); 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); /* Do not copy conversation or useEffect will loop forever */
} //console.log(`${conversation.length - filtered.length} messages filtered out.`);
if (filtered.length === 0) {
setFilteredConversation([
...(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> = await apiClient.getChatMessages(
const messages: ChatMessage[] = response.data; 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

@ -6,7 +6,7 @@ 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;
} }
@ -14,10 +14,10 @@ interface CopyBubbleProps extends IconButtonProps {
const CopyBubble = ({ const CopyBubble = ({
content, content,
sx, sx,
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,12 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
IconButton, IconButton,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
Button, Button,
useMediaQuery, useMediaQuery,
Tooltip, Tooltip,
SxProps, SxProps,
@ -19,8 +19,17 @@ interface DeleteConfirmationProps {
onDelete?: () => void; onDelete?: () => void;
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;
@ -47,7 +56,7 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
disabled, disabled,
label, label,
color, color,
action = "delete", action = 'delete',
// New props // New props
open: controlledOpen, open: controlledOpen,
onClose: controlledOnClose, onClose: controlledOnClose,
@ -56,7 +65,7 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
message, message,
hideButton = false, hideButton = false,
confirmButtonText, confirmButtonText,
cancelButtonText = "Cancel", cancelButtonText = 'Cancel',
sx, sx,
icon = <ResetIcon />, icon = <ResetIcon />,
} = props; } = props;
@ -94,21 +103,32 @@ 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 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 */} <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 => {
color={color || "inherit"} e.stopPropagation();
sx={{ display: "flex", margin: 'auto 0px', ...sx }} e.preventDefault();
handleClickOpen();
}}
color={color || 'inherit'}
sx={{ display: 'flex', margin: 'auto 0px', ...sx }}
size="large" size="large"
edge="start" edge="start"
disabled={disabled} disabled={disabled}
@ -118,20 +138,16 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
</span> </span>
</Tooltip> </Tooltip>
)} )}
<Dialog <Dialog
fullScreen={fullScreen} fullScreen={fullScreen}
open={isOpen} open={isOpen}
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 +160,6 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
</Dialog> </Dialog>
</> </>
); );
} };
export { export { DeleteConfirmation };
DeleteConfirmation
};

View File

@ -9,7 +9,7 @@ interface DocumentProps extends BackstoryElementProps {
const Document = (props: DocumentProps) => { const Document = (props: DocumentProps) => {
const { filepath } = props; const { filepath } = props;
const [document, setDocument] = useState<string>(""); const [document, setDocument] = useState<string>('');
// Get the markdown // Get the markdown
useEffect(() => { useEffect(() => {
@ -26,23 +26,23 @@ const Document = (props: DocumentProps) => {
}); });
if (!response.ok) { if (!response.ok) {
throw Error(`${filepath} not found.`); throw Error(`${filepath} not found.`);
} }
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

@ -24,16 +24,10 @@ import {
Paper, Paper,
} from '@mui/material'; } from '@mui/material';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import { import { CloudUpload, Edit, Delete, Visibility, Close } from '@mui/icons-material';
CloudUpload,
Edit,
Delete,
Visibility,
Close,
} 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';
@ -55,7 +49,7 @@ const DocumentManager = (props: BackstoryElementProps) => {
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] = useState<Types.Document | null>(null);
const [documentContent, setDocumentContent] = useState<string>(''); const [documentContent, setDocumentContent] = useState<string>('');
@ -63,9 +57,9 @@ const DocumentManager = (props: BackstoryElementProps) => {
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(null); const [editingDocument, setEditingDocument] = useState<Types.Document | null>(null);
const [editingName, setEditingName] = useState(''); 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,10 +70,10 @@ 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');
} }
}; };
@ -87,43 +81,47 @@ const DocumentManager = (props: BackstoryElementProps) => {
// 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('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return; return;
} }
try { try {
// Upload file (replace with actual API call) // Upload file (replace with actual API call)
const controller = apiClient.uploadCandidateDocument(file, { includeInRag: true, isJobDocument: false }, { const controller = apiClient.uploadCandidateDocument(
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) {
@ -138,10 +136,10 @@ const DocumentManager = (props: BackstoryElementProps) => {
try { try {
// 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);
@ -159,13 +157,9 @@ const DocumentManager = (props: BackstoryElementProps) => {
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, includeInRag } : doc))
doc.id === document.id
? { ...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) {
@ -182,15 +176,11 @@ const DocumentManager = (props: BackstoryElementProps) => {
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, filename: newName.trim() } : doc))
doc.id === document.id
? { ...doc, filename: newName.trim() }
: doc
)
); );
setSnack('Document renamed successfully', 'success'); setSnack('Document renamed successfully', 'success');
setIsRenameDialogOpen(false); setIsRenameDialogOpen(false);
@ -206,7 +196,7 @@ const DocumentManager = (props: BackstoryElementProps) => {
try { try {
setSelectedDocument(document); setSelectedDocument(document);
setIsViewingContent(true); setIsViewingContent(true);
// Call API to get document content // Call API to get document content
const result = await apiClient.getCandidateDocumentText(document); const result = await apiClient.getCandidateDocumentText(document);
setDocumentContent(result.content); setDocumentContent(result.content);
@ -235,140 +225,158 @@ const DocumentManager = (props: BackstoryElementProps) => {
// 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'}>Documents</Typography>
type="file" <Button
accept=".txt,.md,.docx,.pdf" component="label"
onChange={handleDocumentUpload} variant="contained"
/> startIcon={<CloudUpload />}
</Button> 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)} }}
>
<Typography
variant="body1"
sx={{
wordBreak: 'break-word',
fontSize: { xs: '0.9rem', sm: '1rem' },
}}
>
{doc.filename}
</Typography>
<Chip
label={doc.type.toUpperCase()}
size="small"
color={getFileTypeColor(doc.type)}
/>
{doc.options?.includeInRag && (
<Chip label="RAG" size="small" color="success" variant="outlined" />
)}
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatFileSize(doc.size)} {doc?.uploadDate?.toLocaleDateString()}
</Typography>
<Box sx={{ mt: 1 }}>
<FormControlLabel
control={
<Switch
checked={doc.options?.includeInRag}
onChange={e => handleRAGToggle(doc, e.target.checked)}
size="small"
/> />
{doc.options?.includeInRag && ( }
<Chip label={<Typography variant="caption">Include in RAG</Typography>}
label="RAG"
size="small"
color="success"
variant="outlined"
/>
)}
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatFileSize(doc.size)} {doc?.uploadDate?.toLocaleDateString()}
</Typography>
<Box sx={{ mt: 1 }}>
<FormControlLabel
control={
<Switch
checked={doc.options?.includeInRag}
onChange={(e) => handleRAGToggle(doc, e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption">
Include in RAG
</Typography>
}
/>
</Box>
</Box>
}
/> />
<ListItemSecondaryAction> </Box>
<Box sx={{ display: 'flex', gap: 0.5 }}> </Box>
<IconButton }
edge="end" />
size="small" <ListItemSecondaryAction>
onClick={() => handleViewDocument(doc)} <Box sx={{ display: 'flex', gap: 0.5 }}>
title="View content" <IconButton
> edge="end"
<Visibility /> size="small"
</IconButton> onClick={() => handleViewDocument(doc)}
<IconButton title="View content"
edge="end" >
size="small" <Visibility />
onClick={() => startRename(doc, doc.filename)} </IconButton>
title="Rename" <IconButton
> edge="end"
<Edit /> size="small"
</IconButton> onClick={() => startRename(doc, doc.filename)}
<IconButton title="Rename"
edge="end" >
size="small" <Edit />
onClick={() => handleDeleteDocument(doc)} </IconButton>
title="Delete" <IconButton
color="error" edge="end"
> size="small"
<Delete /> onClick={() => handleDeleteDocument(doc)}
</IconButton> title="Delete"
</Box> color="error"
</ListItemSecondaryAction> >
</ListItem> <Delete />
</React.Fragment> </IconButton>
))} </Box>
</List> </ListItemSecondaryAction>
)} </ListItem>
</CardContent> </React.Fragment>
</Card> ))}
</List>
)}
</CardContent>
</Card>
</Grid> </Grid>
{/* Document Content Viewer */} {/* Document Content Viewer */}
@ -376,10 +384,15 @@ 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
<Typography variant={isMobile ? "subtitle2" : "h6"}> sx={{
Document Content display: 'flex',
</Typography> justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Document Content</Typography>
<IconButton <IconButton
size="small" size="small"
onClick={() => { onClick={() => {
@ -391,22 +404,24 @@ const DocumentManager = (props: BackstoryElementProps) => {
<Close /> <Close />
</IconButton> </IconButton>
</Box> </Box>
<Paper <Paper
variant="outlined" variant="outlined"
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>
@ -415,46 +430,44 @@ 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> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setIsRenameDialogOpen(false)}> <Button onClick={() => setIsRenameDialogOpen(false)}>Cancel</Button>
Cancel <Button
</Button> onClick={() => editingDocument && handleRenameDocument(editingDocument, editingName)}
<Button variant="contained"
onClick={() => editingDocument && handleRenameDocument(editingDocument, editingName)} disabled={!editingName.trim()}
variant="contained" >
disabled={!editingName.trim()} Rename
> </Button>
Rename </DialogActions>
</Button> </Dialog>
</DialogActions> </Grid>
</Dialog>
</Grid>
</> </>
); );
}; };
export { DocumentManager }; export { DocumentManager };

View File

@ -18,7 +18,7 @@ import {
Checkbox, Checkbox,
FormControlLabel, FormControlLabel,
Grid, Grid,
IconButton IconButton,
} from '@mui/material'; } from '@mui/material';
import { import {
Email as EmailIcon, Email as EmailIcon,
@ -28,7 +28,7 @@ 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';
@ -36,9 +36,10 @@ import { Navigate, useNavigate } from 'react-router-dom';
// Email Verification Component // Email Verification Component
const EmailVerificationPage = (props: BackstoryPageProps) => { const EmailVerificationPage = (props: BackstoryPageProps) => {
const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } = useAuth(); const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } =
const navigate = useNavigate(); useAuth();
const [verificationToken, setVerificationToken] = useState(''); const navigate = useNavigate();
const [verificationToken, setVerificationToken] = useState('');
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending'); const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [userType, setUserType] = useState<string>(''); const [userType, setUserType] = useState<string>('');
@ -47,7 +48,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
// 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);
handleVerifyEmail(token); handleVerifyEmail(token);
@ -62,41 +63,41 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
} }
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.');
} }
}; };
@ -108,7 +109,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
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%' }}>
@ -151,18 +152,18 @@ 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>
)} )}
@ -171,11 +172,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
<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" onClick={() => navigate('/login')} fullWidth>
variant="contained"
onClick={() => navigate('/login')}
fullWidth
>
Go to Login Go to Login
</Button> </Button>
</Box> </Box>
@ -186,18 +183,14 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
<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 }}
> >
Resend Verification Email Resend Verification Email
</Button> </Button>
<Button <Button variant="contained" onClick={() => navigate('/login')} fullWidth>
variant="contained"
onClick={() => navigate('/login')}
fullWidth
>
Back to Login Back to Login
</Button> </Button>
</Box> </Box>
@ -206,46 +199,41 @@ 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 } = useAuth();
onClose,
onVerificationSuccess
} = props;
const { verifyMFA, resendMFACode, clearMFA, mfaResponse, isLoading, error } = useAuth();
const [code, setCode] = useState(''); 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;
const timer = setInterval(() => { const timer = setInterval(() => {
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;
@ -263,113 +251,116 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
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
fullWidth fullWidth
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>
@ -379,7 +370,7 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
control={ control={
<Checkbox <Checkbox
checked={rememberDevice} checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)} onChange={e => setRememberDevice(e.target.checked)}
/> />
} }
label="Remember this device for 90 days" label="Remember this device for 90 days"
@ -393,44 +384,44 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
</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.');
} }
}; };
@ -438,19 +429,19 @@ const RegistrationSuccessDialog = ({
<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
</Typography> </Typography>
<Typography variant="body1" color="text.secondary" paragraph> <Typography variant="body1" color="text.secondary" paragraph>
We've sent a verification link to: We've sent a verification link to:
</Typography> </Typography>
<Typography variant="h6" color="primary" gutterBottom> <Typography variant="h6" color="primary" gutterBottom>
{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>
@ -464,10 +455,7 @@ const RegistrationSuccessDialog = ({
</Alert> </Alert>
{resendMessage && ( {resendMessage && (
<Alert <Alert severity={resendMessage.includes('sent') ? 'success' : 'error'} sx={{ mb: 2 }}>
severity={resendMessage.includes('sent') ? 'success' : 'error'}
sx={{ mb: 2 }}
>
{resendMessage} {resendMessage}
</Alert> </Alert>
)} )}
@ -476,8 +464,8 @@ const RegistrationSuccessDialog = ({
<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,46 +475,45 @@ 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 = () => {
@ -546,7 +533,7 @@ const LoginForm = () => {
fullWidth fullWidth
label="Email or Username" label="Email or Username"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
autoComplete="email" autoComplete="email"
autoFocus autoFocus
/> />
@ -555,7 +542,7 @@ const LoginForm = () => {
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"
placeholder="Create a strong password" placeholder="Create a strong password"
required required
@ -565,7 +552,7 @@ const LoginForm = () => {
<IconButton <IconButton
aria-label="toggle password visibility" aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
onMouseDown={(e) => e.preventDefault()} onMouseDown={e => e.preventDefault()}
edge="end" edge="end"
> >
{showPassword ? <VisibilityOff /> : <Visibility />} {showPassword ? <VisibilityOff /> : <Visibility />}
@ -575,9 +562,9 @@ const LoginForm = () => {
}} }}
/> />
{errorMessage && ( {errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}> <Alert severity="error" sx={{ mt: 2 }}>
{errorMessage} {errorMessage}
</Alert> </Alert>
)} )}
@ -585,21 +572,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 = () => {
@ -619,16 +606,16 @@ const TrustedDevicesManager = () => {
<DevicesIcon sx={{ mr: 1, verticalAlign: 'middle' }} /> <DevicesIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Trusted Devices Trusted Devices
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" paragraph> <Typography variant="body2" color="text.secondary" paragraph>
Manage devices that you've marked as trusted. You won't need to verify Manage devices that you've marked as trusted. You won't need to verify your identity when
your identity when signing in from these devices. signing in from these devices.
</Typography> </Typography>
{devices.length === 0 ? ( {devices.length === 0 ? (
<Alert severity="info"> <Alert severity="info">
No trusted devices yet. When you log in from a new device and choose No trusted devices yet. When you log in from a new device and choose to remember it, it
to remember it, it will appear here. will appear here.
</Alert> </Alert>
) : ( ) : (
<Grid container spacing={2}> <Grid container spacing={2}>
@ -636,18 +623,16 @@ const TrustedDevicesManager = () => {
<Grid key={index} size={{ xs: 12, md: 6 }}> <Grid key={index} size={{ xs: 12, md: 6 }}>
<Card variant="outlined"> <Card variant="outlined">
<CardContent> <CardContent>
<Typography variant="subtitle1"> <Typography variant="subtitle1">{device.deviceName}</Typography>
{device.deviceName}
</Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
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"
color="error" color="error"
sx={{ mt: 1 }} sx={{ mt: 1 }}
onClick={() => { onClick={() => {
// Remove device // Remove device
@ -664,6 +649,12 @@ const TrustedDevicesManager = () => {
</CardContent> </CardContent>
</Card> </Card>
); );
} };
export { EmailVerificationPage, MFAVerificationDialog, TrustedDevicesManager, RegistrationSuccessDialog, LoginForm }; export {
EmailVerificationPage,
MFAVerificationDialog,
TrustedDevicesManager,
RegistrationSuccessDialog,
LoginForm,
};

View File

@ -29,6 +29,4 @@ const ExpandMore = styled((props: ExpandMoreProps) => {
], ],
})); }));
export { export { ExpandMore };
ExpandMore
};

View File

@ -8,128 +8,136 @@ import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from 'hooks/GlobalContext';
interface GenerateImageProps extends BackstoryElementProps { interface GenerateImageProps extends BackstoryElementProps {
prompt: string; prompt: string;
chatSession: ChatSession; chatSession: ChatSession;
}; }
const GenerateImage = (props: GenerateImageProps) => { const GenerateImage = (props: GenerateImageProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { chatSession, prompt } = props; const { chatSession, prompt } = props;
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [image, setImage] = useState<string>(''); const [image, setImage] = useState<string>('');
const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || ''; const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || '';
// Only keep refs that are truly necessary // Only keep refs that are truly necessary
const controllerRef = useRef<string>(null); const controllerRef = useRef<string>(null);
// 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: {
display: "flex", // prompt: prompt,
flexDirection: "column", // agentOptions: {
flexGrow: 1, // 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',
flexDirection: 'column',
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,9 +1,9 @@
import React, { useState, useRef, JSX } from 'react'; import React, { useState, useRef, JSX } from 'react';
import { import {
Box, Box,
Button, Button,
Typography, Typography,
TextField, TextField,
Grid, Grid,
useTheme, useTheme,
useMediaQuery, useMediaQuery,
@ -30,7 +30,7 @@ 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';
@ -73,20 +73,20 @@ const UploadBox = styled(Box)(({ theme }) => ({
})); }));
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] = useState<Types.JobRequirements | null>(null);
const [jobTitle, setJobTitle] = useState<string>(''); const [jobTitle, setJobTitle] = useState<string>('');
const [company, setCompany] = useState<string>(''); const [company, setCompany] = useState<string>('');
const [summary, setSummary] = 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>('');
@ -102,7 +102,7 @@ const JobCreator = (props: JobCreatorProps) => {
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 || '');
@ -115,14 +115,14 @@ const JobCreator = (props: JobCreatorProps) => {
}, },
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>) => {
@ -131,23 +131,23 @@ const JobCreator = (props: JobCreatorProps) => {
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('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return; return;
} }
try { try {
@ -175,7 +175,12 @@ 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 (
@ -189,13 +194,7 @@ const JobCreator = (props: JobCreatorProps) => {
</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) => (
<Chip <Chip key={index} label={item} variant="outlined" size="small" sx={{ mb: 1 }} />
key={index}
label={item}
variant="outlined"
size="small"
sx={{ mb: 1 }}
/>
))} ))}
</Stack> </Stack>
</Box> </Box>
@ -214,49 +213,49 @@ const JobCreator = (props: JobCreatorProps) => {
/> />
<CardContent sx={{ pt: 0 }}> <CardContent sx={{ pt: 0 }}>
{renderRequirementSection( {renderRequirementSection(
"Technical Skills (Required)", 'Technical Skills (Required)',
jobRequirements.technicalSkills.required, jobRequirements.technicalSkills.required,
<Build color="primary" />, <Build color="primary" />,
true true
)} )}
{renderRequirementSection( {renderRequirementSection(
"Technical Skills (Preferred)", 'Technical Skills (Preferred)',
jobRequirements.technicalSkills.preferred, jobRequirements.technicalSkills.preferred,
<Build color="action" /> <Build color="action" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Experience Requirements (Required)", 'Experience Requirements (Required)',
jobRequirements.experienceRequirements.required, jobRequirements.experienceRequirements.required,
<Work color="primary" />, <Work color="primary" />,
true true
)} )}
{renderRequirementSection( {renderRequirementSection(
"Experience Requirements (Preferred)", 'Experience Requirements (Preferred)',
jobRequirements.experienceRequirements.preferred, jobRequirements.experienceRequirements.preferred,
<Work color="action" /> <Work color="action" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Soft Skills", 'Soft Skills',
jobRequirements.softSkills, jobRequirements.softSkills,
<Psychology color="secondary" /> <Psychology color="secondary" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Experience", 'Experience',
jobRequirements.experience, jobRequirements.experience,
<Star color="warning" /> <Star color="warning" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Education", 'Education',
jobRequirements.education, jobRequirements.education,
<Description color="info" /> <Description color="info" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Certifications", 'Certifications',
jobRequirements.certifications, jobRequirements.certifications,
<CheckCircle color="success" /> <CheckCircle color="success" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Preferred Attributes", 'Preferred Attributes',
jobRequirements.preferredAttributes, jobRequirements.preferredAttributes,
<Star color="secondary" /> <Star color="secondary" />
)} )}
@ -306,134 +305,144 @@ const JobCreator = (props: JobCreatorProps) => {
}; };
const renderJobCreation = () => { const renderJobCreation = () => {
return ( return (
<Box sx={{ <Box
width: "100%", sx={{
p: 1 width: '100%',
}}> p: 1,
{/* Upload Section */} }}
<Card elevation={3} sx={{ mb: 4 }}> >
<CardHeader {/* Upload Section */}
title="Job Information" <Card elevation={3} sx={{ mb: 4 }}>
subheader="Upload a job description or enter details manually" <CardHeader
avatar={<Work color="primary" />} title="Job Information"
/> subheader="Upload a job description or enter details manually"
<CardContent> avatar={<Work color="primary" />}
<Grid container spacing={3}> />
<Grid size={{ xs: 12, md: 6 }}> <CardContent>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}> <Grid container spacing={3}>
<CloudUpload sx={{ mr: 1 }} /> <Grid size={{ xs: 12, md: 6 }}>
Upload Job Description <Typography
variant="h6"
gutterBottom
sx={{ display: 'flex', alignItems: 'center' }}
>
<CloudUpload sx={{ mr: 1 }} />
Upload Job Description
</Typography>
<UploadBox onClick={handleUploadClick}>
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
Drop your job description here
</Typography> </Typography>
<UploadBox onClick={handleUploadClick}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} /> Supported formats: PDF, DOCX, TXT, MD
<Typography variant="h6" gutterBottom>
Drop your job description here
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Supported formats: PDF, DOCX, TXT, MD
</Typography>
<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>
<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 <Button
fullWidth variant="contained"
multiline startIcon={<FileUploadIcon />}
rows={isMobile ? 8 : 12}
placeholder="Paste or type the job description here..."
variant="outlined"
value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
disabled={isProcessing} disabled={isProcessing}
sx={{ mb: 2 }} // onClick={handleUploadClick}
/> >
{jobRequirements === null && jobDescription && ( Choose File
<Button </Button>
variant="outlined" </UploadBox>
onClick={handleExtractRequirements} <VisuallyHiddenInput
startIcon={<AutoFixHigh />} ref={fileInputRef}
disabled={isProcessing} type="file"
fullWidth={isMobile} accept=".txt,.md,.docx,.pdf"
> onChange={handleJobUpload}
Extract Requirements />
</Button>
)}
</Grid>
</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 +455,105 @@ 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 }}>
<CardHeader
title="Job Summary"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>
{summary}
</CardContent>
</Card>
}
{/* Requirements Display */}
{renderJobRequirements()}
</Box>
);
};
return (
<Box className="JobManagement"
sx={{
background: "white",
p: 0,
width: "100%",
display: "flex", flexDirection: "column"
}}>
{job === null && renderJobCreation()}
{job &&
<Box sx={{
display: "flex", flexDirection: "column",
height: "100%", /* Restrict to main-container's height */
width: "100%",
minHeight: 0,/* Prevent flex overflow */
maxHeight: "min-content",
position: "relative",
}}>
<Box sx={{
display: "flex",
flexDirection: "row",
flexGrow: 1,
gap: 1,
height: "100%", /* Restrict to main-container's height */
width: "100%",
minHeight: 0,/* Prevent flex overflow */
maxHeight: "min-content",
"& > *:not(.Scrollable)": {
flexShrink: 0, /* Prevent shrinking */
},
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>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!jobTitle || !company || !jobDescription || isProcessing}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
Save Job
</Button>
</Box>
</Box>
}
</Box> </Box>
); );
};
return (
<Box
className="JobManagement"
sx={{
background: 'white',
p: 0,
width: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
{job === null && renderJobCreation()}
{job && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
position: 'relative',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
gap: 1,
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
'& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */,
},
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>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}>
<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,10 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Box, Box,
Typography, Typography,
Paper, Paper,
Accordion, Accordion,
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
CircularProgress, CircularProgress,
Grid, Grid,
@ -15,14 +15,26 @@ 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 {
Candidate,
ChatMessage,
ChatMessageError,
ChatMessageStatus,
ChatMessageStreaming,
ChatMessageUser,
ChatSession,
EvidenceDetail,
JobRequirements,
SkillAssessment,
SkillStatus,
} from 'types/types';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab'; import { BackstoryPageProps } from './BackstoryTab';
import { Job } from 'types/types'; import { Job } from 'types/types';
@ -37,12 +49,18 @@ import { JobInfo } from './ui/JobInfo';
interface JobAnalysisProps extends BackstoryPageProps { interface JobAnalysisProps extends BackstoryPageProps {
job: Job; job: Job;
candidate: Candidate; candidate: Candidate;
variant?: "small" | "normal"; variant?: 'small' | 'normal';
onAnalysisComplete: (skills: SkillAssessment[]) => void; onAnalysisComplete: (skills: SkillAssessment[]) => void;
} }
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 {
@ -52,16 +70,11 @@ interface SkillMatch extends SkillAssessment {
} }
const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => { const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => {
const { const { job, candidate, onAnalysisComplete, variant = 'normal' } = props;
job,
candidate,
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);
@ -77,49 +90,77 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
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,
evidenceStrength: "none", evidenceStrength: 'none',
evidenceDetails: [], evidenceDetails: [],
matchScore: 0, matchScore: 0,
})); }));
@ -129,7 +170,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);
@ -150,7 +191,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
const fetchMatchData = async (skills: SkillAssessment[]) => { const fetchMatchData = async (skills: SkillAssessment[]) => {
if (requirements.length === 0) return; if (requirements.length === 0) return;
// 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 {
@ -160,19 +201,35 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
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 = {
@ -191,7 +248,9 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
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;
@ -203,21 +262,21 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
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;
}); });
} }
} }
}; };
setAnalyzing(true); setAnalyzing(true);
const skills: SkillAssessment[] = []; const skills: SkillAssessment[] = [];
fetchMatchData(skills).then(() => { fetchMatchData(skills).then(() => {
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
@ -243,30 +302,47 @@ 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={{
@ -285,22 +361,32 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
</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
{analyzing ? "Assessment in Progress" : "Start Skill Assessment"} sx={{ marginLeft: 'auto' }}
disabled={analyzing || startAnalysis}
onClick={beginAnalysis}
variant="contained"
>
{analyzing ? 'Assessment in Progress' : 'Start Skill Assessment'}
</Button> </Button>
</Box> </Box>
@ -316,18 +402,19 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
<Typography variant="h5" component="h2" gutterBottom> <Typography variant="h5" component="h2" gutterBottom>
Requirements Analysis Requirements Analysis
</Typography> </Typography>
{skillMatches.map((match, index) => ( {skillMatches.map((match, index) => (
<Accordion <Accordion
key={index} key={index}
expanded={expanded === `panel${index}`} expanded={expanded === `panel${index}`}
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,61 +422,91 @@ 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 }}>
@ -400,42 +517,53 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
</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 }}>
{match.assessment} {match.assessment}
</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.length > 0 ? (
match.evidenceDetails.map((evidence, evndex) => ( match.evidenceDetails.map((evidence, evndex) => (
<Card <Card
key={evndex} key={evndex}
variant="outlined" variant="outlined"
sx={{ sx={{
mb: 2, mb: 2,
borderLeft: '4px solid', borderLeft: '4px solid',
borderColor: theme.palette.primary.main, borderColor: theme.palette.primary.main,
}} }}
> >
<CardContent> <CardContent>
<Typography variant="body1" component="div" sx={{ mb: 1, fontStyle: 'italic' }}> <Typography
"{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
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexDirection: 'column',
}}
>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Relevance: {evidence.context} Relevance: {evidence.context}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Source: {evidence.source} Source: {evidence.source}
</Typography> </Typography>
{/* <Chip {/* <Chip
size="small" size="small"
@ -453,20 +581,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>
@ -478,4 +603,4 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
); );
}; };
export { JobMatchAnalysis }; export { JobMatchAnalysis };

View File

@ -45,7 +45,7 @@ const LoadingComponent: React.FC<LoadingComponentProps> = ({
</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}
@ -65,4 +65,4 @@ const LoadingComponent: React.FC<LoadingComponentProps> = ({
); );
}; };
export { LoadingComponent}; export { LoadingComponent };

View File

@ -7,7 +7,7 @@ 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';
@ -32,11 +32,11 @@ 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
); );
@ -46,11 +46,12 @@ const LocationInput: React.FC<LocationInputProps> = ({
// 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(() => {
@ -70,28 +71,38 @@ const LocationInput: React.FC<LocationInputProps> = ({
// Update parent component when values change // Update parent component when values change
useEffect(() => { useEffect(() => {
const newLocation: Partial<Location> = {}; const newLocation: Partial<Location> = {};
if (selectedCountry) { if (selectedCountry) {
newLocation.country = selectedCountry.name; newLocation.country = selectedCountry.name;
} }
if (selectedState) { if (selectedState) {
newLocation.state = selectedState.name; newLocation.state = selectedState.name;
} }
if (selectedCity && showCity) { if (selectedCity && showCity) {
newLocation.city = selectedCity.name; newLocation.city = selectedCity.name;
} }
if (isRemote) { if (isRemote) {
newLocation.remote = isRemote; newLocation.remote = isRemote;
} }
// 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);
@ -120,7 +131,7 @@ const LocationInput: React.FC<LocationInputProps> = ({
<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}>
{/* Country Selection */} {/* Country Selection */}
<Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}> <Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}>
@ -128,19 +139,21 @@ const LocationInput: React.FC<LocationInputProps> = ({
value={selectedCountry} value={selectedCountry}
onChange={handleCountryChange} onChange={handleCountryChange}
options={allCountries} options={allCountries}
getOptionLabel={(option) => option.name} getOptionLabel={option => option.name}
disabled={disabled} disabled={disabled}
renderInput={(params) => ( renderInput={params => (
<TextField <TextField
{...params} {...params}
label="Country" label="Country"
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' }} />,
}} }}
/> />
)} )}
@ -167,14 +180,16 @@ const LocationInput: React.FC<LocationInputProps> = ({
value={selectedState} value={selectedState}
onChange={handleStateChange} onChange={handleStateChange}
options={availableStates} options={availableStates}
getOptionLabel={(option) => option.name} getOptionLabel={option => option.name}
disabled={disabled || availableStates.length === 0} disabled={disabled || availableStates.length === 0}
renderInput={(params) => ( renderInput={params => (
<TextField <TextField
{...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'
}
/> />
)} )}
/> />
@ -188,17 +203,17 @@ const LocationInput: React.FC<LocationInputProps> = ({
value={selectedCity} value={selectedCity}
onChange={handleCityChange} onChange={handleCityChange}
options={availableCities} options={availableCities}
getOptionLabel={(option) => option.name} getOptionLabel={option => option.name}
disabled={disabled || availableCities.length === 0} disabled={disabled || availableCities.length === 0}
renderInput={(params) => ( renderInput={params => (
<TextField <TextField
{...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' }} />,
}} }}
/> />
)} )}
@ -251,14 +266,7 @@ const LocationInput: React.FC<LocationInputProps> = ({
size="small" size="small"
/> />
)} )}
{isRemote && ( {isRemote && <Chip label="Remote" variant="filled" color="success" size="small" />}
<Chip
label="Remote"
variant="filled"
color="success"
size="small"
/>
)}
</Box> </Box>
</Grid> </Grid>
)} )}
@ -287,22 +295,18 @@ const LocationInputDemo: React.FC = () => {
<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}>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<Typography variant="h5" gutterBottom> <Typography variant="h5" gutterBottom>
Basic Location Input Basic Location Input
</Typography> </Typography>
<LocationInput <LocationInput value={location} onChange={handleLocationChange} required />
value={location}
onChange={handleLocationChange}
required
/>
</Grid> </Grid>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
@ -310,7 +314,7 @@ const LocationInputDemo: React.FC = () => {
control={ control={
<Checkbox <Checkbox
checked={showAdvanced} checked={showAdvanced}
onChange={(e) => setShowAdvanced(e.target.checked)} onChange={e => setShowAdvanced(e.target.checked)}
color="primary" color="primary"
/> />
} }
@ -336,21 +340,24 @@ 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 regularly updated and
and includes ISO codes, flags, and comprehensive location data. includes ISO codes, flags, and comprehensive location data.
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>
@ -358,4 +365,4 @@ const LocationInputDemo: React.FC = () => {
); );
}; };
export { LocationInput }; export { LocationInput };

View File

@ -4,7 +4,7 @@ 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',
@ -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(() => {
@ -38,25 +38,29 @@ const Mermaid: React.FC<MermaidProps> = (props: MermaidProps) => {
await mermaid.initialize(mermaidConfig || defaultMermaidConfig); await mermaid.initialize(mermaidConfig || defaultMermaidConfig);
await mermaid.run({ nodes: [containerRef.current] }); await mermaid.run({ nodes: [containerRef.current] });
} catch (e) { } catch (e) {
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

@ -23,7 +23,7 @@ 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';
@ -32,9 +32,19 @@ 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 = (theme: Theme, type: ApiActivityType | ChatSenderType | 'error'): any => {
const defaultRadius = '16px'; const defaultRadius = '16px';
const defaultStyle = { const defaultStyle = {
padding: theme.spacing(1, 2), padding: theme.spacing(1, 2),
@ -163,9 +173,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 +189,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 +219,255 @@ 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 component={Card} className="PromptStats" sx={{ mb: 1 }}>
<TableHead> <Table aria-label="prompt stats" size="small">
<TableRow> <TableHead>
<TableCell></TableCell> <TableRow>
<TableCell align="right" >Tokens</TableCell> <TableCell></TableCell>
<TableCell align="right">Time (s)</TableCell> <TableCell align="right">Tokens</TableCell>
<TableCell align="right">TPS</TableCell> <TableCell align="right">Time (s)</TableCell>
</TableRow> <TableCell align="right">TPS</TableCell>
</TableHead> </TableRow>
<TableBody> </TableHead>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> <TableBody>
<TableCell component="th" scope="row">Prompt</TableCell> <TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell align="right">{promptEvalCount}</TableCell> <TableCell component="th" scope="row">
<TableCell align="right">{Math.round(promptEvalDuration / 10 ** 7) / 100}</TableCell> Prompt
<TableCell align="right">{Math.round(promptEvalCount * 10 ** 9 / promptEvalDuration)}</TableCell> </TableCell>
</TableRow> <TableCell align="right">{promptEvalCount}</TableCell>
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> <TableCell align="right">
<TableCell component="th" scope="row">Response</TableCell> {Math.round(promptEvalDuration / 10 ** 7) / 100}
<TableCell align="right">{evalCount}</TableCell> </TableCell>
<TableCell align="right">{Math.round(evalDuration / 10 ** 7) / 100}</TableCell> <TableCell align="right">
<TableCell align="right">{Math.round(evalCount * 10 ** 9 / evalDuration)}</TableCell> {Math.round((promptEvalCount * 10 ** 9) / promptEvalDuration)}
</TableRow> </TableCell>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> </TableRow>
<TableCell component="th" scope="row">Total</TableCell> <TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell align="right">{promptEvalCount + evalCount}</TableCell> <TableCell component="th" scope="row">
<TableCell align="right">{Math.round((promptEvalDuration + evalDuration) / 10 ** 7) / 100}</TableCell> Response
<TableCell align="right">{Math.round((promptEvalCount + evalCount) * 10 ** 9 / (promptEvalDuration + evalDuration))}</TableCell> </TableCell>
</TableRow> <TableCell align="right">{evalCount}</TableCell>
</TableBody> <TableCell align="right">{Math.round(evalDuration / 10 ** 7) / 100}</TableCell>
</Table> <TableCell align="right">
</TableContainer> {Math.round((evalCount * 10 ** 9) / evalDuration)}
</> </TableCell>
} </TableRow>
{ <TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
tools && tools.tool_calls && tools.tool_calls.length !== 0 && <TableCell component="th" scope="row">
<Accordion sx={{ boxSizing: "border-box" }}> Total
<AccordionSummary expandIcon={<ExpandMoreIcon />}> </TableCell>
<Box sx={{ fontSize: "0.8rem" }}> <TableCell align="right">{promptEvalCount + evalCount}</TableCell>
Tools queried <TableCell align="right">
</Box> {Math.round((promptEvalDuration + evalDuration) / 10 ** 7) / 100}
</AccordionSummary> </TableCell>
<AccordionDetails> <TableCell align="right">
{ {Math.round(
tools.tool_calls.map((tool: any, index: number) => ((promptEvalCount + evalCount) * 10 ** 9) /
<Box key={index} sx={{ m: 0, p: 1, pt: 0, display: "flex", flexDirection: "column", border: "1px solid #e0e0e0" }}> (promptEvalDuration + evalDuration)
{index !== 0 && <Divider />} )}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "column", mt: 1, mb: 1, fontWeight: "bold" }}> </TableCell>
{tool.name} </TableRow>
</Box> </TableBody>
{tool.content !== "null" && </Table>
<JsonView </TableContainer>
displayDataTypes={false} </>
objectSortKeys={true} )}
collapsed={1} value={JSON.parse(tool.content)} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}> {tools && tools.tool_calls && tools.tool_calls.length !== 0 && (
<JsonView.String <Accordion sx={{ boxSizing: 'border-box' }}>
render={({ children, ...reset }) => { <AccordionSummary expandIcon={<ExpandMoreIcon />}>
if (typeof (children) === "string" && children.match("\n")) { <Box sx={{ fontSize: '0.8rem' }}>Tools queried</Box>
return <pre {...reset} style={{ display: "flex", border: "none", ...reset.style }}>{children}</pre> </AccordionSummary>
} <AccordionDetails>
}} {tools.tool_calls.map((tool: any, index: number) => (
/> <Box
</JsonView> key={index}
} sx={{
{tool.content === "null" && "No response from tool call"} m: 0,
</Box>) p: 1,
} pt: 0,
</AccordionDetails> display: 'flex',
</Accordion> flexDirection: 'column',
} border: '1px solid #e0e0e0',
{ }}
ragResults.map((collection: ChromaDBGetResponse) => ( >
{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 inline {...props.messageProps} {...props.metadata} 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 +475,48 @@ 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(`message content is not a string, it is a ${typeof message.content}`);
return (<></>) 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 =
'metadata' in message ? (message.metadata as ChatMessageMetaData) || null : null;
if ('role' in message && message.role === 'user') { 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
<Box sx={{ display: "flex", flexGrow: 1 }} /> sx={{
<Button variant="text" onClick={handleMetaExpandClick} sx={{ flexShrink: 1, color: "darkgrey", p: 0 }}> display: 'flex',
alignItems: 'center',
gap: 1,
flexDirection: 'row',
}}
>
<Box sx={{ display: 'flex', flexGrow: 1 }} />
<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 +524,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,16 +534,26 @@ 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
@ -431,8 +563,11 @@ const Message = (props: MessageProps) => {
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={{
@ -440,27 +575,27 @@ const Message = (props: MessageProps) => {
sx: { sx: {
display: 'flex', display: 'flex',
justifyItems: 'center', justifyItems: 'center',
m: 0, p: 0, m: 0,
p: 0,
fontWeight: 'bold', fontWeight: 'bold',
fontSize: '1.1rem', 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

@ -17,7 +17,7 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
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
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsAnimating(false); setIsAnimating(false);
@ -37,8 +37,8 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
}; };
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',
@ -135,8 +135,8 @@ 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,35 +144,21 @@ 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 key={`ripple-outer-${animationKey}`} style={outerRippleStyle} />
key={`ripple-outer-${animationKey}`}
style={outerRippleStyle}
/>
</> </>
)} )}
</Box> </Box>
</> </>
); );
}; };
export { Pulse } ; export { Pulse };

View File

@ -7,7 +7,7 @@ interface QuoteContainerProps {
} }
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),
@ -29,7 +29,7 @@ const QuoteContainer = styled(Paper, {
})); }));
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,
@ -43,7 +43,7 @@ const QuoteText = styled(Typography, {
})); }));
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',
@ -67,7 +67,7 @@ const ClosingQuote = styled(QuoteMark)(({ size = 'normal' }: QuoteContainerProps
})); }));
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',
@ -82,7 +82,7 @@ const AuthorText = styled(Typography, {
})); }));
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',
@ -104,14 +104,14 @@ const Quote = (props: QuoteProps) => {
<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>
<AccentLine size={size} /> <AccentLine size={size} />
{author && ( {author && (
<AuthorText size={size} variant="body2"> <AuthorText size={size} variant="body2">
{author} {author}
@ -122,4 +122,4 @@ const Quote = (props: QuoteProps) => {
); );
}; };
export { Quote }; export { Quote };

View File

@ -1,14 +1,6 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { import { Tabs, Tab, Box, Button, Paper, Typography, LinearProgress } from '@mui/material';
Tabs, import { Job, Candidate, SkillAssessment } from 'types/types';
Tab,
Box,
Button,
Paper,
Typography,
LinearProgress,
} from '@mui/material';
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';
@ -31,7 +23,12 @@ 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) => {
@ -49,7 +46,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
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) {
@ -58,8 +55,8 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
setGenerated(true); setGenerated(true);
setStatusType("thinking"); setStatusType('thinking');
setStatus("Starting resume generation..."); setStatus('Starting resume generation...');
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = { const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
onMessage: (message: Types.ChatMessageResume) => { onMessage: (message: Types.ChatMessageResume) => {
@ -71,7 +68,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
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);
}, },
@ -92,13 +89,17 @@ 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!');
@ -122,45 +123,61 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
}, [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 disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" />
</Tabs> <Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" />
</Box>} <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 autoscroll sx={{ display: 'flex', flexGrow: 1, position: 'relative' }}>
{tabValue === 'prompt' && <pre>{prompt}</pre>} {tabValue === 'system' && <pre>{systemPrompt}</pre>}
{tabValue === 'resume' && <><CopyBubble onClick={() => { setSnack('Resume copied to clipboard!'); }} sx={{ position: "absolute", top: 0, right: 0 }} content={resume} /><StyledMarkdown content={resume} /></>} {tabValue === 'prompt' && <pre>{prompt}</pre>}
</Scrollable></Paper> {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 onClick={handleSave} variant="contained" color="primary" sx={{ mt: 2 }}>
</Button>} Save Resume
</Button>
)}
</Box> </Box>
) );
};
export {
ResumeGenerator
}; };
export { ResumeGenerator };

View File

@ -14,14 +14,27 @@ 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',
@ -40,4 +53,4 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => {
); );
}); });
export { useAutoScrollToBottom, Scrollable }; export { useAutoScrollToBottom, Scrollable };

View File

@ -10,40 +10,37 @@ 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, sx }: SnackProps, ref) => {
className,
sx
}: SnackProps, ref) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState('');
const [severity, setSeverity] = useState<SeverityType>("success"); const [severity, setSeverity] = useState<SeverityType>('success');
// Set the snack pop-up and open it // Set the snack pop-up and open it
const setSnack: SetSnackType = useCallback<SetSnackType>((message: string, severity: SeverityType = "success") => { const setSnack: SetSnackType = useCallback<SetSnackType>(
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, reason?: SnackbarCloseReason) => {
event: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
if (reason === 'clickaway') { if (reason === 'clickaway') {
return; return;
} }
@ -53,28 +50,19 @@ const Snack = forwardRef<SnackHandle, SnackProps>(({
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={severity === 'success' || severity === 'info' ? 1500 : 6000}
onClose={handleSnackClose}> onClose={handleSnackClose}
<Alert >
onClose={handleSnackClose} <Alert onClose={handleSnackClose} severity={severity} variant="filled" sx={{ width: '100%' }}>
severity={severity}
variant="filled"
sx={{ width: '100%' }}
>
{message} {message}
</Alert> </Alert>
</Snackbar> </Snackbar>
) );
}); });
export type { export type { SeverityType, SetSnackType };
SeverityType,
SetSnackType
};
export { export { Snack };
Snack
};

View File

@ -17,66 +17,90 @@ 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;
const content = element.children?.props?.children || ""; const content = element.children?.props?.children || '';
if (className === "lang-mermaid" && !streaming) { if (className === 'lang-mermaid' && !streaming) {
return <Mermaid className="Mermaid" chart={content} />; return <Mermaid className="Mermaid" chart={content} />;
} }
if (className === "lang-markdown") { if (className === 'lang-markdown') {
return <MuiMarkdown children={content} />; return <MuiMarkdown children={content} />;
} }
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: {
@ -84,7 +108,7 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
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();
@ -93,15 +117,15 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
} }
}, },
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 +133,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 +154,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

@ -28,7 +28,7 @@ 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;
@ -43,10 +43,10 @@ const emptyQuerySet: Types.ChromaDBGetResponse = {
metadatas: [], metadatas: [],
embeddings: [], embeddings: [],
distances: [], distances: [],
name: "Empty", name: 'Empty',
size: 0, size: 0,
dimensions: 2, dimensions: 2,
query: "" query: '',
}; };
interface PlotData { interface PlotData {
@ -105,16 +105,14 @@ 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
@ -132,9 +130,9 @@ const layout: Partial<Plotly.Layout> = {
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[] => {
@ -155,31 +153,31 @@ const emojiMap: Record<string, string> = {
}; };
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) => {
@ -199,7 +197,8 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
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) */
@ -215,12 +214,18 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
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();
}); });
@ -238,12 +243,12 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
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,13 +256,13 @@ 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;
} }
@ -267,7 +272,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
return; return;
} }
let query: Types.ChromaDBGetResponse = { const query: Types.ChromaDBGetResponse = {
ids: [], ids: [],
documents: [], documents: [],
embeddings: [], embeddings: [],
@ -276,9 +281,9 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
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: [],
@ -287,13 +292,13 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
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 +306,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]);
@ -318,63 +325,84 @@ 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 (!view2D && querySet.umapEmbedding3D && querySet.umapEmbedding3D.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.umapEmbedding3D); query.embeddings.unshift(querySet.umapEmbedding3D);
} }
const filtered_docTypes = filtered.metadatas.map(m => m.docType || 'unknown') const filtered_docTypes = filtered.metadatas.map(m => m.docType || 'unknown');
const query_docTypes = query.metadatas.map(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 =>
has_query ? DEFAULT_UNFOCUS_SIZE : DEFAULT_SIZE
);
const filtered_colors = filtered_docTypes.map(type => colorMap[type] || '#4d4d4d'); const filtered_colors = filtered_docTypes.map(type => colorMap[type] || '#4d4d4d');
const filtered_x = normalizeDimension(filtered.embeddings.map((v: number[]) => v[0])); const filtered_x = normalizeDimension(filtered.embeddings.map((v: number[]) => v[0]));
const filtered_y = normalizeDimension(filtered.embeddings.map((v: number[]) => v[1])); 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 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(
m => DEFAULT_SIZE + 2 * DEFAULT_SIZE * Math.pow(1 - (m.distance || 1), 3)
);
const query_colors = query_docTypes.map(type => colorMap[type] || '#4d4d4d'); const query_colors = query_docTypes.map(type => colorMap[type] || '#4d4d4d');
const query_x = normalizeDimension(query.embeddings.map((v: number[]) => v[0])); const query_x = normalizeDimension(query.embeddings.map((v: number[]) => v[0]));
const query_y = normalizeDimension(query.embeddings.map((v: number[]) => v[1])); 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_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,
type: is3D ? 'scatter3d' : 'scatter',
hovertemplate: '&nbsp;',
}, {
name: 'Query', name: 'Query',
x: query_x, x: query_x,
y: query_y, y: query_y,
mode: 'markers', mode: 'markers',
marker: { marker: {
size: query_sizes, size: query_sizes,
symbol: 'circle', symbol: 'circle',
color: query_colors, color: query_colors,
opacity: 1 opacity: 1,
}, },
text: query.ids, text: query.ids,
customdata: query.metadatas, customdata: query.metadatas,
type: is3D ? 'scatter3d' : 'scatter', type: is3D ? 'scatter3d' : 'scatter',
hovertemplate: '%{text}', hovertemplate: '%{text}',
}]; },
];
if (is3D) { if (is3D) {
data[0].z = filtered_z; data[0].z = filtered_z;
@ -382,7 +410,6 @@ 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) => {
@ -400,36 +427,55 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
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) => {
@ -440,21 +486,23 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
...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;
} }
@ -463,7 +511,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
content: `Loading...`, content: `Loading...`,
...metadata, ...metadata,
emoji: emojiMap[metadata.docType] || '❓', emoji: emojiMap[metadata.docType] || '❓',
} };
setNode(node); setNode(node);
@ -471,95 +519,173 @@ 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',
flexDirection: isMobile ? 'column' : 'row',
mt: 0.5,
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 }}> <TableContainer component={Paper} sx={{ mb: isMobile ? 1 : 0, mr: isMobile ? 0 : 1 }}>
<Table size="small" sx={{ tableLayout: 'fixed' }}> <Table size="small" sx={{ tableLayout: 'fixed' }}>
<TableBody sx={{ '& td': { verticalAlign: "top", fontSize: "0.75rem", }, '& td:first-of-type': { whiteSpace: "nowrap", width: "1rem" } }}> <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.source_file.replace(/^.*\//, '')}</TableCell>
{node.path !== undefined && <TableRow> </TableRow>
<TableCell>Section</TableCell> )}
<TableCell>{node.path}</TableCell> {node.path !== undefined && (
</TableRow>} <TableRow>
{node.distance !== undefined && <TableRow> <TableCell>Section</TableCell>
<TableCell>Distance</TableCell> <TableCell>{node.path}</TableCell>
<TableCell>{node.distance}</TableCell> </TableRow>
</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={{
@ -569,51 +695,104 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
p: 0.5, p: 0.5,
pl: 1, pl: 1,
flexShrink: 1, flexShrink: 1,
position: "relative", position: 'relative',
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 ? '#f0f0f0' : 'auto';
return <Box key={index} sx={{ display: "flex", flexDirection: "row", borderBottom: '1px solid #d0d0d0', ':first-of-type': { borderTop: '1px solid #d0d0d0' }, backgroundColor: bgColor }}> return (
<Box sx={{ fontFamily: 'courier', fontSize: "0.8rem", minWidth: "2rem", pt: "0.1rem", align: "left", verticalAlign: "top" }}>{index}</Box> <Box
<pre style={{ margin: 0, padding: 0, border: "none", minHeight: "1rem", overflow: "hidden" }} >{line || " "}</pre> key={index}
</Box>; sx={{
}) display: 'flex',
} flexDirection: 'row',
{!node.lineBegin && <pre style={{ margin: 0, padding: 0, border: "none" }}>{node.content}</pre>} 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
sx={{
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.query !== undefined && querySet.query !== '' && `Query: ${querySet.query}`}
{querySet.ids.length === 0 && "Enter query below to perform a similarity search."} {querySet.ids.length === 0 && 'Enter query below to perform a similarity search.'}
</Paper> </Paper>
} )}
{ {!inline && (
!inline && <Box className="Query" sx={{ display: 'flex', flexDirection: 'row', p: 1 }}>
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}>
<TextField <TextField
variant="outlined" variant="outlined"
fullWidth fullWidth
type="text" type="text"
value={newQuery} value={newQuery}
onChange={(e) => setNewQuery(e.target.value)} onChange={e => setNewQuery(e.target.value)}
onKeyDown={handleKeyPress} onKeyDown={handleKeyPress}
placeholder="Enter query to find related documents..." placeholder="Enter query to find related documents..."
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 +800,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,8 +1,8 @@
// 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';
@ -10,13 +10,10 @@ 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,
getAllRoutes,
} from 'config/navigationConfig';
import { NavigationItem } from 'types/navigation'; import { NavigationItem } from 'types/navigation';
// Legacy type for backward compatibility // Legacy type for backward compatibility
@ -37,35 +34,39 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps) => {
<Container <Container
className="BackstoryPageContainer" className="BackstoryPageContainer"
sx={{ sx={{
display: "flex", display: 'flex',
flexDirection: "row", flexDirection: 'row',
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={{
display: "flex", display: 'flex',
flexGrow: 1, flexGrow: 1,
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>
@ -79,7 +80,7 @@ interface BackstoryLayoutProps {
} }
const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutProps) => { const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutProps) => {
const { page, chatRef, } = props; const { page, chatRef } = props;
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -99,61 +100,59 @@ 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.component as ReactElement, {
...(route.id === 'chat' && { ref: chatRef }), ...(route.id === 'chat' && { ref: chatRef }),
...(route.component.props || {}), ...(route.component.props || {}),
}); });
return ( return (
<Route <Route key={`${route.id}-${index}`} path={route.path} element={componentWithProps} />
key={`${route.id}-${index}`} );
path={route.path} })
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 }}
currentPath={page} >
navigate={navigate} <Header currentPath={page} navigate={navigate} navigationItems={navigationItems} />
navigationItems={navigationItems} <Box
/> sx={{
<Box sx={{ display: 'flex',
display: "flex", width: '100%',
width: "100%", maxHeight: '100%',
maxHeight: "100%", minHeight: '100%',
minHeight: "100%", flex: 1,
flex: 1, m: 0,
m: 0, p: 0,
p: 0, flexDirection: 'column',
flexDirection: "column", backgroundColor: '#D3CDBF' /* Warm Gray */,
backgroundColor: "#D3CDBF", /* Warm Gray */ }}
}}> >
<Scrollable <Scrollable
className="BackstoryPageScrollable" className="BackstoryPageScrollable"
sx={{ sx={{
m: 0, m: 0,
p: 0, p: 0,
mt: "72px", /* Needs to be kept in sync with the height of Header */ mt: '72px' /* Needs to be kept in sync with the height of Header */,
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
backgroundColor: (theme) => darken(theme.palette.background.default, 0.4), backgroundColor: theme => darken(theme.palette.background.default, 0.4),
height: "100%", height: '100%',
maxHeight: "100%", maxHeight: '100%',
minHeight: "100%", minHeight: '100%',
minWidth: "min-content", minWidth: 'min-content',
}} }}
> >
<BackstoryPageContainer> <BackstoryPageContainer>
@ -170,12 +169,10 @@ 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 />}
</BackstoryPageContainer> </BackstoryPageContainer>
</Scrollable> </Scrollable>
</Box> </Box>
@ -183,6 +180,4 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
); );
}; };
export { export { BackstoryLayout };
BackstoryLayout
};

View File

@ -1,13 +1,13 @@
// components/layout/BackstoryRoutes.tsx // components/layout/BackstoryRoutes.tsx
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 {
chatRef: Ref<ConversationHandle>; chatRef: Ref<ConversationHandle>;
@ -22,25 +22,21 @@ 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(route.component as React.ReactElement, {
// Special handling for chat component ref // Special handling for chat component ref
...(route.id === 'chat' && { ref: chatRef }), ...(route.id === 'chat' && { ref: chatRef }),
// Preserve any existing props // Preserve any existing props
...(route.component.props || {}), ...(route.component.props || {}),
}); });
return ( return <Route key={`${route.id}-${index}`} path={route.path} element={componentWithProps} />;
<Route })
key={`${route.id}-${index}`} .filter(Boolean);
path={route.path}
element={componentWithProps}
/>
);
}).filter(Boolean);
}; };
export { getBackstoryDynamicRoutes }; export { getBackstoryDynamicRoutes };

View File

@ -62,7 +62,7 @@ const Footer = () => {
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 */}
@ -79,8 +79,9 @@ const Footer = () => {
> >
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
@ -122,9 +123,11 @@ const Footer = () => {
'&: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,46 +166,46 @@ 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">For Candidates</FooterHeading>
</FooterHeading> <FooterLink href="/create-profile">Create Profile</FooterLink>
<FooterLink href="/create-profile">Create Profile</FooterLink> <FooterLink href="/backstory-editor">Backstory Editor</FooterLink>
<FooterLink href="/backstory-editor">Backstory Editor</FooterLink> <FooterLink href="/resume-builder">Resume Builder</FooterLink>
<FooterLink href="/resume-builder">Resume Builder</FooterLink> <FooterLink href="/career-resources">Career Resources</FooterLink>
<FooterLink href="/career-resources">Career Resources</FooterLink> <FooterLink href="/interview-tips">Interview Tips</FooterLink>
<FooterLink href="/interview-tips">Interview Tips</FooterLink> </Grid>
</Grid> </>
</>} )}
{/* Quick Links */} {/* Quick Links */}
{false && <> {false && (
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <>
<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">Search Candidates</FooterLink>
<FooterLink href="/search-candidates">Search Candidates</FooterLink> <FooterLink href="/company-profile">Company Profile</FooterLink>
<FooterLink href="/company-profile">Company Profile</FooterLink> <FooterLink href="/recruiting-tools">Recruiting Tools</FooterLink>
<FooterLink href="/recruiting-tools">Recruiting Tools</FooterLink> <FooterLink href="/pricing-plans">Pricing Plans</FooterLink>
<FooterLink href="/pricing-plans">Pricing Plans</FooterLink> </Grid>
</Grid> </>
</>} )}
{/* Contact */} {/* Contact */}
{false && <> {false && (
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <>
<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>
@ -215,7 +218,7 @@ const Footer = () => {
</ContactItem> */} </ContactItem> */}
<ContactItem> <ContactItem>
<LocationOn sx={{ mr: 1, fontSize: 20 }} /> <LocationOn sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="body2" sx={{ color: "white" }}> <Typography variant="body2" sx={{ color: 'white' }}>
Beaverton, OR 97003 Beaverton, OR 97003
</Typography> </Typography>
</ContactItem> </ContactItem>
@ -228,25 +231,35 @@ const Footer = () => {
<Grid container spacing={2} alignItems="center"> <Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Copyright sx={{ fontSize: 16, mr: 1, color: "white" }} /> <Copyright sx={{ fontSize: 16, mr: 1, color: 'white' }} />
<Typography variant="body2" sx={{ color: "white" }}> <Typography variant="body2" sx={{ color: 'white' }}>
{currentYear} James P. Ketrenos. All rights reserved. {currentYear} James P. Ketrenos. All rights reserved.
</Typography> </Typography>
</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 +267,4 @@ const Footer = () => {
); );
}; };
export { export { Footer };
Footer
};

View File

@ -53,7 +53,7 @@ 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' : '',
@ -126,24 +126,22 @@ interface HeaderProps {
const Header: React.FC<HeaderProps> = (props: HeaderProps) => { const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const { const { transparent = false, className, navigate, navigationItems, sessionId } = props;
transparent = false,
className,
navigate,
navigationItems,
sessionId,
} = props;
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);
@ -172,7 +170,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
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',
}); });
} }
}); });
@ -183,8 +181,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: 'divider', id: 'divider',
label: '', label: '',
icon: null, icon: null,
action: () => { }, action: () => {},
group: 'divider' group: 'divider',
}); });
} }
@ -196,7 +194,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
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',
}); });
} }
}); });
@ -207,8 +205,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: 'divider', id: 'divider',
label: '', label: '',
icon: null, icon: null,
action: () => { }, action: () => {},
group: 'divider' group: 'divider',
}); });
} }
@ -220,7 +218,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
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',
}); });
} }
}); });
@ -231,8 +229,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: 'divider', id: 'divider',
label: '', label: '',
icon: null, icon: null,
action: () => { }, action: () => {},
group: 'divider' group: 'divider',
}); });
} }
@ -247,7 +245,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
logout(); logout();
navigate('/'); navigate('/');
}, },
group: 'system' group: 'system',
}); });
} else if (!item.divider) { } else if (!item.divider) {
items.push({ items.push({
@ -255,7 +253,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
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',
}); });
} }
}); });
@ -268,7 +266,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
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',
}); });
} }
}); });
@ -319,7 +317,13 @@ 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: {
id: string;
label: string;
icon: React.ReactElement | null;
action: () => void;
group?: string;
}) => {
if (item.group !== 'divider') { if (item.group !== 'divider') {
item.action(); item.action();
handleUserMenuClose(); handleUserMenuClose();
@ -337,18 +341,28 @@ 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 ? 'action.selected' : 'transparent',
@ -372,7 +386,12 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
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>
@ -389,7 +408,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
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>}
@ -404,7 +423,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];
@ -432,24 +451,18 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
}, },
}} }}
> >
{item.icon && ( {item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>}
<ListItemIcon sx={{ minWidth: 36 }}>
{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 && (
<IconButton size="small"> <IconButton size="small">{isExpanded ? <ExpandLess /> : <ExpandMore />}</IconButton>
{isExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
)} )}
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
@ -466,7 +479,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
return ( return (
<List sx={{ pt: 0 }}> <List sx={{ pt: 0 }}>
{navigationItems.map((item) => renderNavigationItem(item))} {navigationItems.map(item => renderNavigationItem(item))}
<Divider sx={{ my: 1 }} /> <Divider sx={{ my: 1 }} />
{!user && ( {!user && (
<ListItem disablePadding> <ListItem disablePadding>
@ -484,18 +497,18 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
return ( return (
<UserMenuContainer> <UserMenuContainer>
<List dense> <List dense>
{userMenuItems.map((item, index) => ( {userMenuItems.map((item, index) =>
item.group === 'divider' ? ( item.group === 'divider' ? (
<Divider key={`divider-${index}`} /> <Divider key={`divider-${index}`} />
) : ( ) : (
<ListItem key={item.id}> <ListItem key={item.id}>
<ListItemButton onClick={() => handleUserMenuAction(item)}> <ListItemButton onClick={() => handleUserMenuAction(item)}>
{item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>} {item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>}
<ListItemText primary={item.label} /> <ListItemText primary={item.label} />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
) )
))} )}
</List> </List>
</UserMenuContainer> </UserMenuContainer>
); );
@ -508,7 +521,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
<Button <Button
color="info" color="info"
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,
@ -527,16 +540,16 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
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>
@ -566,14 +579,12 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
position="fixed" position="fixed"
transparent={transparent} transparent={transparent}
className={className} className={className}
sx={{ overflow: "hidden" }} sx={{ overflow: 'hidden' }}
> >
<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>
@ -608,7 +619,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
content={`${window.location.origin}${window.location.pathname}?id=${sessionId}`} content={`${window.location.origin}${window.location.pathname}?id=${sessionId}`}
onClick={() => { onClick={() => {
navigate(`${window.location.pathname}?id=${sessionId}`); navigate(`${window.location.pathname}?id=${sessionId}`);
setSnack("Link copied!"); setSnack('Link copied!');
}} }}
size="large" size="large"
/> />
@ -630,11 +641,13 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
</Toolbar> </Toolbar>
</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>
); );
}; };
export { Header }; export { Header };

View File

@ -6,28 +6,23 @@ 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,
Avatar,
} from '@mui/material';
import { useTheme } from '@mui/material/styles'; 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

@ -6,47 +6,51 @@ 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,15 +1,10 @@
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 { Box, Link, Typography, Avatar, Grid, SxProps, Tooltip, IconButton } from '@mui/material';
import { import { Card, CardContent, Divider, useTheme } from '@mui/material';
Card,
CardContent,
Divider,
useTheme,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import { useMediaQuery } from '@mui/material'; import { useMediaQuery } from '@mui/material';
import { Candidate, CandidateAI } from 'types/types'; 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';
@ -20,21 +15,16 @@ interface CandidateInfoProps {
sx?: SxProps; sx?: SxProps;
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 = useMediaQuery(theme.breakpoints.down('md')) || variant === 'minimal';
const ai: CandidateAI | null = ('isAI' in candidate) ? candidate as CandidateAI : null; 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,40 +45,40 @@ 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>;
} }
return ( return (
<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}
> >
{ai && <AIBanner variant={variant} />} {ai && <AIBanner variant={variant} />}
<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 }}>
@ -97,34 +87,42 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
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 !== '' && }}
<Typography variant="body1">{action}</Typography> >
} {action !== '' && <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,8 +130,8 @@ 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"
@ -145,7 +143,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
lineHeight: 1.5, lineHeight: 1.5,
fontSize: "0.8rem !important", fontSize: '0.8rem !important',
}} }}
> >
{candidate.description} {candidate.description}
@ -154,7 +152,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
<Link <Link
component="button" component="button"
variant="body2" variant="body2"
onClick={(e) => { onClick={e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded); setIsDescriptionExpanded(!isDescriptionExpanded);
@ -169,48 +167,61 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
display: 'block', display: 'block',
'&:hover': { '&:hover': {
textDecoration: 'underline', textDecoration: 'underline',
} },
}} }}
> >
[{isDescriptionExpanded ? "less" : "more"}] [{isDescriptionExpanded ? 'less' : 'more'}]
</Link> </Link>
)} )}
</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>
); );
}; };
export { CandidateInfo }; export { CandidateInfo };

View File

@ -1,87 +1,97 @@
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

@ -7,19 +7,17 @@ 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,15 +1,26 @@
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 { Box,
Card, Link,
CardContent, Typography,
Divider, Avatar,
useTheme, Grid,
SxProps,
CardActions,
Chip,
Stack,
CardHeader,
Button,
styled,
LinearProgress,
IconButton,
Tooltip,
} from '@mui/material'; } from '@mui/material';
import { Card, CardContent, Divider, useTheme } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import { useMediaQuery } from '@mui/material'; import { useMediaQuery } from '@mui/material';
import { Job } from 'types/types'; 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';
@ -19,7 +30,7 @@ import ModelTrainingIcon from '@mui/icons-material/ModelTraining';
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon'; import { StatusIcon, StatusBox } from 'components/ui/StatusIcon';
import RestoreIcon from '@mui/icons-material/Restore'; import RestoreIcon from '@mui/icons-material/Restore';
import SaveIcon from '@mui/icons-material/Save'; 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';
@ -28,25 +39,22 @@ interface JobInfoProps {
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] = useState<Types.ApiActivityType | null>(null);
const [activeJob, setActiveJob] = useState<Types.Job>({ ...job }); /* Copy of job */ 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,11 +80,11 @@ 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>;
@ -88,12 +96,12 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
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);
@ -101,7 +109,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
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;
@ -116,22 +124,37 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
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) => (
@ -152,7 +175,7 @@ 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" />}
@ -160,49 +183,49 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
/> />
<CardContent sx={{ p: 0 }}> <CardContent sx={{ p: 0 }}>
{renderRequirementSection( {renderRequirementSection(
"Technical Skills (Required)", 'Technical Skills (Required)',
activeJob.requirements.technicalSkills.required, activeJob.requirements.technicalSkills.required,
<Build color="primary" />, <Build color="primary" />,
true true
)} )}
{renderRequirementSection( {renderRequirementSection(
"Technical Skills (Preferred)", 'Technical Skills (Preferred)',
activeJob.requirements.technicalSkills.preferred, activeJob.requirements.technicalSkills.preferred,
<Build color="action" /> <Build color="action" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Experience Requirements (Required)", 'Experience Requirements (Required)',
activeJob.requirements.experienceRequirements.required, activeJob.requirements.experienceRequirements.required,
<Work color="primary" />, <Work color="primary" />,
true true
)} )}
{renderRequirementSection( {renderRequirementSection(
"Experience Requirements (Preferred)", 'Experience Requirements (Preferred)',
activeJob.requirements.experienceRequirements.preferred, activeJob.requirements.experienceRequirements.preferred,
<Work color="action" /> <Work color="action" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Soft Skills", 'Soft Skills',
activeJob.requirements.softSkills, activeJob.requirements.softSkills,
<Psychology color="secondary" /> <Psychology color="secondary" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Experience", 'Experience',
activeJob.requirements.experience, activeJob.requirements.experience,
<Star color="warning" /> <Star color="warning" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Education", 'Education',
activeJob.requirements.education, activeJob.requirements.education,
<Description color="info" /> <Description color="info" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Certifications", 'Certifications',
activeJob.requirements.certifications, activeJob.requirements.certifications,
<CheckCircle color="success" /> <CheckCircle color="success" />
)} )}
{renderRequirementSection( {renderRequirementSection(
"Preferred Attributes", 'Preferred Attributes',
activeJob.requirements.preferredAttributes, activeJob.requirements.preferredAttributes,
<Star color="secondary" /> <Star color="secondary" />
)} )}
@ -213,165 +236,239 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
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
pointerEvents: deleted ? "none" : "auto", ? theme.palette.action.disabledBackground
: theme.palette.background.paper,
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',
<Box sx={{ fontSize: "0.8rem" }}> 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>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: variant !== 'small' && variant !== 'minimal' ? '75%' : '100%',
ref={descriptionRef} }}
variant="body1" >
color="text.secondary" {!isMobile && activeJob.summary && (
sx={{ <Box sx={{ fontSize: '0.8rem' }}>
display: '-webkit-box', <Box>Summary</Box>
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3, <Box sx={{ minHeight: variant === 'small' ? '5rem' : 'inherit' }}>
WebkitBoxOrient: 'vertical', <Typography
overflow: 'hidden', ref={descriptionRef}
textOverflow: 'ellipsis', variant="body1"
lineHeight: 1.5, color="text.secondary"
fontSize: "0.8rem !important",
}}
>
{activeJob.summary}
</Typography>
{shouldShowMoreButton && (
<Link
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.details.location.country}
{activeJob.owner && <Typography variant="body2"> </Typography>
<strong>Submitted by:</strong> {activeJob.owner.fullName} )}
</Typography>} {activeJob.owner && (
{activeJob.createdAt && <Typography variant="body2">
<Typography variant="caption">Created: {activeJob.createdAt.toISOString()}</Typography> <strong>Submitted by:</strong> {activeJob.owner.fullName}
} </Typography>
{activeJob.updatedAt && )}
<Typography variant="caption">Updated: {activeJob.updatedAt.toISOString()}</Typography> {activeJob.createdAt && (
} <Typography variant="caption">
<Typography variant="caption">Job ID: {job.id}</Typography> Created: {activeJob.createdAt.toISOString()}
</>} </Typography>
{variant === 'all' && <StyledMarkdown sx={{ display: "flex" }} content={activeJob.description} />} )}
{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,89 @@
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
};

View File

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

View File

@ -1,31 +1,33 @@
import React from 'react'; import React from 'react';
import { import { Button, Typography, Paper, Container } from '@mui/material';
Button,
Typography,
Paper,
Container,
} from '@mui/material';
import { useNavigate } from 'react-router-dom'; 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

@ -2,21 +2,17 @@ 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</Box>
You must login to access this feature {children}
</Box>
{children}
</Box> </Box>
); );
}; };
export { export { LoginRestricted };
LoginRestricted
};

View File

@ -1,18 +1,18 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { import {
Box, Box,
Link, Link,
Typography, Typography,
Avatar, Avatar,
Grid, Grid,
SxProps, SxProps,
CardActions, CardActions,
Chip, Chip,
Stack, Stack,
CardHeader, CardHeader,
Button, Button,
LinearProgress, LinearProgress,
IconButton, IconButton,
Tooltip, Tooltip,
Card, Card,
CardContent, CardContent,
@ -23,9 +23,9 @@ 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 {
@ -38,12 +38,12 @@ 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';
@ -57,34 +57,32 @@ interface ResumeInfoProps {
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 ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => { 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,10 +98,10 @@ 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) {
@ -120,8 +118,12 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
setSaving(true); setSaving(true);
try { try {
const result = await apiClient.updateResume(activeResume.id || '', editContent); const result = await apiClient.updateResume(activeResume.id || '', editContent);
const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() }; const updatedResume = {
setActiveResume(updatedResume); ...activeResume,
resume: editContent,
updatedAt: new Date(),
};
setActiveResume(updatedResume);
setSnack('Resume updated successfully.'); setSnack('Resume updated successfully.');
} catch (error) { } catch (error) {
setSnack('Failed to update resume.'); setSnack('Failed to update resume.');
@ -146,43 +148,57 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
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
pointerEvents: deleted ? "none" : "auto", ? theme.palette.action.disabledBackground
: theme.palette.background.paper,
pointerEvents: deleted ? 'none' : 'auto',
...sx, ...sx,
}} }}
> >
<Box sx={{ display: "flex", flexGrow: 1, p: 1, pb: 0, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}> <Box
sx={{
display: 'flex',
flexGrow: 1,
p: 1,
pb: 0,
height: '100%',
flexDirection: 'column',
alignItems: 'stretch',
position: 'relative',
}}
>
{/* Header Information */} {/* Header Information */}
<Box sx={{ <Box
display: "flex", sx={{
flexDirection: isMobile ? "column" : "row", display: 'flex',
gap: 2, flexDirection: isMobile ? 'column' : 'row',
mb: 2 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}>
@ -197,10 +213,17 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{activeResume.candidate?.fullName || activeResume.candidateId} {activeResume.candidate?.fullName || activeResume.candidateId}
</Typography> </Typography>
{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
@ -213,7 +236,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
)} )}
</Stack> </Stack>
</Grid> </Grid>
<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 }}>
@ -229,7 +252,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 +263,7 @@ 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" />}
@ -263,12 +286,18 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
component="div" component="div"
sx={{ sx={{
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: isContentExpanded ? 'unset' : (variant === "small" ? 5 : variant === "minimal" ? 3 : 10), WebkitLineClamp: isContentExpanded
? 'unset'
: variant === 'small'
? 5
: variant === 'minimal'
? 3
: 10,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', 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,
@ -279,8 +308,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
> >
{activeResume.resume} {activeResume.resume}
</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"
@ -289,7 +318,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
startIcon={isContentExpanded ? <VisibilityOffIcon /> : <VisibilityIcon />} startIcon={isContentExpanded ? <VisibilityOffIcon /> : <VisibilityIcon />}
sx={{ fontSize: '0.75rem' }} sx={{ fontSize: '0.75rem' }}
> >
{isContentExpanded ? "Show Less" : "Show More"} {isContentExpanded ? 'Show Less' : 'Show More'}
</Button> </Button>
</Box> </Box>
)} )}
@ -303,41 +332,59 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<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>
</Tooltip> </Tooltip>
<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>
</Tooltip> </Tooltip>
<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>
</Tooltip> </Tooltip>
</Box> </Box>
{saving && ( {saving && (
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
<LinearProgress /> <LinearProgress />
@ -349,127 +396,142 @@ 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
open={editDialogOpen} open={editDialogOpen}
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 {activeResume.candidate?.fullName || activeResume.candidateId},{' '}
</Typography> {activeResume.job?.title || 'No Job Title Assigned'},{' '}
<Typography variant="caption" display="block" color="text.secondary"> {activeResume.job?.company || 'No Company Assigned'}
Resume ID: # {activeResume.id} </Typography>
</Typography> <Typography variant="caption" display="block" color="text.secondary">
<Typography variant="caption" display="block" color="text.secondary"> Resume ID: # {activeResume.id}
Last saved: {activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'} </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 value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
width: "100%", <Tab value="preview" icon={<PreviewIcon />} label="Preview" />
minHeight: 0,/* Prevent flex overflow */ <Tab value="job" icon={<WorkIcon />} label="Job" />
//maxHeight: "min-content", <Tab value="print" icon={<PrintIcon />} label="Print" />
"& > *:not(.Scrollable)": { </Tabs>
flexShrink: 0, /* Prevent shrinking */ <Box
}, ref={printContentRef}
position: "relative", sx={{
}}> display: 'flex',
flexDirection: 'column',
{tabValue === "markdown" && height: '100%' /* Restrict to main-container's height */,
<BackstoryTextField width: '100%',
value={editContent} minHeight: 0 /* Prevent flex overflow */,
onChange={(value) => setEditContent(value)} //maxHeight: "min-content",
style={{ '& > *:not(.Scrollable)': {
position: "relative", flexShrink: 0 /* Prevent shrinking */,
// maxHeight: "100%", },
height: "100%", position: 'relative',
width: "100%", }}
display: "flex", >
minHeight: "100%", {tabValue === 'markdown' && (
<BackstoryTextField
flexGrow: 1, value={editContent}
flex: 1, /* Take remaining space in some-container */ onChange={value => setEditContent(value)}
overflowY: "auto", /* Scroll if content overflows */ style={{
}} position: 'relative',
placeholder="Enter resume content..." // maxHeight: "100%",
/> height: '100%',
} width: '100%',
{tabValue === "preview" && <> display: 'flex',
<StyledMarkdown minHeight: '100%',
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>
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> onClick={handleSave}
<Button variant="contained"
onClick={handleSave}
variant="contained"
disabled={saving} disabled={saving}
startIcon={<SaveIcon />} startIcon={<SaveIcon />}
> >
@ -481,4 +543,4 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
); );
}; };
export { ResumeInfo }; export { ResumeInfo };

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ 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 }) => ({
@ -30,30 +30,30 @@ 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" />;
} }
}; };
export { StatusIcon, StatusBox }; export { StatusIcon, StatusBox };

View File

@ -1,4 +1,4 @@
import React from "react"; import React from 'react';
import { import {
Chat as ChatIcon, Chat as ChatIcon,
Dashboard as DashboardIcon, Dashboard as DashboardIcon,
@ -18,35 +18,35 @@ import {
Analytics as AnalyticsIcon, Analytics as AnalyticsIcon,
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';
import { CandidateChatPage } from "pages/CandidateChatPage"; import { CandidateChatPage } from 'pages/CandidateChatPage';
import { DocsPage } from "pages/DocsPage"; import { DocsPage } from 'pages/DocsPage';
import { CreateProfilePage } from "pages/candidate/ProfileWizard"; import { CreateProfilePage } from 'pages/candidate/ProfileWizard';
import { VectorVisualizerPage } from "pages/VectorVisualizerPage"; import { VectorVisualizerPage } from 'pages/VectorVisualizerPage';
import { BetaPage } from "pages/BetaPage"; import { BetaPage } from 'pages/BetaPage';
import { CandidateListingPage } from "pages/FindCandidatePage"; import { CandidateListingPage } from 'pages/FindCandidatePage';
import { JobAnalysisPage } from "pages/JobAnalysisPage"; import { JobAnalysisPage } from 'pages/JobAnalysisPage';
import { GenerateCandidate } from "pages/GenerateCandidate"; import { GenerateCandidate } from 'pages/GenerateCandidate';
import { LoginPage } from "pages/LoginPage"; import { LoginPage } from 'pages/LoginPage';
import { EmailVerificationPage } from "components/EmailVerificationComponents"; import { EmailVerificationPage } from 'components/EmailVerificationComponents';
import { Box, Typography } from "@mui/material"; import { Box, Typography } from '@mui/material';
import { CandidateDashboard } from "pages/candidate/Dashboard"; import { CandidateDashboard } from 'pages/candidate/Dashboard';
import { NavigationConfig, NavigationItem } from "types/navigation"; import { NavigationConfig, NavigationItem } from 'types/navigation';
import { HowItWorks } from "pages/HowItWorks"; import { HowItWorks } from 'pages/HowItWorks';
import SchoolIcon from "@mui/icons-material/School"; import SchoolIcon from '@mui/icons-material/School';
import { CandidateProfile } from "pages/candidate/Profile"; import { CandidateProfile } from 'pages/candidate/Profile';
import { Settings } from "pages/candidate/Settings"; import { Settings } from 'pages/candidate/Settings';
import { VectorVisualizer } from "components/VectorVisualizer"; import { VectorVisualizer } from 'components/VectorVisualizer';
import { DocumentManager } from "components/DocumentManager"; import { DocumentManager } from 'components/DocumentManager';
import { useAuth } from "hooks/AuthContext"; import { useAuth } from 'hooks/AuthContext';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { JobViewer } from "components/ui/JobViewer"; import { JobViewer } from 'components/ui/JobViewer';
import { CandidatePicker } from "components/ui/CandidatePicker"; import { CandidatePicker } from 'components/ui/CandidatePicker';
import { ResumeViewer } from "components/ui/ResumeViewer"; import { ResumeViewer } from 'components/ui/ResumeViewer';
// Beta page components for placeholder routes // Beta page components for placeholder routes
const BackstoryPage = () => ( const BackstoryPage = () => (
@ -89,12 +89,10 @@ const LogoutPage = () => {
const { logout } = useAuth(); const { logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
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>
@ -109,28 +107,28 @@ const SettingsPage = () => (
export const navigationConfig: NavigationConfig = { export const navigationConfig: NavigationConfig = {
items: [ items: [
{ {
id: "home", id: 'home',
label: <BackstoryLogo />, label: <BackstoryLogo />,
path: "/", path: '/',
component: <HowItWorks />, component: <HowItWorks />,
userTypes: ["guest", "candidate", "employer"], userTypes: ['guest', 'candidate', 'employer'],
exact: true, exact: true,
}, },
{ {
id: "job-analysis", id: 'job-analysis',
label: "Job Analysis", label: 'Job Analysis',
path: "/job-analysis", path: '/job-analysis',
icon: <WorkIcon />, icon: <WorkIcon />,
component: <JobAnalysisPage />, component: <JobAnalysisPage />,
userTypes: ["guest", "candidate", "employer"], userTypes: ['guest', 'candidate', 'employer'],
}, },
{ {
id: "chat", id: 'chat',
label: "Candidate Chat", label: 'Candidate Chat',
path: "/chat", path: '/chat',
icon: <ChatIcon />, icon: <ChatIcon />,
component: <CandidateChatPage />, component: <CandidateChatPage />,
userTypes: ["guest", "candidate", "employer"], userTypes: ['guest', 'candidate', 'employer'],
}, },
// { // {
// id: "explore", // id: "explore",
@ -138,93 +136,89 @@ 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,
// }, // },
{ {
id: "generate-candidate", id: 'generate-candidate',
label: "Generate Candidate", label: 'Generate Candidate',
path: "/admin/generate-candidate", path: '/admin/generate-candidate',
icon: <AutoFixHigh />, icon: <AutoFixHigh />,
component: <GenerateCandidate />, component: <GenerateCandidate />,
userTypes: ["admin"], userTypes: ['admin'],
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
userMenuGroup: "admin", userMenuGroup: 'admin',
}, },
// User menu only items (not shown in main navigation) // User menu only items (not shown in main navigation)
{ {
id: "candidate-profile", id: 'candidate-profile',
label: "Profile", label: 'Profile',
icon: <PersonIcon />, icon: <PersonIcon />,
path: "/candidate/profile", path: '/candidate/profile',
component: <CandidateProfile />, component: <CandidateProfile />,
userTypes: ["candidate"], userTypes: ['candidate'],
userMenuGroup: "profile", userMenuGroup: 'profile',
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
}, },
{ {
id: "candidate-dashboard", id: 'candidate-dashboard',
label: "Dashboard", label: 'Dashboard',
path: "/candidate/dashboard", path: '/candidate/dashboard',
icon: <DashboardIcon />, icon: <DashboardIcon />,
component: <CandidateDashboard />, component: <CandidateDashboard />,
userTypes: ["candidate"], userTypes: ['candidate'],
userMenuGroup: "profile", userMenuGroup: 'profile',
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
}, },
{ {
id: "explore-jobs", id: 'explore-jobs',
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,
userMenuGroup: "profile", userMenuGroup: 'profile',
}, },
{ {
id: "explore-resumes", id: 'explore-resumes',
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,
userMenuGroup: "profile", userMenuGroup: 'profile',
}, },
{ {
id: "candidate-docs", id: 'candidate-docs',
label: "Content", label: 'Content',
icon: <BubbleChart />, icon: <BubbleChart />,
path: "/candidate/documents", path: '/candidate/documents',
component: ( component: (
<Box sx={{ display: "flex", width: "100%", flexDirection: "column" }}> <Box sx={{ display: 'flex', width: '100%', flexDirection: 'column' }}>
<VectorVisualizer /> <VectorVisualizer />
<DocumentManager /> <DocumentManager />
</Box> </Box>
), ),
userTypes: ["candidate"], userTypes: ['candidate'],
userMenuGroup: "profile", userMenuGroup: 'profile',
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
}, },
@ -271,65 +265,65 @@ export const navigationConfig: NavigationConfig = {
// showInUserMenu: true, // showInUserMenu: true,
// }, // },
{ {
id: "candidate-settings", id: 'candidate-settings',
label: "System Information", label: 'System Information',
path: "/candidate/settings", path: '/candidate/settings',
icon: <SettingsIcon />, icon: <SettingsIcon />,
component: <Settings />, component: <Settings />,
userTypes: ["candidate"], userTypes: ['candidate'],
userMenuGroup: "account", userMenuGroup: 'account',
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
}, },
{ {
id: "logout", id: 'logout',
label: "Logout", label: 'Logout',
icon: <PersonIcon />, // This will be handled specially in Header icon: <PersonIcon />, // This will be handled specially in Header
userTypes: ["candidate", "employer"], userTypes: ['candidate', 'employer'],
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
userMenuGroup: "system", userMenuGroup: 'system',
}, },
// Auth routes (special handling) // Auth routes (special handling)
{ {
id: "auth", id: 'auth',
label: "Auth", label: 'Auth',
userTypes: ["guest", "candidate", "employer"], userTypes: ['guest', 'candidate', 'employer'],
showInNavigation: false, showInNavigation: false,
children: [ children: [
{ {
id: "verify-email", id: 'verify-email',
label: "Verify Email", label: 'Verify Email',
path: "/login/verify-email", path: '/login/verify-email',
component: <EmailVerificationPage />, component: <EmailVerificationPage />,
userTypes: ["guest", "candidate", "employer"], userTypes: ['guest', 'candidate', 'employer'],
showInNavigation: false, showInNavigation: false,
}, },
{ {
id: "login", id: 'login',
label: "Login", label: 'Login',
path: "/login/:tab?", path: '/login/:tab?',
component: <LoginPage />, component: <LoginPage />,
userTypes: ["guest", "candidate", "employer"], userTypes: ['guest', 'candidate', 'employer'],
showInNavigation: false, showInNavigation: false,
}, },
{ {
id: "logout-page", id: 'logout-page',
label: "Logout", label: 'Logout',
path: "/logout", path: '/logout',
component: <LogoutPage />, component: <LogoutPage />,
userTypes: ["candidate", "employer"], userTypes: ['candidate', 'employer'],
showInNavigation: false, showInNavigation: false,
}, },
], ],
}, },
// Catch-all route // Catch-all route
{ {
id: "catch-all", id: 'catch-all',
label: "Not Found", label: 'Not Found',
path: "*", path: '*',
component: <BetaPage />, component: <BetaPage />,
userTypes: ["guest", "candidate", "employer"], userTypes: ['guest', 'candidate', 'employer'],
showInNavigation: false, showInNavigation: false,
}, },
], ],
@ -337,39 +331,45 @@ export const navigationConfig: NavigationConfig = {
// Utility functions for working with navigation config // Utility functions for working with navigation config
export const getNavigationItemsForUser = ( export const getNavigationItemsForUser = (
userType: "guest" | "candidate" | "employer" | null, userType: 'guest' | 'candidate' | 'employer' | null,
isAdmin: boolean isAdmin: boolean
): NavigationItem[] => { ): NavigationItem[] => {
const currentUserType = userType || "guest"; const currentUserType = userType || 'guest';
const filterItems = (items: NavigationItem[]): NavigationItem[] => { const filterItems = (items: NavigationItem[]): NavigationItem[] => {
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);
}; };
export const getAllRoutes = ( export const getAllRoutes = (
userType: "guest" | "candidate" | "employer" | null, userType: 'guest' | 'candidate' | 'employer' | null,
isAdmin: boolean isAdmin: boolean
): NavigationItem[] => { ): NavigationItem[] => {
const currentUserType = userType || "guest"; const currentUserType = userType || 'guest';
const extractRoutes = (items: NavigationItem[]): NavigationItem[] => { const extractRoutes = (items: NavigationItem[]): NavigationItem[] => {
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);
} }
@ -386,25 +386,28 @@ export const getAllRoutes = (
}; };
export const getMainNavigationItems = ( export const getMainNavigationItems = (
userType: "guest" | "candidate" | "employer" | null, userType: 'guest' | 'candidate' | 'employer' | null,
isAdmin: boolean isAdmin: boolean
): NavigationItem[] => { ): NavigationItem[] => {
return getNavigationItemsForUser(userType, isAdmin).filter( return getNavigationItemsForUser(userType, isAdmin).filter(
(item) => item =>
item.id !== "auth" && item.id !== 'auth' &&
item.id !== "catch-all" && item.id !== 'catch-all' &&
item.showInNavigation !== false && item.showInNavigation !== false &&
(item.path || (item.children && item.children.length > 0)) (item.path || (item.children && item.children.length > 0))
); );
}; };
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[] => {
const menuItems: NavigationItem[] = []; const menuItems: NavigationItem[] = [];
items.forEach((item) => { items.forEach(item => {
if (!item.userTypes || item.userTypes.includes(userType) || isAdmin) { if (!item.userTypes || item.userTypes.includes(userType) || isAdmin) {
if (item.showInUserMenu) { if (item.showInUserMenu) {
menuItems.push(item); menuItems.push(item);
@ -422,7 +425,7 @@ export const getUserMenuItems = (userType: "candidate" | "employer" | "guest" |
}; };
export const getUserMenuItemsByGroup = ( export const getUserMenuItemsByGroup = (
userType: "candidate" | "employer" | "guest" | null, userType: 'candidate' | 'employer' | 'guest' | null,
isAdmin: boolean isAdmin: boolean
): { [key: string]: NavigationItem[] } => { ): { [key: string]: NavigationItem[] } => {
const menuItems = getUserMenuItems(userType, isAdmin); const menuItems = getUserMenuItems(userType, isAdmin);
@ -434,8 +437,8 @@ export const getUserMenuItemsByGroup = (
other: [], other: [],
}; };
menuItems.forEach((item) => { menuItems.forEach(item => {
const group = item.userMenuGroup || "other"; const group = item.userMenuGroup || 'other';
if (!grouped[group]) { if (!grouped[group]) {
grouped[group] = []; grouped[group] = [];
} }

View File

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

View File

@ -12,13 +12,14 @@ const BackstoryAppAnalysisPage = () => {
<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>
<Typography variant="h2" component="h2"> <Typography variant="h2" component="h2">
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 between job candidates
employers/recruiters with an AI-powered approach to professional profiles and resume generation. 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 +28,15 @@ 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 comprehensive professional
and generate tailored resumes for specific positions 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, directly interact
assistants about candidate experiences, and generate position-specific resumes with AI assistants about candidate experiences, and generate position-specific
resumes
</Typography> </Typography>
</li> </li>
</Box> </Box>
@ -42,34 +44,39 @@ const BackstoryAppAnalysisPage = () => {
<Typography variant="h2" component="h2" sx={{ mt: 4 }}> <Typography variant="h2" component="h2" sx={{ mt: 4 }}>
Key Features Key Features
</Typography> </Typography>
<Typography variant="h3" component="h3" sx={{ mt: 3 }}> <Typography variant="h3" component="h3" sx={{ mt: 3 }}>
For Candidates For Candidates
</Typography> </Typography>
<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 +87,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 +130,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 +154,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 +194,8 @@ 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 bottom tabs and a
maintaining the core functionality while adapting to smaller screens. 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 +208,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>
@ -218,17 +243,20 @@ const BackstoryAppAnalysisPage = () => {
</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 +267,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>
@ -293,7 +326,9 @@ const BackstoryAppAnalysisPage = () => {
<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 +337,4 @@ const BackstoryAppAnalysisPage = () => {
); );
}; };
export { BackstoryAppAnalysisPage };
export {
BackstoryAppAnalysisPage
}

View File

@ -6,9 +6,10 @@ import { Box, Paper, Container } from '@mui/material';
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>
@ -19,71 +20,91 @@ const BackstoryThemeVisualizerPage = () => {
<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-8">
<div className="mb-4"> <h2 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
<p style={{ Primary Colors
fontFamily: backstoryTheme.typography.fontFamily, </h2>
fontSize: backstoryTheme.typography.body1.fontSize, <div className="flex space-x-4">
color: backstoryTheme.typography.body1.color, {colorSwatch(
}}> backstoryTheme.palette.primary.main,
Body Text - This is how the regular text content will appear in the Backstory application. 'Primary',
The application uses Roboto as its primary font family, with carefully selected sizing and colors. backstoryTheme.palette.primary.contrastText
</p> )}
</div> {colorSwatch(
backstoryTheme.palette.secondary.main,
{/* <div className="mt-6"> '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>
</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 +112,245 @@ 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 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
backgroundColor: backstoryTheme.palette.primary.main, UI Component Examples
color: backstoryTheme.palette.primary.contrastText, </h2>
display: 'inline-block',
borderRadius: '4px', <div
cursor: 'pointer', className="p-4 mb-4 rounded-lg"
fontFamily: backstoryTheme.typography.fontFamily, style={{
}}> backgroundColor: backstoryTheme.palette.background.paper,
Primary Button }}
>
<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 className="text-xl mb-4" style={{ color: backstoryTheme.palette.text.primary }}>
backgroundColor: backstoryTheme.palette.secondary.main, Theme Color Breakdown
color: backstoryTheme.palette.secondary.contrastText, </h2>
display: 'inline-block', <table className="border-collapse">
borderRadius: '4px', <thead>
cursor: 'pointer', <tr>
fontFamily: backstoryTheme.typography.fontFamily, <th
}}> className="border p-2 text-left"
Secondary Button style={{
</div> backgroundColor: backstoryTheme.palette.background.default,
color: backstoryTheme.palette.text.primary,
<div className="mt-4" style={{ }}
padding: '8px 16px', >
backgroundColor: backstoryTheme.palette.action.active, Color Name
color: '#fff', </th>
display: 'inline-block', <th
borderRadius: '4px', className="border p-2 text-left"
cursor: 'pointer', style={{
fontFamily: backstoryTheme.typography.fontFamily, backgroundColor: backstoryTheme.palette.background.default,
}}> color: backstoryTheme.palette.text.primary,
Action Button }}
>
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

@ -7,31 +7,48 @@ 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 }}>
@ -39,27 +56,39 @@ const BackstoryUIOverviewPage: React.FC = () => {
'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 key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'secondary.main' }} /> <Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: 'secondary.main',
}}
/>
<Typography variant="body2">{item}</Typography> <Typography variant="body2">{item}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
</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 }}>
@ -67,10 +96,17 @@ const BackstoryUIOverviewPage: React.FC = () => {
'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 key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'primary.main' }} /> <Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: 'primary.main',
}}
/>
<Typography variant="body2">{item}</Typography> <Typography variant="body2">{item}</Typography>
</Box> </Box>
))} ))}
@ -78,37 +114,73 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Box> </Box>
</Grid> </Grid>
</Grid> </Grid>
{/* UI Components */} {/* UI Components */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}> <Box
sx={{
p: 3,
bgcolor: 'background.paper',
borderRadius: 2,
mb: 4,
boxShadow: 1,
}}
>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}> <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">
@ -119,49 +191,72 @@ const BackstoryUIOverviewPage: React.FC = () => {
))} ))}
</Grid> </Grid>
</Box> </Box>
{/* Navigation Structure */} {/* Navigation Structure */}
<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: [
'Dashboard',
'Profile',
'Backstory',
'Resumes',
'Q&A Setup',
'Analytics',
'Settings',
],
color: 'secondary.main', color: 'secondary.main',
borderColor: 'secondary.main' borderColor: 'secondary.main',
}, },
{ {
title: 'Employer Navigation', title: 'Employer Navigation',
items: ['Dashboard', 'Search', 'Saved', 'Jobs', 'Company', 'Analytics', 'Settings'], items: [
'Dashboard',
'Search',
'Saved',
'Jobs',
'Company',
'Analytics',
'Settings',
],
color: 'primary.main', color: 'primary.main',
borderColor: '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}
@ -172,86 +267,105 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Grid> </Grid>
))} ))}
</Grid> </Grid>
{/* Connection Points */} {/* Connection Points */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}> <Box
sx={{
p: 3,
bgcolor: 'background.paper',
borderRadius: 2,
mb: 4,
boxShadow: 1,
}}
>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}> <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={{ />
flex: 1,
pl: 3, <Box
}}> sx={{
<Box sx={{ flex: 1,
display: 'inline-block', pl: 3,
bgcolor: 'rgba(26, 37, 54, 0.1)', }}
p: 2, >
borderRadius: 2, <Box
color: 'primary.main', sx={{
fontWeight: 'medium', display: 'inline-block',
border: '1px solid', bgcolor: 'rgba(26, 37, 54, 0.1)',
borderColor: 'rgba(26, 37, 54, 0.3)', p: 2,
}}> borderRadius: 2,
color: 'primary.main',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'rgba(26, 37, 54, 0.3)',
}}
>
{connection.right} {connection.right}
</Box> </Box>
</Box> </Box>
@ -259,91 +373,146 @@ const BackstoryUIOverviewPage: React.FC = () => {
))} ))}
</Box> </Box>
</Box> </Box>
{/* Mobile Adaptation */} {/* Mobile Adaptation */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, boxShadow: 1 }}> <Box
sx={{
p: 3,
bgcolor: 'background.paper',
borderRadius: 2,
boxShadow: 1,
}}
>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}> <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',
borderRadius: 4,
overflow: 'hidden'
}}>
{/* Mobile header */}
<Box sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
p: 1,
display: 'flex', display: 'flex',
justifyContent: 'space-between', flexDirection: 'column',
alignItems: 'center' border: '1px solid',
}}> borderColor: 'divider',
<Typography sx={{ fontWeight: 'bold', fontSize: '0.875rem' }}>BACKSTORY</Typography> borderRadius: 4,
overflow: 'hidden',
}}
>
{/* Mobile header */}
<Box
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
p: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<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', p: 1.5,
fontSize: '0.75rem' 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> >
<Typography sx={{ mb: 1, fontWeight: 'medium' }}>
<Box sx={{ Welcome back, [Name]!
border: '1px solid', </Typography>
borderColor: 'divider', <Typography sx={{ fontSize: '0.675rem', mb: 2 }}>
borderRadius: 1, Profile: 75% complete
p: 1.5, </Typography>
mb: 2,
bgcolor: 'background.paper' <Box
}}> sx={{
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', mb: 0.5 }}>Resume Builder</Typography> 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> <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={{
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' }}> 5 profile views</Typography>
<Typography sx={{ fontSize: '0.675rem' }}> 2 downloads</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 +525,4 @@ const BackstoryUIOverviewPage: React.FC = () => {
); );
}; };
export { export { BackstoryUIOverviewPage };
BackstoryUIOverviewPage
};

View File

@ -1,15 +1,36 @@
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,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
Tab,
Tabs,
TextField,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material'; } 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,
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'; } from '@mui/icons-material';
interface Resume { interface Resume {
@ -22,37 +43,74 @@ 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"
color="default"
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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" component="h1" fontWeight="bold" color="text.primary">Backstory</Typography> <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>
)} )}
@ -71,12 +129,25 @@ 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,7 +159,11 @@ 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" />
@ -100,7 +175,9 @@ 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 }}>
@ -128,16 +205,22 @@ const MockupPage = () => {
<Box sx={{ mt: 'auto' }}> <Box sx={{ mt: 'auto' }}>
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'background.default' }}> <Paper variant="outlined" sx={{ p: 2, bgcolor: 'background.default' }}>
<Typography variant="subtitle2" color="text.primary" gutterBottom>Recent Activity</Typography> <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 key={resume.id} disablePadding sx={{ mb: 0.5 }}>
</ListItemIcon> <ListItemIcon sx={{ minWidth: 24 }}>
<Typography variant="body2" noWrap>{resume.name}</Typography> <ClockIcon fontSize="small" />
</ListItem> </ListItemIcon>
))} <Typography variant="body2" noWrap>
{resume.name}
</Typography>
</ListItem>
))}
</List> </List>
</Paper> </Paper>
</Box> </Box>
@ -152,12 +235,21 @@ const MockupPage = () => {
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)}>
@ -182,7 +274,9 @@ 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)}>
@ -214,18 +308,21 @@ const MockupPage = () => {
<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 variant="h5" component="h2" fontWeight="bold" gutterBottom>
<Typography variant="body2" color="text.secondary">Generate and customize resumes based on job descriptions</Typography> 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)}
aria-label="Resume builder tabs" aria-label="Resume builder tabs"
variant={isMobile ? "scrollable" : "standard"} variant={isMobile ? 'scrollable' : 'standard'}
scrollButtons={isMobile ? "auto" : undefined} scrollButtons={isMobile ? 'auto' : undefined}
> >
<Tab label="Job Description" value="job" /> <Tab label="Job Description" value="job" />
<Tab label="Resume" value="resume" /> <Tab label="Resume" value="resume" />
@ -233,11 +330,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,24 +350,22 @@ 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" size="small" startIcon={<SaveIcon />}>
variant="outlined"
size="small"
startIcon={<SaveIcon />}
>
Save Save
</Button> </Button>
<Button <Button variant="outlined" size="small" startIcon={<EyeIcon />}>
variant="outlined"
size="small"
startIcon={<EyeIcon />}
>
Preview Preview
</Button> </Button>
</Box> </Box>
@ -279,21 +375,46 @@ const MockupPage = () => {
<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" />
@ -303,8 +424,17 @@ const MockupPage = () => {
{/* 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,13 +450,18 @@ 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={{
<Button display: 'flex',
startIcon={<PlusIcon />} justifyContent: 'space-between',
color="primary" alignItems: 'center',
size="small" mb: 2,
> }}
>
<Typography variant="subtitle1" fontWeight="medium">
Work Experience
</Typography>
<Button startIcon={<PlusIcon />} color="primary" size="small">
Add Position Add Position
</Button> </Button>
</Box> </Box>
@ -334,7 +469,9 @@ const MockupPage = () => {
{/* Job entry */} {/* Job entry */}
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: 'background.default' }}> <Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: 'background.default' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}> <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" fontWeight="medium">Senior Developer</Typography> <Typography variant="subtitle2" fontWeight="medium">
Senior Developer
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}> <Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small"> <IconButton size="small">
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
@ -344,10 +481,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>
@ -360,7 +503,7 @@ const MockupPage = () => {
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,16 +511,19 @@ 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" color="primary" size="small" startIcon={<PlusIcon />}>
variant="contained"
color="primary"
size="small"
startIcon={<PlusIcon />}
>
New Resume New Resume
</Button> </Button>
</Box> </Box>
@ -394,15 +540,20 @@ const MockupPage = () => {
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
cursor: 'pointer', cursor: 'pointer',
bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'background.paper', bgcolor:
selectedResume === resume.id ? 'primary.lighter' : 'background.paper',
borderColor: selectedResume === resume.id ? 'primary.light' : 'divider', borderColor: selectedResume === resume.id ? 'primary.light' : 'divider',
'&:hover': { bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'action.hover' } '&: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">
@ -417,10 +568,11 @@ 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>
@ -440,7 +592,8 @@ const MockupPage = () => {
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>
@ -461,7 +614,7 @@ const MockupPage = () => {
justifyContent: 'space-around', justifyContent: 'space-around',
borderTop: 1, borderTop: 1,
borderColor: 'divider', borderColor: 'divider',
zIndex: 1100 zIndex: 1100,
}} }}
elevation={3} elevation={3}
> >
@ -472,7 +625,7 @@ const MockupPage = () => {
alignItems: 'center', alignItems: 'center',
py: 1, py: 1,
px: 2, px: 2,
color: 'text.secondary' color: 'text.secondary',
}} }}
component="button" component="button"
> >
@ -486,7 +639,7 @@ const MockupPage = () => {
alignItems: 'center', alignItems: 'center',
py: 1, py: 1,
px: 2, px: 2,
color: 'primary.main' color: 'primary.main',
}} }}
component="button" component="button"
> >
@ -500,7 +653,7 @@ const MockupPage = () => {
alignItems: 'center', alignItems: 'center',
py: 1, py: 1,
px: 2, px: 2,
color: 'text.secondary' color: 'text.secondary',
}} }}
component="button" component="button"
> >
@ -511,8 +664,6 @@ const MockupPage = () => {
)} )}
</Box> </Box>
); );
} };
export { export { MockupPage };
MockupPage
};

View File

@ -1,15 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Box, Box,
Typography, Typography,
Paper, Paper,
Tabs, Tabs,
Tab, Tab,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
Chip, Chip,
Button, Button,
@ -22,7 +22,7 @@ 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';
@ -68,9 +68,9 @@ const mockUsers: User[] = [
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',
@ -83,9 +83,9 @@ const mockUsers: User[] = [
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',
@ -97,7 +97,7 @@ const mockUsers: User[] = [
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',
@ -109,8 +109,8 @@ const mockUsers: User[] = [
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
@ -120,12 +120,12 @@ const UserManagement: React.FC = () => {
const [openDialog, setOpenDialog] = useState(false); const [openDialog, setOpenDialog] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [aiConfigOpen, setAiConfigOpen] = useState(false); const [aiConfigOpen, setAiConfigOpen] = useState(false);
// Handle tab change // Handle tab change
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue); setTabValue(newValue);
}; };
// 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;
@ -133,30 +133,30 @@ const UserManagement: React.FC = () => {
if (tabValue === 2) return user.type === 'employer'; if (tabValue === 2) return user.type === 'employer';
return false; return false;
}); });
// Handle open user detail dialog // Handle open user detail dialog
const handleOpenUserDetails = (user: User) => { const handleOpenUserDetails = (user: User) => {
setSelectedUser(user); setSelectedUser(user);
setOpenDialog(true); setOpenDialog(true);
}; };
// Handle close user detail dialog // Handle close user detail dialog
const handleCloseDialog = () => { const handleCloseDialog = () => {
setOpenDialog(false); setOpenDialog(false);
setSelectedUser(null); setSelectedUser(null);
}; };
// Handle open AI configuration dialog // Handle open AI configuration dialog
const handleOpenAiConfig = (user: User) => { const handleOpenAiConfig = (user: User) => {
setSelectedUser(user); setSelectedUser(user);
setAiConfigOpen(true); setAiConfigOpen(true);
}; };
// Handle close AI configuration dialog // Handle close AI configuration dialog
const handleCloseAiConfig = () => { const handleCloseAiConfig = () => {
setAiConfigOpen(false); setAiConfigOpen(false);
}; };
// 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') {
@ -165,12 +165,12 @@ const UserManagement: React.FC = () => {
return user.companyName; return user.companyName;
} }
}; };
// Helper function to format date // Helper function to format date
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString(); return new Date(date).toLocaleDateString();
}; };
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 }}>
@ -185,7 +185,7 @@ const UserManagement: React.FC = () => {
<Tab icon={<Person />} label="Candidates" /> <Tab icon={<Person />} label="Candidates" />
<Tab icon={<Business />} label="Employers" /> <Tab icon={<Business />} label="Employers" />
</Tabs> </Tabs>
<TableContainer> <TableContainer>
<Table> <Table>
<TableHead> <TableHead>
@ -200,16 +200,22 @@ const UserManagement: React.FC = () => {
</TableRow> </TableRow>
</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={user.type === 'candidate' ? 'Candidate' : 'Employer'}
color={user.type === 'candidate' ? 'primary' : 'secondary'} color={user.type === 'candidate' ? 'primary' : 'secondary'}
size="small" size="small"
/> />
@ -223,24 +229,24 @@ const UserManagement: React.FC = () => {
{/* <TableCell>{formatDate(user.createdAt)}</TableCell> */} {/* <TableCell>{formatDate(user.createdAt)}</TableCell> */}
<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>
<TableCell> <TableCell>
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
onClick={() => handleOpenUserDetails(user)} onClick={() => handleOpenUserDetails(user)}
sx={{ mr: 1 }} sx={{ mr: 1 }}
> >
Details Details
</Button> </Button>
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
color="secondary" color="secondary"
onClick={() => handleOpenAiConfig(user)} onClick={() => handleOpenAiConfig(user)}
> >
@ -253,7 +259,7 @@ const UserManagement: React.FC = () => {
</Table> </Table>
</TableContainer> </TableContainer>
</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 && (
@ -264,7 +270,7 @@ const UserManagement: React.FC = () => {
<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"
@ -288,11 +294,11 @@ 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 => (
<Chip <Chip
key={skill.id} key={skill.id}
label={`${skill.name} (${skill.level})`} label={`${skill.name} (${skill.level})`}
sx={{ m: 0.5 }} sx={{ m: 0.5 }}
@ -303,7 +309,7 @@ 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"
@ -327,7 +333,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">Contact Information</Typography> <Typography variant="subtitle1">Contact Information</Typography>
<TextField <TextField
label="Email" label="Email"
@ -353,19 +359,17 @@ 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>AI Configuration for {getUserDisplayName(selectedUser)}</DialogTitle>
AI Configuration for {getUserDisplayName(selectedUser)}
</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography variant="subtitle1" gutterBottom> <Typography variant="subtitle1" gutterBottom>
RAG Database Configuration RAG Database Configuration
</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
@ -378,40 +382,32 @@ const UserManagement: React.FC = () => {
<MenuItem value="sentence-t5">Sentence T5</MenuItem> <MenuItem value="sentence-t5">Sentence T5</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth margin="normal"> <FormControl fullWidth margin="normal">
<InputLabel id="vector-store-label">Vector Store</InputLabel> <InputLabel id="vector-store-label">Vector Store</InputLabel>
<Select <Select labelId="vector-store-label" label="Vector Store" defaultValue="pinecone">
labelId="vector-store-label"
label="Vector Store"
defaultValue="pinecone"
>
<MenuItem value="pinecone">Pinecone</MenuItem> <MenuItem value="pinecone">Pinecone</MenuItem>
<MenuItem value="qdrant">Qdrant</MenuItem> <MenuItem value="qdrant">Qdrant</MenuItem>
<MenuItem value="faiss">FAISS</MenuItem> <MenuItem value="faiss">FAISS</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
<Typography variant="subtitle1" gutterBottom sx={{ mt: 2 }}> <Typography variant="subtitle1" gutterBottom sx={{ mt: 2 }}>
AI Model Parameters AI Model Parameters
</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 labelId="model-label" label="AI Model" defaultValue="gpt-4">
labelId="model-label"
label="AI Model"
defaultValue="gpt-4"
>
<MenuItem value="gpt-4">GPT-4</MenuItem> <MenuItem value="gpt-4">GPT-4</MenuItem>
<MenuItem value="claude-3">Claude 3</MenuItem> <MenuItem value="claude-3">Claude 3</MenuItem>
<MenuItem value="custom">Custom</MenuItem> <MenuItem value="custom">Custom</MenuItem>
</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 +417,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 +426,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"
@ -441,20 +437,24 @@ const UserManagement: React.FC = () => {
/> />
</Grid> </Grid>
</Grid> </Grid>
<TextField <TextField
label="System Prompt" label="System Prompt"
multiline multiline
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 }}>
Data Sources Data Sources
</Typography> </Typography>
<TableContainer component={Paper} sx={{ mt: 1 }}> <TableContainer component={Paper} sx={{ mt: 1 }}>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
@ -496,7 +496,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>
</> </>
)} )}
@ -505,4 +507,4 @@ const UserManagement: React.FC = () => {
); );
}; };
export { UserManagement }; export { UserManagement };

View File

@ -2,7 +2,12 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import * as Types from '../types/types'; import * as Types from '../types/types';
import { ApiClient, CreateCandidateRequest, CreateEmployerRequest, GuestConversionRequest } from 'services/api-client'; import {
ApiClient,
CreateCandidateRequest,
CreateEmployerRequest,
GuestConversionRequest,
} from 'services/api-client';
import { formatApiRequest, toCamelCase } from 'types/conversion'; import { formatApiRequest, toCamelCase } from 'types/conversion';
// ============================ // ============================
@ -48,7 +53,7 @@ const TOKEN_STORAGE = {
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;
// ============================ // ============================
@ -77,13 +82,13 @@ function isTokenExpired(token: string): boolean {
if (!payload || !payload.exp) { if (!payload || !payload.exp) {
return true; return true;
} }
// Check if token expires within the next 5 minutes (buffer time) // Check if token expires within the next 5 minutes (buffer time)
const expiryTime = payload.exp * 1000; // Convert to milliseconds const expiryTime = payload.exp * 1000; // Convert to milliseconds
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;
} }
// ============================ // ============================
@ -113,10 +118,10 @@ function prepareUserDataForStorage(user: Types.User): string {
function parseStoredUserData(userDataStr: string): Types.User | null { function parseStoredUserData(userDataStr: string): Types.User | null {
try { try {
const rawUserData = JSON.parse(userDataStr); const rawUserData = JSON.parse(userDataStr);
// Convert the data using toCamelCase which handles date conversion // Convert the data using toCamelCase which handles date conversion
const convertedData = toCamelCase<Types.User>(rawUserData); const convertedData = toCamelCase<Types.User>(rawUserData);
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);
@ -132,7 +137,7 @@ function updateStoredUserData(user: Types.User): void {
} }
} }
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(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(authResponse.user));
@ -155,10 +160,10 @@ function getStoredAuthData(): {
const expiryStr = localStorage.getItem(TOKEN_STORAGE.TOKEN_EXPIRY); const expiryStr = localStorage.getItem(TOKEN_STORAGE.TOKEN_EXPIRY);
const userType = localStorage.getItem(TOKEN_STORAGE.USER_TYPE); const userType = localStorage.getItem(TOKEN_STORAGE.USER_TYPE);
const isGuestStr = localStorage.getItem(TOKEN_STORAGE.IS_GUEST); const isGuestStr = localStorage.getItem(TOKEN_STORAGE.IS_GUEST);
let userData: Types.User | null = null; let userData: Types.User | null = null;
let expiresAt: number | null = null; let expiresAt: number | null = null;
try { try {
if (userDataStr) { if (userDataStr) {
userData = parseStoredUserData(userDataStr); userData = parseStoredUserData(userDataStr);
@ -170,14 +175,14 @@ function getStoredAuthData(): {
console.error('Failed to parse stored auth data:', error); console.error('Failed to parse stored auth data:', error);
clearStoredAuth(); clearStoredAuth();
} }
return { return {
accessToken, accessToken,
refreshToken, refreshToken,
userData, userData,
expiresAt, expiresAt,
userType, userType,
isGuest: isGuestStr === 'true' isGuest: isGuestStr === 'true',
}; };
} }
@ -202,15 +207,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> => {
@ -286,7 +294,7 @@ function useAuthenticationLogic() {
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(`${apiClient.getBaseUrl()}/users/${stored.userData.id}`, {
headers: { 'Authorization': `Bearer ${stored.accessToken}` } headers: { Authorization: `Bearer ${stored.accessToken}` },
}); });
if (!response.ok) { if (!response.ok) {
@ -306,25 +314,25 @@ 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...');
@ -336,18 +344,18 @@ function useAuthenticationLogic() {
// 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) {
@ -378,7 +386,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();
@ -394,131 +402,147 @@ 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> => {
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 => ({ 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 => ({
...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 => ({ 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 () => {
@ -550,56 +574,66 @@ function useAuthenticationLogic() {
}, [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,
user: authState.isGuest ? null : updatedUser,
guest: authState.isGuest ? updatedUser as Types.Guest : prev.guest
}));
console.log('✅ User data updated');
}, [authState.isGuest]);
// Email verification functions (unchanged)
const verifyEmail = useCallback(async (verificationData: EmailVerificationRequest) => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const result = await apiClient.verifyEmail(verificationData);
setAuthState(prev => ({ ...prev, isLoading: false }));
return {
message: result.message || 'Email verified successfully',
userType: result.userType || 'user'
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Email verification failed';
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isLoading: false, user: authState.isGuest ? null : updatedUser,
error: errorMessage guest: authState.isGuest ? (updatedUser as Types.Guest) : prev.guest,
})); }));
return null; console.log('✅ User data updated');
} },
}, [apiClient]); [authState.isGuest]
);
// Email verification functions (unchanged)
const verifyEmail = useCallback(
async (verificationData: EmailVerificationRequest) => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const result = await apiClient.verifyEmail(verificationData);
setAuthState(prev => ({ ...prev, isLoading: false }));
return {
message: result.message || 'Email verified successfully',
userType: result.userType || 'user',
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Email verification failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return null;
}
},
[apiClient]
);
// Other existing methods remain the same... // Other existing methods remain the same...
const resendEmailVerification = useCallback(async (email: string): Promise<boolean> => { const resendEmailVerification = useCallback(
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); async (email: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try { try {
await apiClient.resendVerificationEmail({ email }); await apiClient.resendVerificationEmail({ email });
setAuthState(prev => ({ ...prev, isLoading: false })); setAuthState(prev => ({ ...prev, isLoading: false }));
return true; return true;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to resend verification email'; const errorMessage =
setAuthState(prev => ({ error instanceof Error ? error.message : 'Failed to resend verification email';
...prev, setAuthState(prev => ({
isLoading: false, ...prev,
error: errorMessage isLoading: false,
})); error: errorMessage,
return false; }));
} return false;
}, [apiClient]); }
},
[apiClient]
);
const setPendingVerificationEmail = useCallback((email: string) => { const setPendingVerificationEmail = useCallback((email: string) => {
localStorage.setItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL, email); localStorage.setItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL, email);
@ -609,45 +643,52 @@ 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 {
const employer = await apiClient.createEmployer(employerData);
console.log('✅ Employer created:', employer);
setPendingVerificationEmail(employerData.email); try {
const employer = await apiClient.createEmployer(employerData);
setAuthState(prev => ({ ...prev, isLoading: false })); console.log('✅ Employer created:', employer);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient, setPendingVerificationEmail]);
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => { setPendingVerificationEmail(employerData.email);
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
setAuthState(prev => ({ ...prev, isLoading: false }));
try { return true;
await apiClient.requestPasswordReset({ email }); } catch (error) {
setAuthState(prev => ({ ...prev, isLoading: false })); const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
return true; setAuthState(prev => ({
} catch (error) { ...prev,
const errorMessage = error instanceof Error ? error.message : 'Password reset request failed'; isLoading: false,
setAuthState(prev => ({ error: errorMessage,
...prev, }));
isLoading: false, return false;
error: errorMessage }
})); },
return false; [apiClient, setPendingVerificationEmail]
} );
}, [apiClient]);
const requestPasswordReset = useCallback(
async (email: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
await apiClient.requestPasswordReset({ email });
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Password reset request failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return false;
}
},
[apiClient]
);
const refreshAuth = useCallback(async (): Promise<boolean> => { const refreshAuth = useCallback(async (): Promise<boolean> => {
const stored = getStoredAuthData(); const stored = getStoredAuthData();
@ -656,24 +697,24 @@ function useAuthenticationLogic() {
} }
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;
} else { } else {
await logout(); await logout();
@ -682,36 +723,39 @@ 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 = error instanceof Error ? error.message : 'Failed to resend MFA code';
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isLoading: false, isLoading: false,
error: errorMessage error: errorMessage,
})); }));
return false; return false;
} }
}, [apiClient]); },
[apiClient]
);
// Clear MFA state // Clear MFA state
const clearMFA = useCallback(() => { const clearMFA = useCallback(() => {
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
mfaResponse: null, mfaResponse: null,
error: null error: null,
})); }));
}, []); }, []);
@ -732,7 +776,7 @@ function useAuthenticationLogic() {
refreshAuth, refreshAuth,
updateUserData, updateUserData,
convertGuestToUser, convertGuestToUser,
createGuestSession createGuestSession,
}; };
} }
@ -744,12 +788,8 @@ const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | nu
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() {
@ -771,24 +811,24 @@ interface ProtectedRouteProps {
allowGuests?: boolean; allowGuests?: boolean;
} }
function ProtectedRoute({ 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();
// Show loading while checking stored tokens // Show loading while checking stored tokens
if (isInitializing) { if (isInitializing) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
// Not authenticated at all (shouldn't happen with guest sessions) // Not authenticated at all (shouldn't happen with guest sessions)
if (!isAuthenticated) { if (!isAuthenticated) {
return <>{fallback}</>; return <>{fallback}</>;
} }
// Guest access control // Guest access control
if (isGuest && !allowGuests) { if (isGuest && !allowGuests) {
return <div>Please create an account or log in to access this page.</div>; return <div>Please create an account or log in to access this page.</div>;
@ -798,7 +838,7 @@ function ProtectedRoute({
if (requiredUserType && !isGuest && user?.userType !== requiredUserType) { if (requiredUserType && !isGuest && user?.userType !== requiredUserType) {
return <div>Access denied. Required user type: {requiredUserType}</div>; return <div>Access denied. Required user type: {requiredUserType}</div>;
} }
return <>{children}</>; return <>{children}</>;
} }
@ -808,14 +848,9 @@ 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

@ -17,7 +17,7 @@ const STORAGE_KEYS = {
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;
// ============================ // ============================
@ -40,7 +40,7 @@ export interface AppState {
selectedJob: Types.Job | null; selectedJob: Types.Job | null;
selectedEmployer: Types.Employer | null; selectedEmployer: Types.Employer | null;
selectedResume: Types.Resume | null; selectedResume: Types.Resume | null;
setSelectedResume: (resume: Types.Resume | null) => void; setSelectedResume: (resume: Types.Resume | null) => void;
routeState: RouteState; routeState: RouteState;
isInitializing: boolean; isInitializing: boolean;
} }
@ -118,7 +118,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),
}; };
} }
@ -227,7 +227,6 @@ 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 {
@ -352,7 +351,7 @@ export function useAppStateLogic(): AppStateContextType {
lastRoute: null, lastRoute: null,
activeTab: null, activeTab: null,
appliedFilters: {}, appliedFilters: {},
sidebarCollapsed: false sidebarCollapsed: false,
}; };
setRouteStateState(clearedState); setRouteStateState(clearedState);
@ -388,7 +387,7 @@ export function useAppStateLogic(): AppStateContextType {
setActiveTab, setActiveTab,
setFilters, setFilters,
setSidebarCollapsed, setSidebarCollapsed,
clearRouteState clearRouteState,
}; };
} }
@ -403,10 +402,13 @@ 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}>
{children} {children}
@ -454,7 +456,7 @@ export function useRouteState() {
setFilters, setFilters,
setSidebarCollapsed, setSidebarCollapsed,
restoreLastRoute, restoreLastRoute,
clearRouteState clearRouteState,
} = useAppState(); } = useAppState();
return { return {
@ -463,11 +465,11 @@ export function useRouteState() {
setFilters, setFilters,
setSidebarCollapsed, setSidebarCollapsed,
restoreLastRoute, restoreLastRoute,
clearRouteState clearRouteState,
}; };
} }
export function useAppInitializing() { export function useAppInitializing() {
const { isInitializing } = useAppState(); const { isInitializing } = useAppState();
return isInitializing; return isInitializing;
} }

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,72 @@ 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;
@ -178,34 +189,38 @@ const useAutoScrollToBottom = (
const handleScroll = (ev: Event, pause?: number) => { const handleScroll = (ev: Event, pause?: number) => {
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 =
currentScrollTop <= lastScrollTop.current || pause ? true : false;
debug && console.debug(`Scrolling up or paused: ${isUserScrollingUpRef.current} ${pause}`); 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');
// Delay scroll check to ensure DOM updates // Delay scroll check to ensure DOM updates
setTimeout(() => { setTimeout(() => {
console.log("scrolling for handlePaste"); console.log('scrolling for handlePaste');
requestAnimationFrame(() => checkAndScrollToBottom(true)); requestAnimationFrame(() => checkAndScrollToBottom(true));
}, 100); }, 100);
}; };
@ -236,7 +251,4 @@ const useAutoScrollToBottom = (
return containerRef; return containerRef;
}; };
export { export { useResizeObserverAndMutationObserver, useAutoScrollToBottom };
useResizeObserverAndMutationObserver,
useAutoScrollToBottom
}

View File

@ -2,15 +2,13 @@ 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(
<React.StrictMode> <React.StrictMode>

View File

@ -1,14 +1,14 @@
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,
Typography, Typography,
Paper, Paper,
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';
@ -26,10 +26,10 @@ interface BetaPageProps {
const BetaPage: React.FC<BetaPageProps> = ({ const BetaPage: React.FC<BetaPageProps> = ({
children, children,
title = "Coming Soon", title = 'Coming Soon',
subtitle = "This page is currently in development", subtitle = 'This page is currently in development',
returnPath = "/", returnPath = '/',
returnLabel = "Return to Backstory", returnLabel = 'Return to Backstory',
onReturn, onReturn,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
@ -38,20 +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
const newSparkles = Array.from({ length: 30 }).map((_, index) => ({ const newSparkles = Array.from({ length: 30 }).map((_, index) => ({
@ -63,14 +71,14 @@ const BetaPage: React.FC<BetaPageProps> = ({
duration: 2 + Math.random() * 4, duration: 2 + Math.random() * 4,
delay: Math.random() * 3, delay: Math.random() * 3,
})); }));
setSparkles(newSparkles); setSparkles(newSparkles);
// Show main sparkle effect after a short delay // Show main sparkle effect after a short delay
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowSparkle(true); setShowSparkle(true);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
@ -86,7 +94,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
<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,
@ -95,8 +103,18 @@ const BetaPage: React.FC<BetaPageProps> = ({
}} }}
> >
{/* Animated background elements */} {/* Animated background elements */}
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 0, overflow: 'hidden' }}> <Box
{sparkles.map((sparkle) => ( sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 0,
overflow: 'hidden',
}}
>
{sparkles.map(sparkle => (
<Box <Box
key={sparkle.id} key={sparkle.id}
sx={{ sx={{
@ -107,7 +125,10 @@ const BetaPage: React.FC<BetaPageProps> = ({
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`,
}} }}
/> />
@ -116,7 +137,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
<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"
@ -130,18 +151,13 @@ const BetaPage: React.FC<BetaPageProps> = ({
> >
{title} {title}
</Typography> </Typography>
<Typography <Typography variant="h5" component="h2" color="textSecondary" sx={{ mb: 6 }}>
variant="h5"
component="h2"
color="textSecondary"
sx={{ mb: 6 }}
>
{subtitle} {subtitle}
</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={{
@ -171,19 +187,19 @@ const BetaPage: React.FC<BetaPageProps> = ({
> >
<ConstructionIcon fontSize="large" /> <ConstructionIcon fontSize="large" />
</Box> </Box>
{/* 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>
We're working hard to bring you this exciting new feature! We're working hard to bring you this exciting new feature!
@ -193,9 +209,23 @@ 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
@ -210,7 +240,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
boxShadow: `0 4px 14px ${alpha(theme.palette.primary.main, 0.4)}`, boxShadow: `0 4px 14px ${alpha(theme.palette.primary.main, 0.4)}`,
'&:hover': { '&:hover': {
boxShadow: `0 6px 20px ${alpha(theme.palette.primary.main, 0.6)}`, boxShadow: `0 6px 20px ${alpha(theme.palette.primary.main, 0.6)}`,
} },
}} }}
> >
{returnLabel} {returnLabel}
@ -250,7 +280,10 @@ const BetaPage: React.FC<BetaPageProps> = ({
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': {
@ -270,6 +303,4 @@ const BetaPage: React.FC<BetaPageProps> = ({
); );
}; };
export { export { BetaPage };
BetaPage
}

View File

@ -1,18 +1,15 @@
import React, { forwardRef, useState, useEffect, useRef } from 'react'; import React, { forwardRef, useState, useEffect, useRef } from 'react';
import { import { Box, Paper, Button, Divider, useTheme, useMediaQuery, Tooltip } from '@mui/material';
Box, import { Send as SendIcon } from '@mui/icons-material';
Paper,
Button,
Divider,
useTheme,
useMediaQuery,
Tooltip,
} from '@mui/material';
import {
Send as SendIcon
} from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { ChatMessage, ChatSession, ChatMessageUser, ChatMessageError, ChatMessageStreaming, ChatMessageStatus } from 'types/types'; import {
ChatMessage,
ChatSession,
ChatMessageUser,
ChatMessageError,
ChatMessageStreaming,
ChatMessageStatus,
} from 'types/types';
import { ConversationHandle } from 'components/Conversation'; import { ConversationHandle } from 'components/Conversation';
import { BackstoryPageProps } from 'components/BackstoryTab'; import { BackstoryPageProps } from 'components/BackstoryTab';
import { Message } from 'components/Message'; import { Message } from 'components/Message';
@ -27,206 +24,251 @@ import { CandidatePicker } from 'components/ui/CandidatePicker';
import { Scrollable } from 'components/Scrollable'; 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 {
const result = await apiClient.getChatMessages(chatSession.id);
const chatMessages: ChatMessage[] = result.data;
setMessages(chatMessages);
setProcessingMessage(null);
setStreamingMessage(null);
console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages);
} catch (error) {
console.error('Failed to load messages:', error);
}
};
const onDelete = async (session: ChatSession) => { try {
if (!session.id) { const result = await apiClient.getChatMessages(chatSession.id);
return; const chatMessages: ChatMessage[] = result.data;
} setMessages(chatMessages);
try { setProcessingMessage(null);
await apiClient.resetChatSession(session.id); setStreamingMessage(null);
// If we're deleting the currently selected session, clear it console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages);
setMessages([]); } catch (error) {
setSnack('Session reset succeeded', 'success'); console.error('Failed to load messages:', error);
} 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 +279,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 +297,54 @@ 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 => <BackstoryQuery question={q} />)}
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}> {/* Fixed Message Input */}
<DeleteConfirmation <Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}>
onDelete={() => { chatSession && onDelete(chatSession); }} <DeleteConfirmation
disabled={!chatSession} onDelete={() => {
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content" }} chatSession && onDelete(chatSession);
action="reset" }}
label="chat session" disabled={!chatSession}
title="Reset Chat Session" sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }}
message={`Are you sure you want to reset the session? This action cannot be undone.`} action="reset"
/> label="chat session"
<BackstoryTextField title="Reset Chat Session"
placeholder="Type your message about the candidate..." message={`Are you sure you want to reset the session? This action cannot be undone.`}
ref={backstoryTextRef} />
onEnter={sendMessage} <BackstoryTextField
disabled={streaming || loading} placeholder="Type your message about the candidate..."
/> ref={backstoryTextRef}
<Tooltip title="Send"> onEnter={sendMessage}
<span style={{ minWidth: 'auto', maxHeight: "min-content", alignSelf: "center" }} disabled={streaming || loading}
> />
<Button <Tooltip title="Send">
variant="contained" <span
onClick={() => { sendMessage((backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ""); }} style={{
disabled={streaming || loading} minWidth: 'auto',
> maxHeight: 'min-content',
<SendIcon /> alignSelf: 'center',
}}
</Button> >
</span> <Button
</Tooltip> 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

@ -18,7 +18,7 @@ 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';
@ -61,48 +61,50 @@ 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>
{isMobile && onClose && ( {isMobile && onClose && (
<IconButton <IconButton onClick={onClose} size="small" aria-label="Close navigation">
onClick={onClose}
size="small"
aria-label="Close navigation"
>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
)} )}
</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
@ -110,7 +112,7 @@ const Sidebar: React.FC<{
slotProps={{ slotProps={{
primary: { primary: {
fontWeight: currentPage === doc.route ? 'medium' : 'regular', fontWeight: currentPage === doc.route ? 'medium' : 'regular',
} },
}} }}
/> />
</ListItemButton> </ListItemButton>
@ -128,7 +130,7 @@ const getDocumentIcon = (title: string): React.ReactNode => {
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 +139,90 @@ 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,10 +231,10 @@ 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();
@ -200,10 +266,10 @@ const DocsPage = (props: BackstoryPageProps) => {
// Handle document navigation // Handle document navigation
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;
} }
@ -230,8 +296,8 @@ 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);
@ -240,13 +306,22 @@ const DocsPage = (props: BackstoryPageProps) => {
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>
); );
@ -256,18 +331,22 @@ const DocsPage = (props: BackstoryPageProps) => {
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 (
<Paper sx={{ m: 0, p: 1 }}>
<BackstoryThemeVisualizerPage />
</Paper>
);
case 'app-analysis': case 'app-analysis':
return (<BackstoryAppAnalysisPage />); return <BackstoryAppAnalysisPage />;
case 'ui-mockup': case 'ui-mockup':
return (<MockupPage />); return <MockupPage />;
case 'user-management': case 'user-management':
return (<UserManagement />); 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 +356,50 @@ 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 sx={{ minWidth: '164px' }} size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<CardActionArea onClick={() => doc.route ? onDocumentExpand(doc.route, true) : navigate('/')}> <Card sx={{ minHeight: '180px' }}>
<CardContent sx={{ display: "flex", flexDirection: "column", m: 0, p: 1 }}> <CardActionArea
<Box sx={{ display: 'flex', flexDirection: "row", gap: 1, verticalAlign: 'top' }}> onClick={() =>
{getDocumentIcon(doc.title)} doc.route ? onDocumentExpand(doc.route, true) : navigate('/')
<Typography variant="h3" sx={{ m: "0 !important" }}>{doc.title}</Typography> }
</Box> >
<Typography variant="body2" color="text.secondary"> <CardContent
{doc.description} sx={{
</Typography> display: 'flex',
</CardContent> flexDirection: 'column',
</CardActionArea> m: 0,
</Card> p: 1,
</Grid> }}
) >
<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>
@ -318,22 +419,17 @@ 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"
> >
<Toolbar> <Toolbar>
<IconButton <IconButton aria-label="open drawer" edge="start" onClick={toggleDrawer} sx={{ mr: 2 }}>
aria-label="open drawer"
edge="start"
onClick={toggleDrawer}
sx={{ mr: 2 }}
>
<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>
</AppBar> </AppBar>
@ -344,7 +440,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) */}
@ -360,7 +456,7 @@ const DocsPage = (props: BackstoryPageProps) => {
display: { xs: 'block', md: 'none' }, display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': { '& .MuiDrawer-paper': {
boxSizing: 'border-box', boxSizing: 'border-box',
width: drawerWidth width: drawerWidth,
}, },
}} }}
> >
@ -381,16 +477,12 @@ const DocsPage = (props: BackstoryPageProps) => {
boxSizing: 'border-box', boxSizing: 'border-box',
width: drawerWidth, width: drawerWidth,
position: 'relative', position: 'relative',
height: '100%' height: '100%',
}, },
}} }}
open open
> >
<Sidebar <Sidebar currentPage={page} onDocumentSelect={onDocumentExpand} isMobile={false} />
currentPage={page}
onDocumentSelect={onDocumentExpand}
isMobile={false}
/>
</Drawer> </Drawer>
)} )}
</Box> </Box>
@ -404,7 +496,7 @@ const DocsPage = (props: BackstoryPageProps) => {
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()}
@ -413,4 +505,4 @@ const DocsPage = (props: BackstoryPageProps) => {
); );
}; };
export { DocsPage }; export { DocsPage };

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

@ -17,298 +17,375 @@ import { StyledMarkdown } from 'components/StyledMarkdown';
import { Scrollable } from '../components/Scrollable'; import { Scrollable } from '../components/Scrollable';
import { Pulse } from 'components/Pulse'; import { Pulse } from 'components/Pulse';
import { StreamingResponse } from 'services/api-client'; import { StreamingResponse } from 'services/api-client';
import { ChatMessage, ChatMessageUser, ChatSession, CandidateAI, ChatMessageStatus, ChatMessageError } from 'types/types'; import {
ChatMessage,
ChatMessageUser,
ChatSession,
CandidateAI,
ChatMessageStatus,
ChatMessageError,
} from 'types/types';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { Message } from 'components/Message'; import { Message } from 'components/Message';
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: "user", metadata: null as any status: 'done',
type: 'text',
sessionId: '',
timestamp: new Date(),
content: '',
role: 'user',
metadata: null as any,
}; };
const GenerateCandidate = (props: BackstoryElementProps) => { const GenerateCandidate = (props: BackstoryElementProps) => {
const { apiClient, user } = useAuth(); const { apiClient, user } = useAuth();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [processingMessage, setProcessingMessage] = useState<ChatMessage | null>(null); const [processingMessage, setProcessingMessage] = useState<ChatMessage | null>(null);
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [generatedUser, setGeneratedUser] = useState<CandidateAI | null>(null); const [generatedUser, setGeneratedUser] = useState<CandidateAI | null>(null);
const [prompt, setPrompt] = useState<string>(''); const [prompt, setPrompt] = useState<string>('');
const [resume, setResume] = useState<string | null>(null); const [resume, setResume] = useState<string | null>(null);
const [canGenImage, setCanGenImage] = useState<boolean>(false); const [canGenImage, setCanGenImage] = useState<boolean>(false);
const [timestamp, setTimestamp] = useState<string>(''); const [timestamp, setTimestamp] = useState<string>('');
const [shouldGenerateProfile, setShouldGenerateProfile] = useState<boolean>(false); const [shouldGenerateProfile, setShouldGenerateProfile] = useState<boolean>(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null); const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
// 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>);
} }
return ( try {
<Box className="GenerateCandidate" sx={{ setLoading(true);
display: "flex", apiClient
flexDirection: "column", .getOrCreateChatSession(
flexGrow: 1, 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>;
}
return (
<Box
className="GenerateCandidate"
sx={{
display: 'flex',
flexDirection: 'column',
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 && <CandidateInfo candidate={generatedUser} sx={{ flexShrink: 1 }} />}
sx={{flexShrink: 1}}/> {prompt && <Quote quote={prompt} />}
} {processing && (
{ prompt && <Box
<Quote quote={prompt}/> sx={{
} display: 'flex',
{processing && flexDirection: 'column',
<Box sx={{ alignItems: 'center',
display: "flex", justifyContent: 'center',
flexDirection: "column", m: 2,
alignItems: "center", }}
justifyContent: "center", >
m: 2, {processingMessage && chatSession && (
}}> <Message message={processingMessage} {...{ chatSession }} />
{processingMessage && chatSession && <Message message={processingMessage} {...{ chatSession }} />} )}
<PropagateLoader <PropagateLoader
size="10px" size="10px"
loading={processing} loading={processing}
aria-label="Loading Spinner" aria-label="Loading Spinner"
data-testid="loader" data-testid="loader"
/> />
</Box>
}
<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"}> }}
<span style={{ display: "flex", flexGrow: 1 }}> >
<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 <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

@ -65,11 +65,12 @@ const HeroButton = (props: HeroButtonProps) => {
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;
@ -84,10 +85,12 @@ const ActionButton = (props: ActionButtonProps) => {
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,
@ -106,7 +109,7 @@ 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;
@ -140,24 +143,27 @@ 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"
@ -166,19 +172,17 @@ const HomePage = () => {
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
@ -187,14 +191,19 @@ const HomePage = () => {
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}
@ -224,33 +233,42 @@ 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,20 +277,22 @@ 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">
@ -281,20 +301,22 @@ 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',
}}
>
3 3
</Box> </Box>
<Typography variant="body1"> <Typography variant="body1">
@ -303,105 +325,112 @@ 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>
@ -419,7 +448,16 @@ const HomePage = () => {
</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 +468,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 +488,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 +508,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,59 +533,52 @@ 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" align="center" sx={{ mb: 6, maxWidth: 800, mx: 'auto' }}>
variant="body1" See how Backstory has transformed the hiring process for both candidates and employers.
align="center" </Typography>
sx={{ mb: 6, maxWidth: 800, mx: 'auto' }}
>
See how Backstory has transformed the hiring process for both 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' }} spacing={2} justifyContent="center">
direction={{ xs: 'column', sm: 'row' }} <HeroButton variant="contained" size="large">
spacing={2}
justifyContent="center"
>
<HeroButton
variant="contained"
size="large"
>
Sign Up as Candidate Sign Up as Candidate
</HeroButton> </HeroButton>
<HeroButton <HeroButton
@ -529,7 +587,7 @@ const HomePage = () => {
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 +596,8 @@ const HomePage = () => {
</Box> </Box>
</Container> </Container>
</Box> </Box>
</Box> </Box>
); );
}; };
export { export { HomePage };
HomePage
};

View File

@ -1,22 +1,22 @@
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';
@ -37,232 +37,255 @@ 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 sx={{ ml: { xs: 0, md: 3 }, textAlign: { xs: 'center', md: 'left' } }}>
<Typography variant="h3" component="h2" sx={{ color: 'primary.main', mb: 1 }}> <Typography variant="h3" component="h2" sx={{ color: 'primary.main', mb: 1 }}>
{title} {title}
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', justifyContent: { xs: 'center', md: 'flex-start' } }}> <Box
{icon} sx={{
<Typography variant="body2" color="text.secondary"> display: 'flex',
{subtitle} gap: 1,
</Typography> alignItems: 'center',
</Box> justifyContent: { xs: 'center', md: 'flex-start' },
</Box> }}
</Box> >
{description.map((paragraph, index) => ( {icon}
<Typography key={index} variant="body1" paragraph> <Typography variant="body2" color="text.secondary">
{paragraph} {subtitle}
</Typography> </Typography>
))} </Box>
{note && ( </Box>
<Paper sx={{ p: 2, backgroundColor: 'action.hover', border: '1px solid', borderColor: 'action.active', mt: 2 }}> </Box>
<Typography variant="body2" sx={{ fontStyle: 'italic' }}> {description.map((paragraph, index) => (
<strong>Note:</strong> {note} <Typography key={index} variant="body1" paragraph>
</Typography> {paragraph}
</Paper> </Typography>
)} ))}
{success && ( {note && (
<Paper sx={{ p: 2, backgroundColor: 'secondary.main', color: 'secondary.contrastText', mt: 2 }}> <Paper
<Typography variant="body1" sx={{ fontWeight: 'bold' }}> sx={{
🎉 {success} p: 2,
</Typography> backgroundColor: 'action.hover',
</Paper> border: '1px solid',
)} borderColor: 'action.active',
</Grid> 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 through interactive
<HeroButton Q&A and tailored resumes
variant="contained" </Typography>
size="large" <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
path="/login/register" <HeroButton variant="contained" size="large" path="/login/register">
> Get Started as Candidate
Get Started as Candidate </HeroButton>
</HeroButton> {/* <HeroButton
{/* <HeroButton
variant="outlined" variant="outlined"
size="large" size="large"
sx={{ sx={{
@ -273,218 +296,243 @@ 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,12 +1,12 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Box, Box,
Stepper, Stepper,
Step, Step,
StepLabel, StepLabel,
Button, Button,
Typography, Typography,
Paper, Paper,
useTheme, useTheme,
Snackbar, Snackbar,
Alert, Alert,
@ -16,15 +16,12 @@ import {
useMediaQuery, useMediaQuery,
Divider, Divider,
} from '@mui/material'; } from '@mui/material';
import { import { Add, WorkOutline } from '@mui/icons-material';
Add,
WorkOutline,
} from '@mui/icons-material';
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 AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from '@mui/icons-material/Assessment';
import { JobMatchAnalysis } from 'components/JobMatchAnalysis'; 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';
@ -43,10 +40,12 @@ 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',
}} }}
> >
<WorkOutline sx={{ fontSize: 24 }} /> <WorkOutline sx={{ fontSize: 24 }} />
@ -71,7 +70,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 +78,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,
@ -92,13 +91,23 @@ const initialState: AnalysisState = {
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) => {
@ -115,23 +124,30 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
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(f => !(analysisState as any)[f]);
}, [analysisState]); 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;
@ -153,7 +169,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.scrollTo({ scrollRef.current.scrollTo({
top: 0, top: 0,
behavior: "smooth", behavior: 'smooth',
}); });
} }
}, [setCanAdvance, analysisState, activeStep]); }, [setCanAdvance, analysisState, activeStep]);
@ -167,9 +183,9 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setError(`${capitalize(missing)} is necessary before continuing.`); setError(`${capitalize(missing)} is necessary before continuing.`);
return missing; return missing;
} }
if (activeStep.index < steps.length - 1) { if (activeStep.index < steps.length - 1) {
setActiveStep((prevActiveStep) => steps[prevActiveStep.index + 1]); setActiveStep(prevActiveStep => steps[prevActiveStep.index + 1]);
} }
}; };
@ -178,7 +194,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
return; return;
} }
setActiveStep((prevActiveStep) => steps[prevActiveStep.index - 1]); setActiveStep(prevActiveStep => steps[prevActiveStep.index - 1]);
}; };
const moveToStep = (step: number) => { const moveToStep = (step: number) => {
@ -188,7 +204,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 +214,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 +224,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 +237,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,16 +271,25 @@ 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 = () => {
@ -278,43 +300,56 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
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,82 +357,92 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
</Step> </Step>
))} ))}
</Stepper> </Stepper>
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}> <Box sx={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row' }}>
{analysisState && analysisState.job && {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 && <Box sx={{ display: 'flex', borderBottom: '1px solid lightgrey' }} />}
{!isMobile && <Box sx={{ display: "flex", borderLeft: "1px solid lightgrey" }} />} {!isMobile && <Box sx={{ display: 'flex', borderLeft: '1px solid lightgrey' }} />}
{analysisState && analysisState.candidate && {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
ref={scrollRef} ref={scrollRef}
sx={{ sx={{
position: "relative", position: 'relative',
maxHeight: "100%", maxHeight: '100%',
width: "100%", width: '100%',
display: "flex", flexGrow: 1, display: 'flex',
flex: 1, /* Take remaining space in some-container */ flexGrow: 1,
overflowY: "auto", /* Scroll if content overflows */ flex: 1 /* Take remaining space in some-container */,
}}> overflowY: 'auto' /* Scroll if content overflows */,
}}
>
{activeStep.label === 'job-selection' && renderJobDescription()} {activeStep.label === 'job-selection' && renderJobDescription()}
{activeStep.label === 'select-candidate' && renderCandidateSelection()} {activeStep.label === 'select-candidate' && renderCandidateSelection()}
{activeStep.label === 'job-analysis' && renderAnalysis()} {activeStep.label === 'job-analysis' && renderAnalysis()}
{activeStep.label === 'generated-resume' && renderResume()} {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)} onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
> >
<Alert onClose={() => setError(null)} severity="error" sx={{ width: '100%' }}> <Alert onClose={() => setError(null)} severity="error" sx={{ width: '100%' }}>
{error} {error}
</Alert> </Alert>
</Snackbar> </Snackbar>
</Box>); </Box>
);
}; };
export { JobAnalysisPage }; export { JobAnalysisPage };

View File

@ -4,21 +4,28 @@ 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

@ -12,10 +12,7 @@ import {
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from '@mui/material'; } from '@mui/material';
import { import { Person, PersonAdd } from '@mui/icons-material';
Person,
PersonAdd,
} from '@mui/icons-material';
import 'react-phone-number-input/style.css'; import 'react-phone-number-input/style.css';
import './LoginPage.css'; import './LoginPage.css';
@ -24,8 +21,8 @@ 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';
@ -37,11 +34,12 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
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(() => {
@ -53,7 +51,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
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');
setTimeout(() => { setTimeout(() => {
setErrorMessage(null); setErrorMessage(null);
setLoading(false); setLoading(false);
@ -75,57 +73,53 @@ 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>
); );
}; };
export { LoginPage }; export { LoginPage };

View File

@ -13,51 +13,55 @@ 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 candidate={candidate} action="Chat with Backstory AI about " />
<Conversation <Conversation
ref={ref} ref={ref}
{...{ {...{
multiline: true, multiline: true,
type: "chat", 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

@ -8,7 +8,7 @@ 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} />;
@ -16,6 +16,4 @@ const VectorVisualizerPage: React.FC<VectorVisualizerProps> = (props: VectorVisu
export type { VectorVisualizerProps }; export type { VectorVisualizerProps };
export { export { VectorVisualizerPage };
VectorVisualizerPage
};

View File

@ -1,13 +1,5 @@
import React from 'react'; import React from 'react';
import { import { Box, Card, CardContent, Typography, Button, LinearProgress, Stack } from '@mui/material';
Box,
Card,
CardContent,
Typography,
Button,
LinearProgress,
Stack
} from '@mui/material';
import { import {
Add as AddIcon, Add as AddIcon,
Visibility as VisibilityIcon, Visibility as VisibilityIcon,
@ -23,8 +15,7 @@ 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,178 +24,170 @@ 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(
`The page you were on is only available for candidates (you are a ${user.userType}`,
'warning'
);
navigate('/'); navigate('/');
return (<></>); 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');
}} }}
/> >
</Box> Complete Your Profile
</Button>
<Button
variant="contained"
color="primary"
sx={{ mt: 1 }}
onClick={(e) => { e.stopPropagation(); navigate('/candidate/profile'); }}
>
Complete Your Profile
</Button>
</Box>
{/* 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> </Box>
{/* Bottom Row */} {/* Cards Grid */}
<Box sx={{ display: 'flex', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Complete Your Backstory Card */} {/* Top Row */}
<Card sx={{ flex: 1, minHeight: 200 }}> <Box sx={{ display: 'flex', gap: 3 }}>
<CardContent> {/* Resume Builder Card */}
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}> <Card sx={{ flex: 1, minHeight: 200 }}>
Complete Your Backstory <CardContent>
</Typography> <Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Resume Builder
<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
variant="outlined"
startIcon={<EditIcon />}
fullWidth
>
Edit Backstory
</Button>
</CardContent>
</Card>
{/* Improvement Suggestions Card */} <Typography variant="body2" sx={{ mb: 1, color: '#666' }}>
<Card sx={{ flex: 1, minHeight: 200 }}> 3 custom resumes
<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 variant="body2" sx={{ mb: 3, color: '#666' }}>
Last created: May 15, 2025
</Typography> </Typography>
</Stack>
<Button variant="outlined" startIcon={<AddIcon />} fullWidth>
<Button Create New
variant="outlined" </Button>
startIcon={<TipsIcon />} </CardContent>
fullWidth </Card>
>
View All Tips {/* Recent Activity Card */}
</Button> <Card sx={{ flex: 1, minHeight: 200 }}>
</CardContent> <CardContent>
</Card> <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>
</> </>
); );
}; };
export { CandidateDashboard }; export { CandidateDashboard };

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ 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';
@ -49,18 +49,22 @@ const VisuallyHiddenInput = styled('input')({
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: '',
@ -87,7 +91,7 @@ const CreateProfilePage: React.FC = () => {
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files[0]) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = event => {
if (event.target?.result) { if (event.target?.result) {
setProfileImage(event.target.result.toString()); setProfileImage(event.target.result.toString());
} }
@ -103,7 +107,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',
}); });
} }
}; };
@ -113,27 +117,27 @@ const CreateProfilePage: React.FC = () => {
if (activeStep === steps.length - 1) { if (activeStep === steps.length - 1) {
handleSubmit(); handleSubmit();
} else { } else {
setActiveStep((prevStep) => prevStep + 1); setActiveStep(prevStep => prevStep + 1);
} }
}; };
const handleBack = () => { const handleBack = () => {
setActiveStep((prevStep) => prevStep - 1); setActiveStep(prevStep => prevStep - 1);
}; };
// Form submission // Form submission
const handleSubmit = async () => { const handleSubmit = async () => {
setLoading(true); setLoading(true);
// Simulate API call with timeout // Simulate API call with timeout
setTimeout(() => { setTimeout(() => {
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
// history.push('/dashboard'); // history.push('/dashboard');
}, 2000); }, 2000);
@ -143,9 +147,11 @@ 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:
@ -161,35 +167,33 @@ 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 color="primary" aria-label="upload picture" component="label">
color="primary"
aria-label="upload picture"
component="label"
>
<PhotoCamera /> <PhotoCamera />
<VisuallyHiddenInput <VisuallyHiddenInput type="file" accept="image/*" onChange={handleImageUpload} />
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
</IconButton> </IconButton>
<Typography variant="caption" color="textSecondary"> <Typography variant="caption" color="textSecondary">
Add profile photo Add profile photo
</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 +204,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 +215,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 +227,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 +242,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 +253,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 +264,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,10 +282,10 @@ 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 better understand
(Supported formats: .pdf, .docx, .md, and .txt) 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
@ -291,13 +295,13 @@ const CreateProfilePage: React.FC = () => {
sx={{ mb: 2 }} sx={{ mb: 2 }}
> >
Upload Resume Upload Resume
<VisuallyHiddenInput <VisuallyHiddenInput
type="file" type="file"
accept=".pdf,.docx,.txt,.md" accept=".pdf,.docx,.txt,.md"
onChange={handleResumeUpload} onChange={handleResumeUpload}
/> />
</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}
@ -314,41 +318,35 @@ const CreateProfilePage: React.FC = () => {
return ( return (
<Container component="main"> <Container component="main">
<Paper <Paper
elevation={3} elevation={3}
sx={{ sx={{
p: { xs: 2, sm: 4 }, p: { xs: 2, sm: 4 },
mt: { xs: 2, sm: 4 }, mt: { xs: 2, sm: 4 },
mb: { xs: 2, sm: 4 }, mb: { xs: 2, sm: 4 },
}} }}
> >
<Typography component="h1" variant="h4" align="center" gutterBottom> <Typography component="h1" variant="h4" align="center" gutterBottom>
Create Your Profile Create Your Profile
</Typography> </Typography>
<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 => (
<Step key={label}> <Step key={label}>
<StepLabel>{label}</StepLabel> <StepLabel>{label}</StepLabel>
</Step> </Step>
))} ))}
</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} onClick={handleBack} variant="outlined">
disabled={activeStep === 0}
onClick={handleBack}
variant="outlined"
>
Back Back
</Button> </Button>
<Button <Button
@ -367,8 +365,8 @@ const CreateProfilePage: React.FC = () => {
autoHideDuration={6000} autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })} onClose={() => setSnackbar({ ...snackbar, open: false })}
> >
<Alert <Alert
onClose={() => setSnackbar({ ...snackbar, open: false })} onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity} severity={snackbar.severity}
sx={{ width: '100%' }} sx={{ width: '100%' }}
> >
@ -379,4 +377,4 @@ const CreateProfilePage: React.FC = () => {
); );
}; };
export { CreateProfilePage }; export { CreateProfilePage };

View File

@ -21,7 +21,7 @@ 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';
@ -40,7 +40,7 @@ const CandidateRegistrationForm = () => {
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'));
@ -49,7 +49,7 @@ const CandidateRegistrationForm = () => {
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [showSuccess, setShowSuccess] = useState(false); const [showSuccess, setShowSuccess] = useState(false);
const [registrationResult, setRegistrationResult] = useState<any>(null); const [registrationResult, setRegistrationResult] = useState<any>(null);
// Password visibility states // Password visibility states
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
@ -108,7 +108,7 @@ const CandidateRegistrationForm = () => {
const validatePassword = (password: string): string[] => { const validatePassword = (password: string): string[] => {
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');
} }
@ -130,7 +130,7 @@ const CandidateRegistrationForm = () => {
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,15 +150,14 @@ 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
apiClient.setPendingEmailVerification(formData.email); apiClient.setPendingEmailVerification(formData.email);
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')) {
@ -167,7 +166,9 @@ const CandidateRegistrationForm = () => {
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,11 +181,11 @@ 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 };
@ -209,7 +210,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 +221,7 @@ 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 +233,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 +243,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,10 +256,10 @@ 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'}
/> />
<Box> <Box>
@ -267,7 +268,7 @@ const CandidateRegistrationForm = () => {
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}
@ -278,7 +279,7 @@ const CandidateRegistrationForm = () => {
<IconButton <IconButton
aria-label="toggle password visibility" aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
onMouseDown={(e) => e.preventDefault()} onMouseDown={e => e.preventDefault()}
edge="end" edge="end"
> >
{showPassword ? <VisibilityOff /> : <Visibility />} {showPassword ? <VisibilityOff /> : <Visibility />}
@ -295,7 +296,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>
@ -307,7 +312,7 @@ const CandidateRegistrationForm = () => {
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}
@ -318,7 +323,7 @@ const CandidateRegistrationForm = () => {
<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 />}
@ -328,11 +333,7 @@ const CandidateRegistrationForm = () => {
}} }}
/> />
{errors.general && ( {errors.general && <Alert severity="error">{errors.general}</Alert>}
<Alert severity="error">
{errors.general}
</Alert>
)}
<Button <Button
fullWidth fullWidth
@ -355,9 +356,12 @@ const CandidateRegistrationForm = () => {
<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
@ -389,14 +393,14 @@ const EmployerRegistrationForm = () => {
companySize: '', companySize: '',
companyDescription: '', companyDescription: '',
websiteUrl: '', websiteUrl: '',
phone: '' phone: '',
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [showSuccess, setShowSuccess] = useState(false); const [showSuccess, setShowSuccess] = useState(false);
const [registrationResult, setRegistrationResult] = useState<any>(null); const [registrationResult, setRegistrationResult] = useState<any>(null);
// Password visibility states // Password visibility states
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
@ -404,13 +408,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 = () => {
@ -477,7 +494,7 @@ const EmployerRegistrationForm = () => {
const validatePassword = (password: string): string[] => { const validatePassword = (password: string): string[] => {
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');
} }
@ -499,7 +516,7 @@ const EmployerRegistrationForm = () => {
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,15 +539,14 @@ 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
apiClient.setPendingEmailVerification(formData.email); apiClient.setPendingEmailVerification(formData.email);
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')) {
@ -539,7 +555,9 @@ const EmployerRegistrationForm = () => {
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);
@ -564,7 +582,7 @@ const EmployerRegistrationForm = () => {
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Account Information Account Information
</Typography> </Typography>
<Stack spacing={3}> <Stack spacing={3}>
<Stack direction="row" spacing={2}> <Stack direction="row" spacing={2}>
<TextField <TextField
@ -572,7 +590,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 +600,7 @@ 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}
@ -596,7 +614,7 @@ const EmployerRegistrationForm = () => {
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}
@ -607,7 +625,7 @@ const EmployerRegistrationForm = () => {
<IconButton <IconButton
aria-label="toggle password visibility" aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
onMouseDown={(e) => e.preventDefault()} onMouseDown={e => e.preventDefault()}
edge="end" edge="end"
> >
{showPassword ? <VisibilityOff /> : <Visibility />} {showPassword ? <VisibilityOff /> : <Visibility />}
@ -621,7 +639,7 @@ const EmployerRegistrationForm = () => {
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}
@ -632,7 +650,7 @@ const EmployerRegistrationForm = () => {
<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 />}
@ -650,13 +668,13 @@ const EmployerRegistrationForm = () => {
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Company Information Company Information
</Typography> </Typography>
<Stack spacing={3}> <Stack spacing={3}>
<TextField <TextField
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,11 +686,13 @@ 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>}
@ -682,11 +702,13 @@ const EmployerRegistrationForm = () => {
<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>}
@ -700,10 +722,13 @@ 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,30 +739,26 @@ 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'}
/> />
<TextField <TextField
fullWidth fullWidth
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'}
/> />
</Stack> </Stack>
</Stack> </Stack>
</Box> </Box>
{errors.general && ( {errors.general && <Alert severity="error">{errors.general}</Alert>}
<Alert severity="error">
{errors.general}
</Alert>
)}
<Button <Button
fullWidth fullWidth
@ -796,22 +817,24 @@ export function RegistrationTypeSelector() {
<Stack direction="row" spacing={3}> <Stack direction="row" spacing={3}>
{/* Candidate Option */} {/* Candidate Option */}
<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>
@ -827,22 +850,24 @@ export function RegistrationTypeSelector() {
</Card> </Card>
{/* Employer Option */} {/* Employer Option */}
<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>
@ -871,4 +896,4 @@ export function RegistrationTypeSelector() {
); );
} }
export { CandidateRegistrationForm, EmployerRegistrationForm }; export { CandidateRegistrationForm, EmployerRegistrationForm };

View File

@ -34,13 +34,15 @@ 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
}; };
@ -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>
)); ));
} }
@ -173,14 +182,13 @@ const Settings = (props: BackstoryPageProps) => {
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(() => {
// if (!systemPrompt) { // if (!systemPrompt) {
// return; // return;
@ -284,8 +292,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 +391,20 @@ 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:</AccordionDetails>
The server is running on the following hardware: <AccordionActions>
</AccordionDetails> <SystemInfoComponent systemInfo={systemInfo} />
<AccordionActions> </AccordionActions>
<SystemInfoComponent systemInfo={systemInfo} /> </Accordion>
</AccordionActions>
</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 { export { Settings };
Settings
};

View File

@ -1,23 +1,22 @@
import React, { useEffect } from "react"; 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 +24,33 @@ 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

@ -1,6 +1,6 @@
/** /**
* Type Conversion Utilities * Type Conversion Utilities
* *
* This file provides utilities to convert between TypeScript and Python/API formats, * This file provides utilities to convert between TypeScript and Python/API formats,
* ensuring data consistency between frontend and backend. * ensuring data consistency between frontend and backend.
*/ */
@ -14,20 +14,20 @@
*/ */
export function toSnakeCase<T extends Record<string, any>>(obj: T): Record<string, any> { export function toSnakeCase<T extends Record<string, any>>(obj: T): Record<string, any> {
if (!obj || typeof obj !== 'object') return obj; 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> = {};
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
const snakeCaseKey = camelToSnake(key); const snakeCaseKey = camelToSnake(key);
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) {
@ -39,7 +39,7 @@ export function toSnakeCase<T extends Record<string, any>>(obj: T): Record<strin
result[snakeCaseKey] = value; result[snakeCaseKey] = value;
} }
} }
return result; return result;
} }
@ -48,20 +48,20 @@ export function toSnakeCase<T extends Record<string, any>>(obj: T): Record<strin
*/ */
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> = {};
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
const camelCaseKey = snakeToCamel(key); const camelCaseKey = snakeToCamel(key);
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)) {
@ -73,7 +73,7 @@ export function toCamelCase<T>(obj: Record<string, any>): T {
result[camelCaseKey] = value; result[camelCaseKey] = value;
} }
} }
return result as T; return result as T;
} }
@ -108,10 +108,10 @@ function isIsoDateString(value: string): boolean {
*/ */
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
const formatted: Record<string, any> = {}; const formatted: Record<string, any> = {};
// Convert dates to ISO strings and handle nested objects // Convert dates to ISO strings and handle nested objects
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
if (value instanceof Date) { if (value instanceof Date) {
@ -131,7 +131,7 @@ export function formatApiRequest<T extends Record<string, any>>(data: T): Record
formatted[key] = value; formatted[key] = value;
} }
} }
return toSnakeCase(formatted); return toSnakeCase(formatted);
} }
@ -144,14 +144,14 @@ export function parseApiResponse<T>(data: any): ApiResponse<T> {
success: false, success: false,
error: { error: {
code: 'INVALID_RESPONSE', code: 'INVALID_RESPONSE',
message: 'Invalid response format' message: 'Invalid response format',
} },
}; };
} }
// Convert any snake_case fields to camelCase and parse dates // Convert any snake_case fields to camelCase and parse dates
const parsed = toCamelCase<ApiResponse<T>>(data); const parsed = toCamelCase<ApiResponse<T>>(data);
return parsed; return parsed;
} }
@ -159,28 +159,28 @@ export function parseApiResponse<T>(data: any): ApiResponse<T> {
* Parse paginated API responses * Parse paginated API responses
*/ */
export function parsePaginatedResponse<T>( export function parsePaginatedResponse<T>(
data: any, data: any,
itemParser?: (item: any) => T itemParser?: (item: any) => T
): ApiResponse<PaginatedResponse<T>> { ): ApiResponse<PaginatedResponse<T>> {
const apiResponse = parseApiResponse<PaginatedResponse<any>>(data); const apiResponse = parseApiResponse<PaginatedResponse<any>>(data);
if (!apiResponse.success || !apiResponse.data) { if (!apiResponse.success || !apiResponse.data) {
return apiResponse as ApiResponse<PaginatedResponse<T>>; return apiResponse as ApiResponse<PaginatedResponse<T>>;
} }
const paginatedData = apiResponse.data; const paginatedData = apiResponse.data;
// Apply item parser if provided // Apply item parser if provided
if (itemParser && Array.isArray(paginatedData.data)) { if (itemParser && Array.isArray(paginatedData.data)) {
return { return {
...apiResponse, ...apiResponse,
data: { data: {
...paginatedData, ...paginatedData,
data: paginatedData.data.map(itemParser) data: paginatedData.data.map(itemParser),
} },
}; };
} }
return apiResponse as ApiResponse<PaginatedResponse<T>>; return apiResponse as ApiResponse<PaginatedResponse<T>>;
} }
@ -193,7 +193,7 @@ export function parsePaginatedResponse<T>(
*/ */
export function toUrlParams(obj: Record<string, any>): URLSearchParams { export function toUrlParams(obj: Record<string, any>): URLSearchParams {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
if (value !== null && value !== undefined) { if (value !== null && value !== undefined) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@ -211,7 +211,7 @@ export function toUrlParams(obj: Record<string, any>): URLSearchParams {
} }
} }
} }
return params; return params;
} }
@ -240,11 +240,11 @@ export function extractApiData<T>(response: ApiResponse<T>): T {
if (isSuccessResponse(response) && response.data !== undefined) { if (isSuccessResponse(response) && response.data !== undefined) {
return response.data; return response.data;
} }
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);
} }
@ -306,7 +306,7 @@ export function createPaginatedRequest(params: Partial<PaginatedRequest> = {}):
page: 1, page: 1,
limit: 20, limit: 20,
sortOrder: 'desc', sortOrder: 'desc',
...params ...params,
}; };
} }
@ -318,10 +318,10 @@ export async function handleApiResponse<T>(response: Response): Promise<T> {
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`); throw new Error(`HTTP ${response.status}: ${errorText}`);
} }
const data = await response.json(); const data = await response.json();
const apiResponse = parseApiResponse<T>(data); const apiResponse = parseApiResponse<T>(data);
return extractApiData(apiResponse); return extractApiData(apiResponse);
} }
@ -336,10 +336,10 @@ export async function handlePaginatedApiResponse<T>(
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`); throw new Error(`HTTP ${response.status}: ${errorText}`);
} }
const data = await response.json(); const data = await response.json();
const apiResponse = parsePaginatedResponse<T>(data, itemParser); const apiResponse = parsePaginatedResponse<T>(data, itemParser);
return extractApiData(apiResponse); return extractApiData(apiResponse);
} }
@ -350,7 +350,7 @@ 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);
@ -375,7 +375,7 @@ const exports = {
createPaginatedRequest, createPaginatedRequest,
handleApiResponse, handleApiResponse,
handlePaginatedApiResponse, handlePaginatedApiResponse,
debugConversion debugConversion,
} };
export default exports; export default exports;

View File

@ -10,8 +10,8 @@ export interface NavigationItem {
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
} }

View File

@ -13,4 +13,4 @@ declare module '@mui/material/styles' {
contrast: string; contrast: string;
}; };
} }
} }

File diff suppressed because it is too large Load Diff