Compare commits

..

No commits in common. "66b68270cd1861dcc0511c0bddbd6702bb5fab1a" and "54d5df3fac179900e76f4f10e9fc5f492a1e50aa" have entirely different histories.

84 changed files with 8329 additions and 10756 deletions

View File

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

View File

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

View File

@ -1,10 +0,0 @@
{
"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,10 +50,7 @@
"scripts": {
"start": "WDS_SOCKET_HOST=backstory-beta.ketrenos.com WDS_SOCKET_PORT=443 craco start",
"build": "craco build",
"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}"
"test": "craco test"
},
"eslintConfig": {
"extends": [
@ -76,14 +73,6 @@
"devDependencies": {
"@craco/craco": "^7.1.0",
"@types/markdown-it": "^14.1.2",
"@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"
"@types/plotly.js": "^2.35.5"
}
}

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,6 +158,7 @@ button {
font-size: 0.9rem;
}
.Docs.MuiCard-root {
display: flex;
flex-grow: 1;
@ -180,7 +181,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 {
@ -192,7 +193,7 @@ button {
}
.metadata {
border: 1px solid #e0e0e0;
border: 1px solid #E0E0E0;
font-size: 0.75rem;
padding: 0.125rem;
}
@ -219,7 +220,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;
}
@ -238,7 +239,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;
@ -253,4 +254,4 @@ button {
#SystemPromptInput {
font-size: 0.9rem;
line-height: 1.25rem;
}
}

View File

@ -1,8 +1,9 @@
import React, { useEffect, useState, useRef, JSX } from 'react';
import React, { useEffect, useState, useRef, useCallback } 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';
@ -16,38 +17,47 @@ import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
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>('');
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>("");
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 />} />
{/* Static/shared routes */}
<Route path="/*" element={<BackstoryLayout {...{ page, chatRef, 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 {...{ setSnack }} />} />
{/* Static/shared routes */}
<Route
path="/*"
element={
<BackstoryLayout {...{ setSnack, page, chatRef, snackRef, 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 type { BackstoryQueryInterface, ChatSubmitQueryInterface };
export {
BackstoryQuery,
};
export { BackstoryQuery };

View File

@ -7,41 +7,47 @@ 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,11 +1,4 @@
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';
@ -25,134 +18,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,11 +1,4 @@
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';
@ -13,43 +6,25 @@ 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';
@ -59,450 +34,395 @@ 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]);
// 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');
}
};
createChatSession();
}, [chatSession, setChatSession]);
const getChatMessages = useCallback(async () => {
if (!chatSession || !chatSession.id) {
return;
}
useEffect(() => {
if (chatSession) {
return;
}
const createChatSession = async () => {
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');
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]);
};
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (!chatSession) {
setProcessingMessage(loadingMessage);
return;
}
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;
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
getChatMessages();
}, [chatSession]);
const handleEnter = (value: string) => {
const query: ChatQuery = {
prompt: value,
};
processQuery(query);
};
useImperativeHandle(ref, () => ({
submitQuery: (query: ChatQuery) => {
processQuery(query);
},
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();
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);
}
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);
}
},
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 <></>;
} 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");
}
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,
}}
/>
)}
}, [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) => {
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);
}
},
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={{
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 && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
pt: 1,
fontSize: "0.7rem",
color: "darkgrey"
}}
>
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
>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}
/>
{processing === true && countdown > 0 && (
<Box
sx={{
pt: 1,
fontSize: '0.7rem',
color: 'darkgrey',
}}
>
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 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>
);
});
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,32 +35,28 @@ 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,17 +19,8 @@ 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;
@ -56,7 +47,7 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
disabled,
label,
color,
action = 'delete',
action = "delete",
// New props
open: controlledOpen,
onClose: controlledOnClose,
@ -65,7 +56,7 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
message,
hideButton = false,
confirmButtonText,
cancelButtonText = 'Cancel',
cancelButtonText = "Cancel",
sx,
icon = <ResetIcon />,
} = props;
@ -103,32 +94,21 @@ 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}
@ -138,16 +118,20 @@ 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}>
@ -160,6 +144,8 @@ 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,10 +24,16 @@ 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';
@ -49,7 +55,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>('');
@ -57,9 +63,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(() => {
@ -70,10 +76,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');
}
};
@ -81,47 +87,43 @@ 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) {
@ -136,10 +138,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);
@ -157,9 +159,13 @@ 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) {
@ -176,11 +182,15 @@ 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);
@ -196,7 +206,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);
@ -225,158 +235,140 @@ 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)}
/>
{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"
<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)}
/>
}
label={<Typography variant="caption">Include in RAG</Typography>}
{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>
}
/>
</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>
<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 */}
@ -384,15 +376,10 @@ 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={() => {
@ -404,24 +391,22 @@ 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>
@ -430,44 +415,46 @@ 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,10 +36,9 @@ 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>('');
@ -48,7 +47,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);
@ -63,41 +62,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.');
}
};
@ -109,7 +108,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.50',
p: 2,
p: 2
}}
>
<Card sx={{ maxWidth: 500, width: '100%' }}>
@ -152,18 +151,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>
)}
@ -172,7 +171,11 @@ 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>
@ -183,14 +186,18 @@ 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>
@ -199,41 +206,46 @@ 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;
@ -251,116 +263,113 @@ 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>
@ -370,7 +379,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"
@ -384,44 +393,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.');
}
};
@ -429,19 +438,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>
@ -455,7 +464,10 @@ 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>
)}
@ -464,8 +476,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>
@ -475,45 +487,46 @@ 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);
}, [error]);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
}, [error]);
const success = await login({
login: email,
password,
});
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
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 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();
}
};
const handleMFASuccess = (authData: any) => {
handleLoginSuccess();
handleLoginSuccess();
};
const handleLoginSuccess = () => {
@ -533,7 +546,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
/>
@ -542,7 +555,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
@ -552,7 +565,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 />}
@ -562,9 +575,9 @@ const LoginForm = () => {
}}
/>
{errorMessage && (
{errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}>
{errorMessage}
{errorMessage}
</Alert>
)}
@ -572,21 +585,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 = () => {
@ -606,16 +619,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}>
@ -623,16 +636,18 @@ 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
@ -649,12 +664,6 @@ const TrustedDevicesManager = () => {
</CardContent>
</Card>
);
};
}
export {
EmailVerificationPage,
MFAVerificationDialog,
TrustedDevicesManager,
RegistrationSuccessDialog,
LoginForm,
};
export { EmailVerificationPage, MFAVerificationDialog, TrustedDevicesManager, RegistrationSuccessDialog, LoginForm };

