feat(frontend): brain carousel (#2924)

# Description

Please include a summary of the changes and the related issue. Please
also include relevant motivation and context.

## Checklist before requesting a review

Please delete options that are not relevant.

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented hard-to-understand areas
- [ ] I have ideally added tests that prove my fix is effective or that
my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged

## Screenshots (if appropriate):
This commit is contained in:
Antoine Dewez 2024-07-30 11:55:59 +02:00 committed by GitHub
parent 3fb655e691
commit 54c6252b64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 261 additions and 25 deletions

View File

@ -2,6 +2,8 @@ import { EditorContent } from "@tiptap/react";
import { useEffect } from "react";
import "./styles.css";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useChatStateUpdater } from "./hooks/useChatStateUpdater";
import { useCreateEditorState } from "./hooks/useCreateEditorState";
import { useEditor } from "./hooks/useEditor";
@ -20,6 +22,7 @@ export const Editor = ({
message,
}: EditorProps): JSX.Element => {
const { editor } = useCreateEditorState(placeholder);
const { currentBrain } = useBrainContext();
useEffect(() => {
const htmlString = editor?.getHTML();
@ -33,6 +36,21 @@ export const Editor = ({
}
}, [message, editor]);
useEffect(() => {
editor?.commands.focus();
}, [currentBrain, editor]);
useEffect(() => {
if (editor && placeholder) {
(
editor.extensionManager.extensions.find(
(ext) => ext.name === "placeholder"
) as { options: { placeholder: string } }
).options.placeholder = placeholder;
editor.view.updateState(editor.state);
}
}, [placeholder, editor]);
useChatStateUpdater({
editor,
setMessage,

View File

@ -0,0 +1,50 @@
@use "styles/Radius.module.scss";
@use "styles/Spacings.module.scss";
@use "styles/Typography.module.scss";
@use "styles/Variables.module.scss";
.brain_button_container {
display: flex;
padding: Spacings.$spacing03;
border: 1px solid var(--border-0);
border-radius: Radius.$normal;
cursor: pointer;
gap: Spacings.$spacing03;
flex-direction: column;
align-items: center;
height: Variables.$brainButtonHeight;
overflow: hidden;
&:hover {
border-color: var(--primary-0);
.header {
color: var(--primary-0);
}
}
.header {
display: flex;
gap: Spacings.$spacing03;
width: 100%;
align-items: center;
.name {
@include Typography.EllipsisOverflow;
font-size: Typography.$small;
font-weight: 500;
}
}
.description {
font-size: Typography.$tiny;
color: var(--text-4);
width: 100%;
font-style: italic;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
}

View File

@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import Icon from "@/lib/components/ui/Icon/Icon";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { MinimalBrainForUser } from "@/lib/context/BrainProvider/types";
import styles from "./BrainButton.module.scss";
interface BrainButtonProps {
brain: MinimalBrainForUser;
newBrain: () => void;
}
const BrainButton = ({ brain, newBrain }: BrainButtonProps): JSX.Element => {
const { setCurrentBrainId } = useBrainContext();
const [hovered, setHovered] = useState(false);
return (
<div
className={styles.brain_button_container}
onClick={() => {
setCurrentBrainId(brain.id);
newBrain();
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div className={styles.header}>
<Icon
name="brain"
size="normal"
color={hovered ? "primary" : "black"}
/>
<span className={styles.name}>{brain.name}</span>
</div>
<span className={styles.description}>{brain.description}</span>
</div>
);
};
export default BrainButton;

View File

@ -22,7 +22,7 @@
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding-top: Spacings.$spacing07;
flex-direction: column;
.main_wrapper {
@ -32,10 +32,11 @@
width: 50%;
margin-inline: auto;
transform: translateY(-#{Variables.$searchBarHeight});
margin-top: 25vh;
@media (max-width: ScreenSizes.$small) {
width: 100%;
padding-inline: Spacings.$spacing07;
padding-inline: Spacings.$spacing09;
}
.quivr_logo_wrapper {
@ -52,6 +53,34 @@
}
}
}
.brains_list_container {
display: flex;
margin-inline: -(Spacings.$spacing09);
align-items: center;
gap: calc(Spacings.$spacing05 + Spacings.$spacing01);
.brains_list_wrapper {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: Spacings.$spacing03;
overflow: hidden;
width: 100%;
overflow-y: scroll;
@media screen and (min-width: ScreenSizes.$large) {
grid-template-columns: repeat(3, minmax(200px, 1fr));
}
}
.chevron {
visibility: visible;
&.disabled {
visibility: hidden;
}
}
}
}
}
}

View File

@ -8,6 +8,7 @@ import { useBrainCreationContext } from "@/lib/components/AddBrainModal/brainCre
import { OnboardingModal } from "@/lib/components/OnboardingModal/OnboardingModal";
import { PageHeader } from "@/lib/components/PageHeader/PageHeader";
import { UploadDocumentModal } from "@/lib/components/UploadDocumentModal/UploadDocumentModal";
import Icon from "@/lib/components/ui/Icon/Icon";
import { MessageInfoBox } from "@/lib/components/ui/MessageInfoBox/MessageInfoBox";
import { QuivrButton } from "@/lib/components/ui/QuivrButton/QuivrButton";
import { SearchBar } from "@/lib/components/ui/SearchBar/SearchBar";
@ -19,10 +20,16 @@ import { useUserData } from "@/lib/hooks/useUserData";
import { redirectToLogin } from "@/lib/router/redirectToLogin";
import { ButtonType } from "@/lib/types/QuivrButton";
import BrainButton from "./BrainButton/BrainButton";
import styles from "./page.module.scss";
const Search = (): JSX.Element => {
const [isUserDataFetched, setIsUserDataFetched] = useState(false);
const [isNewBrain, setIsNewBrain] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [transitionDirection, setTransitionDirection] = useState("");
const brainsPerPage = 6;
const pathname = usePathname();
const { session } = useSupabase();
const { isBrainCreationModalOpened, setIsBrainCreationModalOpened } =
@ -45,6 +52,13 @@ const Search = (): JSX.Element => {
},
]);
const newBrain = () => {
setIsNewBrain(true);
setTimeout(() => {
setIsNewBrain(false);
}, 750);
};
useEffect(() => {
if (userIdentityData) {
setIsUserDataFetched(true);
@ -74,6 +88,27 @@ const Search = (): JSX.Element => {
}
}, [pathname, session]);
const totalPages = Math.ceil(allBrains.length / brainsPerPage);
const handleNextPage = () => {
if (currentPage < totalPages - 1) {
setTransitionDirection("next");
setCurrentPage((prevPage) => prevPage + 1);
}
};
const handlePreviousPage = () => {
if (currentPage > 0) {
setTransitionDirection("prev");
setCurrentPage((prevPage) => prevPage - 1);
}
};
const displayedBrains = allBrains.slice(
currentPage * brainsPerPage,
(currentPage + 1) * brainsPerPage
);
return (
<>
<div className={styles.main_container}>
@ -90,7 +125,46 @@ const Search = (): JSX.Element => {
</div>
</div>
<div className={styles.search_bar_wrapper}>
<SearchBar />
<SearchBar newBrain={isNewBrain} />
</div>
<div className={styles.brains_list_container}>
<div
className={`${styles.chevron} ${
currentPage === 0 ? styles.disabled : ""
}`}
onClick={handlePreviousPage}
>
<Icon
name="chevronLeft"
size="big"
color="black"
handleHover={true}
/>
</div>
<div
className={`${styles.brains_list_wrapper} ${
transitionDirection === "next"
? styles.slide_next
: styles.slide_prev
}`}
>
{displayedBrains.map((brain, index) => (
<BrainButton key={index} brain={brain} newBrain={newBrain} />
))}
</div>
<div
className={`${styles.chevron} ${
currentPage >= totalPages - 1 ? styles.disabled : ""
}`}
onClick={handleNextPage}
>
<Icon
name="chevronRight"
size="big"
color="black"
handleHover={true}
/>
</div>
</div>
</div>
</div>

View File

@ -46,6 +46,10 @@
.brain_name {
@include textColor(text-3);
@include Typography.EllipsisOverflow;
&.new {
color: var(--primary-0);
}
}
.warning {
@ -66,14 +70,6 @@
@extend %header_style;
}
.no_brain_selected {
@include textColor(warning);
.strong {
font-weight: 800;
}
}
.no_credits_left {
@include textColor(dangerous);
}

View File

@ -9,11 +9,13 @@ import { LoaderIcon } from "../ui/LoaderIcon/LoaderIcon";
interface CurrentBrainProps {
allowingRemoveBrain: boolean;
remainingCredits: number | null;
isNewBrain?: boolean;
}
export const CurrentBrain = ({
allowingRemoveBrain,
remainingCredits,
isNewBrain,
}: CurrentBrainProps): JSX.Element => {
const { currentBrain, setCurrentBrainId } = useBrainContext();
const removeCurrentBrain = (): void => {
@ -32,13 +34,7 @@ export const CurrentBrain = ({
}
if (!currentBrain) {
return (
<div className={styles.no_brain_selected}>
<span>
Press <strong className={styles.strong}>@</strong> to select a Brain
</span>
</div>
);
return <></>;
}
return (
@ -47,8 +43,16 @@ export const CurrentBrain = ({
<div className={styles.left}>
<span className={styles.title}>Talking to</span>
<div className={styles.brain_name_wrapper}>
<Icon name="brain" size="small" color="black" />
<span className={styles.brain_name}>{currentBrain.name}</span>
<Icon
name="brain"
size="small"
color={isNewBrain ? "primary" : "black"}
/>
<span
className={`${styles.brain_name} ${isNewBrain ? styles.new : ""}`}
>
{currentBrain.name}
</span>
{bulkNotifications.some(
(bulkNotif) =>
bulkNotif.brain_id === currentBrain.id &&

View File

@ -13,16 +13,24 @@
overflow: hidden;
box-shadow: BoxShadow.$large;
&.new_brain {
border-color: var(--primary-0);
}
.editor_wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: Spacings.$spacing05;
padding-top: 0;
&.disabled {
pointer-events: none;
opacity: 0.3;
padding-top: 0;
}
&.current {
padding-top: 0;
}
.search_icon {

View File

@ -15,11 +15,14 @@ import { LoaderIcon } from "../LoaderIcon/LoaderIcon";
export const SearchBar = ({
onSearch,
newBrain,
}: {
onSearch?: () => void;
newBrain?: boolean;
}): JSX.Element => {
const [searching, setSearching] = useState(false);
const [isDisabled, setIsDisabled] = useState(true);
const [placeholder, setPlaceholder] = useState("Select a @brain");
const { message, setMessage } = useChatInput();
const { setMessages } = useChatContext();
const { addQuestion } = useChat();
@ -34,6 +37,10 @@ export const SearchBar = ({
setIsDisabled(message === "");
}, [message]);
useEffect(() => {
setPlaceholder(currentBrain ? "Ask a question..." : "Select a @brain");
}, [currentBrain]);
const submit = async (): Promise<void> => {
if (!!remainingCredits && !!currentBrain && !searching) {
setSearching(true);
@ -52,21 +59,26 @@ export const SearchBar = ({
};
return (
<div className={styles.search_bar_wrapper}>
<div
className={`${styles.search_bar_wrapper} ${
newBrain ? styles.new_brain : ""
}`}
>
<CurrentBrain
allowingRemoveBrain={true}
remainingCredits={remainingCredits}
isNewBrain={newBrain}
/>
<div
className={`${styles.editor_wrapper} ${
!remainingCredits ? styles.disabled : ""
}`}
} ${currentBrain ? styles.current : ""}`}
>
<Editor
message={message}
setMessage={setMessage}
onSubmit={() => void submit()}
placeholder="Ask a question..."
placeholder={placeholder}
></Editor>
{searching ? (
<LoaderIcon size="big" color="accent" />

View File

@ -1 +1,2 @@
$small: 768px
$small: 768px;
$large: 1080px;

View File

@ -1,3 +1,4 @@
$searchBarHeight: 62px;
$pageHeaderHeight: 48px;
$menuWidth: 230px;
$brainButtonHeight: 80px;