Reformatting tsx

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

4
frontend/.eslintignore Normal file
View File

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

43
frontend/.eslintrc.json Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

10
frontend/prettier Normal file
View File

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

View File

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

View File

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

View File

@ -1,76 +1,76 @@
import { createTheme } from '@mui/material/styles';
import { createTheme } from "@mui/material/styles";
const backstoryTheme = createTheme({
palette: {
primary: {
main: '#1A2536', // Midnight Blue
contrastText: '#D3CDBF', // Warm Gray
main: "#1A2536", // Midnight Blue
contrastText: "#D3CDBF", // Warm Gray
},
secondary: {
main: '#4A7A7D', // Dusty Teal
contrastText: '#FFFFFF', // White
main: "#4A7A7D", // Dusty Teal
contrastText: "#FFFFFF", // White
},
text: {
primary: '#2E2E2E', // Charcoal Black
secondary: '#1A2536', // Midnight Blue
primary: "#2E2E2E", // Charcoal Black
secondary: "#1A2536", // Midnight Blue
},
background: {
default: '#D3CDBF', // Warm Gray
paper: '#FFFFFF', // White
default: "#D3CDBF", // Warm Gray
paper: "#FFFFFF", // White
},
action: {
active: '#D4A017', // Golden Ochre
hover: 'rgba(212, 160, 23, 0.1)', // Golden Ochre with opacity
active: "#D4A017", // Golden Ochre
hover: "rgba(212, 160, 23, 0.1)", // Golden Ochre with opacity
},
custom: {
highlight: '#D4A017', // Golden Ochre
contrast: '#2E2E2E', // Charcoal Black
highlight: "#D4A017", // Golden Ochre
contrast: "#2E2E2E", // Charcoal Black
},
},
typography: {
fontFamily: "'Roboto', sans-serif",
h1: {
fontSize: '2rem',
fontSize: "2rem",
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
color: "#2E2E2E", // Charcoal Black
},
h2: {
fontSize: '1.75rem',
fontSize: "1.75rem",
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
marginBottom: '1rem',
color: "#2E2E2E", // Charcoal Black
marginBottom: "1rem",
},
h3: {
fontSize: '1.5rem',
fontSize: "1.5rem",
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
marginBottom: '0.75rem',
color: "#2E2E2E", // Charcoal Black
marginBottom: "0.75rem",
},
h4: {
fontSize: '1.25rem',
fontSize: "1.25rem",
fontWeight: 500,
color: '#2E2E2E', // Charcoal Black
marginBottom: '0.5rem',
color: "#2E2E2E", // Charcoal Black
marginBottom: "0.5rem",
},
body1: {
fontSize: '1rem',
color: '#2E2E2E', // Charcoal Black
marginBottom: '0.5rem',
fontSize: "1rem",
color: "#2E2E2E", // Charcoal Black
marginBottom: "0.5rem",
},
body2: {
fontSize: '0.875rem',
color: '#2E2E2E', // Charcoal Black
fontSize: "0.875rem",
color: "#2E2E2E", // Charcoal Black
},
},
components: {
MuiLink: {
styleOverrides: {
root: {
color: '#4A7A7D', // Dusty Teal (your secondary color)
textDecoration: 'none',
'&:hover': {
color: '#D4A017', // Golden Ochre on hover
textDecoration: 'underline',
color: "#4A7A7D", // Dusty Teal (your secondary color)
textDecoration: "none",
"&:hover": {
color: "#D4A017", // Golden Ochre on hover
textDecoration: "underline",
},
},
},
@ -78,9 +78,9 @@ const backstoryTheme = createTheme({
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
'&:hover': {
backgroundColor: 'rgba(212, 160, 23, 0.2)', // Golden Ochre hover
textTransform: "none",
"&:hover": {
backgroundColor: "rgba(212, 160, 23, 0.2)", // Golden Ochre hover
},
},
},
@ -88,7 +88,7 @@ const backstoryTheme = createTheme({
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#1A2536', // Midnight Blue
backgroundColor: "#1A2536", // Midnight Blue
},
},
},
@ -96,23 +96,23 @@ const backstoryTheme = createTheme({
styleOverrides: {
root: {
// padding: '0.5rem',
borderRadius: '4px',
borderRadius: "4px",
},
},
},
MuiList: {
styleOverrides: {
root: {
padding: '0.5rem',
padding: "0.5rem",
},
},
},
MuiListItem: {
styleOverrides: {
root: {
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(212, 160, 23, 0.1)', // Golden Ochre with opacity
borderRadius: "4px",
"&:hover": {
backgroundColor: "rgba(212, 160, 23, 0.1)", // Golden Ochre with opacity
},
},
},
@ -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 Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import { CandidateQuestion } from "types/types";
type ChatSubmitQueryInterface = (query: CandidateQuestion) => void;
interface BackstoryQueryInterface {
question: CandidateQuestion,
submitQuery?: ChatSubmitQueryInterface
question: CandidateQuestion;
submitQuery?: ChatSubmitQueryInterface;
}
const BackstoryQuery = (props : BackstoryQueryInterface) => {
const BackstoryQuery = (props: BackstoryQueryInterface) => {
const { question, submitQuery } = props;
if (submitQuery === undefined) {
return (<Box>{question.question}</Box>);
return <Box>{question.question}</Box>;
}
return (
<Button variant="outlined" sx={{
color: theme => theme.palette.custom.highlight, // Golden Ochre (#D4A017)
borderColor: theme => theme.palette.custom.highlight,
m: 1
}}
size="small" onClick={(e: any) => { submitQuery(question); }}>
<Button
variant="outlined"
sx={{
color: (theme) => theme.palette.custom.highlight, // Golden Ochre (#D4A017)
borderColor: (theme) => theme.palette.custom.highlight,
m: 1,
}}
size="small"
onClick={(e: any) => {
submitQuery(question);
}}
>
{question.question}
</Button>
);
}
export type {
BackstoryQueryInterface,
ChatSubmitQueryInterface,
};
export {
BackstoryQuery,
};
export type { BackstoryQueryInterface, ChatSubmitQueryInterface };
export { BackstoryQuery };

View File

@ -1,53 +1,50 @@
import React, { ReactElement, JSXElementConstructor } from 'react';
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
import { ChatSubmitQueryInterface } from './BackstoryQuery';
import { SetSnackType } from './Snack';
import React, { ReactElement, JSXElementConstructor } from "react";
import Box from "@mui/material/Box";
import { SxProps, Theme } from "@mui/material";
import { ChatSubmitQueryInterface } from "./BackstoryQuery";
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,6 +1,13 @@
import React, { useRef, useEffect, CSSProperties, KeyboardEvent, useState, useImperativeHandle } from 'react';
import { useTheme } from '@mui/material/styles';
import './BackstoryTextField.css';
import React, {
useRef,
useEffect,
CSSProperties,
KeyboardEvent,
useState,
useImperativeHandle,
} from "react";
import { useTheme } from "@mui/material/styles";
import "./BackstoryTextField.css";
// Define ref interface for exposed methods
interface BackstoryTextFieldRef {
@ -18,9 +25,12 @@ interface BackstoryTextFieldProps {
style?: CSSProperties;
}
const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryTextFieldProps>((props, ref) => {
const BackstoryTextField = React.forwardRef<
BackstoryTextFieldRef,
BackstoryTextFieldProps
>((props, ref) => {
const {
value = '',
value = "",
disabled = false,
placeholder,
onEnter,
@ -59,12 +69,12 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
// Use requestAnimationFrame to ensure DOM is settled
const raf = requestAnimationFrame(() => {
const paddingTop = parseFloat(computed.paddingTop || '0');
const paddingBottom = parseFloat(computed.paddingBottom || '0');
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';
textarea.style.height = "auto";
const newHeight = shadow.scrollHeight + totalPadding;
textarea.style.height = `${newHeight}px`;
@ -78,33 +88,37 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
useImperativeHandle(ref, () => ({
getValue: () => editValue,
setValue: (value: string) => setEditValue(value),
getAndResetValue: () => { const _ev = editValue; setEditValue(''); return _ev; }
getAndResetValue: () => {
const _ev = editValue;
setEditValue("");
return _ev;
},
}));
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (!onEnter) {
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); // Prevent newline
onEnter(editValue);
setEditValue(''); // Clear textarea
setEditValue(""); // Clear textarea
}
};
const fullStyle: CSSProperties = {
display: 'flex',
display: "flex",
flexGrow: 1,
width: '100%',
padding: '16.5px 14px',
resize: 'none',
overflow: 'hidden',
boxSizing: 'border-box',
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)',
lineHeight: "1.5",
borderRadius: "4px",
fontSize: "16px",
backgroundColor: "rgba(0, 0, 0, 0)",
fontFamily: theme.typography.fontFamily,
...style,
};
@ -117,7 +131,10 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
value={editValue}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => { setEditValue(e.target.value); onChange && onChange(e.target.value); }}
onChange={(e) => {
setEditValue(e.target.value);
onChange && onChange(e.target.value);
}}
onKeyDown={handleKeyDown}
style={fullStyle}
/>
@ -127,15 +144,15 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
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',
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}
@ -144,8 +161,6 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
);
});
export type {
BackstoryTextFieldRef
};
export type { BackstoryTextFieldRef };
export { BackstoryTextField };
export { BackstoryTextField };

View File

@ -1,32 +1,65 @@
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';
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 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";
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 { Message } from './Message';
import { DeleteConfirmation } from 'components/DeleteConfirmation';
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
import { BackstoryElementProps } from './BackstoryTab';
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 { StreamingResponse } from 'services/api-client';
import { ChatMessage, ChatContext, ChatSession, ChatQuery, ChatMessageUser, ChatMessageError, ChatMessageStreaming, ChatMessageStatus } from 'types/types';
import { PaginatedResponse } from 'types/conversion';
import { StreamingResponse } from "services/api-client";
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';
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';
type ConversationMode =
| "chat"
| "job_description"
| "resume"
| "fact_check"
| "persona";
interface ConversationHandle {
submitQuery: (query: ChatQuery) => void;
@ -34,395 +67,482 @@ interface ConversationHandle {
}
interface ConversationProps extends BackstoryElementProps {
className?: string, // Override default className
type: ConversationMode, // Type of Conversation chat
placeholder?: string, // Prompt to display in TextField input
actionLabel?: string, // Label to put on the primary button
resetAction?: () => void, // Callback when Reset is pressed
resetLabel?: string, // Label to put on Reset button
defaultPrompts?: React.ReactElement[], // Set of Elements to display after the TextField
defaultQuery?: string, // Default text to populate the TextField input
preamble?: ChatMessage[], // Messages to display at start of Conversation until Action has been invoked
hidePreamble?: boolean, // Whether to hide the preamble after an Action has been invoked
hideDefaultPrompts?: boolean, // Whether to hide the defaultPrompts after an Action has been invoked
messageFilter?: ((messages: ChatMessage[]) => ChatMessage[]) | undefined, // Filter callback to determine which Messages to display in Conversation
messages?: ChatMessage[], //
sx?: SxProps<Theme>,
onResponse?: ((message: ChatMessage) => void) | undefined, // Event called when a query completes (provides messages)
};
className?: string; // Override default className
type: ConversationMode; // Type of Conversation chat
placeholder?: string; // Prompt to display in TextField input
actionLabel?: string; // Label to put on the primary button
resetAction?: () => void; // Callback when Reset is pressed
resetLabel?: string; // Label to put on Reset button
defaultPrompts?: React.ReactElement[]; // Set of Elements to display after the TextField
defaultQuery?: string; // Default text to populate the TextField input
preamble?: ChatMessage[]; // Messages to display at start of Conversation until Action has been invoked
hidePreamble?: boolean; // Whether to hide the preamble after an Action has been invoked
hideDefaultPrompts?: boolean; // Whether to hide the defaultPrompts after an Action has been invoked
messageFilter?: ((messages: ChatMessage[]) => ChatMessage[]) | undefined; // Filter callback to determine which Messages to display in Conversation
messages?: ChatMessage[]; //
sx?: SxProps<Theme>;
onResponse?: ((message: ChatMessage) => void) | undefined; // Event called when a query completes (provides messages)
}
const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: ConversationProps, ref) => {
const {
actionLabel,
defaultPrompts,
hideDefaultPrompts,
hidePreamble,
messageFilter,
messages,
onResponse,
placeholder,
preamble,
resetAction,
resetLabel,
sx,
type,
} = props;
const { apiClient } = useAuth()
const [processing, setProcessing] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const [conversation, setConversation] = useState<ChatMessage[]>([]);
const conversationRef = useRef<ChatMessage[]>([]);
const [filteredConversation, setFilteredConversation] = useState<ChatMessage[]>([]);
const [processingMessage, setProcessingMessage] = useState<ChatMessage | undefined>(undefined);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | undefined>(undefined);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const viewableElementRef = useRef<HTMLDivElement>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const stopRef = useRef(false);
const controllerRef = useRef<StreamingResponse>(null);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const { setSnack } = useAppState();
const Conversation = forwardRef<ConversationHandle, ConversationProps>(
(props: ConversationProps, ref) => {
const {
actionLabel,
defaultPrompts,
hideDefaultPrompts,
hidePreamble,
messageFilter,
messages,
onResponse,
placeholder,
preamble,
resetAction,
resetLabel,
sx,
type,
} = props;
const { apiClient } = useAuth();
const [processing, setProcessing] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const [conversation, setConversation] = useState<ChatMessage[]>([]);
const conversationRef = useRef<ChatMessage[]>([]);
const [filteredConversation, setFilteredConversation] = useState<
ChatMessage[]
>([]);
const [processingMessage, setProcessingMessage] = useState<
ChatMessage | undefined
>(undefined);
const [streamingMessage, setStreamingMessage] = useState<
ChatMessage | undefined
>(undefined);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const viewableElementRef = useRef<HTMLDivElement>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const stopRef = useRef(false);
const controllerRef = useRef<StreamingResponse>(null);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const { setSnack } = useAppState();
// Keep the ref updated whenever items changes
useEffect(() => {
conversationRef.current = conversation;
}, [conversation]);
// Keep the ref updated whenever items changes
useEffect(() => {
conversationRef.current = conversation;
}, [conversation]);
// Update the context status
/* Transform the 'Conversation' by filtering via callback, then adding
* preamble and messages based on whether the conversation
* has any elements yet */
useEffect(() => {
let filtered = [];
if (messageFilter === undefined) {
filtered = conversation;
// console.log('No message filter provided. Using all messages.', filtered);
} else {
//console.log('Filtering conversation...')
filtered = messageFilter(conversation); /* Do not copy conversation or useEffect will loop forever */
//console.log(`${conversation.length - filtered.length} messages filtered out.`);
}
if (filtered.length === 0) {
setFilteredConversation([
...(preamble || []),
...(messages || []),
]);
} else {
setFilteredConversation([
...(hidePreamble ? [] : (preamble || [])),
...(messages || []),
...filtered,
]);
};
}, [conversation, setFilteredConversation, messageFilter, preamble, messages, hidePreamble]);
useEffect(() => {
if (chatSession) {
return;
}
const createChatSession = async () => {
try {
const chatContext: ChatContext = { type: "general" };
const response: ChatSession = await apiClient.createChatSession(chatContext);
setChatSession(response);
} catch (e) {
console.error(e);
setSnack("Unable to create chat session.", "error");
// Update the context status
/* Transform the 'Conversation' by filtering via callback, then adding
* preamble and messages based on whether the conversation
* has any elements yet */
useEffect(() => {
let filtered = [];
if (messageFilter === undefined) {
filtered = conversation;
// console.log('No message filter provided. Using all messages.', filtered);
} else {
//console.log('Filtering conversation...')
filtered =
messageFilter(
conversation
); /* Do not copy conversation or useEffect will loop forever */
//console.log(`${conversation.length - filtered.length} messages filtered out.`);
}
};
if (filtered.length === 0) {
setFilteredConversation([...(preamble || []), ...(messages || [])]);
} else {
setFilteredConversation([
...(hidePreamble ? [] : preamble || []),
...(messages || []),
...filtered,
]);
}
}, [
conversation,
setFilteredConversation,
messageFilter,
preamble,
messages,
hidePreamble,
]);
createChatSession();
useEffect(() => {
if (chatSession) {
return;
}
const createChatSession = async () => {
try {
const chatContext: ChatContext = { type: "general" };
const response: ChatSession = await apiClient.createChatSession(
chatContext
);
setChatSession(response);
} catch (e) {
console.error(e);
setSnack("Unable to create chat session.", "error");
}
};
}, [chatSession, setChatSession]);
createChatSession();
}, [chatSession, setChatSession]);
const getChatMessages = useCallback(async () => {
if (!chatSession || !chatSession.id) {
return;
}
try {
const response: PaginatedResponse<ChatMessage> = await apiClient.getChatMessages(chatSession.id);
const messages: ChatMessage[] = response.data;
const getChatMessages = useCallback(async () => {
if (!chatSession || !chatSession.id) {
return;
}
try {
const response: PaginatedResponse<ChatMessage> =
await apiClient.getChatMessages(chatSession.id);
const messages: ChatMessage[] = response.data;
setProcessingMessage(undefined);
setStreamingMessage(undefined);
if (messages.length === 0) {
console.log(`History returned with 0 entries`);
setConversation([]);
setNoInteractions(true);
} else {
console.log(
`History returned with ${messages.length} entries:`,
messages
);
setConversation(messages);
setNoInteractions(false);
}
} catch (error) {
console.error("Unable to obtain chat history", error);
setProcessingMessage({
...defaultMessage,
status: "error",
content: `Unable to obtain history from server.`,
});
setTimeout(() => {
setProcessingMessage(undefined);
setNoInteractions(true);
}, 3000);
setSnack("Unable to obtain chat history.", "error");
}
}, [chatSession]);
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (!chatSession) {
setProcessingMessage(loadingMessage);
return;
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
if (messages.length === 0) {
console.log(`History returned with 0 entries`)
setConversation([])
setNoInteractions(true);
} else {
console.log(`History returned with ${messages.length} entries:`, messages)
setConversation(messages);
setNoInteractions(false);
}
} catch (error) {
console.error('Unable to obtain chat history', error);
setProcessingMessage({ ...defaultMessage, status: "error", content: `Unable to obtain history from server.` });
setTimeout(() => {
setProcessingMessage(undefined);
setNoInteractions(true);
}, 3000);
setSnack("Unable to obtain chat history.", "error");
}
}, [chatSession]);
getChatMessages();
}, [chatSession]);
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (!chatSession) {
setProcessingMessage(loadingMessage);
return;
}
setProcessingMessage(undefined);
setStreamingMessage(undefined);
setConversation([]);
setNoInteractions(true);
getChatMessages();
}, [chatSession]);
const handleEnter = (value: string) => {
const query: ChatQuery = {
prompt: value
}
processQuery(query);
};
useImperativeHandle(ref, () => ({
submitQuery: (query: ChatQuery) => {
const handleEnter = (value: string) => {
const query: ChatQuery = {
prompt: value,
};
processQuery(query);
},
fetchHistory: () => { getChatMessages(); }
}));
// const reset = async () => {
// try {
// const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// 'Accept': 'application/json',
// },
// body: JSON.stringify({ reset: ['history'] })
// });
// if (!response.ok) {
// throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
// }
// if (!response.body) {
// throw new Error('Response body is null');
// }
// setProcessingMessage(undefined);
// setStreamingMessage(undefined);
// setConversation([]);
// setNoInteractions(true);
// } catch (e) {
// setSnack("Error resetting history", "error")
// console.error('Error resetting history:', e);
// }
// };
const cancelQuery = () => {
console.log("Stop query");
if (controllerRef.current) {
controllerRef.current.cancel();
}
controllerRef.current = null;
};
const processQuery = (query: ChatQuery) => {
if (controllerRef.current || !chatSession || !chatSession.id) {
return;
}
const sessionId: string = chatSession.id;
setNoInteractions(false);
setConversation([
...conversationRef.current,
{
...defaultMessage,
type: 'text',
content: query.prompt,
}
]);
setProcessing(true);
setProcessingMessage(
{ ...defaultMessage, content: 'Submitting request...' }
);
const chatMessage: ChatMessageUser = {
role: "user",
sessionId: chatSession.id,
content: query.prompt,
tunables: query.tunables,
status: "done",
type: "text",
timestamp: new Date()
};
controllerRef.current = apiClient.sendMessageStream(chatMessage, {
onMessage: (msg: ChatMessage) => {
console.log("onMessage:", msg);
setConversation([
...conversationRef.current,
msg
]);
setStreamingMessage(undefined);
setProcessingMessage(undefined);
setProcessing(false);
if (onResponse) {
onResponse(msg);
}
useImperativeHandle(ref, () => ({
submitQuery: (query: ChatQuery) => {
processQuery(query);
},
onError: (error: string | ChatMessageError) => {
console.log("onError:", error);
// Type-guard to determine if this is a ChatMessageBase or a string
if (typeof error === "object" && error !== null && "content" in error) {
setProcessingMessage(error as ChatMessage);
setProcessing(false);
controllerRef.current = null;
} else {
setProcessingMessage({ ...defaultMessage, content: error as string });
}
fetchHistory: () => {
getChatMessages();
},
onStreaming: (chunk: ChatMessageStreaming) => {
console.log("onStreaming:", chunk);
setStreamingMessage({ ...defaultMessage, ...chunk });
},
onStatus: (status: ChatMessageStatus) => {
console.log("onStatus:", status);
},
onComplete: () => {
console.log("onComplete");
controllerRef.current = null;
}
});
};
}));
if (!chatSession) {
return (<></>);
}
return (
// <Scrollable
// className={`${className || ""} Conversation`}
// autoscroll
// textFieldRef={viewableElementRef}
// fallbackThreshold={0.5}
// sx={{
// p: 1,
// mt: 0,
// ...sx
// }}
// >
<Box className="Conversation" sx={{ flexGrow: 1, minHeight: "max-content", height: "max-content", maxHeight: "max-content", overflow: "hidden" }}>
<Box sx={{ p: 1, mt: 0, ...sx }}>
{
filteredConversation.map((message, index) =>
<Message key={index} {...{ chatSession, sendQuery: processQuery, message, }} />
)
// const reset = async () => {
// try {
// const response = await fetch(connectionBase + `/api/reset/${sessionId}/${type}`, {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// 'Accept': 'application/json',
// },
// body: JSON.stringify({ reset: ['history'] })
// });
// if (!response.ok) {
// throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
// }
// if (!response.body) {
// throw new Error('Response body is null');
// }
// setProcessingMessage(undefined);
// setStreamingMessage(undefined);
// setConversation([]);
// setNoInteractions(true);
// } catch (e) {
// setSnack("Error resetting history", "error")
// console.error('Error resetting history:', e);
// }
// };
const cancelQuery = () => {
console.log("Stop query");
if (controllerRef.current) {
controllerRef.current.cancel();
}
{
processingMessage !== undefined &&
<Message {...{ chatSession, sendQuery: processQuery, message: processingMessage, }} />
controllerRef.current = null;
};
const processQuery = (query: ChatQuery) => {
if (controllerRef.current || !chatSession || !chatSession.id) {
return;
}
{
streamingMessage !== undefined &&
<Message {...{ chatSession, sendQuery: processQuery, message: streamingMessage }} />
}
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 1,
}}>
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
/>
{processing === true && countdown > 0 && (
const sessionId: string = chatSession.id;
setNoInteractions(false);
setConversation([
...conversationRef.current,
{
...defaultMessage,
type: "text",
content: query.prompt,
},
]);
setProcessing(true);
setProcessingMessage({
...defaultMessage,
content: "Submitting request...",
});
const chatMessage: ChatMessageUser = {
role: "user",
sessionId: chatSession.id,
content: query.prompt,
tunables: query.tunables,
status: "done",
type: "text",
timestamp: new Date(),
};
controllerRef.current = apiClient.sendMessageStream(chatMessage, {
onMessage: (msg: ChatMessage) => {
console.log("onMessage:", msg);
setConversation([...conversationRef.current, msg]);
setStreamingMessage(undefined);
setProcessingMessage(undefined);
setProcessing(false);
if (onResponse) {
onResponse(msg);
}
},
onError: (error: string | ChatMessageError) => {
console.log("onError:", error);
// Type-guard to determine if this is a ChatMessageBase or a string
if (
typeof error === "object" &&
error !== null &&
"content" in error
) {
setProcessingMessage(error as ChatMessage);
setProcessing(false);
controllerRef.current = null;
} else {
setProcessingMessage({
...defaultMessage,
content: error as string,
});
}
},
onStreaming: (chunk: ChatMessageStreaming) => {
console.log("onStreaming:", chunk);
setStreamingMessage({ ...defaultMessage, ...chunk });
},
onStatus: (status: ChatMessageStatus) => {
console.log("onStatus:", status);
},
onComplete: () => {
console.log("onComplete");
controllerRef.current = null;
},
});
};
if (!chatSession) {
return <></>;
}
return (
// <Scrollable
// className={`${className || ""} Conversation`}
// autoscroll
// textFieldRef={viewableElementRef}
// fallbackThreshold={0.5}
// sx={{
// p: 1,
// mt: 0,
// ...sx
// }}
// >
<Box
className="Conversation"
sx={{
flexGrow: 1,
minHeight: "max-content",
height: "max-content",
maxHeight: "max-content",
overflow: "hidden",
}}
>
<Box sx={{ p: 1, mt: 0, ...sx }}>
{filteredConversation.map((message, index) => (
<Message
key={index}
{...{ chatSession, sendQuery: processQuery, message }}
/>
))}
{processingMessage !== undefined && (
<Message
{...{
chatSession,
sendQuery: processQuery,
message: processingMessage,
}}
/>
)}
{streamingMessage !== undefined && (
<Message
{...{
chatSession,
sendQuery: processQuery,
message: streamingMessage,
}}
/>
)}
<Box
sx={{
pt: 1,
fontSize: "0.7rem",
color: "darkgrey"
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
m: 1,
}}
>Response will be stopped in: {countdown}s</Box>
)}
</Box>
<Box className="Query" sx={{ display: "flex", flexDirection: "column", p: 1, flexGrow: 1 }}>
{placeholder &&
<Box sx={{ display: "flex", flexGrow: 1, p: 0, m: 0, flexDirection: "column" }}
ref={viewableElementRef}>
<BackstoryTextField
ref={backstoryTextRef}
disabled={processing}
onEnter={handleEnter}
placeholder={placeholder}
>
<PropagateLoader
size="10px"
loading={processing}
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
}
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
<DeleteConfirmation
label={resetLabel || "all data"}
disabled={!chatSession || processingMessage !== undefined || noInteractions}
onDelete={() => { /*reset(); resetAction && resetAction(); */ }} />
<Tooltip title={actionLabel || "Send"}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={!chatSession || processingMessage !== undefined}
onClick={() => { processQuery({ prompt: (backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || "" }); }}>
{actionLabel}<SendIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Cancel">
<span style={{ display: "flex" }}> { /* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={() => { cancelQuery(); }}
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
disabled={stopRef.current || !chatSession || processing === false}
{processing === true && countdown > 0 && (
<Box
sx={{
pt: 1,
fontSize: "0.7rem",
color: "darkgrey",
}}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
Response will be stopped in: {countdown}s
</Box>
)}
</Box>
<Box
className="Query"
sx={{ display: "flex", flexDirection: "column", p: 1, flexGrow: 1 }}
>
{placeholder && (
<Box
sx={{
display: "flex",
flexGrow: 1,
p: 0,
m: 0,
flexDirection: "column",
}}
ref={viewableElementRef}
>
<BackstoryTextField
ref={backstoryTextRef}
disabled={processing}
onEnter={handleEnter}
placeholder={placeholder}
/>
</Box>
)}
<Box
key="jobActions"
sx={{
display: "flex",
justifyContent: "center",
flexDirection: "row",
}}
>
<DeleteConfirmation
label={resetLabel || "all data"}
disabled={
!chatSession ||
processingMessage !== undefined ||
noInteractions
}
onDelete={() => {
/*reset(); resetAction && resetAction(); */
}}
/>
<Tooltip title={actionLabel || "Send"}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
disabled={!chatSession || processingMessage !== undefined}
onClick={() => {
processQuery({
prompt:
(backstoryTextRef.current &&
backstoryTextRef.current.getAndResetValue()) ||
"",
});
}}
>
{actionLabel}
<SendIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Cancel">
<span style={{ display: "flex" }}>
{" "}
{/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
<IconButton
aria-label="cancel"
onClick={() => {
cancelQuery();
}}
sx={{ display: "flex", margin: "auto 0px" }}
size="large"
edge="start"
disabled={
stopRef.current || !chatSession || processing === false
}
>
<CancelIcon />
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
{(noInteractions || !hideDefaultPrompts) &&
defaultPrompts !== undefined &&
defaultPrompts.length !== 0 && (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{defaultPrompts.map((element, index) => {
return <Box key={index}>{element}</Box>;
})}
</Box>
)}
<Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box>
</Box>
{(noInteractions || !hideDefaultPrompts) && defaultPrompts !== undefined && defaultPrompts.length !== 0 &&
<Box sx={{ display: "flex", flexDirection: "column" }}>
{
defaultPrompts.map((element, index) => {
return (<Box key={index}>{element}</Box>);
})
}
</Box>
}
<Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box >
</Box>
);
});
);
}
);
export type {
ConversationProps,
ConversationHandle,
};
export type { ConversationProps, ConversationHandle };
export {
Conversation
};
export { Conversation };

View File

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

View File

@ -1,18 +1,18 @@
import React, { useEffect, useState } from 'react';
import {
IconButton,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Button,
import React, { useEffect, useState } from "react";
import {
IconButton,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Button,
useMediaQuery,
Tooltip,
SxProps,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import ResetIcon from '@mui/icons-material/History';
} from "@mui/material";
import { useTheme } from "@mui/material/styles";
import ResetIcon from "@mui/icons-material/History";
interface DeleteConfirmationProps {
// Legacy props for backward compatibility (uncontrolled mode)
@ -20,7 +20,16 @@ interface DeleteConfirmationProps {
disabled?: boolean;
label?: string;
action?: "delete" | "reset";
color?: "inherit" | "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" | undefined;
color?:
| "inherit"
| "default"
| "primary"
| "secondary"
| "error"
| "info"
| "success"
| "warning"
| undefined;
sx?: SxProps;
// New props for controlled mode
open?: boolean;
@ -64,7 +73,7 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
// Internal state for uncontrolled mode
const [internalOpen, setInternalOpen] = useState(false);
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
// Determine if we're in controlled or uncontrolled mode
const isControlled = controlledOpen !== undefined;
@ -95,20 +104,34 @@ 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 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(); }}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleClickOpen();
}}
color={color || "inherit"}
sx={{ display: "flex", margin: 'auto 0px', ...sx }}
sx={{ display: "flex", margin: "auto 0px", ...sx }}
size="large"
edge="start"
disabled={disabled}
@ -118,20 +141,16 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
</span>
</Tooltip>
)}
<Dialog
fullScreen={fullScreen}
open={isOpen}
onClose={handleClose}
aria-labelledby="responsive-dialog-title"
>
<DialogTitle id="responsive-dialog-title">
{dialogTitle}
</DialogTitle>
<DialogTitle id="responsive-dialog-title">{dialogTitle}</DialogTitle>
<DialogContent>
<DialogContentText>
{dialogMessage}
</DialogContentText>
<DialogContentText>{dialogMessage}</DialogContentText>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleClose}>
@ -144,8 +163,6 @@ const DeleteConfirmation = (props: DeleteConfirmationProps) => {
</Dialog>
</>
);
}
};
export {
DeleteConfirmation
};
export { DeleteConfirmation };

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { BackstoryElementProps } from './BackstoryTab';
import { StyledMarkdown } from './StyledMarkdown';
import React, { useState, useEffect } from "react";
import { BackstoryElementProps } from "./BackstoryTab";
import { StyledMarkdown } from "./StyledMarkdown";
interface DocumentProps extends BackstoryElementProps {
filepath?: string;
@ -19,30 +19,30 @@ const Document = (props: DocumentProps) => {
const fetchDocument = async () => {
try {
const response = await fetch(filepath, {
method: 'GET',
method: "GET",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
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);
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

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import {
Box,
Button,
@ -22,50 +22,54 @@ import {
Chip,
Divider,
Paper,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import {
} from "@mui/material";
import { styled } from "@mui/material/styles";
import {
CloudUpload,
Edit,
Delete,
Visibility,
Close,
} from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
} from "@mui/icons-material";
import { useTheme } from "@mui/material/styles";
import { useAuth } from "hooks/AuthContext";
import * as Types from 'types/types';
import { BackstoryElementProps } from './BackstoryTab';
import { useAppState } from 'hooks/GlobalContext';
import * as Types from "types/types";
import { BackstoryElementProps } from "./BackstoryTab";
import { useAppState } from "hooks/GlobalContext";
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
const VisuallyHiddenInput = styled("input")({
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: 1,
overflow: 'hidden',
position: 'absolute',
overflow: "hidden",
position: "absolute",
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
whiteSpace: "nowrap",
width: 1,
});
const DocumentManager = (props: BackstoryElementProps) => {
const theme = useTheme();
const { setSnack } = useAppState();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
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>('');
const [selectedDocument, setSelectedDocument] =
useState<Types.Document | null>(null);
const [documentContent, setDocumentContent] = useState<string>("");
const [isViewingContent, setIsViewingContent] = useState(false);
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(null);
const [editingName, setEditingName] = useState('');
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(
null
);
const [editingName, setEditingName] = useState("");
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
// Check if user is a candidate
const candidate = user?.userType === 'candidate' ? user as Types.Candidate : null;
const candidate =
user?.userType === "candidate" ? (user as Types.Candidate) : null;
// Load documents on component mount
useEffect(() => {
@ -76,59 +80,68 @@ 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);
setSnack('Failed to load documents', 'error');
console.error(error);
setSnack("Failed to load documents", "error");
}
};
// Handle document upload
const handleDocumentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleDocumentUpload = async (
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.files && e.target.files[0]) {
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');
setDocuments((prev) => [...prev, result.document]);
setSnack(`Document uploaded: ${file.name}`, "success");
}
// Reset file input
e.target.value = '';
e.target.value = "";
} catch (error) {
console.error(error);
setSnack('Failed to upload document', 'error');
setSnack("Failed to upload document", "error");
}
}
};
@ -138,66 +151,71 @@ 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');
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);
setSelectedDocument(null);
setDocumentContent('');
setDocumentContent("");
}
} catch (error) {
setSnack('Failed to delete document', 'error');
setSnack("Failed to delete document", "error");
}
};
// Handle RAG flag toggle
const handleRAGToggle = async (document: Types.Document, includeInRag: boolean) => {
const handleRAGToggle = async (
document: Types.Document,
includeInRag: boolean
) => {
try {
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');
setSnack(
`Document ${includeInRag ? "included in" : "excluded from"} RAG`,
"success"
);
} catch (error) {
setSnack('Failed to update RAG setting', 'error');
setSnack("Failed to update RAG setting", "error");
}
};
// Handle document rename
const handleRenameDocument = async (document: Types.Document, newName: string) => {
const handleRenameDocument = async (
document: Types.Document,
newName: string
) => {
if (!newName.trim()) {
setSnack('Document name cannot be empty', 'error');
setSnack("Document name cannot be empty", "error");
return;
}
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');
setSnack("Document renamed successfully", "success");
setIsRenameDialogOpen(false);
setEditingDocument(null);
setEditingName('');
setEditingName("");
} catch (error) {
setSnack('Failed to rename document', 'error');
setSnack("Failed to rename document", "error");
}
};
@ -206,12 +224,12 @@ const DocumentManager = (props: BackstoryElementProps) => {
try {
setSelectedDocument(document);
setIsViewingContent(true);
// Call API to get document content
const result = await apiClient.getCandidateDocumentText(document);
setDocumentContent(result.content);
} catch (error) {
setSnack('Failed to load document content', 'error');
setSnack("Failed to load document content", "error");
setIsViewingContent(false);
}
};
@ -225,150 +243,189 @@ const DocumentManager = (props: BackstoryElementProps) => {
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// Get file type color
const getFileTypeColor = (type: string): 'primary' | 'secondary' | 'success' | 'warning' => {
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"
/>
}
label={
<Typography variant="caption">
Include in RAG
</Typography>
}
/>
</Box>
</Box>
}
<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"
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
edge="end"
size="small"
onClick={() => handleViewDocument(doc)}
title="View content"
>
<Visibility />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => startRename(doc, doc.filename)}
title="Rename"
>
<Edit />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => handleDeleteDocument(doc)}
title="Delete"
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</React.Fragment>
))}
</List>
)}
</CardContent>
</Card>
)}
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography
variant="caption"
color="text.secondary"
>
{formatFileSize(doc.size)} {" "}
{doc?.uploadDate?.toLocaleDateString()}
</Typography>
<Box sx={{ mt: 1 }}>
<FormControlLabel
control={
<Switch
checked={doc.options?.includeInRag}
onChange={(e) =>
handleRAGToggle(doc, e.target.checked)
}
size="small"
/>
}
label={
<Typography variant="caption">
Include in RAG
</Typography>
}
/>
</Box>
</Box>
}
/>
<ListItemSecondaryAction>
<Box sx={{ display: "flex", gap: 0.5 }}>
<IconButton
edge="end"
size="small"
onClick={() => handleViewDocument(doc)}
title="View content"
>
<Visibility />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => startRename(doc, doc.filename)}
title="Rename"
>
<Edit />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => handleDeleteDocument(doc)}
title="Delete"
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</React.Fragment>
))}
</List>
)}
</CardContent>
</Card>
</Grid>
{/* Document Content Viewer */}
@ -376,7 +433,14 @@ 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 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant={isMobile ? "subtitle2" : "h6"}>
Document Content
</Typography>
@ -385,29 +449,31 @@ const DocumentManager = (props: BackstoryElementProps) => {
onClick={() => {
setIsViewingContent(false);
setSelectedDocument(null);
setDocumentContent('');
setDocumentContent("");
}}
>
<Close />
</IconButton>
</Box>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 400,
overflow: 'auto',
backgroundColor: 'grey.50'
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 400,
overflow: "auto",
backgroundColor: "grey.50",
}}
>
<pre style={{
margin: 0,
fontFamily: 'monospace',
fontSize: isMobile ? '0.75rem' : '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{documentContent || 'Loading content...'}
<pre
style={{
margin: 0,
fontFamily: "monospace",
fontSize: isMobile ? "0.75rem" : "0.875rem",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{documentContent || "Loading content..."}
</pre>
</Paper>
</CardContent>
@ -415,46 +481,47 @@ 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);
{/* 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)
}
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsRenameDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => editingDocument && handleRenameDocument(editingDocument, editingName)}
variant="contained"
disabled={!editingName.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
</Grid>
variant="contained"
disabled={!editingName.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
</Grid>
</>
);
};
export { DocumentManager };
export { DocumentManager };

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import {
Box,
Card,
@ -18,8 +18,8 @@ import {
Checkbox,
FormControlLabel,
Grid,
IconButton
} from '@mui/material';
IconButton,
} from "@mui/material";
import {
Email as EmailIcon,
Security as SecurityIcon,
@ -28,26 +28,34 @@ import {
Refresh as RefreshIcon,
DevicesOther as DevicesIcon,
VisibilityOff,
Visibility
} from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
import { Navigate, useNavigate } from 'react-router-dom';
Visibility,
} from "@mui/icons-material";
import { useAuth } from "hooks/AuthContext";
import { BackstoryPageProps } from "./BackstoryTab";
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 [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending');
const [message, setMessage] = useState('');
const [userType, setUserType] = useState<string>('');
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>("");
useEffect(() => {
// Get token from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const token = urlParams.get("token");
if (token) {
setVerificationToken(token);
handleVerifyEmail(token);
@ -56,67 +64,69 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
const handleVerifyEmail = async (token: string) => {
if (!token) {
setStatus('error');
setMessage('Invalid verification link');
setStatus("error");
setMessage("Invalid verification link");
return;
}
try {
const result = await verifyEmail({ token });
const result = await verifyEmail({ token });
if (result) {
setStatus("success");
setMessage(result.message);
setUserType(result.userType);
if (result) {
setStatus('success');
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');
setStatus("error");
setMessage("Email verification failed");
}
} catch (error) {
setStatus('error');
setMessage('Email verification failed');
setStatus("error");
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.");
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.50',
p: 2
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.50",
p: 2,
}}
>
<Card sx={{ maxWidth: 500, width: '100%' }}>
<Card sx={{ maxWidth: 500, width: "100%" }}>
<CardContent sx={{ p: 4 }}>
<Box textAlign="center" mb={3}>
{status === 'pending' && (
{status === "pending" && (
<>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<EmailIcon
sx={{ fontSize: 64, color: "primary.main", mb: 2 }}
/>
<Typography variant="h4" gutterBottom>
Verifying Email
</Typography>
@ -126,9 +136,11 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</>
)}
{status === 'success' && (
{status === "success" && (
<>
<CheckCircleIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
<CheckCircleIcon
sx={{ fontSize: 64, color: "success.main", mb: 2 }}
/>
<Typography variant="h4" gutterBottom color="success.main">
Email Verified!
</Typography>
@ -138,9 +150,9 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</>
)}
{status === 'error' && (
{status === "error" && (
<>
<ErrorIcon sx={{ fontSize: 64, color: 'error.main', mb: 2 }} />
<ErrorIcon sx={{ fontSize: 64, color: "error.main", mb: 2 }} />
<Typography variant="h4" gutterBottom color="error.main">
Verification Failed
</Typography>
@ -151,29 +163,35 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
)}
</Box>
{isLoading && (
{isLoading && (
<Box display="flex" justifyContent="center" my={3}>
<CircularProgress />
</Box>
)}
{(message || error) && (
<Alert
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'}
{(message || error) && (
<Alert
severity={
status === "success"
? "success"
: status === "error"
? "error"
: "info"
}
sx={{ mt: 2 }}
>
{message || error}
{message || error}
</Alert>
)}
{status === 'success' && (
{status === "success" && (
<Box mt={3} textAlign="center">
<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')}
<Button
variant="contained"
onClick={() => navigate("/login")}
fullWidth
>
Go to Login
@ -181,12 +199,12 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Box>
)}
{status === 'error' && (
{status === "error" && (
<Box mt={3}>
<Button
variant="outlined"
onClick={handleResendVerification}
disabled={isLoading}
disabled={isLoading}
startIcon={<RefreshIcon />}
fullWidth
sx={{ mb: 2 }}
@ -195,7 +213,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Button>
<Button
variant="contained"
onClick={() => navigate('/login')}
onClick={() => navigate("/login")}
fullWidth
>
Back to Login
@ -206,37 +224,33 @@ 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 [code, setCode] = useState('');
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;
@ -245,7 +259,7 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
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;
@ -258,85 +272,88 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
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('');
alert('New verification code sent to your email');
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
@ -344,32 +361,37 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
label="Enter 6-digit code"
value={code}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
setCode(value);
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}>
<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>
@ -387,71 +409,72 @@ const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
If you didn't attempt to log in, please change your password immediately.
If you didn't attempt to log in, please change your password
immediately.
</Typography>
</Alert>
</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 [resendMessage, setResendMessage] = useState('');
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.");
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogContent sx={{ textAlign: 'center', p: 4 }}>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<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' }}>
<Alert severity="info" sx={{ mt: 2, mb: 3, textAlign: "left" }}>
<Typography variant="body2">
<strong>Next steps:</strong>
<br />
@ -464,8 +487,8 @@ const RegistrationSuccessDialog = ({
</Alert>
{resendMessage && (
<Alert
severity={resendMessage.includes('sent') ? 'success' : 'error'}
<Alert
severity={resendMessage.includes("sent") ? "success" : "error"}
sx={{ mb: 2 }}
>
{resendMessage}
@ -473,11 +496,13 @@ const RegistrationSuccessDialog = ({
)}
</DialogContent>
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}>
<DialogActions sx={{ p: 3, justifyContent: "space-between" }}>
<Button
onClick={handleResendVerification}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
disabled={isLoading}
startIcon={
isLoading ? <CircularProgress size={16} /> : <RefreshIcon />
}
>
Resend Email
</Button>
@ -487,55 +512,54 @@ const RegistrationSuccessDialog = ({
</DialogActions>
</Dialog>
);
}
};
// Enhanced Login Component with MFA Support
const LoginForm = () => {
const { login, mfaResponse, isLoading, error, user } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, "");
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
}, [error]);
}, [error]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const success = await login({
login: email,
password,
});
const success = await login({
login: email,
password
});
console.log(`login success: ${success}`);
if (success) {
// Redirect based on user type - this could be handled in AuthContext
// or by a higher-level component that listens to auth state changes
handleLoginSuccess();
}
console.log(`login success: ${success}`);
if (success) {
// Redirect based on user type - this could be handled in AuthContext
// or by a higher-level component that listens to auth state changes
handleLoginSuccess();
}
};
const handleMFASuccess = (authData: any) => {
handleLoginSuccess();
handleLoginSuccess();
};
const handleLoginSuccess = () => {
if (!user) {
navigate('/');
navigate("/");
} else {
navigate(`/${user.userType}/dashboard`);
}
console.log('Login successful - redirect to dashboard');
console.log("Login successful - redirect to dashboard");
};
return (
@ -553,7 +577,7 @@ const LoginForm = () => {
<TextField
fullWidth
label="Password"
type={showPassword ? 'text' : 'password'}
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
@ -575,9 +599,9 @@ const LoginForm = () => {
}}
/>
{errorMessage && (
{errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}>
{errorMessage}
{errorMessage}
</Alert>
)}
@ -585,21 +609,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 = () => {
@ -616,18 +640,18 @@ const TrustedDevicesManager = () => {
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<DevicesIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
<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
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
No trusted devices yet. When you log in from a new device and choose
to remember it, it will appear here.
</Alert>
) : (
@ -643,11 +667,12 @@ const TrustedDevicesManager = () => {
Added: {new Date(device.addedAt).toLocaleDateString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Last used: {new Date(device.lastUsed).toLocaleDateString()}
Last used:{" "}
{new Date(device.lastUsed).toLocaleDateString()}
</Typography>
<Button
size="small"
color="error"
<Button
size="small"
color="error"
sx={{ mt: 1 }}
onClick={() => {
// Remove device
@ -664,6 +689,12 @@ const TrustedDevicesManager = () => {
</CardContent>
</Card>
);
}
};
export { EmailVerificationPage, MFAVerificationDialog, TrustedDevicesManager, RegistrationSuccessDialog, LoginForm };
export {
EmailVerificationPage,
MFAVerificationDialog,
TrustedDevicesManager,
RegistrationSuccessDialog,
LoginForm,
};

View File

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

View File

@ -1,135 +1,146 @@
import React, { useEffect, useState, useRef } from 'react';
import Box from '@mui/material/Box';
import PropagateLoader from 'react-spinners/PropagateLoader';
import { Quote } from 'components/Quote';
import { BackstoryElementProps } from 'components/BackstoryTab';
import { Candidate, ChatSession } from 'types/types';
import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
import React, { useEffect, useState, useRef } from "react";
import Box from "@mui/material/Box";
import PropagateLoader from "react-spinners/PropagateLoader";
import { Quote } from "components/Quote";
import { BackstoryElementProps } from "components/BackstoryTab";
import { Candidate, ChatSession } from "types/types";
import { useAuth } from "hooks/AuthContext";
import { useAppState } from "hooks/GlobalContext";
interface GenerateImageProps extends BackstoryElementProps {
prompt: string;
chatSession: ChatSession;
};
prompt: string;
chatSession: ChatSession;
}
const GenerateImage = (props: GenerateImageProps) => {
const { user } = useAuth();
const { chatSession, prompt } = props;
const { setSnack } = useAppState();
const [processing, setProcessing] = useState<boolean>(false);
const [status, setStatus] = useState<string>('');
const [image, setImage] = useState<string>('');
const [processing, setProcessing] = useState<boolean>(false);
const [status, setStatus] = useState<string>("");
const [image, setImage] = useState<string>("");
const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || '';
// Only keep refs that are truly necessary
const controllerRef = useRef<string>(null);
const name =
(user?.userType === "candidate"
? (user as Candidate).username
: user?.email) || "";
// Only keep refs that are truly necessary
const controllerRef = useRef<string>(null);
// Effect to trigger profile generation when user data is ready
useEffect(() => {
if (controllerRef.current) {
console.log("Controller already active, skipping profile generation");
return;
}
if (!prompt) {
return;
}
setStatus('Starting image generation...');
setProcessing(true);
const start = Date.now();
// controllerRef.current = streamQueryResponse({
// query: {
// prompt: prompt,
// agentOptions: {
// username: name,
// }
// },
// type: "image",
// onComplete: (msg) => {
// switch (msg.status) {
// case "partial":
// case "done":
// if (msg.status === "done") {
// if (!msg.response) {
// setSnack("Image generation failed", "error");
// } else {
// setImage(msg.response);
// }
// setProcessing(false);
// controllerRef.current = null;
// }
// break;
// case "error":
// console.log(`Error generating profile: ${msg.response} after ${Date.now() - start}`);
// setSnack(msg.response || "", "error");
// setProcessing(false);
// controllerRef.current = null;
// break;
// default:
// let data: any = {};
// try {
// data = typeof msg.response === 'string' ? JSON.parse(msg.response) : msg.response;
// } catch (e) {
// data = { message: msg.response };
// }
// if (msg.status !== "heartbeat") {
// console.log(data);
// }
// if (data.message) {
// setStatus(data.message);
// }
// break;
// }
// }
// });
}, [user, prompt, setSnack]);
if (!chatSession) {
return <></>;
// Effect to trigger profile generation when user data is ready
useEffect(() => {
if (controllerRef.current) {
console.log("Controller already active, skipping profile generation");
return;
}
if (!prompt) {
return;
}
setStatus("Starting image generation...");
setProcessing(true);
const start = Date.now();
return (
<Box className="GenerateImage" sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
// controllerRef.current = streamQueryResponse({
// query: {
// prompt: prompt,
// agentOptions: {
// username: name,
// }
// },
// type: "image",
// onComplete: (msg) => {
// switch (msg.status) {
// case "partial":
// case "done":
// if (msg.status === "done") {
// if (!msg.response) {
// setSnack("Image generation failed", "error");
// } else {
// setImage(msg.response);
// }
// setProcessing(false);
// controllerRef.current = null;
// }
// break;
// case "error":
// console.log(`Error generating profile: ${msg.response} after ${Date.now() - start}`);
// setSnack(msg.response || "", "error");
// setProcessing(false);
// controllerRef.current = null;
// break;
// default:
// let data: any = {};
// try {
// data = typeof msg.response === 'string' ? JSON.parse(msg.response) : msg.response;
// } catch (e) {
// data = { message: msg.response };
// }
// if (msg.status !== "heartbeat") {
// console.log(data);
// }
// if (data.message) {
// setStatus(data.message);
// }
// break;
// }
// }
// });
}, [user, prompt, setSnack]);
if (!chatSession) {
return <></>;
}
return (
<Box
className="GenerateImage"
sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
gap: 1,
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
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>);
}}
>
{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 React, { useState, useRef, JSX } from "react";
import {
Box,
Button,
Typography,
TextField,
Grid,
useTheme,
useMediaQuery,
@ -14,7 +14,7 @@ import {
LinearProgress,
Stack,
Paper,
} from '@mui/material';
} from "@mui/material";
import {
SyncAlt,
Favorite,
@ -30,31 +30,31 @@ import {
Business,
Work,
CheckCircle,
Star
} from '@mui/icons-material';
import { styled } from '@mui/material/styles';
import FileUploadIcon from '@mui/icons-material/FileUpload';
Star,
} from "@mui/icons-material";
import { styled } from "@mui/material/styles";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
import { BackstoryElementProps } from './BackstoryTab';
import { LoginRequired } from 'components/ui/LoginRequired';
import { useAuth } from "hooks/AuthContext";
import { useAppState, useSelectedJob } from "hooks/GlobalContext";
import { BackstoryElementProps } from "./BackstoryTab";
import { LoginRequired } from "components/ui/LoginRequired";
import * as Types from 'types/types';
import { StyledMarkdown } from './StyledMarkdown';
import { JobInfo } from './ui/JobInfo';
import { Scrollable } from './Scrollable';
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon';
import * as Types from "types/types";
import { StyledMarkdown } from "./StyledMarkdown";
import { JobInfo } from "./ui/JobInfo";
import { Scrollable } from "./Scrollable";
import { StatusIcon, StatusBox } from "components/ui/StatusIcon";
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
const VisuallyHiddenInput = styled("input")({
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: 1,
overflow: 'hidden',
position: 'absolute',
overflow: "hidden",
position: "absolute",
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
whiteSpace: "nowrap",
width: 1,
});
@ -62,73 +62,75 @@ const UploadBox = styled(Box)(({ theme }) => ({
border: `2px dashed ${theme.palette.primary.main}`,
borderRadius: theme.shape.borderRadius * 2,
padding: theme.spacing(4),
textAlign: 'center',
textAlign: "center",
backgroundColor: theme.palette.action.hover,
transition: 'all 0.3s ease',
cursor: 'pointer',
'&:hover': {
transition: "all 0.3s ease",
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.action.selected,
borderColor: theme.palette.primary.dark,
},
}));
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 isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [jobDescription, setJobDescription] = useState<string>('');
const [jobRequirements, setJobRequirements] = useState<Types.JobRequirements | null>(null);
const [jobTitle, setJobTitle] = useState<string>('');
const [company, setCompany] = useState<string>('');
const [summary, setSummary] = 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 [summary, setSummary] = useState<string>("");
const [job, setJob] = useState<Types.Job | null>(null);
const [jobStatus, setJobStatus] = useState<string>('');
const [jobStatusType, setJobStatusType] = useState<Types.ApiActivityType | null>(null);
const [jobStatus, setJobStatus] = useState<string>("");
const [jobStatusType, setJobStatusType] =
useState<Types.ApiActivityType | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content);
console.log("status:", status.content);
setJobStatusType(status.activity);
setJobStatus(status.content);
},
onMessage: (jobMessage: Types.JobRequirementsMessage) => {
const job: Types.Job = jobMessage.job
console.log('onMessage - job', job);
const job: Types.Job = jobMessage.job;
console.log("onMessage - job", job);
setJob(job);
setCompany(job.company || '');
setCompany(job.company || "");
setJobDescription(job.description);
setSummary(job.summary || '');
setJobTitle(job.title || '');
setSummary(job.summary || "");
setJobTitle(job.title || "");
setJobRequirements(job.requirements || null);
setJobStatusType(null);
setJobStatus('');
setJobStatus("");
},
onError: (error: Types.ChatMessageError) => {
console.log('onError', error);
console.log("onError", error);
setSnack(error.content, "error");
setIsProcessing(false);
},
onComplete: () => {
setJobStatusType(null);
setJobStatus('');
setJobStatus("");
setIsProcessing(false);
}
},
};
const handleJobUpload = 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();
const fileExtension = "." + file.name.split(".").pop()?.toLowerCase();
let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) {
case "pdf":
@ -146,26 +148,29 @@ const JobCreator = (props: JobCreatorProps) => {
}
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 {
setIsProcessing(true);
setJobDescription('');
setJobTitle('');
setJobDescription("");
setJobTitle("");
setJobRequirements(null);
setSummary('');
setSummary("");
const controller = apiClient.createJobFromFile(file, jobStatusHandlers);
const job = await controller.promise;
if (!job) {
return;
}
console.log(`Job id: ${job.id}`);
e.target.value = '';
e.target.value = "";
} catch (error) {
console.error(error);
setSnack('Failed to upload document', 'error');
setSnack("Failed to upload document", "error");
setIsProcessing(false);
}
}
@ -175,17 +180,24 @@ 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 (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "center", mb: 1.5 }}>
{icon}
<Typography variant="subtitle1" sx={{ ml: 1, fontWeight: 600 }}>
{title}
</Typography>
{required && <Chip label="Required" size="small" color="error" sx={{ ml: 1 }} />}
{required && (
<Chip label="Required" size="small" color="error" sx={{ ml: 1 }} />
)}
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{items.map((item, index) => (
@ -267,8 +279,8 @@ const JobCreator = (props: JobCreatorProps) => {
const handleSave = async () => {
const newJob: Types.Job = {
ownerId: user?.id || '',
ownerType: 'candidate',
ownerId: user?.id || "",
ownerType: "candidate",
description: jobDescription,
company: company,
summary: summary,
@ -281,7 +293,7 @@ const JobCreator = (props: JobCreatorProps) => {
const job = await apiClient.createJob(newJob);
setIsProcessing(false);
if (!job) {
setSnack('Failed to save job', 'error');
setSnack("Failed to save job", "error");
return;
}
onSave && onSave(job);
@ -290,7 +302,10 @@ const JobCreator = (props: JobCreatorProps) => {
const handleExtractRequirements = async () => {
try {
setIsProcessing(true);
const controller = apiClient.createJobFromDescription(jobDescription, jobStatusHandlers);
const controller = apiClient.createJobFromDescription(
jobDescription,
jobStatusHandlers
);
const job = await controller.promise;
if (!job) {
setIsProcessing(false);
@ -299,141 +314,161 @@ const JobCreator = (props: JobCreatorProps) => {
console.log(`Job id: ${job.id}`);
} catch (error) {
console.error(error);
setSnack('Failed to upload document', 'error');
setSnack("Failed to upload document", "error");
setIsProcessing(false);
}
setIsProcessing(false);
};
const renderJobCreation = () => {
return (
<Box sx={{
return (
<Box
sx={{
width: "100%",
p: 1
}}>
{/* Upload Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Information"
subheader="Upload a job description or enter details manually"
avatar={<Work color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<CloudUpload sx={{ mr: 1 }} />
Upload Job Description
</Typography>
<UploadBox onClick={handleUploadClick}>
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
Drop your job description here
</Typography>
<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}
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 }}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<Description sx={{ mr: 1 }} />
Or Enter Manually
<Typography variant="h6" gutterBottom>
Drop your job description here
</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}
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2 }}
/>
{jobRequirements === null && jobDescription && (
<Button
variant="outlined"
onClick={handleExtractRequirements}
startIcon={<AutoFixHigh />}
disabled={isProcessing}
fullWidth={isMobile}
>
Extract Requirements
</Button>
)}
</Grid>
>
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>
{(jobStatus || isProcessing) && (
<Box sx={{ mt: 3 }}>
<StatusBox>
{jobStatusType && <StatusIcon type={jobStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{jobStatus || 'Processing...'}
</Typography>
</StatusBox>
{isProcessing && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
</CardContent>
</Card>
{/* Job Details Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Details"
subheader="Enter specific information about the position"
avatar={<Business color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Title"
<Grid size={{ xs: 12, md: 6 }}>
<Typography
variant="h6"
gutterBottom
sx={{ display: "flex", alignItems: "center" }}
>
<Description sx={{ mr: 1 }} />
Or Enter Manually
</Typography>
<TextField
fullWidth
multiline
rows={isMobile ? 8 : 12}
placeholder="Paste or type the job description here..."
variant="outlined"
value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
disabled={isProcessing}
sx={{ mb: 2 }}
/>
{jobRequirements === null && jobDescription && (
<Button
variant="outlined"
value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)}
required
onClick={handleExtractRequirements}
startIcon={<AutoFixHigh />}
disabled={isProcessing}
InputProps={{
startAdornment: <Work sx={{ mr: 1, color: 'text.secondary' }} />
}}
/>
</Grid>
fullWidth={isMobile}
>
Extract Requirements
</Button>
)}
</Grid>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Company"
variant="outlined"
value={company}
onChange={(e) => setCompany(e.target.value)}
required
disabled={isProcessing}
InputProps={{
startAdornment: <Business sx={{ mr: 1, color: 'text.secondary' }} />
}}
/>
</Grid>
{(jobStatus || isProcessing) && (
<Box sx={{ mt: 3 }}>
<StatusBox>
{jobStatusType && <StatusIcon type={jobStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{jobStatus || "Processing..."}
</Typography>
</StatusBox>
{isProcessing && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
</CardContent>
</Card>
{/* <Grid size={{ xs: 12, md: 6 }}>
{/* Job Details Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Details"
subheader="Enter specific information about the position"
avatar={<Business color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Title"
variant="outlined"
value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)}
required
disabled={isProcessing}
InputProps={{
startAdornment: (
<Work sx={{ mr: 1, color: "text.secondary" }} />
),
}}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Company"
variant="outlined"
value={company}
onChange={(e) => setCompany(e.target.value)}
required
disabled={isProcessing}
InputProps={{
startAdornment: (
<Business sx={{ mr: 1, color: "text.secondary" }} />
),
}}
/>
</Grid>
{/* <Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Location"
@ -446,83 +481,107 @@ const JobCreator = (props: JobCreatorProps) => {
}}
/>
</Grid> */}
</Grid>
</CardContent>
</Grid>
</CardContent>
</Card>
{/* Job Summary */}
{summary !== "" && (
<Card elevation={2} sx={{ mt: 3 }}>
<CardHeader
title="Job Summary"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>{summary}</CardContent>
</Card>
)}
{/* Job Summary */}
{summary !== '' &&
<Card elevation={2} sx={{ mt: 3 }}>
<CardHeader
title="Job Summary"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>
{summary}
</CardContent>
</Card>
}
{/* Requirements Display */}
{renderJobRequirements()}
</Box>
);
};
{/* 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 */
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 */
minHeight: 0 /* Prevent flex overflow */,
maxHeight: "min-content",
position: "relative",
}}>
<Box sx={{
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
flexGrow: 1,
gap: 1,
height: "100%", /* Restrict to main-container's height */
height: "100%" /* Restrict to main-container's height */,
width: "100%",
minHeight: 0,/* Prevent flex overflow */
minHeight: 0 /* Prevent flex overflow */,
maxHeight: "min-content",
"& > *:not(.Scrollable)": {
flexShrink: 0, /* Prevent shrinking */
flexShrink: 0 /* Prevent shrinking */,
},
position: "relative",
}}>
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><JobInfo job={job} /></Scrollable>
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><StyledMarkdown content={job.description} /></Scrollable>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!jobTitle || !company || !jobDescription || isProcessing}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
Save Job
</Button>
</Box>
}}
>
<Scrollable
sx={{
display: "flex",
flexGrow: 1,
position: "relative",
maxHeight: "30rem",
}}
>
<JobInfo job={job} />
</Scrollable>
<Scrollable
sx={{
display: "flex",
flexGrow: 1,
position: "relative",
maxHeight: "30rem",
}}
>
<StyledMarkdown content={job.description} />
</Scrollable>
</Box>
}
</Box>
);
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
<Button
variant="contained"
onClick={handleSave}
disabled={
!jobTitle || !company || !jobDescription || isProcessing
}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
Save Job
</Button>
</Box>
</Box>
)}
</Box>
);
};
export { JobCreator };
export { JobCreator };