View File

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

View File

@ -8,136 +8,128 @@ 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;
// 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 <></>;
}
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 <></>;
}
return (
<Box
className="GenerateImage"
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
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,12 +175,7 @@ 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 (
@ -194,7 +189,13 @@ 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>
@ -213,49 +214,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" />
)}
@ -305,144 +306,134 @@ 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
</Typography>
<UploadBox onClick={handleUploadClick}>
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
Drop your job description here
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>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Supported formats: PDF, DOCX, TXT, MD
</Typography>
<Button
variant="contained"
startIcon={<FileUploadIcon />}
disabled={isProcessing}
<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>
>
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>
<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"
onClick={handleExtractRequirements}
startIcon={<AutoFixHigh />}
disabled={isProcessing}
fullWidth={isMobile}
>
Extract Requirements
</Button>
)}
</Grid>
</Grid>
{(jobStatus || isProcessing) && (
<Box sx={{ mt: 3 }}>
<StatusBox>
{jobStatusType && <StatusIcon type={jobStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{jobStatus || 'Processing...'}
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<Description sx={{ mr: 1 }} />
Or Enter Manually
</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"
variant="outlined"
value={jobTitle}
onChange={e => setJobTitle(e.target.value)}
required
disabled={isProcessing}
InputProps={{
startAdornment: <Work sx={{ mr: 1, color: 'text.secondary' }} />,
}}
/>
<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"
onClick={handleExtractRequirements}
startIcon={<AutoFixHigh />}
disabled={isProcessing}
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"
@ -455,105 +446,83 @@ const JobCreator = (props: JobCreatorProps) => {
}}
/>
</Grid> */}
</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>
</Grid>
</CardContent>
</Card>
)}
{/* Requirements Display */}
{renderJobRequirements()}
</Box>
);
};
{/* 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>
}
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',
{/* 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 */,
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>
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 sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!jobTitle || !company || !jobDescription || isProcessing}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
Save Job
</Button>
</Box>
</Box>
)}
</Box>
);
}
</Box>
);
};
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,26 +15,14 @@ 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';
@ -49,18 +37,12 @@ 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 {
@ -70,11 +52,16 @@ 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);
@ -90,77 +77,49 @@ 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,
}));
@ -170,7 +129,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
setStatusMessage(null);
setLoadingRequirements(false);
setOverallScore(0);
};
}
useEffect(() => {
initializeRequirements(job);
@ -191,7 +150,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 {
@ -201,35 +160,19 @@ 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 = 0;
let matchScore: number = 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 = {
@ -248,9 +191,7 @@ 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;
@ -262,21 +203,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
@ -302,47 +243,30 @@ 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
<Box sx={{
position: 'relative',
display: 'inline-flex',
mr: 2
}}>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{
position: 'relative',
display: 'inline-flex',
mr: 2,
}}
>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{
color: getMatchColor(overallScore),
}}
}}
/>
<Box
sx={{
@ -361,32 +285,22 @@ 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>
@ -402,19 +316,18 @@ 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
@ -422,91 +335,61 @@ 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 }}>
@ -517,53 +400,42 @@ 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"
@ -581,17 +453,20 @@ 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>
@ -603,4 +478,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,12 +46,11 @@ 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(() => {
@ -71,38 +70,28 @@ 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);
@ -131,7 +120,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 }}>
@ -139,21 +128,19 @@ 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' }} />
}}
/>
)}
@ -180,16 +167,14 @@ 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"}
/>
)}
/>
@ -203,17 +188,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' }} />
}}
/>
)}
@ -266,7 +251,14 @@ 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>
)}
@ -295,18 +287,22 @@ 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 }}>
@ -314,7 +310,7 @@ const LocationInputDemo: React.FC = () => {
control={
<Checkbox
checked={showAdvanced}
onChange={e => setShowAdvanced(e.target.checked)}
onChange={(e) => setShowAdvanced(e.target.checked)}
color="primary"
/>
}
@ -340,24 +336,21 @@ 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>
@ -365,4 +358,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,29 +38,25 @@ 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,19 +32,9 @@ 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),
@ -173,11 +163,9 @@ 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 />,
@ -189,23 +177,23 @@ const getIcon = (
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 {
@ -219,255 +207,162 @@ const MessageMeta = (props: MessageMetaProps) => {
} = props.metadata || {};
const message: any = props.messageProps.message;
let llm_submission = '<|system|>\n';
llm_submission += message.system_prompt + '\n\n';
llm_submission += message.context_prompt;
let llm_submission: string = "<|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}
</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>
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>;
};
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 = () => {
@ -475,48 +370,32 @@ 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
@ -524,8 +403,7 @@ const Message = (props: MessageProps) => {
expand={metaExpanded}
onClick={handleMetaExpandClick}
aria-expanded={true /*message.expanded*/}
aria-label="show more"
>
aria-label="show more">
<ExpandMoreIcon />
</ExpandMore>
</Box>
@ -534,26 +412,16 @@ 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
@ -563,11 +431,8 @@ 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={{
@ -575,27 +440,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 type { MessageProps };
export {
Message,
MessageMeta,
};
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,21 +144,35 @@ 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,6 +1,14 @@
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';
@ -23,12 +31,7 @@ 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) => {
@ -46,7 +49,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);
};
}
useEffect(() => {
if (!job || !candidate || !skills || generated) {
@ -55,8 +58,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) => {
@ -68,7 +71,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
onStreaming: (chunk: Types.ChatMessageStreaming) => {
if (status === '') {
setStatus('Generating resume...');
setStatusType('generating');
setStatusType("generating");
}
setResume(chunk.content);
},
@ -89,17 +92,13 @@ 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!');
@ -123,61 +122,45 @@ 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,27 +14,14 @@ 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',
@ -53,4 +40,4 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => {
);
});
export { useAutoScrollToBottom, Scrollable };
export { useAutoScrollToBottom, Scrollable };

View File

@ -10,37 +10,40 @@ 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;
}
@ -50,19 +53,28 @@ const Snack = forwardRef<SnackHandle, SnackProps>(({ className, sx }: 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,90 +17,66 @@ 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 {
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',
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>
}
}}
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>
);
/>
</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: {
@ -108,7 +84,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();
@ -117,15 +93,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 }) => {
@ -133,20 +109,16 @@ 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) {
@ -154,31 +126,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,14 +105,16 @@ 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
@ -130,9 +132,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[] => {
@ -153,31 +155,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) => {
@ -197,8 +199,7 @@ 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) */
@ -214,18 +215,12 @@ 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();
});
@ -243,12 +238,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;
@ -256,13 +251,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 || []],
};
const is2D = full.embeddings.every((v: number[]) => v.length === 2);
const is3D = full.embeddings.every((v: number[]) => v.length === 3);
let is2D = full.embeddings.every((v: number[]) => v.length === 2);
let is3D = full.embeddings.every((v: number[]) => v.length === 3);
if ((view2D && !is2D) || (!view2D && !is3D)) {
return;
}
@ -272,7 +267,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
return;
}
const query: Types.ChromaDBGetResponse = {
let query: Types.ChromaDBGetResponse = {
ids: [],
documents: [],
embeddings: [],
@ -281,9 +276,9 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
query: '',
size: 0,
dimensions: 2,
name: '',
name: ''
};
const filtered: Types.ChromaDBGetResponse = {
let filtered: Types.ChromaDBGetResponse = {
ids: [],
documents: [],
embeddings: [],
@ -292,13 +287,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 */
@ -306,9 +301,7 @@ 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]);
@ -325,84 +318,63 @@ 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,
},
text: filtered.ids,
customdata: filtered.metadatas,
type: is3D ? 'scatter3d' : 'scatter',
hovertemplate: '&nbsp;',
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;',
}, {
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;
@ -410,6 +382,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
}
setPlotData(data);
}, [result, querySet, view2D]);
const handleKeyPress = (event: any) => {
@ -427,55 +400,36 @@ 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) => {
@ -486,23 +440,21 @@ 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;
}
@ -511,7 +463,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
content: `Loading...`,
...metadata,
emoji: emojiMap[metadata.docType] || '❓',
};
}
setNode(node);
@ -519,173 +471,95 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
};
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',
}}
layout={{
...layout,
width: plotDimensions.width,
height: plotDimensions.height,
width: "100%",
height: "100%",
overflow: "hidden",
}}
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={{
@ -695,104 +569,51 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
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>
);
@ -800,4 +621,6 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
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,10 +10,13 @@ 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
@ -34,39 +37,35 @@ 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>
@ -80,7 +79,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();
@ -100,59 +99,61 @@ 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>
@ -169,10 +170,12 @@ 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>
@ -180,4 +183,6 @@ 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,21 +22,25 @@ 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,9 +79,8 @@ 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
@ -123,11 +122,9 @@ 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>
@ -166,46 +163,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>
@ -218,7 +215,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>
@ -231,35 +228,25 @@ 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>
@ -267,4 +254,6 @@ 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,22 +126,24 @@ 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);
@ -170,7 +172,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'
});
}
});
@ -181,8 +183,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: 'divider',
label: '',
icon: null,
action: () => {},
group: 'divider',
action: () => { },
group: 'divider'
});
}
@ -194,7 +196,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'
});
}
});
@ -205,8 +207,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: 'divider',
label: '',
icon: null,
action: () => {},
group: 'divider',
action: () => { },
group: 'divider'
});
}
@ -218,7 +220,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'
});
}
});
@ -229,8 +231,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: 'divider',
label: '',
icon: null,
action: () => {},
group: 'divider',
action: () => { },
group: 'divider'
});
}
@ -245,7 +247,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
logout();
navigate('/');
},
group: 'system',
group: 'system'
});
} else if (!item.divider) {
items.push({
@ -253,7 +255,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'
});
}
});
@ -266,7 +268,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'
});
}
});
@ -317,13 +319,7 @@ 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();
@ -341,28 +337,18 @@ 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',
@ -386,12 +372,7 @@ 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>
@ -408,7 +389,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>}
@ -423,7 +404,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
// Render mobile accordion navigation
const renderMobileNavigation = () => {
const renderNavigationItem = (item: NavigationItem, depth = 0) => {
const renderNavigationItem = (item: NavigationItem, depth: number = 0) => {
const hasChildren = item.children && item.children.length > 0;
const isActive = isCurrentPath(item) || hasActiveChild(item);
const isExpanded = mobileExpanded[item.id];
@ -451,18 +432,24 @@ 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>
@ -479,7 +466,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>
@ -497,18 +484,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>
);
@ -521,7 +508,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,
@ -540,16 +527,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>
@ -579,12 +566,14 @@ 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>
@ -619,7 +608,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"
/>
@ -641,13 +630,11 @@ 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,23 +6,28 @@ import './AIBanner.css';
import { useMediaQuery, useTheme } from '@mui/material';
type AIBannerProps = {
sx?: SxProps;
variant?: 'minimal' | 'small' | 'normal' | undefined;
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>
);
};
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 };
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,51 +6,47 @@ import { SxProps, useTheme } from '@mui/material/styles';
import './Beta.css';
type BetaProps = {
adaptive?: boolean;
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
sx?: SxProps;
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>
);
};
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 };
export {
Beta
};

