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-plotly.js": "^2.6.0",
"react-router-dom": "^7.6.0",
"react-scripts": "5.0.1",
"react-scripts": "^5.0.1",
"react-spinners": "^0.15.0",
"react-to-print": "^3.1.0",
"rehype-katex": "^7.0.1",
@ -50,7 +50,10 @@
"scripts": {
"start": "WDS_SOCKET_HOST=backstory-beta.ketrenos.com WDS_SOCKET_PORT=443 craco start",
"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": {
"extends": [
@ -73,6 +76,14 @@
"devDependencies": {
"@craco/craco": "^7.1.0",
"@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;
white-space: pre-wrap;
box-sizing: border-box;
border: 3px solid #E0E0E0;
border: 3px solid #e0e0e0;
}
button {
@ -72,8 +72,8 @@ button {
.Controls {
display: flex;
background-color: #F5F5F5;
border: 1px solid #E0E0E0;
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
overflow-y: auto;
padding: 10px;
flex-direction: column;
@ -93,8 +93,8 @@ button {
flex-direction: column;
min-width: 10rem;
flex-grow: 1;
background-color: #1A2536; /* Midnight Blue */
color: #D3CDBF; /* Warm Gray */
background-color: #1a2536; /* Midnight Blue */
color: #d3cdbf; /* Warm Gray */
border-radius: 0;
}
@ -115,12 +115,12 @@ button {
max-width: 1024px;
width: 100%;
margin: 0 auto;
background-color: #D3CDBF;
background-color: #d3cdbf;
}
.user-message.MuiCard-root {
background-color: #DCF8C6;
border: 1px solid #B2E0A7;
background-color: #dcf8c6;
border: 1px solid #b2e0a7;
color: #333333;
margin-bottom: 0.75rem;
margin-left: 1rem;
@ -140,8 +140,8 @@ button {
.Docs.MuiCard-root,
.assistant-message.MuiCard-root {
border: 1px solid #E0E0E0;
background-color: #FFFFFF;
border: 1px solid #e0e0e0;
background-color: #ffffff;
color: #333333;
margin-bottom: 0.75rem;
margin-right: 1rem;
@ -158,7 +158,6 @@ button {
font-size: 0.9rem;
}
.Docs.MuiCard-root {
display: flex;
flex-grow: 1;
@ -181,7 +180,7 @@ button {
.user-message .MuiCardContent-root:last-child,
.assistant-message .MuiCardContent-root:last-child,
.Docs .MuiCardContent-root:last-child {
padding: 16px;
padding: 16px;
}
.users > div {
@ -193,7 +192,7 @@ button {
}
.metadata {
border: 1px solid #E0E0E0;
border: 1px solid #e0e0e0;
font-size: 0.75rem;
padding: 0.125rem;
}
@ -220,7 +219,7 @@ button {
/* Reduce space in lists */
* ul.MuiTypography-root,
* ol.MuiTypography-root {
margin-top: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
@ -239,7 +238,7 @@ button {
/* Reduce space around code blocks */
* .MuiTypography-root pre {
border: 1px solid #F5F5F5;
border: 1px solid #f5f5f5;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
margin-top: 0;
@ -254,4 +253,4 @@ button {
#SystemPromptInput {
font-size: 0.9rem;
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 { ThemeProvider } from '@mui/material/styles';
import { backstoryTheme } from './BackstoryTheme';
import { SeverityType } from 'components/Snack';
import { ConversationHandle } from 'components/Conversation';
import { CandidateRoute } from 'routes/CandidateRoute';
import { BackstoryLayout } from 'components/layout/BackstoryLayout';
@ -17,47 +16,38 @@ import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
const BackstoryApp = () => {
const navigate = useNavigate();
const location = useLocation();
const chatRef = useRef<ConversationHandle>(null);
const snackRef = useRef<any>(null);
const setSnack = useCallback((message: string, severity?: SeverityType) => {
snackRef.current?.setSnack(message, severity);
}, [snackRef]);
const submitQuery = (query: ChatQuery) => {
console.log(`handleSubmitChatQuery:`, query, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query);
navigate('/chat');
};
const [page, setPage] = useState<string>("");
const BackstoryApp = (): JSX.Element => {
const navigate = useNavigate();
const location = useLocation();
const chatRef = useRef<ConversationHandle>(null);
const submitQuery = (query: ChatQuery): void => {
console.log(`handleSubmitChatQuery:`, query, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query);
navigate('/chat');
};
const [page, setPage] = useState<string>('');
useEffect(() => {
const currentRoute = location.pathname.split("/")[1] ? `/${location.pathname.split("/")[1]}` : "/";
setPage(currentRoute);
}, [location.pathname]);
useEffect(() => {
const currentRoute = location.pathname.split('/')[1]
? `/${location.pathname.split('/')[1]}`
: '/';
setPage(currentRoute);
}, [location.pathname]);
// Render appropriate routes based on user type
return (
<ThemeProvider theme={backstoryTheme}>
<AuthProvider>
<AppStateProvider>
<Routes>
<Route path="/u/:username" element={<CandidateRoute {...{ setSnack }} />} />
{/* Static/shared routes */}
<Route
path="/*"
element={
<BackstoryLayout {...{ setSnack, page, chatRef, snackRef, submitQuery }} />
}
/>
</Routes>
</AppStateProvider>
</AuthProvider>
</ThemeProvider>
);
// Render appropriate routes based on user type
return (
<ThemeProvider theme={backstoryTheme}>
<AuthProvider>
<AppStateProvider>
<Routes>
<Route path="/u/:username" element={<CandidateRoute />} />
{/* Static/shared routes */}
<Route path="/*" element={<BackstoryLayout {...{ page, chatRef, submitQuery }} />} />
</Routes>
</AppStateProvider>
</AuthProvider>
</ThemeProvider>
);
};
export {
BackstoryApp
};
export { 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 Button from '@mui/material/Button';
import { CandidateQuestion } from "types/types";
import { CandidateQuestion } from 'types/types';
type ChatSubmitQueryInterface = (query: CandidateQuestion) => void;
interface BackstoryQueryInterface {
question: CandidateQuestion,
submitQuery?: ChatSubmitQueryInterface
question: CandidateQuestion;
submitQuery?: ChatSubmitQueryInterface;
}
const BackstoryQuery = (props : BackstoryQueryInterface) => {
const BackstoryQuery = (props: BackstoryQueryInterface) => {
const { question, submitQuery } = props;
if (submitQuery === undefined) {
return (<Box>{question.question}</Box>);
return <Box>{question.question}</Box>;
}
return (
<Button variant="outlined" sx={{
color: theme => theme.palette.custom.highlight, // Golden Ochre (#D4A017)
borderColor: theme => theme.palette.custom.highlight,
m: 1
}}
size="small" onClick={(e: any) => { submitQuery(question); }}>
<Button
variant="outlined"
sx={{
color: theme => theme.palette.custom.highlight, // Golden Ochre (#D4A017)
borderColor: theme => theme.palette.custom.highlight,
m: 1,
}}
size="small"
onClick={(e: any) => {
submitQuery(question);
}}
>
{question.question}
</Button>
);
}
export type {
BackstoryQueryInterface,
ChatSubmitQueryInterface,
};
export {
BackstoryQuery,
};
export type { BackstoryQueryInterface, ChatSubmitQueryInterface };
export { BackstoryQuery };

View File

@ -7,47 +7,41 @@ import { SetSnackType } from './Snack';
interface BackstoryElementProps {
// setSnack: SetSnackType,
// submitQuery: ChatSubmitQueryInterface,
sx?: SxProps<Theme>,
sx?: SxProps<Theme>;
}
interface BackstoryPageProps extends BackstoryElementProps {
route?: string,
setRoute?: (route: string) => void,
};
route?: string;
setRoute?: (route: string) => void;
}
interface BackstoryTabProps {
label?: string,
path: string,
children?: ReactElement<BackstoryPageProps>,
active?: boolean,
className?: string,
label?: string;
path: string;
children?: ReactElement<BackstoryPageProps>;
active?: boolean;
className?: string;
tabProps?: {
label?: string,
sx?: SxProps,
icon?: string | ReactElement<unknown, string | JSXElementConstructor<any>> | undefined,
iconPosition?: "bottom" | "top" | "start" | "end" | undefined
}
};
label?: string;
sx?: SxProps;
icon?: string | ReactElement<unknown, string | JSXElementConstructor<any>> | undefined;
iconPosition?: 'bottom' | 'top' | 'start' | 'end' | undefined;
};
}
function BackstoryPage(props: BackstoryTabProps) {
const { className, active, children } = props;
return (
<Box
className={ className || "BackstoryTab"}
sx={{ "display": active ? "flex" : "none", p: 0, m: 0, borders: "none" }}
className={className || 'BackstoryTab'}
sx={{ display: active ? 'flex' : 'none', p: 0, m: 0, borders: 'none' }}
>
{children}
</Box>
);
}
export type {
BackstoryPageProps,
BackstoryTabProps,
BackstoryElementProps,
};
export type { BackstoryPageProps, BackstoryTabProps, BackstoryElementProps };
export {
BackstoryPage
}
export { 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 './BackstoryTextField.css';
@ -18,134 +25,134 @@ interface BackstoryTextFieldProps {
style?: CSSProperties;
}
const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryTextFieldProps>((props, ref) => {
const {
value = '',
disabled = false,
placeholder,
onEnter,
onChange,
style,
} = props;
const theme = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const shadowRef = useRef<HTMLTextAreaElement>(null);
const [editValue, setEditValue] = useState<string>(value);
const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryTextFieldProps>(
(props, ref) => {
const { value = '', disabled = false, placeholder, onEnter, onChange, 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
useEffect(() => {
setEditValue(value || "");
}, [value]);
// Sync editValue with prop value if it changes externally
useEffect(() => {
setEditValue(value || '');
}, [value]);
// Adjust textarea height based on content
useEffect(() => {
if (!textareaRef.current || !shadowRef.current) {
return;
}
const textarea = textareaRef.current;
const shadow = shadowRef.current;
// Adjust textarea height based on content
useEffect(() => {
if (!textareaRef.current || !shadowRef.current) {
return;
}
const textarea = textareaRef.current;
const shadow = shadowRef.current;
// Set shadow value to editValue or placeholder if editValue is empty
shadow.value = editValue || placeholder;
// Set shadow value to editValue or placeholder if editValue is empty
shadow.value = editValue || placeholder;
// Ensure shadow textarea has same content-relevant styles
const computed = getComputedStyle(textarea);
shadow.style.width = computed.width; // Match width for accurate wrapping
shadow.style.fontSize = computed.fontSize;
shadow.style.lineHeight = computed.lineHeight;
shadow.style.fontFamily = computed.fontFamily;
shadow.style.letterSpacing = computed.letterSpacing;
shadow.style.wordSpacing = computed.wordSpacing;
// Ensure shadow textarea has same content-relevant styles
const computed = getComputedStyle(textarea);
shadow.style.width = computed.width; // Match width for accurate wrapping
shadow.style.fontSize = computed.fontSize;
shadow.style.lineHeight = computed.lineHeight;
shadow.style.fontFamily = computed.fontFamily;
shadow.style.letterSpacing = computed.letterSpacing;
shadow.style.wordSpacing = computed.wordSpacing;
// Use requestAnimationFrame to ensure DOM is settled
const raf = requestAnimationFrame(() => {
const paddingTop = parseFloat(computed.paddingTop || '0');
const paddingBottom = parseFloat(computed.paddingBottom || '0');
const totalPadding = paddingTop + paddingBottom;
// Use requestAnimationFrame to ensure DOM is settled
const raf = requestAnimationFrame(() => {
const paddingTop = parseFloat(computed.paddingTop || '0');
const paddingBottom = parseFloat(computed.paddingBottom || '0');
const totalPadding = paddingTop + paddingBottom;
// Reset height to auto to allow shrinking
textarea.style.height = 'auto';
const newHeight = shadow.scrollHeight + totalPadding;
// Reset height to auto to allow shrinking
textarea.style.height = 'auto';
const newHeight = shadow.scrollHeight + totalPadding;
textarea.style.height = `${newHeight}px`;
});
textarea.style.height = `${newHeight}px`;
});
// Cleanup RAF to prevent memory leaks
return () => cancelAnimationFrame(raf);
}, [editValue, placeholder]);
// Cleanup RAF to prevent memory leaks
return () => cancelAnimationFrame(raf);
}, [editValue, placeholder]);
// Expose getValue method via ref
useImperativeHandle(ref, () => ({
getValue: () => editValue,
setValue: (value: string) => setEditValue(value),
getAndResetValue: () => { const _ev = editValue; setEditValue(''); return _ev; }
}));
// Expose getValue method via ref
useImperativeHandle(ref, () => ({
getValue: () => editValue,
setValue: (value: string) => setEditValue(value),
getAndResetValue: () => {
const _ev = editValue;
setEditValue('');
return _ev;
},
}));
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (!onEnter) {
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); // Prevent newline
onEnter(editValue);
setEditValue(''); // Clear textarea
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (!onEnter) {
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); // Prevent newline
onEnter(editValue);
setEditValue(''); // Clear textarea
}
};
const fullStyle: CSSProperties = {
display: 'flex',
flexGrow: 1,
width: '100%',
padding: '16.5px 14px',
resize: 'none',
overflow: 'hidden',
boxSizing: 'border-box',
// minHeight: 'calc(1.5rem + 28px)', // lineHeight + padding
lineHeight: '1.5',
borderRadius: '4px',
fontSize: '16px',
backgroundColor: 'rgba(0, 0, 0, 0)',
fontFamily: theme.typography.fontFamily,
...style,
};
const fullStyle: CSSProperties = {
display: 'flex',
flexGrow: 1,
width: '100%',
padding: '16.5px 14px',
resize: 'none',
overflow: 'hidden',
boxSizing: 'border-box',
// minHeight: 'calc(1.5rem + 28px)', // lineHeight + padding
lineHeight: '1.5',
borderRadius: '4px',
fontSize: '16px',
backgroundColor: 'rgba(0, 0, 0, 0)',
fontFamily: theme.typography.fontFamily,
...style,
};
return (
<>
<textarea
className="BackstoryTextField"
ref={textareaRef}
value={editValue}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => { setEditValue(e.target.value); onChange && onChange(e.target.value); }}
onKeyDown={handleKeyDown}
style={fullStyle}
/>
<textarea
className="BackgroundTextField"
ref={shadowRef}
aria-hidden="true"
style={{
...fullStyle,
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
padding: '0px', // No padding to match content height
margin: '0px',
border: '0px', // Remove border to avoid extra height
height: 'auto', // Allow natural height
minHeight: '0px',
}}
readOnly
tabIndex={-1}
/>
</>
);
});
return (
<>
<textarea
className="BackstoryTextField"
ref={textareaRef}
value={editValue}
disabled={disabled}
placeholder={placeholder}
onChange={e => {
setEditValue(e.target.value);
onChange && onChange(e.target.value);
}}
onKeyDown={handleKeyDown}
style={fullStyle}
/>
<textarea
className="BackgroundTextField"
ref={shadowRef}
aria-hidden="true"
style={{
...fullStyle,
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
padding: '0px', // No padding to match content height
margin: '0px',
border: '0px', // Remove border to avoid extra height
height: 'auto', // Allow natural height
minHeight: '0px',
}}
readOnly
tabIndex={-1}
/>
</>
);
}
);
export type {
BackstoryTextFieldRef
};
export type { 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 IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
@ -6,25 +13,43 @@ import Box from '@mui/material/Box';
import SendIcon from '@mui/icons-material/Send';
import CancelIcon from '@mui/icons-material/Cancel';
import { SxProps, Theme } from '@mui/material';
import PropagateLoader from "react-spinners/PropagateLoader";
import PropagateLoader from 'react-spinners/PropagateLoader';
import { Message } from './Message';
import { DeleteConfirmation } from 'components/DeleteConfirmation';
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
import { BackstoryElementProps } from './BackstoryTab';
import { useAuth } from "hooks/AuthContext";
import { useAuth } from 'hooks/AuthContext';
import { StreamingResponse } from 'services/api-client';
import { 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 './Conversation.css';
import { useAppState } from 'hooks/GlobalContext';
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';
@ -34,395 +59,450 @@ interface ConversationHandle {
}
interface ConversationProps extends BackstoryElementProps {
className?: string, // Override default className
type: ConversationMode, // Type of Conversation chat
placeholder?: string, // Prompt to display in TextField input
actionLabel?: string, // Label to put on the primary button
resetAction?: () => void, // Callback when Reset is pressed
resetLabel?: string, // Label to put on Reset button
defaultPrompts?: React.ReactElement[], // Set of Elements to display after the TextField
defaultQuery?: string, // Default text to populate the TextField input
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
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
messages?: ChatMessage[], //
sx?: SxProps<Theme>,
onResponse?: ((message: ChatMessage) => void) | undefined, // Event called when a query completes (provides messages)
};
className?: string; // Override default className
type: ConversationMode; // Type of Conversation chat
placeholder?: string; // Prompt to display in TextField input
actionLabel?: string; // Label to put on the primary button
resetAction?: () => void; // Callback when Reset is pressed
resetLabel?: string; // Label to put on Reset button
defaultPrompts?: React.ReactElement[]; // Set of Elements to display after the TextField
defaultQuery?: string; // Default text to populate the TextField input
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
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
messages?: ChatMessage[]; //
sx?: SxProps<Theme>;
onResponse?: ((message: ChatMessage) => void) | undefined; // Event called when a query completes (provides messages)
}
const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: ConversationProps, ref) => {
const {
actionLabel,
defaultPrompts,
hideDefaultPrompts,
hidePreamble,
messageFilter,
messages,
onResponse,
placeholder,
preamble,
resetAction,
resetLabel,
sx,
type,
} = props;
const { apiClient } = useAuth()
const [processing, setProcessing] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const [conversation, setConversation] = useState<ChatMessage[]>([]);
const conversationRef = useRef<ChatMessage[]>([]);
const [filteredConversation, setFilteredConversation] = useState<ChatMessage[]>([]);
const [processingMessage, setProcessingMessage] = useState<ChatMessage | undefined>(undefined);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | undefined>(undefined);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const viewableElementRef = useRef<HTMLDivElement>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const stopRef = useRef(false);
const controllerRef = useRef<StreamingResponse>(null);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const { setSnack } = useAppState();
const Conversation = forwardRef<ConversationHandle, ConversationProps>(
(props: ConversationProps, ref) => {
const {
actionLabel,
defaultPrompts,
hideDefaultPrompts,
hidePreamble,
messageFilter,
messages,
onResponse,
placeholder,
preamble,
resetAction,
resetLabel,
sx,
type,
} = props;
const { apiClient } = useAuth();
const [processing, setProcessing] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const [conversation, setConversation] = useState<ChatMessage[]>([]);
const conversationRef = useRef<ChatMessage[]>([]);
const [filteredConversation, setFilteredConversation] = useState<ChatMessage[]>([]);
const [processingMessage, setProcessingMessage] = useState<ChatMessage | undefined>(undefined);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | undefined>(undefined);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const viewableElementRef = useRef<HTMLDivElement>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const stopRef = useRef(false);
const controllerRef = useRef<StreamingResponse>(null);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const { setSnack } = useAppState();
// Keep the ref updated whenever items changes
useEffect(() => {
conversationRef.current = conversation;
}, [conversation]);
// Keep the ref updated whenever items changes
useEffect(() => {
conversationRef.current = conversation;
}, [conversation]);
// Update the context status
/* Transform the 'Conversation' by filtering via callback, then adding
* preamble and messages based on whether the conversation
* has any elements yet */
useEffect(() => {
let filtered = [];
if (messageFilter === undefined) {
filtered = conversation;
// console.log('No message filter provided. Using all messages.', filtered);
} else {
//console.log('Filtering conversation...')
filtered = 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");
// Update the context status
/* Transform the 'Conversation' by filtering via callback, then adding
* preamble and messages based on whether the conversation
* has any elements yet */
useEffect(() => {
let filtered = [];
if (messageFilter === undefined) {
filtered = conversation;
// console.log('No message filter provided. Using all messages.', filtered);
} else {
//console.log('Filtering conversation...')
filtered =
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]);
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 () => {
if (!chatSession || !chatSession.id) {
return;
}
try {
const response: PaginatedResponse<ChatMessage> = await apiClient.getChatMessages(chatSession.id);
const messages: ChatMessage[] = response.data;
const getChatMessages = useCallback(async () => {
if (!chatSession || !chatSession.id) {
return;
}
try {
const response: PaginatedResponse<ChatMessage> = await apiClient.getChatMessages(
chatSession.id
);
const messages: ChatMessage[] = response.data;
setProcessingMessage(undefined);
setStreamingMessage(undefined);
if (messages.length === 0) {
console.log(`History returned with 0 entries`);
setConversation([]);
setNoInteractions(true);
} else {
console.log(`History returned with ${messages.length} entries:`, messages);
setConversation(messages);
setNoInteractions(false);
}
} catch (error) {
console.error('Unable to obtain chat history', error);
setProcessingMessage({
...defaultMessage,
status: 'error',
content: `Unable to obtain history from server.`,
});
setTimeout(() => {
setProcessingMessage(undefined);
setNoInteractions(true);
}, 3000);
setSnack('Unable to obtain chat history.', 'error');
}
}, [chatSession]);
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (!chatSession) {
setProcessingMessage(loadingMessage);
return;
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
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]);
getChatMessages();
}, [chatSession]);
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
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) => {
const handleEnter = (value: string) => {
const query: ChatQuery = {
prompt: value,
};
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, {
onMessage: (msg: ChatMessage) => {
console.log("onMessage:", msg);
setConversation([
...conversationRef.current,
msg
]);
setStreamingMessage(undefined);
setProcessingMessage(undefined);
setProcessing(false);
if (onResponse) {
onResponse(msg);
}
useImperativeHandle(ref, () => ({
submitQuery: (query: ChatQuery) => {
processQuery(query);
},
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 });
}
fetchHistory: () => {
getChatMessages();
},
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, }} />
)
// 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();
}
{
processingMessage !== undefined &&
<Message {...{ chatSession, sendQuery: processQuery, message: processingMessage, }} />
controllerRef.current = null;
};
const processQuery = (query: ChatQuery) => {
if (controllerRef.current || !chatSession || !chatSession.id) {
return;
}
{
streamingMessage !== undefined &&
<Message {...{ chatSession, sendQuery: processQuery, message: streamingMessage }} />
}
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 1,
}}>
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
/>
{processing === true && countdown > 0 && (
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, {
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
sx={{
pt: 1,
fontSize: "0.7rem",
color: "darkgrey"
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
}}
>Response will be stopped in: {countdown}s</Box>
)}
</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}
>
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
/>
</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}
{processing === true && countdown > 0 && (
<Box
sx={{
pt: 1,
fontSize: '0.7rem',
color: 'darkgrey',
}}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
Response will be stopped in: {countdown}s
</Box>
)}
</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>
{(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 {
ConversationProps,
ConversationHandle,
};
export type { ConversationProps, ConversationHandle };
export {
Conversation
};
export { Conversation };

View File

@ -6,7 +6,7 @@ import { Tooltip } from '@mui/material';
import { SxProps, Theme } from '@mui/material';
interface CopyBubbleProps extends IconButtonProps {
content: string | undefined,
content: string | undefined;
sx?: SxProps<Theme>;
tooltip?: string;
}
@ -14,10 +14,10 @@ interface CopyBubbleProps extends IconButtonProps {
const CopyBubble = ({
content,
sx,
tooltip = "Copy to clipboard",
tooltip = 'Copy to clipboard',
onClick,
...rest
} : CopyBubbleProps) => {
}: CopyBubbleProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = (e: any) => {
@ -35,28 +35,32 @@ const CopyBubble = ({
}
};
return (
<Tooltip title={tooltip} placement="top" arrow>
<IconButton
onClick={(e) => { handleCopy(e) }}
sx={{
width: 24,
height: 24,
opacity: 0.75,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'action.hover', opacity: 1 },
...sx,
}}
size="small"
color={copied ? "success" : "default"}
{...rest}
>
{copied ? <CheckIcon sx={{ width: 16, height: 16 }} /> : <ContentCopyIcon sx={{ width: 16, height: 16 }} />}
</IconButton>
</Tooltip>
return (
<Tooltip title={tooltip} placement="top" arrow>
<IconButton
onClick={e => {
handleCopy(e);
}}
sx={{
width: 24,
height: 24,
opacity: 0.75,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'action.hover', opacity: 1 },
...sx,
}}
size="small"
color={copied ? 'success' : 'default'}
{...rest}
>
{copied ? (
<CheckIcon sx={{ width: 16, height: 16 }} />
) : (
<ContentCopyIcon sx={{ width: 16, height: 16 }} />
)}
</IconButton>
</Tooltip>
);
}
};
export {
CopyBubble
}
export { CopyBubble };

View File

@ -1,12 +1,12 @@
import React, { useEffect, useState } from 'react';
import {
IconButton,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Button,
import {
IconButton,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Button,
useMediaQuery,
Tooltip,
SxProps,
@ -19,8 +19,17 @@ interface DeleteConfirmationProps {
onDelete?: () => void;
disabled?: boolean;
label?: string;
action?: "delete" | "reset";
color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined;
action?: 'delete' | 'reset';
color?:
| 'inherit'
| 'default'
| 'primary'
| 'secondary'
| 'error'
| 'info'
| 'success'
| 'warning'
| undefined;
sx?: SxProps;
// New props for controlled mode
open?: boolean;
@ -47,7 +56,7 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
disabled,
label,
color,
action = "delete",
action = 'delete',
// New props
open: controlledOpen,
onClose: controlledOnClose,
@ -56,7 +65,7 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
message,
hideButton = false,
confirmButtonText,
cancelButtonText = "Cancel",
cancelButtonText = 'Cancel',
sx,
icon = <ResetIcon />,
} = props;
@ -94,21 +103,32 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
};
// Determine dialog content based on mode
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 confirmText = confirmButtonText || `${capitalizeFirstLetter(action)} ${label || "Everything"}`;
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 confirmText =
confirmButtonText || `${capitalizeFirstLetter(action)} ${label || 'Everything'}`;
return (
<>
{/* Only show button if not hidden (for controlled mode) */}
{!hideButton && (
<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 */}
<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 */}
<IconButton
aria-label={action}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); handleClickOpen(); }}
color={color || "inherit"}
sx={{ display: "flex", margin: 'auto 0px', ...sx }}
onClick={e => {
e.stopPropagation();
e.preventDefault();
handleClickOpen();
}}
color={color || 'inherit'}
sx={{ display: 'flex', margin: 'auto 0px', ...sx }}
size="large"
edge="start"
disabled={disabled}
@ -118,20 +138,16 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
</span>
</Tooltip>
)}
<Dialog
fullScreen={fullScreen}
open={isOpen}
onClose={handleClose}
aria-labelledby="responsive-dialog-title"
>
<DialogTitle id="responsive-dialog-title">
{dialogTitle}
</DialogTitle>
<DialogTitle id="responsive-dialog-title">{dialogTitle}</DialogTitle>
<DialogContent>
<DialogContentText>
{dialogMessage}
</DialogContentText>
<DialogContentText>{dialogMessage}</DialogContentText>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleClose}>
@ -144,8 +160,6 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
</Dialog>
</>
);
}
};
export {
DeleteConfirmation
};
export { DeleteConfirmation };

View File

@ -9,7 +9,7 @@ interface DocumentProps extends BackstoryElementProps {
const Document = (props: DocumentProps) => {
const { filepath } = props;
const [document, setDocument] = useState<string>("");
const [document, setDocument] = useState<string>('');
// Get the markdown
useEffect(() => {
@ -26,23 +26,23 @@ const Document = (props: DocumentProps) => {
});
if (!response.ok) {
throw Error(`${filepath} not found.`);
}
}
const data = await response.text();
setDocument(data);
} catch (error: any) {
console.error('Error obtaining Docs content information:', error);
setDocument(`${filepath} not found.`);
};
}
};
fetchDocument();
}, [document, setDocument, filepath])
}, [document, setDocument, filepath]);
return (<>
<StyledMarkdown content={document} />
</>);
return (
<>
<StyledMarkdown content={document} />
</>
);
};
export {
Document
};
export { Document };

View File

@ -24,16 +24,10 @@ import {
Paper,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import {
CloudUpload,
Edit,
Delete,
Visibility,
Close,
} from '@mui/icons-material';
import { CloudUpload, Edit, Delete, Visibility, Close } from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
import { useAuth } from "hooks/AuthContext";
import { useAuth } from 'hooks/AuthContext';
import * as Types from 'types/types';
import { BackstoryElementProps } from './BackstoryTab';
import { useAppState } from 'hooks/GlobalContext';
@ -55,7 +49,7 @@ const DocumentManager = (props: BackstoryElementProps) => {
const { setSnack } = useAppState();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { user, apiClient } = useAuth();
const [documents, setDocuments] = useState<Types.Document[]>([]);
const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null);
const [documentContent, setDocumentContent] = useState<string>('');
@ -63,9 +57,9 @@ const DocumentManager = (props: BackstoryElementProps) => {
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(null);
const [editingName, setEditingName] = useState('');
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
// 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
useEffect(() => {
@ -76,10 +70,10 @@ const DocumentManager = (props: BackstoryElementProps) => {
const loadDocuments = async () => {
try {
const results = await apiClient.getCandidateDocuments();
setDocuments(results.documents);
const results = await apiClient.getCandidateDocuments();
setDocuments(results.documents);
} catch (error) {
console.error(error);
console.error(error);
setSnack('Failed to load documents', 'error');
}
};
@ -87,43 +81,47 @@ const DocumentManager = (props: BackstoryElementProps) => {
// Handle document upload
const handleDocumentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
let docType : Types.DocumentType | null = null;
switch (fileExtension.substring(1)) {
case "pdf":
docType = "pdf";
break;
case "docx":
docType = "docx";
break;
case "md":
docType = "markdown";
break;
case "txt":
docType = "txt";
break;
}
const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) {
case 'pdf':
docType = 'pdf';
break;
case 'docx':
docType = 'docx';
break;
case 'md':
docType = 'markdown';
break;
case 'txt':
docType = 'txt';
break;
}
if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return;
}
if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return;
}
try {
// Upload file (replace with actual API call)
const controller = apiClient.uploadCandidateDocument(file, { includeInRag: true, isJobDocument: false }, {
onError: (error) => {
console.error(error);
setSnack(error.content, 'error');
const controller = apiClient.uploadCandidateDocument(
file,
{ includeInRag: true, isJobDocument: false },
{
onError: error => {
console.error(error);
setSnack(error.content, 'error');
},
}
});
);
const result = await controller.promise;
if (result && result.document) {
setDocuments(prev => [...prev, result.document]);
setSnack(`Document uploaded: ${file.name}`, 'success');
}
// Reset file input
e.target.value = '';
} catch (error) {
@ -138,10 +136,10 @@ const DocumentManager = (props: BackstoryElementProps) => {
try {
// Call API to delete document
await apiClient.deleteCandidateDocument(document);
setDocuments(prev => prev.filter(doc => doc.id !== document.id));
setSnack('Document deleted successfully', 'success');
// Close content view if this document was being viewed
if (selectedDocument?.id === document.id) {
setIsViewingContent(false);
@ -159,13 +157,9 @@ const DocumentManager = (props: BackstoryElementProps) => {
document.options = { includeInRag };
// Call API to update RAG flag
await apiClient.updateCandidateDocument(document);
setDocuments(prev =>
prev.map(doc =>
doc.id === document.id
? { ...doc, includeInRag }
: doc
)
setDocuments(prev =>
prev.map(doc => (doc.id === document.id ? { ...doc, includeInRag } : doc))
);
setSnack(`Document ${includeInRag ? 'included in' : 'excluded from'} RAG`, 'success');
} catch (error) {
@ -182,15 +176,11 @@ const DocumentManager = (props: BackstoryElementProps) => {
try {
// Call API to rename document
document.filename = newName
document.filename = newName;
await apiClient.updateCandidateDocument(document);
setDocuments(prev =>
prev.map(doc =>
doc.id === document.id
? { ...doc, filename: newName.trim() }
: doc
)
setDocuments(prev =>
prev.map(doc => (doc.id === document.id ? { ...doc, filename: newName.trim() } : doc))
);
setSnack('Document renamed successfully', 'success');
setIsRenameDialogOpen(false);
@ -206,7 +196,7 @@ const DocumentManager = (props: BackstoryElementProps) => {
try {
setSelectedDocument(document);
setIsViewingContent(true);
// Call API to get document content
const result = await apiClient.getCandidateDocumentText(document);
setDocumentContent(result.content);
@ -235,140 +225,158 @@ const DocumentManager = (props: BackstoryElementProps) => {
// Get file type color
const getFileTypeColor = (type: string): 'primary' | 'secondary' | 'success' | 'warning' => {
switch (type) {
case 'pdf': return 'primary';
case 'docx': return 'secondary';
case 'txt': return 'success';
case 'md': return 'warning';
default: return 'primary';
case 'pdf':
return 'primary';
case 'docx':
return 'secondary';
case 'txt':
return 'success';
case 'md':
return 'warning';
default:
return 'primary';
}
};
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 (
<>
<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" }}>
<Typography variant={isMobile ? "subtitle2" : "h6"}>
Documents
</Typography>
<Button
component="label"
variant="contained"
startIcon={<CloudUpload />}
size={isMobile ? "small" : "medium"}>
Upload Document
<VisuallyHiddenInput
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleDocumentUpload}
/>
</Button>
<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',
}}
>
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Documents</Typography>
<Button
component="label"
variant="contained"
startIcon={<CloudUpload />}
size={isMobile ? 'small' : 'medium'}
>
Upload Document
<VisuallyHiddenInput
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleDocumentUpload}
/>
</Button>
</Box>
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
{documents.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
textAlign: 'center',
py: 3
}}>
No additional documents uploaded
</Typography>
) : (
<List sx={{ width: '100%' }}>
{documents.map((doc, index) => (
<React.Fragment key={doc.id}>
{index > 0 && <Divider />}
<ListItem sx={{ px: 0 }}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<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)}
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
{documents.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
textAlign: 'center',
py: 3,
}}
>
No additional documents uploaded
</Typography>
) : (
<List sx={{ width: '100%' }}>
{documents.map((doc, index) => (
<React.Fragment key={doc.id}>
{index > 0 && <Divider />}
<ListItem sx={{ px: 0 }}>
<ListItemText
primary={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
<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="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>
}
}
label={<Typography variant="caption">Include in RAG</Typography>}
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
edge="end"
size="small"
onClick={() => handleViewDocument(doc)}
title="View content"
>
<Visibility />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => startRename(doc, doc.filename)}
title="Rename"
>
<Edit />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => handleDeleteDocument(doc)}
title="Delete"
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</React.Fragment>
))}
</List>
)}
</CardContent>
</Card>
</Box>
</Box>
}
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
edge="end"
size="small"
onClick={() => handleViewDocument(doc)}
title="View content"
>
<Visibility />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => startRename(doc, doc.filename)}
title="Rename"
>
<Edit />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => handleDeleteDocument(doc)}
title="Delete"
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</React.Fragment>
))}
</List>
)}
</CardContent>
</Card>
</Grid>
{/* Document Content Viewer */}
@ -376,10 +384,15 @@ const DocumentManager = (props: BackstoryElementProps) => {
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant={isMobile ? "subtitle2" : "h6"}>
Document Content
</Typography>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Document Content</Typography>
<IconButton
size="small"
onClick={() => {
@ -391,22 +404,24 @@ const DocumentManager = (props: BackstoryElementProps) => {
<Close />
</IconButton>
</Box>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 400,
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 400,
overflow: 'auto',
backgroundColor: 'grey.50'
backgroundColor: 'grey.50',
}}
>
<pre style={{
margin: 0,
fontFamily: 'monospace',
fontSize: isMobile ? '0.75rem' : '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
<pre
style={{
margin: 0,
fontFamily: 'monospace',
fontSize: isMobile ? '0.75rem' : '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{documentContent || 'Loading content...'}
</pre>
</Paper>
@ -415,46 +430,44 @@ const DocumentManager = (props: BackstoryElementProps) => {
</Grid>
)}
{/* Rename Dialog */}
<Dialog
open={isRenameDialogOpen}
onClose={() => setIsRenameDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Rename Document</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Document Name"
fullWidth
variant="outlined"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && editingDocument) {
handleRenameDocument(editingDocument, editingName);
}
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsRenameDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => editingDocument && handleRenameDocument(editingDocument, editingName)}
variant="contained"
disabled={!editingName.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
</Grid>
{/* Rename Dialog */}
<Dialog
open={isRenameDialogOpen}
onClose={() => setIsRenameDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Rename Document</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Document Name"
fullWidth
variant="outlined"
value={editingName}
onChange={e => setEditingName(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter' && editingDocument) {
handleRenameDocument(editingDocument, editingName);
}
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsRenameDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => editingDocument && handleRenameDocument(editingDocument, editingName)}
variant="contained"
disabled={!editingName.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
</Grid>
</>
);
};
export { DocumentManager };
export { DocumentManager };

View File

@ -18,7 +18,7 @@ import {
Checkbox,
FormControlLabel,
Grid,
IconButton
IconButton,
} from '@mui/material';
import {
Email as EmailIcon,
@ -28,7 +28,7 @@ import {
Refresh as RefreshIcon,
DevicesOther as DevicesIcon,
VisibilityOff,
Visibility
Visibility,
} from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
@ -36,9 +36,10 @@ import { Navigate, useNavigate } from 'react-router-dom';
// Email Verification Component
const EmailVerificationPage = (props: BackstoryPageProps) => {
const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } = useAuth();
const navigate = useNavigate();
const [verificationToken, setVerificationToken] = useState('');
const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } =
useAuth();
const navigate = useNavigate();
const [verificationToken, setVerificationToken] = useState('');
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending');
const [message, setMessage] = useState('');
const [userType, setUserType] = useState<string>('');
@ -47,7 +48,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
// Get token from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
setVerificationToken(token);
handleVerifyEmail(token);
@ -62,41 +63,41 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
}
try {
const result = await verifyEmail({ token });
const result = await verifyEmail({ token });
if (result) {
if (result) {
setStatus('success');
setMessage(result.message);
setUserType(result.userType);
setMessage(result.message);
setUserType(result.userType);
// Redirect to login after 3 seconds
setTimeout(() => {
navigate('/login');
navigate('/login');
}, 3000);
} else {
setStatus('error');
setMessage('Email verification failed');
setMessage('Email verification failed');
}
} catch (error) {
setStatus('error');
setMessage('Email verification failed');
setMessage('Email verification failed');
}
};
const handleResendVerification = async () => {
const email = getPendingVerificationEmail();
if (!email) {
setMessage('No pending verification email found.');
return;
}
const email = getPendingVerificationEmail();
if (!email) {
setMessage('No pending verification email found.');
return;
}
try {
const success = await resendEmailVerification(email);
if (success) {
setMessage('Verification email sent successfully!');
const success = await resendEmailVerification(email);
if (success) {
setMessage('Verification email sent successfully!');
}
} catch (error) {
setMessage('Failed to resend verification email.');
setMessage('Failed to resend verification email.');
}
};
@ -108,7 +109,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.50',
p: 2
p: 2,
}}
>
<Card sx={{ maxWidth: 500, width: '100%' }}>
@ -151,18 +152,18 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
)}
</Box>
{isLoading && (
{isLoading && (
<Box display="flex" justifyContent="center" my={3}>
<CircularProgress />
</Box>
)}
{(message || error) && (
<Alert
{(message || error) && (
<Alert
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'}
sx={{ mt: 2 }}
>
{message || error}
{message || error}
</Alert>
)}
@ -171,11 +172,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
<Typography variant="body2" color="text.secondary" mb={2}>
You will be redirected to the login page in a few seconds...
</Typography>
<Button
variant="contained"
onClick={() => navigate('/login')}
fullWidth
>
<Button variant="contained" onClick={() => navigate('/login')} fullWidth>
Go to Login
</Button>
</Box>
@ -186,18 +183,14 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
<Button
variant="outlined"
onClick={handleResendVerification}
disabled={isLoading}
disabled={isLoading}
startIcon={<RefreshIcon />}
fullWidth
sx={{ mb: 2 }}
>
Resend Verification Email
</Button>
<Button
variant="contained"
onClick={() => navigate('/login')}
fullWidth
>
<Button variant="contained" onClick={() => navigate('/login')} fullWidth>
Back to Login
</Button>
</Box>
@ -206,46 +199,41 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Card>
</Box>
);
}
};
// MFA Verification Component
interface MFAVerificationDialogProps {
open: boolean;
onClose: () => void;
onVerificationSuccess: (authData: any) => void;
open: boolean;
onClose: () => void;
onVerificationSuccess: (authData: any) => void;
}
const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
const {
open,
onClose,
onVerificationSuccess
} = props;
const { verifyMFA, resendMFACode, clearMFA, mfaResponse, isLoading, error } = useAuth();
const { open, onClose, onVerificationSuccess } = props;
const { verifyMFA, resendMFACode, clearMFA, mfaResponse, isLoading, error } = useAuth();
const [code, setCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(false);
const [localError, setLocalError] = useState('');
const [localError, setLocalError] = useState('');
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
}, [error]);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
}, [error]);
useEffect(() => {
if (!open) return;
const timer = setInterval(() => {
setTimeLeft((prev) => {
setTimeLeft(prev => {
if (prev <= 1) {
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 prev - 1;
@ -263,113 +251,116 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
const handleVerifyMFA = async () => {
if (!code || code.length !== 6) {
setLocalError('Please enter a valid 6-digit code');
setLocalError('Please enter a valid 6-digit code');
return;
}
if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available');
return;
}
if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available');
return;
}
setLocalError('');
setLocalError('');
try {
const success = await verifyMFA({
email: mfaResponse.mfaData.email,
code,
deviceId: mfaResponse.mfaData.deviceId,
rememberDevice,
const success = await verifyMFA({
email: mfaResponse.mfaData.email,
code,
deviceId: mfaResponse.mfaData.deviceId,
rememberDevice,
});
if (success) {
onVerificationSuccess({ success: true });
onClose();
if (success) {
onVerificationSuccess({ success: true });
onClose();
}
} catch (error) {
setLocalError('Verification failed. Please try again.');
setLocalError('Verification failed. Please try again.');
}
};
const handleResendCode = async () => {
if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available');
return;
}
if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available');
return;
}
try {
const success = await resendMFACode(mfaResponse.mfaData.email, mfaResponse.mfaData.deviceId, mfaResponse.mfaData.deviceName);
if (success) {
const success = await resendMFACode(
mfaResponse.mfaData.email,
mfaResponse.mfaData.deviceId,
mfaResponse.mfaData.deviceName
);
if (success) {
setTimeLeft(600); // Reset timer
setLocalError('');
setLocalError('');
alert('New verification code sent to your email');
}
} catch (error) {
setLocalError('Failed to resend code');
setLocalError('Failed to resend code');
}
};
const handleClose = () => {
clearMFA();
onClose();
};
const handleClose = () => {
clearMFA();
onClose();
};
if (!mfaResponse || !mfaResponse.mfaData) return null;
if (!mfaResponse || !mfaResponse.mfaData) return null;
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<SecurityIcon color="primary" />
<Typography variant="h6">
Verify Your Identity
</Typography>
<Typography variant="h6">Verify Your Identity</Typography>
</Box>
</DialogTitle>
<DialogContent>
<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>
<Typography variant="body1" gutterBottom>
We've sent a 6-digit verification code to:
We've sent a 6-digit verification code to:
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
{mfaResponse.mfaData.email}
{mfaResponse.mfaData.email}
</Typography>
<TextField
fullWidth
label="Enter 6-digit code"
value={code}
onChange={(e) => {
onChange={e => {
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
setCode(value);
setLocalError('');
setLocalError('');
}}
placeholder="000000"
inputProps={{
maxLength: 6,
style: {
fontSize: 24,
textAlign: 'center',
letterSpacing: 8
}
style: {
fontSize: 24,
textAlign: 'center',
letterSpacing: 8,
},
}}
sx={{ mt: 2, mb: 2 }}
error={!!(localError || errorMessage)}
helperText={localError || errorMessage}
error={!!(localError || errorMessage)}
helperText={localError || errorMessage}
/>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="body2" color="text.secondary">
Code expires in: {formatTime(timeLeft)}
</Typography>
<Button
size="small"
onClick={handleResendCode}
disabled={isLoading || timeLeft > 540} // Allow resend after 1 minute
<Button
size="small"
onClick={handleResendCode}
disabled={isLoading || timeLeft > 540} // Allow resend after 1 minute
>
Resend Code
</Button>
@ -379,7 +370,7 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
control={
<Checkbox
checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)}
onChange={e => setRememberDevice(e.target.checked)}
/>
}
label="Remember this device for 90 days"
@ -393,44 +384,44 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button onClick={handleClose} disabled={isLoading}>
<Button onClick={handleClose} disabled={isLoading}>
Cancel
</Button>
<Button
variant="contained"
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>
</DialogActions>
</Dialog>
);
}
};
// Enhanced Registration Success Component
const RegistrationSuccessDialog = ({
open,
onClose,
email,
userType
const RegistrationSuccessDialog = ({
open,
onClose,
email,
userType,
}: {
open: boolean;
onClose: () => void;
email: string;
userType: string;
}) => {
const { resendEmailVerification, isLoading } = useAuth();
const { resendEmailVerification, isLoading } = useAuth();
const [resendMessage, setResendMessage] = useState('');
const handleResendVerification = async () => {
const handleResendVerification = async () => {
try {
const success = await resendEmailVerification(email);
if (success) {
setResendMessage('Verification email sent!');
}
const success = await resendEmailVerification(email);
if (success) {
setResendMessage('Verification email sent!');
}
} 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>
<DialogContent sx={{ textAlign: 'center', p: 4 }}>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h5" gutterBottom>
Check Your Email
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
We've sent a verification link to:
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
{email}
</Typography>
<Alert severity="info" sx={{ mt: 2, mb: 3, textAlign: 'left' }}>
<Typography variant="body2">
<strong>Next steps:</strong>
@ -464,10 +455,7 @@ const RegistrationSuccessDialog = ({
</Alert>
{resendMessage && (
<Alert
severity={resendMessage.includes('sent') ? 'success' : 'error'}
sx={{ mb: 2 }}
>
<Alert severity={resendMessage.includes('sent') ? 'success' : 'error'} sx={{ mb: 2 }}>
{resendMessage}
</Alert>
)}
@ -476,8 +464,8 @@ const RegistrationSuccessDialog = ({
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}>
<Button
onClick={handleResendVerification}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
>
Resend Email
</Button>
@ -487,46 +475,45 @@ const RegistrationSuccessDialog = ({
</DialogActions>
</Dialog>
);
}
};
// Enhanced Login Component with MFA Support
const LoginForm = () => {
const { login, mfaResponse, isLoading, error, user } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
}, [error]);
}, [error]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const success = await login({
login: email,
password,
});
const success = await login({
login: email,
password
});
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();
}
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) => {
handleLoginSuccess();
handleLoginSuccess();
};
const handleLoginSuccess = () => {
@ -546,7 +533,7 @@ const LoginForm = () => {
fullWidth
label="Email or Username"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={e => setEmail(e.target.value)}
autoComplete="email"
autoFocus
/>
@ -555,7 +542,7 @@ const LoginForm = () => {
label="Password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
placeholder="Create a strong password"
required
@ -565,7 +552,7 @@ const LoginForm = () => {
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
@ -575,9 +562,9 @@ const LoginForm = () => {
}}
/>
{errorMessage && (
{errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}>
{errorMessage}
{errorMessage}
</Alert>
)}
@ -585,21 +572,21 @@ const LoginForm = () => {
type="submit"
fullWidth
variant="contained"
disabled={isLoading}
disabled={isLoading}
sx={{ mt: 3, mb: 2 }}
>
{isLoading ? <CircularProgress size={20} /> : 'Sign In'}
{isLoading ? <CircularProgress size={20} /> : 'Sign In'}
</Button>
{/* MFA Dialog */}
<MFAVerificationDialog
open={mfaResponse?.mfaRequired || false}
onClose={() => { }} // This will be handled by clearMFA in the dialog
onVerificationSuccess={handleMFASuccess}
/>
{/* MFA Dialog */}
<MFAVerificationDialog
open={mfaResponse?.mfaRequired || false}
onClose={() => {}} // This will be handled by clearMFA in the dialog
onVerificationSuccess={handleMFASuccess}
/>
</Box>
);
}
};
// Device Management Component
const TrustedDevicesManager = () => {
@ -619,16 +606,16 @@ const TrustedDevicesManager = () => {
<DevicesIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Trusted Devices
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Manage devices that you've marked as trusted. You won't need to verify
your identity when signing in from these devices.
Manage devices that you've marked as trusted. You won't need to verify your identity when
signing in from these devices.
</Typography>
{devices.length === 0 ? (
<Alert severity="info">
No trusted devices yet. When you log in from a new device and choose
to remember it, it will appear here.
No trusted devices yet. When you log in from a new device and choose to remember it, it
will appear here.
</Alert>
) : (
<Grid container spacing={2}>
@ -636,18 +623,16 @@ const TrustedDevicesManager = () => {
<Grid key={index} size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1">
{device.deviceName}
</Typography>
<Typography variant="subtitle1">{device.deviceName}</Typography>
<Typography variant="body2" color="text.secondary">
Added: {new Date(device.addedAt).toLocaleDateString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Last used: {new Date(device.lastUsed).toLocaleDateString()}
</Typography>
<Button
size="small"
color="error"
<Button
size="small"
color="error"
sx={{ mt: 1 }}
onClick={() => {
// Remove device
@ -664,6 +649,12 @@ const TrustedDevicesManager = () => {
</CardContent>
</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 {
ExpandMore
};
export { ExpandMore };

View File

@ -8,128 +8,136 @@ import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
interface GenerateImageProps extends BackstoryElementProps {
prompt: string;
chatSession: ChatSession;
};
prompt: string;
chatSession: ChatSession;
}
const GenerateImage = (props: GenerateImageProps) => {
const { user } = useAuth();
const { chatSession, prompt } = props;
const { setSnack } = useAppState();
const [processing, setProcessing] = useState<boolean>(false);
const [status, setStatus] = useState<string>('');
const [image, setImage] = useState<string>('');
const [processing, setProcessing] = useState<boolean>(false);
const [status, setStatus] = useState<string>('');
const [image, setImage] = useState<string>('');
const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || '';
// Only keep refs that are truly necessary
const controllerRef = useRef<string>(null);
const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || '';
// Only keep refs that are truly necessary
const controllerRef = useRef<string>(null);
// Effect to trigger profile generation when user data is ready
useEffect(() => {
if (controllerRef.current) {
console.log("Controller already active, skipping profile generation");
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 <></>;
// Effect to trigger profile generation when user data is ready
useEffect(() => {
if (controllerRef.current) {
console.log('Controller already active, skipping profile generation');
return;
}
if (!prompt) {
return;
}
setStatus('Starting image generation...');
setProcessing(true);
const start = Date.now();
return (
<Box className="GenerateImage" sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
// 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 <></>;
}
return (
<Box
className="GenerateImage"
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
gap: 1,
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
minHeight: "max-content",
}}>
{image !== '' && <img alt={prompt} src={`${image}/${chatSession.id}`} />}
{ prompt &&
<Quote size={processing ? "normal" : "small"} quote={prompt} sx={{ "& *": { color: "#2E2E2E !important" }}}/>
}
{processing &&
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 0,
gap: 1,
minHeight: "min-content",
mb: 2
}}>
{ status &&
<Box sx={{ display: "flex", flexDirection: "column"}}>
<Box sx={{ fontSize: "0.5rem"}}>Generation status</Box>
<Box sx={{ fontWeight: "bold"}}>{status}</Box>
</Box>
}
<PropagateLoader
size="10px"
loading={processing}
color="white"
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
}
</Box>);
minHeight: 'max-content',
}}
>
{image !== '' && <img alt={prompt} src={`${image}/${chatSession.id}`} />}
{prompt && (
<Quote
size={processing ? 'normal' : 'small'}
quote={prompt}
sx={{ '& *': { color: '#2E2E2E !important' } }}
/>
)}
{processing && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 0,
gap: 1,
minHeight: 'min-content',
mb: 2,
}}
>
{status && (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ fontSize: '0.5rem' }}>Generation status</Box>
<Box sx={{ fontWeight: 'bold' }}>{status}</Box>
</Box>
)}
<PropagateLoader
size="10px"
loading={processing}
color="white"
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
)}
</Box>
);
};
export {
GenerateImage
};
export { GenerateImage };

View File

@ -1,9 +1,9 @@
import React, { useState, useRef, JSX } from 'react';
import {
Box,
Button,
Typography,
TextField,
import {
Box,
Button,
Typography,
TextField,
Grid,
useTheme,
useMediaQuery,
@ -30,7 +30,7 @@ import {
Business,
Work,
CheckCircle,
Star
Star,
} from '@mui/icons-material';
import { styled } from '@mui/material/styles';
import FileUploadIcon from '@mui/icons-material/FileUpload';
@ -73,20 +73,20 @@ const UploadBox = styled(Box)(({ theme }) => ({
}));
interface JobCreatorProps extends BackstoryElementProps {
onSave?: (job: Types.Job) => void;
onSave?: (job: Types.Job) => void;
}
const JobCreator = (props: JobCreatorProps) => {
const { user, apiClient } = useAuth();
const { user, apiClient } = useAuth();
const { onSave } = props;
const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack } = useAppState();
const theme = useTheme();
const theme = useTheme();
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 [jobTitle, setJobTitle] = useState<string>('');
const [company, setCompany] = useState<string>('');
const [jobTitle, setJobTitle] = useState<string>('');
const [company, setCompany] = useState<string>('');
const [summary, setSummary] = useState<string>('');
const [job, setJob] = useState<Types.Job | null>(null);
const [jobStatus, setJobStatus] = useState<string>('');
@ -102,7 +102,7 @@ const JobCreator = (props: JobCreatorProps) => {
setJobStatus(status.content);
},
onMessage: (jobMessage: Types.JobRequirementsMessage) => {
const job: Types.Job = jobMessage.job
const job: Types.Job = jobMessage.job;
console.log('onMessage - job', job);
setJob(job);
setCompany(job.company || '');
@ -115,14 +115,14 @@ const JobCreator = (props: JobCreatorProps) => {
},
onError: (error: Types.ChatMessageError) => {
console.log('onError', error);
setSnack(error.content, "error");
setSnack(error.content, 'error');
setIsProcessing(false);
},
onComplete: () => {
setJobStatusType(null);
setJobStatus('');
setIsProcessing(false);
}
},
};
const handleJobUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -131,23 +131,23 @@ const JobCreator = (props: JobCreatorProps) => {
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) {
case "pdf":
docType = "pdf";
case 'pdf':
docType = 'pdf';
break;
case "docx":
docType = "docx";
case 'docx':
docType = 'docx';
break;
case "md":
docType = "markdown";
case 'md':
docType = 'markdown';
break;
case "txt":
docType = "txt";
case 'txt':
docType = 'txt';
break;
}
if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return;
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return;
}
try {
@ -175,7 +175,12 @@ const JobCreator = (props: JobCreatorProps) => {
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;
return (
@ -189,13 +194,7 @@ const JobCreator = (props: JobCreatorProps) => {
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{items.map((item, index) => (
<Chip
key={index}
label={item}
variant="outlined"
size="small"
sx={{ mb: 1 }}
/>
<Chip key={index} label={item} variant="outlined" size="small" sx={{ mb: 1 }} />
))}
</Stack>
</Box>
@ -214,49 +213,49 @@ const JobCreator = (props: JobCreatorProps) => {
/>
<CardContent sx={{ pt: 0 }}>
{renderRequirementSection(
"Technical Skills (Required)",
'Technical Skills (Required)',
jobRequirements.technicalSkills.required,
<Build color="primary" />,
true
)}
{renderRequirementSection(
"Technical Skills (Preferred)",
'Technical Skills (Preferred)',
jobRequirements.technicalSkills.preferred,
<Build color="action" />
)}
{renderRequirementSection(
"Experience Requirements (Required)",
'Experience Requirements (Required)',
jobRequirements.experienceRequirements.required,
<Work color="primary" />,
true
)}
{renderRequirementSection(
"Experience Requirements (Preferred)",
'Experience Requirements (Preferred)',
jobRequirements.experienceRequirements.preferred,
<Work color="action" />
)}
{renderRequirementSection(
"Soft Skills",
'Soft Skills',
jobRequirements.softSkills,
<Psychology color="secondary" />
)}
{renderRequirementSection(
"Experience",
'Experience',
jobRequirements.experience,
<Star color="warning" />
)}
{renderRequirementSection(
"Education",
'Education',
jobRequirements.education,
<Description color="info" />
)}
{renderRequirementSection(
"Certifications",
'Certifications',
jobRequirements.certifications,
<CheckCircle color="success" />
)}
{renderRequirementSection(
"Preferred Attributes",
'Preferred Attributes',
jobRequirements.preferredAttributes,
<Star color="secondary" />
)}
@ -306,134 +305,144 @@ const JobCreator = (props: JobCreatorProps) => {
};
const renderJobCreation = () => {
return (
<Box sx={{
width: "100%",
p: 1
}}>
{/* Upload Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Information"
subheader="Upload a job description or enter details manually"
avatar={<Work color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<CloudUpload sx={{ mr: 1 }} />
Upload Job Description
return (
<Box
sx={{
width: '100%',
p: 1,
}}
>
{/* Upload Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Information"
subheader="Upload a job description or enter details manually"
avatar={<Work color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<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>
<UploadBox onClick={handleUploadClick}>
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<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 variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Supported formats: PDF, DOCX, TXT, MD
</Typography>
<TextField
fullWidth
multiline
rows={isMobile ? 8 : 12}
placeholder="Paste or type the job description here..."
variant="outlined"
value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
<Button
variant="contained"
startIcon={<FileUploadIcon />}
disabled={isProcessing}
sx={{ mb: 2 }}
/>
{jobRequirements === null && jobDescription && (
<Button
variant="outlined"
onClick={handleExtractRequirements}
startIcon={<AutoFixHigh />}
disabled={isProcessing}
fullWidth={isMobile}
>
Extract Requirements
</Button>
)}
</Grid>
// onClick={handleUploadClick}
>
Choose File
</Button>
</UploadBox>
<VisuallyHiddenInput
ref={fileInputRef}
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleJobUpload}
/>
</Grid>
{(jobStatus || isProcessing) && (
<Box sx={{ mt: 3 }}>
<StatusBox>
{jobStatusType && <StatusIcon type={jobStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{jobStatus || 'Processing...'}
</Typography>
</StatusBox>
{isProcessing && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
</CardContent>
</Card>
{/* 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"
<Grid size={{ xs: 12, md: 6 }}>
<Typography
variant="h6"
gutterBottom
sx={{ display: 'flex', alignItems: 'center' }}
>
<Description sx={{ mr: 1 }} />
Or Enter Manually
</Typography>
<TextField
fullWidth
multiline
rows={isMobile ? 8 : 12}
placeholder="Paste or type the job description here..."
variant="outlined"
value={jobDescription}
onChange={e => setJobDescription(e.target.value)}
disabled={isProcessing}
sx={{ mb: 2 }}
/>
{jobRequirements === null && jobDescription && (
<Button
variant="outlined"
value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)}
required
onClick={handleExtractRequirements}
startIcon={<AutoFixHigh />}
disabled={isProcessing}
InputProps={{
startAdornment: <Work sx={{ mr: 1, color: 'text.secondary' }} />
}}
/>
</Grid>
fullWidth={isMobile}
>
Extract Requirements
</Button>
)}
</Grid>
</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>
{(jobStatus || isProcessing) && (
<Box sx={{ mt: 3 }}>
<StatusBox>
{jobStatusType && <StatusIcon type={jobStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{jobStatus || 'Processing...'}
</Typography>
</StatusBox>
{isProcessing && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
</CardContent>
</Card>
{/* <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
fullWidth
label="Job Location"
@ -446,83 +455,105 @@ const JobCreator = (props: JobCreatorProps) => {
}}
/>
</Grid> */}
</Grid>
</CardContent>
</Grid>
</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>
)}
{/* 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>
}
{/* 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>
}
{/* 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>
);
};
export { JobCreator };
export { JobCreator };

View File

@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
Grid,
@ -15,14 +15,26 @@ import {
useTheme,
LinearProgress,
useMediaQuery,
Button
Button,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import PendingIcon from '@mui/icons-material/Pending';
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 { BackstoryPageProps } from './BackstoryTab';
import { Job } from 'types/types';
@ -37,12 +49,18 @@ import { JobInfo } from './ui/JobInfo';
interface JobAnalysisProps extends BackstoryPageProps {
job: Job;
candidate: Candidate;
variant?: "small" | "normal";
variant?: 'small' | 'normal';
onAnalysisComplete: (skills: SkillAssessment[]) => void;
}
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 {
@ -52,16 +70,11 @@ interface SkillMatch extends SkillAssessment {
}
const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => {
const {
job,
candidate,
onAnalysisComplete,
variant = "normal",
} = props
const { job, candidate, onAnalysisComplete, variant = 'normal' } = props;
const { apiClient } = useAuth();
const { setSnack } = useAppState();
const { setSnack } = useAppState();
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 [creatingSession, setCreatingSession] = 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'));
// Handle accordion expansion
const handleAccordionChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpanded(isExpanded ? panel : false);
};
const handleAccordionChange =
(panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpanded(isExpanded ? panel : false);
};
const initializeRequirements = (job: Job) => {
if (!job || !job.requirements) {
return;
}
const requirements: { requirement: string, domain: string }[] = [];
const requirements: { requirement: string; domain: string }[] = [];
if (job.requirements?.technicalSkills) {
job.requirements.technicalSkills.required?.forEach(req => requirements.push({ requirement: req, domain: 'Technical Skills (required)' }));
job.requirements.technicalSkills.preferred?.forEach(req => requirements.push({ requirement: req, domain: 'Technical Skills (preferred)' }));
job.requirements.technicalSkills.required?.forEach(req =>
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) {
job.requirements.experienceRequirements.required?.forEach(req => requirements.push({ requirement: req, domain: 'Experience (required)' }));
job.requirements.experienceRequirements.preferred?.forEach(req => requirements.push({ requirement: req, domain: 'Experience (preferred)' }));
job.requirements.experienceRequirements.required?.forEach(req =>
requirements.push({ requirement: req, domain: 'Experience (required)' })
);
job.requirements.experienceRequirements.preferred?.forEach(req =>
requirements.push({
requirement: req,
domain: 'Experience (preferred)',
})
);
}
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) {
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) {
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) {
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) {
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 => ({
skill: req.requirement,
skillModified: req.requirement,
candidateId: candidate.id || "",
candidateId: candidate.id || '',
domain: req.domain,
status: 'waiting' as const,
assessment: "",
description: "",
assessment: '',
description: '',
evidenceFound: false,
evidenceStrength: "none",
evidenceStrength: 'none',
evidenceDetails: [],
matchScore: 0,
}));
@ -129,7 +170,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
setStatusMessage(null);
setLoadingRequirements(false);
setOverallScore(0);
}
};
useEffect(() => {
initializeRequirements(job);
@ -150,7 +191,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
const fetchMatchData = async (skills: SkillAssessment[]) => {
if (requirements.length === 0) return;
// Process requirements one by one
for (let i = 0; i < requirements.length; i++) {
try {
@ -160,19 +201,35 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
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 skillMatch = result.skillAssessment;
skills.push(skillMatch);
setMatchStatus('');
let matchScore: number = 0;
let matchScore = 0;
switch (skillMatch.evidenceStrength.toUpperCase()) {
case "STRONG": matchScore = 100; break;
case "MODERATE": matchScore = 75; break;
case "WEAK": matchScore = 50; break;
case "NONE": matchScore = 0; break;
case 'STRONG':
matchScore = 100;
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);
}
const match: SkillMatch = {
@ -191,7 +248,9 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
setSkillMatches(current => {
const completedMatches = current.filter(match => match.status === 'complete');
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);
}
return current;
@ -203,21 +262,21 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
updated[i] = {
...updated[i],
status: 'error',
assessment: 'Failed to analyze this requirement.'
assessment: 'Failed to analyze this requirement.',
};
return updated;
});
}
}
};
setAnalyzing(true);
const skills: SkillAssessment[] = [];
fetchMatchData(skills).then(() => {
setAnalyzing(false);
setStartAnalysis(false);
fetchMatchData(skills).then(() => {
setAnalyzing(false);
setStartAnalysis(false);
onAnalysisComplete && onAnalysisComplete(skills);
});
});
}, [job, onAnalysisComplete, startAnalysis, analyzing, requirements, loadingRequirements]);
// Get color based on match score
@ -243,30 +302,47 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
};
return (
<Box sx={{ display: "flex", flexDirection: "column", m: 0, p: 0 }}>
{variant !== "small" &&
<JobInfo job={job} variant="normal" />
}
<Box sx={{ display: 'flex', flexDirection: 'column', m: 0, p: 0 }}>
{variant !== 'small' && <JobInfo job={job} variant="normal" />}
<Box sx={{ 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 && <>
<Box
sx={{
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 }}>
Overall Match:
</Typography>
<Box sx={{
position: 'relative',
display: 'inline-flex',
mr: 2
}}>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
<Box
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
sx={{
@ -285,22 +361,32 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
</Typography>
</Box>
</Box>
<Chip
<Chip
label={
overallScore >= 80 ? "Excellent Match" :
overallScore >= 60 ? "Good Match" :
overallScore >= 40 ? "Partial Match" : "Low Match"
}
sx={{
overallScore >= 80
? 'Excellent Match'
: overallScore >= 60
? 'Good Match'
: overallScore >= 40
? 'Partial Match'
: 'Low Match'
}
sx={{
bgcolor: getMatchColor(overallScore),
color: 'white',
fontWeight: 'bold'
}}
fontWeight: 'bold',
}}
/>
</>}
</>
)}
</Box>
<Button sx={{ marginLeft: "auto" }} disabled={analyzing || startAnalysis} onClick={beginAnalysis} variant="contained">
{analyzing ? "Assessment in Progress" : "Start Skill Assessment"}
<Button
sx={{ marginLeft: 'auto' }}
disabled={analyzing || startAnalysis}
onClick={beginAnalysis}
variant="contained"
>
{analyzing ? 'Assessment in Progress' : 'Start Skill Assessment'}
</Button>
</Box>
@ -316,18 +402,19 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
<Typography variant="h5" component="h2" gutterBottom>
Requirements Analysis
</Typography>
{skillMatches.map((match, index) => (
<Accordion
<Accordion
key={index}
expanded={expanded === `panel${index}`}
onChange={handleAccordionChange(`panel${index}`)}
sx={{
sx={{
mb: 2,
border: '1px solid',
borderColor: match.status === 'complete'
? getMatchColor(match.matchScore)
: theme.palette.divider
borderColor:
match.status === 'complete'
? getMatchColor(match.matchScore)
: theme.palette.divider,
}}
>
<AccordionSummary
@ -335,61 +422,91 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
aria-controls={`panel${index}bh-content`}
id={`panel${index}bh-header`}
sx={{
bgcolor: match.status === 'complete'
? `${getMatchColor(match.matchScore)}22` // Add transparency
: 'inherit'
bgcolor:
match.status === 'complete'
? `${getMatchColor(match.matchScore)}22` // Add transparency
: 'inherit',
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: 'space-between'
}}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{getStatusIcon(match.status, match.matchScore)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 0, p: 0, m: 0 }}>
<Typography sx={{ ml: 1, mb: 0, fontWeight: 'medium', marginBottom: "0px !important" }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0,
p: 0,
m: 0,
}}
>
<Typography
sx={{
ml: 1,
mb: 0,
fontWeight: 'medium',
marginBottom: '0px !important',
}}
>
{match.skill}
</Typography>
</Typography>
<Typography variant="caption" sx={{ ml: 1, fontWeight: 'light' }}>
{match.domain}
</Typography>
</Box>
</Box>
{match.status === 'complete' ? (
<Chip
<Chip
label={`${match.matchScore}% Match`}
size="small"
sx={{
sx={{
bgcolor: getMatchColor(match.matchScore),
color: 'white',
minWidth: 90
}}
minWidth: 90,
}}
/>
) : match.status === 'waiting' ? (
<Chip
label="Waiting..."
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' ? (
<Chip
label="Analyzing..."
<Chip
label="Analyzing..."
size="small"
sx={{ bgcolor: theme.palette.grey[400], color: 'white', minWidth: 90 }}
sx={{
bgcolor: theme.palette.grey[400],
color: 'white',
minWidth: 90,
}}
/>
) : (
<Chip
label="Error"
<Chip
label="Error"
size="small"
sx={{ bgcolor: theme.palette.error.main, color: 'white', minWidth: 90 }}
sx={{
bgcolor: theme.palette.error.main,
color: 'white',
minWidth: 90,
}}
/>
)}
</Box>
</AccordionSummary>
<AccordionDetails>
{match.status === 'pending' ? (
<Box sx={{ width: '100%', p: 2 }}>
@ -400,42 +517,53 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
</Box>
) : match.status === 'error' ? (
<Typography color="error">
{match.assessment || "An error occurred while analyzing this requirement."}
{match.assessment || 'An error occurred while analyzing this requirement.'}
</Typography>
) : (
<Box>
<Typography variant="h6" gutterBottom>
Assessment
Assessment
</Typography>
<Typography paragraph sx={{ mb: 3 }}>
{match.assessment}
</Typography>
<Typography variant="h6" gutterBottom>
Supporting Evidence
</Typography>
{match.evidenceDetails && match.evidenceDetails.length > 0 ? (
match.evidenceDetails.map((evidence, evndex) => (
<Card
key={evndex}
variant="outlined"
sx={{
Supporting Evidence
</Typography>
{match.evidenceDetails && match.evidenceDetails.length > 0 ? (
match.evidenceDetails.map((evidence, evndex) => (
<Card
key={evndex}
variant="outlined"
sx={{
mb: 2,
borderLeft: '4px solid',
borderColor: theme.palette.primary.main,
}}
>
<CardContent>
<Typography variant="body1" component="div" sx={{ mb: 1, fontStyle: 'italic' }}>
"{evidence.quote}"
<Typography
variant="body1"
component="div"
sx={{ mb: 1, fontStyle: 'italic' }}
>
"{evidence.quote}"
</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">
Relevance: {evidence.context}
Relevance: {evidence.context}
</Typography>
<Typography variant="caption" color="text.secondary">
Source: {evidence.source}
Source: {evidence.source}
</Typography>
{/* <Chip
size="small"
@ -453,20 +581,17 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
No specific evidence found in candidate's profile.
</Typography>
)}
<Typography variant="h6" gutterBottom>
Skill description
</Typography>
<Typography paragraph>
{match.description}
</Typography>
{/* { match.ragResults && match.ragResults.length !== 0 && <>
<Typography variant="h6" gutterBottom>
Skill description
</Typography>
<Typography paragraph>{match.description}</Typography>
{/* { match.ragResults && match.ragResults.length !== 0 && <>
<Typography variant="h6" gutterBottom>
RAG Information
</Typography>
<VectorVisualizer inline rag={match.ragResults[0]} />
</>
} */}
</Box>
)}
</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>
)}
</Grid>
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center' }}>
<Typography variant="body1" color="textSecondary">
{loadingText}
@ -65,4 +65,4 @@ const LoadingComponent: React.FC<LoadingComponentProps> = ({
);
};
export { LoadingComponent};
export { LoadingComponent };

View File

@ -7,7 +7,7 @@ import {
Grid,
Chip,
FormControlLabel,
Checkbox
Checkbox,
} from '@mui/material';
import { LocationOn, Public, Home } from '@mui/icons-material';
import { Country, State, City } from 'country-state-city';
@ -32,11 +32,11 @@ const LocationInput: React.FC<LocationInputProps> = ({
helperText,
required = false,
disabled = false,
showCity = false
showCity = false,
}) => {
// Get all countries from the library
const allCountries = Country.getAllCountries();
const [selectedCountry, setSelectedCountry] = useState<ICountry | 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
const availableStates = selectedCountry ? State.getStatesOfCountry(selectedCountry.isoCode) : [];
// Get cities for selected state
const availableCities = selectedCountry && selectedState
? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode)
: [];
const availableCities =
selectedCountry && selectedState
? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode)
: [];
// Initialize state and city from value prop
useEffect(() => {
@ -70,28 +71,38 @@ const LocationInput: React.FC<LocationInputProps> = ({
// Update parent component when values change
useEffect(() => {
const newLocation: Partial<Location> = {};
if (selectedCountry) {
newLocation.country = selectedCountry.name;
}
if (selectedState) {
newLocation.state = selectedState.name;
}
if (selectedCity && showCity) {
newLocation.city = selectedCity.name;
}
if (isRemote) {
newLocation.remote = isRemote;
}
// 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);
}
}, [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) => {
setSelectedCountry(newValue);
@ -120,7 +131,7 @@ const LocationInput: React.FC<LocationInputProps> = ({
<LocationOn color="primary" />
Location {required && <span style={{ color: 'red' }}>*</span>}
</Typography>
<Grid container spacing={2}>
{/* Country Selection */}
<Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}>
@ -128,19 +139,21 @@ const LocationInput: React.FC<LocationInputProps> = ({
value={selectedCountry}
onChange={handleCountryChange}
options={allCountries}
getOptionLabel={(option) => option.name}
getOptionLabel={option => option.name}
disabled={disabled}
renderInput={(params) => (
renderInput={params => (
<TextField
{...params}
label="Country"
variant="outlined"
required={required}
error={error && required && !selectedCountry}
helperText={error && required && !selectedCountry ? 'Country is required' : helperText}
helperText={
error && required && !selectedCountry ? 'Country is required' : helperText
}
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}
onChange={handleStateChange}
options={availableStates}
getOptionLabel={(option) => option.name}
getOptionLabel={option => option.name}
disabled={disabled || availableStates.length === 0}
renderInput={(params) => (
renderInput={params => (
<TextField
{...params}
label="State/Region"
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}
onChange={handleCityChange}
options={availableCities}
getOptionLabel={(option) => option.name}
getOptionLabel={option => option.name}
disabled={disabled || availableCities.length === 0}
renderInput={(params) => (
renderInput={params => (
<TextField
{...params}
label="City"
variant="outlined"
placeholder={availableCities.length > 0 ? "Select city" : "No cities available"}
placeholder={availableCities.length > 0 ? 'Select city' : 'No cities available'}
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"
/>
)}
{isRemote && (
<Chip
label="Remote"
variant="filled"
color="success"
size="small"
/>
)}
{isRemote && <Chip label="Remote" variant="filled" color="success" size="small" />}
</Box>
</Grid>
)}
@ -287,22 +295,18 @@ const LocationInputDemo: React.FC = () => {
<Typography variant="h4" gutterBottom align="center" color="primary">
Location Input with Real Data
</Typography>
<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
</Typography>
<Grid container spacing={4}>
<Grid size={{ xs: 12 }}>
<Typography variant="h5" gutterBottom>
Basic Location Input
</Typography>
<LocationInput
value={location}
onChange={handleLocationChange}
required
/>
<LocationInput value={location} onChange={handleLocationChange} required />
</Grid>
<Grid size={{ xs: 12 }}>
@ -310,7 +314,7 @@ const LocationInputDemo: React.FC = () => {
control={
<Checkbox
checked={showAdvanced}
onChange={(e) => setShowAdvanced(e.target.checked)}
onChange={e => setShowAdvanced(e.target.checked)}
color="primary"
/>
}
@ -336,21 +340,24 @@ const LocationInputDemo: React.FC = () => {
<Typography variant="h6" gutterBottom>
Current Location Data:
</Typography>
<Box component="pre" sx={{
bgcolor: 'grey.100',
p: 2,
borderRadius: 1,
overflow: 'auto',
fontSize: '0.875rem'
}}>
<Box
component="pre"
sx={{
bgcolor: 'grey.100',
p: 2,
borderRadius: 1,
overflow: 'auto',
fontSize: '0.875rem',
}}
>
{JSON.stringify(location, null, 2)}
</Box>
</Grid>
<Grid size={{ xs: 12 }}>
<Typography variant="body2" color="text.secondary">
💡 This component uses the country-state-city library which is regularly updated
and includes ISO codes, flags, and comprehensive location data.
💡 This component uses the country-state-city library which is regularly updated and
includes ISO codes, flags, and comprehensive location data.
</Typography>
</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 { useResizeObserverAndMutationObserver } from '../hooks/useAutoScrollToBottom';
const defaultMermaidConfig : MermaidConfig = {
const defaultMermaidConfig: MermaidConfig = {
startOnLoad: true,
securityLevel: 'loose',
fontFamily: 'Fira Code',
@ -19,7 +19,7 @@ interface MermaidProps {
const Mermaid: React.FC<MermaidProps> = (props: MermaidProps) => {
const { chart, sx, className, mermaidConfig } = props;
const [ visible, setVisible] = useState<boolean>(false);
const [visible, setVisible] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const checkVisible = useCallback(() => {
@ -38,25 +38,29 @@ const Mermaid: React.FC<MermaidProps> = (props: MermaidProps) => {
await mermaid.initialize(mermaidConfig || defaultMermaidConfig);
await mermaid.run({ nodes: [containerRef.current] });
} catch (e) {
console.error("Mermaid render error:", e, containerRef.current);
console.error('Mermaid render error:', e, containerRef.current);
}
}
}
};
renderMermaid();
}, [containerRef, mermaidConfig, visible, chart]);
// Observe container and TextField size, plus DOM changes
useResizeObserverAndMutationObserver(containerRef, null, checkVisible);
return <Box className={className || "Mermaid"} ref={containerRef} sx={{
display: "flex",
flexGrow: 1,
...sx
}}>
{chart}
</Box>;
return (
<Box
className={className || 'Mermaid'}
ref={containerRef}
sx={{
display: 'flex',
flexGrow: 1,
...sx,
}}
>
{chart}
</Box>
);
};
export {
Mermaid
};
export { Mermaid };

View File

@ -23,7 +23,7 @@ import { SxProps, Theme } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
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';
@ -32,9 +32,19 @@ import { SetSnackType } from './Snack';
import { CopyBubble } from './CopyBubble';
import { Scrollable } from './Scrollable';
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 defaultStyle = {
padding: theme.spacing(1, 2),
@ -163,9 +173,11 @@ const getStyle = (theme: Theme, type: ApiActivityType | ChatSenderType | "error"
}
return styles[type];
}
};
const getIcon = (activityType: ApiActivityType | ChatSenderType | "error"): React.ReactNode | null => {
const getIcon = (
activityType: ApiActivityType | ChatSenderType | 'error'
): React.ReactNode | null => {
const icons: any = {
error: <ErrorOutline color="error" />,
generating: <LocationSearchingIcon />,
@ -177,23 +189,23 @@ const getIcon = (activityType: ApiActivityType | ChatSenderType | "error"): Reac
tooling: <LocationSearchingIcon />,
};
return icons[activityType] || null;
}
};
interface MessageProps extends BackstoryElementProps {
message: ChatMessageUser | ChatMessage | ChatMessageError | ChatMessageStatus,
title?: string,
chatSession?: ChatSession,
className?: string,
sx?: SxProps<Theme>,
expandable?: boolean,
expanded?: boolean,
onExpand?: (open: boolean) => void,
};
message: ChatMessageUser | ChatMessage | ChatMessageError | ChatMessageStatus;
title?: string;
chatSession?: ChatSession;
className?: string;
sx?: SxProps<Theme>;
expandable?: boolean;
expanded?: boolean;
onExpand?: (open: boolean) => void;
}
interface MessageMetaProps {
metadata: ChatMessageMetaData,
messageProps: MessageProps
};
metadata: ChatMessageMetaData;
messageProps: MessageProps;
}
const MessageMeta = (props: MessageMetaProps) => {
const {
@ -207,162 +219,255 @@ const MessageMeta = (props: MessageMetaProps) => {
} = props.metadata || {};
const message: any = props.messageProps.message;
let llm_submission: string = "<|system|>\n"
llm_submission += message.system_prompt + "\n\n"
llm_submission += message.context_prompt
let llm_submission = '<|system|>\n';
llm_submission += message.system_prompt + '\n\n';
llm_submission += message.context_prompt;
return (<>
{
promptEvalDuration !== 0 && evalDuration !== 0 && <>
<TableContainer component={Card} className="PromptStats" sx={{ mb: 1 }}>
<Table aria-label="prompt stats" size="small">
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell align="right" >Tokens</TableCell>
<TableCell align="right">Time (s)</TableCell>
<TableCell align="right">TPS</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Prompt</TableCell>
<TableCell align="right">{promptEvalCount}</TableCell>
<TableCell align="right">{Math.round(promptEvalDuration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(promptEvalCount * 10 ** 9 / promptEvalDuration)}</TableCell>
</TableRow>
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Response</TableCell>
<TableCell align="right">{evalCount}</TableCell>
<TableCell align="right">{Math.round(evalDuration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(evalCount * 10 ** 9 / evalDuration)}</TableCell>
</TableRow>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Total</TableCell>
<TableCell align="right">{promptEvalCount + evalCount}</TableCell>
<TableCell align="right">{Math.round((promptEvalDuration + evalDuration) / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round((promptEvalCount + evalCount) * 10 ** 9 / (promptEvalDuration + evalDuration))}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</>
}
{
tools && tools.tool_calls && tools.tool_calls.length !== 0 &&
<Accordion sx={{ boxSizing: "border-box" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Tools queried
</Box>
</AccordionSummary>
<AccordionDetails>
{
tools.tool_calls.map((tool: any, index: number) =>
<Box key={index} sx={{ m: 0, p: 1, pt: 0, display: "flex", flexDirection: "column", border: "1px solid #e0e0e0" }}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "column", mt: 1, mb: 1, fontWeight: "bold" }}>
{tool.name}
</Box>
{tool.content !== "null" &&
<JsonView
displayDataTypes={false}
objectSortKeys={true}
collapsed={1} value={JSON.parse(tool.content)} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}>
<JsonView.String
render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) {
return <pre {...reset} style={{ display: "flex", border: "none", ...reset.style }}>{children}</pre>
}
}}
/>
</JsonView>
}
{tool.content === "null" && "No response from tool call"}
</Box>)
}
</AccordionDetails>
</Accordion>
}
{
ragResults.map((collection: ChromaDBGetResponse) => (
return (
<>
{promptEvalDuration !== 0 && evalDuration !== 0 && (
<>
<TableContainer component={Card} className="PromptStats" sx={{ mb: 1 }}>
<Table aria-label="prompt stats" size="small">
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell align="right">Tokens</TableCell>
<TableCell align="right">Time (s)</TableCell>
<TableCell align="right">TPS</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">
Prompt
</TableCell>
<TableCell align="right">{promptEvalCount}</TableCell>
<TableCell align="right">
{Math.round(promptEvalDuration / 10 ** 7) / 100}
</TableCell>
<TableCell align="right">
{Math.round((promptEvalCount * 10 ** 9) / promptEvalDuration)}
</TableCell>
</TableRow>
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">
Response
</TableCell>
<TableCell align="right">{evalCount}</TableCell>
<TableCell align="right">{Math.round(evalDuration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">
{Math.round((evalCount * 10 ** 9) / evalDuration)}
</TableCell>
</TableRow>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">
Total
</TableCell>
<TableCell align="right">{promptEvalCount + evalCount}</TableCell>
<TableCell align="right">
{Math.round((promptEvalDuration + evalDuration) / 10 ** 7) / 100}
</TableCell>
<TableCell align="right">
{Math.round(
((promptEvalCount + evalCount) * 10 ** 9) /
(promptEvalDuration + evalDuration)
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</>
)}
{tools && tools.tool_calls && tools.tool_calls.length !== 0 && (
<Accordion sx={{ boxSizing: 'border-box' }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: '0.8rem' }}>Tools queried</Box>
</AccordionSummary>
<AccordionDetails>
{tools.tool_calls.map((tool: any, index: number) => (
<Box
key={index}
sx={{
m: 0,
p: 1,
pt: 0,
display: 'flex',
flexDirection: 'column',
border: '1px solid #e0e0e0',
}}
>
{index !== 0 && <Divider />}
<Box
sx={{
fontSize: '0.75rem',
display: 'flex',
flexDirection: 'column',
mt: 1,
mb: 1,
fontWeight: 'bold',
}}
>
{tool.name}
</Box>
{tool.content !== 'null' && (
<JsonView
displayDataTypes={false}
objectSortKeys={true}
collapsed={1}
value={JSON.parse(tool.content)}
style={{
fontSize: '0.8rem',
maxHeight: '20rem',
overflow: 'auto',
}}
>
<JsonView.String
render={({ children, ...reset }) => {
if (typeof children === 'string' && children.match('\n')) {
return (
<pre
{...reset}
style={{
display: 'flex',
border: 'none',
...reset.style,
}}
>
{children}
</pre>
);
}
}}
/>
</JsonView>
)}
{tool.content === 'null' && 'No response from tool call'}
</Box>
))}
</AccordionDetails>
</Accordion>
)}
{ragResults.map((collection: ChromaDBGetResponse) => (
<Accordion key={collection.name}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Top {collection.ids?.length} RAG matches from {collection.size} entries using an embedding vector of {collection.queryEmbedding?.length} dimensions
<Box sx={{ fontSize: '0.8rem' }}>
Top {collection.ids?.length} RAG matches from {collection.size} entries using an
embedding vector of {collection.queryEmbedding?.length} dimensions
</Box>
</AccordionSummary>
<AccordionDetails>
<VectorVisualizer inline
{...props.messageProps} {...props.metadata}
rag={collection} />
<VectorVisualizer inline {...props.messageProps} {...props.metadata} rag={collection} />
{/* { ...rag, query: message.prompt }} /> */}
</AccordionDetails>
</Accordion>
))
}
))}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Full Response Details
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ pb: 1 }}>Copy LLM submission: <CopyBubble content={llm_submission} /></Box>
<JsonView displayDataTypes={false} objectSortKeys={true} collapsed={1} value={message} style={{ fontSize: "0.8rem", maxHeight: "20rem", overflow: "auto" }}>
<JsonView.String
render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) {
return <pre {...reset} style={{ display: "inline", border: "none", ...reset.style }}>{children.trim()}</pre>
}
}}
/>
</JsonView>
</AccordionDetails>
</Accordion>
</>);
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: '0.8rem' }}>Full Response Details</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ pb: 1 }}>
Copy LLM submission: <CopyBubble content={llm_submission} />
</Box>
<JsonView
displayDataTypes={false}
objectSortKeys={true}
collapsed={1}
value={message}
style={{ fontSize: '0.8rem', maxHeight: '20rem', overflow: 'auto' }}
>
<JsonView.String
render={({ children, ...reset }) => {
if (typeof children === 'string' && children.match('\n')) {
return (
<pre
{...reset}
style={{
display: 'inline',
border: 'none',
...reset.style,
}}
>
{children.trim()}
</pre>
);
}
}}
/>
</JsonView>
</AccordionDetails>
</Accordion>
</>
);
};
interface MessageContainerProps {
type: ApiActivityType | ChatSenderType | "error",
metadataView?: React.ReactNode | null,
messageView?: React.ReactNode | null,
sx?: SxProps<Theme>,
copyContent?: string,
};
type: ApiActivityType | ChatSenderType | 'error';
metadataView?: React.ReactNode | null;
messageView?: React.ReactNode | null;
sx?: SxProps<Theme>;
copyContent?: string;
}
const MessageContainer = (props: MessageContainerProps) => {
const { type, sx, messageView, metadataView, copyContent } = props;
const icon = getIcon(type);
return <Box
className={`Message Message-${type}`}
sx={{
display: "flex",
flexDirection: "column",
m: 0,
mt: 1,
marginBottom: "0px !important", // Remove whitespace from expanded Accordion
gap: 1,
...sx,
}}>
<Box sx={{ display: "flex", flexDirection: 'row', alignItems: 'center', gap: 1 }}>
{icon !== null && icon}
{messageView}
return (
<Box
className={`Message Message-${type}`}
sx={{
display: 'flex',
flexDirection: 'column',
m: 0,
mt: 1,
marginBottom: '0px !important', // Remove whitespace from expanded Accordion
gap: 1,
...sx,
}}
>
<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 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, title, sx, className, chatSession, onExpand, expanded, expandable } = props;
const [metaExpanded, setMetaExpanded] = useState<boolean>(false);
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 handleMetaExpandClick = () => {
@ -370,32 +475,48 @@ const Message = (props: MessageProps) => {
};
let content;
if (typeof (message.content) === "string") {
if (typeof message.content === 'string') {
content = message.content.trim();
} else {
console.error(`message content is not a string, it is a ${typeof message.content}`);
return (<></>)
return <></>;
}
if (!content) {
return (<></>)
};
return <></>;
}
const messageView = (
<StyledMarkdown chatSession={chatSession} streaming={message.status === "streaming"} content={content} />
<StyledMarkdown
chatSession={chatSession}
streaming={message.status === 'streaming'}
content={content}
/>
);
let metadataView = (<></>);
let metadata: ChatMessageMetaData | null = ('metadata' in message) ? (message.metadata as ChatMessageMetaData || null) : null;
let metadataView = <></>;
let metadata: ChatMessageMetaData | null =
'metadata' in message ? (message.metadata as ChatMessageMetaData) || null : null;
if ('role' in message && message.role === 'user') {
metadata = null;
}
if (metadata) {
metadataView = (
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<Box sx={{ 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 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<Box
sx={{
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
</Button>
<ExpandMore
@ -403,7 +524,8 @@ const Message = (props: MessageProps) => {
expand={metaExpanded}
onClick={handleMetaExpandClick}
aria-expanded={true /*message.expanded*/}
aria-label="show more">
aria-label="show more"
>
<ExpandMoreIcon />
</ExpandMore>
</Box>
@ -412,16 +534,26 @@ const Message = (props: MessageProps) => {
<MessageMeta messageProps={props} metadata={metadata} />
</CardContent>
</Collapse>
</Box>);
</Box>
);
}
const copyContent = (type === 'assistant') ? message.content : undefined;
const copyContent = type === 'assistant' ? message.content : undefined;
if (!expandable) {
/* When not expandable, the styles are applied directly to MessageContainer */
return (<>
{messageView && <MessageContainer copyContent={copyContent} type={type} {...{ messageView, metadataView }} sx={{ ...style, ...sx }} />}
</>);
return (
<>
{messageView && (
<MessageContainer
copyContent={copyContent}
type={type}
{...{ messageView, metadataView }}
sx={{ ...style, ...sx }}
/>
)}
</>
);
}
// Determine if Accordion is controlled
@ -431,8 +563,11 @@ const Message = (props: MessageProps) => {
expanded={isControlled ? expanded : undefined} // Omit expanded prop for uncontrolled
defaultExpanded={expanded} // Default to collapsed for uncontrolled Accordion
className={className}
onChange={(_event, newExpanded) => { isControlled && onExpand && onExpand(newExpanded) }}
sx={{ ...sx, ...style }}>
onChange={(_event, newExpanded) => {
isControlled && onExpand && onExpand(newExpanded);
}}
sx={{ ...sx, ...style }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
slotProps={{
@ -440,27 +575,27 @@ const Message = (props: MessageProps) => {
sx: {
display: 'flex',
justifyItems: 'center',
m: 0, p: 0,
m: 0,
p: 0,
fontWeight: 'bold',
fontSize: '1.1rem',
},
},
}}>
}}
>
{title || ''}
</AccordionSummary>
<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>
</Accordion>
);
}
export type {
MessageProps,
};
export {
Message,
MessageMeta,
};
export type { MessageProps };
export { Message, MessageMeta };

View File

@ -17,7 +17,7 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
previousTimestamp.current = timestamp;
setAnimationKey(prev => prev + 1);
setIsAnimating(true);
// Reset animation state after animation completes
const timer = setTimeout(() => {
setIsAnimating(false);
@ -37,8 +37,8 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
};
const baseCoreStyle: React.CSSProperties = {
width: 0,
height: 0,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: '#2196f3',
position: 'relative',
@ -135,8 +135,8 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
}
`}
</style>
<Box sx={{...containerStyle, ...sx}}>
<Box sx={{ ...containerStyle, ...sx }}>
{/* Base circle */}
<div style={coreStyle} />
@ -144,35 +144,21 @@ const Pulse: React.FC<PulseProps> = ({ timestamp, sx }) => {
{isAnimating && (
<>
{/* Primary pulse ring */}
<div
key={`pulse-1-${animationKey}`}
style={pulseRing1Style}
/>
<div key={`pulse-1-${animationKey}`} style={pulseRing1Style} />
{/* Secondary pulse ring with delay */}
<div
key={`pulse-2-${animationKey}`}
style={pulseRing2Style}
/>
<div key={`pulse-2-${animationKey}`} style={pulseRing2Style} />
{/* Ripple effect */}
<div
key={`ripple-${animationKey}`}
style={rippleStyle}
/>
<div key={`ripple-${animationKey}`} style={rippleStyle} />
{/* Outer ripple */}
<div
key={`ripple-outer-${animationKey}`}
style={outerRippleStyle}
/>
<div key={`ripple-outer-${animationKey}`} style={outerRippleStyle} />
</>
)}
</Box>
</>
);
};
export { Pulse } ;
export { Pulse };

View File

@ -7,7 +7,7 @@ interface QuoteContainerProps {
}
const QuoteContainer = styled(Paper, {
shouldForwardProp: (prop) => prop !== 'size',
shouldForwardProp: prop => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
position: 'relative',
padding: size === 'small' ? theme.spacing(1) : theme.spacing(4),
@ -29,7 +29,7 @@ const QuoteContainer = styled(Paper, {
}));
const QuoteText = styled(Typography, {
shouldForwardProp: (prop) => prop !== 'size',
shouldForwardProp: prop => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
fontSize: size === 'small' ? '0.9rem' : '1.2rem',
lineHeight: size === 'small' ? 1.4 : 1.6,
@ -43,7 +43,7 @@ const QuoteText = styled(Typography, {
}));
const QuoteMark = styled(Typography, {
shouldForwardProp: (prop) => prop !== 'size',
shouldForwardProp: prop => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
fontSize: size === 'small' ? '2.5rem' : '4rem',
fontFamily: '"Georgia", "Times New Roman", serif',
@ -67,7 +67,7 @@ const ClosingQuote = styled(QuoteMark)(({ size = 'normal' }: QuoteContainerProps
}));
const AuthorText = styled(Typography, {
shouldForwardProp: (prop) => prop !== 'size',
shouldForwardProp: prop => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
marginTop: size === 'small' ? theme.spacing(1) : theme.spacing(2),
textAlign: 'right',
@ -82,7 +82,7 @@ const AuthorText = styled(Typography, {
}));
const AccentLine = styled(Box, {
shouldForwardProp: (prop) => prop !== 'size',
shouldForwardProp: prop => prop !== 'size',
})<QuoteContainerProps>(({ theme, size = 'normal' }) => ({
width: size === 'small' ? '40px' : '60px',
height: size === 'small' ? '1px' : '2px',
@ -104,14 +104,14 @@ const Quote = (props: QuoteProps) => {
<QuoteContainer size={size} elevation={0} sx={sx}>
<OpeningQuote size={size}>"</OpeningQuote>
<ClosingQuote size={size}>"</ClosingQuote>
<Box sx={{ position: 'relative', zIndex: 2 }}>
<QuoteText size={size} variant="body1">
{quote}
</QuoteText>
<AccentLine size={size} />
{author && (
<AuthorText size={size} variant="body2">
{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 {
Tabs,
Tab,
Box,
Button,
Paper,
Typography,
LinearProgress,
} from '@mui/material';
import { Job, Candidate, SkillAssessment } from "types/types";
import { Tabs, Tab, Box, Button, Paper, Typography, LinearProgress } from '@mui/material';
import { Job, Candidate, SkillAssessment } from 'types/types';
import { Scrollable } from './Scrollable';
import { useAuth } from 'hooks/AuthContext';
import * as Types from 'types/types';
@ -31,7 +23,12 @@ interface ResumeGeneratorProps {
}
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) => {
@ -49,7 +46,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);
}
};
useEffect(() => {
if (!job || !candidate || !skills || generated) {
@ -58,8 +55,8 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
setGenerated(true);
setStatusType("thinking");
setStatus("Starting resume generation...");
setStatusType('thinking');
setStatus('Starting resume generation...');
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
onMessage: (message: Types.ChatMessageResume) => {
@ -71,7 +68,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
onStreaming: (chunk: Types.ChatMessageStreaming) => {
if (status === '') {
setStatus('Generating resume...');
setStatusType("generating");
setStatusType('generating');
}
setResume(chunk.content);
},
@ -92,13 +89,17 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
};
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;
};
generateResume();
}, [job, candidate, apiClient, resume, skills, generated, setSystemPrompt, setPrompt, setResume]);
const handleSave = useCallback(async () => {
if (!resume) {
setSnack('No resume to save!');
@ -122,45 +123,61 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
}, [apiClient, candidate.id, job.id, resume, setSnack]);
return (
<Box
className="ResumeGenerator"
sx={{
display: "flex",
flexDirection: "column",
}}>
{user?.isAdmin && <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" />
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" />
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" />
</Tabs>
</Box>}
<Box
className="ResumeGenerator"
sx={{
display: 'flex',
flexDirection: 'column',
}}
>
{user?.isAdmin && (
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" />
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" />
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" />
</Tabs>
</Box>
)}
{status && <Box sx={{ mt: 0, mb: 1 }}>
<StatusBox>
{statusType && <StatusIcon type={statusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{status || 'Processing...'}
</Typography>
</StatusBox>
{status && !error && <LinearProgress sx={{ mt: 1 }} />}
</Box>}
{status && (
<Box sx={{ mt: 0, mb: 1 }}>
<StatusBox>
{statusType && <StatusIcon type={statusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{status || 'Processing...'}
</Typography>
</StatusBox>
{status && !error && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
<Paper elevation={3} sx={{ p: 3, m: 1, mt: 0 }}><Scrollable autoscroll sx={{ display: "flex", flexGrow: 1, position: "relative" }}>
{tabValue === 'system' && <pre>{systemPrompt}</pre>}
{tabValue === 'prompt' && <pre>{prompt}</pre>}
{tabValue === 'resume' && <><CopyBubble onClick={() => { setSnack('Resume copied to clipboard!'); }} sx={{ position: "absolute", top: 0, right: 0 }} content={resume} /><StyledMarkdown content={resume} /></>}
</Scrollable></Paper>
<Paper elevation={3} sx={{ p: 3, m: 1, mt: 0 }}>
<Scrollable autoscroll sx={{ display: 'flex', flexGrow: 1, position: 'relative' }}>
{tabValue === 'system' && <pre>{systemPrompt}</pre>}
{tabValue === 'prompt' && <pre>{prompt}</pre>}
{tabValue === 'resume' && (
<>
<CopyBubble
onClick={() => {
setSnack('Resume copied to clipboard!');
}}
sx={{ position: 'absolute', top: 0, right: 0 }}
content={resume}
/>
<StyledMarkdown content={resume} />
</>
)}
</Scrollable>
</Paper>
{resume && !status && !error && <Button onClick={handleSave} variant="contained" color="primary" sx={{ mt: 2 }}>
Save Resume
</Button>}
{resume && !status && !error && (
<Button onClick={handleSave} variant="contained" color="primary" sx={{ mt: 2 }}>
Save Resume
</Button>
)}
</Box>
)
};
export {
ResumeGenerator
);
};
export { ResumeGenerator };

View File

@ -14,14 +14,27 @@ interface ScrollableProps {
}
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
const defaultTextFieldRef = useRef<HTMLElement | null>(null);
const scrollRef = useAutoScrollToBottom(textFieldRef ?? defaultTextFieldRef, true, fallbackThreshold, contentUpdateTrigger);
const scrollRef = useAutoScrollToBottom(
textFieldRef ?? defaultTextFieldRef,
true,
fallbackThreshold,
contentUpdateTrigger
);
return (
<Box
className={`Scrollable ${className || ""}`}
className={`Scrollable ${className || ''}`}
sx={{
display: 'flex',
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 {
setSnack: SetSnackType;
};
}
interface SnackProps {
sx?: SxProps<Theme>;
className?: string;
};
}
const Snack = forwardRef<SnackHandle, SnackProps>(({
className,
sx
}: SnackProps, ref) => {
const Snack = forwardRef<SnackHandle, SnackProps>(({ className, sx }: SnackProps, ref) => {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [severity, setSeverity] = useState<SeverityType>("success");
const [message, setMessage] = useState('');
const [severity, setSeverity] = useState<SeverityType>('success');
// Set the snack pop-up and open it
const setSnack: SetSnackType = useCallback<SetSnackType>((message: string, severity: SeverityType = "success") => {
setTimeout(() => {
setMessage(message);
setSeverity(severity);
setOpen(true);
});
}, [setMessage, setSeverity, setOpen]);
const setSnack: SetSnackType = useCallback<SetSnackType>(
(message: string, severity: SeverityType = 'success') => {
setTimeout(() => {
setMessage(message);
setSeverity(severity);
setOpen(true);
});
},
[setMessage, setSeverity, setOpen]
);
useImperativeHandle(ref, () => ({
setSnack: (message: string, severity?: SeverityType) => {
setSnack(message, severity);
}
},
}));
const handleSnackClose = (
event: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
const handleSnackClose = (event: React.SyntheticEvent | Event, reason?: SnackbarCloseReason) => {
if (reason === 'clickaway') {
return;
}
@ -53,28 +50,19 @@ const Snack = forwardRef<SnackHandle, SnackProps>(({
return (
<Snackbar
className={className || "Snack"}
className={className || 'Snack'}
sx={{ ...sx }}
open={open}
autoHideDuration={(severity === "success" || severity === "info") ? 1500 : 6000}
onClose={handleSnackClose}>
<Alert
onClose={handleSnackClose}
severity={severity}
variant="filled"
sx={{ width: '100%' }}
>
autoHideDuration={severity === 'success' || severity === 'info' ? 1500 : 6000}
onClose={handleSnackClose}
>
<Alert onClose={handleSnackClose} severity={severity} variant="filled" sx={{ width: '100%' }}>
{message}
</Alert>
</Snackbar>
)
);
});
export type {
SeverityType,
SetSnackType
};
export type { SeverityType, SetSnackType };
export {
Snack
};
export { Snack };

View File

@ -17,66 +17,90 @@ import { CandidateQuestion, ChatQuery, ChatSession } from 'types/types';
import { ChatSubmitQueryInterface } from 'components/BackstoryQuery';
interface StyledMarkdownProps extends BackstoryElementProps {
className?: string,
content: string,
streaming?: boolean,
chatSession?: ChatSession,
submitQuery?: ChatSubmitQueryInterface
};
className?: string;
content: string;
streaming?: boolean;
chatSession?: ChatSession;
submitQuery?: ChatSubmitQueryInterface;
}
const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProps) => {
const { className, content, chatSession, submitQuery, sx, streaming } = props;
const theme = useTheme();
const overrides: any = {
p: { component: (element: any) =>{
return <div>{element.children}</div>
}},
p: {
component: (element: any) => {
return <div>{element.children}</div>;
},
},
pre: {
component: (element: any) => {
const { className } = element.children.props;
const content = element.children?.props?.children || "";
if (className === "lang-mermaid" && !streaming) {
const content = element.children?.props?.children || '';
if (className === 'lang-mermaid' && !streaming) {
return <Mermaid className="Mermaid" chart={content} />;
}
if (className === "lang-markdown") {
if (className === 'lang-markdown') {
return <MuiMarkdown children={content} />;
}
if (className === "lang-json" && !streaming) {
if (className === 'lang-json' && !streaming) {
try {
let fixed = JSON.parse(jsonrepair(content));
return <Scrollable className="JsonViewScrollable">
<JsonView
className="JsonView"
style={{
...vscodeTheme,
fontSize: "0.8rem",
maxHeight: "10rem",
padding: "14px 0",
overflow: "hidden",
width: "100%",
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>
}
const fixed = JSON.parse(jsonrepair(content));
return (
<Scrollable className="JsonViewScrollable">
<JsonView
className="JsonView"
style={{
...vscodeTheme,
fontSize: '0.8rem',
maxHeight: '10rem',
padding: '14px 0',
overflow: 'hidden',
width: '100%',
minHeight: 'max-content',
backgroundColor: 'transparent',
}}
/>
</JsonView>
</Scrollable>
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>
);
}
}}
/>
</JsonView>
</Scrollable>
);
} 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: {
@ -84,7 +108,7 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
props: {
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => {
const href = event.currentTarget.getAttribute('href');
console.log("StyledMarkdown onClick:", href);
console.log('StyledMarkdown onClick:', href);
if (href) {
if (href.match(/^\//)) {
event.preventDefault();
@ -93,15 +117,15 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
}
},
sx: {
wordBreak: "break-all",
wordBreak: 'break-all',
color: theme.palette.secondary.main,
textDecoration: 'none',
'&:hover': {
color: theme.palette.custom.highlight,
textDecoration: 'underline',
}
}
}
},
},
},
},
BackstoryQuery: {
component: (props: { query: string }) => {
@ -109,16 +133,20 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
try {
const query = JSON.parse(queryString);
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) {
console.log("StyledMarkdown error:", queryString, e);
console.log('StyledMarkdown error:', queryString, e);
return props.query;
}
},
}
},
};
if (chatSession) {
@ -126,31 +154,31 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
component: (props: { prompt: string }) => {
const prompt = props.prompt.replace(/(\w+):/g, '"$1":');
try {
return <GenerateImage {...{ chatSession, prompt }} />
return <GenerateImage {...{ chatSession, prompt }} />;
} catch (e) {
console.log("StyledMarkdown error:", prompt, e);
console.log('StyledMarkdown error:', prompt, e);
return props.prompt;
}
}
}
},
};
}
return <Box
className={`MuiMarkdown ${className || ""}`}
sx={{
display: "flex",
m: 0,
p: 0,
boxSizing: "border-box",
flexGrow: 1,
height: "auto",
...sx
}}>
<MuiMarkdown
overrides={overrides}
children={content}
/>
</Box>;
return (
<Box
className={`MuiMarkdown ${className || ''}`}
sx={{
display: 'flex',
m: 0,
p: 0,
boxSizing: 'border-box',
flexGrow: 1,
height: 'auto',
...sx,
}}
>
<MuiMarkdown overrides={overrides} children={content} />
</Box>
);
};
export { StyledMarkdown };
export { StyledMarkdown };

View File

@ -28,7 +28,7 @@ import { useNavigate } from 'react-router-dom';
interface VectorVisualizerProps extends BackstoryPageProps {
inline?: boolean;
rag?: Types.ChromaDBGetResponse;
};
}
interface Metadata {
id: string;
@ -43,10 +43,10 @@ const emptyQuerySet: Types.ChromaDBGetResponse = {
metadatas: [],
embeddings: [],
distances: [],
name: "Empty",
name: 'Empty',
size: 0,
dimensions: 2,
query: ""
query: '',
};
interface PlotData {
@ -105,16 +105,14 @@ const config: Partial<Plotly.Config> = {
// | "hovercompare"
// | "hoverclosest"
// | "v1hovermode";
modeBarButtonsToRemove: [
'lasso2d', 'select2d',
]
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
};
const layout: Partial<Plotly.Layout> = {
autosize: false,
clickmode: 'event+select',
paper_bgcolor: '#FFFFFF', // white
plot_bgcolor: '#FFFFFF', // white plot background
plot_bgcolor: '#FFFFFF', // white plot background
font: {
family: 'Roboto, sans-serif',
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)
xanchor: 'left',
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[] => {
@ -155,31 +153,31 @@ const emojiMap: Record<string, string> = {
};
const colorMap: Record<string, string> = {
query: '#D4A017', // Golden Ochre — strong highlight
resume: '#4A7A7D', // Dusty Teal — secondary theme color
projects: '#1A2536', // Midnight Blue — rich and deep
news: '#D3CDBF', // Warm Gray — soft and neutral
query: '#D4A017', // Golden Ochre — strong highlight
resume: '#4A7A7D', // Dusty Teal — secondary theme color
projects: '#1A2536', // Midnight Blue — rich and deep
news: '#D3CDBF', // Warm Gray — soft and neutral
'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_UNFOCUS_SIZE = 2.;
const DEFAULT_SIZE = 6;
const DEFAULT_UNFOCUS_SIZE = 2;
type Node = {
id: string,
content: string, // Portion of content that was used for embedding
fullContent: string | undefined, // Portion of content plus/minus buffer
emoji: string,
docType: string,
source_file: string,
distance: number | undefined,
path: string,
chunkBegin: number,
lineBegin: number,
chunkEnd: number,
lineEnd: number,
sx: SxProps,
id: string;
content: string; // Portion of content that was used for embedding
fullContent: string | undefined; // Portion of content plus/minus buffer
emoji: string;
docType: string;
source_file: string;
distance: number | undefined;
path: string;
chunkBegin: number;
lineBegin: number;
chunkEnd: number;
lineEnd: number;
sx: SxProps;
};
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 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
* 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();
svgContainer.style.width = `${plotContainerRect.width}px`;
svgContainer.style.height = `${plotContainerRect.height}px`;
if (plotDimensions.width !== plotContainerRect.width || plotDimensions.height !== plotContainerRect.height) {
setPlotDimensions({ width: plotContainerRect.width, height: plotContainerRect.height });
if (
plotDimensions.width !== plotContainerRect.width ||
plotDimensions.height !== plotContainerRect.height
) {
setPlotDimensions({
width: plotContainerRect.width,
height: plotContainerRect.height,
});
}
}
});
}
};
resize();
});
@ -238,12 +243,12 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
setResult(result);
} catch (error) {
console.error('Error obtaining collection information:', error);
setSnack("Unable to obtain collection information.", "error");
};
setSnack('Unable to obtain collection information.', 'error');
}
};
fetchCollection();
}, [result, setSnack, view2D])
}, [result, setSnack, view2D]);
useEffect(() => {
if (!result || !result.embeddings) return;
@ -251,13 +256,13 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
const full: Types.ChromaDBGetResponse = {
...result,
ids: [...result.ids || []],
documents: [...result.documents || []],
ids: [...(result.ids || [])],
documents: [...(result.documents || [])],
embeddings: [...result.embeddings],
metadatas: [...result.metadatas || []],
metadatas: [...(result.metadatas || [])],
};
let is2D = full.embeddings.every((v: number[]) => v.length === 2);
let is3D = full.embeddings.every((v: number[]) => v.length === 3);
const is2D = full.embeddings.every((v: number[]) => v.length === 2);
const is3D = full.embeddings.every((v: number[]) => v.length === 3);
if ((view2D && !is2D) || (!view2D && !is3D)) {
return;
}
@ -267,7 +272,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
return;
}
let query: Types.ChromaDBGetResponse = {
const query: Types.ChromaDBGetResponse = {
ids: [],
documents: [],
embeddings: [],
@ -276,9 +281,9 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
query: '',
size: 0,
dimensions: 2,
name: ''
name: '',
};
let filtered: Types.ChromaDBGetResponse = {
const filtered: Types.ChromaDBGetResponse = {
ids: [],
documents: [],
embeddings: [],
@ -287,13 +292,13 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
query: '',
size: 0,
dimensions: 2,
name: ''
name: '',
};
/* Loop through all items and divide into two groups:
* filtered is for any item not in the querySet
* query is for any item that is in the querySet
*/
* filtered is for any item not in the querySet
* query is for any item that is in the querySet
*/
full.ids.forEach((id, index) => {
const foundIndex = querySet.ids.indexOf(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];
if (foundIndex !== -1) {
/* 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.documents.push(full.documents[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) {
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);
}
if (!view2D && querySet.umapEmbedding3D && querySet.umapEmbedding3D.length) {
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);
}
const filtered_docTypes = filtered.metadatas.map(m => m.docType || 'unknown')
const query_docTypes = query.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 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_x = normalizeDimension(filtered.embeddings.map((v: number[]) => v[0]));
const filtered_y = normalizeDimension(filtered.embeddings.map((v: number[]) => v[1]));
const filtered_z = is3D ? normalizeDimension(filtered.embeddings.map((v: number[]) => v[2])) : undefined;
const 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_x = normalizeDimension(query.embeddings.map((v: number[]) => v[0]));
const query_y = normalizeDimension(query.embeddings.map((v: number[]) => v[1]));
const query_z = is3D ? normalizeDimension(query.embeddings.map((v: number[]) => v[2])) : undefined;
const query_z = is3D
? normalizeDimension(query.embeddings.map((v: number[]) => v[2]))
: undefined;
const data: any = [{
name: 'All data',
x: filtered_x,
y: filtered_y,
mode: 'markers',
marker: {
size: filtered_sizes,
symbol: 'circle',
color: filtered_colors,
opacity: 1
const data: any = [
{
name: 'All data',
x: filtered_x,
y: filtered_y,
mode: 'markers',
marker: {
size: filtered_sizes,
symbol: 'circle',
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',
x: query_x,
y: query_y,
mode: 'markers',
marker: {
size: query_sizes,
symbol: 'circle',
color: query_colors,
opacity: 1
},
mode: 'markers',
marker: {
size: query_sizes,
symbol: 'circle',
color: query_colors,
opacity: 1,
},
text: query.ids,
customdata: query.metadatas,
type: is3D ? 'scatter3d' : 'scatter',
hovertemplate: '%{text}',
}];
},
];
if (is3D) {
data[0].z = filtered_z;
@ -382,7 +410,6 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
}
setPlotData(data);
}, [result, querySet, view2D]);
const handleKeyPress = (event: any) => {
@ -400,36 +427,55 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
console.log(result);
setQuerySet(result);
} catch (error) {
const msg = `Error obtaining similar content to ${query}.`
setSnack(msg, "error");
};
const msg = `Error obtaining similar content to ${query}.`;
setSnack(msg, 'error');
}
};
if (!result) return (
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
<div>Loading visualization...</div>
</Box>
);
if (!result)
return (
<Box
sx={{
display: 'flex',
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
}}
>
<div>Loading visualization...</div>
</Box>
);
if (!candidate) return (
<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>
);
if (!candidate)
return (
<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) => {
try {
const result = await apiClient.getCandidateRAGContent(node.id);
const update: Node = {
...node,
fullContent: result.content
}
fullContent: result.content,
};
setNode(update);
} catch (error) {
const msg = `Error obtaining content for ${node.id}.`
const msg = `Error obtaining content for ${node.id}.`;
console.error(msg, error);
setSnack(msg, "error");
};
setSnack(msg, 'error');
}
};
const onNodeSelected = (metadata: any) => {
@ -440,21 +486,23 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
...metadata,
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],
sx: {
m: 0.5,
p: 2,
width: '3rem',
display: "flex",
alignContent: "center",
justifyContent: "center",
display: 'flex',
alignContent: 'center',
justifyContent: 'center',
flexGrow: 0,
flexWrap: "wrap",
flexWrap: 'wrap',
backgroundColor: colorMap[metadata.docType] || '#ff8080',
}
}
},
};
setNode(node);
return;
}
@ -463,7 +511,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
content: `Loading...`,
...metadata,
emoji: emojiMap[metadata.docType] || '❓',
}
};
setNode(node);
@ -471,95 +519,173 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
};
return (
<Box className="VectorVisualizer"
<Box
className="VectorVisualizer"
ref={boxRef}
sx={{
...sx
}}>
...sx,
}}
>
<Box sx={{ p: 0, m: 0, gap: 0 }}>
<Paper sx={{
p: 0.5, m: 0,
display: "flex",
flexGrow: 0,
height: isMobile ? "auto" : "auto", //"320px",
minHeight: isMobile ? "auto" : "auto", //"320px",
maxHeight: isMobile ? "auto" : "auto", //"320px",
position: "relative",
flexDirection: "column"
}}>
<Paper
sx={{
p: 0.5,
m: 0,
display: 'flex',
flexGrow: 0,
height: isMobile ? 'auto' : 'auto', //"320px",
minHeight: isMobile ? 'auto' : 'auto', //"320px",
maxHeight: isMobile ? 'auto' : 'auto', //"320px",
position: 'relative',
flexDirection: 'column',
}}
>
<FormControlLabel
sx={{
display: "flex",
position: "relative",
width: "fit-content",
display: 'flex',
position: 'relative',
width: 'fit-content',
ml: 1,
mb: '-2.5rem',
zIndex: 100,
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
ref={plotlyRef}
onClick={(event: any) => { onNodeSelected(event.points[0].customdata); }}
onClick={(event: any) => {
onNodeSelected(event.points[0].customdata);
}}
data={plotData}
useResizeHandler={true}
config={config}
style={{
display: "flex",
display: 'flex',
flexGrow: 1,
minHeight: '240px',
padding: 0,
margin: 0,
width: "100%",
height: "100%",
overflow: "hidden",
width: '100%',
height: '100%',
overflow: 'hidden',
}}
layout={{
...layout,
width: plotDimensions.width,
height: plotDimensions.height,
}}
layout={{...layout, width: plotDimensions.width, height: plotDimensions.height }}
/>
</Paper>
<Paper sx={{ 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" }}>
<Paper
sx={{
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 }}>
<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>
<TableCell>Type</TableCell>
<TableCell>{node.emoji} {node.docType}</TableCell>
<TableCell>
{node.emoji} {node.docType}
</TableCell>
</TableRow>
{node.source_file !== undefined && <TableRow>
<TableCell>File</TableCell>
<TableCell>{node.source_file.replace(/^.*\//, '')}</TableCell>
</TableRow>}
{node.path !== undefined && <TableRow>
<TableCell>Section</TableCell>
<TableCell>{node.path}</TableCell>
</TableRow>}
{node.distance !== undefined && <TableRow>
<TableCell>Distance</TableCell>
<TableCell>{node.distance}</TableCell>
</TableRow>}
{node.source_file !== undefined && (
<TableRow>
<TableCell>File</TableCell>
<TableCell>{node.source_file.replace(/^.*\//, '')}</TableCell>
</TableRow>
)}
{node.path !== undefined && (
<TableRow>
<TableCell>Section</TableCell>
<TableCell>{node.path}</TableCell>
</TableRow>
)}
{node.distance !== undefined && (
<TableRow>
<TableCell>Distance</TableCell>
<TableCell>{node.distance}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{node.content !== "" && node.content !== undefined &&
<Paper elevation={6} 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>
{node.content !== '' && node.content !== undefined && (
<Paper
elevation={6}
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>
}
)}
</Box>
}
)}
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 2, flexBasis: 0, flexShrink: 1 }}>
{node === null &&
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 2,
flexBasis: 0,
flexShrink: 1,
}}
>
{node === null && (
<Paper sx={{ m: 0.5, p: 2, flexGrow: 1 }}>
Click a point in the scatter-graph to see information about that node.
</Paper>
}
{node !== null && node.fullContent &&
)}
{node !== null && node.fullContent && (
<Scrollable
autoscroll={false}
sx={{
@ -569,51 +695,104 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
p: 0.5,
pl: 1,
flexShrink: 1,
position: "relative",
maxWidth: "100%",
position: 'relative',
maxWidth: '100%',
}}
>
{
node.fullContent.split('\n').map((line, index) => {
index += 1 + node.chunkBegin;
const bgColor = (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 }}>
<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>}
{node.fullContent.split('\n').map((line, index) => {
index += 1 + node.chunkBegin;
const bgColor =
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,
}}
>
<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>
}
)}
</Box>
</Paper>
{!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 }}>
{!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,
}}
>
{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>
}
)}
{
!inline &&
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}>
{!inline && (
<Box className="Query" sx={{ display: 'flex', flexDirection: 'row', p: 1 }}>
<TextField
variant="outlined"
fullWidth
type="text"
value={newQuery}
onChange={(e) => setNewQuery(e.target.value)}
onChange={e => setNewQuery(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Enter query to find related documents..."
id="QueryInput"
/>
<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>
</Box>
}
)}
</Box>
</Box>
);
@ -621,6 +800,4 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
export type { VectorVisualizerProps };
export {
VectorVisualizer,
};
export { VectorVisualizer };

View File

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

View File

@ -1,13 +1,13 @@
// components/layout/BackstoryRoutes.tsx
import React, { Ref, ReactNode } from "react";
import { Route } from "react-router-dom";
import React, { Ref, ReactNode } from 'react';
import { Route } from 'react-router-dom';
import { BackstoryPageProps } from '../BackstoryTab';
import { ConversationHandle } from '../Conversation';
import { User } from 'types/types';
import { getAllRoutes } from 'config/navigationConfig';
import { NavigationItem } from 'types/navigation';
import { useAppState } from "hooks/GlobalContext";
import { useAppState } from 'hooks/GlobalContext';
interface BackstoryDynamicRoutesProps extends BackstoryPageProps {
chatRef: Ref<ConversationHandle>;
@ -22,25 +22,21 @@ const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNod
// Get all routes from navigation config
const routes = getAllRoutes(userType, isAdmin);
return routes.map((route: NavigationItem, index: number) => {
if (!route.path || !route.component) return null;
return routes
.map((route: NavigationItem, index: number) => {
if (!route.path || !route.component) return null;
// Clone the component and pass necessary props
const componentWithProps = React.cloneElement(route.component as React.ReactElement, {
// Special handling for chat component ref
...(route.id === 'chat' && { ref: chatRef }),
// Preserve any existing props
...(route.component.props || {}),
});
// Clone the component and pass necessary props
const componentWithProps = React.cloneElement(route.component as React.ReactElement, {
// Special handling for chat component ref
...(route.id === 'chat' && { ref: chatRef }),
// Preserve any existing props
...(route.component.props || {}),
});
return (
<Route
key={`${route.id}-${index}`}
path={route.path}
element={componentWithProps}
/>
);
}).filter(Boolean);
return <Route key={`${route.id}-${index}`} path={route.path} element={componentWithProps} />;
})
.filter(Boolean);
};
export { getBackstoryDynamicRoutes };
export { getBackstoryDynamicRoutes };

View File

@ -62,7 +62,7 @@ const Footer = () => {
const currentYear = new Date().getFullYear();
return (
<FooterContainer elevation={0} >
<FooterContainer elevation={0}>
<Container maxWidth="lg">
<Grid container spacing={4} justifyContent="space-between">
{/* About Company */}
@ -79,8 +79,9 @@ const Footer = () => {
>
BACKSTORY
</Typography>
<Typography variant="body2" sx={{ mb: 2, color: "white" }}>
Helping candidates share their professional journey and connect with the right employers through compelling backstories.
<Typography variant="body2" sx={{ mb: 2, color: 'white' }}>
Helping candidates share their professional journey and connect with the right
employers through compelling backstories.
</Typography>
<Stack direction="row">
{/* <IconButton
@ -122,9 +123,11 @@ const Footer = () => {
'&:hover': {
backgroundColor: 'rgba(211, 205, 191, 0.1)',
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 />
</IconButton>
@ -163,46 +166,46 @@ const Footer = () => {
</Grid>
{/* Quick Links */}
{false && <>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">
For Candidates
</FooterHeading>
<FooterLink href="/create-profile">Create Profile</FooterLink>
<FooterLink href="/backstory-editor">Backstory Editor</FooterLink>
<FooterLink href="/resume-builder">Resume Builder</FooterLink>
<FooterLink href="/career-resources">Career Resources</FooterLink>
<FooterLink href="/interview-tips">Interview Tips</FooterLink>
</Grid>
</>}
{false && (
<>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">For Candidates</FooterHeading>
<FooterLink href="/create-profile">Create Profile</FooterLink>
<FooterLink href="/backstory-editor">Backstory Editor</FooterLink>
<FooterLink href="/resume-builder">Resume Builder</FooterLink>
<FooterLink href="/career-resources">Career Resources</FooterLink>
<FooterLink href="/interview-tips">Interview Tips</FooterLink>
</Grid>
</>
)}
{/* Quick Links */}
{false && <>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">
For Employers
</FooterHeading>
<FooterLink href="/post-job">Post a Job</FooterLink>
<FooterLink href="/search-candidates">Search Candidates</FooterLink>
<FooterLink href="/company-profile">Company Profile</FooterLink>
<FooterLink href="/recruiting-tools">Recruiting Tools</FooterLink>
<FooterLink href="/pricing-plans">Pricing Plans</FooterLink>
</Grid>
</>}
{false && (
<>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">For Employers</FooterHeading>
<FooterLink href="/post-job">Post a Job</FooterLink>
<FooterLink href="/search-candidates">Search Candidates</FooterLink>
<FooterLink href="/company-profile">Company Profile</FooterLink>
<FooterLink href="/recruiting-tools">Recruiting Tools</FooterLink>
<FooterLink href="/pricing-plans">Pricing Plans</FooterLink>
</Grid>
</>
)}
{/* Contact */}
{false && <>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">
Company
</FooterHeading>
<FooterLink href="/about-us">About Us</FooterLink>
<FooterLink href="/our-team">Our Team</FooterLink>
<FooterLink href="/blog">Blog</FooterLink>
<FooterLink href="/press">Press</FooterLink>
<FooterLink href="/careers">Careers</FooterLink>
<FooterLink href="/contact-us">Contact Us</FooterLink>
</Grid>
</>}
{false && (
<>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FooterHeading variant="subtitle1">Company</FooterHeading>
<FooterLink href="/about-us">About Us</FooterLink>
<FooterLink href="/our-team">Our Team</FooterLink>
<FooterLink href="/blog">Blog</FooterLink>
<FooterLink href="/press">Press</FooterLink>
<FooterLink href="/careers">Careers</FooterLink>
<FooterLink href="/contact-us">Contact Us</FooterLink>
</Grid>
</>
)}
{/* Newsletter */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<ContactItem>
@ -215,7 +218,7 @@ const Footer = () => {
</ContactItem> */}
<ContactItem>
<LocationOn sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="body2" sx={{ color: "white" }}>
<Typography variant="body2" sx={{ color: 'white' }}>
Beaverton, OR 97003
</Typography>
</ContactItem>
@ -228,25 +231,35 @@ const Footer = () => {
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}>
<Box display="flex" alignItems="center">
<Copyright sx={{ fontSize: 16, mr: 1, color: "white" }} />
<Typography variant="body2" sx={{ color: "white" }}>
<Copyright sx={{ fontSize: 16, mr: 1, color: 'white' }} />
<Typography variant="body2" sx={{ color: 'white' }}>
{currentYear} James P. Ketrenos. All rights reserved.
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
{false && <>
<Stack
direction={isMobile ? 'column' : 'row'}
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="/accessibility" sx={{ mb: 0 }}>Accessibility</FooterLink>
<FooterLink href="/sitemap" sx={{ mb: 0 }}>Sitemap</FooterLink>
</Stack>
</>}
{false && (
<>
<Stack
direction={isMobile ? 'column' : 'row'}
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="/accessibility" sx={{ mb: 0 }}>
Accessibility
</FooterLink>
<FooterLink href="/sitemap" sx={{ mb: 0 }}>
Sitemap
</FooterLink>
</Stack>
</>
)}
</Grid>
</Grid>
</Container>
@ -254,6 +267,4 @@ const Footer = () => {
);
};
export {
Footer
};
export { Footer };

View File

@ -53,7 +53,7 @@ import { useAppState } from 'hooks/GlobalContext';
// Styled components
const StyledAppBar = styled(AppBar, {
shouldForwardProp: (prop) => prop !== 'transparent',
shouldForwardProp: prop => prop !== 'transparent',
})<{ transparent?: boolean }>(({ theme, transparent }) => ({
backgroundColor: transparent ? 'transparent' : theme.palette.primary.main,
boxShadow: transparent ? 'none' : '',
@ -126,24 +126,22 @@ interface HeaderProps {
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
const { user, logout } = useAuth();
const { setSnack } = useAppState();
const {
transparent = false,
className,
navigate,
navigationItems,
sessionId,
} = props;
const { transparent = false, className, navigate, navigationItems, sessionId } = props;
const theme = useTheme();
const location = useLocation();
const name = (user?.firstName || user?.email || '');
const name = user?.firstName || user?.email || '';
// 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
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileExpanded, setMobileExpanded] = useState<{ [key: string]: boolean }>({});
const [mobileExpanded, setMobileExpanded] = useState<{
[key: string]: boolean;
}>({});
// State for user menu
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
@ -172,7 +170,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
label: item.label as string,
icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')),
group: 'profile'
group: 'profile',
});
}
});
@ -183,8 +181,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: 'divider',
label: '',
icon: null,
action: () => { },
group: 'divider'
action: () => {},
group: 'divider',
});
}
@ -196,7 +194,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
label: item.label as string,
icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')),
group: 'account'
group: 'account',
});
}
});
@ -207,8 +205,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: 'divider',
label: '',
icon: null,
action: () => { },
group: 'divider'
action: () => {},
group: 'divider',
});
}
@ -220,7 +218,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
label: item.label as string,
icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')),
group: 'admin'
group: 'admin',
});
}
});
@ -231,8 +229,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: 'divider',
label: '',
icon: null,
action: () => { },
group: 'divider'
action: () => {},
group: 'divider',
});
}
@ -247,7 +245,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
logout();
navigate('/');
},
group: 'system'
group: 'system',
});
} else if (!item.divider) {
items.push({
@ -255,7 +253,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
label: item.label as string,
icon: item.icon || null,
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,
icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')),
group: 'other'
group: 'other',
});
}
});
@ -319,7 +317,13 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
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') {
item.action();
handleUserMenuClose();
@ -337,18 +341,28 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Render desktop navigation with dropdowns
const renderDesktopNavigation = () => {
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) => {
const hasChildren = item.children && item.children.length > 0;
const isActive = isCurrentPath(item) || hasActiveChild(item);
if (hasChildren) {
return (
<Box key={item.id} sx={{
mr: (index === 0 || index === navigationItems.length - 1) ? "auto" : "unset",
}}>
<Box
key={item.id}
sx={{
mr: index === 0 || index === navigationItems.length - 1 ? 'auto' : 'unset',
}}
>
<DropdownButton
onClick={(e) => handleDropdownOpen(e, item.id)}
onClick={e => handleDropdownOpen(e, item.id)}
endIcon={<KeyboardArrowDown />}
sx={{
backgroundColor: isActive ? 'action.selected' : 'transparent',
@ -372,7 +386,12 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
onClick={() => child.path && handleNavigate(child.path)}
selected={isCurrentPath(child)}
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>}
<ListItemText>{child.label}</ListItemText>
@ -389,7 +408,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
sx={{
backgroundColor: isActive ? 'action.selected' : 'transparent',
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>}
@ -404,7 +423,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Render mobile accordion navigation
const renderMobileNavigation = () => {
const renderNavigationItem = (item: NavigationItem, depth: number = 0) => {
const renderNavigationItem = (item: NavigationItem, depth = 0) => {
const hasChildren = item.children && item.children.length > 0;
const isActive = isCurrentPath(item) || hasActiveChild(item);
const isExpanded = mobileExpanded[item.id];
@ -432,24 +451,18 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
},
}}
>
{item.icon && (
<ListItemIcon sx={{ minWidth: 36 }}>
{item.icon}
</ListItemIcon>
)}
{item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>}
<ListItemText
primary={item.label}
sx={{
'& .MuiTypography-root': {
'& .MuiTypography-root': {
fontSize: depth > 0 ? '0.875rem' : '1rem',
fontWeight: depth === 0 ? 500 : 400,
}
}}
},
}}
/>
{hasChildren && (
<IconButton size="small">
{isExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
<IconButton size="small">{isExpanded ? <ExpandLess /> : <ExpandMore />}</IconButton>
)}
</ListItemButton>
</ListItem>
@ -466,7 +479,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
return (
<List sx={{ pt: 0 }}>
{navigationItems.map((item) => renderNavigationItem(item))}
{navigationItems.map(item => renderNavigationItem(item))}
<Divider sx={{ my: 1 }} />
{!user && (
<ListItem disablePadding>
@ -484,18 +497,18 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
return (
<UserMenuContainer>
<List dense>
{userMenuItems.map((item, index) => (
{userMenuItems.map((item, index) =>
item.group === 'divider' ? (
<Divider key={`divider-${index}`} />
) : (
<ListItem key={item.id}>
<ListItemButton onClick={() => handleUserMenuAction(item)}>
{item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>}
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
<ListItem key={item.id}>
<ListItemButton onClick={() => handleUserMenuAction(item)}>
{item.icon && <ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>}
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
)
))}
)}
</List>
</UserMenuContainer>
);
@ -508,7 +521,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
<Button
color="info"
variant="contained"
onClick={() => navigate("/login")}
onClick={() => navigate('/login')}
sx={{
display: { xs: 'none', sm: 'block' },
color: theme.palette.primary.contrastText,
@ -527,16 +540,16 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
aria-haspopup="true"
aria-expanded={userMenuOpen ? 'true' : undefined}
>
<Avatar sx={{
width: 32,
height: 32,
bgcolor: theme.palette.secondary.main,
}}>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: theme.palette.secondary.main,
}}
>
{name.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
{name}
</Box>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>{name}</Box>
<ExpandMore fontSize="small" />
</UserButton>
@ -566,14 +579,12 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
position="fixed"
transparent={transparent}
className={className}
sx={{ overflow: "hidden" }}
sx={{ overflow: 'hidden' }}
>
<Container maxWidth="xl">
<Toolbar disableGutters>
{/* Navigation Links - Desktop */}
<NavLinksContainer>
{renderDesktopNavigation()}
</NavLinksContainer>
<NavLinksContainer>{renderDesktopNavigation()}</NavLinksContainer>
{/* User Actions Section */}
<UserActionsContainer>
@ -608,7 +619,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
content={`${window.location.origin}${window.location.pathname}?id=${sessionId}`}
onClick={() => {
navigate(`${window.location.pathname}?id=${sessionId}`);
setSnack("Link copied!");
setSnack('Link copied!');
}}
size="large"
/>
@ -630,11 +641,13 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
</Toolbar>
</Container>
<Beta
sx={{ left: "-90px", "& .mobile": { right: "-72px" } }}
onClick={() => { navigate('/docs/beta'); }}
sx={{ left: '-90px', '& .mobile': { right: '-72px' } }}
onClick={() => {
navigate('/docs/beta');
}}
/>
</StyledAppBar>
);
};
export { Header };
export { Header };

View File

@ -6,28 +6,23 @@ import './AIBanner.css';
import { useMediaQuery, useTheme } from '@mui/material';
type AIBannerProps = {
sx?: SxProps;
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>
);
sx?: SxProps;
variant?: 'minimal' | 'small' | 'normal' | undefined;
};
export {
AIBanner
};
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 { AIBanner };

View File

@ -1,39 +1,39 @@
import React from 'react';
import {
Typography,
Avatar,
} from '@mui/material';
import { Typography, Avatar } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import 'components/layout/Header.css';
const BackstoryLogo = () => {
const theme = useTheme();
return <Typography
variant="h6"
className="BackstoryLogo"
noWrap
sx={{
cursor: "pointer",
fontWeight: 700,
letterSpacing: '.2rem',
color: theme.palette.primary.contrastText,
textDecoration: 'none',
display: "inline-flex",
flexDirection: "row",
alignItems: "center",
verticalAlign: "center",
gap: 1,
textTransform: "uppercase",
}}
>
<Avatar sx={{ width: 24, height: 24 }}
variant="rounded"
alt="Backstory logo"
src="/logo192.png" />
Backstory
</Typography>
return (
<Typography
variant="h6"
className="BackstoryLogo"
noWrap
sx={{
cursor: 'pointer',
fontWeight: 700,
letterSpacing: '.2rem',
color: theme.palette.primary.contrastText,
textDecoration: 'none',
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
verticalAlign: 'center',
gap: 1,
textTransform: 'uppercase',
}}
>
<Avatar
sx={{ width: 24, height: 24 }}
variant="rounded"
alt="Backstory logo"
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';
type BetaProps = {
adaptive?: boolean;
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
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>
);
adaptive?: boolean;
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
sx?: SxProps;
};
export {
Beta
};
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 { Beta };

View File

@ -1,15 +1,10 @@
import React, { useState, useRef, useEffect } from 'react';
import { Box, Link, Typography, Avatar, Grid, SxProps, Tooltip, IconButton } from '@mui/material';
import {
Card,
CardContent,
Divider,
useTheme,
} from '@mui/material';
import { Card, CardContent, Divider, useTheme } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { useMediaQuery } from '@mui/material';
import { Candidate, CandidateAI } from 'types/types';
import { CopyBubble } from "components/CopyBubble";
import { CopyBubble } from 'components/CopyBubble';
import { rest } from 'lodash';
import { AIBanner } from 'components/ui/AIBanner';
import { useAuth } from 'hooks/AuthContext';
@ -20,21 +15,16 @@ interface CandidateInfoProps {
sx?: SxProps;
action?: string;
elevation?: number;
variant?: "minimal" | "small" | "normal" | undefined;
};
variant?: 'minimal' | 'small' | 'normal' | undefined;
}
const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps) => {
const { candidate } = props;
const { user, apiClient } = useAuth();
const {
sx,
action = '',
elevation = 1,
variant = "normal"
} = props;
const { sx, action = '', elevation = 1, variant = 'normal' } = props;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')) || variant === "minimal";
const ai: CandidateAI | null = ('isAI' in candidate) ? candidate as CandidateAI : null;
const isMobile = useMediaQuery(theme.breakpoints.down('md')) || variant === 'minimal';
const ai: CandidateAI | null = 'isAI' in candidate ? (candidate as CandidateAI) : null;
const isAdmin = user?.isAdmin;
// State for description expansion
@ -55,40 +45,40 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
if (candidateId) {
await apiClient.deleteCandidate(candidateId);
}
}
};
if (!candidate) {
return <Box>No user loaded.</Box>;
}
return (
<Box
sx={{
display: "flex",
<Box
sx={{
display: 'flex',
transition: 'all 0.3s ease',
flexGrow: 1,
p: isMobile ? 1 : 2,
height: '100%',
flexDirection: 'column',
alignItems: 'stretch',
position: "relative",
overflow: "hidden",
...sx
position: 'relative',
overflow: 'hidden',
...sx,
}}
{...rest}
>
{ai && <AIBanner variant={variant} />}
<Box sx={{ display: "flex", flexDirection: "row" }}>
<Box sx={{ display: 'flex', flexDirection: 'row' }}>
<Avatar
src={candidate.profileImage ? `/api/1.0/candidates/profile/${candidate.username}` : ''}
alt={`${candidate.fullName}'s profile`}
sx={{
alignSelf: "flex-start",
width: isMobile ? 40 : 80,
height: isMobile ? 40 : 80,
border: '2px solid #e0e0e0',
}}
sx={{
alignSelf: 'flex-start',
width: isMobile ? 40 : 80,
height: isMobile ? 40 : 80,
border: '2px solid #e0e0e0',
}}
/>
<Box sx={{ ml: 1 }}>
@ -97,34 +87,42 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 1
}}>
mb: 1,
}}
>
<Box>
<Box sx={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
alignItems: "left",
gap: 1, "& > .MuiTypography-root": { m: 0 }
}}>
{
action !== '' &&
<Typography variant="body1">{action}</Typography>
}
{action === '' &&
<Typography variant="h5" component="h1"
<Box
sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: 'left',
gap: 1,
'& > .MuiTypography-root': { m: 0 },
}}
>
{action !== '' && <Typography variant="body1">{action}</Typography>}
{action === '' && (
<Typography
variant="h5"
component="h1"
sx={{
fontWeight: 'bold',
whiteSpace: 'nowrap'
}}>
whiteSpace: 'nowrap',
}}
>
{candidate.fullName}
</Typography>
}
</Typography>
)}
</Box>
<Box sx={{ fontSize: "0.75rem", alignItems: "center" }} >
<Box sx={{ fontSize: '0.75rem', alignItems: 'center' }}>
<Link href={`/u/${candidate.username}`}>{`/u/${candidate.username}`}</Link>
<CopyBubble
onClick={(event: any) => { event.stopPropagation() }}
tooltip="Copy link" content={`${window.location.origin}/u/{candidate.username}`} />
onClick={(event: any) => {
event.stopPropagation();
}}
tooltip="Copy link"
content={`${window.location.origin}/u/{candidate.username}`}
/>
</Box>
</Box>
</Box>
@ -132,8 +130,8 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
</Box>
<Box>
{(!isMobile && variant === "normal") && (
<Box sx={{ minHeight: "5rem" }}>
{!isMobile && variant === 'normal' && (
<Box sx={{ minHeight: '5rem' }}>
<Typography
ref={descriptionRef}
variant="body1"
@ -145,7 +143,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.5,
fontSize: "0.8rem !important",
fontSize: '0.8rem !important',
}}
>
{candidate.description}
@ -154,7 +152,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
<Link
component="button"
variant="body2"
onClick={(e) => {
onClick={e => {
e.preventDefault();
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
@ -169,48 +167,61 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
display: 'block',
'&:hover': {
textDecoration: 'underline',
}
},
}}
>
[{isDescriptionExpanded ? "less" : "more"}]
[{isDescriptionExpanded ? 'less' : 'more'}]
</Link>
)}
</Box>
)}
{(variant !== "small" && variant !== "minimal") && <>
{variant !== 'small' && variant !== 'minimal' && (
<>
<Divider sx={{ my: 2 }} />
{candidate.location &&
{candidate.location && (
<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>
}
{candidate.email &&
)}
{candidate.email && (
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email}
</Typography>
}
{candidate.phone && <Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
</Typography>
}
</>}
</Box>
{isAdmin && ai &&
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "flex-start" }}>
)}
{candidate.phone && (
<Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
</Typography>
)}
</>
)}
</Box>
{isAdmin && ai && (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
}}
>
<Tooltip title="Delete Job">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); deleteCandidate(candidate.id); }}
onClick={e => {
e.stopPropagation();
deleteCandidate(candidate.id);
}}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
}
)}
</Box>
);
};
export { CandidateInfo };
export { CandidateInfo };

View File

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

View File

@ -7,19 +7,17 @@ import './ComingSoon.css';
type ComingSoonProps = {
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 {
ComingSoon
};
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 { ComingSoon };

View File

@ -1,15 +1,26 @@
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 {
Card,
CardContent,
Divider,
useTheme,
import {
Box,
Link,
Typography,
Avatar,
Grid,
SxProps,
CardActions,
Chip,
Stack,
CardHeader,
Button,
styled,
LinearProgress,
IconButton,
Tooltip,
} from '@mui/material';
import { Card, CardContent, Divider, useTheme } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { useMediaQuery } from '@mui/material';
import { Job } from 'types/types';
import { CopyBubble } from "components/CopyBubble";
import { CopyBubble } from 'components/CopyBubble';
import { rest } from 'lodash';
import { AIBanner } from 'components/ui/AIBanner';
import { useAuth } from 'hooks/AuthContext';
@ -19,7 +30,7 @@ import ModelTrainingIcon from '@mui/icons-material/ModelTraining';
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon';
import RestoreIcon from '@mui/icons-material/Restore';
import SaveIcon from '@mui/icons-material/Save';
import * as Types from "types/types";
import * as Types from 'types/types';
import { useAppState } from 'hooks/GlobalContext';
import { StyledMarkdown } from 'components/StyledMarkdown';
@ -28,25 +39,22 @@ interface JobInfoProps {
sx?: SxProps;
action?: string;
elevation?: number;
variant?: "minimal" | "small" | "normal" | "all" | null
};
variant?: 'minimal' | 'small' | 'normal' | 'all' | null;
}
const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
const { setSnack } = useAppState();
const { job } = props;
const { user, apiClient } = useAuth();
const {
sx,
action = '',
elevation = 1,
variant = "normal"
} = props;
const { sx, action = '', elevation = 1, variant = 'normal' } = props;
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 [adminStatus, setAdminStatus] = useState<string | 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
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
@ -72,11 +80,11 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
if (jobId) {
await apiClient.deleteJob(jobId);
}
}
};
const handleReset = async () => {
setActiveJob({ ...job });
}
};
if (!job) {
return <Box>No job provided.</Box>;
@ -88,12 +96,12 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
requirements: activeJob.requirements,
});
job.updatedAt = newJob.updatedAt;
setActiveJob(newJob)
setActiveJob(newJob);
setSnack('Job updated.');
}
};
const handleRefresh = () => {
setAdminStatus("Re-extracting Job information...");
setAdminStatus('Re-extracting Job information...');
const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content);
@ -101,7 +109,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
setAdminStatus(status.content);
},
onMessage: async (jobMessage: Types.JobRequirementsMessage) => {
const newJob: Types.Job = jobMessage.job
const newJob: Types.Job = jobMessage.job;
console.log('onMessage - job', newJob);
newJob.id = job.id;
newJob.createdAt = job.createdAt;
@ -116,22 +124,37 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
onComplete: () => {
setAdminStatusType(null);
setAdminStatus(null);
}
},
};
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;
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
{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}
</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>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{items.map((item, index) => (
@ -152,7 +175,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
if (!activeJob.requirements) return null;
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
title="Job Requirements Analysis"
avatar={<CheckCircle color="success" />}
@ -160,49 +183,49 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
/>
<CardContent sx={{ p: 0 }}>
{renderRequirementSection(
"Technical Skills (Required)",
'Technical Skills (Required)',
activeJob.requirements.technicalSkills.required,
<Build color="primary" />,
true
)}
{renderRequirementSection(
"Technical Skills (Preferred)",
'Technical Skills (Preferred)',
activeJob.requirements.technicalSkills.preferred,
<Build color="action" />
)}
{renderRequirementSection(
"Experience Requirements (Required)",
'Experience Requirements (Required)',
activeJob.requirements.experienceRequirements.required,
<Work color="primary" />,
true
)}
{renderRequirementSection(
"Experience Requirements (Preferred)",
'Experience Requirements (Preferred)',
activeJob.requirements.experienceRequirements.preferred,
<Work color="action" />
)}
{renderRequirementSection(
"Soft Skills",
'Soft Skills',
activeJob.requirements.softSkills,
<Psychology color="secondary" />
)}
{renderRequirementSection(
"Experience",
'Experience',
activeJob.requirements.experience,
<Star color="warning" />
)}
{renderRequirementSection(
"Education",
'Education',
activeJob.requirements.education,
<Description color="info" />
)}
{renderRequirementSection(
"Certifications",
'Certifications',
activeJob.requirements.certifications,
<CheckCircle color="success" />
)}
{renderRequirementSection(
"Preferred Attributes",
'Preferred Attributes',
activeJob.requirements.preferredAttributes,
<Star color="secondary" />
)}
@ -213,165 +236,239 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
return (
<Box
sx={{
display: "flex",
sx={{
display: 'flex',
borderColor: 'transparent',
borderWidth: 2,
borderStyle: 'solid',
transition: 'all 0.3s ease',
flexDirection: "column",
flexDirection: 'column',
minWidth: 0,
opacity: deleted ? 0.5 : 1.0,
backgroundColor: deleted ? theme.palette.action.disabledBackground : theme.palette.background.paper,
pointerEvents: deleted ? "none" : "auto",
backgroundColor: deleted
? theme.palette.action.disabledBackground
: theme.palette.background.paper,
pointerEvents: deleted ? 'none' : 'auto',
...sx,
}}
{...rest}
>
<Box sx={{ display: "flex", flexGrow: 1, p: 1, pb: 0, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
<Box sx={{
display: "flex", flexDirection: (isMobile || variant === "small") ? "column" : "row",
"& > div > div > :first-of-type": { fontWeight: "bold", whiteSpace: "nowrap" },
"& > div > div > :last-of-type": { mb: 0.75, mr: 1 }
}}>
<Box sx={{ display: "flex", flexDirection: isMobile ? "row" : "column", flexGrow: 1, gap: 1 }}>
{activeJob.company &&
<Box sx={{ fontSize: "0.8rem" }}>
<Box
sx={{
display: 'flex',
flexGrow: 1,
p: 1,
pb: 0,
height: '100%',
flexDirection: 'column',
alignItems: 'stretch',
position: 'relative',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: isMobile || variant === 'small' ? 'column' : 'row',
'& > div > div > :first-of-type': {
fontWeight: 'bold',
whiteSpace: 'nowrap',
},
'& > div > div > :last-of-type': { mb: 0.75, mr: 1 },
}}
>
<Box
sx={{
display: 'flex',
flexDirection: isMobile ? 'row' : 'column',
flexGrow: 1,
gap: 1,
}}
>
{activeJob.company && (
<Box sx={{ fontSize: '0.8rem' }}>
<Box>Company</Box>
<Box sx={{ whiteSpace: "nowrap" }}>{activeJob.company}</Box>
<Box sx={{ whiteSpace: 'nowrap' }}>{activeJob.company}</Box>
</Box>
}
{activeJob.title &&
<Box sx={{ fontSize: "0.8rem" }}>
)}
{activeJob.title && (
<Box sx={{ fontSize: '0.8rem' }}>
<Box>Title</Box>
<Box>{activeJob.title}</Box>
</Box>
}
)}
</Box>
<Box sx={{ display: "flex", flexDirection: "column", width: (variant !== "small" && variant !== "minimal") ? "75%" : "100%" }}>
{!isMobile && activeJob.summary && <Box sx={{ fontSize: "0.8rem" }}>
<Box>Summary</Box>
<Box sx={{ minHeight: variant === "small" ? "5rem" : "inherit" }}>
<Typography
ref={descriptionRef}
variant="body1"
color="text.secondary"
sx={{
display: '-webkit-box',
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.5,
fontSize: "0.8rem !important",
}}
>
{activeJob.summary}
</Typography>
{shouldShowMoreButton && (
<Link
component="button"
variant="body2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: variant !== 'small' && variant !== 'minimal' ? '75%' : '100%',
}}
>
{!isMobile && activeJob.summary && (
<Box sx={{ fontSize: '0.8rem' }}>
<Box>Summary</Box>
<Box sx={{ minHeight: variant === 'small' ? '5rem' : 'inherit' }}>
<Typography
ref={descriptionRef}
variant="body1"
color="text.secondary"
sx={{
color: theme.palette.primary.main,
textDecoration: 'none',
cursor: 'pointer',
fontSize: '0.725rem',
fontWeight: 500,
mt: 0.5,
display: 'block',
'&:hover': {
textDecoration: 'underline',
}
display: '-webkit-box',
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.5,
fontSize: '0.8rem !important',
}}
>
[{isDescriptionExpanded ? "less" : "more"}]
</Link>
)}
{activeJob.summary}
</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>
{(variant !== "small" && variant !== "minimal") && <>
{activeJob.details &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {activeJob.details.location.city}, {activeJob.details.location.state || activeJob.details.location.country}
</Typography>
}
{activeJob.owner && <Typography variant="body2">
<strong>Submitted by:</strong> {activeJob.owner.fullName}
</Typography>}
{activeJob.createdAt &&
<Typography variant="caption">Created: {activeJob.createdAt.toISOString()}</Typography>
}
{activeJob.updatedAt &&
<Typography variant="caption">Updated: {activeJob.updatedAt.toISOString()}</Typography>
}
<Typography variant="caption">Job ID: {job.id}</Typography>
</>}
{variant === 'all' && <StyledMarkdown sx={{ display: "flex" }} content={activeJob.description} />}
{variant !== 'small' && variant !== 'minimal' && (
<>
{activeJob.details && (
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {activeJob.details.location.city},{' '}
{activeJob.details.location.state || activeJob.details.location.country}
</Typography>
)}
{activeJob.owner && (
<Typography variant="body2">
<strong>Submitted by:</strong> {activeJob.owner.fullName}
</Typography>
)}
{activeJob.createdAt && (
<Typography variant="caption">
Created: {activeJob.createdAt.toISOString()}
</Typography>
)}
{activeJob.updatedAt && (
<Typography variant="caption">
Updated: {activeJob.updatedAt.toISOString()}
</Typography>
)}
<Typography variant="caption">Job ID: {job.id}</Typography>
</>
)}
{variant === 'all' && (
<StyledMarkdown sx={{ display: 'flex' }} content={activeJob.description} />
)}
{(variant !== 'small' && variant !== 'minimal') && <Box><Divider />{renderJobRequirements()}</Box>}
{variant !== 'small' && variant !== 'minimal' && (
<Box>
<Divider />
{renderJobRequirements()}
</Box>
)}
{isAdmin &&
<Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}>
{(job.updatedAt && job.updatedAt.toISOString()) !== (activeJob.updatedAt && activeJob.updatedAt.toISOString()) &&
<Tooltip title="Save Job">
{isAdmin && (
<Box sx={{ display: 'flex', flexDirection: 'column', p: 1 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
pl: 1,
pr: 1,
gap: 1,
alignContent: 'center',
height: '32px',
}}
>
{(job.updatedAt && job.updatedAt.toISOString()) !==
(activeJob.updatedAt && activeJob.updatedAt.toISOString()) && (
<Tooltip title="Save Job">
<IconButton
size="small"
onClick={e => {
e.stopPropagation();
handleSave();
}}
>
<SaveIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete Job">
<IconButton
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>
</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>
}
</Box >
</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>
)}
</Box>
</Box>
);
};

View File

@ -1,78 +1,89 @@
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 Box from '@mui/material/Box';
import { BackstoryElementProps } from 'components/BackstoryTab';
import { JobInfo } from 'components/ui/JobInfo';
import { Job } from "types/types";
import { Job } from 'types/types';
import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
import { Paper } from '@mui/material';
interface JobPickerProps extends BackstoryElementProps {
onSelect?: (job: Job) => void
};
onSelect?: (job: Job) => void;
}
const JobPicker = (props: JobPickerProps) => {
const { onSelect } = props;
const { apiClient } = useAuth();
const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack } = useAppState();
const [jobs, setJobs] = useState<Job[] | null>(null);
const { onSelect } = props;
const { apiClient } = useAuth();
const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack } = useAppState();
const [jobs, setJobs] = useState<Job[] | null>(null);
useEffect(() => {
if (jobs !== null) {
return;
}
const getJobs = async () => {
try {
const results = await apiClient.getJobs();
const jobs: Job[] = results.data;
jobs.sort((a, b) => {
let result = a.company?.localeCompare(b.company || '');
if (result === 0) {
result = a.title?.localeCompare(b.title || '');
}
return result || 0;
});
setJobs(jobs);
} catch (err) {
setSnack("" + err);
}
};
useEffect(() => {
if (jobs !== null) {
return;
}
const getJobs = async () => {
try {
const results = await apiClient.getJobs();
const jobs: Job[] = results.data;
jobs.sort((a, b) => {
let result = a.company?.localeCompare(b.company || '');
if (result === 0) {
result = a.title?.localeCompare(b.title || '');
}
return result || 0;
});
setJobs(jobs);
} catch (err) {
setSnack('' + err);
}
};
getJobs();
}, [jobs, setSnack]);
getJobs();
}, [jobs, setSnack]);
return (
<Box sx={{display: "flex", flexDirection: "column", mb: 1}}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
{jobs?.map((j, i) =>
<Paper key={`${j.id}`}
onClick={() => { console.log('Selected job', j); onSelect && onSelect(j) }}
sx={{ cursor: "pointer" }}>
<JobInfo variant="small"
sx={{
maxWidth: "100%",
minWidth: "320px",
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>
);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', mb: 1 }}>
<Box
sx={{
display: 'flex',
gap: 1,
flexWrap: 'wrap',
justifyContent: 'center',
}}
>
{jobs?.map((j, i) => (
<Paper
key={`${j.id}`}
onClick={() => {
console.log('Selected job', j);
onSelect && onSelect(j);
}}
sx={{ cursor: 'pointer' }}
>
<JobInfo
variant="small"
sx={{
maxWidth: '100%',
minWidth: '320px',
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 {
JobPicker
};
export { JobPicker };

View File

@ -1,39 +1,39 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
FormControl,
Select,
MenuItem,
InputLabel,
Chip,
IconButton,
Dialog,
AppBar,
Toolbar,
useMediaQuery,
useTheme,
Slide
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
FormControl,
Select,
MenuItem,
InputLabel,
Chip,
IconButton,
Dialog,
AppBar,
Toolbar,
useMediaQuery,
useTheme,
Slide,
} from '@mui/material';
import {
KeyboardArrowUp as ArrowUpIcon,
KeyboardArrowDown as ArrowDownIcon,
Business as BusinessIcon,
Work as WorkIcon,
Schedule as ScheduleIcon,
Close as CloseIcon,
ArrowBack as ArrowBackIcon
KeyboardArrowUp as ArrowUpIcon,
KeyboardArrowDown as ArrowDownIcon,
Business as BusinessIcon,
Work as WorkIcon,
Schedule as ScheduleIcon,
Close as CloseIcon,
ArrowBack as ArrowBackIcon,
} from '@mui/icons-material';
import { TransitionProps } from '@mui/material/transitions';
import { JobInfo } from 'components/ui/JobInfo';
import { Job } from "types/types";
import { Job } from 'types/types';
import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
@ -42,462 +42,482 @@ type SortField = 'updatedAt' | 'createdAt' | 'company' | 'title';
type SortOrder = 'asc' | 'desc';
interface JobViewerProps {
onSelect?: (job: Job) => void;
onSelect?: (job: Job) => void;
}
const Transition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref<unknown>,
props: TransitionProps & {
children: React.ReactElement;
},
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 navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const { apiClient } = useAuth();
const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack } = useAppState();
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
const { jobId } = useParams<{ jobId?: string }>();
const { apiClient } = useAuth();
const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack } = useAppState();
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
const { jobId } = useParams<{ jobId?: string }>();
useEffect(() => {
const getJobs = async () => {
setLoading(true);
try {
const results = await apiClient.getJobs();
const jobsData: Job[] = results.data || [];
setJobs(jobsData);
useEffect(() => {
const getJobs = async () => {
setLoading(true);
try {
const results = await apiClient.getJobs();
const jobsData: Job[] = results.data || [];
setJobs(jobsData);
if (jobId) {
const job = jobsData.find(j => j.id === jobId);
if (job) {
setSelectedJob(job);
onSelect?.(job);
setMobileDialogOpen(true);
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');
if (jobId) {
const job = jobsData.find(j => j.id === jobId);
if (job) {
setSelectedJob(job);
onSelect?.(job);
setMobileDialogOpen(true);
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);
}
};
const handleJobSelect = (job: Job) => {
setSelectedJob(job);
onSelect?.(job);
setMobileDialogOpen(true);
navigate(`/candidate/jobs/${job.id}`);
};
getJobs();
}, [apiClient, setSnack]);
const handleMobileDialogClose = () => {
setMobileDialogOpen(false);
};
const sortJobs = (jobsList: Job[], field: SortField, order: SortOrder): Job[] => {
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 (!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);
};
if (aValue < bValue) return order === 'asc' ? -1 : 1;
if (aValue > bValue) return order === 'asc' ? 1 : -1;
return 0;
});
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return null;
return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />;
};
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('desc');
}
};
const JobList = () => (
<Paper
elevation={isMobile ? 0 : 1}
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
boxShadow: 'none',
backgroundColor: 'transparent'
}}
const handleJobSelect = (job: Job) => {
setSelectedJob(job);
onSelect?.(job);
setMobileDialogOpen(true);
navigate(`/candidate/jobs/${job.id}`);
};
const handleMobileDialogClose = () => {
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={{
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 }}
>
Jobs ({jobs.length})
</Typography>
Jobs ({jobs.length})
</Typography>
<FormControl size="small" sx={{ minWidth: isSmall ? 120 : isMobile ? 150 : 200 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={`${sortField}-${sortOrder}`}
label="Sort by"
onChange={(e) => {
const [field, order] = e.target.value.split('-') as [SortField, SortOrder];
setSortField(field);
setSortOrder(order);
}}
>
<MenuItem value="updatedAt-desc">Updated (Newest)</MenuItem>
<MenuItem value="updatedAt-asc">Updated (Oldest)</MenuItem>
<MenuItem value="createdAt-desc">Created (Newest)</MenuItem>
<MenuItem value="createdAt-asc">Created (Oldest)</MenuItem>
<MenuItem value="company-asc">Company (A-Z)</MenuItem>
<MenuItem value="company-desc">Company (Z-A)</MenuItem>
<MenuItem value="title-asc">Title (A-Z)</MenuItem>
<MenuItem value="title-desc">Title (Z-A)</MenuItem>
</Select>
</FormControl>
</Box>
<FormControl size="small" sx={{ minWidth: isSmall ? 120 : isMobile ? 150 : 200 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={`${sortField}-${sortOrder}`}
label="Sort by"
onChange={e => {
const [field, order] = e.target.value.split('-') as [SortField, SortOrder];
setSortField(field);
setSortOrder(order);
}}
>
<MenuItem value="updatedAt-desc">Updated (Newest)</MenuItem>
<MenuItem value="updatedAt-asc">Updated (Oldest)</MenuItem>
<MenuItem value="createdAt-desc">Created (Newest)</MenuItem>
<MenuItem value="createdAt-asc">Created (Oldest)</MenuItem>
<MenuItem value="company-asc">Company (A-Z)</MenuItem>
<MenuItem value="company-desc">Company (Z-A)</MenuItem>
<MenuItem value="title-asc">Title (A-Z)</MenuItem>
<MenuItem value="title-desc">Title (Z-A)</MenuItem>
</Select>
</FormControl>
</Box>
<TableContainer sx={{
flex: 1,
overflow: 'auto',
'& .MuiTable-root': {
tableLayout: isMobile ? 'fixed' : 'auto'
}
}}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell
sx={{
cursor: 'pointer',
userSelect: 'none',
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '25%' : 'auto',
backgroundColor: 'background.paper'
}}
onClick={() => handleSort('company')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<BusinessIcon fontSize={isMobile ? "small" : "medium"} />
<Typography variant="caption" fontWeight="bold" noWrap>
{isSmall ? 'Co.' : isMobile ? 'Company' : 'Company'}
</Typography>
{getSortIcon('company')}
</Box>
</TableCell>
<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>
<TableContainer
sx={{
flex: 1,
overflow: 'auto',
'& .MuiTable-root': {
tableLayout: isMobile ? 'fixed' : 'auto',
},
}}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell
sx={{
cursor: 'pointer',
userSelect: 'none',
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '25%' : 'auto',
backgroundColor: 'background.paper',
}}
onClick={() => handleSort('company')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<BusinessIcon fontSize={isMobile ? 'small' : 'medium'} />
<Typography variant="caption" fontWeight="bold" noWrap>
{isSmall ? 'Co.' : isMobile ? 'Company' : 'Company'}
</Typography>
{getSortIcon('company')}
</Box>
)}
</Box>
);
</TableCell>
<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 (
<Box sx={{
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%',
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>
color: 'text.secondary',
textAlign: 'center',
p: 2,
}}
>
<Typography variant="body2">Select a job to view details</Typography>
</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 {
Button,
Typography,
Paper,
Container,
} from '@mui/material';
import { Button, Typography, Paper, Container } from '@mui/material';
import { useNavigate } from 'react-router-dom';
interface LoginRequiredProps {
asset: string;
}
const LoginRequired = (props: LoginRequiredProps) => {
const { asset } = props;
const navigate = useNavigate();
return (
<Container maxWidth="md">
<Paper elevation={3} sx={{ p: 4, mt: 4, textAlign: 'center' }}>
<Typography variant="h5" gutterBottom>
Please log in to access {asset}
</Typography>
<Button variant="contained" onClick={() => { navigate('/login'); }} color="primary" sx={{ mt: 2 }}>
Log In
</Button>
</Paper>
</Container>
);
const { asset } = props;
const navigate = useNavigate();
return (
<Container maxWidth="md">
<Paper elevation={3} sx={{ p: 4, mt: 4, textAlign: 'center' }}>
<Typography variant="h5" gutterBottom>
Please log in to access {asset}
</Typography>
<Button
variant="contained"
onClick={() => {
navigate('/login');
}}
color="primary"
sx={{ mt: 2 }}
>
Log In
</Button>
</Paper>
</Container>
);
};
export { LoginRequired };

View File

@ -2,21 +2,17 @@ import Box from '@mui/material/Box';
import './LoginRestricted.css';
interface LoginRestrictedProps {
children?: React.ReactNode
children?: React.ReactNode;
}
const LoginRestricted = (props: LoginRestrictedProps) => {
const { children } = props;
return (
<Box className="LoginRestricted">
<Box className="LoginRestricted-label">
You must login to access this feature
</Box>
{children}
<Box className="LoginRestricted-label">You must login to access this feature</Box>
{children}
</Box>
);
};
export {
LoginRestricted
};
export { LoginRestricted };

View File

@ -1,18 +1,18 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Box,
Link,
Typography,
Avatar,
Grid,
SxProps,
CardActions,
Chip,
Stack,
CardHeader,
Button,
LinearProgress,
IconButton,
import {
Box,
Link,
Typography,
Avatar,
Grid,
SxProps,
CardActions,
Chip,
Stack,
CardHeader,
Button,
LinearProgress,
IconButton,
Tooltip,
Card,
CardContent,
@ -23,9 +23,9 @@ import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Tabs,
Tab
DialogActions,
Tabs,
Tab,
} from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
import {
@ -38,12 +38,12 @@ import {
Person as PersonIcon,
Schedule as ScheduleIcon,
Visibility as VisibilityIcon,
VisibilityOff as VisibilityOffIcon
VisibilityOff as VisibilityOffIcon,
} from '@mui/icons-material';
import PreviewIcon from '@mui/icons-material/Preview';
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 { useAppState } from 'hooks/GlobalContext';
@ -57,34 +57,32 @@ interface ResumeInfoProps {
sx?: SxProps;
action?: string;
elevation?: number;
variant?: "minimal" | "small" | "normal" | "all" | null;
variant?: 'minimal' | 'small' | 'normal' | 'all' | null;
}
const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const { setSnack } = useAppState();
const { resume } = props;
const { user, apiClient } = useAuth();
const {
sx,
action = '',
elevation = 1,
variant = "normal"
} = props;
const { sx, action = '', elevation = 1, variant = 'normal' } = props;
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 [activeResume, setActiveResume] = useState<Resume>({ ...resume });
const [isContentExpanded, setIsContentExpanded] = useState(false);
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
const [deleted, setDeleted] = 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 [saving, setSaving] = useState<boolean>(false);
const contentRef = useRef<HTMLDivElement>(null);
const [tabValue, setTabValue] = useState("markdown");
const printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({ contentRef: printContentRef, pageStyle: '@page { margin: 10px; }' });
const [tabValue, setTabValue] = useState('markdown');
const printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({
contentRef: printContentRef,
pageStyle: '@page { margin: 10px; }',
});
useEffect(() => {
if (resume && resume.id !== activeResume?.id) {
@ -100,10 +98,10 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
}
}, [resume.resume]);
const deleteResume = async (id: string | undefined) => {
if (id) {
const deleteResume = async (id: string | undefined) => {
if (id) {
try {
await apiClient.deleteResume(id);
await apiClient.deleteResume(id);
setDeleted(true);
setSnack('Resume deleted successfully.');
} catch (error) {
@ -120,8 +118,12 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
setSaving(true);
try {
const result = await apiClient.updateResume(activeResume.id || '', editContent);
const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() };
setActiveResume(updatedResume);
const updatedResume = {
...activeResume,
resume: editContent,
updatedAt: new Date(),
};
setActiveResume(updatedResume);
setSnack('Resume updated successfully.');
} catch (error) {
setSnack('Failed to update resume.');
@ -146,43 +148,57 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
minute: '2-digit',
}).format(date);
};
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
if (newValue === "print") {
reactToPrintFn();
return;
}
setTabValue(newValue);
};
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
if (newValue === 'print') {
reactToPrintFn();
return;
}
setTabValue(newValue);
};
return (
<Box
sx={{
display: "flex",
sx={{
display: 'flex',
borderColor: 'transparent',
borderWidth: 2,
borderStyle: 'solid',
transition: 'all 0.3s ease',
flexDirection: "column",
flexDirection: 'column',
minWidth: 0,
opacity: deleted ? 0.5 : 1.0,
backgroundColor: deleted ? theme.palette.action.disabledBackground : theme.palette.background.paper,
pointerEvents: deleted ? "none" : "auto",
backgroundColor: deleted
? theme.palette.action.disabledBackground
: theme.palette.background.paper,
pointerEvents: deleted ? 'none' : 'auto',
...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 */}
<Box sx={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
gap: 2,
mb: 2
}}>
<Box
sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: 2,
mb: 2,
}}
>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Stack spacing={1}>
@ -197,10 +213,17 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Typography variant="body2" color="text.secondary">
{activeResume.candidate?.fullName || activeResume.candidateId}
</Typography>
{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" />
<Typography variant="subtitle2" fontWeight="bold">
Job
@ -213,7 +236,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
)}
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Stack spacing={1}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@ -229,7 +252,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
Updated: {formatDate(activeResume.updatedAt)}
</Typography>
<Typography variant="caption" color="text.secondary">
Resume ID: {activeResume.id}
Resume ID: {activeResume.id}
</Typography>
</Stack>
</Grid>
@ -240,7 +263,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
{/* Resume Content */}
{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
title="Resume Content"
avatar={<DescriptionIcon color="success" />}
@ -263,12 +286,18 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
component="div"
sx={{
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',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.6,
fontSize: "0.875rem !important",
fontSize: '0.875rem !important',
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
backgroundColor: theme.palette.action.hover,
@ -279,8 +308,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
>
{activeResume.resume}
</Typography>
{shouldShowMoreButton && variant !== "all" && (
{shouldShowMoreButton && variant !== 'all' && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
<Button
variant="text"
@ -289,7 +318,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
startIcon={isContentExpanded ? <VisibilityOffIcon /> : <VisibilityIcon />}
sx={{ fontSize: '0.75rem' }}
>
{isContentExpanded ? "Show Less" : "Show More"}
{isContentExpanded ? 'Show Less' : 'Show More'}
</Button>
</Box>
)}
@ -303,41 +332,59 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<StyledMarkdown content={activeResume.resume} />
</Box>
)}
</Box>
{/* Admin Controls */}
{isAdmin && (
<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: 'column', p: 1 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
pl: 1,
pr: 1,
gap: 1,
alignContent: 'center',
height: '32px',
}}
>
<Tooltip title="Edit Resume">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); handleEditOpen(); }}
onClick={e => {
e.stopPropagation();
handleEditOpen();
}}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete Resume">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); deleteResume(activeResume.id); }}
onClick={e => {
e.stopPropagation();
deleteResume(activeResume.id);
}}
>
<DeleteIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reset Resume">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); handleReset(); }}
onClick={e => {
e.stopPropagation();
handleReset();
}}
>
<RestoreIcon />
</IconButton>
</Tooltip>
</Box>
{saving && (
<Box sx={{ mt: 1 }}>
<LinearProgress />
@ -349,127 +396,142 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Box>
)}
{/* Print Dialog */}
<Dialog
open={printDialogOpen}
onClose={() => { }}//setPrintDialogOpen(false)}
maxWidth="lg"
fullWidth
fullScreen={true}
>
<StyledMarkdown
content={activeResume.resume}
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 */
}} />
</Dialog>
{/* Print Dialog */}
<Dialog
open={printDialogOpen}
onClose={() => {}} //setPrintDialogOpen(false)}
maxWidth="lg"
fullWidth
fullScreen={true}
>
<StyledMarkdown
content={activeResume.resume}
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 */,
}}
/>
</Dialog>
{/* Edit Dialog */}
<Dialog
open={editDialogOpen}
<Dialog
open={editDialogOpen}
onClose={() => setEditDialogOpen(false)}
maxWidth="lg"
fullWidth
disableEscapeKeyDown={true}
fullScreen={true}
disableEscapeKeyDown={true}
fullScreen={true}
>
<DialogTitle>
Edit Resume Content
<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'}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume ID: # {activeResume.id}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Last saved: {activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
</Typography>
<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'}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume ID: # {activeResume.id}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Last saved:{' '}
{activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
</Typography>
</DialogTitle>
<DialogContent sx={{ position: "relative", display: "flex", flexDirection: "column", height: "100%" }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="job" icon={<WorkIcon />} label="Job" />
<Tab value="print" icon={<PrintIcon />} label="Print" />
</Tabs>
<Box ref={printContentRef} sx={{
display: "flex", flexDirection: "column",
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",
}}>
{tabValue === "markdown" &&
<BackstoryTextField
value={editContent}
onChange={(value) => setEditContent(value)}
style={{
position: "relative",
// maxHeight: "100%",
height: "100%",
width: "100%",
display: "flex",
minHeight: "100%",
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
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="job" icon={<WorkIcon />} label="Job" />
<Tab value="print" icon={<PrintIcon />} label="Print" />
</Tabs>
<Box
ref={printContentRef}
sx={{
display: 'flex',
flexDirection: 'column',
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',
}}
>
{tabValue === 'markdown' && (
<BackstoryTextField
value={editContent}
onChange={value => setEditContent(value)}
style={{
position: 'relative',
// maxHeight: "100%",
height: '100%',
width: '100%',
display: 'flex',
minHeight: '100%',
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>
<DialogActions>
<Button onClick={() => setEditDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
variant="contained"
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={saving}
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';
interface StatusIconProps {
type: Types.ApiActivityType;
type: Types.ApiActivityType;
}
const StatusBox = styled(Box)(({ theme }) => ({
@ -30,30 +30,30 @@ const StatusBox = styled(Box)(({ theme }) => ({
}));
const StatusIcon = (props: StatusIconProps) => {
const {type} = props;
const { type } = props;
switch (type) {
case 'converting':
case 'converting':
return <SyncAlt color="primary" />;
case 'heartbeat':
case 'heartbeat':
return <Favorite color="error" />;
case 'system':
case 'system':
return <Settings color="action" />;
case 'info':
case 'info':
return <Info color="info" />;
case 'searching':
case 'searching':
return <Search color="primary" />;
case 'generating':
case 'generating':
return <AutoFixHigh color="secondary" />;
case 'generating_image':
case 'generating_image':
return <Image color="primary" />;
case 'thinking':
case 'thinking':
return <Psychology color="secondary" />;
case 'tooling':
case 'tooling':
return <Build color="action" />;
default:
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 {
Chat as ChatIcon,
Dashboard as DashboardIcon,
@ -18,35 +18,35 @@ import {
Analytics as AnalyticsIcon,
BubbleChart,
AutoFixHigh,
} from "@mui/icons-material";
} from '@mui/icons-material';
import EditDocumentIcon from '@mui/icons-material/EditDocument';
import { BackstoryLogo } from "components/ui/BackstoryLogo";
import { HomePage } from "pages/HomePage";
import { CandidateChatPage } from "pages/CandidateChatPage";
import { DocsPage } from "pages/DocsPage";
import { CreateProfilePage } from "pages/candidate/ProfileWizard";
import { VectorVisualizerPage } from "pages/VectorVisualizerPage";
import { BetaPage } from "pages/BetaPage";
import { CandidateListingPage } from "pages/FindCandidatePage";
import { JobAnalysisPage } from "pages/JobAnalysisPage";
import { GenerateCandidate } from "pages/GenerateCandidate";
import { LoginPage } from "pages/LoginPage";
import { EmailVerificationPage } from "components/EmailVerificationComponents";
import { Box, Typography } from "@mui/material";
import { CandidateDashboard } from "pages/candidate/Dashboard";
import { NavigationConfig, NavigationItem } from "types/navigation";
import { HowItWorks } from "pages/HowItWorks";
import SchoolIcon from "@mui/icons-material/School";
import { CandidateProfile } from "pages/candidate/Profile";
import { Settings } from "pages/candidate/Settings";
import { VectorVisualizer } from "components/VectorVisualizer";
import { DocumentManager } from "components/DocumentManager";
import { useAuth } from "hooks/AuthContext";
import { useNavigate } from "react-router-dom";
import { JobViewer } from "components/ui/JobViewer";
import { CandidatePicker } from "components/ui/CandidatePicker";
import { ResumeViewer } from "components/ui/ResumeViewer";
import { BackstoryLogo } from 'components/ui/BackstoryLogo';
import { HomePage } from 'pages/HomePage';
import { CandidateChatPage } from 'pages/CandidateChatPage';
import { DocsPage } from 'pages/DocsPage';
import { CreateProfilePage } from 'pages/candidate/ProfileWizard';
import { VectorVisualizerPage } from 'pages/VectorVisualizerPage';
import { BetaPage } from 'pages/BetaPage';
import { CandidateListingPage } from 'pages/FindCandidatePage';
import { JobAnalysisPage } from 'pages/JobAnalysisPage';
import { GenerateCandidate } from 'pages/GenerateCandidate';
import { LoginPage } from 'pages/LoginPage';
import { EmailVerificationPage } from 'components/EmailVerificationComponents';
import { Box, Typography } from '@mui/material';
import { CandidateDashboard } from 'pages/candidate/Dashboard';
import { NavigationConfig, NavigationItem } from 'types/navigation';
import { HowItWorks } from 'pages/HowItWorks';
import SchoolIcon from '@mui/icons-material/School';
import { CandidateProfile } from 'pages/candidate/Profile';
import { Settings } from 'pages/candidate/Settings';
import { VectorVisualizer } from 'components/VectorVisualizer';
import { DocumentManager } from 'components/DocumentManager';
import { useAuth } from 'hooks/AuthContext';
import { useNavigate } from 'react-router-dom';
import { JobViewer } from 'components/ui/JobViewer';
import { CandidatePicker } from 'components/ui/CandidatePicker';
import { ResumeViewer } from 'components/ui/ResumeViewer';
// Beta page components for placeholder routes
const BackstoryPage = () => (
@ -89,12 +89,10 @@ const LogoutPage = () => {
const { logout } = useAuth();
const navigate = useNavigate();
logout().then(() => {
navigate("/");
navigate('/');
});
return (
<Typography variant="h4">Logging out...</Typography>
);
}
return <Typography variant="h4">Logging out...</Typography>;
};
const AnalyticsPage = () => (
<BetaPage>
<Typography variant="h4">Analytics</Typography>
@ -109,28 +107,28 @@ const SettingsPage = () => (
export const navigationConfig: NavigationConfig = {
items: [
{
id: "home",
id: 'home',
label: <BackstoryLogo />,
path: "/",
path: '/',
component: <HowItWorks />,
userTypes: ["guest", "candidate", "employer"],
userTypes: ['guest', 'candidate', 'employer'],
exact: true,
},
{
id: "job-analysis",
label: "Job Analysis",
path: "/job-analysis",
id: 'job-analysis',
label: 'Job Analysis',
path: '/job-analysis',
icon: <WorkIcon />,
component: <JobAnalysisPage />,
userTypes: ["guest", "candidate", "employer"],
userTypes: ['guest', 'candidate', 'employer'],
},
{
id: "chat",
label: "Candidate Chat",
path: "/chat",
id: 'chat',
label: 'Candidate Chat',
path: '/chat',
icon: <ChatIcon />,
component: <CandidateChatPage />,
userTypes: ["guest", "candidate", "employer"],
userTypes: ['guest', 'candidate', 'employer'],
},
// {
// id: "explore",
@ -138,93 +136,89 @@ export const navigationConfig: NavigationConfig = {
// icon: <SearchIcon />,
// userTypes: ["candidate", "guest", "employer"],
// children: [
// {
// id: "explore-candidates",
// label: "Candidates",
// path: "/candidate/candidates",
// icon: <SearchIcon />,
// component: (
// <CandidatePicker />
// ),
// userTypes: ["candidate", "guest", "employer"],
// },
// {
// id: "explore-candidates",
// label: "Candidates",
// path: "/candidate/candidates",
// icon: <SearchIcon />,
// component: (
// <CandidatePicker />
// ),
// userTypes: ["candidate", "guest", "employer"],
// },
// ],
// showInNavigation: true,
// },
{
id: "generate-candidate",
label: "Generate Candidate",
path: "/admin/generate-candidate",
id: 'generate-candidate',
label: 'Generate Candidate',
path: '/admin/generate-candidate',
icon: <AutoFixHigh />,
component: <GenerateCandidate />,
userTypes: ["admin"],
userTypes: ['admin'],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: "admin",
userMenuGroup: 'admin',
},
// User menu only items (not shown in main navigation)
{
id: "candidate-profile",
label: "Profile",
id: 'candidate-profile',
label: 'Profile',
icon: <PersonIcon />,
path: "/candidate/profile",
path: '/candidate/profile',
component: <CandidateProfile />,
userTypes: ["candidate"],
userMenuGroup: "profile",
userTypes: ['candidate'],
userMenuGroup: 'profile',
showInNavigation: false,
showInUserMenu: true,
},
{
id: "candidate-dashboard",
label: "Dashboard",
path: "/candidate/dashboard",
id: 'candidate-dashboard',
label: 'Dashboard',
path: '/candidate/dashboard',
icon: <DashboardIcon />,
component: <CandidateDashboard />,
userTypes: ["candidate"],
userMenuGroup: "profile",
userTypes: ['candidate'],
userMenuGroup: 'profile',
showInNavigation: false,
showInUserMenu: true,
},
{
id: "explore-jobs",
label: "Jobs",
path: "/candidate/jobs/:jobId?",
id: 'explore-jobs',
label: 'Jobs',
path: '/candidate/jobs/:jobId?',
icon: <WorkIcon />,
component: (
<JobViewer />
),
userTypes: ["candidate", "guest", "employer"],
component: <JobViewer />,
userTypes: ['candidate', 'guest', 'employer'],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: "profile",
userMenuGroup: 'profile',
},
{
id: "explore-resumes",
label: "Resumes",
path: "/candidate/resumes/:resumeId?",
id: 'explore-resumes',
label: 'Resumes',
path: '/candidate/resumes/:resumeId?',
icon: <EditDocumentIcon />,
component: (
<ResumeViewer />
),
userTypes: ["candidate", "guest", "employer"],
component: <ResumeViewer />,
userTypes: ['candidate', 'guest', 'employer'],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: "profile",
userMenuGroup: 'profile',
},
{
id: "candidate-docs",
label: "Content",
id: 'candidate-docs',
label: 'Content',
icon: <BubbleChart />,
path: "/candidate/documents",
path: '/candidate/documents',
component: (
<Box sx={{ display: "flex", width: "100%", flexDirection: "column" }}>
<Box sx={{ display: 'flex', width: '100%', flexDirection: 'column' }}>
<VectorVisualizer />
<DocumentManager />
</Box>
),
userTypes: ["candidate"],
userMenuGroup: "profile",
userTypes: ['candidate'],
userMenuGroup: 'profile',
showInNavigation: false,
showInUserMenu: true,
},
@ -271,65 +265,65 @@ export const navigationConfig: NavigationConfig = {
// showInUserMenu: true,
// },
{
id: "candidate-settings",
label: "System Information",
path: "/candidate/settings",
id: 'candidate-settings',
label: 'System Information',
path: '/candidate/settings',
icon: <SettingsIcon />,
component: <Settings />,
userTypes: ["candidate"],
userMenuGroup: "account",
userTypes: ['candidate'],
userMenuGroup: 'account',
showInNavigation: false,
showInUserMenu: true,
},
{
id: "logout",
label: "Logout",
id: 'logout',
label: 'Logout',
icon: <PersonIcon />, // This will be handled specially in Header
userTypes: ["candidate", "employer"],
userTypes: ['candidate', 'employer'],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: "system",
userMenuGroup: 'system',
},
// Auth routes (special handling)
{
id: "auth",
label: "Auth",
userTypes: ["guest", "candidate", "employer"],
id: 'auth',
label: 'Auth',
userTypes: ['guest', 'candidate', 'employer'],
showInNavigation: false,
children: [
{
id: "verify-email",
label: "Verify Email",
path: "/login/verify-email",
id: 'verify-email',
label: 'Verify Email',
path: '/login/verify-email',
component: <EmailVerificationPage />,
userTypes: ["guest", "candidate", "employer"],
userTypes: ['guest', 'candidate', 'employer'],
showInNavigation: false,
},
{
id: "login",
label: "Login",
path: "/login/:tab?",
id: 'login',
label: 'Login',
path: '/login/:tab?',
component: <LoginPage />,
userTypes: ["guest", "candidate", "employer"],
userTypes: ['guest', 'candidate', 'employer'],
showInNavigation: false,
},
{
id: "logout-page",
label: "Logout",
path: "/logout",
id: 'logout-page',
label: 'Logout',
path: '/logout',
component: <LogoutPage />,
userTypes: ["candidate", "employer"],
userTypes: ['candidate', 'employer'],
showInNavigation: false,
},
],
},
// Catch-all route
{
id: "catch-all",
label: "Not Found",
path: "*",
id: 'catch-all',
label: 'Not Found',
path: '*',
component: <BetaPage />,
userTypes: ["guest", "candidate", "employer"],
userTypes: ['guest', 'candidate', 'employer'],
showInNavigation: false,
},
],
@ -337,39 +331,45 @@ export const navigationConfig: NavigationConfig = {
// Utility functions for working with navigation config
export const getNavigationItemsForUser = (
userType: "guest" | "candidate" | "employer" | null,
userType: 'guest' | 'candidate' | 'employer' | null,
isAdmin: boolean
): NavigationItem[] => {
const currentUserType = userType || "guest";
const currentUserType = userType || 'guest';
const filterItems = (items: NavigationItem[]): NavigationItem[] => {
return items
.filter(
(item) =>
!item.userTypes || item.userTypes.includes(currentUserType) || (item.userTypes.includes("admin") && isAdmin)
item =>
!item.userTypes ||
item.userTypes.includes(currentUserType) ||
(item.userTypes.includes('admin') && isAdmin)
)
.filter((item) => item.showInNavigation !== false) // Default to true if not specified
.map((item) => ({
.filter(item => item.showInNavigation !== false) // Default to true if not specified
.map(item => ({
...item,
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);
};
export const getAllRoutes = (
userType: "guest" | "candidate" | "employer" | null,
userType: 'guest' | 'candidate' | 'employer' | null,
isAdmin: boolean
): NavigationItem[] => {
const currentUserType = userType || "guest";
const currentUserType = userType || 'guest';
const extractRoutes = (items: NavigationItem[]): NavigationItem[] => {
const routes: NavigationItem[] = [];
items.forEach((item) => {
if (!item.userTypes || item.userTypes.includes(currentUserType) || (item.userTypes.includes("admin") && isAdmin)) {
items.forEach(item => {
if (
!item.userTypes ||
item.userTypes.includes(currentUserType) ||
(item.userTypes.includes('admin') && isAdmin)
) {
if (item.path && item.component) {
routes.push(item);
}
@ -386,25 +386,28 @@ export const getAllRoutes = (
};
export const getMainNavigationItems = (
userType: "guest" | "candidate" | "employer" | null,
userType: 'guest' | 'candidate' | 'employer' | null,
isAdmin: boolean
): NavigationItem[] => {
return getNavigationItemsForUser(userType, isAdmin).filter(
(item) =>
item.id !== "auth" &&
item.id !== "catch-all" &&
item =>
item.id !== 'auth' &&
item.id !== 'catch-all' &&
item.showInNavigation !== false &&
(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 [];
const extractUserMenuItems = (items: NavigationItem[]): NavigationItem[] => {
const menuItems: NavigationItem[] = [];
items.forEach((item) => {
items.forEach(item => {
if (!item.userTypes || item.userTypes.includes(userType) || isAdmin) {
if (item.showInUserMenu) {
menuItems.push(item);
@ -422,7 +425,7 @@ export const getUserMenuItems = (userType: "candidate" | "employer" | "guest" |
};
export const getUserMenuItemsByGroup = (
userType: "candidate" | "employer" | "guest" | null,
userType: 'candidate' | 'employer' | 'guest' | null,
isAdmin: boolean
): { [key: string]: NavigationItem[] } => {
const menuItems = getUserMenuItems(userType, isAdmin);
@ -434,8 +437,8 @@ export const getUserMenuItemsByGroup = (
other: [],
};
menuItems.forEach((item) => {
const group = item.userMenuGroup || "other";
menuItems.forEach(item => {
const group = item.userMenuGroup || 'other';
if (!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' }}>
Backstory Application Analysis
</Typography>
<Typography variant="h2" component="h2">
Core Concept
</Typography>
<Typography variant="body1">
Backstory is a dual-purpose platform designed to bridge the gap between job candidates and
employers/recruiters with an AI-powered approach to professional profiles and resume generation.
Backstory is a dual-purpose platform designed to bridge the gap between job candidates
and employers/recruiters with an AI-powered approach to professional profiles and resume
generation.
</Typography>
<Typography variant="h3" component="h3" sx={{ mt: 3 }}>
@ -27,14 +28,15 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ol" sx={{ pl: 4 }}>
<li>
<Typography variant="body1" component="div">
<strong>Job Candidates</strong> - Upload and manage comprehensive professional histories
and generate tailored resumes for specific positions
<strong>Job Candidates</strong> - Upload and manage comprehensive professional
histories and generate tailored resumes for specific positions
</Typography>
</li>
<li>
<Typography variant="body1" component="div">
<strong>Employers/Recruiters</strong> - Search for candidates, directly interact with AI
assistants about candidate experiences, and generate position-specific resumes
<strong>Employers/Recruiters</strong> - Search for candidates, directly interact
with AI assistants about candidate experiences, and generate position-specific
resumes
</Typography>
</li>
</Box>
@ -42,34 +44,39 @@ const BackstoryAppAnalysisPage = () => {
<Typography variant="h2" component="h2" sx={{ mt: 4 }}>
Key Features
</Typography>
<Typography variant="h3" component="h3" sx={{ mt: 3 }}>
For Candidates
</Typography>
<Box component="ul" sx={{ pl: 4 }}>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</Box>
@ -80,27 +87,32 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</Box>
@ -118,17 +130,20 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</Box>
@ -139,32 +154,38 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ol" sx={{ pl: 4 }}>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</Box>
@ -173,8 +194,8 @@ const BackstoryAppAnalysisPage = () => {
Mobile Adaptations
</Typography>
<Typography variant="body1">
The mobile designs show a simplified navigation structure with bottom tabs and a hamburger menu,
maintaining the core functionality while adapting to smaller screens.
The mobile designs show a simplified navigation structure with bottom tabs and a
hamburger menu, maintaining the core functionality while adapting to smaller screens.
</Typography>
<Typography variant="h2" component="h2" sx={{ mt: 4 }}>
@ -187,22 +208,26 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ul" sx={{ pl: 4 }}>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</Box>
@ -218,17 +243,20 @@ const BackstoryAppAnalysisPage = () => {
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</Box>
@ -239,27 +267,32 @@ const BackstoryAppAnalysisPage = () => {
<Box component="ol" sx={{ pl: 4 }}>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</Box>
@ -293,7 +326,9 @@ const BackstoryAppAnalysisPage = () => {
<Typography variant="body1">Role-based access for employer teams</Typography>
</li>
<li>
<Typography variant="body1">Data management options for compliance requirements</Typography>
<Typography variant="body1">
Data management options for compliance requirements
</Typography>
</li>
</Box>
</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 colorSwatch = (color: string, name: string, textColor = '#fff') => (
<div className="flex flex-col items-center">
<div
className="w-20 h-20 rounded-lg shadow-md flex items-center justify-center mb-2"
style={{ backgroundColor: color, color: textColor }}>
<div
className="w-20 h-20 rounded-lg shadow-md flex items-center justify-center mb-2"
style={{ backgroundColor: color, color: textColor }}
>
{name}
</div>
<span className="text-xs">{color}</span>
@ -19,71 +20,91 @@ const BackstoryThemeVisualizerPage = () => {
<Box sx={{ backgroundColor: 'background.default', minHeight: '100%', py: 4 }}>
<Container maxWidth="lg">
<Paper sx={{ p: 4, boxShadow: 2 }}>
<div className="p-8">
<h1 className="text-2xl font-bold mb-6" style={{ color: backstoryTheme.palette.text.primary }}>
Backstory Theme Visualization
</h1>
<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
<div className="p-8">
<h1
className="text-2xl font-bold mb-6"
style={{ color: backstoryTheme.palette.text.primary }}
>
Backstory Theme Visualization
</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">
<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>
</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={{
color: backstoryTheme.components?.MuiLink?.styleOverrides.root.color || "inherit",
textDecoration: backstoryTheme.components.MuiLink.styleOverrides.root.textDecoration,
@ -91,112 +112,245 @@ const BackstoryThemeVisualizerPage = () => {
This is how links will appear by default
</a>
</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 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 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
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 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>
<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>
</div>
<div>
<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>
</Paper>
</Container>
</Box>
);
};
export {
BackstoryThemeVisualizerPage
};
export { BackstoryThemeVisualizerPage };

View File

@ -7,31 +7,48 @@ const BackstoryUIOverviewPage: React.FC = () => {
return (
<ThemeProvider theme={backstoryTheme}>
<CssBaseline />
<Box sx={{ bgcolor: 'background.default', overflow: "hidden", py: 4 }}>
<Box sx={{ bgcolor: 'background.default', overflow: 'hidden', py: 4 }}>
<Container maxWidth="lg">
<Paper sx={{ p: 4, borderRadius: 2, boxShadow: 2 }}>
{/* Header */}
<Box 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 }}>
<Box
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
</Typography>
<Typography variant="body1" color="text.secondary">
A visual overview of the dual-purpose application serving candidates and employers
</Typography>
</Box>
{/* User Types */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{xs: 12, md: 6}}>
<Box sx={{
p: 3,
bgcolor: 'rgba(74, 122, 125, 0.1)',
borderRadius: 2,
border: '1px solid',
borderColor: 'rgba(74, 122, 125, 0.3)',
height: '100%'
}}>
<Typography variant="h6" sx={{ color: 'secondary.main', mb: 2, fontWeight: 'bold' }}>
<Grid size={{ xs: 12, md: 6 }}>
<Box
sx={{
p: 3,
bgcolor: 'rgba(74, 122, 125, 0.1)',
borderRadius: 2,
border: '1px solid',
borderColor: 'rgba(74, 122, 125, 0.3)',
height: '100%',
}}
>
<Typography
variant="h6"
sx={{ color: 'secondary.main', mb: 2, fontWeight: 'bold' }}
>
Candidate Experience
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
@ -39,27 +56,39 @@ const BackstoryUIOverviewPage: React.FC = () => {
'Create comprehensive professional profiles',
'Configure AI assistant for employer Q&A',
'Generate tailored resumes for specific jobs',
'Track profile engagement metrics'
'Track profile engagement metrics',
].map((item, index) => (
<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>
</Box>
))}
</Box>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6}}>
<Box sx={{
p: 3,
bgcolor: 'rgba(26, 37, 54, 0.1)',
borderRadius: 2,
border: '1px solid',
borderColor: 'rgba(26, 37, 54, 0.3)',
height: '100%'
}}>
<Typography variant="h6" sx={{ color: 'primary.main', mb: 2, fontWeight: 'bold' }}>
<Grid size={{ xs: 12, md: 6 }}>
<Box
sx={{
p: 3,
bgcolor: 'rgba(26, 37, 54, 0.1)',
borderRadius: 2,
border: '1px solid',
borderColor: 'rgba(26, 37, 54, 0.3)',
height: '100%',
}}
>
<Typography
variant="h6"
sx={{ color: 'primary.main', mb: 2, fontWeight: 'bold' }}
>
Employer Experience
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
@ -67,10 +96,17 @@ const BackstoryUIOverviewPage: React.FC = () => {
'Search for candidates with specific skills',
'Interact with candidate AI assistants',
'Generate position-specific candidate resumes',
'Manage talent pools and job listings'
'Manage talent pools and job listings',
].map((item, index) => (
<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>
</Box>
))}
@ -78,37 +114,73 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Box>
</Grid>
</Grid>
{/* 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' }}>
Key UI Components
</Typography>
<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: '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' }
{
title: 'Dashboards',
description:
'Role-specific dashboards with card-based metrics and action items',
},
{
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) => (
<Grid size={{xs: 12, sm: 6, md: 4}} key={index}>
<Box sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
height: '100%',
transition: 'all 0.2s ease-in-out',
'&:hover': {
bgcolor: 'rgba(212, 160, 23, 0.05)',
borderColor: 'action.active',
transform: 'translateY(-2px)',
boxShadow: 1
}
}}>
<Typography variant="h6" sx={{ color: 'secondary.main', mb: 1, fontWeight: 'medium' }}>
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<Box
sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
height: '100%',
transition: 'all 0.2s ease-in-out',
'&:hover': {
bgcolor: 'rgba(212, 160, 23, 0.05)',
borderColor: 'action.active',
transform: 'translateY(-2px)',
boxShadow: 1,
},
}}
>
<Typography
variant="h6"
sx={{
color: 'secondary.main',
mb: 1,
fontWeight: 'medium',
}}
>
{component.title}
</Typography>
<Typography variant="body2" color="text.secondary">
@ -119,49 +191,72 @@ const BackstoryUIOverviewPage: React.FC = () => {
))}
</Grid>
</Box>
{/* Navigation Structure */}
<Grid container spacing={3} sx={{ mb: 4 }}>
{[
{
{
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',
borderColor: 'secondary.main'
borderColor: 'secondary.main',
},
{
{
title: 'Employer Navigation',
items: ['Dashboard', 'Search', 'Saved', 'Jobs', 'Company', 'Analytics', 'Settings'],
items: [
'Dashboard',
'Search',
'Saved',
'Jobs',
'Company',
'Analytics',
'Settings',
],
color: 'primary.main',
borderColor: 'primary.main'
borderColor: 'primary.main',
},
{
{
title: 'Public Navigation',
items: ['Home', 'Docs', 'Pricing', 'Login', 'Register'],
color: 'custom.highlight',
borderColor: 'custom.highlight'
}
borderColor: 'custom.highlight',
},
].map((nav, index) => (
<Grid size={{xs:12, md:4}} key={index}>
<Box sx={{
p: 3,
bgcolor: 'background.paper',
borderRadius: 2,
boxShadow: 1,
height: '100%'
}}>
<Typography variant="h6" sx={{ color: 'text.primary', mb: 2, fontWeight: 'bold' }}>
<Grid size={{ xs: 12, md: 4 }} key={index}>
<Box
sx={{
p: 3,
bgcolor: 'background.paper',
borderRadius: 2,
boxShadow: 1,
height: '100%',
}}
>
<Typography
variant="h6"
sx={{ color: 'text.primary', mb: 2, fontWeight: 'bold' }}
>
{nav.title}
</Typography>
<Box sx={{
borderLeft: 3,
borderColor: nav.borderColor,
pl: 2,
py: 1,
display: 'flex',
flexDirection: 'column',
gap: 1.5
}}>
<Box
sx={{
borderLeft: 3,
borderColor: nav.borderColor,
pl: 2,
py: 1,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
{nav.items.map((item, idx) => (
<Typography key={idx} sx={{ color: nav.color, fontWeight: 'medium' }}>
{item}
@ -172,86 +267,105 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Grid>
))}
</Grid>
{/* 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' }}>
System Connection Points
</Typography>
<Box sx={{ position: 'relative', py: 2 }}>
{/* Connection line */}
<Box sx={{
position: 'absolute',
left: '50%',
top: 0,
bottom: 0,
width: 1,
borderColor: 'divider',
zIndex: 0,
borderLeft: "1px solid",
overflow: "hidden",
}} />
<Box
sx={{
position: 'absolute',
left: '50%',
top: 0,
bottom: 0,
width: 1,
borderColor: 'divider',
zIndex: 0,
borderLeft: '1px solid',
overflow: 'hidden',
}}
/>
{/* Connection points */}
{[
{ left: 'Candidate Profile', right: 'Employer Search' },
{ left: 'Q&A Setup', right: 'Q&A Interface' },
{ left: 'Resume Generator', right: 'Job Posts' }
{ left: 'Resume Generator', right: 'Job Posts' },
].map((connection, index) => (
<Box
key={index}
sx={{
display: 'flex',
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
mb: index < 2 ? 5 : 0,
position: 'relative',
zIndex: 1,
}}
>
<Box sx={{
flex: 1,
display: 'flex',
justifyContent: 'flex-end',
pr: 3
}}>
<Box sx={{
display: 'inline-block',
bgcolor: 'rgba(74, 122, 125, 0.1)',
p: 2,
borderRadius: 2,
color: 'secondary.main',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'rgba(74, 122, 125, 0.3)'
}}>
<Box
sx={{
flex: 1,
display: 'flex',
justifyContent: 'flex-end',
pr: 3,
}}
>
<Box
sx={{
display: 'inline-block',
bgcolor: 'rgba(74, 122, 125, 0.1)',
p: 2,
borderRadius: 2,
color: 'secondary.main',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'rgba(74, 122, 125, 0.3)',
}}
>
{connection.left}
</Box>
</Box>
<Box sx={{
width: 16,
height: 16,
borderRadius: '50%',
bgcolor: 'custom.highlight',
zIndex: 2,
boxShadow: 2,
}} />
<Box sx={{
flex: 1,
pl: 3,
}}>
<Box sx={{
display: 'inline-block',
bgcolor: 'rgba(26, 37, 54, 0.1)',
p: 2,
borderRadius: 2,
color: 'primary.main',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'rgba(26, 37, 54, 0.3)',
}}>
<Box
sx={{
width: 16,
height: 16,
borderRadius: '50%',
bgcolor: 'custom.highlight',
zIndex: 2,
boxShadow: 2,
}}
/>
<Box
sx={{
flex: 1,
pl: 3,
}}
>
<Box
sx={{
display: 'inline-block',
bgcolor: 'rgba(26, 37, 54, 0.1)',
p: 2,
borderRadius: 2,
color: 'primary.main',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'rgba(26, 37, 54, 0.3)',
}}
>
{connection.right}
</Box>
</Box>
@ -259,91 +373,146 @@ const BackstoryUIOverviewPage: React.FC = () => {
))}
</Box>
</Box>
{/* 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' }}>
Mobile Adaptation
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Box sx={{
width: 200,
height: 400,
border: '4px solid',
borderColor: 'text.primary',
borderRadius: 5,
p: 1,
bgcolor: 'background.default'
}}>
<Box sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: '1px solid',
borderColor: 'divider',
borderRadius: 4,
overflow: 'hidden'
}}>
{/* Mobile header */}
<Box sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
p: 1,
<Box
sx={{
width: 200,
height: 400,
border: '4px solid',
borderColor: 'text.primary',
borderRadius: 5,
p: 1,
bgcolor: 'background.default',
}}
>
<Box
sx={{
height: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.875rem' }}>BACKSTORY</Typography>
flexDirection: 'column',
border: '1px solid',
borderColor: 'divider',
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>
{/* Mobile content */}
<Box sx={{
flex: 1,
p: 1.5,
overflow: 'auto',
fontSize: '0.75rem'
}}>
<Typography sx={{ mb: 1, fontWeight: 'medium' }}>Welcome back, [Name]!</Typography>
<Typography sx={{ fontSize: '0.675rem', mb: 2 }}>Profile: 75% complete</Typography>
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
mb: 2,
bgcolor: 'background.paper'
}}>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', mb: 0.5 }}>Resume Builder</Typography>
<Box
sx={{
flex: 1,
p: 1.5,
overflow: 'auto',
fontSize: '0.75rem',
}}
>
<Typography sx={{ mb: 1, fontWeight: 'medium' }}>
Welcome back, [Name]!
</Typography>
<Typography sx={{ fontSize: '0.675rem', mb: 2 }}>
Profile: 75% complete
</Typography>
<Box
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
mb: 2,
bgcolor: 'background.paper',
}}
>
<Typography
sx={{
fontWeight: 'bold',
fontSize: '0.75rem',
mb: 0.5,
}}
>
Resume Builder
</Typography>
<Typography sx={{ fontSize: '0.675rem' }}>3 custom resumes</Typography>
</Box>
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'background.paper'
}}>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', mb: 0.5 }}>Recent Activity</Typography>
<Box
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'background.paper',
}}
>
<Typography
sx={{
fontWeight: 'bold',
fontSize: '0.75rem',
mb: 0.5,
}}
>
Recent Activity
</Typography>
<Typography sx={{ fontSize: '0.675rem' }}> 5 profile views</Typography>
<Typography sx={{ fontSize: '0.675rem' }}> 2 downloads</Typography>
</Box>
</Box>
{/* Mobile footer */}
<Box sx={{
bgcolor: 'background.default',
p: 1,
display: 'flex',
justifyContent: 'space-around',
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>
<Box
sx={{
bgcolor: 'background.default',
p: 1,
display: 'flex',
justifyContent: 'space-around',
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>
</Box>
</Box>
</Box>
@ -356,6 +525,4 @@ const BackstoryUIOverviewPage: React.FC = () => {
);
};
export {
BackstoryUIOverviewPage
};
export { BackstoryUIOverviewPage };

View File

@ -1,15 +1,36 @@
import React, { useState } from 'react';
import {
AppBar, Box, Button, Chip, Drawer,
IconButton, List, ListItem, ListItemButton, ListItemIcon,
ListItemText, Paper, Tab, Tabs, TextField, Typography,
useMediaQuery, useTheme
AppBar,
Box,
Button,
Chip,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
Tab,
Tabs,
TextField,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import {
Menu as MenuIcon, Search as SearchIcon, Description as FileTextIcon,
Person as UserIcon, Settings as SettingsIcon, Add as PlusIcon,
Edit as EditIcon, Visibility as EyeIcon, Save as SaveIcon,
Delete as TrashIcon, AccessTime as ClockIcon
Menu as MenuIcon,
Search as SearchIcon,
Description as FileTextIcon,
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';
interface Resume {
@ -22,37 +43,74 @@ interface Resume {
const MockupPage = () => {
const theme = useTheme();
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 [selectedResume, setSelectedResume] = useState<number | null>(null);
// Mock data
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: 3, name: "Data Scientist - AI Corp", date: "May 5, 2025", isRecent: false },
{
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: 3,
name: 'Data Scientist - AI Corp',
date: 'May 5, 2025',
isRecent: false,
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', bgcolor: 'background.default' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
bgcolor: 'background.default',
}}
>
{/* Header */}
<AppBar 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 }}>
<AppBar
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 }}>
<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 && (
<IconButton edge="start" color="inherit" onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}>
<IconButton
edge="start"
color="inherit"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<MenuIcon />
</IconButton>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{!isMobile && (
<Button
startIcon={<PlusIcon />}
color="primary"
size="small"
>
<Button startIcon={<PlusIcon />} color="primary" size="small">
New Resume
</Button>
)}
@ -71,12 +129,25 @@ const MockupPage = () => {
sx={{
width: 240,
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 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>Main</Typography>
<Typography variant="overline" color="text.secondary" gutterBottom>
Main
</Typography>
<List disablePadding>
<ListItem disablePadding>
<ListItemButton sx={{ borderRadius: 1 }}>
@ -88,7 +159,11 @@ const MockupPage = () => {
</ListItem>
<ListItem disablePadding>
<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' }}>
<FileTextIcon fontSize="small" />
@ -100,7 +175,9 @@ const MockupPage = () => {
</Box>
<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>
<ListItem disablePadding>
<ListItemButton sx={{ borderRadius: 1 }}>
@ -128,16 +205,22 @@ const MockupPage = () => {
<Box sx={{ mt: 'auto' }}>
<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>
{savedResumes.filter(r => r.isRecent).map(resume => (
<ListItem key={resume.id} disablePadding sx={{ mb: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<ClockIcon fontSize="small" />
</ListItemIcon>
<Typography variant="body2" noWrap>{resume.name}</Typography>
</ListItem>
))}
{savedResumes
.filter(r => r.isRecent)
.map(resume => (
<ListItem key={resume.id} disablePadding sx={{ mb: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<ClockIcon fontSize="small" />
</ListItemIcon>
<Typography variant="body2" noWrap>
{resume.name}
</Typography>
</ListItem>
))}
</List>
</Paper>
</Box>
@ -152,12 +235,21 @@ const MockupPage = () => {
onClose={() => setIsMobileMenuOpen(false)}
sx={{
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 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>Main</Typography>
<Typography variant="overline" color="text.secondary" gutterBottom>
Main
</Typography>
<List disablePadding>
<ListItem disablePadding>
<ListItemButton onClick={() => setIsMobileMenuOpen(false)}>
@ -182,7 +274,9 @@ const MockupPage = () => {
</Box>
<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>
<ListItem disablePadding>
<ListItemButton onClick={() => setIsMobileMenuOpen(false)}>
@ -214,18 +308,21 @@ const MockupPage = () => {
<Box sx={{ flex: 1, overflow: 'auto', p: 3 }}>
{/* Resume Builder content */}
<Box sx={{ mb: 4 }}>
<Typography variant="h5" component="h2" fontWeight="bold" gutterBottom>Resume Builder</Typography>
<Typography variant="body2" color="text.secondary">Generate and customize resumes based on job descriptions</Typography>
<Typography variant="h5" component="h2" fontWeight="bold" gutterBottom>
Resume Builder
</Typography>
<Typography variant="body2" color="text.secondary">
Generate and customize resumes based on job descriptions
</Typography>
</Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
aria-label="Resume builder tabs"
variant={isMobile ? "scrollable" : "standard"}
scrollButtons={isMobile ? "auto" : undefined}
variant={isMobile ? 'scrollable' : 'standard'}
scrollButtons={isMobile ? 'auto' : undefined}
>
<Tab label="Job Description" value="job" />
<Tab label="Resume" value="resume" />
@ -233,11 +330,12 @@ const MockupPage = () => {
<Tab label="Saved Resumes" value="saved" />
</Tabs>
</Box>
{/* Tab content */}
{activeTab === 'job' && (
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Job Description</Typography>
<Typography variant="h6" gutterBottom>
Job Description
</Typography>
<TextField
fullWidth
multiline
@ -252,24 +350,22 @@ const MockupPage = () => {
</Box>
</Paper>
)}
{activeTab === 'resume' && (
<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>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<SaveIcon />}
>
<Button variant="outlined" size="small" startIcon={<SaveIcon />}>
Save
</Button>
<Button
variant="outlined"
size="small"
startIcon={<EyeIcon />}
>
<Button variant="outlined" size="small" startIcon={<EyeIcon />}>
Preview
</Button>
</Box>
@ -279,21 +375,46 @@ const MockupPage = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Contact information */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="medium">Contact Information</Typography>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Contact Information
</Typography>
<IconButton size="small" color="default">
<EditIcon fontSize="small" />
</IconButton>
</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>
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
<Typography
variant="caption"
color="text.secondary"
display="block"
gutterBottom
>
Full Name
</Typography>
<TextField size="small" fullWidth defaultValue="John Doe" />
</Box>
<Box>
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
<Typography
variant="caption"
color="text.secondary"
display="block"
gutterBottom
>
Email
</Typography>
<TextField size="small" fullWidth defaultValue="john@example.com" />
@ -303,8 +424,17 @@ const MockupPage = () => {
{/* Professional Summary */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="medium">Professional Summary</Typography>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Professional Summary
</Typography>
<IconButton size="small" color="default">
<EditIcon fontSize="small" />
</IconButton>
@ -320,13 +450,18 @@ const MockupPage = () => {
{/* Work Experience */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="medium">Work Experience</Typography>
<Button
startIcon={<PlusIcon />}
color="primary"
size="small"
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Work Experience
</Typography>
<Button startIcon={<PlusIcon />} color="primary" size="small">
Add Position
</Button>
</Box>
@ -334,7 +469,9 @@ const MockupPage = () => {
{/* Job entry */}
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: 'background.default' }}>
<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 }}>
<IconButton size="small">
<EditIcon fontSize="small" />
@ -344,10 +481,16 @@ const MockupPage = () => {
</IconButton>
</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 }}>
<Typography component="li" variant="body2">Led development of company's flagship product</Typography>
<Typography component="li" variant="body2">Improved performance by 40% through code optimization</Typography>
<Typography component="li" variant="body2">
Led development of company's flagship product
</Typography>
<Typography component="li" variant="body2">
Improved performance by 40% through code optimization
</Typography>
</Box>
</Paper>
</Paper>
@ -360,7 +503,7 @@ const MockupPage = () => {
borderStyle: 'dashed',
p: 1.5,
color: 'text.secondary',
'&:hover': { bgcolor: 'background.default' }
'&:hover': { bgcolor: 'background.default' },
}}
startIcon={<PlusIcon />}
>
@ -368,16 +511,19 @@ const MockupPage = () => {
</Button>
</Box>
</Paper>
)} {activeTab === 'saved' && (
)}{' '}
{activeTab === 'saved' && (
<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>
<Button
variant="contained"
color="primary"
size="small"
startIcon={<PlusIcon />}
>
<Button variant="contained" color="primary" size="small" startIcon={<PlusIcon />}>
New Resume
</Button>
</Box>
@ -394,15 +540,20 @@ const MockupPage = () => {
justifyContent: 'space-between',
alignItems: 'center',
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',
'&:hover': { bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'action.hover' }
'&:hover': {
bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'action.hover',
},
}}
onClick={() => setSelectedResume(resume.id)}
>
<Box>
<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 sx={{ display: 'flex', gap: 1 }}>
<IconButton size="small">
@ -417,10 +568,11 @@ const MockupPage = () => {
</Box>
</Paper>
)}
{activeTab === 'fact' && (
<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>
This tab shows how your resume content compares to your employment history data.
</Typography>
@ -440,7 +592,8 @@ const MockupPage = () => {
Skills Verification
</Typography>
<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>
</Paper>
</Box>
@ -461,7 +614,7 @@ const MockupPage = () => {
justifyContent: 'space-around',
borderTop: 1,
borderColor: 'divider',
zIndex: 1100
zIndex: 1100,
}}
elevation={3}
>
@ -472,7 +625,7 @@ const MockupPage = () => {
alignItems: 'center',
py: 1,
px: 2,
color: 'text.secondary'
color: 'text.secondary',
}}
component="button"
>
@ -486,7 +639,7 @@ const MockupPage = () => {
alignItems: 'center',
py: 1,
px: 2,
color: 'primary.main'
color: 'primary.main',
}}
component="button"
>
@ -500,7 +653,7 @@ const MockupPage = () => {
alignItems: 'center',
py: 1,
px: 2,
color: 'text.secondary'
color: 'text.secondary',
}}
component="button"
>
@ -511,8 +664,6 @@ const MockupPage = () => {
)}
</Box>
);
}
};
export {
MockupPage
};
export { MockupPage };

View File

@ -1,15 +1,15 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Button,
@ -22,7 +22,7 @@ import {
Select,
FormControl,
InputLabel,
Grid
Grid,
} from '@mui/material';
import { Person, Business, AssignmentInd } from '@mui/icons-material';
@ -68,9 +68,9 @@ const mockUsers: User[] = [
lastName: 'Doe',
skills: [
{ 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',
@ -83,9 +83,9 @@ const mockUsers: User[] = [
lastName: 'Smith',
skills: [
{ 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',
@ -97,7 +97,7 @@ const mockUsers: User[] = [
companyName: 'Acme Tech',
industry: 'Software',
companySize: '50-200',
location: { city: 'San Francisco', country: 'USA' }
location: { city: 'San Francisco', country: 'USA' },
},
{
id: '4',
@ -109,8 +109,8 @@ const mockUsers: User[] = [
companyName: 'Globex Corporation',
industry: 'Manufacturing',
companySize: '1000+',
location: { city: 'Chicago', country: 'USA' }
}
location: { city: 'Chicago', country: 'USA' },
},
];
// Component for User Management
@ -120,12 +120,12 @@ const UserManagement: React.FC = () => {
const [openDialog, setOpenDialog] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [aiConfigOpen, setAiConfigOpen] = useState(false);
// Handle tab change
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
// Filter users based on tab value
const filteredUsers = users.filter(user => {
if (tabValue === 0) return true;
@ -133,30 +133,30 @@ const UserManagement: React.FC = () => {
if (tabValue === 2) return user.type === 'employer';
return false;
});
// Handle open user detail dialog
const handleOpenUserDetails = (user: User) => {
setSelectedUser(user);
setOpenDialog(true);
};
// Handle close user detail dialog
const handleCloseDialog = () => {
setOpenDialog(false);
setSelectedUser(null);
};
// Handle open AI configuration dialog
const handleOpenAiConfig = (user: User) => {
setSelectedUser(user);
setAiConfigOpen(true);
};
// Handle close AI configuration dialog
const handleCloseAiConfig = () => {
setAiConfigOpen(false);
};
// Helper function to get user's name for display
const getUserDisplayName = (user: User) => {
if (user.type === 'candidate') {
@ -165,12 +165,12 @@ const UserManagement: React.FC = () => {
return user.companyName;
}
};
// Helper function to format date
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString();
};
return (
<Box sx={{ width: '100%', p: 3 }}>
<Paper sx={{ width: '100%', mb: 2 }}>
@ -185,7 +185,7 @@ const UserManagement: React.FC = () => {
<Tab icon={<Person />} label="Candidates" />
<Tab icon={<Business />} label="Employers" />
</Tabs>
<TableContainer>
<Table>
<TableHead>
@ -200,16 +200,22 @@ const UserManagement: React.FC = () => {
</TableRow>
</TableHead>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id} sx={{ "& > td": { whiteSpace: "nowrap"}}}>
{filteredUsers.map(user => (
<TableRow key={user.id} sx={{ '& > td': { whiteSpace: 'nowrap' } }}>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'flex-start', flexDirection: "column" }}>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
flexDirection: 'column',
}}
>
<Typography>{getUserDisplayName(user)}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={user.type === 'candidate' ? 'Candidate' : 'Employer'}
<Chip
label={user.type === 'candidate' ? 'Candidate' : 'Employer'}
color={user.type === 'candidate' ? 'primary' : 'secondary'}
size="small"
/>
@ -223,24 +229,24 @@ const UserManagement: React.FC = () => {
{/* <TableCell>{formatDate(user.createdAt)}</TableCell> */}
<TableCell>{formatDate(user.lastLogin)}</TableCell>
<TableCell>
<Chip
label={user.isActive ? 'Active' : 'Inactive'}
<Chip
label={user.isActive ? 'Active' : 'Inactive'}
color={user.isActive ? 'success' : 'error'}
size="small"
/>
</TableCell>
<TableCell>
<Button
size="small"
variant="outlined"
<Button
size="small"
variant="outlined"
onClick={() => handleOpenUserDetails(user)}
sx={{ mr: 1 }}
>
Details
</Button>
<Button
size="small"
variant="outlined"
<Button
size="small"
variant="outlined"
color="secondary"
onClick={() => handleOpenAiConfig(user)}
>
@ -253,7 +259,7 @@ const UserManagement: React.FC = () => {
</Table>
</TableContainer>
</Paper>
{/* User Details Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
{selectedUser && (
@ -264,7 +270,7 @@ const UserManagement: React.FC = () => {
<DialogContent dividers>
{selectedUser.type === 'candidate' ? (
<Grid container spacing={2}>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">Personal Information</Typography>
<TextField
label="First Name"
@ -288,11 +294,11 @@ const UserManagement: React.FC = () => {
InputProps={{ readOnly: true }}
/>
</Grid>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">Skills</Typography>
<Box sx={{ mt: 2 }}>
{selectedUser.skills.map((skill) => (
<Chip
{selectedUser.skills.map(skill => (
<Chip
key={skill.id}
label={`${skill.name} (${skill.level})`}
sx={{ m: 0.5 }}
@ -303,7 +309,7 @@ const UserManagement: React.FC = () => {
</Grid>
) : (
<Grid container spacing={2}>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">Company Information</Typography>
<TextField
label="Company Name"
@ -327,7 +333,7 @@ const UserManagement: React.FC = () => {
InputProps={{ readOnly: true }}
/>
</Grid>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">Contact Information</Typography>
<TextField
label="Email"
@ -353,19 +359,17 @@ const UserManagement: React.FC = () => {
</>
)}
</Dialog>
{/* AI Config Dialog */}
<Dialog open={aiConfigOpen} onClose={handleCloseAiConfig} maxWidth="md" fullWidth>
{selectedUser && (
<>
<DialogTitle>
AI Configuration for {getUserDisplayName(selectedUser)}
</DialogTitle>
<DialogTitle>AI Configuration for {getUserDisplayName(selectedUser)}</DialogTitle>
<DialogContent dividers>
<Typography variant="subtitle1" gutterBottom>
RAG Database Configuration
</Typography>
<FormControl fullWidth margin="normal">
<InputLabel id="embedding-model-label">Embedding Model</InputLabel>
<Select
@ -378,40 +382,32 @@ const UserManagement: React.FC = () => {
<MenuItem value="sentence-t5">Sentence T5</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="normal">
<InputLabel id="vector-store-label">Vector Store</InputLabel>
<Select
labelId="vector-store-label"
label="Vector Store"
defaultValue="pinecone"
>
<Select labelId="vector-store-label" label="Vector Store" defaultValue="pinecone">
<MenuItem value="pinecone">Pinecone</MenuItem>
<MenuItem value="qdrant">Qdrant</MenuItem>
<MenuItem value="faiss">FAISS</MenuItem>
</Select>
</FormControl>
<Typography variant="subtitle1" gutterBottom sx={{ mt: 2 }}>
AI Model Parameters
</Typography>
<Grid container spacing={2}>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth margin="normal">
<InputLabel id="model-label">AI Model</InputLabel>
<Select
labelId="model-label"
label="AI Model"
defaultValue="gpt-4"
>
<Select labelId="model-label" label="AI Model" defaultValue="gpt-4">
<MenuItem value="gpt-4">GPT-4</MenuItem>
<MenuItem value="claude-3">Claude 3</MenuItem>
<MenuItem value="custom">Custom</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Temperature"
type="number"
@ -421,7 +417,7 @@ const UserManagement: React.FC = () => {
InputProps={{ inputProps: { min: 0, max: 1, step: 0.1 } }}
/>
</Grid>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Max Tokens"
type="number"
@ -430,7 +426,7 @@ const UserManagement: React.FC = () => {
margin="normal"
/>
</Grid>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Top P"
type="number"
@ -441,20 +437,24 @@ const UserManagement: React.FC = () => {
/>
</Grid>
</Grid>
<TextField
label="System Prompt"
multiline
rows={4}
fullWidth
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 }}>
Data Sources
</Typography>
<TableContainer component={Paper} sx={{ mt: 1 }}>
<Table size="small">
<TableHead>
@ -496,7 +496,9 @@ const UserManagement: React.FC = () => {
</DialogContent>
<DialogActions>
<Button onClick={handleCloseAiConfig}>Cancel</Button>
<Button variant="contained" color="primary">Save Configuration</Button>
<Button variant="contained" color="primary">
Save Configuration
</Button>
</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 * 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';
// ============================
@ -48,7 +53,7 @@ const TOKEN_STORAGE = {
TOKEN_EXPIRY: 'tokenExpiry',
USER_TYPE: 'userType',
IS_GUEST: 'isGuest',
PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail'
PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail',
} as const;
// ============================
@ -77,13 +82,13 @@ function isTokenExpired(token: string): boolean {
if (!payload || !payload.exp) {
return true;
}
// Check if token expires within the next 5 minutes (buffer time)
const expiryTime = payload.exp * 1000; // Convert to milliseconds
const bufferTime = 5 * 60 * 1000; // 5 minutes
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 {
try {
const rawUserData = JSON.parse(userDataStr);
// Convert the data using toCamelCase which handles date conversion
const convertedData = toCamelCase<Types.User>(rawUserData);
return convertedData;
} catch (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.REFRESH_TOKEN, authResponse.refreshToken);
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(authResponse.user));
@ -155,10 +160,10 @@ function getStoredAuthData(): {
const expiryStr = localStorage.getItem(TOKEN_STORAGE.TOKEN_EXPIRY);
const userType = localStorage.getItem(TOKEN_STORAGE.USER_TYPE);
const isGuestStr = localStorage.getItem(TOKEN_STORAGE.IS_GUEST);
let userData: Types.User | null = null;
let expiresAt: number | null = null;
try {
if (userDataStr) {
userData = parseStoredUserData(userDataStr);
@ -170,14 +175,14 @@ function getStoredAuthData(): {
console.error('Failed to parse stored auth data:', error);
clearStoredAuth();
}
return {
accessToken,
refreshToken,
userData,
expiresAt,
userType,
isGuest: isGuestStr === 'true'
isGuest: isGuestStr === 'true',
};
}
@ -202,15 +207,18 @@ function useAuthenticationLogic() {
const guestCreationAttempted = useRef(false);
// Token refresh function
const refreshAccessToken = useCallback(async (refreshToken: string): Promise<Types.AuthResponse | null> => {
try {
const response = await apiClient.refreshToken(refreshToken);
return response;
} catch (error) {
console.error('Token refresh failed:', error);
return null;
}
}, [apiClient]);
const refreshAccessToken = useCallback(
async (refreshToken: string): Promise<Types.AuthResponse | null> => {
try {
const response = await apiClient.refreshToken(refreshToken);
return response;
} catch (error) {
console.error('Token refresh failed:', error);
return null;
}
},
[apiClient]
);
// Create guest session
const createGuestSession = useCallback(async (): Promise<boolean> => {
@ -286,7 +294,7 @@ function useAuthenticationLogic() {
try {
// Make a quick API call to verify guest still exists
const response = await fetch(`${apiClient.getBaseUrl()}/users/${stored.userData.id}`, {
headers: { 'Authorization': `Bearer ${stored.accessToken}` }
headers: { Authorization: `Bearer ${stored.accessToken}` },
});
if (!response.ok) {
@ -306,25 +314,25 @@ function useAuthenticationLogic() {
// Check if access token is expired
if (isTokenExpired(stored.accessToken)) {
console.log('🔄 Access token expired, attempting refresh...');
const refreshResult = await refreshAccessToken(stored.refreshToken);
if (refreshResult) {
const isGuest = stored.userType === 'guest';
storeAuthData(refreshResult, isGuest);
apiClient.setAuthToken(refreshResult.accessToken);
setAuthState({
user: isGuest ? null : refreshResult.user,
guest: isGuest ? refreshResult.user as Types.Guest : null,
guest: isGuest ? (refreshResult.user as Types.Guest) : null,
isAuthenticated: true,
isGuest,
isLoading: false,
isInitializing: false,
error: null,
mfaResponse: null
mfaResponse: null,
});
console.log('✅ Token refreshed successfully');
} else {
console.log('❌ Token refresh failed, creating new guest session...');
@ -336,18 +344,18 @@ function useAuthenticationLogic() {
// Access token is still valid
apiClient.setAuthToken(stored.accessToken);
const isGuest = stored.userType === 'guest';
setAuthState({
user: isGuest ? null : stored.userData,
guest: isGuest ? stored.userData as Types.Guest : null,
guest: isGuest ? (stored.userData as Types.Guest) : null,
isAuthenticated: true,
isGuest,
isLoading: false,
isInitializing: false,
error: null,
mfaResponse: null
mfaResponse: null,
});
console.log('✅ Restored authentication from stored tokens');
}
} catch (error) {
@ -378,7 +386,7 @@ function useAuthenticationLogic() {
const expiryTime = stored.expiresAt * 1000;
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) {
initializeAuth();
@ -394,131 +402,147 @@ function useAuthenticationLogic() {
}, [authState.isAuthenticated, initializeAuth]);
// Enhanced login with MFA support
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null, mfaResponse: null }));
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.';
const login = useCallback(
async (loginData: LoginRequest): Promise<boolean> => {
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
isLoading: true,
error: null,
mfaResponse: null,
}));
return false;
}
}, [apiClient]);
try {
const result = await apiClient.login({
login: loginData.login,
password: loginData.password,
});
if ('mfaRequired' in result) {
// MFA required for new device
setAuthState(prev => ({
...prev,
isLoading: false,
mfaResponse: result,
}));
return false; // Login not complete yet
} else {
// Normal login success - convert from guest to authenticated user
const authResponse: Types.AuthResponse = result;
storeAuthData(authResponse, false);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({
...prev,
user: authResponse.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log('✅ Login successful, converted from guest to authenticated user');
return true;
}
} catch (error: any) {
const errorMessage =
error instanceof Error ? error.message : 'Network error occurred. Please try again.';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
mfaResponse: null,
}));
return false;
}
},
[apiClient]
);
// Convert guest to permanent user
const convertGuestToUser = useCallback(async (registrationData: GuestConversionRequest): Promise<boolean> => {
if (!authState.isGuest || !authState.guest) {
throw new Error('Not currently a guest user');
}
const convertGuestToUser = useCallback(
async (registrationData: GuestConversionRequest): Promise<boolean> => {
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 {
const result = await apiClient.convertGuestToUser(registrationData);
try {
const result = await apiClient.convertGuestToUser(registrationData);
// Store new authentication
storeAuthData(result.auth, false);
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);
// Store new authentication
storeAuthData(result.auth, false);
apiClient.setAuthToken(result.auth.accessToken);
setAuthState(prev => ({
...prev,
user: authResponse.user,
user: result.auth.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log('✅ MFA verification successful, converted from guest');
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]
);
return false;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'MFA verification failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient]);
// MFA verification
const verifyMFA = useCallback(
async (mfaData: Types.MFAVerifyRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const result = await apiClient.verifyMFA(mfaData);
if (result.accessToken) {
const authResponse: Types.AuthResponse = result;
storeAuthData(authResponse, false);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({
...prev,
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
const logout = useCallback(async () => {
@ -550,56 +574,66 @@ function useAuthenticationLogic() {
}, [apiClient, authState.isAuthenticated, authState.isGuest, createGuestSession]);
// Update user data
const updateUserData = useCallback((updatedUser: Types.User) => {
updateStoredUserData(updatedUser);
setAuthState(prev => ({
...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';
const updateUserData = useCallback(
(updatedUser: Types.User) => {
updateStoredUserData(updatedUser);
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
user: authState.isGuest ? null : updatedUser,
guest: authState.isGuest ? (updatedUser as Types.Guest) : prev.guest,
}));
return null;
}
}, [apiClient]);
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 => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return null;
}
},
[apiClient]
);
// Other existing methods remain the same...
const resendEmailVerification = useCallback(async (email: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
const resendEmailVerification = useCallback(
async (email: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
await apiClient.resendVerificationEmail({ email });
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to resend verification email';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient]);
try {
await apiClient.resendVerificationEmail({ email });
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to resend verification email';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return false;
}
},
[apiClient]
);
const setPendingVerificationEmail = useCallback((email: string) => {
localStorage.setItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL, email);
@ -609,45 +643,52 @@ function useAuthenticationLogic() {
return localStorage.getItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL);
}, []);
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const employer = await apiClient.createEmployer(employerData);
console.log('✅ Employer created:', employer);
const createEmployerAccount = useCallback(
async (employerData: CreateEmployerRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
setPendingVerificationEmail(employerData.email);
setAuthState(prev => ({ ...prev, isLoading: false }));
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]);
try {
const employer = await apiClient.createEmployer(employerData);
console.log('✅ Employer created:', employer);
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]);
setPendingVerificationEmail(employerData.email);
setAuthState(prev => ({ ...prev, isLoading: false }));
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> => {
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 stored = getStoredAuthData();
@ -656,24 +697,24 @@ function useAuthenticationLogic() {
}
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
const refreshResult = await refreshAccessToken(stored.refreshToken);
if (refreshResult) {
const isGuest = stored.userType === 'guest';
storeAuthData(refreshResult, isGuest);
apiClient.setAuthToken(refreshResult.accessToken);
setAuthState(prev => ({
...prev,
user: isGuest ? null : refreshResult.user,
guest: isGuest ? refreshResult.user as Types.Guest : null,
guest: isGuest ? (refreshResult.user as Types.Guest) : null,
isAuthenticated: true,
isGuest,
isLoading: false,
error: null
error: null,
}));
return true;
} else {
await logout();
@ -682,36 +723,39 @@ function useAuthenticationLogic() {
}, [refreshAccessToken, logout]);
// Resend MFA code
const resendMFACode = useCallback(async (email: string, deviceId: string, deviceName: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
const resendMFACode = useCallback(
async (email: string, deviceId: string, deviceName: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
await apiClient.requestMFA({
email,
password: '', // This would need to be stored securely or re-entered
deviceId,
deviceName,
});
try {
await apiClient.requestMFA({
email,
password: '', // This would need to be stored securely or re-entered
deviceId,
deviceName,
});
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to resend MFA code';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient]);
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to resend MFA code';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
}));
return false;
}
},
[apiClient]
);
// Clear MFA state
const clearMFA = useCallback(() => {
setAuthState(prev => ({
...prev,
mfaResponse: null,
error: null
error: null,
}));
}, []);
@ -732,7 +776,7 @@ function useAuthenticationLogic() {
refreshAuth,
updateUserData,
convertGuestToUser,
createGuestSession
createGuestSession,
};
}
@ -744,12 +788,8 @@ const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | nu
function AuthProvider({ children }: { children: React.ReactNode }) {
const auth = useAuthenticationLogic();
return (
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
);
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
function useAuth() {
@ -771,24 +811,24 @@ interface ProtectedRouteProps {
allowGuests?: boolean;
}
function ProtectedRoute({
children,
function ProtectedRoute({
children,
fallback = <div>Please log in to access this page.</div>,
requiredUserType,
allowGuests = false
allowGuests = false,
}: ProtectedRouteProps) {
const { isAuthenticated, isInitializing, user, isGuest } = useAuth();
// Show loading while checking stored tokens
if (isInitializing) {
return <div>Loading...</div>;
}
// Not authenticated at all (shouldn't happen with guest sessions)
if (!isAuthenticated) {
return <>{fallback}</>;
}
// Guest access control
if (isGuest && !allowGuests) {
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) {
return <div>Access denied. Required user type: {requiredUserType}</div>;
}
return <>{children}</>;
}
@ -808,14 +848,9 @@ export type {
EmailVerificationRequest,
ResendVerificationRequest,
PasswordResetRequest,
GuestConversionRequest
}
GuestConversionRequest,
};
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
export {
useAuthenticationLogic,
AuthProvider,
useAuth,
ProtectedRoute
}
export { useAuthenticationLogic, AuthProvider, useAuth, ProtectedRoute };

View File

@ -17,7 +17,7 @@ const STORAGE_KEYS = {
ROUTE_STATE: 'routeState',
ACTIVE_TAB: 'activeTab',
APPLIED_FILTERS: 'appliedFilters',
SIDEBAR_COLLAPSED: 'sidebarCollapsed'
SIDEBAR_COLLAPSED: 'sidebarCollapsed',
} as const;
// ============================
@ -40,7 +40,7 @@ export interface AppState {
selectedJob: Types.Job | null;
selectedEmployer: Types.Employer | null;
selectedResume: Types.Resume | null;
setSelectedResume: (resume: Types.Resume | null) => void;
setSelectedResume: (resume: Types.Resume | null) => void;
routeState: RouteState;
isInitializing: boolean;
}
@ -118,7 +118,7 @@ function getInitialRouteState(): RouteState {
lastRoute: getStoredId(STORAGE_KEYS.LAST_ROUTE),
activeTab: getStoredId(STORAGE_KEYS.ACTIVE_TAB),
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
await Promise.all(promises);
} catch (error) {
console.error('Error during app state initialization:', error);
} finally {
@ -352,7 +351,7 @@ export function useAppStateLogic(): AppStateContextType {
lastRoute: null,
activeTab: null,
appliedFilters: {},
sidebarCollapsed: false
sidebarCollapsed: false,
};
setRouteStateState(clearedState);
@ -388,7 +387,7 @@ export function useAppStateLogic(): AppStateContextType {
setActiveTab,
setFilters,
setSidebarCollapsed,
clearRouteState
clearRouteState,
};
}
@ -403,10 +402,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
const snackRef = useRef<any>(null);
// Global UI components
appState.setSnack = useCallback((message: string, severity?: SeverityType) => {
snackRef.current?.setSnack(message, severity);
}, [snackRef]);
appState.setSnack = useCallback(
(message: string, severity?: SeverityType) => {
snackRef.current?.setSnack(message, severity);
},
[snackRef]
);
return (
<AppStateContext.Provider value={appState}>
{children}
@ -454,7 +456,7 @@ export function useRouteState() {
setFilters,
setSidebarCollapsed,
restoreLastRoute,
clearRouteState
clearRouteState,
} = useAppState();
return {
@ -463,11 +465,11 @@ export function useRouteState() {
setFilters,
setSidebarCollapsed,
restoreLastRoute,
clearRouteState
clearRouteState,
};
}
export function useAppInitializing() {
const { isInitializing } = useAppState();
return isInitializing;
}
}

View File

@ -1,13 +1,13 @@
import { useEffect, useRef, RefObject, useCallback } from 'react';
const debug: boolean = false;
const debug = false;
type ResizeCallback = () => void;
// Define the debounce function with cancel capability
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
let timeout: NodeJS.Timeout | null = null;
let lastCall: number = 0;
let lastCall = 0;
const debounced = function (...args: Parameters<T>) {
const now = Date.now();
@ -68,8 +68,12 @@ const useResizeObserverAndMutationObserver = (
requestAnimationFrame(() => callbackRef.current());
}, 500);
const resizeObserver = new ResizeObserver((e: any) => { debouncedCallback("resize"); });
const mutationObserver = new MutationObserver((e: any) => { debouncedCallback("mutation"); });
const resizeObserver = new ResizeObserver((e: any) => {
debouncedCallback('resize');
});
const mutationObserver = new MutationObserver((e: any) => {
debouncedCallback('mutation');
});
// Observe container size
resizeObserver.observe(container);
@ -102,8 +106,8 @@ const useResizeObserverAndMutationObserver = (
*/
const useAutoScrollToBottom = (
scrollToRef: RefObject<HTMLElement | null>,
smooth: boolean = true,
fallbackThreshold: number = 0.33,
smooth = true,
fallbackThreshold = 0.33,
contentUpdateTrigger?: any
): RefObject<HTMLDivElement | null> => {
const containerRef = useRef<HTMLDivElement | null>(null);
@ -111,65 +115,72 @@ const useAutoScrollToBottom = (
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
const isUserScrollingUpRef = useRef(false);
const checkAndScrollToBottom = useCallback((isPasteEvent: boolean = false) => {
const container = containerRef.current;
if (!container) return;
const checkAndScrollToBottom = useCallback(
(isPasteEvent = false) => {
const container = containerRef.current;
if (!container) return;
let shouldScroll = false;
const scrollTo = scrollToRef.current;
let shouldScroll = false;
const scrollTo = scrollToRef.current;
if (isPasteEvent && !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',
});
});
if (isPasteEvent && !scrollTo) {
console.error('Paste Event triggered without scrollTo');
}
} 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',
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) {
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(() => {
const container = containerRef.current;
@ -178,34 +189,38 @@ const useAutoScrollToBottom = (
const handleScroll = (ev: Event, pause?: number) => {
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 */
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}`);
lastScrollTop.current = currentScrollTop;
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
scrollTimeout.current = setTimeout(() => {
isUserScrollingUpRef.current = false;
debug && console.debug(`Scrolling up: ${isUserScrollingUpRef.current}`);
}, pause ? pause : 500);
scrollTimeout.current = setTimeout(
() => {
isUserScrollingUpRef.current = false;
debug && console.debug(`Scrolling up: ${isUserScrollingUpRef.current}`);
},
pause ? pause : 500
);
};
const pauseScroll = (ev: Event) => {
debug && console.log("Pausing for mouse movement");
debug && console.log('Pausing for mouse movement');
handleScroll(ev, 500);
}
};
const pauseClick = (ev: Event) => {
debug && console.log("Pausing for mouse click");
debug && console.log('Pausing for mouse click');
handleScroll(ev, 1000);
}
};
const handlePaste = () => {
console.log("handlePaste");
console.log('handlePaste');
// Delay scroll check to ensure DOM updates
setTimeout(() => {
console.log("scrolling for handlePaste");
console.log('scrolling for handlePaste');
requestAnimationFrame(() => checkAndScrollToBottom(true));
}, 100);
};
@ -236,7 +251,4 @@ const useAutoScrollToBottom = (
return containerRef;
};
export {
useResizeObserverAndMutationObserver,
useAutoScrollToBottom
}
export { useResizeObserverAndMutationObserver, useAutoScrollToBottom };

View File

@ -2,15 +2,13 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
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 { BackstoryTestApp } from 'TestApp';
import './index.css';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>

View File

@ -1,14 +1,14 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Box,
Container,
Typography,
Paper,
Grid,
Button,
import {
Box,
Container,
Typography,
Paper,
Grid,
Button,
alpha,
GlobalStyles
GlobalStyles,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import ConstructionIcon from '@mui/icons-material/Construction';
@ -26,10 +26,10 @@ interface BetaPageProps {
const BetaPage: React.FC<BetaPageProps> = ({
children,
title = "Coming Soon",
subtitle = "This page is currently in development",
returnPath = "/",
returnLabel = "Return to Backstory",
title = 'Coming Soon',
subtitle = 'This page is currently in development',
returnPath = '/',
returnLabel = 'Return to Backstory',
onReturn,
}) => {
const theme = useTheme();
@ -38,20 +38,28 @@ const BetaPage: React.FC<BetaPageProps> = ({
const location = useLocation();
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
const [sparkles, setSparkles] = useState<Array<{
id: number;
x: number;
y: number;
size: number;
opacity: number;
duration: number;
delay: number;
}>>([]);
const [sparkles, setSparkles] = useState<
Array<{
id: number;
x: number;
y: number;
size: number;
opacity: number;
duration: number;
delay: number;
}>
>([]);
useEffect(() => {
// Generate sparkle elements with random properties
const newSparkles = Array.from({ length: 30 }).map((_, index) => ({
@ -63,14 +71,14 @@ const BetaPage: React.FC<BetaPageProps> = ({
duration: 2 + Math.random() * 4,
delay: Math.random() * 3,
}));
setSparkles(newSparkles);
// Show main sparkle effect after a short delay
const timer = setTimeout(() => {
setShowSparkle(true);
}, 500);
return () => clearTimeout(timer);
}, []);
@ -86,7 +94,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
<Box
sx={{
minHeight: '100%',
width: "100%",
width: '100%',
position: 'relative',
overflow: 'hidden',
bgcolor: theme.palette.background.default,
@ -95,8 +103,18 @@ const BetaPage: React.FC<BetaPageProps> = ({
}}
>
{/* Animated background elements */}
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 0, overflow: 'hidden' }}>
{sparkles.map((sparkle) => (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 0,
overflow: 'hidden',
}}
>
{sparkles.map(sparkle => (
<Box
key={sparkle.id}
sx={{
@ -107,7 +125,10 @@ const BetaPage: React.FC<BetaPageProps> = ({
height: sparkle.size,
borderRadius: '50%',
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`,
}}
/>
@ -116,7 +137,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 2 }}>
<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
variant="h2"
component="h1"
@ -130,18 +151,13 @@ const BetaPage: React.FC<BetaPageProps> = ({
>
{title}
</Typography>
<Typography
variant="h5"
component="h2"
color="textSecondary"
sx={{ mb: 6 }}
>
<Typography variant="h5" component="h2" color="textSecondary" sx={{ mb: 6 }}>
{subtitle}
</Typography>
</Grid>
<Grid size={{xs: 12, md: 10, lg: 8}} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, md: 10, lg: 8 }} sx={{ mb: 4 }}>
<Paper
elevation={8}
sx={{
@ -171,19 +187,19 @@ const BetaPage: React.FC<BetaPageProps> = ({
>
<ConstructionIcon fontSize="large" />
</Box>
{/* Content */}
<Box sx={{ mt: 3, mb: 3 }}>
{children || (
<Box sx={{ textAlign: 'center', py: 4 }}>
<RocketLaunchIcon
fontSize="large"
color="primary"
sx={{
fontSize: 80,
<RocketLaunchIcon
fontSize="large"
color="primary"
sx={{
fontSize: 80,
mb: 2,
animation: 'rocketWobble 3s ease-in-out infinite'
}}
animation: 'rocketWobble 3s ease-in-out infinite',
}}
/>
<Typography>
We're working hard to bring you this exciting new feature!
@ -193,9 +209,23 @@ const BetaPage: React.FC<BetaPageProps> = ({
</Typography>
</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>
{/* Return button */}
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Button
@ -210,7 +240,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
boxShadow: `0 4px 14px ${alpha(theme.palette.primary.main, 0.4)}`,
'&:hover': {
boxShadow: `0 6px 20px ${alpha(theme.palette.primary.main, 0.6)}`,
}
},
}}
>
{returnLabel}
@ -250,7 +280,10 @@ const BetaPage: React.FC<BetaPageProps> = ({
textShadow: `0 0 10px ${alpha(theme.palette.primary.main, 0.3)}`,
},
'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': {
@ -270,6 +303,4 @@ const BetaPage: React.FC<BetaPageProps> = ({
);
};
export {
BetaPage
}
export { BetaPage };

View File

@ -1,18 +1,15 @@
import React, { forwardRef, useState, useEffect, useRef } from 'react';
import {
Box,
Paper,
Button,
Divider,
useTheme,
useMediaQuery,
Tooltip,
} from '@mui/material';
import {
Send as SendIcon
} from '@mui/icons-material';
import { Box, Paper, Button, Divider, useTheme, useMediaQuery, Tooltip } from '@mui/material';
import { Send as SendIcon } from '@mui/icons-material';
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 { BackstoryPageProps } from 'components/BackstoryTab';
import { Message } from 'components/Message';
@ -27,206 +24,251 @@ import { CandidatePicker } from 'components/ui/CandidatePicker';
import { Scrollable } from 'components/Scrollable';
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 { apiClient } = useAuth();
const navigate = useNavigate();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate()
const theme = useTheme();
const [processingMessage, setProcessingMessage] = useState<ChatMessageStatus | ChatMessageError | null>(null);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
(props: BackstoryPageProps, ref) => {
const { apiClient } = useAuth();
const navigate = useNavigate();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const theme = useTheme();
const [processingMessage, setProcessingMessage] = useState<
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 [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef(null);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef(null);
// Load messages for current session
const loadMessages = async () => {
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);
}
};
// Load messages for current session
const loadMessages = async () => {
if (!chatSession?.id) return;
const onDelete = async (session: ChatSession) => {
if (!session.id) {
return;
}
try {
await apiClient.resetChatSession(session.id);
// If we're deleting the currently selected session, clear it
setMessages([]);
setSnack('Session reset succeeded', 'success');
} catch (error) {
console.error('Failed to delete session:', error);
setSnack('Failed to delete session', 'error');
}
};
// Send message
const sendMessage = async (message: string) => {
if (!message.trim() || !chatSession?.id || streaming || !selectedCandidate) return;
const messageContent = message;
setStreaming(true);
const chatMessage: ChatMessageUser = {
sessionId: chatSession.id,
role: "user",
content: messageContent,
status: "done",
type: "text",
timestamp: new Date()
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);
}
};
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 => {
const filtered = prev.filter((m: any) => m.id !== chatMessage.id);
return [...filtered, chatMessage] as any;
});
// Send message
const sendMessage = async (message: string) => {
if (!message.trim() || !chatSession?.id || streaming || !selectedCandidate) return;
try {
apiClient.sendMessageStream(chatMessage, {
onMessage: (msg: ChatMessage) => {
setMessages(prev => {
const filtered = prev.filter((m: any) => m.id !== msg.id);
return [...filtered, msg] as any;
});
setStreamingMessage(null);
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);
}
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.`,
});
} catch (error) {
console.error('Failed to send message:', error);
setStreaming(false);
}
};
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
(messagesEndRef.current as any)?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
setMessages(prev => {
const filtered = prev.filter((m: any) => m.id !== chatMessage.id);
return [...filtered, chatMessage] as any;
});
// 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);
try {
apiClient.sendMessageStream(chatMessage, {
onMessage: (msg: ChatMessage) => {
setMessages(prev => {
const filtered = prev.filter((m: any) => m.id !== msg.id);
return [...filtered, msg] as any;
});
setStreamingMessage(null);
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) {
setSnack('Unable to load chat session', 'error');
} finally {
setLoading(false);
} catch (error) {
console.error('Failed to send message:', error);
setStreaming(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
useEffect(() => {
if (chatSession?.id) {
loadMessages();
}
}, [chatSession]);
const welcomeMessage: ChatMessage = {
sessionId: chatSession?.id || '',
role: 'information',
type: 'text',
status: 'done',
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 <CandidatePicker />;
}
const welcomeMessage: ChatMessage = {
sessionId: chatSession?.id || '',
role: "information",
type: "text",
status: "done",
timestamp: new Date(),
content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}. Ask any questions you have about ${selectedCandidate.firstName}.`,
metadata: null as any
};
return (
<Box ref={ref}
sx={{
display: "flex", flexDirection: "column",
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",
}}>
<Paper elevation={2} sx={{ m: 1, p: 1 }}>
<CandidateInfo
key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4}
candidate={selectedCandidate}
variant="small"
sx={{ flexShrink: 1, width: "100%", maxHeight: 0, minHeight: "min-content" }} // Prevent header from shrinking
/>
<Button sx={{ maxWidth: "max-content" }} onClick={() => { setSelectedCandidate(null); }} variant="contained">Change Candidates</Button>
</Paper>
{/* Chat Interface */}
{/* Scrollable Messages Area */}
{chatSession &&
<Scrollable
sx={{
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex", flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
pt: 2,
pl: 1,
pr: 1,
pb: 2,
}}>
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage, }} />}
return (
<Box
ref={ref}
sx={{
display: 'flex',
flexDirection: 'column',
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',
}}
>
<Paper elevation={2} sx={{ m: 1, p: 1 }}>
<CandidateInfo
key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4}
candidate={selectedCandidate}
variant="small"
sx={{
flexShrink: 1,
width: '100%',
maxHeight: 0,
minHeight: 'min-content',
}} // Prevent header from shrinking
/>
<Button
sx={{ maxWidth: 'max-content' }}
onClick={() => {
setSelectedCandidate(null);
}}
variant="contained"
>
Change Candidates
</Button>
</Paper>
{/* Chat Interface */}
{/* Scrollable Messages Area */}
{chatSession && (
<Scrollable
sx={{
position: 'relative',
maxHeight: '100%',
width: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
pt: 2,
pl: 1,
pr: 1,
pb: 2,
}}
>
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage }} />}
{messages.map((message: ChatMessage) => (
<Message key={message.id} {...{ chatSession, message }} />
))}
@ -237,13 +279,15 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
<Message {...{ chatSession, message: streamingMessage }} />
)}
{streaming && (
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 1,
}}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
}}
>
<PropagateLoader
size="10px"
loading={streaming}
@ -253,42 +297,54 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
</Box>
)}
<div ref={messagesEndRef} />
</Scrollable>
}
{selectedCandidate.questions?.length !== 0 && selectedCandidate.questions?.map(q => <BackstoryQuery question={q} />)}
{/* Fixed Message Input */}
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}>
<DeleteConfirmation
onDelete={() => { chatSession && onDelete(chatSession); }}
disabled={!chatSession}
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content" }}
action="reset"
label="chat session"
title="Reset Chat Session"
message={`Are you sure you want to reset the session? This action cannot be undone.`}
/>
<BackstoryTextField
placeholder="Type your message about the candidate..."
ref={backstoryTextRef}
onEnter={sendMessage}
disabled={streaming || loading}
/>
<Tooltip title="Send">
<span style={{ minWidth: 'auto', maxHeight: "min-content", alignSelf: "center" }}
>
<Button
variant="contained"
onClick={() => { sendMessage((backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ""); }}
disabled={streaming || loading}
>
<SendIcon />
</Button>
</span>
</Tooltip>
</Scrollable>
)}
{selectedCandidate.questions?.length !== 0 &&
selectedCandidate.questions?.map(q => <BackstoryQuery question={q} />)}
{/* Fixed Message Input */}
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}>
<DeleteConfirmation
onDelete={() => {
chatSession && onDelete(chatSession);
}}
disabled={!chatSession}
sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }}
action="reset"
label="chat session"
title="Reset Chat Session"
message={`Are you sure you want to reset the session? This action cannot be undone.`}
/>
<BackstoryTextField
placeholder="Type your message about the candidate..."
ref={backstoryTextRef}
onEnter={sendMessage}
disabled={streaming || loading}
/>
<Tooltip title="Send">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'center',
}}
>
<Button
variant="contained"
onClick={() => {
sendMessage(
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ''
);
}}
disabled={streaming || loading}
>
<SendIcon />
</Button>
</span>
</Tooltip>
</Box>
</Box>
</Box>
);
});
);
}
);
export { CandidateChatPage };
export { CandidateChatPage };

View File

@ -18,7 +18,7 @@ import {
CardContent,
CardActionArea,
useTheme,
useMediaQuery
useMediaQuery,
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import PersonIcon from '@mui/icons-material/Person';
@ -61,48 +61,50 @@ const Sidebar: React.FC<{
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: 1,
borderColor: 'divider',
}}>
<Box
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Typography variant="h6" component="h2" fontWeight="bold">
Documentation
</Typography>
{isMobile && onClose && (
<IconButton
onClick={onClose}
size="small"
aria-label="Close navigation"
>
<IconButton onClick={onClose} size="small" aria-label="Close navigation">
<CloseIcon />
</IconButton>
)}
</Box>
<Box sx={{
flexGrow: 1,
overflow: 'auto',
p: 1
}}>
<Box
sx={{
flexGrow: 1,
overflow: 'auto',
p: 1,
}}
>
<List>
{documents.map((doc, index) => (
<ListItem key={index} disablePadding>
<ListItemButton
onClick={() => doc.route ? handleItemClick(doc.route) : navigate('/')}
onClick={() => (doc.route ? handleItemClick(doc.route) : navigate('/'))}
selected={currentPage === doc.route}
sx={{
borderRadius: 1,
mb: 0.5
mb: 0.5,
}}
>
<ListItemIcon sx={{
color: currentPage === doc.route ? 'primary.main' : 'text.secondary',
minWidth: 40
}}>
<ListItemIcon
sx={{
color: currentPage === doc.route ? 'primary.main' : 'text.secondary',
minWidth: 40,
}}
>
{getDocumentIcon(doc.title)}
</ListItemIcon>
<ListItemText
@ -110,7 +112,7 @@ const Sidebar: React.FC<{
slotProps={{
primary: {
fontWeight: currentPage === doc.route ? 'medium' : 'regular',
}
},
}}
/>
</ListItemButton>
@ -128,7 +130,7 @@ const getDocumentIcon = (title: string): React.ReactNode => {
throw Error(`${title} does not exist in documents`);
}
return item.icon || <ViewQuiltIcon />;
}
};
type DocType = {
title: string;
@ -137,26 +139,90 @@ type DocType = {
icon?: React.ReactNode;
};
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: "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 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: '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);
if (index === -1) {
return null
return null;
}
return documents[index];
};
@ -165,10 +231,10 @@ const documentFromRoute = (route: string) : DocType | null => {
const documentTitleFromRoute = (route: string): string => {
const doc = documentFromRoute(route);
if (doc === null) {
return 'Documentation'
return 'Documentation';
}
return doc.title;
}
};
const DocsPage = (props: BackstoryPageProps) => {
const { setSnack } = useAppState();
@ -200,10 +266,10 @@ const DocsPage = (props: BackstoryPageProps) => {
// Handle document navigation
const onDocumentExpand = (docName: string, open: boolean) => {
console.log("Document expanded:", { docName, open, location });
console.log('Document expanded:', { docName, open, location });
if (open) {
const parts = location.pathname.split('/');
if (docName === "backstory") {
if (docName === 'backstory') {
navigate('/');
return;
}
@ -230,8 +296,8 @@ const DocsPage = (props: BackstoryPageProps) => {
};
interface DocViewProps {
page: string
};
page: string;
}
const DocView = (props: DocViewProps) => {
const { page = 'about' } = props;
const title = documentTitleFromRoute(page);
@ -240,13 +306,22 @@ const DocsPage = (props: BackstoryPageProps) => {
return (
<Card>
<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}
{title}
</Box>
{page && <Document
filepath={`/docs/${page}.md`}
/>}
{page && <Document filepath={`/docs/${page}.md`} />}
</CardContent>
</Card>
);
@ -256,18 +331,22 @@ const DocsPage = (props: BackstoryPageProps) => {
function renderContent() {
switch (page) {
case 'ui-overview':
return (<BackstoryUIOverviewPage />);
return <BackstoryUIOverviewPage />;
case 'theme-visualizer':
return (<Paper sx={{ m: 0, p: 1 }}><BackstoryThemeVisualizerPage /></Paper>);
return (
<Paper sx={{ m: 0, p: 1 }}>
<BackstoryThemeVisualizerPage />
</Paper>
);
case 'app-analysis':
return (<BackstoryAppAnalysisPage />);
return <BackstoryAppAnalysisPage />;
case 'ui-mockup':
return (<MockupPage />);
return <MockupPage />;
case 'user-management':
return (<UserManagement />);
return <UserManagement />;
default:
if (documentFromRoute(page)) {
return <DocView page={page}/>
return <DocView page={page} />;
}
// Document grid for landing page
return (
@ -277,28 +356,50 @@ const DocsPage = (props: BackstoryPageProps) => {
Documentation
</Typography>
<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>
</Box>
<Grid container spacing={1}>
{documents.map((doc, index) => {
if (doc.route === null) return (<></>);
return (<Grid sx={{ minWidth: "164px" }} size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<Card sx={{ minHeight: "180px" }}>
<CardActionArea onClick={() => doc.route ? onDocumentExpand(doc.route, true) : navigate('/')}>
<CardContent sx={{ display: "flex", flexDirection: "column", m: 0, p: 1 }}>
<Box sx={{ display: 'flex', flexDirection: "row", gap: 1, verticalAlign: 'top' }}>
{getDocumentIcon(doc.title)}
<Typography variant="h3" sx={{ m: "0 !important" }}>{doc.title}</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{doc.description}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
)
if (doc.route === null) return <></>;
return (
<Grid sx={{ minWidth: '164px' }} size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<Card sx={{ minHeight: '180px' }}>
<CardActionArea
onClick={() =>
doc.route ? onDocumentExpand(doc.route, true) : navigate('/')
}
>
<CardContent
sx={{
display: 'flex',
flexDirection: 'column',
m: 0,
p: 1,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
verticalAlign: 'top',
}}
>
{getDocumentIcon(doc.title)}
<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>
</Paper>
@ -318,22 +419,17 @@ const DocsPage = (props: BackstoryPageProps) => {
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
display: { md: 'none' }
display: { md: 'none' },
}}
elevation={0}
color="default"
>
<Toolbar>
<IconButton
aria-label="open drawer"
edge="start"
onClick={toggleDrawer}
sx={{ mr: 2 }}
>
<IconButton aria-label="open drawer" edge="start" onClick={toggleDrawer} sx={{ mr: 2 }}>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ color: "white" }}>
{page ? documentTitleFromRoute(page) : "Documentation"}
<Typography variant="h6" noWrap component="div" sx={{ color: 'white' }}>
{page ? documentTitleFromRoute(page) : 'Documentation'}
</Typography>
</Toolbar>
</AppBar>
@ -344,7 +440,7 @@ const DocsPage = (props: BackstoryPageProps) => {
component="nav"
sx={{
width: { md: drawerWidth },
flexShrink: { md: 0 }
flexShrink: { md: 0 },
}}
>
{/* Mobile drawer (temporary) */}
@ -360,7 +456,7 @@ const DocsPage = (props: BackstoryPageProps) => {
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: drawerWidth
width: drawerWidth,
},
}}
>
@ -381,16 +477,12 @@ const DocsPage = (props: BackstoryPageProps) => {
boxSizing: 'border-box',
width: drawerWidth,
position: 'relative',
height: '100%'
height: '100%',
},
}}
open
>
<Sidebar
currentPage={page}
onDocumentSelect={onDocumentExpand}
isMobile={false}
/>
<Sidebar currentPage={page} onDocumentSelect={onDocumentExpand} isMobile={false} />
</Drawer>
)}
</Box>
@ -404,7 +496,7 @@ const DocsPage = (props: BackstoryPageProps) => {
width: { md: `calc(100% - ${drawerWidth}px)` },
pt: isMobile ? { xs: 8, sm: 9 } : 3, // Add padding top on mobile to account for AppBar
height: '100%',
overflow: 'auto'
overflow: 'auto',
}}
>
{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 { CandidatePicker } from 'components/ui/CandidatePicker';
const CandidateListingPage = (props: BackstoryPageProps) => {
return <CandidatePicker {...props} />;
return <CandidatePicker {...props} />;
};
export {
CandidateListingPage
};
export { CandidateListingPage };

View File

@ -17,298 +17,375 @@ import { StyledMarkdown } from 'components/StyledMarkdown';
import { Scrollable } from '../components/Scrollable';
import { Pulse } from 'components/Pulse';
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 { Message } from 'components/Message';
import { useAppState } from 'hooks/GlobalContext';
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 { apiClient, user } = useAuth();
const { setSnack } = useAppState();
const [processingMessage, setProcessingMessage] = useState<ChatMessage | null>(null);
const [processing, setProcessing] = useState<boolean>(false);
const [generatedUser, setGeneratedUser] = useState<CandidateAI | null>(null);
const [prompt, setPrompt] = useState<string>('');
const [resume, setResume] = useState<string | null>(null);
const [canGenImage, setCanGenImage] = useState<boolean>(false);
const [timestamp, setTimestamp] = useState<string>('');
const [shouldGenerateProfile, setShouldGenerateProfile] = useState<boolean>(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const { apiClient, user } = useAuth();
const { setSnack } = useAppState();
const [processingMessage, setProcessingMessage] = useState<ChatMessage | null>(null);
const [processing, setProcessing] = useState<boolean>(false);
const [generatedUser, setGeneratedUser] = useState<CandidateAI | null>(null);
const [prompt, setPrompt] = useState<string>('');
const [resume, setResume] = useState<string | null>(null);
const [canGenImage, setCanGenImage] = useState<boolean>(false);
const [timestamp, setTimestamp] = useState<string>('');
const [shouldGenerateProfile, setShouldGenerateProfile] = useState<boolean>(false);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [loading, setLoading] = useState<boolean>(false);
// Only keep refs that are truly necessary
const controllerRef = useRef<StreamingResponse>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
// Only keep refs that are truly necessary
const controllerRef = useRef<StreamingResponse>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
/* Create the chat session */
useEffect(() => {
if (chatSession || loading || !generatedUser) {
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>);
/* Create the chat session */
useEffect(() => {
if (chatSession || loading || !generatedUser) {
return;
}
return (
<Box className="GenerateCandidate" sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
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 (
<Box
className="GenerateCandidate"
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
gap: 1,
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
}}>
{generatedUser && <CandidateInfo
candidate={generatedUser}
sx={{flexShrink: 1}}/>
}
{ prompt &&
<Quote quote={prompt}/>
}
{processing &&
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 2,
}}>
{processingMessage && chatSession && <Message message={processingMessage} {...{ chatSession }} />}
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
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>
}}
>
{generatedUser && <CandidateInfo candidate={generatedUser} sx={{ flexShrink: 1 }} />}
{prompt && <Quote quote={prompt} />}
{processing && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 2,
}}
>
{processingMessage && chatSession && (
<Message message={processingMessage} {...{ chatSession }} />
)}
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
/>
</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 }}>
)}
<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, flexGrow: 1 }}
sx={{
m: 1,
gap: 1,
justifySelf: 'flex-start',
alignSelf: 'center',
flexGrow: 0,
maxHeight: 'min-content',
}}
variant="contained"
disabled={processing}
onClick={handleSendClick}>
Generate New Persona<SendIcon />
disabled={processing || !canGenImage}
onClick={() => {
setShouldGenerateProfile(true);
}}
>
{generatedUser?.profileImage ? 'Re-' : ''}Generate Picture
<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>);
</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 {
GenerateCandidate
};
export { GenerateCandidate };

View File

@ -65,11 +65,12 @@ const HeroButton = (props: HeroButtonProps) => {
opacity: 0.9,
},
}));
return <HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</HeroStyledButton>
}
return (
<HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</HeroStyledButton>
);
};
interface ActionButtonProps extends ButtonProps {
children?: string;
@ -84,10 +85,12 @@ const ActionButton = (props: ActionButtonProps) => {
navigate(path);
};
return <Button onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</Button>
}
return (
<Button onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</Button>
);
};
const FeatureIcon = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.action.active,
@ -106,7 +109,7 @@ const FeatureIcon = styled(Box)(({ theme }) => ({
const FeatureCard = ({
icon,
title,
description
description,
}: {
icon: React.ReactNode;
title: string;
@ -140,24 +143,27 @@ const HomePage = () => {
if (isGuest) {
// Show guest-specific UI
console.log('Guest session:', guest?.sessionId || "No guest");
console.log('Guest session:', guest?.sessionId || 'No guest');
} else {
// 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 */}
<HeroSection>
<Container>
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 4,
alignItems: 'center',
flexGrow: 1,
maxWidth: "1024px"
}}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 4,
alignItems: 'center',
flexGrow: 1,
maxWidth: '1024px',
}}
>
<Box sx={{ flex: 1, flexGrow: 1 }}>
<Typography
variant="h2"
@ -166,19 +172,17 @@ const HomePage = () => {
fontWeight: 700,
fontSize: { xs: '2rem', md: '3rem' },
mb: 2,
color: "white"
color: 'white',
}}
>
Your complete professional story, beyond a single page
</Typography>
<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>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<HeroButton
variant="contained"
size="large"
>
<HeroButton variant="contained" size="large">
Get Started as Candidate
</HeroButton>
<HeroButton
@ -187,14 +191,19 @@ const HomePage = () => {
sx={{
backgroundColor: 'transparent',
border: '2px solid',
borderColor: 'action.active'
borderColor: 'action.active',
}}
>
Recruit Talent
</HeroButton>
</Stack>
</Box>
<Box sx={{ justifyContent: "center", display: { xs: 'none', md: 'block' } }}>
<Box
sx={{
justifyContent: 'center',
display: { xs: 'none', md: 'block' },
}}
>
<Box
component="img"
src={professionalConversationPng}
@ -224,33 +233,42 @@ const HomePage = () => {
How Backstory Works
</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 }}>
<Typography variant="h4" component="h3" gutterBottom sx={{ color: 'primary.main' }}>
For Job Seekers
</Typography>
<Box sx={{ my: 3 }}>
<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>
</Box>
<Stack spacing={3}>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
<Box
sx={{
backgroundColor: 'primary.main',
color: 'primary.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">
@ -259,20 +277,22 @@ const HomePage = () => {
</Box>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
<Box
sx={{
backgroundColor: 'primary.main',
color: 'primary.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">
@ -281,20 +301,22 @@ const HomePage = () => {
</Box>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
<Box
sx={{
backgroundColor: 'primary.main',
color: 'primary.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">
@ -303,105 +325,112 @@ const HomePage = () => {
</Box>
</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
variant="contained"
color="secondary"
sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />}
>
Start Recruiting
Create Your Profile
</ActionButton>
</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>
</Container>
@ -419,7 +448,16 @@ const HomePage = () => {
</Typography>
<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
icon={
<FeatureIcon>
@ -430,7 +468,16 @@ const HomePage = () => {
description="Find the perfect candidates based on skills, experience, and fit for your specific requirements."
/>
</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
icon={
<FeatureIcon>
@ -441,7 +488,16 @@ const HomePage = () => {
description="Share your full professional journey beyond the limitations of a traditional resume."
/>
</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
icon={
<FeatureIcon>
@ -452,7 +508,16 @@ const HomePage = () => {
description="Ask detailed questions about a candidate's experience and get immediate answers."
/>
</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
icon={
<FeatureIcon>
@ -468,59 +533,52 @@ const HomePage = () => {
</Box>
{/* Testimonials Section */}
{testimonials &&
<Container sx={{ py: 8 }}>
<Typography
variant="h3"
component="h2"
align="center"
gutterBottom
sx={{ mb: 2, fontWeight: 600 }}
>
Success Stories
</Typography>
<Typography
variant="body1"
align="center"
sx={{ mb: 6, maxWidth: 800, mx: 'auto' }}
>
See how Backstory has transformed the hiring process for both candidates and employers.
</Typography>
{testimonials && (
<Container sx={{ py: 8 }}>
<Typography
variant="h3"
component="h2"
align="center"
gutterBottom
sx={{ mb: 2, fontWeight: 600 }}
>
Success Stories
</Typography>
<Typography variant="body1" align="center" sx={{ mb: 6, maxWidth: 800, mx: 'auto' }}>
See how Backstory has transformed the hiring process for both candidates and employers.
</Typography>
<Testimonials />
</Container>
}
<Testimonials />
</Container>
)}
{/* CTA Section */}
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
py: 8
}}>
<Box
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
py: 8,
}}
>
<Container>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
maxWidth: 800,
mx: 'auto'
}}>
<Typography variant="h3" component="h2" gutterBottom sx={{ color: "white" }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
maxWidth: 800,
mx: 'auto',
}}
>
<Typography variant="h3" component="h2" gutterBottom sx={{ color: 'white' }}>
Ready to transform your hiring process?
</Typography>
<Typography variant="h6" sx={{ mb: 4 }}>
Join Backstory today and discover a better way to connect talent with opportunity.
</Typography>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
justifyContent="center"
>
<HeroButton
variant="contained"
size="large"
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="center">
<HeroButton variant="contained" size="large">
Sign Up as Candidate
</HeroButton>
<HeroButton
@ -529,7 +587,7 @@ const HomePage = () => {
sx={{
backgroundColor: 'transparent',
border: '2px solid',
borderColor: 'action.active'
borderColor: 'action.active',
}}
>
Sign Up as Employer
@ -538,11 +596,8 @@ const HomePage = () => {
</Box>
</Container>
</Box>
</Box>
</Box>
);
};
export {
HomePage
};
export { HomePage };

View File

@ -1,22 +1,22 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Button,
Container,
Paper,
Typography,
Grid,
Card,
CardContent,
Chip,
Step,
StepLabel,
Stepper,
Stack,
ButtonProps,
useMediaQuery,
useTheme,
Box,
Button,
Container,
Paper,
Typography,
Grid,
Card,
CardContent,
Chip,
Step,
StepLabel,
Stepper,
Stack,
ButtonProps,
useMediaQuery,
useTheme,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@ -37,232 +37,255 @@ import { Beta } from 'components/ui/Beta';
// Styled components matching HomePage patterns
const HeroSection = styled(Box)(({ theme }) => ({
padding: theme.spacing(3, 0),
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
[theme.breakpoints.down('md')]: {
padding: theme.spacing(2, 0),
},
padding: theme.spacing(3, 0),
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
[theme.breakpoints.down('md')]: {
padding: theme.spacing(2, 0),
},
}));
const StepSection = styled(Box)(({ theme }) => ({
padding: theme.spacing(6, 0),
'&:nth-of-type(even)': {
backgroundColor: theme.palette.background.default,
},
padding: theme.spacing(6, 0),
'&:nth-of-type(even)': {
backgroundColor: theme.palette.background.default,
},
}));
const StepNumber = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper,
borderRadius: '50%',
width: 60,
height: 60,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '1.5rem',
fontWeight: 'bold',
margin: '0 auto 1rem auto',
[theme.breakpoints.up('md')]: {
margin: 0,
},
backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper,
borderRadius: '50%',
width: 60,
height: 60,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '1.5rem',
fontWeight: 'bold',
margin: '0 auto 1rem auto',
[theme.breakpoints.up('md')]: {
margin: 0,
},
}));
const ImageContainer = styled(Box)(({ theme }) => ({
textAlign: 'center',
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: theme.spacing(1),
boxShadow: theme.shadows[3],
border: `2px solid ${theme.palette.action.active}`,
},
textAlign: 'center',
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: theme.spacing(1),
boxShadow: theme.shadows[3],
border: `2px solid ${theme.palette.action.active}`,
},
}));
const StepCard = styled(Card)(({ theme }) => ({
height: '100%',
display: 'flex',
flexDirection: 'column',
border: `1px solid ${theme.palette.action.active}`,
'&:hover': {
boxShadow: theme.shadows[4],
},
height: '100%',
display: 'flex',
flexDirection: 'column',
border: `1px solid ${theme.palette.action.active}`,
'&:hover': {
boxShadow: theme.shadows[4],
},
}));
const steps = [
'Select Job Analysis',
'Choose a Job',
'Select a Candidate',
'Start Assessment',
'Review Results',
'Generate Resume'
'Select Job Analysis',
'Choose a Job',
'Select a Candidate',
'Start Assessment',
'Review Results',
'Generate Resume',
];
interface StepContentProps {
stepNumber: number;
title: string;
subtitle: string;
icon: React.ReactNode;
description: string[];
imageSrc: string;
imageAlt: string;
note?: string;
success?: string;
reversed?: boolean;
stepNumber: number;
title: string;
subtitle: string;
icon: React.ReactNode;
description: string[];
imageSrc: string;
imageAlt: string;
note?: string;
success?: string;
reversed?: boolean;
}
const StepContent: React.FC<StepContentProps> = ({
stepNumber,
title,
subtitle,
icon,
description,
imageSrc,
imageAlt,
note,
success,
reversed = false
stepNumber,
title,
subtitle,
icon,
description,
imageSrc,
imageAlt,
note,
success,
reversed = false,
}) => {
const textContent = (
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<StepNumber>{stepNumber}</StepNumber>
<Box sx={{ ml: { xs: 0, md: 3 }, textAlign: { xs: 'center', md: 'left' } }}>
<Typography variant="h3" component="h2" sx={{ color: 'primary.main', mb: 1 }}>
{title}
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', justifyContent: { xs: 'center', md: 'flex-start' } }}>
{icon}
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
</Box>
</Box>
</Box>
{description.map((paragraph, index) => (
<Typography key={index} variant="body1" paragraph>
{paragraph}
</Typography>
))}
{note && (
<Paper sx={{ p: 2, backgroundColor: 'action.hover', border: '1px solid', borderColor: 'action.active', mt: 2 }}>
<Typography variant="body2" sx={{ fontStyle: 'italic' }}>
<strong>Note:</strong> {note}
</Typography>
</Paper>
)}
{success && (
<Paper sx={{ p: 2, backgroundColor: 'secondary.main', color: 'secondary.contrastText', mt: 2 }}>
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>
🎉 {success}
</Typography>
</Paper>
)}
</Grid>
);
const textContent = (
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<StepNumber>{stepNumber}</StepNumber>
<Box sx={{ ml: { xs: 0, md: 3 }, textAlign: { xs: 'center', md: 'left' } }}>
<Typography variant="h3" component="h2" sx={{ color: 'primary.main', mb: 1 }}>
{title}
</Typography>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
justifyContent: { xs: 'center', md: 'flex-start' },
}}
>
{icon}
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
</Box>
</Box>
</Box>
{description.map((paragraph, index) => (
<Typography key={index} variant="body1" paragraph>
{paragraph}
</Typography>
))}
{note && (
<Paper
sx={{
p: 2,
backgroundColor: 'action.hover',
border: '1px solid',
borderColor: 'action.active',
mt: 2,
}}
>
<Typography variant="body2" sx={{ fontStyle: 'italic' }}>
<strong>Note:</strong> {note}
</Typography>
</Paper>
)}
{success && (
<Paper
sx={{
p: 2,
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
mt: 2,
}}
>
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>
🎉 {success}
</Typography>
</Paper>
)}
</Grid>
);
const imageContent = (
<Grid size={{ xs: 12, md: 6 }}>
<ImageContainer>
<img src={imageSrc} alt={imageAlt} />
</ImageContainer>
</Grid>
);
const imageContent = (
<Grid size={{ xs: 12, md: 6 }}>
<ImageContainer>
<img src={imageSrc} alt={imageAlt} />
</ImageContainer>
</Grid>
);
return (
<Grid container spacing={4} alignItems="center">
{reversed ? (
<>
{imageContent}
{textContent}
</>
) : (
<>
{textContent}
{imageContent}
</>
)}
</Grid>
);
return (
<Grid container spacing={4} alignItems="center">
{reversed ? (
<>
{imageContent}
{textContent}
</>
) : (
<>
{textContent}
{imageContent}
</>
)}
</Grid>
);
};
interface HeroButtonProps extends ButtonProps {
children?: string;
path: string;
children?: string;
path: string;
}
const HeroButton = (props: HeroButtonProps) => {
const { children, onClick, path, ...rest } = props;
const { children, onClick, path, ...rest } = props;
const navigate = useNavigate();
const navigate = useNavigate();
const handleClick = () => {
navigate(path);
};
const handleClick = () => {
navigate(path);
};
const HeroStyledButton = styled(Button)(({ theme }) => ({
marginTop: theme.spacing(2),
padding: theme.spacing(1, 3),
fontWeight: 500,
backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper,
'&:hover': {
backgroundColor: theme.palette.action.active,
opacity: 0.9,
},
}));
return <HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{children}
const HeroStyledButton = styled(Button)(({ theme }) => ({
marginTop: theme.spacing(2),
padding: theme.spacing(1, 3),
fontWeight: 500,
backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper,
'&:hover': {
backgroundColor: theme.palette.action.active,
opacity: 0.9,
},
}));
return (
<HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</HeroStyledButton>
}
);
};
const HowItWorks: React.FC = () => {
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const handleGetStarted = () => {
navigate('/job-analysis');
};
const handleGetStarted = () => {
navigate('/job-analysis');
};
return (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{/* Hero Section */}
{/* Hero Section */}
<HeroSection>
<Container>
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 4,
alignItems: 'center',
flexGrow: 1,
maxWidth: "1024px"
}}>
<Box sx={{ flex: 1, flexGrow: 1 }}>
<Typography
variant="h2"
component="h1"
sx={{
fontWeight: 700,
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 }}>
Let potential employers discover the depth of your experience through interactive Q&A and tailored resumes
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<HeroButton
variant="contained"
size="large"
path="/login/register"
>
Get Started as Candidate
</HeroButton>
{/* <HeroButton
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{/* Hero Section */}
{/* Hero Section */}
<HeroSection>
<Container>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 4,
alignItems: 'center',
flexGrow: 1,
maxWidth: '1024px',
}}
>
<Box sx={{ flex: 1, flexGrow: 1 }}>
<Typography
variant="h2"
component="h1"
sx={{
fontWeight: 700,
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 }}>
Let potential employers discover the depth of your experience through interactive
Q&A and tailored resumes
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<HeroButton variant="contained" size="large" path="/login/register">
Get Started as Candidate
</HeroButton>
{/* <HeroButton
variant="outlined"
size="large"
sx={{
@ -273,218 +296,243 @@ const HowItWorks: React.FC = () => {
>
Recruit Talent
</HeroButton> */}
</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>
</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>
);
};
export { HowItWorks };
export { HowItWorks };

View File

@ -1,12 +1,12 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Box,
Stepper,
Step,
StepLabel,
Button,
Typography,
Paper,
import {
Box,
Stepper,
Step,
StepLabel,
Button,
Typography,
Paper,
useTheme,
Snackbar,
Alert,
@ -16,15 +16,12 @@ import {
useMediaQuery,
Divider,
} from '@mui/material';
import {
Add,
WorkOutline,
} from '@mui/icons-material';
import { Add, WorkOutline } from '@mui/icons-material';
import PersonIcon from '@mui/icons-material/Person';
import WorkIcon from '@mui/icons-material/Work';
import AssessmentIcon from '@mui/icons-material/Assessment';
import { JobMatchAnalysis } from 'components/JobMatchAnalysis';
import { Candidate, Job, SkillAssessment } from "types/types";
import { Candidate, Job, SkillAssessment } from 'types/types';
import { useNavigate } from 'react-router-dom';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { useAuth } from 'hooks/AuthContext';
@ -43,10 +40,12 @@ import { JobInfo } from 'components/ui/JobInfo';
function WorkAddIcon() {
return (
<Box position="relative" display="inline-flex"
<Box
position="relative"
display="inline-flex"
sx={{
lineHeight: "30px",
mb: "6px",
lineHeight: '30px',
mb: '6px',
}}
>
<WorkOutline sx={{ fontSize: 24 }} />
@ -71,7 +70,7 @@ interface AnalysisState {
candidate: Candidate | null;
analysis: SkillAssessment[] | null;
resume: string | null;
};
}
interface Step {
index: number;
@ -79,7 +78,7 @@ interface Step {
requiredState: string[];
title: string;
icon: React.ReactNode;
};
}
const initialState: AnalysisState = {
job: null,
@ -92,13 +91,23 @@ const initialState: AnalysisState = {
const steps: Step[] = [
{ requiredState: [], title: 'Job Selection', icon: <WorkIcon /> },
{ requiredState: ['job'], title: 'Select Candidate', icon: <PersonIcon /> },
{ requiredState: ['job', 'candidate'], 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, '-') } });
{
requiredState: ['job', 'candidate'],
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) => {
return str.charAt(0).toUpperCase() + str.slice(1);
}
};
// Main component
const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
@ -115,23 +124,30 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
const scrollRef = useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const canAccessStep = useCallback((step: Step) => {
if (!analysisState) {
return;
}
const missing = step.requiredState.find(f => !(analysisState as any)[f])
return missing;
}, [analysisState]);
const canAccessStep = useCallback(
(step: Step) => {
if (!analysisState) {
return;
}
const missing = step.requiredState.find(f => !(analysisState as any)[f]);
return missing;
},
[analysisState]
);
useEffect(() => {
if (analysisState !== null) {
return;
}
const analysis = { ...initialState, candidate: selectedCandidate, job: selectedJob }
const analysis = {
...initialState,
candidate: selectedCandidate,
job: selectedJob,
};
setAnalysisState(analysis);
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) {
setActiveStep(steps[i]);
return;
@ -153,7 +169,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: 0,
behavior: "smooth",
behavior: 'smooth',
});
}
}, [setCanAdvance, analysisState, activeStep]);
@ -167,9 +183,9 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setError(`${capitalize(missing)} is necessary before continuing.`);
return missing;
}
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;
}
setActiveStep((prevActiveStep) => steps[prevActiveStep.index - 1]);
setActiveStep(prevActiveStep => steps[prevActiveStep.index - 1]);
};
const moveToStep = (step: number) => {
@ -188,7 +204,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
return;
}
setActiveStep(steps[step]);
}
};
const onCandidateSelect = (candidate: Candidate) => {
if (!analysisState) {
@ -198,7 +214,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setAnalysisState({ ...analysisState });
setSelectedCandidate(candidate);
handleNext();
}
};
const onJobSelect = (job: Job) => {
if (!analysisState) {
@ -208,7 +224,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setAnalysisState({ ...analysisState });
setSelectedJob(job);
handleNext();
}
};
// Render function for the candidate selection step
const renderCandidateSelection = () => (
@ -221,28 +237,25 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
// Render function for the job description step
const renderJobDescription = () => {
return (<Box sx={{ mt: 3, width: "100%" }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value='select' icon={<WorkOutline />} label="Select Job" />
<Tab value='create' icon={<WorkAddIcon />} label="Create Job" />
</Tabs>
</Box>
return (
<Box sx={{ mt: 3, width: '100%' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value="select" icon={<WorkOutline />} label="Select Job" />
<Tab value="create" icon={<WorkAddIcon />} label="Create Job" />
</Tabs>
</Box>
{jobTab === 'select' &&
<JobPicker onSelect={onJobSelect} />
}
{jobTab === 'create' && user &&
<JobCreator
onSave={onJobSelect}
/>}
{jobTab === 'create' && guest &&
<LoginRestricted><JobCreator
onSave={onJobSelect}
/></LoginRestricted>}
</Box>
{jobTab === 'select' && <JobPicker onSelect={onJobSelect} />}
{jobTab === 'create' && user && <JobCreator onSave={onJobSelect} />}
{jobTab === 'create' && guest && (
<LoginRestricted>
<JobCreator onSave={onJobSelect} />
</LoginRestricted>
)}
</Box>
);
}
};
const onAnalysisComplete = (skills: SkillAssessment[]) => {
if (!analysisState) {
@ -258,16 +271,25 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
return;
}
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 }}>
<JobMatchAnalysis
variant="small"
job={analysisState.job}
candidate={analysisState.candidate}
onAnalysisComplete={onAnalysisComplete}
/>
</Box>);
return (
<Box sx={{ mt: 3 }}>
<JobMatchAnalysis
variant="small"
job={analysisState.job}
candidate={analysisState.candidate}
onAnalysisComplete={onAnalysisComplete}
/>
</Box>
);
};
const renderResume = () => {
@ -278,43 +300,56 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
return <></>;
}
return (<Box sx={{ mt: 3 }}>
<ResumeGenerator
job={analysisState.job}
candidate={analysisState.candidate}
skills={analysisState.analysis}
/>
</Box>);
return (
<Box sx={{ mt: 3 }}>
<ResumeGenerator
job={analysisState.job}
candidate={analysisState.candidate}
skills={analysisState.analysis}
/>
</Box>
);
};
return (
<Box sx={{
display: "flex", flexDirection: "column",
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",
}}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
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',
}}
>
<Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0, gap: 1 }}>
<Stepper activeStep={activeStep.index} alternativeLabel sx={{ mt: 2, mb: 2 }}>
{steps.map((step, index) => (
<Step>
<StepLabel sx={{ cursor: "pointer" }} onClick={() => { moveToStep(index); }}
<StepLabel
sx={{ cursor: 'pointer' }}
onClick={() => {
moveToStep(index);
}}
slots={{
stepIcon: () => (
<Avatar key={step.index}
<Avatar
key={step.index}
sx={{
bgcolor: activeStep.index >= step.index ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white'
bgcolor:
activeStep.index >= step.index
? theme.palette.primary.main
: theme.palette.grey[300],
color: 'white',
}}
>
{step.icon}
</Avatar>
)
),
}}
>
{step.title}
@ -322,82 +357,92 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
</Step>
))}
</Stepper>
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
{analysisState && analysisState.job &&
<Box sx={{ display: "flex", flexDirection: "row", width: "100%" }}>
{!isMobile &&
<Box sx={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row' }}>
{analysisState && analysisState.job && (
<Box sx={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
{!isMobile && (
<Avatar
sx={{
ml: 1, mt: 1,
ml: 1,
mt: 1,
bgcolor: theme.palette.primary.main,
color: 'white'
color: 'white',
}}
>
<WorkIcon />
</Avatar>
}
)}
<JobInfo variant="minimal" job={analysisState.job} />
</Box>
}
{isMobile && <Box sx={{ display: "flex", borderBottom: "1px solid lightgrey" }} />}
{!isMobile && <Box sx={{ display: "flex", borderLeft: "1px solid lightgrey" }} />}
{analysisState && analysisState.candidate &&
<Box sx={{ display: "flex", flexDirection: "row", width: "100%" }}>
)}
{isMobile && <Box sx={{ display: 'flex', borderBottom: '1px solid lightgrey' }} />}
{!isMobile && <Box sx={{ display: 'flex', borderLeft: '1px solid lightgrey' }} />}
{analysisState && analysisState.candidate && (
<Box sx={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
<CandidateInfo variant="minimal" candidate={analysisState.candidate} sx={{}} />
</Box>
}
)}
</Box>
</Paper>
<Scrollable
ref={scrollRef}
sx={{
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex", flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}}>
position: 'relative',
maxHeight: '100%',
width: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
}}
>
{activeStep.label === 'job-selection' && renderJobDescription()}
{activeStep.label === 'select-candidate' && renderCandidateSelection()}
{activeStep.label === 'job-analysis' && renderAnalysis()}
{activeStep.label === 'generated-resume' && renderResume()}
</Scrollable>
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
<Button
color="inherit"
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
<Button
color="inherit"
disabled={activeStep.index === steps[0].index}
onClick={handleBack}
sx={{ mr: 1 }}
>
Back
</Button>
<Box sx={{ flex: '1 1 auto' }} />
onClick={handleBack}
sx={{ mr: 1 }}
>
Back
</Button>
<Box sx={{ flex: '1 1 auto' }} />
{activeStep.index === steps[steps.length - 1].index ? (
<Button disabled={!canAdvance} onClick={() => { moveToStep(0) }} variant="outlined">
Start New Analysis
</Button>
) : (
<Button disabled={!canAdvance} onClick={handleNext} variant="contained">
{activeStep.index === steps.length - 1 ? 'Done' : 'Next'}
</Button>
)}
</Box>
<Button
disabled={!canAdvance}
onClick={() => {
moveToStep(0);
}}
variant="outlined"
>
Start New Analysis
</Button>
) : (
<Button disabled={!canAdvance} onClick={handleNext} variant="contained">
{activeStep.index === steps.length - 1 ? 'Done' : 'Next'}
</Button>
)}
</Box>
{/* Error Snackbar */}
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => setError(null)} severity="error" sx={{ width: '100%' }}>
{error}
</Alert>
{/* Error Snackbar */}
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => setError(null)} severity="error" sx={{ width: '100%' }}>
{error}
</Alert>
</Snackbar>
</Box>);
</Box>
);
};
export { JobAnalysisPage };
export { JobAnalysisPage };

View File

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

View File

@ -12,10 +12,7 @@ import {
useMediaQuery,
useTheme,
} from '@mui/material';
import {
Person,
PersonAdd,
} from '@mui/icons-material';
import { Person, PersonAdd } from '@mui/icons-material';
import 'react-phone-number-input/style.css';
import './LoginPage.css';
@ -24,8 +21,8 @@ import { BackstoryLogo } from 'components/ui/BackstoryLogo';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { LoginForm } from "components/EmailVerificationComponents";
import { CandidateRegistrationForm } from "pages/candidate/RegistrationForms";
import { LoginForm } from 'components/EmailVerificationComponents';
import { CandidateRegistrationForm } from 'pages/candidate/RegistrationForms';
import { useNavigate, useParams } from 'react-router-dom';
import { useAppState } from 'hooks/GlobalContext';
import * as Types from 'types/types';
@ -37,11 +34,12 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
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 theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const showGuest: boolean = false;
const showGuest = false;
const { tab } = useParams();
useEffect(() => {
@ -53,7 +51,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
setSnack(data.error.message, "error");
setSnack(data.error.message, 'error');
setTimeout(() => {
setErrorMessage(null);
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) {
navigate('/candidate/profile');
return (<></>);
return <></>;
}
return (
<Paper elevation={3} sx={{ p: isMobile ? 0 : 4 }}>
<BackstoryLogo />
{showGuest && guest && (
<Card sx={{ mb: 3, bgcolor: 'grey.50' }} elevation={1}>
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
Guest Session Active
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
Session ID: {guest.sessionId}
</Typography>
<Typography variant="body2" color="text.secondary">
Created: {guest.createdAt?.toLocaleString()}
</Typography>
</CardContent>
</Card>
)}
<BackstoryLogo />
{showGuest && guest && (
<Card sx={{ mb: 3, bgcolor: 'grey.50' }} elevation={1}>
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
Guest Session Active
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
Session ID: {guest.sessionId}
</Typography>
<Typography variant="body2" color="text.secondary">
Created: {guest.createdAt?.toLocaleString()}
</Typography>
</CardContent>
</Card>
)}
<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="register" icon={<PersonAdd />} label="Register" />
</Tabs>
</Box>
</Tabs>
</Box>
{errorMessage && (
<Alert severity="error" sx={{ mb: 2 }}>
{errorMessage}
</Alert>
)}
{errorMessage && (
<Alert severity="error" sx={{ mb: 2 }}>
{errorMessage}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
{success}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
{success}
</Alert>
)}
{tabValue === "login" && (
<LoginForm />
)}
{tabValue === 'login' && <LoginForm />}
{tabValue === "register" && (
<CandidateRegistrationForm />
)}
{tabValue === 'register' && <CandidateRegistrationForm />}
</Paper>
);
};
export { LoginPage };
export { LoginPage };

View File

@ -13,51 +13,55 @@ import { Candidate } from 'types/types';
import { useAppState } from 'hooks/GlobalContext';
import * as Types from 'types/types';
const ChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
const { setSnack } = useAppState();
const { user } = useAuth();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [questions, setQuestions] = useState<React.ReactElement[]>([]);
const candidate: Candidate | null = user?.userType === 'candidate' ? user as Types.Candidate : null;
const ChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
(props: BackstoryPageProps, ref) => {
const { setSnack } = useAppState();
const { user } = useAuth();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
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) {
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 (
<Box>
<Box>
<CandidateInfo candidate={candidate} action="Chat with Backstory AI about " />
<Conversation
ref={ref}
{...{
multiline: true,
type: "chat",
<Conversation
ref={ref}
{...{
multiline: true,
type: 'chat',
placeholder: `What would you like to know about ${candidate?.firstName}?`,
resetLabel: "chat",
resetLabel: 'chat',
defaultPrompts: questions,
}} />
</Box>);
});
}}
/>
</Box>
);
}
);
export {
ChatPage
};
export { ChatPage };

View File

@ -8,7 +8,7 @@ import './VectorVisualizerPage.css';
interface VectorVisualizerProps extends BackstoryPageProps {
inline?: boolean;
rag?: any;
};
}
const VectorVisualizerPage: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => {
return <VectorVisualizer inline={false} {...props} />;
@ -16,6 +16,4 @@ const VectorVisualizerPage: React.FC<VectorVisualizerProps> = (props: VectorVisu
export type { VectorVisualizerProps };
export {
VectorVisualizerPage
};
export { VectorVisualizerPage };

View File

@ -1,13 +1,5 @@
import React from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
LinearProgress,
Stack
} from '@mui/material';
import { Box, Card, CardContent, Typography, Button, LinearProgress, Stack } from '@mui/material';
import {
Add as AddIcon,
Visibility as VisibilityIcon,
@ -23,8 +15,7 @@ import { useNavigate } from 'react-router-dom';
import { ComingSoon } from 'components/ui/ComingSoon';
import { useAppState } from 'hooks/GlobalContext';
interface CandidateDashboardProps extends BackstoryElementProps {
};
type CandidateDashboardProps = BackstoryElementProps;
const CandidateDashboard = (props: CandidateDashboardProps) => {
const { setSnack } = useAppState();
@ -33,178 +24,170 @@ const CandidateDashboard = (props: CandidateDashboardProps) => {
const profileCompletion = 75;
if (!user) {
return <LoginRequired asset="candidate dashboard"/>;
return <LoginRequired asset="candidate dashboard" />;
}
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('/');
return (<></>);
return <></>;
}
return (<>
return (
<>
{/* Main Content */}
<ComingSoon>
<Box sx={{ flex: 1, p: 3 }}>
{/* Welcome Section */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold' }}>
Welcome back, {user.firstName}!
</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
Your profile is {profileCompletion}% complete
<Box sx={{ flex: 1, p: 3 }}>
{/* Welcome Section */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold' }}>
Welcome back, {user.firstName}!
</Typography>
<LinearProgress
variant="determinate"
value={profileCompletion}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: '#e0e0e0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#4caf50',
},
<Box sx={{ mb: 2 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
Your profile is {profileCompletion}% complete
</Typography>
<LinearProgress
variant="determinate"
value={profileCompletion}
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>
<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>
>
Complete Your Profile
</Button>
</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
{/* 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={{ 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 variant="body2" sx={{ mb: 1, color: '#666' }}>
3 custom resumes
</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>
</Stack>
<Button
variant="outlined"
startIcon={<TipsIcon />}
fullWidth
>
View All Tips
</Button>
</CardContent>
</Card>
<Button variant="outlined" startIcon={<AddIcon />} fullWidth>
Create New
</Button>
</CardContent>
</Card>
{/* Recent Activity Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Recent Activity
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VisibilityIcon sx={{ fontSize: 16, color: '#666' }} />
<Typography variant="body2">5 profile views</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DownloadIcon sx={{ fontSize: 16, color: '#666' }} />
<Typography variant="body2">2 resume downloads</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ContactMailIcon sx={{ fontSize: 16, color: '#666' }} />
<Typography variant="body2">1 direct contact</Typography>
</Box>
</Stack>
<Button variant="outlined" fullWidth>
View All Activity
</Button>
</CardContent>
</Card>
</Box>
{/* Bottom Row */}
<Box sx={{ display: 'flex', gap: 3 }}>
{/* Complete Your Backstory Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Complete Your Backstory
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Add projects
</Typography>
<Typography 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>
</ComingSoon>
</>
</>
);
};
export { CandidateDashboard };
export { CandidateDashboard };

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ import {
useMediaQuery,
CircularProgress,
Snackbar,
Alert
Alert,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { CloudUpload, PhotoCamera } from '@mui/icons-material';
@ -49,18 +49,22 @@ const VisuallyHiddenInput = styled('input')({
const CreateProfilePage: React.FC = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// State management
const [activeStep, setActiveStep] = useState<number>(0);
const [profileImage, setProfileImage] = useState<string | null>(null);
const [resumeFile, setResumeFile] = useState<File | null>(null);
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,
message: '',
severity: 'success'
severity: 'success',
});
const [formData, setFormData] = useState<ProfileFormData>({
firstName: '',
lastName: '',
@ -87,7 +91,7 @@ const CreateProfilePage: React.FC = () => {
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const reader = new FileReader();
reader.onload = (event) => {
reader.onload = event => {
if (event.target?.result) {
setProfileImage(event.target.result.toString());
}
@ -103,7 +107,7 @@ const CreateProfilePage: React.FC = () => {
setSnackbar({
open: true,
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) {
handleSubmit();
} else {
setActiveStep((prevStep) => prevStep + 1);
setActiveStep(prevStep => prevStep + 1);
}
};
const handleBack = () => {
setActiveStep((prevStep) => prevStep - 1);
setActiveStep(prevStep => prevStep - 1);
};
// Form submission
const handleSubmit = async () => {
setLoading(true);
// Simulate API call with timeout
setTimeout(() => {
setLoading(false);
setSnackbar({
open: true,
message: 'Profile created successfully! Redirecting to dashboard...',
severity: 'success'
severity: 'success',
});
// Redirect would happen here in a real application
// history.push('/dashboard');
}, 2000);
@ -143,9 +147,11 @@ const CreateProfilePage: React.FC = () => {
const isStepValid = () => {
switch (activeStep) {
case 0:
return formData.firstName.trim() !== '' &&
formData.lastName.trim() !== '' &&
formData.email.trim() !== '';
return (
formData.firstName.trim() !== '' &&
formData.lastName.trim() !== '' &&
formData.email.trim() !== ''
);
case 1:
return formData.jobTitle.trim() !== '';
case 2:
@ -161,35 +167,33 @@ const CreateProfilePage: React.FC = () => {
case 0:
return (
<Grid container spacing={3}>
<Grid size={{xs: 12}} sx={{ textAlign: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center', mb: 2 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar
src={profileImage || ''}
sx={{
width: 120,
height: 120,
sx={{
width: 120,
height: 120,
mb: 2,
border: `2px solid ${theme.palette.primary.main}`
border: `2px solid ${theme.palette.primary.main}`,
}}
/>
<IconButton
color="primary"
aria-label="upload picture"
component="label"
>
<IconButton color="primary" aria-label="upload picture" component="label">
<PhotoCamera />
<VisuallyHiddenInput
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
<VisuallyHiddenInput type="file" accept="image/*" onChange={handleImageUpload} />
</IconButton>
<Typography variant="caption" color="textSecondary">
Add profile photo
</Typography>
</Box>
</Grid>
<Grid size={{xs: 12, sm: 6}}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
required
fullWidth
@ -200,7 +204,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs: 12, sm: 6}}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
required
fullWidth
@ -211,7 +215,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs: 12}}>
<Grid size={{ xs: 12 }}>
<TextField
required
fullWidth
@ -223,7 +227,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs:12}}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label="Phone Number"
@ -238,7 +242,7 @@ const CreateProfilePage: React.FC = () => {
case 1:
return (
<Grid container spacing={3}>
<Grid size={{xs:12}}>
<Grid size={{ xs: 12 }}>
<TextField
required
fullWidth
@ -249,7 +253,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs: 12}}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label="Location"
@ -260,7 +264,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs:12}}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
multiline
@ -278,10 +282,10 @@ const CreateProfilePage: React.FC = () => {
case 2:
return (
<Grid container spacing={3}>
<Grid size={{xs: 12}}>
<Grid size={{ xs: 12 }}>
<Typography variant="body1" component="p">
Upload your resume to complete your profile. We'll analyze it to better understand your skills and experience.
(Supported formats: .pdf, .docx, .md, and .txt)
Upload your resume to complete your profile. We'll analyze it to better understand
your skills and experience. (Supported formats: .pdf, .docx, .md, and .txt)
</Typography>
<Box sx={{ textAlign: 'center', mt: 2 }}>
<Button
@ -291,13 +295,13 @@ const CreateProfilePage: React.FC = () => {
sx={{ mb: 2 }}
>
Upload Resume
<VisuallyHiddenInput
type="file"
<VisuallyHiddenInput
type="file"
accept=".pdf,.docx,.txt,.md"
onChange={handleResumeUpload}
/>
</Button>
{resumeFile && (
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
File uploaded: {resumeFile.name}
@ -314,41 +318,35 @@ const CreateProfilePage: React.FC = () => {
return (
<Container component="main">
<Paper
elevation={3}
sx={{
p: { xs: 2, sm: 4 },
mt: { xs: 2, sm: 4 },
<Paper
elevation={3}
sx={{
p: { xs: 2, sm: 4 },
mt: { xs: 2, sm: 4 },
mb: { xs: 2, sm: 4 },
}}
>
<Typography component="h1" variant="h4" align="center" gutterBottom>
Create Your Profile
</Typography>
<Stepper
activeStep={activeStep}
<Stepper
activeStep={activeStep}
alternativeLabel={!isMobile}
orientation={isMobile ? 'vertical' : 'horizontal'}
sx={{ mt: 3, mb: 5 }}
>
{steps.map((label) => (
{steps.map(label => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<Box sx={{ mt: 2, mb: 4 }}>
{getStepContent(activeStep)}
</Box>
<Box sx={{ mt: 2, mb: 4 }}>{getStepContent(activeStep)}</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
<Button
disabled={activeStep === 0}
onClick={handleBack}
variant="outlined"
>
<Button disabled={activeStep === 0} onClick={handleBack} variant="outlined">
Back
</Button>
<Button
@ -367,8 +365,8 @@ const CreateProfilePage: React.FC = () => {
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
@ -379,4 +377,4 @@ const CreateProfilePage: React.FC = () => {
);
};
export { CreateProfilePage };
export { CreateProfilePage };

View File

@ -21,7 +21,7 @@ import {
useMediaQuery,
useTheme,
IconButton,
InputAdornment
InputAdornment,
} from '@mui/material';
import { Visibility, VisibilityOff } from '@mui/icons-material';
import { ApiClient } from 'services/api-client';
@ -40,7 +40,7 @@ const CandidateRegistrationForm = () => {
confirmPassword: '',
firstName: '',
lastName: '',
phone: ''
phone: '',
});
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@ -49,7 +49,7 @@ const CandidateRegistrationForm = () => {
const [errors, setErrors] = useState<Record<string, string>>({});
const [showSuccess, setShowSuccess] = useState(false);
const [registrationResult, setRegistrationResult] = useState<any>(null);
// Password visibility states
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
@ -108,7 +108,7 @@ const CandidateRegistrationForm = () => {
const validatePassword = (password: string): string[] => {
const errors: string[] = [];
if (password.length < 8) {
errors.push('at least 8 characters');
}
@ -130,7 +130,7 @@ const CandidateRegistrationForm = () => {
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
@ -150,15 +150,14 @@ const CandidateRegistrationForm = () => {
password: formData.password,
firstName: formData.firstName,
lastName: formData.lastName,
phone: formData.phone || undefined
phone: formData.phone || undefined,
});
// Set pending verification
apiClient.setPendingEmailVerification(formData.email);
setRegistrationResult(result);
setShowSuccess(true);
} catch (error: any) {
if (error.message.includes('already exists')) {
if (error.message.includes('email')) {
@ -167,7 +166,9 @@ const CandidateRegistrationForm = () => {
setErrors({ username: 'This username is already taken' });
}
} else {
setErrors({ general: error.message || 'Registration failed. Please try again.' });
setErrors({
general: error.message || 'Registration failed. Please try again.',
});
}
} finally {
setLoading(false);
@ -180,11 +181,11 @@ const CandidateRegistrationForm = () => {
/[a-z]/.test(password),
/[A-Z]/.test(password),
/\d/.test(password),
/[!@#$%^&*(),.?":{}|<>]/.test(password)
/[!@#$%^&*(),.?":{}|<>]/.test(password),
];
const strength = validations.filter(Boolean).length;
if (strength < 2) return { level: 'weak', color: 'error', value: 20 };
if (strength < 4) return { level: 'medium', color: 'warning', value: 60 };
return { level: 'strong', color: 'success', value: 100 };
@ -209,7 +210,7 @@ const CandidateRegistrationForm = () => {
label="Email Address"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
onChange={e => handleInputChange('email', e.target.value)}
placeholder="your.email@example.com"
error={!!errors.email}
helperText={errors.email}
@ -220,7 +221,7 @@ const CandidateRegistrationForm = () => {
fullWidth
label="Username"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value.toLowerCase())}
onChange={e => handleInputChange('username', e.target.value.toLowerCase())}
placeholder="johndoe123"
error={!!errors.username}
helperText={errors.username}
@ -232,7 +233,7 @@ const CandidateRegistrationForm = () => {
fullWidth
label="First Name"
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
onChange={e => handleInputChange('firstName', e.target.value)}
placeholder="John"
error={!!errors.firstName}
helperText={errors.firstName}
@ -242,7 +243,7 @@ const CandidateRegistrationForm = () => {
fullWidth
label="Last Name"
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
onChange={e => handleInputChange('lastName', e.target.value)}
placeholder="Doe"
error={!!errors.lastName}
helperText={errors.lastName}
@ -255,10 +256,10 @@ const CandidateRegistrationForm = () => {
label="Phone Number"
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
onChange={e => handleInputChange('phone', e.target.value)}
placeholder="+1 (555) 123-4567"
error={!!errors.phone}
helperText={errors.phone || "Optional"}
helperText={errors.phone || 'Optional'}
/>
<Box>
@ -267,7 +268,7 @@ const CandidateRegistrationForm = () => {
label="Password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
onChange={e => handleInputChange('password', e.target.value)}
placeholder="Create a strong password"
error={!!errors.password}
helperText={errors.password}
@ -278,7 +279,7 @@ const CandidateRegistrationForm = () => {
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
@ -295,7 +296,11 @@ const CandidateRegistrationForm = () => {
color={passwordStrength.color as any}
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}
</Typography>
</Box>
@ -307,7 +312,7 @@ const CandidateRegistrationForm = () => {
label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
onChange={e => handleInputChange('confirmPassword', e.target.value)}
placeholder="Confirm your password"
error={!!errors.confirmPassword}
helperText={errors.confirmPassword}
@ -318,7 +323,7 @@ const CandidateRegistrationForm = () => {
<IconButton
aria-label="toggle confirm password visibility"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
@ -328,11 +333,7 @@ const CandidateRegistrationForm = () => {
}}
/>
{errors.general && (
<Alert severity="error">
{errors.general}
</Alert>
)}
{errors.general && <Alert severity="error">{errors.general}</Alert>}
<Button
fullWidth
@ -355,9 +356,12 @@ const CandidateRegistrationForm = () => {
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Already have an account?{' '}
<Link
<Link
component="button"
onClick={(e) => { e.preventDefault(); navigate('/login'); }}
onClick={e => {
e.preventDefault();
navigate('/login');
}}
sx={{ fontWeight: 600 }}
>
Sign in here
@ -389,14 +393,14 @@ const EmployerRegistrationForm = () => {
companySize: '',
companyDescription: '',
websiteUrl: '',
phone: ''
phone: '',
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [showSuccess, setShowSuccess] = useState(false);
const [registrationResult, setRegistrationResult] = useState<any>(null);
// Password visibility states
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
@ -404,13 +408,26 @@ const EmployerRegistrationForm = () => {
const apiClient = new ApiClient();
const industryOptions = [
'Technology', 'Healthcare', 'Finance', 'Education', 'Manufacturing',
'Retail', 'Consulting', 'Media', 'Non-profit', 'Government', 'Other'
'Technology',
'Healthcare',
'Finance',
'Education',
'Manufacturing',
'Retail',
'Consulting',
'Media',
'Non-profit',
'Government',
'Other',
];
const companySizeOptions = [
'1-10 employees', '11-50 employees', '51-200 employees',
'201-500 employees', '501-1000 employees', '1000+ employees'
'1-10 employees',
'11-50 employees',
'51-200 employees',
'201-500 employees',
'501-1000 employees',
'1000+ employees',
];
const validateForm = () => {
@ -477,7 +494,7 @@ const EmployerRegistrationForm = () => {
const validatePassword = (password: string): string[] => {
const errors: string[] = [];
if (password.length < 8) {
errors.push('at least 8 characters');
}
@ -499,7 +516,7 @@ const EmployerRegistrationForm = () => {
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
@ -522,15 +539,14 @@ const EmployerRegistrationForm = () => {
companySize: formData.companySize,
companyDescription: formData.companyDescription,
websiteUrl: formData.websiteUrl || undefined,
phone: formData.phone || undefined
phone: formData.phone || undefined,
});
// Set pending verification
apiClient.setPendingEmailVerification(formData.email);
setRegistrationResult(result);
setShowSuccess(true);
} catch (error: any) {
if (error.message.includes('already exists')) {
if (error.message.includes('email')) {
@ -539,7 +555,9 @@ const EmployerRegistrationForm = () => {
setErrors({ username: 'This username is already taken' });
}
} else {
setErrors({ general: error.message || 'Registration failed. Please try again.' });
setErrors({
general: error.message || 'Registration failed. Please try again.',
});
}
} finally {
setLoading(false);
@ -564,7 +582,7 @@ const EmployerRegistrationForm = () => {
<Typography variant="h6" sx={{ mb: 2 }}>
Account Information
</Typography>
<Stack spacing={3}>
<Stack direction="row" spacing={2}>
<TextField
@ -572,7 +590,7 @@ const EmployerRegistrationForm = () => {
label="Email Address"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
onChange={e => handleInputChange('email', e.target.value)}
placeholder="company@example.com"
error={!!errors.email}
helperText={errors.email}
@ -582,7 +600,7 @@ const EmployerRegistrationForm = () => {
fullWidth
label="Username"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value.toLowerCase())}
onChange={e => handleInputChange('username', e.target.value.toLowerCase())}
placeholder="company123"
error={!!errors.username}
helperText={errors.username}
@ -596,7 +614,7 @@ const EmployerRegistrationForm = () => {
label="Password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
onChange={e => handleInputChange('password', e.target.value)}
placeholder="Create a strong password"
error={!!errors.password}
helperText={errors.password}
@ -607,7 +625,7 @@ const EmployerRegistrationForm = () => {
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
@ -621,7 +639,7 @@ const EmployerRegistrationForm = () => {
label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
onChange={e => handleInputChange('confirmPassword', e.target.value)}
placeholder="Confirm your password"
error={!!errors.confirmPassword}
helperText={errors.confirmPassword}
@ -632,7 +650,7 @@ const EmployerRegistrationForm = () => {
<IconButton
aria-label="toggle confirm password visibility"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
@ -650,13 +668,13 @@ const EmployerRegistrationForm = () => {
<Typography variant="h6" sx={{ mb: 2 }}>
Company Information
</Typography>
<Stack spacing={3}>
<TextField
fullWidth
label="Company Name"
value={formData.companyName}
onChange={(e) => handleInputChange('companyName', e.target.value)}
onChange={e => handleInputChange('companyName', e.target.value)}
placeholder="Your Company Inc."
error={!!errors.companyName}
helperText={errors.companyName}
@ -668,11 +686,13 @@ const EmployerRegistrationForm = () => {
<InputLabel>Industry</InputLabel>
<Select
value={formData.industry}
onChange={(e) => handleInputChange('industry', e.target.value)}
onChange={e => handleInputChange('industry', e.target.value)}
label="Industry"
>
{industryOptions.map(industry => (
<MenuItem key={industry} value={industry}>{industry}</MenuItem>
<MenuItem key={industry} value={industry}>
{industry}
</MenuItem>
))}
</Select>
{errors.industry && <FormHelperText>{errors.industry}</FormHelperText>}
@ -682,11 +702,13 @@ const EmployerRegistrationForm = () => {
<InputLabel>Company Size</InputLabel>
<Select
value={formData.companySize}
onChange={(e) => handleInputChange('companySize', e.target.value)}
onChange={e => handleInputChange('companySize', e.target.value)}
label="Company Size"
>
{companySizeOptions.map(size => (
<MenuItem key={size} value={size}>{size}</MenuItem>
<MenuItem key={size} value={size}>
{size}
</MenuItem>
))}
</Select>
{errors.companySize && <FormHelperText>{errors.companySize}</FormHelperText>}
@ -700,10 +722,13 @@ const EmployerRegistrationForm = () => {
multiline
rows={4}
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..."
error={!!errors.companyDescription}
helperText={errors.companyDescription || `${formData.companyDescription.length}/50 characters minimum`}
helperText={
errors.companyDescription ||
`${formData.companyDescription.length}/50 characters minimum`
}
required
/>
</Box>
@ -714,30 +739,26 @@ const EmployerRegistrationForm = () => {
label="Website URL"
type="url"
value={formData.websiteUrl}
onChange={(e) => handleInputChange('websiteUrl', e.target.value)}
onChange={e => handleInputChange('websiteUrl', e.target.value)}
placeholder="https://www.yourcompany.com"
error={!!errors.websiteUrl}
helperText={errors.websiteUrl || "Optional"}
helperText={errors.websiteUrl || 'Optional'}
/>
<TextField
fullWidth
label="Phone Number"
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
onChange={e => handleInputChange('phone', e.target.value)}
placeholder="+1 (555) 123-4567"
error={!!errors.phone}
helperText={errors.phone || "Optional"}
helperText={errors.phone || 'Optional'}
/>
</Stack>
</Stack>
</Box>
{errors.general && (
<Alert severity="error">
{errors.general}
</Alert>
)}
{errors.general && <Alert severity="error">{errors.general}</Alert>}
<Button
fullWidth
@ -796,22 +817,24 @@ export function RegistrationTypeSelector() {
<Stack direction="row" spacing={3}>
{/* Candidate Option */}
<Card
sx={{
flex: 1,
<Card
sx={{
flex: 1,
cursor: 'pointer',
transition: 'all 0.3s ease',
border: '2px solid transparent',
'&:hover': {
transform: 'translateY(-4px)',
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 }}>
<Typography variant="h1" sx={{ mb: 2 }}>👤</Typography>
<Typography variant="h1" sx={{ mb: 2 }}>
👤
</Typography>
<Typography variant="h5" component="h3" sx={{ mb: 1.5 }}>
I'm looking for work
</Typography>
@ -827,22 +850,24 @@ export function RegistrationTypeSelector() {
</Card>
{/* Employer Option */}
<Card
sx={{
flex: 1,
<Card
sx={{
flex: 1,
cursor: 'pointer',
transition: 'all 0.3s ease',
border: '2px solid transparent',
'&:hover': {
transform: 'translateY(-4px)',
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 }}>
<Typography variant="h1" sx={{ mb: 2 }}>🏢</Typography>
<Typography variant="h1" sx={{ mb: 2 }}>
🏢
</Typography>
<Typography variant="h5" component="h3" sx={{ mb: 1.5 }}>
I'm hiring
</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
// };
const SystemInfoComponent: React.FC<{ systemInfo: Types.SystemInfo | undefined }> = ({ systemInfo }) => {
const SystemInfoComponent: React.FC<{
systemInfo: Types.SystemInfo | undefined;
}> = ({ systemInfo }) => {
const [systemElements, setSystemElements] = useState<ReactElement[]>([]);
const convertToSymbols = (text: string) => {
return text
.replace(/\(R\)/g, '®') // Replace (R) with the ® symbol
.replace(/\(C\)/g, '©') // Replace (C) with the © symbol
.replace(/\(R\)/g, '®') // Replace (R) with the ® symbol
.replace(/\(C\)/g, '©') // Replace (C) 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)) {
return v.map((card, index) => (
<div key={index} className="SystemInfoItem">
<div>{convertToSymbols(k)} {index}</div>
<div>{convertToSymbols(card.name)} {card.discrete ? `w/ ${Math.round(card.memory / (1024 * 1024 * 1024))}GB RAM` : "(integrated)"}</div>
<div>
{convertToSymbols(k)} {index}
</div>
<div>
{convertToSymbols(card.name)}{' '}
{card.discrete
? `w/ ${Math.round(card.memory / (1024 * 1024 * 1024))}GB RAM`
: '(integrated)'}
</div>
</div>
));
}
@ -173,14 +182,13 @@ const Settings = (props: BackstoryPageProps) => {
setSystemInfo(response);
} catch (error) {
console.error('Error obtaining system information:', error);
setSnack("Unable to obtain system information.", "error");
};
}
setSnack('Unable to obtain system information.', 'error');
}
};
fetchSystemInfo();
}, [systemInfo, setSystemInfo, setSnack, apiClient]);
// useEffect(() => {
// if (!systemPrompt) {
// return;
@ -284,8 +292,9 @@ const Settings = (props: BackstoryPageProps) => {
// }
// };
return (<div className="Controls">
{/* <Typography component="span" sx={{ mb: 1 }}>
return (
<div className="Controls">
{/* <Typography component="span" sx={{ mb: 1 }}>
You can change the information available to the LLM by adjusting the following settings:
</Typography>
<Accordion>
@ -382,23 +391,20 @@ const Settings = (props: BackstoryPageProps) => {
</AccordionActions>
</Accordion> */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography component="span">System Information</Typography>
</AccordionSummary>
<AccordionDetails>
The server is running on the following hardware:
</AccordionDetails>
<AccordionActions>
<SystemInfoComponent systemInfo={systemInfo} />
</AccordionActions>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography component="span">System Information</Typography>
</AccordionSummary>
<AccordionDetails>The server is running on the following hardware:</AccordionDetails>
<AccordionActions>
<SystemInfoComponent systemInfo={systemInfo} />
</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> */}
</div>);
}
</div>
);
};
export {
Settings
};
export { Settings };

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -10,8 +10,8 @@ export interface NavigationItem {
userTypes?: ('candidate' | 'employer' | 'guest' | 'admin')[];
exact?: boolean;
divider?: boolean;
showInNavigation?: boolean; // Controls if item appears in main navigation
showInUserMenu?: boolean; // Controls if item appears in user menu
showInNavigation?: boolean; // Controls if item appears in main navigation
showInUserMenu?: boolean; // Controls if item appears 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;
};
}
}
}

File diff suppressed because it is too large Load Diff