View File

@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
import React, { useState, useEffect } from "react";
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
Grid,
@ -15,24 +15,36 @@ import {
useTheme,
LinearProgress,
useMediaQuery,
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 { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
import { Job } from 'types/types';
import { StyledMarkdown } from './StyledMarkdown';
import { Scrollable } from './Scrollable';
import { useAppState } from 'hooks/GlobalContext';
import * as Types from 'types/types';
import JsonView from '@uiw/react-json-view';
import { VectorVisualizer } from './VectorVisualizer';
import { JobInfo } from './ui/JobInfo';
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 { useAuth } from "hooks/AuthContext";
import { BackstoryPageProps } from "./BackstoryTab";
import { Job } from "types/types";
import { StyledMarkdown } from "./StyledMarkdown";
import { Scrollable } from "./Scrollable";
import { useAppState } from "hooks/GlobalContext";
import * as Types from "types/types";
import JsonView from "@uiw/react-json-view";
import { VectorVisualizer } from "./VectorVisualizer";
import { JobInfo } from "./ui/JobInfo";
interface JobAnalysisProps extends BackstoryPageProps {
job: Job;
@ -42,7 +54,13 @@ interface JobAnalysisProps extends BackstoryPageProps {
}
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 {
@ -51,71 +69,101 @@ interface SkillMatch extends SkillAssessment {
matchScore: number;
}
const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => {
const {
job,
candidate,
onAnalysisComplete,
variant = "normal",
} = props
const JobMatchAnalysis: React.FC<JobAnalysisProps> = (
props: JobAnalysisProps
) => {
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);
const [loadingRequirements, setLoadingRequirements] =
useState<boolean>(false);
const [expanded, setExpanded] = useState<string | false>(false);
const [overallScore, setOverallScore] = useState<number>(0);
const [requirementsSession, setRequirementsSession] = useState<ChatSession | null>(null);
const [requirementsSession, setRequirementsSession] =
useState<ChatSession | null>(null);
const [statusMessage, setStatusMessage] = useState<ChatMessage | null>(null);
const [startAnalysis, setStartAnalysis] = useState<boolean>(false);
const [analyzing, setAnalyzing] = useState<boolean>(false);
const [matchStatus, setMatchStatus] = useState<string>('');
const [matchStatusType, setMatchStatusType] = useState<Types.ApiActivityType | null>(null);
const [matchStatus, setMatchStatus] = useState<string>("");
const [matchStatusType, setMatchStatusType] =
useState<Types.ApiActivityType | null>(null);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
// Handle accordion expansion
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 => ({
const initialSkillMatches: SkillMatch[] = requirements.map((req) => ({
skill: req.requirement,
skillModified: req.requirement,
candidateId: candidate.id || "",
domain: req.domain,
status: 'waiting' as const,
status: "waiting" as const,
assessment: "",
description: "",
evidenceFound: false,
@ -129,7 +177,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
setStatusMessage(null);
setLoadingRequirements(false);
setOverallScore(0);
}
};
useEffect(() => {
initializeRequirements(job);
@ -150,75 +198,107 @@ 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 {
setSkillMatches(prev => {
setSkillMatches((prev) => {
const updated = [...prev];
updated[i] = { ...updated[i], status: 'pending' };
updated[i] = { ...updated[i], status: "pending" };
return updated;
});
const request: any = await apiClient.candidateMatchForRequirement(candidate.id || '', requirements[i].requirement, skillMatchHandlers);
const request: any = await apiClient.candidateMatchForRequirement(
candidate.id || "",
requirements[i].requirement,
skillMatchHandlers
);
const result = await request.promise;
const skillMatch = result.skillAssessment;
skills.push(skillMatch);
setMatchStatus('');
let matchScore: number = 0;
setMatchStatus("");
let matchScore = 0;
switch (skillMatch.evidenceStrength.toUpperCase()) {
case "STRONG": matchScore = 100; break;
case "MODERATE": matchScore = 75; break;
case "WEAK": matchScore = 50; break;
case "NONE": matchScore = 0; break;
case "STRONG":
matchScore = 100;
break;
case "MODERATE":
matchScore = 75;
break;
case "WEAK":
matchScore = 50;
break;
case "NONE":
matchScore = 0;
break;
}
if (skillMatch.evidenceStrength == "NONE" && skillMatch.citations && skillMatch.citations.length > 3) {
if (
skillMatch.evidenceStrength == "NONE" &&
skillMatch.citations &&
skillMatch.citations.length > 3
) {
matchScore = Math.min(skillMatch.citations.length * 8, 40);
}
const match: SkillMatch = {
...skillMatch,
status: 'complete',
status: "complete",
matchScore,
domain: requirements[i].domain,
};
setSkillMatches(prev => {
setSkillMatches((prev) => {
const updated = [...prev];
updated[i] = match;
return updated;
});
// Update overall score
setSkillMatches(current => {
const completedMatches = current.filter(match => match.status === 'complete');
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;
});
} catch (error) {
console.error(`Error fetching match for requirement ${requirements[i]}:`, error);
setSkillMatches(prev => {
console.error(
`Error fetching match for requirement ${requirements[i]}:`,
error
);
setSkillMatches((prev) => {
const updated = [...prev];
updated[i] = {
...updated[i],
status: 'error',
assessment: 'Failed to analyze this requirement.'
status: "error",
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]);
});
}, [
job,
onAnalysisComplete,
startAnalysis,
analyzing,
requirements,
loadingRequirements,
]);
// Get color based on match score
const getMatchColor = (score: number): string => {
@ -230,8 +310,8 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
// Get icon based on status
const getStatusIcon = (status: string, score: number) => {
if (status === 'pending' || status === 'waiting') return <PendingIcon />;
if (status === 'error') return <ErrorIcon color="error" />;
if (status === "pending" || status === "waiting") return <PendingIcon />;
if (status === "error") return <ErrorIcon color="error" />;
if (score >= 70) return <CheckCircleIcon color="success" />;
if (score >= 40) return <WarningIcon color="warning" />;
return <ErrorIcon color="error" />;
@ -244,29 +324,46 @@ 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" />
}
{variant !== "small" && <JobInfo job={job} variant="normal" />}
<Box sx={{ display: 'flex', flexDirection: "row", alignItems: 'center', mb: isMobile ? 1 : 2, gap: 1, justifyContent: "space-between" }}>
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", flexGrow: 1, gap: 1 }}>
{overallScore !== 0 && <>
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
mb: isMobile ? 1 : 2,
gap: 1,
justifyContent: "space-between",
}}
>
<Box
sx={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
flexGrow: 1,
gap: 1,
}}
>
{overallScore !== 0 && (
<>
<Typography variant="h5" component="h2" sx={{ mr: 2 }}>
Overall Match:
</Typography>
<Box sx={{
position: 'relative',
display: 'inline-flex',
mr: 2
}}>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
<Box
sx={{
color: getMatchColor(overallScore),
position: "relative",
display: "inline-flex",
mr: 2,
}}
>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{
color: getMatchColor(overallScore),
}}
/>
<Box
sx={{
@ -274,38 +371,52 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography variant="caption" component="div" sx={{ fontWeight: 'bold' }}>
<Typography
variant="caption"
component="div"
sx={{ fontWeight: "bold" }}
>
{`${Math.round(overallScore)}%`}
</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'
}}
color: "white",
fontWeight: "bold",
}}
/>
</>}
</>
)}
</Box>
<Button sx={{ marginLeft: "auto" }} disabled={analyzing || startAnalysis} onClick={beginAnalysis} variant="contained">
<Button
sx={{ marginLeft: "auto" }}
disabled={analyzing || startAnalysis}
onClick={beginAnalysis}
variant="contained"
>
{analyzing ? "Assessment in Progress" : "Start Skill Assessment"}
</Button>
</Box>
{loadingRequirements ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
<Typography variant="h6" sx={{ ml: 2 }}>
Analyzing job requirements...
@ -316,18 +427,19 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
<Typography variant="h5" component="h2" gutterBottom>
Requirements Analysis
</Typography>
{skillMatches.map((match, index) => (
<Accordion
<Accordion
key={index}
expanded={expanded === `panel${index}`}
onChange={handleAccordionChange(`panel${index}`)}
sx={{
sx={{
mb: 2,
border: '1px solid',
borderColor: match.status === 'complete'
? getMatchColor(match.matchScore)
: theme.palette.divider
border: "1px solid",
borderColor:
match.status === "complete"
? getMatchColor(match.matchScore)
: theme.palette.divider,
}}
>
<AccordionSummary
@ -335,107 +447,160 @@ 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' }}>
<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 variant="caption" sx={{ ml: 1, fontWeight: 'light' }}>
</Typography>
<Typography
variant="caption"
sx={{ ml: 1, fontWeight: "light" }}
>
{match.domain}
</Typography>
</Box>
</Box>
{match.status === 'complete' ? (
<Chip
{match.status === "complete" ? (
<Chip
label={`${match.matchScore}% Match`}
size="small"
sx={{
sx={{
bgcolor: getMatchColor(match.matchScore),
color: 'white',
minWidth: 90
}}
color: "white",
minWidth: 90,
}}
/>
) : match.status === 'waiting' ? (
) : 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..."
) : match.status === "pending" ? (
<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 }}>
{match.status === "pending" ? (
<Box sx={{ width: "100%", p: 2 }}>
<LinearProgress />
<Typography sx={{ mt: 2 }}>
Analyzing candidate's match for this requirement... {matchStatus}
Analyzing candidate's match for this requirement...{" "}
{matchStatus}
</Typography>
</Box>
) : match.status === 'error' ? (
) : 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',
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" }}>
<Typography variant="body2" color="text.secondary">
Relevance: {evidence.context}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
flexDirection: "column",
}}
>
<Typography
variant="body2"
color="text.secondary"
>
Relevance: {evidence.context}
</Typography>
<Typography variant="caption" color="text.secondary">
Source: {evidence.source}
<Typography
variant="caption"
color="text.secondary"
>
Source: {evidence.source}
</Typography>
{/* <Chip
size="small"
@ -453,20 +618,17 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
No specific evidence found in candidate's profile.
</Typography>
)}
<Typography variant="h6" gutterBottom>
Skill description
</Typography>
<Typography paragraph>
{match.description}
</Typography>
{/* { match.ragResults && match.ragResults.length !== 0 && <>
<Typography variant="h6" gutterBottom>
Skill description
</Typography>
<Typography paragraph>{match.description}</Typography>
{/* { match.ragResults && match.ragResults.length !== 0 && <>
<Typography variant="h6" gutterBottom>
RAG Information
</Typography>
<VectorVisualizer inline rag={match.ragResults[0]} />
</>
} */}
</Box>
)}
</AccordionDetails>
@ -478,4 +640,4 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
);
};
export { JobMatchAnalysis };
export { JobMatchAnalysis };

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import {
Box,
TextField,
@ -7,13 +7,13 @@ import {
Grid,
Chip,
FormControlLabel,
Checkbox
} from '@mui/material';
import { LocationOn, Public, Home } from '@mui/icons-material';
import { Country, State, City } from 'country-state-city';
import type { ICountry, IState, ICity } from 'country-state-city';
Checkbox,
} from "@mui/material";
import { LocationOn, Public, Home } from "@mui/icons-material";
import { Country, State, City } from "country-state-city";
import type { ICountry, IState, ICity } from "country-state-city";
// Import from your types file - adjust path as needed
import type { Location } from 'types/types';
import type { Location } from "types/types";
interface LocationInputProps {
value?: Partial<Location>;
@ -32,37 +32,42 @@ 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
value.country
? allCountries.find((c) => c.name === value.country) || null
: null
);
const [selectedState, setSelectedState] = useState<IState | null>(null);
const [selectedCity, setSelectedCity] = useState<ICity | null>(null);
const [isRemote, setIsRemote] = useState<boolean>(value.remote || false);
// 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 availableStates = selectedCountry
? State.getStatesOfCountry(selectedCountry.isoCode)
: [];
// Get cities for selected state
const availableCities =
selectedCountry && selectedState
? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode)
: [];
// Initialize state and city from value prop
useEffect(() => {
if (selectedCountry && value.state) {
const stateMatch = availableStates.find(s => s.name === value.state);
const stateMatch = availableStates.find((s) => s.name === value.state);
setSelectedState(stateMatch || null);
}
}, [selectedCountry, value.state, availableStates]);
useEffect(() => {
if (selectedCountry && selectedState && value.city && showCity) {
const cityMatch = availableCities.find(c => c.name === value.city);
const cityMatch = availableCities.find((c) => c.name === value.city);
setSelectedCity(cityMatch || null);
}
}, [selectedCountry, selectedState, value.city, availableCities, showCity]);
@ -70,28 +75,43 @@ 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);
@ -116,11 +136,15 @@ const LocationInput: React.FC<LocationInputProps> = ({
return (
<Box>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="h6"
gutterBottom
sx={{ display: "flex", alignItems: "center", gap: 1 }}
>
<LocationOn color="primary" />
Location {required && <span style={{ color: 'red' }}>*</span>}
Location {required && <span style={{ color: "red" }}>*</span>}
</Typography>
<Grid container spacing={2}>
{/* Country Selection */}
<Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}>
@ -137,10 +161,16 @@ const LocationInput: React.FC<LocationInputProps> = ({
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" }} />
),
}}
/>
)}
@ -174,7 +204,11 @@ const LocationInput: React.FC<LocationInputProps> = ({
{...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"
}
/>
)}
/>
@ -195,10 +229,16 @@ const LocationInput: React.FC<LocationInputProps> = ({
{...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" }} />
),
}}
/>
)}
@ -224,7 +264,7 @@ const LocationInput: React.FC<LocationInputProps> = ({
{/* Location Summary Chips */}
{(selectedCountry || selectedState || selectedCity || isRemote) && (
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1, mt: 1 }}>
{selectedCountry && (
<Chip
icon={<Public />}
@ -274,25 +314,31 @@ const LocationInputDemo: React.FC = () => {
const handleLocationChange = (newLocation: Partial<Location>) => {
setLocation(newLocation);
console.log('Location updated:', newLocation);
console.log("Location updated:", newLocation);
};
// Show some stats about the data
const totalCountries = Country.getAllCountries().length;
const usStates = State.getStatesOfCountry('US').length;
const canadaProvinces = State.getStatesOfCountry('CA').length;
const usStates = State.getStatesOfCountry("US").length;
const canadaProvinces = State.getStatesOfCountry("CA").length;
return (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto' }}>
<Box sx={{ p: 3, maxWidth: 800, mx: "auto" }}>
<Typography variant="h4" gutterBottom align="center" color="primary">
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,
{usStates} US states, {canadaProvinces} Canadian provinces, and thousands of cities
<Typography
variant="body2"
color="text.secondary"
align="center"
sx={{ mb: 3 }}
>
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>
@ -336,21 +382,25 @@ const LocationInputDemo: React.FC = () => {
<Typography variant="h6" gutterBottom>
Current Location Data:
</Typography>
<Box component="pre" sx={{
bgcolor: 'grey.100',
p: 2,
borderRadius: 1,
overflow: 'auto',
fontSize: '0.875rem'
}}>
<Box
component="pre"
sx={{
bgcolor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
fontSize: "0.875rem",
}}
>
{JSON.stringify(location, null, 2)}
</Box>
</Grid>
<Grid size={{ xs: 12 }}>
<Typography variant="body2" color="text.secondary">
💡 This component uses the country-state-city library which is regularly updated
and includes ISO codes, flags, and comprehensive location data.
💡 This component uses the country-state-city library which is
regularly updated and includes ISO codes, flags, and comprehensive
location data.
</Typography>
</Grid>
</Grid>
@ -358,4 +408,4 @@ const LocationInputDemo: React.FC = () => {
);
};
export { LocationInput };
export { LocationInput };

View File

@ -1,13 +1,13 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import mermaid, { MermaidConfig } from 'mermaid';
import { SxProps } from '@mui/material/styles';
import { Box } from '@mui/material';
import { useResizeObserverAndMutationObserver } from '../hooks/useAutoScrollToBottom';
import React, { useEffect, useRef, useState, useCallback } from "react";
import mermaid, { MermaidConfig } from "mermaid";
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',
securityLevel: "loose",
fontFamily: "Fira Code",
};
interface MermaidProps {
@ -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(() => {
@ -41,22 +41,26 @@ const Mermaid: React.FC<MermaidProps> = (props: MermaidProps) => {
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

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import React, { useState, useCallback, useRef, useEffect } from "react";
import {
Tabs,
Tab,
@ -7,21 +7,21 @@ import {
Paper,
Typography,
LinearProgress,
} from '@mui/material';
} 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';
import { StyledMarkdown } from './StyledMarkdown';
import { Message } from './Message';
import InputIcon from '@mui/icons-material/Input';
import TuneIcon from '@mui/icons-material/Tune';
import ArticleIcon from '@mui/icons-material/Article';
import { StatusBox, StatusIcon } from './ui/StatusIcon';
import { CopyBubble } from './CopyBubble';
import { useAppState } from 'hooks/GlobalContext';
import { StreamingOptions } from 'services/api-client';
import { setDefaultResultOrder } from 'dns';
import { Scrollable } from "./Scrollable";
import { useAuth } from "hooks/AuthContext";
import * as Types from "types/types";
import { StyledMarkdown } from "./StyledMarkdown";
import { Message } from "./Message";
import InputIcon from "@mui/icons-material/Input";
import TuneIcon from "@mui/icons-material/Tune";
import ArticleIcon from "@mui/icons-material/Article";
import { StatusBox, StatusIcon } from "./ui/StatusIcon";
import { CopyBubble } from "./CopyBubble";
import { useAppState } from "hooks/GlobalContext";
import { StreamingOptions } from "services/api-client";
import { setDefaultResultOrder } from "dns";
interface ResumeGeneratorProps {
job: Job;
@ -31,25 +31,34 @@ 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) => {
const ResumeGenerator: React.FC<ResumeGeneratorProps> = (
props: ResumeGeneratorProps
) => {
const { job, candidate, skills, onComplete } = props;
const { setSnack } = useAppState();
const { apiClient, user } = useAuth();
const [resume, setResume] = useState<string>('');
const [prompt, setPrompt] = useState<string>('');
const [systemPrompt, setSystemPrompt] = useState<string>('');
const [resume, setResume] = useState<string>("");
const [prompt, setPrompt] = useState<string>("");
const [systemPrompt, setSystemPrompt] = useState<string>("");
const [generated, setGenerated] = useState<boolean>(false);
const [tabValue, setTabValue] = useState<string>('resume');
const [status, setStatus] = useState<string>('');
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
const [tabValue, setTabValue] = useState<string>("resume");
const [status, setStatus] = useState<string>("");
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(
null
);
const [error, setError] = useState<Types.ChatMessageError | null>(null);
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);
}
};
useEffect(() => {
if (!job || !candidate || !skills || generated) {
@ -63,20 +72,20 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
onMessage: (message: Types.ChatMessageResume) => {
setSystemPrompt(message.systemPrompt || '');
setPrompt(message.prompt || '');
setResume(message.resume || '');
setStatus('');
setSystemPrompt(message.systemPrompt || "");
setPrompt(message.prompt || "");
setResume(message.resume || "");
setStatus("");
},
onStreaming: (chunk: Types.ChatMessageStreaming) => {
if (status === '') {
setStatus('Generating resume...');
if (status === "") {
setStatus("Generating resume...");
setStatusType("generating");
}
setResume(chunk.content);
},
onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content);
console.log("status:", status.content);
setStatusType(status.activity);
setStatus(status.content);
},
@ -84,7 +93,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
onComplete && onComplete(resume);
},
onError: (error: Types.ChatMessageError) => {
console.log('error:', error);
console.log("error:", error);
setStatusType(null);
setStatus(error.content);
setError(error);
@ -92,75 +101,128 @@ 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]);
}, [
job,
candidate,
apiClient,
resume,
skills,
generated,
setSystemPrompt,
setPrompt,
setResume,
]);
const handleSave = useCallback(async () => {
if (!resume) {
setSnack('No resume to save!');
setSnack("No resume to save!");
return;
}
try {
if (!candidate.id || !job.id) {
setSnack('Candidate or job ID is missing.');
setSnack("Candidate or job ID is missing.");
return;
}
const controller = apiClient.saveResume(candidate.id, job.id, resume);
const result = await controller.promise;
if (result.resume.id) {
setSnack('Resume saved successfully!');
setSnack("Resume saved successfully!");
}
} catch (error) {
console.error('Error saving resume:', error);
setSnack('Error saving resume.');
console.error("Error saving resume:", error);
setSnack("Error saving resume.");
}
}, [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

@ -1,7 +1,7 @@
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
import { RefObject, useRef, forwardRef, useImperativeHandle } from 'react';
import { useAutoScrollToBottom } from '../hooks/useAutoScrollToBottom';
import Box from "@mui/material/Box";
import { SxProps, Theme } from "@mui/material";
import { RefObject, useRef, forwardRef, useImperativeHandle } from "react";
import { useAutoScrollToBottom } from "../hooks/useAutoScrollToBottom";
interface ScrollableProps {
children?: React.ReactNode;
@ -14,22 +14,35 @@ 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 || ""}`}
sx={{
display: 'flex',
flexDirection: 'column',
margin: '0 auto',
display: "flex",
flexDirection: "column",
margin: "0 auto",
p: 0,
flexGrow: 1,
overflow: 'auto',
position: 'relative',
overflow: "auto",
position: "relative",
// backgroundColor: '#F5F5F5',
...sx,
}}
@ -40,4 +53,4 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => {
);
});
export { useAutoScrollToBottom, Scrollable };
export { useAutoScrollToBottom, Scrollable };

View File

@ -1,80 +1,85 @@
import React, { useState, useCallback, useImperativeHandle, forwardRef } from 'react';
import { SxProps, Theme } from '@mui/material';
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import React, {
useState,
useCallback,
useImperativeHandle,
forwardRef,
} from "react";
import { SxProps, Theme } from "@mui/material";
import Snackbar, { SnackbarCloseReason } from "@mui/material/Snackbar";
import Alert from "@mui/material/Alert";
import './Snack.css';
import "./Snack.css";
type SeverityType = 'error' | 'info' | 'success' | 'warning' | undefined;
type SeverityType = "error" | "info" | "success" | "warning" | undefined;
type SetSnackType = (message: string, severity?: SeverityType) => void;
interface SnackHandle {
setSnack: SetSnackType;
};
}
interface SnackProps {
sx?: SxProps<Theme>;
className?: string;
};
}
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 Snack = forwardRef<SnackHandle, SnackProps>(
({ className, sx }: SnackProps, ref) => {
const [open, setOpen] = useState(false);
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]);
// 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]
);
useImperativeHandle(ref, () => ({
setSnack: (message: string, severity?: SeverityType) => {
setSnack(message, severity);
}
}));
useImperativeHandle(ref, () => ({
setSnack: (message: string, severity?: SeverityType) => {
setSnack(message, severity);
},
}));
const handleSnackClose = (
event: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
if (reason === 'clickaway') {
return;
}
const handleSnackClose = (
event: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason
) => {
if (reason === "clickaway") {
return;
}
setOpen(false);
};
setOpen(false);
};
return (
<Snackbar
className={className || "Snack"}
sx={{ ...sx }}
open={open}
autoHideDuration={(severity === "success" || severity === "info") ? 1500 : 6000}
onClose={handleSnackClose}>
<Alert
return (
<Snackbar
className={className || "Snack"}
sx={{ ...sx }}
open={open}
autoHideDuration={
severity === "success" || severity === "info" ? 1500 : 6000
}
onClose={handleSnackClose}
severity={severity}
variant="filled"
sx={{ width: '100%' }}
>
{message}
</Alert>
</Snackbar>
)
});
<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

@ -1,37 +1,44 @@
import React from 'react';
import { MuiMarkdown } from 'mui-markdown';
import { useTheme } from '@mui/material/styles';
import { Link } from '@mui/material';
import { BackstoryQuery, BackstoryQueryInterface } from 'components/BackstoryQuery';
import Box from '@mui/material/Box';
import JsonView from '@uiw/react-json-view';
import { vscodeTheme } from '@uiw/react-json-view/vscode';
import { Mermaid } from 'components/Mermaid';
import { Scrollable } from 'components/Scrollable';
import { jsonrepair } from 'jsonrepair';
import { GenerateImage } from 'components/GenerateImage';
import React from "react";
import { MuiMarkdown } from "mui-markdown";
import { useTheme } from "@mui/material/styles";
import { Link } from "@mui/material";
import {
BackstoryQuery,
BackstoryQueryInterface,
} from "components/BackstoryQuery";
import Box from "@mui/material/Box";
import JsonView from "@uiw/react-json-view";
import { vscodeTheme } from "@uiw/react-json-view/vscode";
import { Mermaid } from "components/Mermaid";
import { Scrollable } from "components/Scrollable";
import { jsonrepair } from "jsonrepair";
import { GenerateImage } from "components/GenerateImage";
import './StyledMarkdown.css';
import { BackstoryElementProps } from './BackstoryTab';
import { CandidateQuestion, ChatQuery, ChatSession } from 'types/types';
import { ChatSubmitQueryInterface } from 'components/BackstoryQuery';
import "./StyledMarkdown.css";
import { BackstoryElementProps } from "./BackstoryTab";
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 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;
@ -44,64 +51,89 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
}
if (className === "lang-json" && !streaming) {
try {
let fixed = JSON.parse(jsonrepair(content));
return <Scrollable className="JsonViewScrollable">
<JsonView
className="JsonView"
style={{
...vscodeTheme,
fontSize: "0.8rem",
maxHeight: "10rem",
padding: "14px 0",
overflow: "hidden",
width: "100%",
minHeight: "max-content",
backgroundColor: "transparent",
}}
displayDataTypes={false}
objectSortKeys={false}
collapsed={1}
shortenTextAfterLength={100}
value={fixed}>
<JsonView.String
render={({ children, ...reset }) => {
if (typeof (children) === "string" && children.match("\n")) {
return <pre {...reset} style={{ display: "flex", border: "none", ...reset.style }}>{children}</pre>
}
const fixed = JSON.parse(jsonrepair(content));
return (
<Scrollable className="JsonViewScrollable">
<JsonView
className="JsonView"
style={{
...vscodeTheme,
fontSize: "0.8rem",
maxHeight: "10rem",
padding: "14px 0",
overflow: "hidden",
width: "100%",
minHeight: "max-content",
backgroundColor: "transparent",
}}
/>
</JsonView>
</Scrollable>
displayDataTypes={false}
objectSortKeys={false}
collapsed={1}
shortenTextAfterLength={100}
value={fixed}
>
<JsonView.String
render={({ children, ...reset }) => {
if (
typeof children === "string" &&
children.match("\n")
) {
return (
<pre
{...reset}
style={{
display: "flex",
border: "none",
...reset.style,
}}
>
{children}
</pre>
);
}
}}
/>
</JsonView>
</Scrollable>
);
} catch (e) {
return <pre><code className="JsonRaw">{content}</code></pre>
};
return (
<pre>
<code className="JsonRaw">{content}</code>
</pre>
);
}
}
return <pre><code className={className || ''}>{element.children}</code></pre>;
return (
<pre>
<code className={className || ""}>{element.children}</code>
</pre>
);
},
},
a: {
component: Link,
props: {
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => {
const href = event.currentTarget.getAttribute('href');
const href = event.currentTarget.getAttribute("href");
console.log("StyledMarkdown onClick:", href);
if (href) {
if (href.match(/^\//)) {
event.preventDefault();
window.history.replaceState({}, '', `${href}`);
window.history.replaceState({}, "", `${href}`);
}
}
},
sx: {
wordBreak: "break-all",
color: theme.palette.secondary.main,
textDecoration: 'none',
'&:hover': {
textDecoration: "none",
"&:hover": {
color: theme.palette.custom.highlight,
textDecoration: 'underline',
}
}
}
textDecoration: "underline",
},
},
},
},
BackstoryQuery: {
component: (props: { query: string }) => {
@ -109,16 +141,20 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
try {
const query = JSON.parse(queryString);
const backstoryQuestion: CandidateQuestion = {
question: queryString
}
question: queryString,
};
return submitQuery ? <BackstoryQuery submitQuery={submitQuery} question={query} /> : query.question;
return submitQuery ? (
<BackstoryQuery submitQuery={submitQuery} question={query} />
) : (
query.question
);
} catch (e) {
console.log("StyledMarkdown error:", queryString, e);
return props.query;
}
},
}
},
};
if (chatSession) {
@ -126,31 +162,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);
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

@ -1,34 +1,34 @@
import React, { useEffect, useState, useRef } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Plot from 'react-plotly.js';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import SendIcon from '@mui/icons-material/Send';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import useMediaQuery from '@mui/material/useMediaQuery';
import { SxProps, useTheme } from '@mui/material/styles';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableRow from '@mui/material/TableRow';
import React, { useEffect, useState, useRef } from "react";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Plot from "react-plotly.js";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Button from "@mui/material/Button";
import SendIcon from "@mui/icons-material/Send";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import useMediaQuery from "@mui/material/useMediaQuery";
import { SxProps, useTheme } from "@mui/material/styles";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableRow from "@mui/material/TableRow";
import { Scrollable } from './Scrollable';
import { Scrollable } from "./Scrollable";
import './VectorVisualizer.css';
import { BackstoryPageProps } from './BackstoryTab';
import { useAuth } from 'hooks/AuthContext';
import * as Types from 'types/types';
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext';
import { useNavigate } from 'react-router-dom';
import "./VectorVisualizer.css";
import { BackstoryPageProps } from "./BackstoryTab";
import { useAuth } from "hooks/AuthContext";
import * as Types from "types/types";
import { useAppState, useSelectedCandidate } from "hooks/GlobalContext";
import { useNavigate } from "react-router-dom";
interface VectorVisualizerProps extends BackstoryPageProps {
inline?: boolean;
rag?: Types.ChromaDBGetResponse;
};
}
interface Metadata {
id: string;
@ -46,7 +46,7 @@ const emptyQuerySet: Types.ChromaDBGetResponse = {
name: "Empty",
size: 0,
dimensions: 2,
query: ""
query: "",
};
interface PlotData {
@ -105,36 +105,34 @@ 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
clickmode: "event+select",
paper_bgcolor: "#FFFFFF", // white
plot_bgcolor: "#FFFFFF", // white plot background
font: {
family: 'Roboto, sans-serif',
color: '#2E2E2E', // charcoal black
family: "Roboto, sans-serif",
color: "#2E2E2E", // charcoal black
},
hovermode: 'closest',
hovermode: "closest",
scene: {
bgcolor: '#FFFFFF', // 3D plot background
zaxis: { title: 'Z', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
bgcolor: "#FFFFFF", // 3D plot background
zaxis: { title: "Z", gridcolor: "#cccccc", zerolinecolor: "#aaaaaa" },
},
xaxis: { title: 'X', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
yaxis: { title: 'Y', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
xaxis: { title: "X", gridcolor: "#cccccc", zerolinecolor: "#aaaaaa" },
yaxis: { title: "Y", gridcolor: "#cccccc", zerolinecolor: "#aaaaaa" },
margin: { r: 0, b: 0, l: 0, t: 0 },
legend: {
x: 0.8, // Horizontal position (0 to 1, 0 is left, 1 is right)
y: 0, // Vertical position (0 to 1, 0 is bottom, 1 is top)
xanchor: 'left',
yanchor: 'top',
orientation: 'h' // 'v' for horizontal legend
xanchor: "left",
yanchor: "top",
orientation: "h", // 'v' for horizontal legend
},
showlegend: true // Show the legend
showlegend: true, // Show the legend
};
const normalizeDimension = (arr: number[]): number[] => {
@ -142,64 +140,69 @@ const normalizeDimension = (arr: number[]): number[] => {
const max = Math.max(...arr);
const range = max - min;
if (range === 0) return arr.map(() => 0.5); // flat dimension
return arr.map(v => (v - min) / range);
return arr.map((v) => (v - min) / range);
};
const emojiMap: Record<string, string> = {
query: '🔍',
resume: '📄',
projects: '📁',
jobs: '📁',
'performance-reviews': '📄',
news: '📰',
query: "🔍",
resume: "📄",
projects: "📁",
jobs: "📁",
"performance-reviews": "📄",
news: "📰",
};
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
'performance-reviews': '#8FD0D0', // Light red
'jobs': '#F3aD8F', // 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
};
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) => {
const VectorVisualizer: React.FC<VectorVisualizerProps> = (
props: VectorVisualizerProps
) => {
const { user, apiClient } = useAuth();
const { rag, inline, sx } = props;
const { setSnack } = useAppState();
const [plotData, setPlotData] = useState<PlotData | null>(null);
const [newQuery, setNewQuery] = useState<string>('');
const [querySet, setQuerySet] = useState<Types.ChromaDBGetResponse>(rag || emptyQuerySet);
const [newQuery, setNewQuery] = useState<string>("");
const [querySet, setQuerySet] = useState<Types.ChromaDBGetResponse>(
rag || emptyQuerySet
);
const [result, setResult] = useState<Types.ChromaDBGetResponse | null>(null);
const [view2D, setView2D] = useState<boolean>(true);
const plotlyRef = useRef(null);
const boxRef = useRef<HTMLElement>(null);
const [node, setNode] = useState<Node | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [plotDimensions, setPlotDimensions] = useState({ width: 0, height: 0 });
const 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) */
@ -209,18 +212,28 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
}
const resize = () => {
requestAnimationFrame(() => {
const plotContainer = document.querySelector('.plot-container') as HTMLElement;
const svgContainer = document?.querySelector('.svg-container') as HTMLElement;
const plotContainer = document.querySelector(
".plot-container"
) as HTMLElement;
const svgContainer = document?.querySelector(
".svg-container"
) as HTMLElement;
if (plotContainer && svgContainer) {
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();
});
@ -237,13 +250,13 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
const result = await apiClient.getCandidateVectors(view2D ? 2 : 3);
setResult(result);
} catch (error) {
console.error('Error obtaining collection information:', error);
console.error("Error obtaining collection information:", error);
setSnack("Unable to obtain collection information.", "error");
};
}
};
fetchCollection();
}, [result, setSnack, view2D])
}, [result, setSnack, view2D]);
useEffect(() => {
if (!result || !result.embeddings) return;
@ -251,49 +264,49 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
const full: Types.ChromaDBGetResponse = {
...result,
ids: [...result.ids || []],
documents: [...result.documents || []],
ids: [...(result.ids || [])],
documents: [...(result.documents || [])],
embeddings: [...result.embeddings],
metadatas: [...result.metadatas || []],
metadatas: [...(result.metadatas || [])],
};
let is2D = full.embeddings.every((v: number[]) => v.length === 2);
let is3D = full.embeddings.every((v: number[]) => v.length === 3);
const is2D = full.embeddings.every((v: number[]) => v.length === 2);
const is3D = full.embeddings.every((v: number[]) => v.length === 3);
if ((view2D && !is2D) || (!view2D && !is3D)) {
return;
}
if (!is2D && !is3D) {
console.warn('Modified vectors are neither 2D nor 3D');
console.warn("Modified vectors are neither 2D nor 3D");
return;
}
let query: Types.ChromaDBGetResponse = {
const query: Types.ChromaDBGetResponse = {
ids: [],
documents: [],
embeddings: [],
metadatas: [],
distances: [],
query: '',
query: "",
size: 0,
dimensions: 2,
name: ''
name: "",
};
let filtered: Types.ChromaDBGetResponse = {
const filtered: Types.ChromaDBGetResponse = {
ids: [],
documents: [],
embeddings: [],
metadatas: [],
distances: [],
query: '',
query: "",
size: 0,
dimensions: 2,
name: ''
name: "",
};
/* Loop through all items and divide into two groups:
* filtered is for any item not in the querySet
* query is for any item that is in the querySet
*/
* filtered is for any item not in the querySet
* query is for any item that is in the querySet
*/
full.ids.forEach((id, index) => {
const foundIndex = querySet.ids.indexOf(id);
/* Update metadata to hold the doc content and id */
@ -301,7 +314,9 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
full.metadatas[index].content = full.documents[index];
if (foundIndex !== -1) {
/* The query set will contain the distance to the query */
full.metadatas[index].distance = querySet.distances ? querySet.distances[foundIndex] : undefined;
full.metadatas[index].distance = querySet.distances
? querySet.distances[foundIndex]
: undefined;
query.ids.push(id);
query.documents.push(full.documents[index]);
query.embeddings.push(full.embeddings[index]);
@ -317,64 +332,104 @@ 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.ids.unshift("query");
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 });
if (
!view2D &&
querySet.umapEmbedding3D &&
querySet.umapEmbedding3D.length
) {
query.ids.unshift("query");
query.metadatas.unshift({
id: "query",
docType: "query",
content: querySet.query || "",
distance: 0,
});
query.embeddings.unshift(querySet.umapEmbedding3D);
}
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_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_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 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_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 data: any = [{
name: 'All data',
x: filtered_x,
y: filtered_y,
mode: 'markers',
marker: {
size: filtered_sizes,
symbol: 'circle',
color: filtered_colors,
opacity: 1
const data: any = [
{
name: "All data",
x: filtered_x,
y: filtered_y,
mode: "markers",
marker: {
size: filtered_sizes,
symbol: "circle",
color: filtered_colors,
opacity: 1,
},
text: filtered.ids,
customdata: filtered.metadatas,
type: is3D ? "scatter3d" : "scatter",
hovertemplate: "&nbsp;",
},
text: filtered.ids,
customdata: filtered.metadatas,
type: is3D ? 'scatter3d' : 'scatter',
hovertemplate: '&nbsp;',
}, {
name: 'Query',
{
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}',
}];
type: is3D ? "scatter3d" : "scatter",
hovertemplate: "%{text}",
},
];
if (is3D) {
data[0].z = filtered_z;
@ -382,79 +437,102 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
}
setPlotData(data);
}, [result, querySet, view2D]);
const handleKeyPress = (event: any) => {
if (event.key === 'Enter') {
if (event.key === "Enter") {
sendQuery(newQuery);
}
};
const sendQuery = async (query: string) => {
if (!query.trim()) return;
setNewQuery('');
setNewQuery("");
try {
const result = await apiClient.getCandidateSimilarContent(query);
console.log(result);
setQuerySet(result);
} catch (error) {
const msg = `Error obtaining similar content to ${query}.`
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");
};
}
};
const onNodeSelected = (metadata: any) => {
let node: Node;
console.log(metadata);
if (metadata.docType === 'query') {
if (metadata.docType === "query") {
node = {
...metadata,
content: `Similarity results for the query **${querySet.query || ''}**
content: `Similarity results for the query **${querySet.query || ""}**
The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '2' : '3'}-dimensional space. Larger dots represent relative similarity in N-dimensional space.
The scatter graph shows the query in N-dimensional space, mapped to ${
view2D ? "2" : "3"
}-dimensional space. Larger dots represent relative similarity in N-dimensional space.
`,
emoji: emojiMap[metadata.docType],
sx: {
m: 0.5,
p: 2,
width: '3rem',
width: "3rem",
display: "flex",
alignContent: "center",
justifyContent: "center",
flexGrow: 0,
flexWrap: "wrap",
backgroundColor: colorMap[metadata.docType] || '#ff8080',
}
}
backgroundColor: colorMap[metadata.docType] || "#ff8080",
},
};
setNode(node);
return;
}
@ -462,8 +540,8 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
node = {
content: `Loading...`,
...metadata,
emoji: emojiMap[metadata.docType] || '❓',
}
emoji: emojiMap[metadata.docType] || "❓",
};
setNode(node);
@ -471,100 +549,186 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
};
return (
<Box className="VectorVisualizer"
<Box
className="VectorVisualizer"
ref={boxRef}
sx={{
...sx
}}>
...sx,
}}
>
<Box sx={{ p: 0, m: 0, gap: 0 }}>
<Paper sx={{
p: 0.5, m: 0,
display: "flex",
flexGrow: 0,
height: isMobile ? "auto" : "auto", //"320px",
minHeight: isMobile ? "auto" : "auto", //"320px",
maxHeight: isMobile ? "auto" : "auto", //"320px",
position: "relative",
flexDirection: "column"
}}>
<Paper
sx={{
p: 0.5,
m: 0,
display: "flex",
flexGrow: 0,
height: isMobile ? "auto" : "auto", //"320px",
minHeight: isMobile ? "auto" : "auto", //"320px",
maxHeight: isMobile ? "auto" : "auto", //"320px",
position: "relative",
flexDirection: "column",
}}
>
<FormControlLabel
sx={{
display: "flex",
position: "relative",
width: "fit-content",
ml: 1,
mb: '-2.5rem',
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",
flexGrow: 1,
minHeight: '240px',
minHeight: "240px",
padding: 0,
margin: 0,
width: "100%",
height: "100%",
overflow: "hidden",
}}
layout={{...layout, width: plotDimensions.width, height: plotDimensions.height }}
layout={{
...layout,
width: plotDimensions.width,
height: plotDimensions.height,
}}
/>
</Paper>
<Paper sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", mt: 0.5, p: 0.5, flexGrow: 1, minHeight: "fit-content" }}>
{node !== null &&
<Box sx={{ display: "flex", fontSize: "0.75rem", flexDirection: "column", flexGrow: 1, maxWidth: "100%", flexBasis: 1, maxHeight: "min-content" }}>
<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" } }}>
<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",
},
}}
>
<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.
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={{
display: 'flex',
flexDirection: 'column',
display: "flex",
flexDirection: "column",
m: 0,
p: 0.5,
pl: 1,
@ -573,32 +737,87 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
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 }}>
{querySet.query !== undefined && querySet.query !== '' && `Query: ${querySet.query}`}
{querySet.ids.length === 0 && "Enter query below to perform a similarity search."}
{!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."}
</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
@ -610,10 +829,18 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
id="QueryInput"
/>
<Tooltip title="Send">
<Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(newQuery); }}><SendIcon /></Button>
<Button
sx={{ m: 1 }}
variant="contained"
onClick={() => {
sendQuery(newQuery);
}}
>
<SendIcon />
</Button>
</Tooltip>
</Box>
}
)}
</Box>
</Box>
);
@ -621,6 +848,4 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
export type { VectorVisualizerProps };
export {
VectorVisualizer,
};
export { VectorVisualizer };

View File

@ -1,23 +1,20 @@
// components/layout/BackstoryLayout.tsx
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement, useEffect, useState } from "react";
import { Outlet, useLocation, Routes, Route } from "react-router-dom";
import { Box, Container, Paper } from '@mui/material';
import { Box, Container, Paper } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { SxProps, Theme } from '@mui/material';
import { darken } from '@mui/material/styles';
import { Header } from 'components/layout/Header';
import { Scrollable } from 'components/Scrollable';
import { Footer } from 'components/layout/Footer';
import { Snack, SetSnackType } from 'components/Snack';
import { User } from 'types/types';
import { SxProps, Theme } from "@mui/material";
import { darken } from "@mui/material/styles";
import { Header } from "components/layout/Header";
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 { AuthProvider, useAuth, ProtectedRoute } from 'hooks/AuthContext';
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext';
import {
getMainNavigationItems,
getAllRoutes,
} from 'config/navigationConfig';
import { NavigationItem } from 'types/navigation';
import { AuthProvider, useAuth, ProtectedRoute } from "hooks/AuthContext";
import { useAppState, useSelectedCandidate } from "hooks/GlobalContext";
import { getMainNavigationItems, getAllRoutes } from "config/navigationConfig";
import { NavigationItem } from "types/navigation";
// Legacy type for backward compatibility
export type NavigationLinkType = {
@ -42,17 +39,20 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps) => {
flexGrow: 1,
p: "0 !important",
m: "0 auto !important",
maxWidth: '1024px',
maxWidth: "1024px",
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={{
@ -61,11 +61,12 @@ const BackstoryPageContainer = (props: BackstoryPageContainerProps) => {
m: 0,
p: 0.5,
minHeight: "min-content",
backgroundColor: 'background.paper',
backgroundColor: "background.paper",
borderRadius: 0.5,
maxWidth: '100%',
maxWidth: "100%",
flexDirection: "column",
}}>
}}
>
{children}
</Paper>
</Box>
@ -78,8 +79,10 @@ interface BackstoryLayoutProps {
chatRef: React.Ref<any>;
}
const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutProps) => {
const { page, chatRef, } = props;
const BackstoryLayout: React.FC<BackstoryLayoutProps> = (
props: BackstoryLayoutProps
) => {
const { page, chatRef } = props;
const { setSnack } = useAppState();
const navigate = useNavigate();
const location = useLocation();
@ -88,7 +91,9 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
useEffect(() => {
const userType = user?.userType || null;
setNavigationItems(getMainNavigationItems(userType, user?.isAdmin ? true : false));
setNavigationItems(
getMainNavigationItems(userType, user?.isAdmin ? true : false)
);
}, [user]);
// Generate dynamic routes from navigation config
@ -99,57 +104,67 @@ 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
<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={{
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 */
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),
backgroundColor: (theme) =>
darken(theme.palette.background.default, 0.4),
height: "100%",
maxHeight: "100%",
minHeight: "100%",
@ -170,9 +185,7 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
{(guest || user) && (
<>
<Outlet />
<Routes>
{generateRoutes()}
</Routes>
<Routes>{generateRoutes()}</Routes>
</>
)}
{location.pathname === "/" && <Footer />}
@ -183,6 +196,4 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
);
};
export {
BackstoryLayout
};
export { BackstoryLayout };

View File

@ -2,11 +2,11 @@
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 { 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";
interface BackstoryDynamicRoutesProps extends BackstoryPageProps {
@ -14,7 +14,9 @@ interface BackstoryDynamicRoutesProps extends BackstoryPageProps {
user?: User | null;
}
const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNode => {
const getBackstoryDynamicRoutes = (
props: BackstoryDynamicRoutesProps
): ReactNode => {
const { user, chatRef } = props;
const userType = user?.userType || null;
const isAdmin = user?.isAdmin ? true : false;
@ -22,25 +24,30 @@ 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

@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
import {
Paper,
Box,
@ -10,8 +10,8 @@ import {
IconButton,
Stack,
useMediaQuery,
} from '@mui/material';
import { styled, useTheme } from '@mui/material/styles';
} from "@mui/material";
import { styled, useTheme } from "@mui/material/styles";
import {
Facebook,
Twitter,
@ -21,7 +21,7 @@ import {
Email,
LocationOn,
Copyright,
} from '@mui/icons-material';
} from "@mui/icons-material";
// Styled components
const FooterContainer = styled(Paper)(({ theme }) => ({
@ -34,12 +34,12 @@ const FooterContainer = styled(Paper)(({ theme }) => ({
const FooterLink = styled(Link)(({ theme }) => ({
color: theme.palette.primary.contrastText,
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
textDecoration: "none",
"&:hover": {
textDecoration: "underline",
color: theme.palette.action.active,
},
display: 'block',
display: "block",
marginBottom: theme.spacing(1),
}));
@ -50,19 +50,19 @@ const FooterHeading = styled(Typography)(({ theme }) => ({
}));
const ContactItem = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
marginBottom: theme.spacing(1.5),
}));
// Footer component
const Footer = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const currentYear = new Date().getFullYear();
return (
<FooterContainer elevation={0} >
<FooterContainer elevation={0}>
<Container maxWidth="lg">
<Grid container spacing={4} justifyContent="space-between">
{/* About Company */}
@ -73,14 +73,15 @@ const Footer = () => {
component="div"
sx={{
fontWeight: 700,
letterSpacing: '.2rem',
letterSpacing: ".2rem",
marginBottom: 2,
}}
>
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.
Helping candidates share their professional journey and connect
with the right employers through compelling backstories.
</Typography>
<Stack direction="row">
{/* <IconButton
@ -119,12 +120,17 @@ const Footer = () => {
sx={{
color: theme.palette.primary.contrastText,
mr: 1,
'&:hover': {
backgroundColor: 'rgba(211, 205, 191, 0.1)',
"&:hover": {
backgroundColor: "rgba(211, 205, 191, 0.1)",
color: theme.palette.action.active,
}
},
}}
onClick={() => window.open('https://www.linkedin.com/in/james-ketrenos/', '_blank')}
onClick={() =>
window.open(
"https://www.linkedin.com/in/james-ketrenos/",
"_blank"
)
}
>
<LinkedIn />
</IconButton>
@ -163,51 +169,63 @@ 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>
<Email sx={{ mr: 1, fontSize: 20 }} />
<FooterLink href="mailto:james_backstory@ketrenos.com">Email</FooterLink>
<FooterLink href="mailto:james_backstory@ketrenos.com">
Email
</FooterLink>
</ContactItem>
{/* <ContactItem>
<Phone sx={{ mr: 1, fontSize: 20 }} />
@ -222,7 +240,7 @@ const Footer = () => {
</Grid>
</Grid>
<Divider sx={{ my: 4, backgroundColor: 'rgba(211, 205, 191, 0.2)' }} />
<Divider sx={{ my: 4, backgroundColor: "rgba(211, 205, 191, 0.2)" }} />
{/* Bottom Footer */}
<Grid container spacing={2} alignItems="center">
@ -235,18 +253,28 @@ const Footer = () => {
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
{false && <>
<Stack
direction={isMobile ? 'column' : 'row'}
spacing={isMobile ? 1 : 3}
sx={{ textAlign: { xs: 'left', md: 'right' } }}
>
<FooterLink href="/terms" sx={{ mb: 0 }}>Terms of Service</FooterLink>
<FooterLink href="/privacy" sx={{ mb: 0 }}>Privacy Policy</FooterLink>
<FooterLink href="/accessibility" sx={{ mb: 0 }}>Accessibility</FooterLink>
<FooterLink href="/sitemap" sx={{ mb: 0 }}>Sitemap</FooterLink>
</Stack>
</>}
{false && (
<>
<Stack
direction={isMobile ? "column" : "row"}
spacing={isMobile ? 1 : 3}
sx={{ textAlign: { xs: "left", md: "right" } }}
>
<FooterLink href="/terms" sx={{ mb: 0 }}>
Terms of Service
</FooterLink>
<FooterLink href="/privacy" sx={{ mb: 0 }}>
Privacy Policy
</FooterLink>
<FooterLink href="/accessibility" sx={{ mb: 0 }}>
Accessibility
</FooterLink>
<FooterLink href="/sitemap" sx={{ mb: 0 }}>
Sitemap
</FooterLink>
</Stack>
</>
)}
</Grid>
</Grid>
</Container>
@ -254,6 +282,4 @@ const Footer = () => {
);
};
export {
Footer
};
export { Footer };

View File

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

View File

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

View File

@ -1,39 +1,39 @@
import React from 'react';
import {
Typography,
Avatar,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import React from "react";
import { Typography, Avatar } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import 'components/layout/Header.css';
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

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

View File

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

View File

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

View File

@ -1,25 +1,23 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import useMediaQuery from '@mui/material/useMediaQuery';
import { SxProps, useTheme } from '@mui/material/styles';
import React, { useEffect, useRef, useState } from "react";
import Box from "@mui/material/Box";
import useMediaQuery from "@mui/material/useMediaQuery";
import { SxProps, useTheme } from "@mui/material/styles";
import './ComingSoon.css';
import "./ComingSoon.css";
type ComingSoonProps = {
children?: React.ReactNode;
}
const ComingSoon: React.FC<ComingSoonProps> = (props : ComingSoonProps) => {
const { children } = props;
const theme = useTheme();
return (
<Box className="ComingSoon">
<Box className="ComingSoon-label">Coming Soon</Box>
{children}
</Box>
);
};
export {
ComingSoon
};
const ComingSoon: React.FC<ComingSoonProps> = (props: ComingSoonProps) => {
const { children } = props;
const theme = useTheme();
return (
<Box className="ComingSoon">
<Box className="ComingSoon-label">Coming Soon</Box>
{children}
</Box>
);
};
export { ComingSoon };

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

@ -1,22 +1,20 @@
import Box from '@mui/material/Box';
import './LoginRestricted.css';
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 React, { useEffect, useRef, useState } from "react";
import {
Box,
Link,
Typography,
Avatar,
Grid,
SxProps,
CardActions,
Chip,
Stack,
CardHeader,
Button,
LinearProgress,
IconButton,
Tooltip,
Card,
CardContent,
@ -23,11 +23,11 @@ import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Tabs,
Tab
} from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
DialogActions,
Tabs,
Tab,
} from "@mui/material";
import PrintIcon from "@mui/icons-material/Print";
import {
Delete as DeleteIcon,
Restore as RestoreIcon,
@ -38,19 +38,19 @@ import {
Person as PersonIcon,
Schedule as ScheduleIcon,
Visibility as VisibilityIcon,
VisibilityOff as VisibilityOffIcon
} from '@mui/icons-material';
import PreviewIcon from '@mui/icons-material/Preview';
import EditDocumentIcon from '@mui/icons-material/EditDocument';
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 { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
import { StyledMarkdown } from 'components/StyledMarkdown';
import { Resume } from 'types/types';
import { BackstoryTextField } from 'components/BackstoryTextField';
import { JobInfo } from './JobInfo';
import { useAuth } from "hooks/AuthContext";
import { useAppState } from "hooks/GlobalContext";
import { StyledMarkdown } from "components/StyledMarkdown";
import { Resume } from "types/types";
import { BackstoryTextField } from "components/BackstoryTextField";
import { JobInfo } from "./JobInfo";
interface ResumeInfoProps {
resume: Resume;
@ -64,27 +64,26 @@ 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 [editContent, setEditContent] = useState<string>('');
const [printDialogOpen, setPrintDialogOpen] = useState<boolean>(false);
const [editContent, setEditContent] = useState<string>("");
const [saving, setSaving] = useState<boolean>(false);
const contentRef = useRef<HTMLDivElement>(null);
const [tabValue, setTabValue] = useState("markdown");
const printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({ contentRef: printContentRef, pageStyle: '@page { margin: 10px; }' });
const [tabValue, setTabValue] = useState("markdown");
const printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({
contentRef: printContentRef,
pageStyle: "@page { margin: 10px; }",
});
useEffect(() => {
if (resume && resume.id !== activeResume?.id) {
@ -100,14 +99,14 @@ 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.');
setSnack("Resume deleted successfully.");
} catch (error) {
setSnack('Failed to delete resume.');
setSnack("Failed to delete resume.");
}
}
};
@ -119,12 +118,19 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const handleSave = async () => {
setSaving(true);
try {
const result = await apiClient.updateResume(activeResume.id || '', editContent);
const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() };
setActiveResume(updatedResume);
setSnack('Resume updated successfully.');
const result = await apiClient.updateResume(
activeResume.id || "",
editContent
);
const updatedResume = {
...activeResume,
resume: editContent,
updatedAt: new Date(),
};
setActiveResume(updatedResume);
setSnack("Resume updated successfully.");
} catch (error) {
setSnack('Failed to update resume.');
setSnack("Failed to update resume.");
} finally {
setSaving(false);
}
@ -140,54 +146,68 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
}
const formatDate = (date: Date | undefined) => {
if (!date) return 'N/A';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
if (!date) return "N/A";
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "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={{
sx={{
display: "flex",
borderColor: 'transparent',
borderColor: "transparent",
borderWidth: 2,
borderStyle: 'solid',
transition: 'all 0.3s ease',
borderStyle: "solid",
transition: "all 0.3s ease",
flexDirection: "column",
minWidth: 0,
opacity: deleted ? 0.5 : 1.0,
backgroundColor: deleted ? theme.palette.action.disabledBackground : theme.palette.background.paper,
backgroundColor: deleted
? theme.palette.action.disabledBackground
: theme.palette.background.paper,
pointerEvents: deleted ? "none" : "auto",
...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}>
{activeResume.candidate && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<PersonIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold">
Candidate
@ -197,10 +217,17 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
<Typography variant="body2" color="text.secondary">
{activeResume.candidate?.fullName || activeResume.candidateId}
</Typography>
{activeResume.job && (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mt: 1,
}}
>
<WorkIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold">
Job
@ -213,10 +240,10 @@ 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 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<ScheduleIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold">
Timeline
@ -229,7 +256,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
Updated: {formatDate(activeResume.updatedAt)}
</Typography>
<Typography variant="caption" color="text.secondary">
Resume ID: {activeResume.id}
Resume ID: {activeResume.id}
</Typography>
</Stack>
</Grid>
@ -240,7 +267,10 @@ 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" />}
@ -256,21 +286,27 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
}
/>
<CardContent sx={{ p: 0 }}>
<Box sx={{ position: 'relative' }}>
<Box sx={{ position: "relative" }}>
<Typography
ref={contentRef}
variant="body2"
component="div"
sx={{
display: '-webkit-box',
WebkitLineClamp: isContentExpanded ? 'unset' : (variant === "small" ? 5 : variant === "minimal" ? 3 : 10),
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: "-webkit-box",
WebkitLineClamp: isContentExpanded
? "unset"
: variant === "small"
? 5
: variant === "minimal"
? 3
: 10,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
lineHeight: 1.6,
fontSize: "0.875rem !important",
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
whiteSpace: "pre-wrap",
fontFamily: "monospace",
backgroundColor: theme.palette.action.hover,
p: 2,
borderRadius: 1,
@ -279,15 +315,23 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
>
{activeResume.resume}
</Typography>
{shouldShowMoreButton && variant !== "all" && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
<Box
sx={{ display: "flex", justifyContent: "center", mt: 1 }}
>
<Button
variant="text"
size="small"
onClick={() => setIsContentExpanded(!isContentExpanded)}
startIcon={isContentExpanded ? <VisibilityOffIcon /> : <VisibilityIcon />}
sx={{ fontSize: '0.75rem' }}
startIcon={
isContentExpanded ? (
<VisibilityOffIcon />
) : (
<VisibilityIcon />
)
}
sx={{ fontSize: "0.75rem" }}
>
{isContentExpanded ? "Show Less" : "Show More"}
</Button>
@ -298,46 +342,64 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Card>
)}
{variant === 'all' && activeResume.resume && (
{variant === "all" && activeResume.resume && (
<Box sx={{ mt: 2 }}>
<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: "row",
pl: 1,
pr: 1,
gap: 1,
alignContent: "center",
height: "32px",
}}
>
<Tooltip title="Edit Resume">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); handleEditOpen(); }}
onClick={(e) => {
e.stopPropagation();
handleEditOpen();
}}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete Resume">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); deleteResume(activeResume.id); }}
onClick={(e) => {
e.stopPropagation();
deleteResume(activeResume.id);
}}
>
<DeleteIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reset Resume">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); handleReset(); }}
onClick={(e) => {
e.stopPropagation();
handleReset();
}}
>
<RestoreIcon />
</IconButton>
</Tooltip>
</Box>
{saving && (
<Box sx={{ mt: 1 }}>
<LinearProgress />
@ -349,131 +411,153 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Box>
)}
{/* Print Dialog */}
<Dialog
open={printDialogOpen}
onClose={() => { }}//setPrintDialogOpen(false)}
maxWidth="lg"
fullWidth
fullScreen={true}
>
<StyledMarkdown
content={activeResume.resume}
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}} />
</Dialog>
{/* Print Dialog */}
<Dialog
open={printDialogOpen}
onClose={() => {}} //setPrintDialogOpen(false)}
maxWidth="lg"
fullWidth
fullScreen={true}
>
<StyledMarkdown
content={activeResume.resume}
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: "auto" /* Scroll if content overflows */,
}}
/>
</Dialog>
{/* Edit Dialog */}
<Dialog
open={editDialogOpen}
<Dialog
open={editDialogOpen}
onClose={() => setEditDialogOpen(false)}
maxWidth="lg"
fullWidth
disableEscapeKeyDown={true}
fullScreen={true}
disableEscapeKeyDown={true}
fullScreen={true}
>
<DialogTitle>
Edit Resume Content
<Typography variant="caption" display="block" color="text.secondary">
Resume for {activeResume.candidate?.fullName || activeResume.candidateId}, {activeResume.job?.title || 'No Job Title Assigned'}, {activeResume.job?.company || 'No Company Assigned'}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume ID: # {activeResume.id}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Last saved: {activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume for{" "}
{activeResume.candidate?.fullName || activeResume.candidateId},{" "}
{activeResume.job?.title || "No Job Title Assigned"},{" "}
{activeResume.job?.company || "No Company Assigned"}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume ID: # {activeResume.id}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Last saved:{" "}
{activeResume.updatedAt
? new Date(activeResume.updatedAt).toLocaleString()
: "N/A"}
</Typography>
</DialogTitle>
<DialogContent sx={{ position: "relative", display: "flex", flexDirection: "column", height: "100%" }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="job" icon={<WorkIcon />} label="Job" />
<Tab value="print" icon={<PrintIcon />} label="Print" />
</Tabs>
<Box ref={printContentRef} sx={{
display: "flex", flexDirection: "column",
height: "100%", /* Restrict to main-container's height */
width: "100%",
minHeight: 0,/* Prevent flex overflow */
//maxHeight: "min-content",
"& > *:not(.Scrollable)": {
flexShrink: 0, /* Prevent shrinking */
},
position: "relative",
}}>
{tabValue === "markdown" &&
<BackstoryTextField
value={editContent}
onChange={(value) => setEditContent(value)}
style={{
position: "relative",
// maxHeight: "100%",
height: "100%",
width: "100%",
display: "flex",
minHeight: "100%",
flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}}
placeholder="Enter resume content..."
/>
}
{tabValue === "preview" && <>
<StyledMarkdown
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}}
content={editContent} />
<Box sx={{ pb: 2 }}></Box></>
}
{tabValue === "job" && activeResume.job && <JobInfo
variant="all"
job={activeResume.job}
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}}
/>}
</Box>
<DialogContent
sx={{
position: "relative",
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab
value="markdown"
icon={<EditDocumentIcon />}
label="Markdown"
/>
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="job" icon={<WorkIcon />} label="Job" />
<Tab value="print" icon={<PrintIcon />} label="Print" />
</Tabs>
<Box
ref={printContentRef}
sx={{
display: "flex",
flexDirection: "column",
height: "100%" /* Restrict to main-container's height */,
width: "100%",
minHeight: 0 /* Prevent flex overflow */,
//maxHeight: "min-content",
"& > *:not(.Scrollable)": {
flexShrink: 0 /* Prevent shrinking */,
},
position: "relative",
}}
>
{tabValue === "markdown" && (
<BackstoryTextField
value={editContent}
onChange={(value) => setEditContent(value)}
style={{
position: "relative",
// maxHeight: "100%",
height: "100%",
width: "100%",
display: "flex",
minHeight: "100%",
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: "auto" /* Scroll if content overflows */,
}}
placeholder="Enter resume content..."
/>
)}
{tabValue === "preview" && (
<>
<StyledMarkdown
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: "auto" /* Scroll if content overflows */,
}}
content={editContent}
/>
<Box sx={{ pb: 2 }}></Box>
</>
)}
{tabValue === "job" && activeResume.job && (
<JobInfo
variant="all"
job={activeResume.job}
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: "auto" /* Scroll if content overflows */,
}}
/>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
variant="contained"
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={saving}
startIcon={<SaveIcon />}
>
{saving ? 'Saving...' : 'Save'}
{saving ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
@ -481,4 +565,4 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
);
};
export { ResumeInfo };
export { ResumeInfo };

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
import {
SyncAlt,
Favorite,
@ -9,18 +9,18 @@ import {
Image,
Psychology,
Build,
} from '@mui/icons-material';
import { styled } from '@mui/material/styles';
import * as Types from 'types/types';
import { Box } from '@mui/material';
} from "@mui/icons-material";
import { styled } from "@mui/material/styles";
import * as Types from "types/types";
import { Box } from "@mui/material";
interface StatusIconProps {
type: Types.ApiActivityType;
type: Types.ApiActivityType;
}
const StatusBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
padding: theme.spacing(1, 2),
backgroundColor: theme.palette.background.paper,
@ -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

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

View File

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

View File

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

View File

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

View File

@ -1,76 +1,130 @@
import React from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { Box, Container, Paper, Typography, Grid, CssBaseline } from '@mui/material';
import { backstoryTheme } from 'BackstoryTheme';
import React from "react";
import { ThemeProvider } from "@mui/material/styles";
import {
Box,
Container,
Paper,
Typography,
Grid,
CssBaseline,
} from "@mui/material";
import { backstoryTheme } from "BackstoryTheme";
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
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 }}>
<Box
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{[
'Create comprehensive professional profiles',
'Configure AI assistant for employer Q&A',
'Generate tailored resumes for specific jobs',
'Track profile engagement metrics'
"Create comprehensive professional profiles",
"Configure AI assistant for employer Q&A",
"Generate tailored resumes for specific jobs",
"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
key={index}
sx={{ display: "flex", alignItems: "center", gap: 1.5 }}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: "secondary.main",
}}
/>
<Typography variant="body2">{item}</Typography>
</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 }}>
<Box
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{[
'Search for candidates with specific skills',
'Interact with candidate AI assistants',
'Generate position-specific candidate resumes',
'Manage talent pools and job listings'
"Search for candidates with specific skills",
"Interact with candidate AI assistants",
"Generate position-specific candidate resumes",
"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
key={index}
sx={{ display: "flex", alignItems: "center", gap: 1.5 }}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: "primary.main",
}}
/>
<Typography variant="body2">{item}</Typography>
</Box>
))}
@ -78,37 +132,81 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Box>
</Grid>
</Grid>
{/* UI Components */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}>
<Box
sx={{
p: 3,
bgcolor: "background.paper",
borderRadius: 2,
mb: 4,
boxShadow: 1,
}}
>
<Typography
variant="h5"
sx={{ color: "text.primary", mb: 3, fontWeight: "bold" }}
>
Key UI Components
</Typography>
<Grid container spacing={2}>
{[
{ title: 'Dashboards', description: 'Role-specific dashboards with card-based metrics and action items' },
{ title: 'Profile Editors', description: 'Comprehensive forms for managing professional information' },
{ title: 'Resume Builder', description: 'AI-powered tools for creating tailored resumes' },
{ title: 'Q&A Interface', description: 'Chat-like interface for employer-candidate AI interaction' },
{ title: 'Search & Filters', description: 'Advanced search with multiple filter categories' },
{ title: 'Analytics Dashboards', description: 'Visual metrics for tracking engagement and performance' }
{
title: "Dashboards",
description:
"Role-specific dashboards with card-based metrics and action items",
},
{
title: "Profile Editors",
description:
"Comprehensive forms for managing professional information",
},
{
title: "Resume Builder",
description:
"AI-powered tools for creating tailored resumes",
},
{
title: "Q&A Interface",
description:
"Chat-like interface for employer-candidate AI interaction",
},
{
title: "Search & Filters",
description:
"Advanced search with multiple filter categories",
},
{
title: "Analytics Dashboards",
description:
"Visual metrics for tracking engagement and performance",
},
].map((component, index) => (
<Grid size={{xs: 12, sm: 6, md: 4}} key={index}>
<Box sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
height: '100%',
transition: 'all 0.2s ease-in-out',
'&:hover': {
bgcolor: 'rgba(212, 160, 23, 0.05)',
borderColor: 'action.active',
transform: 'translateY(-2px)',
boxShadow: 1
}
}}>
<Typography variant="h6" sx={{ color: 'secondary.main', mb: 1, fontWeight: 'medium' }}>
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<Box
sx={{
p: 2,
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
height: "100%",
transition: "all 0.2s ease-in-out",
"&:hover": {
bgcolor: "rgba(212, 160, 23, 0.05)",
borderColor: "action.active",
transform: "translateY(-2px)",
boxShadow: 1,
},
}}
>
<Typography
variant="h6"
sx={{
color: "secondary.main",
mb: 1,
fontWeight: "medium",
}}
>
{component.title}
</Typography>
<Typography variant="body2" color="text.secondary">
@ -119,51 +217,77 @@ 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'],
color: 'secondary.main',
borderColor: 'secondary.main'
{
title: "Candidate Navigation",
items: [
"Dashboard",
"Profile",
"Backstory",
"Resumes",
"Q&A Setup",
"Analytics",
"Settings",
],
color: "secondary.main",
borderColor: "secondary.main",
},
{
title: 'Employer Navigation',
items: ['Dashboard', 'Search', 'Saved', 'Jobs', 'Company', 'Analytics', 'Settings'],
color: 'primary.main',
borderColor: 'primary.main'
{
title: "Employer Navigation",
items: [
"Dashboard",
"Search",
"Saved",
"Jobs",
"Company",
"Analytics",
"Settings",
],
color: "primary.main",
borderColor: "primary.main",
},
{
title: "Public Navigation",
items: ["Home", "Docs", "Pricing", "Login", "Register"],
color: "custom.highlight",
borderColor: "custom.highlight",
},
{
title: 'Public Navigation',
items: ['Home', 'Docs', 'Pricing', 'Login', 'Register'],
color: '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' }}>
<Typography
key={idx}
sx={{ color: nav.color, fontWeight: "medium" }}
>
{item}
</Typography>
))}
@ -172,86 +296,108 @@ const BackstoryUIOverviewPage: React.FC = () => {
</Grid>
))}
</Grid>
{/* Connection Points */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, mb: 4, boxShadow: 1 }}>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}>
<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 }}>
<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: "Candidate Profile", right: "Employer Search" },
{ left: "Q&A Setup", right: "Q&A Interface" },
{ left: "Resume Generator", right: "Job Posts" },
].map((connection, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
<Box
key={index}
sx={{
display: "flex",
alignItems: "center",
mb: index < 2 ? 5 : 0,
position: 'relative',
position: "relative",
zIndex: 1,
}}
>
<Box sx={{
flex: 1,
display: 'flex',
justifyContent: 'flex-end',
pr: 3
}}>
<Box sx={{
display: 'inline-block',
bgcolor: 'rgba(74, 122, 125, 0.1)',
p: 2,
borderRadius: 2,
color: 'secondary.main',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'rgba(74, 122, 125, 0.3)'
}}>
<Box
sx={{
flex: 1,
display: "flex",
justifyContent: "flex-end",
pr: 3,
}}
>
<Box
sx={{
display: "inline-block",
bgcolor: "rgba(74, 122, 125, 0.1)",
p: 2,
borderRadius: 2,
color: "secondary.main",
fontWeight: "medium",
border: "1px solid",
borderColor: "rgba(74, 122, 125, 0.3)",
}}
>
{connection.left}
</Box>
</Box>
<Box sx={{
width: 16,
height: 16,
borderRadius: '50%',
bgcolor: 'custom.highlight',
zIndex: 2,
boxShadow: 2,
}} />
<Box sx={{
flex: 1,
pl: 3,
}}>
<Box sx={{
display: 'inline-block',
bgcolor: 'rgba(26, 37, 54, 0.1)',
p: 2,
borderRadius: 2,
color: 'primary.main',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'rgba(26, 37, 54, 0.3)',
}}>
<Box
sx={{
width: 16,
height: 16,
borderRadius: "50%",
bgcolor: "custom.highlight",
zIndex: 2,
boxShadow: 2,
}}
/>
<Box
sx={{
flex: 1,
pl: 3,
}}
>
<Box
sx={{
display: "inline-block",
bgcolor: "rgba(26, 37, 54, 0.1)",
p: 2,
borderRadius: 2,
color: "primary.main",
fontWeight: "medium",
border: "1px solid",
borderColor: "rgba(26, 37, 54, 0.3)",
}}
>
{connection.right}
</Box>
</Box>
@ -259,91 +405,161 @@ const BackstoryUIOverviewPage: React.FC = () => {
))}
</Box>
</Box>
{/* Mobile Adaptation */}
<Box sx={{ p: 3, bgcolor: 'background.paper', borderRadius: 2, boxShadow: 1 }}>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 3, fontWeight: 'bold' }}>
<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={{ display: "flex", justifyContent: "center" }}>
<Box
sx={{
width: 200,
height: 400,
border: "4px solid",
borderColor: "text.primary",
borderRadius: 5,
p: 1,
bgcolor: "background.default",
}}
>
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
border: "1px solid",
borderColor: "divider",
borderRadius: 4,
overflow: "hidden",
}}
>
{/* Mobile header */}
<Box sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
p: 1,
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>
<Typography sx={{ fontSize: '0.675rem' }}>3 custom resumes</Typography>
</Box>
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
<Box
sx={{
flex: 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>
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>
<Typography sx={{ fontSize: "0.675rem" }}>
5 profile views
</Typography>
<Typography sx={{ fontSize: "0.675rem" }}>
2 downloads
</Typography>
</Box>
</Box>
{/* Mobile footer */}
<Box sx={{
bgcolor: 'background.default',
p: 1,
display: 'flex',
justifyContent: 'space-around',
borderTop: '1px solid',
borderColor: 'divider'
}}>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.75rem', color: 'secondary.main' }}>Home</Typography>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>Profile</Typography>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>More</Typography>
<Box
sx={{
bgcolor: "background.default",
p: 1,
display: "flex",
justifyContent: "space-around",
borderTop: "1px solid",
borderColor: "divider",
}}
>
<Typography
sx={{
fontWeight: "bold",
fontSize: "0.75rem",
color: "secondary.main",
}}
>
Home
</Typography>
<Typography
sx={{ fontSize: "0.75rem", color: "text.secondary" }}
>
Profile
</Typography>
<Typography
sx={{ fontSize: "0.75rem", color: "text.secondary" }}
>
More
</Typography>
</Box>
</Box>
</Box>
@ -356,6 +572,4 @@ const BackstoryUIOverviewPage: React.FC = () => {
);
};
export {
BackstoryUIOverviewPage
};
export { BackstoryUIOverviewPage };

View File

@ -1,16 +1,37 @@
import React, { useState } from 'react';
import React, { useState } from "react";
import {
AppBar, Box, Button, Chip, Drawer,
IconButton, List, ListItem, ListItemButton, ListItemIcon,
ListItemText, Paper, Tab, Tabs, TextField, Typography,
useMediaQuery, useTheme
} from '@mui/material';
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
} from '@mui/icons-material';
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 {
id: number;
@ -21,49 +42,91 @@ interface Resume {
const MockupPage = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
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 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" component="h1" fontWeight="bold" color="text.primary">Backstory</Typography>
<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>
{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 }}>
<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>
)}
<IconButton sx={{ bgcolor: 'action.hover', borderRadius: '50%' }}>
<IconButton sx={{ bgcolor: "action.hover", borderRadius: "50%" }}>
<UserIcon />
</IconButton>
</Box>
</Box>
</AppBar>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<Box sx={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Sidebar - hidden on mobile */}
{!isMobile && (
<Drawer
@ -71,12 +134,29 @@ const MockupPage = () => {
sx={{
width: 240,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: 240, boxSizing: 'border-box', position: 'relative' },
[`& .MuiDrawer-paper`]: {
width: 240,
boxSizing: "border-box",
position: "relative",
},
}}
>
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box
sx={{
p: 2,
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<Box sx={{ mb: 3 }}>
<Typography variant="overline" color="text.secondary" gutterBottom>Main</Typography>
<Typography
variant="overline"
color="text.secondary"
gutterBottom
>
Main
</Typography>
<List disablePadding>
<ListItem disablePadding>
<ListItemButton sx={{ borderRadius: 1 }}>
@ -88,9 +168,15 @@ 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' }}>
<ListItemIcon
sx={{ minWidth: 36, color: "primary.main" }}
>
<FileTextIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Resume Builder" />
@ -100,7 +186,13 @@ 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 }}>
@ -126,18 +218,35 @@ const MockupPage = () => {
</List>
</Box>
<Box sx={{ mt: 'auto' }}>
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'background.default' }}>
<Typography variant="subtitle2" color="text.primary" gutterBottom>Recent Activity</Typography>
<Box sx={{ mt: "auto" }}>
<Paper
variant="outlined"
sx={{ p: 2, bgcolor: "background.default" }}
>
<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>
@ -151,13 +260,26 @@ const MockupPage = () => {
open={isMobileMenuOpen}
onClose={() => setIsMobileMenuOpen(false)}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': { width: 240 }
display: { xs: "block", md: "none" },
"& .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)}>
@ -170,9 +292,9 @@ const MockupPage = () => {
<ListItem disablePadding>
<ListItemButton
onClick={() => setIsMobileMenuOpen(false)}
sx={{ bgcolor: 'primary.lighter', color: 'primary.main' }}
sx={{ bgcolor: "primary.lighter", color: "primary.main" }}
>
<ListItemIcon sx={{ color: 'primary.main' }}>
<ListItemIcon sx={{ color: "primary.main" }}>
<FileTextIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Resume Builder" />
@ -182,7 +304,13 @@ 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)}>
@ -211,15 +339,23 @@ const MockupPage = () => {
</Drawer>
{/* Main content */}
<Box sx={{ flex: 1, overflow: 'auto', p: 3 }}>
<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 }}>
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 3 }}>
<Tabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
@ -233,11 +369,12 @@ const MockupPage = () => {
<Tab label="Saved Resumes" value="saved" />
</Tabs>
</Box>
{/* Tab content */}
{activeTab === 'job' && (
{activeTab === "job" && (
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Job Description</Typography>
<Typography variant="h6" gutterBottom>
Job Description
</Typography>
<TextField
fullWidth
multiline
@ -252,12 +389,18 @@ const MockupPage = () => {
</Box>
</Paper>
)}
{activeTab === 'resume' && (
{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 }}>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
size="small"
@ -276,35 +419,77 @@ const MockupPage = () => {
</Box>
{/* Resume content editor with sections */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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" />
<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" />
<TextField
size="small"
fullWidth
defaultValue="john@example.com"
/>
</Box>
</Box>
</Paper>
{/* Professional Summary */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="medium">Professional Summary</Typography>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Professional Summary
</Typography>
<IconButton size="small" color="default">
<EditIcon fontSize="small" />
</IconButton>
@ -320,8 +505,17 @@ 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>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="subtitle1" fontWeight="medium">
Work Experience
</Typography>
<Button
startIcon={<PlusIcon />}
color="primary"
@ -332,10 +526,17 @@ const MockupPage = () => {
</Box>
{/* 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>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<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>
<Box sx={{ display: "flex", gap: 0.5 }}>
<IconButton size="small">
<EditIcon fontSize="small" />
</IconButton>
@ -344,10 +545,16 @@ const MockupPage = () => {
</IconButton>
</Box>
</Box>
<Typography variant="body2" color="text.secondary">Tech Company Inc. 2020-Present</Typography>
<Typography variant="body2" color="text.secondary">
Tech Company Inc. 2020-Present
</Typography>
<Box component="ul" sx={{ pl: 2, mt: 1 }}>
<Typography component="li" variant="body2">Led development of company's flagship product</Typography>
<Typography component="li" variant="body2">Improved performance by 40% through code optimization</Typography>
<Typography component="li" variant="body2">
Led development of company's flagship product
</Typography>
<Typography component="li" variant="body2">
Improved performance by 40% through code optimization
</Typography>
</Box>
</Paper>
</Paper>
@ -357,10 +564,10 @@ const MockupPage = () => {
variant="outlined"
fullWidth
sx={{
borderStyle: 'dashed',
borderStyle: "dashed",
p: 1.5,
color: 'text.secondary',
'&:hover': { bgcolor: 'background.default' }
color: "text.secondary",
"&:hover": { bgcolor: "background.default" },
}}
startIcon={<PlusIcon />}
>
@ -368,9 +575,17 @@ 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"
@ -383,28 +598,41 @@ const MockupPage = () => {
</Box>
{/* Resume list */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{savedResumes.map(resume => (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{savedResumes.map((resume) => (
<Paper
key={resume.id}
variant="outlined"
sx={{
p: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'background.paper',
borderColor: selectedResume === resume.id ? 'primary.light' : 'divider',
'&:hover': { bgcolor: selectedResume === resume.id ? 'primary.lighter' : 'action.hover' }
display: "flex",
justifyContent: "space-between",
alignItems: "center",
cursor: "pointer",
bgcolor:
selectedResume === resume.id
? "primary.lighter"
: "background.paper",
borderColor:
selectedResume === resume.id
? "primary.light"
: "divider",
"&: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 }}>
<Box sx={{ display: "flex", gap: 1 }}>
<IconButton size="small">
<EditIcon fontSize="small" />
</IconButton>
@ -417,17 +645,26 @@ const MockupPage = () => {
</Box>
</Paper>
)}
{activeTab === 'fact' && (
{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.
This tab shows how your resume content compares to your
employment history data.
</Typography>
<Box sx={{ mt: 2 }}>
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: 'success.lighter' }}>
<Typography variant="subtitle2" fontWeight="medium" color="success.dark">
<Paper
variant="outlined"
sx={{ p: 2, mb: 2, bgcolor: "success.lighter" }}
>
<Typography
variant="subtitle2"
fontWeight="medium"
color="success.dark"
>
Work History Verification
</Typography>
<Typography variant="body2">
@ -435,12 +672,20 @@ const MockupPage = () => {
</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: 'warning.lighter' }}>
<Typography variant="subtitle2" fontWeight="medium" color="warning.dark">
<Paper
variant="outlined"
sx={{ p: 2, mb: 2, bgcolor: "warning.lighter" }}
>
<Typography
variant="subtitle2"
fontWeight="medium"
color="warning.dark"
>
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>
@ -453,26 +698,26 @@ const MockupPage = () => {
{isMobile && (
<Paper
sx={{
position: 'fixed',
position: "fixed",
bottom: 0,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'space-around',
display: "flex",
justifyContent: "space-around",
borderTop: 1,
borderColor: 'divider',
zIndex: 1100
borderColor: "divider",
zIndex: 1100,
}}
elevation={3}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
display: "flex",
flexDirection: "column",
alignItems: "center",
py: 1,
px: 2,
color: 'text.secondary'
color: "text.secondary",
}}
component="button"
>
@ -481,12 +726,12 @@ const MockupPage = () => {
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
display: "flex",
flexDirection: "column",
alignItems: "center",
py: 1,
px: 2,
color: 'primary.main'
color: "primary.main",
}}
component="button"
>
@ -495,12 +740,12 @@ const MockupPage = () => {
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
display: "flex",
flexDirection: "column",
alignItems: "center",
py: 1,
px: 2,
color: 'text.secondary'
color: "text.secondary",
}}
component="button"
>
@ -511,8 +756,6 @@ const MockupPage = () => {
)}
</Box>
);
}
};
export {
MockupPage
};
export { MockupPage };

View File

@ -1,15 +1,15 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
import React, { useState } from "react";
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Button,
@ -22,9 +22,9 @@ import {
Select,
FormControl,
InputLabel,
Grid
} from '@mui/material';
import { Person, Business, AssignmentInd } from '@mui/icons-material';
Grid,
} from "@mui/material";
import { Person, Business, AssignmentInd } from "@mui/icons-material";
// Interfaces from the data model
interface BaseUser {
@ -37,7 +37,7 @@ interface BaseUser {
}
interface Candidate extends BaseUser {
type: 'candidate';
type: "candidate";
firstName: string;
lastName: string;
skills: { id: string; name: string; level: string }[];
@ -45,7 +45,7 @@ interface Candidate extends BaseUser {
}
interface Employer extends BaseUser {
type: 'employer';
type: "employer";
companyName: string;
industry: string;
companySize: string;
@ -58,59 +58,59 @@ type User = Candidate | Employer;
// Mock data
const mockUsers: User[] = [
{
id: '1',
email: 'john.doe@example.com',
createdAt: new Date('2023-08-15'),
lastLogin: new Date('2023-10-22'),
id: "1",
email: "john.doe@example.com",
createdAt: new Date("2023-08-15"),
lastLogin: new Date("2023-10-22"),
isActive: true,
type: 'candidate',
firstName: 'John',
lastName: 'Doe',
type: "candidate",
firstName: "John",
lastName: "Doe",
skills: [
{ id: 's1', name: 'React', level: 'advanced' },
{ id: 's2', name: 'TypeScript', level: 'intermediate' }
{ id: "s1", name: "React", level: "advanced" },
{ id: "s2", name: "TypeScript", level: "intermediate" },
],
location: { city: 'Austin', country: 'USA' }
location: { city: "Austin", country: "USA" },
},
{
id: '2',
email: 'sarah.smith@example.com',
createdAt: new Date('2023-09-10'),
lastLogin: new Date('2023-10-24'),
id: "2",
email: "sarah.smith@example.com",
createdAt: new Date("2023-09-10"),
lastLogin: new Date("2023-10-24"),
isActive: true,
type: 'candidate',
firstName: 'Sarah',
lastName: 'Smith',
type: "candidate",
firstName: "Sarah",
lastName: "Smith",
skills: [
{ id: 's3', name: 'Python', level: 'expert' },
{ id: 's4', name: 'Data Science', level: 'advanced' }
{ id: "s3", name: "Python", level: "expert" },
{ id: "s4", name: "Data Science", level: "advanced" },
],
location: { city: 'Seattle', country: 'USA', remote: true }
location: { city: "Seattle", country: "USA", remote: true },
},
{
id: '3',
email: 'tech@acme.com',
createdAt: new Date('2023-07-05'),
lastLogin: new Date('2023-10-23'),
id: "3",
email: "tech@acme.com",
createdAt: new Date("2023-07-05"),
lastLogin: new Date("2023-10-23"),
isActive: true,
type: 'employer',
companyName: 'Acme Tech',
industry: 'Software',
companySize: '50-200',
location: { city: 'San Francisco', country: 'USA' }
type: "employer",
companyName: "Acme Tech",
industry: "Software",
companySize: "50-200",
location: { city: "San Francisco", country: "USA" },
},
{
id: '4',
email: 'careers@globex.com',
createdAt: new Date('2023-08-20'),
lastLogin: new Date('2023-10-20'),
id: "4",
email: "careers@globex.com",
createdAt: new Date("2023-08-20"),
lastLogin: new Date("2023-10-20"),
isActive: false,
type: 'employer',
companyName: 'Globex Corporation',
industry: 'Manufacturing',
companySize: '1000+',
location: { city: 'Chicago', country: 'USA' }
}
type: "employer",
companyName: "Globex Corporation",
industry: "Manufacturing",
companySize: "1000+",
location: { city: "Chicago", country: "USA" },
},
];
// Component for User Management
@ -120,60 +120,60 @@ 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 => {
const filteredUsers = users.filter((user) => {
if (tabValue === 0) return true;
if (tabValue === 1) return user.type === 'candidate';
if (tabValue === 2) return user.type === 'employer';
if (tabValue === 1) return user.type === "candidate";
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') {
if (user.type === "candidate") {
return `${user.firstName} ${user.lastName}`;
} else {
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 }}>
<Box sx={{ width: "100%", p: 3 }}>
<Paper sx={{ width: "100%", mb: 2 }}>
<Tabs
value={tabValue}
onChange={handleTabChange}
@ -185,7 +185,7 @@ const UserManagement: React.FC = () => {
<Tab icon={<Person />} label="Candidates" />
<Tab icon={<Business />} label="Employers" />
</Tabs>
<TableContainer>
<Table>
<TableHead>
@ -201,16 +201,29 @@ const UserManagement: React.FC = () => {
</TableHead>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id} sx={{ "& > td": { whiteSpace: "nowrap"}}}>
<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'}
color={user.type === 'candidate' ? 'primary' : 'secondary'}
<Chip
label={
user.type === "candidate" ? "Candidate" : "Employer"
}
color={
user.type === "candidate" ? "primary" : "secondary"
}
size="small"
/>
</TableCell>
@ -223,24 +236,24 @@ const UserManagement: React.FC = () => {
{/* <TableCell>{formatDate(user.createdAt)}</TableCell> */}
<TableCell>{formatDate(user.lastLogin)}</TableCell>
<TableCell>
<Chip
label={user.isActive ? 'Active' : 'Inactive'}
color={user.isActive ? 'success' : 'error'}
<Chip
label={user.isActive ? "Active" : "Inactive"}
color={user.isActive ? "success" : "error"}
size="small"
/>
</TableCell>
<TableCell>
<Button
size="small"
variant="outlined"
<Button
size="small"
variant="outlined"
onClick={() => handleOpenUserDetails(user)}
sx={{ mr: 1 }}
>
Details
</Button>
<Button
size="small"
variant="outlined"
<Button
size="small"
variant="outlined"
color="secondary"
onClick={() => handleOpenAiConfig(user)}
>
@ -253,19 +266,28 @@ const UserManagement: React.FC = () => {
</Table>
</TableContainer>
</Paper>
{/* User Details Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<Dialog
open={openDialog}
onClose={handleCloseDialog}
maxWidth="md"
fullWidth
>
{selectedUser && (
<>
<DialogTitle>
{selectedUser.type === 'candidate' ? 'Candidate Details' : 'Employer Details'}
{selectedUser.type === "candidate"
? "Candidate Details"
: "Employer Details"}
</DialogTitle>
<DialogContent dividers>
{selectedUser.type === 'candidate' ? (
{selectedUser.type === "candidate" ? (
<Grid container spacing={2}>
<Grid size={{xs: 12, md: 6}}>
<Typography variant="subtitle1">Personal Information</Typography>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">
Personal Information
</Typography>
<TextField
label="First Name"
value={selectedUser.firstName}
@ -288,11 +310,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
<Chip
key={skill.id}
label={`${skill.name} (${skill.level})`}
sx={{ m: 0.5 }}
@ -303,8 +325,10 @@ const UserManagement: React.FC = () => {
</Grid>
) : (
<Grid container spacing={2}>
<Grid size={{xs: 12, md: 6}}>
<Typography variant="subtitle1">Company Information</Typography>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">
Company Information
</Typography>
<TextField
label="Company Name"
value={selectedUser.companyName}
@ -327,8 +351,10 @@ const UserManagement: React.FC = () => {
InputProps={{ readOnly: true }}
/>
</Grid>
<Grid size={{xs: 12, md: 6}}>
<Typography variant="subtitle1">Contact Information</Typography>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1">
Contact Information
</Typography>
<TextField
label="Email"
value={selectedUser.email}
@ -353,9 +379,14 @@ const UserManagement: React.FC = () => {
</>
)}
</Dialog>
{/* AI Config Dialog */}
<Dialog open={aiConfigOpen} onClose={handleCloseAiConfig} maxWidth="md" fullWidth>
<Dialog
open={aiConfigOpen}
onClose={handleCloseAiConfig}
maxWidth="md"
fullWidth
>
{selectedUser && (
<>
<DialogTitle>
@ -365,9 +396,11 @@ const UserManagement: React.FC = () => {
<Typography variant="subtitle1" gutterBottom>
RAG Database Configuration
</Typography>
<FormControl fullWidth margin="normal">
<InputLabel id="embedding-model-label">Embedding Model</InputLabel>
<InputLabel id="embedding-model-label">
Embedding Model
</InputLabel>
<Select
labelId="embedding-model-label"
label="Embedding Model"
@ -378,7 +411,7 @@ 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
@ -391,13 +424,13 @@ const UserManagement: React.FC = () => {
<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
@ -411,7 +444,7 @@ const UserManagement: React.FC = () => {
</Select>
</FormControl>
</Grid>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Temperature"
type="number"
@ -421,7 +454,7 @@ const UserManagement: React.FC = () => {
InputProps={{ inputProps: { min: 0, max: 1, step: 0.1 } }}
/>
</Grid>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Max Tokens"
type="number"
@ -430,7 +463,7 @@ const UserManagement: React.FC = () => {
margin="normal"
/>
</Grid>
<Grid size={{xs: 12, md: 6}}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Top P"
type="number"
@ -441,20 +474,24 @@ const UserManagement: React.FC = () => {
/>
</Grid>
</Grid>
<TextField
label="System Prompt"
multiline
rows={4}
fullWidth
margin="normal"
defaultValue={`You are an AI assistant helping ${selectedUser.type === 'candidate' ? 'job candidates find relevant positions' : 'employers find qualified candidates'}. Be professional, helpful, and concise in your responses.`}
defaultValue={`You are an AI assistant helping ${
selectedUser.type === "candidate"
? "job candidates find relevant positions"
: "employers find qualified candidates"
}. Be professional, helpful, and concise in your responses.`}
/>
<Typography variant="subtitle1" gutterBottom sx={{ mt: 2 }}>
Data Sources
</Typography>
<TableContainer component={Paper} sx={{ mt: 1 }}>
<Table size="small">
<TableHead>
@ -496,7 +533,9 @@ const UserManagement: React.FC = () => {
</DialogContent>
<DialogActions>
<Button onClick={handleCloseAiConfig}>Cancel</Button>
<Button variant="contained" color="primary">Save Configuration</Button>
<Button variant="contained" color="primary">
Save Configuration
</Button>
</DialogActions>
</>
)}
@ -505,4 +544,4 @@ const UserManagement: React.FC = () => {
);
};
export { UserManagement };
export { UserManagement };

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,30 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import * as Types from 'types/types';
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
} from "react";
import * as Types from "types/types";
// Assuming you're using React Router
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from 'hooks/AuthContext';
import { SetSnackType, SeverityType, Snack } from 'components/Snack';
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "hooks/AuthContext";
import { SetSnackType, SeverityType, Snack } from "components/Snack";
// ============================
// Storage Keys
// ============================
const STORAGE_KEYS = {
SELECTED_CANDIDATE_ID: 'selectedCandidateId',
SELECTED_JOB_ID: 'selectedJobId',
SELECTED_EMPLOYER_ID: 'selectedEmployerId',
LAST_ROUTE: 'lastVisitedRoute',
ROUTE_STATE: 'routeState',
ACTIVE_TAB: 'activeTab',
APPLIED_FILTERS: 'appliedFilters',
SIDEBAR_COLLAPSED: 'sidebarCollapsed'
SELECTED_CANDIDATE_ID: "selectedCandidateId",
SELECTED_JOB_ID: "selectedJobId",
SELECTED_EMPLOYER_ID: "selectedEmployerId",
LAST_ROUTE: "lastVisitedRoute",
ROUTE_STATE: "routeState",
ACTIVE_TAB: "activeTab",
APPLIED_FILTERS: "appliedFilters",
SIDEBAR_COLLAPSED: "sidebarCollapsed",
} as const;
// ============================
@ -40,7 +47,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;
}
@ -74,7 +81,7 @@ function getStoredId(key: string): string | null {
try {
return localStorage.getItem(key);
} catch (error) {
console.warn('Failed to read from localStorage:', error);
console.warn("Failed to read from localStorage:", error);
return null;
}
}
@ -87,7 +94,7 @@ function setStoredId(key: string, id: string | null): void {
localStorage.removeItem(key);
}
} catch (error) {
console.warn('Failed to write to localStorage:', error);
console.warn("Failed to write to localStorage:", error);
}
}
@ -118,7 +125,7 @@ function getInitialRouteState(): RouteState {
lastRoute: getStoredId(STORAGE_KEYS.LAST_ROUTE),
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),
};
}
@ -139,14 +146,19 @@ export function useAppStateLogic(): AppStateContextType {
const { apiClient } = useAuth();
// Entity state
const [selectedCandidate, setSelectedCandidateState] = useState<Types.Candidate | null>(null);
const [selectedCandidate, setSelectedCandidateState] =
useState<Types.Candidate | null>(null);
const [selectedJob, setSelectedJobState] = useState<Types.Job | null>(null);
const [selectedEmployer, setSelectedEmployerState] = useState<Types.Employer | null>(null);
const [selectedResume, setSelectedResume] = useState<Types.Resume | null>(null);
const [selectedEmployer, setSelectedEmployerState] =
useState<Types.Employer | null>(null);
const [selectedResume, setSelectedResume] = useState<Types.Resume | null>(
null
);
const [isInitializing, setIsInitializing] = useState<boolean>(true);
// Route state
const [routeState, setRouteStateState] = useState<RouteState>(getInitialRouteState);
const [routeState, setRouteStateState] =
useState<RouteState>(getInitialRouteState);
// ============================
// Initialization Effect
@ -172,13 +184,13 @@ export function useAppStateLogic(): AppStateContextType {
const candidate = await apiClient.getCandidate(candidateId);
if (candidate) {
setSelectedCandidateState(candidate);
console.log('Restored candidate from storage:', candidate);
console.log("Restored candidate from storage:", candidate);
} else {
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null);
console.log('Candidate not found, cleared from storage');
console.log("Candidate not found, cleared from storage");
}
} catch (error) {
console.warn('Failed to restore candidate:', error);
console.warn("Failed to restore candidate:", error);
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null);
}
})()
@ -192,13 +204,13 @@ export function useAppStateLogic(): AppStateContextType {
const job = await apiClient.getJob(jobId);
if (job) {
setSelectedJobState(job);
console.log('Restored job from storage:', job);
console.log("Restored job from storage:", job);
} else {
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
console.log('Job not found, cleared from storage');
console.log("Job not found, cleared from storage");
}
} catch (error) {
console.warn('Failed to restore job:', error);
console.warn("Failed to restore job:", error);
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
}
})()
@ -212,13 +224,13 @@ export function useAppStateLogic(): AppStateContextType {
const employer = await apiClient.getEmployer(employerId);
if (employer) {
setSelectedEmployerState(employer);
console.log('Restored employer from storage:', employer);
console.log("Restored employer from storage:", employer);
} else {
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
console.log('Employer not found, cleared from storage');
console.log("Employer not found, cleared from storage");
}
} catch (error) {
console.warn('Failed to restore employer:', error);
console.warn("Failed to restore employer:", error);
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
}
})()
@ -227,9 +239,8 @@ 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);
console.error("Error during app state initialization:", error);
} finally {
setIsInitializing(false);
}
@ -244,11 +255,18 @@ export function useAppStateLogic(): AppStateContextType {
useEffect(() => {
// Don't save certain routes (login, register, etc.)
const excludedRoutes = ['/login', '/register', '/verify-email', '/reset-password'];
const shouldSaveRoute = !excludedRoutes.some(route => location.pathname.startsWith(route));
const excludedRoutes = [
"/login",
"/register",
"/verify-email",
"/reset-password",
];
const shouldSaveRoute = !excludedRoutes.some((route) =>
location.pathname.startsWith(route)
);
if (shouldSaveRoute && !isInitializing) {
setRouteStateState(prev => {
setRouteStateState((prev) => {
const newState = { ...prev, lastRoute: location.pathname };
persistRouteState(newState);
return newState;
@ -260,25 +278,28 @@ export function useAppStateLogic(): AppStateContextType {
// Entity State Setters with Persistence
// ============================
const setSelectedCandidate = useCallback((candidate: Types.Candidate | null) => {
setSelectedCandidateState(candidate);
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, candidate?.id || null);
const setSelectedCandidate = useCallback(
(candidate: Types.Candidate | null) => {
setSelectedCandidateState(candidate);
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, candidate?.id || null);
if (candidate) {
console.log('Selected candidate:', candidate);
} else {
console.log('Cleared selected candidate');
}
}, []);
if (candidate) {
console.log("Selected candidate:", candidate);
} else {
console.log("Cleared selected candidate");
}
},
[]
);
const setSelectedJob = useCallback((job: Types.Job | null) => {
setSelectedJobState(job);
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, job?.id || null);
if (job) {
console.log('Selected job:', job);
console.log("Selected job:", job);
} else {
console.log('Cleared selected job');
console.log("Cleared selected job");
}
}, []);
@ -287,9 +308,9 @@ export function useAppStateLogic(): AppStateContextType {
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, employer?.id || null);
if (employer) {
console.log('Selected employer:', employer);
console.log("Selected employer:", employer);
} else {
console.log('Cleared selected employer');
console.log("Cleared selected employer");
}
}, []);
@ -302,7 +323,7 @@ export function useAppStateLogic(): AppStateContextType {
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
console.log('Cleared all selections');
console.log("Cleared all selections");
}, []);
// ============================
@ -310,7 +331,7 @@ export function useAppStateLogic(): AppStateContextType {
// ============================
const saveCurrentRoute = useCallback(() => {
setRouteStateState(prev => {
setRouteStateState((prev) => {
const newState = { ...prev, lastRoute: location.pathname };
persistRouteState(newState);
return newState;
@ -324,7 +345,7 @@ export function useAppStateLogic(): AppStateContextType {
}, [routeState.lastRoute, location.pathname, navigate]);
const setActiveTab = useCallback((tab: string) => {
setRouteStateState(prev => {
setRouteStateState((prev) => {
const newState = { ...prev, activeTab: tab };
persistRouteState(newState);
return newState;
@ -332,7 +353,7 @@ export function useAppStateLogic(): AppStateContextType {
}, []);
const setFilters = useCallback((filters: Record<string, any>) => {
setRouteStateState(prev => {
setRouteStateState((prev) => {
const newState = { ...prev, appliedFilters: filters };
persistRouteState(newState);
return newState;
@ -340,7 +361,7 @@ export function useAppStateLogic(): AppStateContextType {
}, []);
const setSidebarCollapsed = useCallback((collapsed: boolean) => {
setRouteStateState(prev => {
setRouteStateState((prev) => {
const newState = { ...prev, sidebarCollapsed: collapsed };
persistRouteState(newState);
return newState;
@ -352,7 +373,7 @@ export function useAppStateLogic(): AppStateContextType {
lastRoute: null,
activeTab: null,
appliedFilters: {},
sidebarCollapsed: false
sidebarCollapsed: false,
};
setRouteStateState(clearedState);
@ -363,10 +384,13 @@ export function useAppStateLogic(): AppStateContextType {
localStorage.removeItem(STORAGE_KEYS.APPLIED_FILTERS);
localStorage.removeItem(STORAGE_KEYS.SIDEBAR_COLLAPSED);
console.log('Cleared all route state');
console.log("Cleared all route state");
}, []);
const emptySetSnack: SetSnackType = (message: string, severity?: SeverityType) => {
const emptySetSnack: SetSnackType = (
message: string,
severity?: SeverityType
) => {
return;
};
@ -388,7 +412,7 @@ export function useAppStateLogic(): AppStateContextType {
setActiveTab,
setFilters,
setSidebarCollapsed,
clearRouteState
clearRouteState,
};
}
@ -403,10 +427,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
const snackRef = useRef<any>(null);
// Global UI components
appState.setSnack = useCallback((message: string, severity?: SeverityType) => {
snackRef.current?.setSnack(message, severity);
}, [snackRef]);
appState.setSnack = useCallback(
(message: string, severity?: SeverityType) => {
snackRef.current?.setSnack(message, severity);
},
[snackRef]
);
return (
<AppStateContext.Provider value={appState}>
{children}
@ -418,7 +445,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
export function useAppState() {
const context = useContext(AppStateContext);
if (!context) {
throw new Error('useAppState must be used within an AppStateProvider');
throw new Error("useAppState must be used within an AppStateProvider");
}
return context;
}
@ -454,7 +481,7 @@ export function useRouteState() {
setFilters,
setSidebarCollapsed,
restoreLastRoute,
clearRouteState
clearRouteState,
} = useAppState();
return {
@ -463,11 +490,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';
import { useEffect, useRef, RefObject, useCallback } from "react";
const debug: boolean = false;
const debug = false;
type ResizeCallback = () => void;
// Define the debounce function with cancel capability
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
let timeout: NodeJS.Timeout | null = null;
let lastCall: number = 0;
let lastCall = 0;
const debounced = function (...args: Parameters<T>) {
const now = Date.now();
@ -68,8 +68,12 @@ const useResizeObserverAndMutationObserver = (
requestAnimationFrame(() => callbackRef.current());
}, 500);
const resizeObserver = new ResizeObserver((e: any) => { debouncedCallback("resize"); });
const mutationObserver = new MutationObserver((e: any) => { debouncedCallback("mutation"); });
const resizeObserver = new ResizeObserver((e: any) => {
debouncedCallback("resize");
});
const mutationObserver = new MutationObserver((e: any) => {
debouncedCallback("mutation");
});
// Observe container size
resizeObserver.observe(container);
@ -102,8 +106,8 @@ const useResizeObserverAndMutationObserver = (
*/
const useAutoScrollToBottom = (
scrollToRef: RefObject<HTMLElement | null>,
smooth: boolean = true,
fallbackThreshold: number = 0.33,
smooth = true,
fallbackThreshold = 0.33,
contentUpdateTrigger?: any
): RefObject<HTMLDivElement | null> => {
const containerRef = useRef<HTMLDivElement | null>(null);
@ -111,65 +115,74 @@ const useAutoScrollToBottom = (
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
const isUserScrollingUpRef = useRef(false);
const checkAndScrollToBottom = useCallback((isPasteEvent: boolean = false) => {
const container = containerRef.current;
if (!container) return;
const checkAndScrollToBottom = useCallback(
(isPasteEvent = false) => {
const container = containerRef.current;
if (!container) return;
let shouldScroll = false;
const scrollTo = scrollToRef.current;
let shouldScroll = false;
const scrollTo = scrollToRef.current;
if (isPasteEvent && !scrollTo) {
console.error("Paste Event triggered without scrollTo");
}
if (scrollTo) {
// Get positions
const containerRect = container.getBoundingClientRect();
const scrollToRect = scrollTo.getBoundingClientRect();
const containerTop = containerRect.top;
const containerBottom = containerTop + container.clientHeight;
// Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible =
scrollToRect.top < containerBottom && scrollToRect.bottom > containerTop;
// Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll = isPasteEvent || (isTextFieldVisible && !isUserScrollingUpRef.current);
if (shouldScroll) {
requestAnimationFrame(() => {
debug && console.debug('Scrolling to container bottom:', {
scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight,
isPasteEvent,
isTextFieldVisible,
isUserScrollingUp: isUserScrollingUpRef.current,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
});
if (isPasteEvent && !scrollTo) {
console.error("Paste Event triggered without scrollTo");
}
} else {
// Fallback to threshold-based check
const scrollHeight = container.scrollHeight;
const isNearBottom =
scrollHeight - container.scrollTop - container.clientHeight <=
container.clientHeight * fallbackThreshold;
shouldScroll = isNearBottom && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
debug && console.debug('Scrolling to container bottom (fallback):', { scrollHeight });
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
if (scrollTo) {
// Get positions
const containerRect = container.getBoundingClientRect();
const scrollToRect = scrollTo.getBoundingClientRect();
const containerTop = containerRect.top;
const containerBottom = containerTop + container.clientHeight;
// Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible =
scrollToRect.top < containerBottom &&
scrollToRect.bottom > containerTop;
// Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll =
isPasteEvent || (isTextFieldVisible && !isUserScrollingUpRef.current);
if (shouldScroll) {
requestAnimationFrame(() => {
debug &&
console.debug("Scrolling to container bottom:", {
scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight,
isPasteEvent,
isTextFieldVisible,
isUserScrollingUp: isUserScrollingUpRef.current,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
});
});
}
} else {
// Fallback to threshold-based check
const scrollHeight = container.scrollHeight;
const isNearBottom =
scrollHeight - container.scrollTop - container.clientHeight <=
container.clientHeight * fallbackThreshold;
shouldScroll = isNearBottom && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
debug &&
console.debug("Scrolling to container bottom (fallback):", {
scrollHeight,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
});
}
}
}
}, [fallbackThreshold, smooth, scrollToRef]);
},
[fallbackThreshold, smooth, scrollToRef]
);
useEffect(() => {
const container = containerRef.current;
@ -178,28 +191,36 @@ 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;
debug && console.debug(`Scrolling up or paused: ${isUserScrollingUpRef.current} ${pause}`);
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");
handleScroll(ev, 500);
}
};
const pauseClick = (ev: Event) => {
debug && console.log("Pausing for mouse click");
handleScroll(ev, 1000);
}
};
const handlePaste = () => {
console.log("handlePaste");
@ -210,33 +231,40 @@ const useAutoScrollToBottom = (
}, 100);
};
window.addEventListener('mousemove', pauseScroll);
window.addEventListener('mousedown', pauseClick);
window.addEventListener("mousemove", pauseScroll);
window.addEventListener("mousedown", pauseClick);
container.addEventListener('scroll', handleScroll);
container.addEventListener("scroll", handleScroll);
if (scrollTo) {
scrollTo.addEventListener('paste', handlePaste);
scrollTo.addEventListener("paste", handlePaste);
}
checkAndScrollToBottom();
return () => {
window.removeEventListener('mousedown', pauseClick);
window.removeEventListener('mousemove', pauseScroll);
container.removeEventListener('scroll', handleScroll);
window.removeEventListener("mousedown", pauseClick);
window.removeEventListener("mousemove", pauseScroll);
container.removeEventListener("scroll", handleScroll);
if (scrollTo) {
scrollTo.removeEventListener('paste', handlePaste);
scrollTo.removeEventListener("paste", handlePaste);
}
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
};
}, [smooth, scrollToRef, fallbackThreshold, contentUpdateTrigger, checkAndScrollToBottom]);
}, [
smooth,
scrollToRef,
fallbackThreshold,
contentUpdateTrigger,
checkAndScrollToBottom,
]);
// Observe container and TextField size, plus DOM changes
useResizeObserverAndMutationObserver(containerRef, scrollToRef, checkAndScrollToBottom);
useResizeObserverAndMutationObserver(
containerRef,
scrollToRef,
checkAndScrollToBottom
);
return containerRef;
};
export {
useResizeObserverAndMutationObserver,
useAutoScrollToBottom
}
export { useResizeObserverAndMutationObserver, useAutoScrollToBottom };

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation, useParams } from 'react-router-dom';
import React, { useState, useEffect } from "react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import {
Box,
Drawer,
@ -18,28 +18,28 @@ import {
CardContent,
CardActionArea,
useTheme,
useMediaQuery
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import PersonIcon from '@mui/icons-material/Person';
import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DescriptionIcon from '@mui/icons-material/Description';
import CodeIcon from '@mui/icons-material/Code';
import LayersIcon from '@mui/icons-material/Layers';
import DashboardIcon from '@mui/icons-material/Dashboard';
import PaletteIcon from '@mui/icons-material/Palette';
import AnalyticsIcon from '@mui/icons-material/Analytics';
import ViewQuiltIcon from '@mui/icons-material/ViewQuilt';
useMediaQuery,
} from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import PersonIcon from "@mui/icons-material/Person";
import CloseIcon from "@mui/icons-material/Close";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DescriptionIcon from "@mui/icons-material/Description";
import CodeIcon from "@mui/icons-material/Code";
import LayersIcon from "@mui/icons-material/Layers";
import DashboardIcon from "@mui/icons-material/Dashboard";
import PaletteIcon from "@mui/icons-material/Palette";
import AnalyticsIcon from "@mui/icons-material/Analytics";
import ViewQuiltIcon from "@mui/icons-material/ViewQuilt";
import { Document } from '../components/Document';
import { BackstoryPageProps } from '../components/BackstoryTab';
import { BackstoryUIOverviewPage } from 'documents/BackstoryUIOverviewPage';
import { BackstoryAppAnalysisPage } from 'documents/BackstoryAppAnalysisPage';
import { BackstoryThemeVisualizerPage } from 'documents/BackstoryThemeVisualizerPage';
import { UserManagement } from 'documents/UserManagement';
import { MockupPage } from 'documents/MockupPage';
import { useAppState } from 'hooks/GlobalContext';
import { Document } from "../components/Document";
import { BackstoryPageProps } from "../components/BackstoryTab";
import { BackstoryUIOverviewPage } from "documents/BackstoryUIOverviewPage";
import { BackstoryAppAnalysisPage } from "documents/BackstoryAppAnalysisPage";
import { BackstoryThemeVisualizerPage } from "documents/BackstoryThemeVisualizerPage";
import { UserManagement } from "documents/UserManagement";
import { MockupPage } from "documents/MockupPage";
import { useAppState } from "hooks/GlobalContext";
// Sidebar navigation component using MUI components
const Sidebar: React.FC<{
@ -60,15 +60,17 @@ 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={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box
sx={{
p: 2,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography variant="h6" component="h2" fontWeight="bold">
Documentation
</Typography>
@ -83,34 +85,44 @@ const Sidebar: React.FC<{
)}
</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
primary={doc.title}
slotProps={{
primary: {
fontWeight: currentPage === doc.route ? 'medium' : 'regular',
}
fontWeight:
currentPage === doc.route ? "medium" : "regular",
},
}}
/>
</ListItemButton>
@ -123,12 +135,14 @@ const Sidebar: React.FC<{
};
const getDocumentIcon = (title: string): React.ReactNode => {
const item = documents.find(d => d.title.toLocaleLowerCase() === title.toLocaleLowerCase());
const item = documents.find(
(d) => d.title.toLocaleLowerCase() === title.toLocaleLowerCase()
);
if (!item) {
throw Error(`${title} does not exist in documents`);
}
return item.icon || <ViewQuiltIcon />;
}
};
type DocType = {
title: string;
@ -137,26 +151,91 @@ 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 index = documents.findIndex(v => v.route === route);
const documentFromRoute = (route: string): DocType | null => {
const index = documents.findIndex((v) => v.route === route);
if (index === -1) {
return null
return null;
}
return documents[index];
};
@ -165,29 +244,29 @@ 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();
const navigate = useNavigate();
const location = useLocation();
const { paramPage = '' } = useParams();
const { paramPage = "" } = useParams();
const [page, setPage] = useState<string>(paramPage);
const [drawerOpen, setDrawerOpen] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
// Track location changes
useEffect(() => {
const parts = location.pathname.split('/');
const parts = location.pathname.split("/");
if (parts.length > 2) {
setPage(parts[2]);
} else {
setPage('');
setPage("");
}
}, [location]);
@ -202,19 +281,19 @@ const DocsPage = (props: BackstoryPageProps) => {
const onDocumentExpand = (docName: string, open: boolean) => {
console.log("Document expanded:", { docName, open, location });
if (open) {
const parts = location.pathname.split('/');
const parts = location.pathname.split("/");
if (docName === "backstory") {
navigate('/');
navigate("/");
return;
}
if (parts.length > 2) {
const basePath = parts.slice(0, -1).join('/');
const basePath = parts.slice(0, -1).join("/");
navigate(`${basePath}/${docName}`);
} else {
navigate(docName);
}
} else {
const basePath = location.pathname.split('/').slice(0, -1).join('/');
const basePath = location.pathname.split("/").slice(0, -1).join("/");
navigate(`${basePath}`);
}
};
@ -230,23 +309,32 @@ const DocsPage = (props: BackstoryPageProps) => {
};
interface DocViewProps {
page: string
};
page: string;
}
const DocView = (props: DocViewProps) => {
const { page = 'about' } = props;
const { page = "about" } = props;
const title = documentTitleFromRoute(page);
const icon = getDocumentIcon(title);
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>
);
@ -255,19 +343,23 @@ const DocsPage = (props: BackstoryPageProps) => {
// Render the appropriate content based on current page
function renderContent() {
switch (page) {
case 'ui-overview':
return (<BackstoryUIOverviewPage />);
case 'theme-visualizer':
return (<Paper sx={{ m: 0, p: 1 }}><BackstoryThemeVisualizerPage /></Paper>);
case 'app-analysis':
return (<BackstoryAppAnalysisPage />);
case 'ui-mockup':
return (<MockupPage />);
case 'user-management':
return (<UserManagement />);
case "ui-overview":
return <BackstoryUIOverviewPage />;
case "theme-visualizer":
return (
<Paper sx={{ m: 0, p: 1 }}>
<BackstoryThemeVisualizerPage />
</Paper>
);
case "app-analysis":
return <BackstoryAppAnalysisPage />;
case "ui-mockup":
return <MockupPage />;
case "user-management":
return <UserManagement />;
default:
if (documentFromRoute(page)) {
return <DocView page={page}/>
return <DocView page={page} />;
}
// Document grid for landing page
return (
@ -277,28 +369,56 @@ 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>
@ -310,7 +430,7 @@ const DocsPage = (props: BackstoryPageProps) => {
const drawerWidth = 240;
return (
<Box sx={{ display: 'flex', height: '100%' }}>
<Box sx={{ display: "flex", height: "100%" }}>
{/* Mobile App Bar */}
{isMobile && (
<AppBar
@ -318,7 +438,7 @@ 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"
@ -332,7 +452,12 @@ const DocsPage = (props: BackstoryPageProps) => {
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ color: "white" }}>
<Typography
variant="h6"
noWrap
component="div"
sx={{ color: "white" }}
>
{page ? documentTitleFromRoute(page) : "Documentation"}
</Typography>
</Toolbar>
@ -344,7 +469,7 @@ const DocsPage = (props: BackstoryPageProps) => {
component="nav"
sx={{
width: { md: drawerWidth },
flexShrink: { md: 0 }
flexShrink: { md: 0 },
}}
>
{/* Mobile drawer (temporary) */}
@ -357,10 +482,10 @@ const DocsPage = (props: BackstoryPageProps) => {
keepMounted: true, // Better open performance on mobile
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: drawerWidth
display: { xs: "block", md: "none" },
"& .MuiDrawer-paper": {
boxSizing: "border-box",
width: drawerWidth,
},
}}
>
@ -376,12 +501,12 @@ const DocsPage = (props: BackstoryPageProps) => {
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
display: { xs: "none", md: "block" },
"& .MuiDrawer-paper": {
boxSizing: "border-box",
width: drawerWidth,
position: 'relative',
height: '100%'
position: "relative",
height: "100%",
},
}}
open
@ -403,8 +528,8 @@ const DocsPage = (props: BackstoryPageProps) => {
p: 3,
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'
height: "100%",
overflow: "auto",
}}
>
{renderContent()}
@ -413,4 +538,4 @@ const DocsPage = (props: BackstoryPageProps) => {
);
};
export { DocsPage };
export { DocsPage };

View File

@ -1,12 +1,10 @@
import React, { } from 'react';
import React from "react";
import { BackstoryPageProps } from '../components/BackstoryTab';
import { CandidatePicker } from 'components/ui/CandidatePicker';
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

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

View File

@ -1,5 +1,5 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import React from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Button,
@ -11,16 +11,16 @@ import {
Card,
CardContent,
CardActions,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import PersonSearchIcon from '@mui/icons-material/PersonSearch';
import WorkHistoryIcon from '@mui/icons-material/WorkHistory';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import DescriptionIcon from '@mui/icons-material/Description';
import professionalConversationPng from 'assets/Conversation.png';
import { ComingSoon } from 'components/ui/ComingSoon';
import { useAuth } from 'hooks/AuthContext';
} from "@mui/material";
import { styled } from "@mui/material/styles";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import PersonSearchIcon from "@mui/icons-material/PersonSearch";
import WorkHistoryIcon from "@mui/icons-material/WorkHistory";
import QuestionAnswerIcon from "@mui/icons-material/QuestionAnswer";
import DescriptionIcon from "@mui/icons-material/Description";
import professionalConversationPng from "assets/Conversation.png";
import { ComingSoon } from "components/ui/ComingSoon";
import { useAuth } from "hooks/AuthContext";
// Placeholder for Testimonials component
const Testimonials = () => {
@ -36,7 +36,7 @@ const HeroSection = styled(Box)(({ theme }) => ({
padding: theme.spacing(8, 0),
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
[theme.breakpoints.down('md')]: {
[theme.breakpoints.down("md")]: {
padding: theme.spacing(6, 0),
},
}));
@ -50,7 +50,7 @@ const HeroButton = (props: HeroButtonProps) => {
const navigate = useNavigate();
const handleClick = () => {
const path = children?.replace(/ /g, '-').toLocaleLowerCase() || '/';
const path = children?.replace(/ /g, "-").toLocaleLowerCase() || "/";
navigate(path);
};
@ -60,16 +60,17 @@ const HeroButton = (props: HeroButtonProps) => {
fontWeight: 500,
backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper,
'&:hover': {
"&:hover": {
backgroundColor: theme.palette.action.active,
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;
@ -80,23 +81,25 @@ const ActionButton = (props: ActionButtonProps) => {
const navigate = useNavigate();
const handleClick = () => {
const path = children?.replace(/ /g, '-').toLocaleLowerCase() || '/';
const path = children?.replace(/ /g, "-").toLocaleLowerCase() || "/";
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,
color: theme.palette.background.paper,
borderRadius: '50%',
borderRadius: "50%",
padding: theme.spacing(2),
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
display: "flex",
justifyContent: "center",
alignItems: "center",
marginBottom: theme.spacing(2),
width: 64,
height: 64,
@ -106,14 +109,14 @@ const FeatureIcon = styled(Box)(({ theme }) => ({
const FeatureCard = ({
icon,
title,
description
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) => {
return (
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Card sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<CardContent sx={{ flexGrow: 1 }}>
<Box display="flex" justifyContent="center" mb={2}>
{icon}
@ -125,7 +128,7 @@ const FeatureCard = ({
{description}
</Typography>
</CardContent>
<CardActions sx={{ justifyContent: 'center', pb: 2 }}>
<CardActions sx={{ justifyContent: "center", pb: 2 }}>
<Button size="small" endIcon={<ArrowForwardIcon />}>
Learn more
</Button>
@ -140,69 +143,75 @@ 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"
component="h1"
sx={{
fontWeight: 700,
fontSize: { xs: '2rem', md: '3rem' },
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"
>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<HeroButton variant="contained" size="large">
Get Started as Candidate
</HeroButton>
<HeroButton
variant="outlined"
size="large"
sx={{
backgroundColor: 'transparent',
border: '2px solid',
borderColor: 'action.active'
backgroundColor: "transparent",
border: "2px solid",
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}
alt="Professional conversation"
sx={{
width: '100%',
width: "100%",
maxWidth: 200,
height: 'auto',
height: "auto",
borderRadius: 2,
boxShadow: 3,
}}
@ -224,33 +233,48 @@ 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' }}>
<Typography
variant="h4"
component="h3"
gutterBottom
sx={{ color: "primary.main" }}
>
For Job Seekers
</Typography>
<Box sx={{ my: 3 }}>
<Typography variant="body1" paragraph>
Backstory helps you tell your complete professional story, highlight your achievements, and showcase your skills beyond what fits on a traditional resume.
Backstory helps you tell your complete professional story,
highlight your achievements, and showcase your skills beyond
what fits on a traditional resume.
</Typography>
</Box>
<Stack spacing={3}>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
<Box
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
borderRadius: "50%",
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
mr: 2,
fontWeight: "bold",
}}
>
1
</Box>
<Typography variant="body1">
@ -259,42 +283,47 @@ 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">
Configure your AI assistant to answer questions about your experience
Configure your AI assistant to answer questions about your
experience
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
<Box
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
borderRadius: "50%",
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
mr: 2,
fontWeight: "bold",
}}
>
3
</Box>
<Typography variant="body1">
@ -303,110 +332,125 @@ const HomePage = () => {
</Box>
</Stack>
<ActionButton
variant="contained"
color="secondary"
sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />}
>
Create Your Profile
</ActionButton>
</Box>
<ComingSoon>
<Box sx={{ flex: 1 }}>
<Typography variant="h4" component="h3" gutterBottom sx={{ color: 'primary.main' }}>
For Employers
</Typography>
<Box sx={{ my: 3 }}>
<Typography variant="body1" paragraph>
Discover candidates with the perfect skills and experience for your positions by engaging in meaningful Q&A to learn more about their background.
</Typography>
</Box>
<Stack spacing={3}>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
1
</Box>
<Typography variant="body1">
Search the candidate pool based on skills, experience, and location
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
2
</Box>
<Typography variant="body1">
Ask personalized questions about candidates' experience and skills
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box sx={{
backgroundColor: 'secondary.main',
color: 'secondary.contrastText',
borderRadius: '50%',
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mr: 2,
fontWeight: 'bold'
}}>
3
</Box>
<Typography variant="body1">
Generate targeted resumes that match your job requirements
</Typography>
</Box>
</Stack>
<ActionButton
variant="contained"
color="secondary"
sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />}
>
Start Recruiting
Create Your Profile
</ActionButton>
</Box>
</ComingSoon>
<ComingSoon>
<Box sx={{ flex: 1 }}>
<Typography
variant="h4"
component="h3"
gutterBottom
sx={{ color: "primary.main" }}
>
For Employers
</Typography>
<Box sx={{ my: 3 }}>
<Typography variant="body1" paragraph>
Discover candidates with the perfect skills and experience for
your positions by engaging in meaningful Q&A to learn more
about their background.
</Typography>
</Box>
<Stack spacing={3}>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: "secondary.main",
color: "secondary.contrastText",
borderRadius: "50%",
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
mr: 2,
fontWeight: "bold",
}}
>
1
</Box>
<Typography variant="body1">
Search the candidate pool based on skills, experience, and
location
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: "secondary.main",
color: "secondary.contrastText",
borderRadius: "50%",
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
mr: 2,
fontWeight: "bold",
}}
>
2
</Box>
<Typography variant="body1">
Ask personalized questions about candidates' experience and
skills
</Typography>
</Box>
<Box display="flex" alignItems="center">
<Box
sx={{
backgroundColor: "secondary.main",
color: "secondary.contrastText",
borderRadius: "50%",
width: 40,
height: 40,
minWidth: 40,
minHeight: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
mr: 2,
fontWeight: "bold",
}}
>
3
</Box>
<Typography variant="body1">
Generate targeted resumes that match your job requirements
</Typography>
</Box>
</Stack>
<ActionButton
variant="contained"
color="secondary"
sx={{ mt: 4 }}
endIcon={<ArrowForwardIcon />}
>
Start Recruiting
</ActionButton>
</Box>
</ComingSoon>
</Box>
</Container>
{/* Features Section */}
<Box sx={{ backgroundColor: 'background.paper', py: 8 }}>
<Box sx={{ backgroundColor: "background.paper", py: 8 }}>
<Container>
<Typography
variant="h3"
@ -418,8 +462,17 @@ const HomePage = () => {
Key Features
</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={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
<Box
sx={{
flex: "1 1 250px",
minWidth: {
xs: "100%",
sm: "calc(50% - 16px)",
md: "calc(25% - 16px)",
},
}}
>
<FeatureCard
icon={
<FeatureIcon>
@ -430,7 +483,16 @@ const HomePage = () => {
description="Find the perfect candidates based on skills, experience, and fit for your specific requirements."
/>
</Box>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}>
<Box
sx={{
flex: "1 1 250px",
minWidth: {
xs: "100%",
sm: "calc(50% - 16px)",
md: "calc(25% - 16px)",
},
}}
>
<FeatureCard
icon={
<FeatureIcon>
@ -441,7 +503,16 @@ const HomePage = () => {
description="Share your full professional journey beyond the limitations of a traditional resume."
/>
</Box>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}>
<Box
sx={{
flex: "1 1 250px",
minWidth: {
xs: "100%",
sm: "calc(50% - 16px)",
md: "calc(25% - 16px)",
},
}}
>
<FeatureCard
icon={
<FeatureIcon>
@ -452,7 +523,16 @@ const HomePage = () => {
description="Ask detailed questions about a candidate's experience and get immediate answers."
/>
</Box>
<Box sx={{ flex: '1 1 250px', minWidth: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(25% - 16px)' } }}>
<Box
sx={{
flex: "1 1 250px",
minWidth: {
xs: "100%",
sm: "calc(50% - 16px)",
md: "calc(25% - 16px)",
},
}}
>
<FeatureCard
icon={
<FeatureIcon>
@ -468,68 +548,76 @@ 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.
Join Backstory today and discover a better way to connect talent
with opportunity.
</Typography>
<Stack
direction={{ xs: 'column', sm: 'row' }}
direction={{ xs: "column", sm: "row" }}
spacing={2}
justifyContent="center"
>
<HeroButton
variant="contained"
size="large"
>
<HeroButton variant="contained" size="large">
Sign Up as Candidate
</HeroButton>
<HeroButton
variant="outlined"
size="large"
sx={{
backgroundColor: 'transparent',
border: '2px solid',
borderColor: 'action.active'
backgroundColor: "transparent",
border: "2px solid",
borderColor: "action.active",
}}
>
Sign Up as Employer
@ -538,11 +626,8 @@ const HomePage = () => {
</Box>
</Container>
</Box>
</Box>
</Box>
);
};
export {
HomePage
};
export { HomePage };

View File

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

View File

@ -1,12 +1,12 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Box,
Stepper,
Step,
StepLabel,
Button,
Typography,
Paper,
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
Box,
Stepper,
Step,
StepLabel,
Button,
Typography,
Paper,
useTheme,
Snackbar,
Alert,
@ -15,35 +15,38 @@ import {
Avatar,
useMediaQuery,
Divider,
} from '@mui/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';
} from "@mui/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 { useNavigate } from 'react-router-dom';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext';
import { CandidateInfo } from 'components/ui/CandidateInfo';
import { ComingSoon } from 'components/ui/ComingSoon';
import { LoginRequired } from 'components/ui/LoginRequired';
import { Scrollable } from 'components/Scrollable';
import { CandidatePicker } from 'components/ui/CandidatePicker';
import { JobPicker } from 'components/ui/JobPicker';
import { JobCreator } from 'components/JobCreator';
import { LoginRestricted } from 'components/ui/LoginRestricted';
import JsonView from '@uiw/react-json-view';
import { ResumeGenerator } from 'components/ResumeGenerator';
import { JobInfo } from 'components/ui/JobInfo';
import { useNavigate } from "react-router-dom";
import { BackstoryPageProps } from "components/BackstoryTab";
import { useAuth } from "hooks/AuthContext";
import {
useAppState,
useSelectedCandidate,
useSelectedJob,
} from "hooks/GlobalContext";
import { CandidateInfo } from "components/ui/CandidateInfo";
import { ComingSoon } from "components/ui/ComingSoon";
import { LoginRequired } from "components/ui/LoginRequired";
import { Scrollable } from "components/Scrollable";
import { CandidatePicker } from "components/ui/CandidatePicker";
import { JobPicker } from "components/ui/JobPicker";
import { JobCreator } from "components/JobCreator";
import { LoginRestricted } from "components/ui/LoginRestricted";
import JsonView from "@uiw/react-json-view";
import { ResumeGenerator } from "components/ResumeGenerator";
import { JobInfo } from "components/ui/JobInfo";
function WorkAddIcon() {
return (
<Box position="relative" display="inline-flex"
<Box
position="relative"
display="inline-flex"
sx={{
lineHeight: "30px",
mb: "6px",
@ -52,12 +55,12 @@ function WorkAddIcon() {
<WorkOutline sx={{ fontSize: 24 }} />
<Add
sx={{
position: 'absolute',
position: "absolute",
bottom: -2,
right: -2,
fontSize: 14,
bgcolor: 'background.paper',
borderRadius: '50%',
bgcolor: "background.paper",
borderRadius: "50%",
boxShadow: 1,
}}
color="primary"
@ -71,7 +74,7 @@ interface AnalysisState {
candidate: Candidate | null;
analysis: SkillAssessment[] | null;
resume: string | null;
};
}
interface Step {
index: number;
@ -79,7 +82,7 @@ interface Step {
requiredState: string[];
title: string;
icon: React.ReactNode;
};
}
const initialState: AnalysisState = {
job: null,
@ -90,18 +93,30 @@ const initialState: AnalysisState = {
// Steps in our process
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: [], 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, "-") };
});
const capitalize = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1);
}
};
// Main component
const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const JobAnalysisPage: React.FC<BackstoryPageProps> = (
props: BackstoryPageProps
) => {
const theme = useTheme();
const { user, guest } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
@ -109,35 +124,52 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
const [activeStep, setActiveStep] = useState<Step>(steps[0]);
const [error, setError] = useState<string | null>(null);
const [jobTab, setJobTab] = useState<string>('select');
const [analysisState, setAnalysisState] = useState<AnalysisState | null>(null);
const [jobTab, setJobTab] = useState<string>("select");
const [analysisState, setAnalysisState] = useState<AnalysisState | null>(
null
);
const [canAdvance, setCanAdvance] = useState<boolean>(false);
const scrollRef = useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
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;
}
}
}, [analysisState, selectedCandidate, selectedJob, setActiveStep, canAccessStep]);
}, [
analysisState,
selectedCandidate,
selectedJob,
setActiveStep,
canAccessStep,
]);
useEffect(() => {
if (activeStep.index === steps.length - 1) {
@ -167,7 +199,7 @@ 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]);
}
@ -188,7 +220,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
return;
}
setActiveStep(steps[step]);
}
};
const onCandidateSelect = (candidate: Candidate) => {
if (!analysisState) {
@ -198,7 +230,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setAnalysisState({ ...analysisState });
setSelectedCandidate(candidate);
handleNext();
}
};
const onJobSelect = (job: Job) => {
if (!analysisState) {
@ -208,7 +240,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
setAnalysisState({ ...analysisState });
setSelectedJob(job);
handleNext();
}
};
// Render function for the candidate selection step
const renderCandidateSelection = () => (
@ -221,28 +253,25 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
// Render function for the job description step
const renderJobDescription = () => {
return (<Box sx={{ mt: 3, width: "100%" }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value='select' icon={<WorkOutline />} label="Select Job" />
<Tab value='create' icon={<WorkAddIcon />} label="Create Job" />
</Tabs>
</Box>
return (
<Box sx={{ mt: 3, width: "100%" }}>
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 3 }}>
<Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value="select" icon={<WorkOutline />} label="Select Job" />
<Tab value="create" icon={<WorkAddIcon />} label="Create Job" />
</Tabs>
</Box>
{jobTab === 'select' &&
<JobPicker onSelect={onJobSelect} />
}
{jobTab === 'create' && user &&
<JobCreator
onSave={onJobSelect}
/>}
{jobTab === 'create' && guest &&
<LoginRestricted><JobCreator
onSave={onJobSelect}
/></LoginRestricted>}
</Box>
{jobTab === "select" && <JobPicker onSelect={onJobSelect} />}
{jobTab === "create" && user && <JobCreator onSave={onJobSelect} />}
{jobTab === "create" && guest && (
<LoginRestricted>
<JobCreator onSave={onJobSelect} />
</LoginRestricted>
)}
</Box>
);
}
};
const onAnalysisComplete = (skills: SkillAssessment[]) => {
if (!analysisState) {
@ -258,63 +287,93 @@ 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 = () => {
if (!analysisState) {
return;
}
if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) {
if (
!analysisState.job ||
!analysisState.candidate ||
!analysisState.analysis
) {
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 }}>
<Stepper
activeStep={activeStep.index}
alternativeLabel
sx={{ mt: 2, mb: 2 }}
>
{steps.map((step, index) => (
<Step>
<StepLabel sx={{ cursor: "pointer" }} onClick={() => { moveToStep(index); }}
<StepLabel
sx={{ cursor: "pointer" }}
onClick={() => {
moveToStep(index);
}}
slots={{
stepIcon: () => (
<Avatar key={step.index}
<Avatar
key={step.index}
sx={{
bgcolor: activeStep.index >= step.index ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white'
bgcolor:
activeStep.index >= step.index
? theme.palette.primary.main
: theme.palette.grey[300],
color: "white",
}}
>
{step.icon}
</Avatar>
)
),
}}
>
{step.title}
@ -322,30 +381,43 @@ 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: isMobile ? "column" : "row" }}
>
{analysisState && analysisState.job && (
<Box sx={{ display: "flex", flexDirection: "row", width: "100%" }}>
{!isMobile &&
{!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 &&
)}
{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={{}} />
<CandidateInfo
variant="minimal"
candidate={analysisState.candidate}
sx={{}}
/>
</Box>
}
)}
</Box>
</Paper>
<Scrollable
@ -354,50 +426,67 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
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()}
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}
{/* Error Snackbar */}
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert
onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
severity="error"
sx={{ width: "100%" }}
>
<Alert onClose={() => setError(null)} severity="error" sx={{ width: '100%' }}>
{error}
</Alert>
{error}
</Alert>
</Snackbar>
</Box>);
</Box>
);
};
export { JobAnalysisPage };
export { JobAnalysisPage };

View File

@ -1,24 +1,31 @@
import Box from '@mui/material/Box';
import { BackstoryPageProps } from '../components/BackstoryTab';
import { Message } from '../components/Message';
import { ChatMessage } from 'types/types';
import Box from "@mui/material/Box";
import { BackstoryPageProps } from "../components/BackstoryTab";
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

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import {
Box,
Container,
@ -11,37 +11,37 @@ import {
CardContent,
useMediaQuery,
useTheme,
} from '@mui/material';
import {
Person,
PersonAdd,
} from '@mui/icons-material';
import 'react-phone-number-input/style.css';
import './LoginPage.css';
} from "@mui/material";
import { Person, PersonAdd } from "@mui/icons-material";
import "react-phone-number-input/style.css";
import "./LoginPage.css";
import { useAuth } from 'hooks/AuthContext';
import { BackstoryLogo } from 'components/ui/BackstoryLogo';
import { useAuth } from "hooks/AuthContext";
import { BackstoryLogo } from "components/ui/BackstoryLogo";
import { BackstoryPageProps } from 'components/BackstoryTab';
import { BackstoryPageProps } from "components/BackstoryTab";
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';
import { useNavigate, useParams } from "react-router-dom";
import { useAppState } from "hooks/GlobalContext";
import * as Types from "types/types";
const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const navigate = useNavigate();
const { setSnack } = useAppState();
const [tabValue, setTabValue] = useState<string>('login');
const [tabValue, setTabValue] = useState<string>("login");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
const { guest, user, login, isLoading, error } = useAuth();
const name = (user?.userType === 'candidate') ? (user as Types.Candidate).username : user?.email || '';
const name =
user?.userType === "candidate"
? (user as Types.Candidate).username
: user?.email || "";
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const showGuest: boolean = false;
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const showGuest = false;
const { tab } = useParams();
useEffect(() => {
@ -50,7 +50,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
}
if (loading && error) {
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const jsonStr = error.replace(/^[^{]*/, "");
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
setSnack(data.error.message, "error");
@ -62,7 +62,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
}, [error, loading]);
useEffect(() => {
if (tab === 'register') {
if (tab === "register") {
setTabValue(tab);
}
}, [tab, setTabValue]);
@ -74,58 +74,54 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
// If user is logged in, navigate to the profile page
if (user) {
navigate('/candidate/profile');
return (<></>);
navigate("/candidate/profile");
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 />
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
{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>
<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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState } from "react";
import {
Box,
Button,
@ -15,11 +15,11 @@ import {
useMediaQuery,
CircularProgress,
Snackbar,
Alert
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { CloudUpload, PhotoCamera } from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
Alert,
} from "@mui/material";
import { styled } from "@mui/material/styles";
import { CloudUpload, PhotoCamera } from "@mui/icons-material";
import { useTheme } from "@mui/material/styles";
// import { Beta } from '../components/Beta';
// Interfaces
@ -34,45 +34,53 @@ interface ProfileFormData {
}
// Styled components
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
const VisuallyHiddenInput = styled("input")({
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: 1,
overflow: 'hidden',
position: 'absolute',
overflow: "hidden",
position: "absolute",
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
whiteSpace: "nowrap",
width: 1,
});
const CreateProfilePage: React.FC = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
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'
message: "",
severity: "success",
});
const [formData, setFormData] = useState<ProfileFormData>({
firstName: '',
lastName: '',
email: '',
phoneNumber: '',
jobTitle: '',
location: '',
bio: '',
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
jobTitle: "",
location: "",
bio: "",
});
// Steps for the profile creation process
const steps = ['Personal Information', 'Professional Details', 'Resume Upload'];
const steps = [
"Personal Information",
"Professional Details",
"Resume Upload",
];
// Handle form input changes
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -103,7 +111,7 @@ const CreateProfilePage: React.FC = () => {
setSnackbar({
open: true,
message: `Resume uploaded: ${e.target.files[0].name}`,
severity: 'success'
severity: "success",
});
}
};
@ -124,16 +132,16 @@ const CreateProfilePage: React.FC = () => {
// 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'
message: "Profile created successfully! Redirecting to dashboard...",
severity: "success",
});
// Redirect would happen here in a real application
// history.push('/dashboard');
}, 2000);
@ -143,11 +151,13 @@ 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() !== '';
return formData.jobTitle.trim() !== "";
case 2:
return resumeFile !== null;
default:
@ -161,25 +171,31 @@ 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,
src={profileImage || ""}
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"
<IconButton
color="primary"
aria-label="upload picture"
component="label"
>
<PhotoCamera />
<VisuallyHiddenInput
type="file"
<VisuallyHiddenInput
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
@ -189,7 +205,7 @@ const CreateProfilePage: React.FC = () => {
</Typography>
</Box>
</Grid>
<Grid size={{xs: 12, sm: 6}}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
required
fullWidth
@ -200,7 +216,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs: 12, sm: 6}}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
required
fullWidth
@ -211,7 +227,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs: 12}}>
<Grid size={{ xs: 12 }}>
<TextField
required
fullWidth
@ -223,7 +239,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs:12}}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label="Phone Number"
@ -238,7 +254,7 @@ const CreateProfilePage: React.FC = () => {
case 1:
return (
<Grid container spacing={3}>
<Grid size={{xs:12}}>
<Grid size={{ xs: 12 }}>
<TextField
required
fullWidth
@ -249,7 +265,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs: 12}}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label="Location"
@ -260,7 +276,7 @@ const CreateProfilePage: React.FC = () => {
variant="outlined"
/>
</Grid>
<Grid size={{xs:12}}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
multiline
@ -278,12 +294,13 @@ 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 }}>
<Box sx={{ textAlign: "center", mt: 2 }}>
<Button
component="label"
variant="contained"
@ -291,15 +308,19 @@ 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 }}>
<Typography
variant="body2"
color="textSecondary"
sx={{ mt: 1 }}
>
File uploaded: {resumeFile.name}
</Typography>
)}
@ -308,28 +329,28 @@ const CreateProfilePage: React.FC = () => {
</Grid>
);
default:
return 'Unknown step';
return "Unknown step";
}
};
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'}
orientation={isMobile ? "vertical" : "horizontal"}
sx={{ mt: 3, mb: 5 }}
>
{steps.map((label) => (
@ -338,12 +359,10 @@ const CreateProfilePage: React.FC = () => {
</Step>
))}
</Stepper>
<Box sx={{ mt: 2, mb: 4 }}>
{getStepContent(activeStep)}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
<Box sx={{ mt: 2, mb: 4 }}>{getStepContent(activeStep)}</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 4 }}>
<Button
disabled={activeStep === 0}
onClick={handleBack}
@ -355,9 +374,11 @@ const CreateProfilePage: React.FC = () => {
variant="contained"
onClick={handleNext}
disabled={!isStepValid() || loading}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : null}
startIcon={
loading ? <CircularProgress size={20} color="inherit" /> : null
}
>
{activeStep === steps.length - 1 ? 'Create Profile' : 'Next'}
{activeStep === steps.length - 1 ? "Create Profile" : "Next"}
</Button>
</Box>
</Paper>
@ -367,10 +388,10 @@ 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%' }}
sx={{ width: "100%" }}
>
{snackbar.message}
</Alert>
@ -379,4 +400,4 @@ const CreateProfilePage: React.FC = () => {
);
};
export { CreateProfilePage };
export { CreateProfilePage };

View File

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

View File

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

@ -2,22 +2,23 @@ import React, { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Box } from "@mui/material";
import { SetSnackType } from '../components/Snack';
import { SetSnackType } from "../components/Snack";
import { LoadingComponent } from "../components/LoadingComponent";
import { User, Guest, Candidate } from 'types/types';
import { User, Guest, Candidate } from "types/types";
import { useAuth } from "hooks/AuthContext";
import { useSelectedCandidate } from "hooks/GlobalContext";
import { useAppState, useSelectedCandidate } from "hooks/GlobalContext";
interface CandidateRouteProps {
guest?: Guest | null;
user?: User | null;
setSnack: SetSnackType,
};
}
const CandidateRoute: React.FC<CandidateRouteProps> = (props: CandidateRouteProps) => {
const CandidateRoute: React.FC<CandidateRouteProps> = (
props: CandidateRouteProps
) => {
const { apiClient } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const { setSnack } = props;
const { setSnack } = useAppState();
const { username } = useParams<{ username: string }>();
const navigate = useNavigate();
@ -25,30 +26,40 @@ const CandidateRoute: React.FC<CandidateRouteProps> = (props: CandidateRouteProp
if (selectedCandidate?.username === username || !username) {
return;
}
const getCandidate = async (reference: string) => {
const getCandidate = async (reference: string): Promise<void> => {
try {
const result: Candidate = await apiClient.getCandidate(reference);
setSelectedCandidate(result);
navigate('/chat');
navigate("/chat");
} catch {
setSnack(`Unable to obtain information for ${username}.`, "error");
navigate('/');
navigate("/");
}
}
};
getCandidate(username);
}, [setSelectedCandidate, selectedCandidate, username, navigate, setSnack, apiClient]);
}, [
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.
*/
@ -12,34 +12,36 @@
/**
* Converts a camelCase object to snake_case for sending to the Python backend
*/
export function toSnakeCase<T extends Record<string, any>>(obj: T): Record<string, any> {
if (!obj || typeof obj !== 'object') return obj;
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));
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 =>
typeof item === 'object' && item !== null ? toSnakeCase(item) : item
result[snakeCaseKey] = value.map((item) =>
typeof item === "object" && item !== null ? toSnakeCase(item) : item
);
} else if (value instanceof Date) {
// Convert Date to ISO string for Python datetime
result[snakeCaseKey] = value.toISOString();
} else if (typeof value === 'object') {
} else if (typeof value === "object") {
result[snakeCaseKey] = toSnakeCase(value);
} else {
result[snakeCaseKey] = value;
}
}
return result;
}
@ -47,33 +49,33 @@ export function toSnakeCase<T extends Record<string, any>>(obj: T): Record<strin
* Converts a snake_case object to camelCase for TypeScript/JavaScript
*/
export function toCamelCase<T>(obj: Record<string, any>): T {
if (!obj || typeof obj !== 'object') return obj as T;
if (!obj || typeof obj !== "object") return obj as T;
if (Array.isArray(obj)) {
return obj.map(item => toCamelCase(item)) as T;
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 =>
typeof item === 'object' && item !== null ? toCamelCase(item) : item
result[camelCaseKey] = value.map((item) =>
typeof item === "object" && item !== null ? toCamelCase(item) : item
);
} else if (typeof value === 'string' && isIsoDateString(value)) {
} else if (typeof value === "string" && isIsoDateString(value)) {
// Convert ISO date string to Date object
result[camelCaseKey] = new Date(value);
} else if (typeof value === 'object') {
} else if (typeof value === "object") {
result[camelCaseKey] = toCamelCase(value);
} else {
result[camelCaseKey] = value;
}
}
return result as T;
}
@ -81,7 +83,7 @@ export function toCamelCase<T>(obj: Record<string, any>): T {
* Helper function to convert camelCase to snake_case
*/
function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
@ -95,8 +97,10 @@ function snakeToCamel(str: string): string {
* Checks if a string is an ISO date format
*/
function isIsoDateString(value: string): boolean {
if (typeof value !== 'string') return false;
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(value);
if (typeof value !== "string") return false;
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(
value
);
}
// ============================
@ -106,32 +110,34 @@ function isIsoDateString(value: string): boolean {
/**
* Format data for API requests (converts to format expected by Python backend)
*/
export function formatApiRequest<T extends Record<string, any>>(data: T): Record<string, any> {
export function formatApiRequest<T extends Record<string, any>>(
data: T
): Record<string, any> {
if (!data) return data;
// 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) {
formatted[key] = value.toISOString();
} else if (Array.isArray(value)) {
formatted[key] = value.map(item => {
formatted[key] = value.map((item) => {
if (item instanceof Date) {
return item.toISOString();
} else if (typeof item === 'object' && item !== null) {
} else if (typeof item === "object" && item !== null) {
return formatApiRequest(item);
}
return item;
});
} else if (typeof value === 'object' && value !== null) {
} else if (typeof value === "object" && value !== null) {
formatted[key] = formatApiRequest(value);
} else {
formatted[key] = value;
}
}
return toSnakeCase(formatted);
}
@ -139,19 +145,19 @@ export function formatApiRequest<T extends Record<string, any>>(data: T): Record
* Parse API responses and convert to TypeScript format
*/
export function parseApiResponse<T>(data: any): ApiResponse<T> {
if (!data || typeof data !== 'object') {
if (!data || typeof data !== "object") {
return {
success: false,
error: {
code: 'INVALID_RESPONSE',
message: 'Invalid response format'
}
code: "INVALID_RESPONSE",
message: "Invalid response format",
},
};
}
// Convert any snake_case fields to camelCase and parse dates
const parsed = toCamelCase<ApiResponse<T>>(data);
return parsed;
}
@ -159,28 +165,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,17 +199,17 @@ 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)) {
// Handle arrays by adding multiple params with same key
value.forEach(item => {
value.forEach((item) => {
params.append(key, String(item));
});
} else if (value instanceof Date) {
params.append(key, value.toISOString());
} else if (typeof value === 'object') {
} else if (typeof value === "object") {
// For nested objects, we could flatten or JSON stringify
params.append(key, JSON.stringify(value));
} else {
@ -211,7 +217,7 @@ export function toUrlParams(obj: Record<string, any>): URLSearchParams {
}
}
}
return params;
}
@ -222,15 +228,17 @@ export function toUrlParams(obj: Record<string, any>): URLSearchParams {
/**
* Check if response is a successful API response
*/
export function isSuccessResponse<T>(response: any): response is SuccessApiResponse<T> {
return response && typeof response === 'object' && response.success === true;
export function isSuccessResponse<T>(
response: any
): response is SuccessApiResponse<T> {
return response && typeof response === "object" && response.success === true;
}
/**
* Check if response is an error API response
*/
export function isErrorResponse(response: any): response is ErrorApiResponse {
return response && typeof response === 'object' && response.success === false;
return response && typeof response === "object" && response.success === false;
}
/**
@ -240,11 +248,11 @@ export function extractApiData<T>(response: ApiResponse<T>): T {
if (isSuccessResponse(response) && response.data !== undefined) {
return response.data;
}
const errorMessage = isErrorResponse(response)
? response.error?.message || 'Unknown API error'
: 'Invalid API response format';
const errorMessage = isErrorResponse(response)
? response.error?.message || "Unknown API error"
: "Invalid API response format";
throw new Error(errorMessage);
}
@ -290,7 +298,7 @@ export interface PaginatedRequest {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
sortOrder?: "asc" | "desc";
filters?: Record<string, any>;
}
@ -301,12 +309,14 @@ export interface PaginatedRequest {
/**
* Create a paginated request with defaults
*/
export function createPaginatedRequest(params: Partial<PaginatedRequest> = {}): PaginatedRequest {
export function createPaginatedRequest(
params: Partial<PaginatedRequest> = {}
): PaginatedRequest {
return {
page: 1,
limit: 20,
sortOrder: 'desc',
...params
sortOrder: "desc",
...params,
};
}
@ -318,10 +328,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 +346,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,12 +360,12 @@ export async function handlePaginatedApiResponse<T>(
/**
* Log conversion for debugging
*/
export function debugConversion<T>(obj: T, label: string = 'Object'): T {
if (process.env.NODE_ENV === 'development') {
export function debugConversion<T>(obj: T, label = "Object"): T {
if (process.env.NODE_ENV === "development") {
console.group(`🔄 ${label} Conversion`);
console.log('Original:', obj);
if (typeof obj === 'object' && obj !== null) {
console.log('Formatted for API:', formatApiRequest(obj as any));
console.log("Original:", obj);
if (typeof obj === "object" && obj !== null) {
console.log("Formatted for API:", formatApiRequest(obj as any));
}
console.groupEnd();
}
@ -375,7 +385,7 @@ const exports = {
createPaginatedRequest,
handleApiResponse,
handlePaginatedApiResponse,
debugConversion
}
debugConversion,
};
export default exports;
export default exports;

View File

@ -1,4 +1,4 @@
import { ReactElement } from 'react';
import { ReactElement } from "react";
export interface NavigationItem {
id: string;
@ -7,12 +7,12 @@ export interface NavigationItem {
icon?: ReactElement;
children?: NavigationItem[];
component?: ReactElement;
userTypes?: ('candidate' | 'employer' | 'guest' | 'admin')[];
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
userMenuGroup?: 'profile' | 'account' | 'system' | 'admin'; // Groups items 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
}
export interface NavigationConfig {

View File

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

File diff suppressed because it is too large Load Diff