View File

@ -1,10 +1,15 @@
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';
@ -15,16 +20,21 @@ 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
@ -45,40 +55,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 }}>
@ -87,42 +97,34 @@ 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>
@ -130,8 +132,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"
@ -143,7 +145,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}
@ -152,7 +154,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
<Link
component="button"
variant="body2"
onClick={e => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
@ -167,61 +169,48 @@ 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,97 +1,87 @@
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;
}
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);
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]);
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>
);
onSelect?: (candidate: Candidate) => void;
};
export { CandidatePicker };
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);
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]);
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
};

View File

@ -7,17 +7,19 @@ 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>
);
};
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 };
export {
ComingSoon
};

View File

@ -1,26 +1,15 @@
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,
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 { 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';
@ -30,7 +19,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';
@ -39,22 +28,25 @@ 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);
@ -80,11 +72,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>;
@ -96,12 +88,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);
@ -109,7 +101,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;
@ -124,37 +116,22 @@ 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) => (
@ -175,7 +152,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" />}
@ -183,49 +160,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" />
)}
@ -236,239 +213,165 @@ 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"
<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);
}}
sx={{
display: '-webkit-box',
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.5,
fontSize: '0.8rem !important',
color: theme.palette.primary.main,
textDecoration: 'none',
cursor: 'pointer',
fontSize: '0.725rem',
fontWeight: 500,
mt: 0.5,
display: 'block',
'&:hover': {
textDecoration: 'underline',
}
}}
>
{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>
[{isDescriptionExpanded ? "less" : "more"}]
</Link>
)}
</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()}
{(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">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); handleSave(); }}
>
<SaveIcon />
</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>
)}
{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();
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>
{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>
{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>
}
</Box>
}
</Box >
</Box >
);
};

View File

@ -1,89 +1,78 @@
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;
}
const JobPicker = (props: JobPickerProps) => {
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);
}
};
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>
);
onSelect?: (job: Job) => void
};
export { JobPicker };
const JobPicker = (props: JobPickerProps) => {
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);
}
};
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>
);
};
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,482 +42,462 @@ 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;
}
}
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);
}
// 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;
});
};
getJobs();
}, [apiClient, setSnack]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('desc');
}
};
const sortJobs = (jobsList: Job[], field: SortField, order: SortOrder): Job[] => {
return [...jobsList].sort((a, b) => {
let aValue: any;
let bValue: any;
const handleJobSelect = (job: Job) => {
setSelectedJob(job);
onSelect?.(job);
setMobileDialogOpen(true);
navigate(`/candidate/jobs/${job.id}`);
};
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 handleMobileDialogClose = () => {
setMobileDialogOpen(false);
};
if (aValue < bValue) return order === 'asc' ? -1 : 1;
if (aValue > bValue) return order === 'asc' ? 1 : -1;
return 0;
});
};
const sortedJobs = sortJobs(jobs, sortField, sortOrder);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('desc');
}
};
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 handleJobSelect = (job: Job) => {
setSelectedJob(job);
onSelect?.(job);
setMobileDialogOpen(true);
navigate(`/candidate/jobs/${job.id}`);
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return null;
return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />;
};
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 }}
>
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);
const JobList = () => (
<Paper
elevation={isMobile ? 0 : 1}
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
boxShadow: 'none',
backgroundColor: 'transparent'
}}
>
<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>
</Box>
)}
</Box>
);
<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>
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>
<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>
</Toolbar>
</AppBar>
<JobDetails inDialog />
</Dialog>
</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>
</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,33 +1,31 @@
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,17 +2,21 @@ 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,32 +57,34 @@ 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) {
@ -98,10 +100,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) {
@ -118,12 +120,8 @@ 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.');
@ -148,57 +146,43 @@ 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}>
@ -213,17 +197,10 @@ 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
@ -236,7 +213,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 }}>
@ -252,7 +229,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>
@ -263,7 +240,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" />}
@ -286,18 +263,12 @@ 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,
@ -308,8 +279,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"
@ -318,7 +289,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>
)}
@ -332,59 +303,41 @@ 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 />
@ -396,142 +349,127 @@ 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%',
<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>
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 />}
>
@ -543,4 +481,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,10 +89,12 @@ 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>
@ -107,28 +109,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",
@ -136,89 +138,93 @@ 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'],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: 'profile',
},
{
id: 'explore-resumes',
label: 'Resumes',
path: '/candidate/resumes/:resumeId?',
icon: <EditDocumentIcon />,
component: <ResumeViewer />,
userTypes: ['candidate', 'guest', 'employer'],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: 'profile',
},
{
id: 'candidate-docs',
label: 'Content',
icon: <BubbleChart />,
path: '/candidate/documents',
component: (
<Box sx={{ display: 'flex', width: '100%', flexDirection: 'column' }}>
<JobViewer />
),
userTypes: ["candidate", "guest", "employer"],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: "profile",
},
{
id: "explore-resumes",
label: "Resumes",
path: "/candidate/resumes/:resumeId?",
icon: <EditDocumentIcon />,
component: (
<ResumeViewer />
),
userTypes: ["candidate", "guest", "employer"],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: "profile",
},
{
id: "candidate-docs",
label: "Content",
icon: <BubbleChart />,
path: "/candidate/documents",
component: (
<Box sx={{ display: "flex", width: "100%", flexDirection: "column" }}>
<VectorVisualizer />
<DocumentManager />
</Box>
),
userTypes: ['candidate'],
userMenuGroup: 'profile',
userTypes: ["candidate"],
userMenuGroup: "profile",
showInNavigation: false,
showInUserMenu: true,
},
@ -265,65 +271,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,
},
],
@ -331,45 +337,39 @@ 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,28 +386,25 @@ 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);
@ -425,7 +422,7 @@ export const getUserMenuItems = (
};
export const getUserMenuItemsByGroup = (
userType: 'candidate' | 'employer' | 'guest' | null,
userType: "candidate" | "employer" | "guest" | null,
isAdmin: boolean
): { [key: string]: NavigationItem[] } => {
const menuItems = getUserMenuItems(userType, isAdmin);
@ -437,8 +434,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,14 +12,13 @@ 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 }}>
@ -28,15 +27,14 @@ 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>
@ -44,39 +42,34 @@ 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>
@ -87,32 +80,27 @@ 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>
@ -130,20 +118,17 @@ 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>
@ -154,38 +139,32 @@ 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>
@ -194,8 +173,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 }}>
@ -208,26 +187,22 @@ 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>
@ -243,20 +218,17 @@ 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>
@ -267,32 +239,27 @@ 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>
@ -326,9 +293,7 @@ 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>
@ -337,4 +302,7 @@ const BackstoryAppAnalysisPage = () => {
);
};
export { BackstoryAppAnalysisPage };
export {
BackstoryAppAnalysisPage
}

View File

@ -6,10 +6,9 @@ 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>
@ -20,91 +19,71 @@ 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
<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
</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
</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>
<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,
@ -112,245 +91,112 @@ 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 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 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>
<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 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>
</Paper>
</Container>
</Box>
</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>
);
};
export { BackstoryThemeVisualizerPage };
export {
BackstoryThemeVisualizerPage
};

View File

@ -7,48 +7,31 @@ 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 }}>
@ -56,39 +39,27 @@ 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 }}>
@ -96,17 +67,10 @@ 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>
))}
@ -114,73 +78,37 @@ 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">
@ -191,72 +119,49 @@ 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}
@ -267,105 +172,86 @@ 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>
@ -373,146 +259,91 @@ 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',
}}
>
<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,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.875rem' }}>
BACKSTORY
</Typography>
<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>
@ -525,4 +356,6 @@ const BackstoryUIOverviewPage: React.FC = () => {
);
};
export { BackstoryUIOverviewPage };
export {
BackstoryUIOverviewPage
};

View File

@ -1,36 +1,15 @@
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 {
@ -43,74 +22,37 @@ 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>
)}
@ -129,25 +71,12 @@ 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 }}>
@ -159,11 +88,7 @@ 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" />
@ -175,9 +100,7 @@ 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 }}>
@ -205,22 +128,16 @@ 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>
@ -235,21 +152,12 @@ 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)}>
@ -274,9 +182,7 @@ 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)}>
@ -308,21 +214,18 @@ 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" />
@ -330,12 +233,11 @@ 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
@ -350,22 +252,24 @@ 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>
@ -375,46 +279,21 @@ 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" />
@ -424,17 +303,8 @@ 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>
@ -450,18 +320,13 @@ 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>
@ -469,9 +334,7 @@ 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" />
@ -481,16 +344,10 @@ 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>
@ -503,7 +360,7 @@ const MockupPage = () => {
borderStyle: 'dashed',
p: 1.5,
color: 'text.secondary',
'&:hover': { bgcolor: 'background.default' },
'&:hover': { bgcolor: 'background.default' }
}}
startIcon={<PlusIcon />}
>
@ -511,19 +368,16 @@ 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>
@ -540,20 +394,15 @@ 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">
@ -568,11 +417,10 @@ 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>
@ -592,8 +440,7 @@ 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>
@ -614,7 +461,7 @@ const MockupPage = () => {
justifyContent: 'space-around',
borderTop: 1,
borderColor: 'divider',
zIndex: 1100,
zIndex: 1100
}}
elevation={3}
>
@ -625,7 +472,7 @@ const MockupPage = () => {
alignItems: 'center',
py: 1,
px: 2,
color: 'text.secondary',
color: 'text.secondary'
}}
component="button"
>
@ -639,7 +486,7 @@ const MockupPage = () => {
alignItems: 'center',
py: 1,
px: 2,
color: 'primary.main',
color: 'primary.main'
}}
component="button"
>
@ -653,7 +500,7 @@ const MockupPage = () => {
alignItems: 'center',
py: 1,
px: 2,
color: 'text.secondary',
color: 'text.secondary'
}}
component="button"
>
@ -664,6 +511,8 @@ 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,22 +200,16 @@ 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"
/>
@ -229,24 +223,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)}
>
@ -259,7 +253,7 @@ const UserManagement: React.FC = () => {
</Table>
</TableContainer>
</Paper>
{/* User Details Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
{selectedUser && (
@ -270,7 +264,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"
@ -294,11 +288,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 }}
@ -309,7 +303,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"
@ -333,7 +327,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"
@ -359,17 +353,19 @@ 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
@ -382,32 +378,40 @@ 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"
@ -417,7 +421,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"
@ -426,7 +430,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"
@ -437,24 +441,20 @@ 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,9 +496,7 @@ 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>
</>
)}
@ -507,4 +505,4 @@ const UserManagement: React.FC = () => {
);
};
export { UserManagement };
export { UserManagement };

View File

@ -2,12 +2,7 @@
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';
// ============================
@ -53,7 +48,7 @@ const TOKEN_STORAGE = {
TOKEN_EXPIRY: 'tokenExpiry',
USER_TYPE: 'userType',
IS_GUEST: 'isGuest',
PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail',
PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail'
} as const;
// ============================
@ -82,13 +77,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);
}
// ============================
@ -118,10 +113,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);
@ -137,7 +132,7 @@ function updateStoredUserData(user: Types.User): void {
}
}
function storeAuthData(authResponse: Types.AuthResponse, isGuest = false): void {
function storeAuthData(authResponse: Types.AuthResponse, isGuest: boolean = 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));
@ -160,10 +155,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);
@ -175,14 +170,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'
};
}
@ -207,18 +202,15 @@ 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> => {
@ -294,7 +286,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) {
@ -314,25 +306,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...');
@ -344,18 +336,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) {
@ -386,7 +378,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();
@ -402,147 +394,131 @@ 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,
}));
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,
});
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.';
if ('mfaRequired' in result) {
// MFA required for new device
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
mfaResponse: null,
mfaResponse: result,
}));
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');
}
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const result = await apiClient.convertGuestToUser(registrationData);
// Store new authentication
storeAuthData(result.auth, false);
apiClient.setAuthToken(result.auth.accessToken);
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: result.auth.user,
user: authResponse.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log('✅ Guest successfully converted to permanent user');
console.log('✅ Login successful, converted from guest to authenticated 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]
);
} 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');
}
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
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 }));
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);
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,
user: authResponse.user,
guest: null,
isAuthenticated: true,
isGuest: false,
isLoading: false,
error: errorMessage,
error: null,
mfaResponse: null,
}));
return false;
console.log('✅ MFA verification successful, converted from guest');
return true;
}
},
[apiClient]
);
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 () => {
@ -574,66 +550,56 @@ 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]
);
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 }));
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]
);
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);
@ -643,52 +609,45 @@ 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 }));
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);
try {
const employer = await apiClient.createEmployer(employerData);
console.log('✅ Employer created:', employer);
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]);
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 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();
@ -697,24 +656,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();
@ -723,39 +682,36 @@ 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
}));
}, []);
@ -776,7 +732,7 @@ function useAuthenticationLogic() {
refreshAuth,
updateUserData,
convertGuestToUser,
createGuestSession,
createGuestSession
};
}
@ -788,8 +744,12 @@ 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() {
@ -811,24 +771,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>;
@ -838,7 +798,7 @@ function ProtectedRoute({
if (requiredUserType && !isGuest && user?.userType !== requiredUserType) {
return <div>Access denied. Required user type: {requiredUserType}</div>;
}
return <>{children}</>;
}
@ -848,9 +808,14 @@ 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,6 +227,7 @@ 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 {
@ -351,7 +352,7 @@ export function useAppStateLogic(): AppStateContextType {
lastRoute: null,
activeTab: null,
appliedFilters: {},
sidebarCollapsed: false,
sidebarCollapsed: false
};
setRouteStateState(clearedState);
@ -387,7 +388,7 @@ export function useAppStateLogic(): AppStateContextType {
setActiveTab,
setFilters,
setSidebarCollapsed,
clearRouteState,
clearRouteState
};
}
@ -402,13 +403,10 @@ 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}
@ -456,7 +454,7 @@ export function useRouteState() {
setFilters,
setSidebarCollapsed,
restoreLastRoute,
clearRouteState,
clearRouteState
} = useAppState();
return {
@ -465,11 +463,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 = false;
const debug: boolean = 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 = 0;
let lastCall: number = 0;
const debounced = function (...args: Parameters<T>) {
const now = Date.now();
@ -68,12 +68,8 @@ 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);
@ -106,8 +102,8 @@ const useResizeObserverAndMutationObserver = (
*/
const useAutoScrollToBottom = (
scrollToRef: RefObject<HTMLElement | null>,
smooth = true,
fallbackThreshold = 0.33,
smooth: boolean = true,
fallbackThreshold: number = 0.33,
contentUpdateTrigger?: any
): RefObject<HTMLDivElement | null> => {
const containerRef = useRef<HTMLDivElement | null>(null);
@ -115,72 +111,65 @@ const useAutoScrollToBottom = (
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
const isUserScrollingUpRef = useRef(false);
const checkAndScrollToBottom = useCallback(
(isPasteEvent = false) => {
const container = containerRef.current;
if (!container) return;
const checkAndScrollToBottom = useCallback((isPasteEvent: boolean = 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 (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;
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;
// 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',
});
// 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,
});
}
} 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',
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
}
});
}
},
[fallbackThreshold, smooth, scrollToRef]
);
} 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]);
useEffect(() => {
const container = containerRef.current;
@ -189,38 +178,34 @@ 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);
};
@ -251,4 +236,7 @@ const useAutoScrollToBottom = (
return containerRef;
};
export { useResizeObserverAndMutationObserver, useAutoScrollToBottom };
export {
useResizeObserverAndMutationObserver,
useAutoScrollToBottom
}

View File

@ -2,13 +2,15 @@ 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,28 +38,20 @@ 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) => ({
@ -71,14 +63,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);
}, []);
@ -94,7 +86,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
<Box
sx={{
minHeight: '100%',
width: '100%',
width: "100%",
position: 'relative',
overflow: 'hidden',
bgcolor: theme.palette.background.default,
@ -103,18 +95,8 @@ 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={{
@ -125,10 +107,7 @@ 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`,
}}
/>
@ -137,7 +116,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"
@ -151,13 +130,18 @@ 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={{
@ -187,19 +171,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!
@ -209,23 +193,9 @@ 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
@ -240,7 +210,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}
@ -280,10 +250,7 @@ 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': {
@ -303,4 +270,6 @@ const BetaPage: React.FC<BetaPageProps> = ({
);
};
export { BetaPage };
export {
BetaPage
}

View File

@ -1,15 +1,18 @@
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 { useAuth } from 'hooks/AuthContext';
import {
ChatMessage,
ChatSession,
ChatMessageUser,
ChatMessageError,
ChatMessageStreaming,
ChatMessageStatus,
} from 'types/types';
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 { ConversationHandle } from 'components/Conversation';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { Message } from 'components/Message';
@ -24,251 +27,206 @@ 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);
}
};
const onDelete = async (session: ChatSession) => {
if (!session.id) {
return;
}
try {
await apiClient.resetChatSession(session.id);
// If we're deleting the currently selected session, clear it
setMessages([]);
setSnack('Session reset succeeded', 'success');
} catch (error) {
console.error('Failed to delete session:', error);
setSnack('Failed to delete session', 'error');
}
};
// Send message
const sendMessage = async (message: string) => {
if (!message.trim() || !chatSession?.id || streaming || !selectedCandidate) return;
const messageContent = message;
setStreaming(true);
const chatMessage: ChatMessageUser = {
sessionId: chatSession.id,
role: 'user',
content: messageContent,
status: 'done',
type: 'text',
timestamp: new Date(),
};
setProcessingMessage({
...defaultMessage,
status: 'status',
activity: 'info',
content: `Establishing connection with ${selectedCandidate.firstName}'s chat session.`,
});
setMessages(prev => {
const filtered = prev.filter((m: any) => m.id !== chatMessage.id);
return [...filtered, chatMessage] as any;
});
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) {
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 />;
// 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);
}
};
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,
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()
};
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 }} />}
setProcessingMessage({ ...defaultMessage, status: 'status', activity: "info", content: `Establishing connection with ${selectedCandidate.firstName}'s chat session.` });
setMessages(prev => {
const filtered = prev.filter((m: any) => m.id !== chatMessage.id);
return [...filtered, chatMessage] as any;
});
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) {
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 />;
}
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, }} />}
{messages.map((message: ChatMessage) => (
<Message key={message.id} {...{ chatSession, message }} />
))}
@ -279,15 +237,13 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
<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}
@ -297,54 +253,42 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
</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>
</Box>
</Box>
);
}
);
</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 />
export { CandidateChatPage };
</Button>
</span>
</Tooltip>
</Box>
</Box>
);
});
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,50 +61,48 @@ 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
@ -112,7 +110,7 @@ const Sidebar: React.FC<{
slotProps={{
primary: {
fontWeight: currentPage === doc.route ? 'medium' : 'regular',
},
}
}}
/>
</ListItemButton>
@ -130,7 +128,7 @@ const getDocumentIcon = (title: string): React.ReactNode => {
throw Error(`${title} does not exist in documents`);
}
return item.icon || <ViewQuiltIcon />;
};
}
type DocType = {
title: string;
@ -139,90 +137,26 @@ 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];
};
@ -231,10 +165,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();
@ -266,10 +200,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;
}
@ -296,8 +230,8 @@ const DocsPage = (props: BackstoryPageProps) => {
};
interface DocViewProps {
page: string;
}
page: string
};
const DocView = (props: DocViewProps) => {
const { page = 'about' } = props;
const title = documentTitleFromRoute(page);
@ -306,22 +240,13 @@ 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>
);
@ -331,22 +256,18 @@ 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 (
@ -356,50 +277,28 @@ 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>
@ -419,17 +318,22 @@ 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>
@ -440,7 +344,7 @@ const DocsPage = (props: BackstoryPageProps) => {
component="nav"
sx={{
width: { md: drawerWidth },
flexShrink: { md: 0 },
flexShrink: { md: 0 }
}}
>
{/* Mobile drawer (temporary) */}
@ -456,7 +360,7 @@ const DocsPage = (props: BackstoryPageProps) => {
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: drawerWidth,
width: drawerWidth
},
}}
>
@ -477,12 +381,16 @@ 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>
@ -496,7 +404,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()}
@ -505,4 +413,4 @@ const DocsPage = (props: BackstoryPageProps) => {
);
};
export { DocsPage };
export { DocsPage };

View File

@ -1,10 +1,12 @@
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,375 +17,298 @@ 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;
}
/* 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]);
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 cancelQuery = useCallback(() => {
if (controllerRef.current) {
controllerRef.current.cancel();
controllerRef.current = null;
setProcessing(false);
}
}, []);
const onEnter = useCallback(
(value: string) => {
if (processing) {
return;
}
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(),
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");
}
};
setPrompt(prompt || '');
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);
setProcessingMessage({
...defaultMessage,
content: 'Generating persona...',
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}`);
},
});
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');
}
};
}, [chatSession, shouldGenerateProfile, generatedUser, prompt, setSnack, apiClient]);
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 (!user?.isAdmin) {
return (<Box>You must be logged in as an admin to generate AI candidates.</Box>);
}
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,
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}
/>
)}
}}>
{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>
<Tooltip title={`${generatedUser?.profileImage ? 'Re-' : ''}Generate Picture`}>
<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, justifySelf: "flex-start", alignSelf: "center", flexGrow: 0, maxHeight: "min-content" }}
variant="contained"
disabled={
processing || !canGenImage
}
onClick={() => { setShouldGenerateProfile(true); }}>
{generatedUser?.profileImage ? 'Re-' : ''}Generate Picture<SendIcon />
</Button>
</span>
</Tooltip>
</Box>
</Box>
{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,
justifySelf: 'flex-start',
alignSelf: 'center',
flexGrow: 0,
maxHeight: 'min-content',
}}
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={processing || !canGenImage}
onClick={() => {
setShouldGenerateProfile(true);
}}
>
{generatedUser?.profileImage ? 'Re-' : ''}Generate Picture
<SendIcon />
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>
{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>
);
<Box sx={{display: "flex", flexGrow: 1}}/>
</Box>);
};
export { GenerateCandidate };
export {
GenerateCandidate
};

View File

@ -65,12 +65,11 @@ 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;
@ -85,12 +84,10 @@ 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,
@ -109,7 +106,7 @@ const FeatureIcon = styled(Box)(({ theme }) => ({
const FeatureCard = ({
icon,
title,
description,
description
}: {
icon: React.ReactNode;
title: string;
@ -143,27 +140,24 @@ 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"
@ -172,17 +166,19 @@ 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
@ -191,19 +187,14 @@ 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}
@ -233,42 +224,33 @@ 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">
@ -277,22 +259,20 @@ 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">
@ -301,22 +281,20 @@ 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">
@ -325,112 +303,105 @@ 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 />}
>
Create Your Profile
Start Recruiting
</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
</ActionButton>
</Box>
</ComingSoon>
</ComingSoon>
</Box>
</Container>
@ -448,16 +419,7 @@ 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>
@ -468,16 +430,7 @@ 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>
@ -488,16 +441,7 @@ 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>
@ -508,16 +452,7 @@ 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>
@ -533,52 +468,59 @@ 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
@ -587,7 +529,7 @@ const HomePage = () => {
sx={{
backgroundColor: 'transparent',
border: '2px solid',
borderColor: 'action.active',
borderColor: 'action.active'
}}
>
Sign Up as Employer
@ -596,8 +538,11 @@ 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,255 +37,232 @@ 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={{
@ -296,243 +273,218 @@ 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>
</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>
{/* 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>
</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,12 +16,15 @@ 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';
@ -40,12 +43,10 @@ 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 }} />
@ -70,7 +71,7 @@ interface AnalysisState {
candidate: Candidate | null;
analysis: SkillAssessment[] | null;
resume: string | null;
}
};
interface Step {
index: number;
@ -78,7 +79,7 @@ interface Step {
requiredState: string[];
title: string;
icon: React.ReactNode;
}
};
const initialState: AnalysisState = {
job: null,
@ -91,23 +92,13 @@ 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) => {
@ -124,30 +115,23 @@ 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;
@ -169,7 +153,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: 0,
behavior: 'smooth',
behavior: "smooth",
});
}
}, [setCanAdvance, analysisState, activeStep]);
@ -183,9 +167,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]);
}
};
@ -194,7 +178,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) => {
@ -204,7 +188,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
return;
}
setActiveStep(steps[step]);
};
}
const onCandidateSelect = (candidate: Candidate) => {
if (!analysisState) {
@ -214,7 +198,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setAnalysisState({ ...analysisState });
setSelectedCandidate(candidate);
handleNext();
};
}
const onJobSelect = (job: Job) => {
if (!analysisState) {
@ -224,7 +208,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setAnalysisState({ ...analysisState });
setSelectedJob(job);
handleNext();
};
}
// Render function for the candidate selection step
const renderCandidateSelection = () => (
@ -237,25 +221,28 @@ 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>
{jobTab === 'select' && <JobPicker onSelect={onJobSelect} />}
{jobTab === 'create' && user && <JobCreator onSave={onJobSelect} />}
{jobTab === 'create' && guest && (
<LoginRestricted>
<JobCreator onSave={onJobSelect} />
</LoginRestricted>
)}
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>
);
};
}
const onAnalysisComplete = (skills: SkillAssessment[]) => {
if (!analysisState) {
@ -271,25 +258,16 @@ 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 = () => {
@ -300,56 +278,43 @@ 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}
@ -357,92 +322,82 @@ 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,28 +4,21 @@ 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,7 +12,10 @@ 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';
@ -21,8 +24,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';
@ -34,12 +37,11 @@ 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 = false;
const showGuest: boolean = false;
const { tab } = useParams();
useEffect(() => {
@ -51,7 +53,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);
@ -73,53 +75,57 @@ 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,55 +13,51 @@ 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;
// 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]);
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 <></>;
return;
}
return (
<Box>
<CandidateInfo candidate={candidate} action="Chat with Backstory AI about " />
<Conversation
ref={ref}
{...{
multiline: true,
type: 'chat',
placeholder: `What would you like to know about ${candidate?.firstName}?`,
resetLabel: 'chat',
defaultPrompts: questions,
}}
/>
</Box>
);
}
);
export { ChatPage };
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>
<CandidateInfo candidate={candidate} action="Chat with Backstory AI about " />
<Conversation
ref={ref}
{...{
multiline: true,
type: "chat",
placeholder: `What would you like to know about ${candidate?.firstName}?`,
resetLabel: "chat",
defaultPrompts: questions,
}} />
</Box>);
});
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,4 +16,6 @@ const VectorVisualizerPage: React.FC<VectorVisualizerProps> = (props: VectorVisu
export type { VectorVisualizerProps };
export { VectorVisualizerPage };
export {
VectorVisualizerPage
};

View File

@ -1,5 +1,13 @@
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,
@ -15,7 +23,8 @@ import { useNavigate } from 'react-router-dom';
import { ComingSoon } from 'components/ui/ComingSoon';
import { useAppState } from 'hooks/GlobalContext';
type CandidateDashboardProps = BackstoryElementProps;
interface CandidateDashboardProps extends BackstoryElementProps {
};
const CandidateDashboard = (props: CandidateDashboardProps) => {
const { setSnack } = useAppState();
@ -24,170 +33,178 @@ 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}!
<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
</Typography>
<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');
<LinearProgress
variant="determinate"
value={profileCompletion}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: '#e0e0e0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#4caf50',
},
}}
>
Complete Your Profile
</Button>
/>
</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>
</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
{/* 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={{ mb: 1, color: '#666' }}>
3 custom resumes
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Detail skills
</Typography>
<Typography variant="body2" sx={{ mb: 3, color: '#666' }}>
Last created: May 15, 2025
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Work history
</Typography>
</Stack>
<Button
variant="outlined"
startIcon={<EditIcon />}
fullWidth
>
Edit Backstory
</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
{/* 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>
<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 variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Enhance your project details
</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>
</Stack>
<Button
variant="outlined"
startIcon={<TipsIcon />}
fullWidth
>
View All Tips
</Button>
</CardContent>
</Card>
</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,22 +49,18 @@ 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: '',
@ -91,7 +87,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());
}
@ -107,7 +103,7 @@ const CreateProfilePage: React.FC = () => {
setSnackbar({
open: true,
message: `Resume uploaded: ${e.target.files[0].name}`,
severity: 'success',
severity: 'success'
});
}
};
@ -117,27 +113,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);
@ -147,11 +143,9 @@ 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:
@ -167,33 +161,35 @@ 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
@ -204,7 +200,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Grid size={{xs: 12, sm: 6}}>
<TextField
required
fullWidth
@ -215,7 +211,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Grid size={{xs: 12}}>
<TextField
required
fullWidth
@ -227,7 +223,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Grid size={{xs:12}}>
<TextField
fullWidth
label="Phone Number"
@ -242,7 +238,7 @@ const CreateProfilePage: React.FC = () => {
case 1:
return (
<Grid container spacing={3}>
<Grid size={{ xs: 12 }}>
<Grid size={{xs:12}}>
<TextField
required
fullWidth
@ -253,7 +249,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Grid size={{xs: 12}}>
<TextField
fullWidth
label="Location"
@ -264,7 +260,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Grid size={{xs:12}}>
<TextField
fullWidth
multiline
@ -282,10 +278,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
@ -295,13 +291,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}
@ -318,35 +314,41 @@ 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
@ -365,8 +367,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%' }}
>
@ -377,4 +379,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,14 +150,15 @@ 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')) {
@ -166,9 +167,7 @@ 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);
@ -181,11 +180,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 };
@ -210,7 +209,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}
@ -221,7 +220,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}
@ -233,7 +232,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}
@ -243,7 +242,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}
@ -256,10 +255,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>
@ -268,7 +267,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}
@ -279,7 +278,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 />}
@ -296,11 +295,7 @@ 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>
@ -312,7 +307,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}
@ -323,7 +318,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 />}
@ -333,7 +328,11 @@ const CandidateRegistrationForm = () => {
}}
/>
{errors.general && <Alert severity="error">{errors.general}</Alert>}
{errors.general && (
<Alert severity="error">
{errors.general}
</Alert>
)}
<Button
fullWidth
@ -356,12 +355,9 @@ 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
@ -393,14 +389,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);
@ -408,26 +404,13 @@ 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 = () => {
@ -494,7 +477,7 @@ const EmployerRegistrationForm = () => {
const validatePassword = (password: string): string[] => {
const errors: string[] = [];
if (password.length < 8) {
errors.push('at least 8 characters');
}
@ -516,7 +499,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]: '' }));
@ -539,14 +522,15 @@ 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')) {
@ -555,9 +539,7 @@ 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);
@ -582,7 +564,7 @@ const EmployerRegistrationForm = () => {
<Typography variant="h6" sx={{ mb: 2 }}>
Account Information
</Typography>
<Stack spacing={3}>
<Stack direction="row" spacing={2}>
<TextField
@ -590,7 +572,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}
@ -600,7 +582,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}
@ -614,7 +596,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}
@ -625,7 +607,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 />}
@ -639,7 +621,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}
@ -650,7 +632,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 />}
@ -668,13 +650,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}
@ -686,13 +668,11 @@ 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>}
@ -702,13 +682,11 @@ 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>}
@ -722,13 +700,10 @@ 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>
@ -739,26 +714,30 @@ 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
@ -817,24 +796,22 @@ 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>
@ -850,24 +827,22 @@ 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>
@ -896,4 +871,4 @@ export function RegistrationTypeSelector() {
);
}
export { CandidateRegistrationForm, EmployerRegistrationForm };
export { CandidateRegistrationForm, EmployerRegistrationForm };

View File

@ -34,15 +34,13 @@ 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
};
@ -55,15 +53,8 @@ const SystemInfoComponent: React.FC<{
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>
));
}
@ -182,13 +173,14 @@ 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]);
}, [systemInfo, setSystemInfo, setSnack, apiClient]);
// useEffect(() => {
// if (!systemPrompt) {
// return;
@ -292,9 +284,8 @@ 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>
@ -391,20 +382,23 @@ 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,22 +1,23 @@
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 { useAppState, useSelectedCandidate } from 'hooks/GlobalContext';
import { useAuth } from "hooks/AuthContext";
import { 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 } = useAppState();
const { setSnack } = props;
const { username } = useParams<{ username: string }>();
const navigate = useNavigate();
@ -24,33 +25,30 @@ const CandidateRoute: React.FC<CandidateRouteProps> = (props: CandidateRouteProp
if (selectedCandidate?.username === username || !username) {
return;
}
const getCandidate = async (reference: string): Promise<void> => {
const getCandidate = async (reference: string) => {
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 = 'Object'): T {
export function debugConversion<T>(obj: T, label: string = '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