mirror of
https://github.com/QuivrHQ/quivr.git
synced 2024-09-20 19:08:18 +03:00
feat(frontend): design changes on user profile (#2140)
# 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:
parent
7d871d9c34
commit
8f49a724ce
@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
import { CopyButton } from "@/lib/components/ui/CopyButton";
|
||||
import Icon from "@/lib/components/ui/Icon/Icon";
|
||||
import { Source } from "@/lib/types/MessageMetadata";
|
||||
|
||||
import styles from "./MessageRow.module.scss";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { MessageContent } from "./components/MessageContent/MessageContent";
|
||||
import { QuestionBrain } from "./components/QuestionBrain/QuestionBrain";
|
||||
import { QuestionPrompt } from "./components/QuestionPrompt/QuestionPrompt";
|
||||
@ -26,7 +26,7 @@ export const MessageRow = React.forwardRef(
|
||||
{ speaker, text, brainName, promptName, children }: MessageRowProps,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
const { handleCopy, isCopied, isUserSpeaker } = useMessageRow({
|
||||
const { handleCopy, isUserSpeaker } = useMessageRow({
|
||||
speaker,
|
||||
text,
|
||||
});
|
||||
@ -58,7 +58,7 @@ export const MessageRow = React.forwardRef(
|
||||
<MessageContent text={messageContent} isUser={isUserSpeaker} />
|
||||
{!isUserSpeaker && messageContent !== "🧠" && (
|
||||
<div className={styles.copy_button}>
|
||||
<CopyButton handleCopy={handleCopy} isCopied={isCopied} />
|
||||
<CopyButton handleCopy={handleCopy} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
@ -1,24 +0,0 @@
|
||||
import Icon from "@/lib/components/ui/Icon/Icon";
|
||||
|
||||
type CopyButtonProps = {
|
||||
handleCopy: () => void;
|
||||
isCopied: boolean;
|
||||
};
|
||||
|
||||
export const CopyButton = ({
|
||||
handleCopy,
|
||||
isCopied,
|
||||
}: CopyButtonProps): JSX.Element => (
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 transition"
|
||||
onClick={handleCopy}
|
||||
title={isCopied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
<Icon
|
||||
name={isCopied ? "checkCircle" : "copy"}
|
||||
color={isCopied ? "primary" : "black"}
|
||||
size="small"
|
||||
handleHover={true}
|
||||
/>
|
||||
</button>
|
||||
);
|
@ -1,5 +1,3 @@
|
||||
import { useState } from "react";
|
||||
|
||||
type UseMessageRowProps = {
|
||||
speaker: "user" | "assistant";
|
||||
text?: string;
|
||||
@ -8,22 +6,16 @@ type UseMessageRowProps = {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useMessageRow = ({ speaker, text }: UseMessageRowProps) => {
|
||||
const isUserSpeaker = speaker === "user";
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (text === undefined) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => setIsCopied(true),
|
||||
(err) => console.error("Failed to copy!", err)
|
||||
);
|
||||
setTimeout(() => setIsCopied(false), 2000); // Reset after 2 seconds
|
||||
navigator.clipboard.writeText(text).catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
return {
|
||||
isUserSpeaker,
|
||||
isCopied,
|
||||
handleCopy,
|
||||
};
|
||||
};
|
||||
|
@ -0,0 +1,6 @@
|
||||
@use "@/styles/Spacings.module.scss";
|
||||
|
||||
.response_wrapper {
|
||||
display: flex;
|
||||
gap: Spacings.$spacing03;
|
||||
}
|
@ -2,45 +2,35 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCopy } from "react-icons/fa";
|
||||
|
||||
import Button from "@/lib/components/ui/Button";
|
||||
import Field from "@/lib/components/ui/Field";
|
||||
import { CopyButton } from "@/lib/components/ui/CopyButton";
|
||||
import { FieldHeader } from "@/lib/components/ui/FieldHeader/FieldHeader";
|
||||
|
||||
import styles from "./ApiKeyConfig.module.scss";
|
||||
import { useApiKeyConfig } from "./hooks/useApiKeyConfig";
|
||||
|
||||
export const ApiKeyConfig = (): JSX.Element => {
|
||||
const {
|
||||
apiKey,
|
||||
handleCopyClick,
|
||||
handleCreateClick,
|
||||
|
||||
} = useApiKeyConfig();
|
||||
const { apiKey, handleCopyClick, handleCreateClick } = useApiKeyConfig();
|
||||
const { t } = useTranslation(["config"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="font-semibold mb-2">Quivr {t("apiKey")}</h3>
|
||||
|
||||
<div>
|
||||
{apiKey === "" ? (
|
||||
<Button
|
||||
data-testid="create-new-key"
|
||||
variant="secondary"
|
||||
onClick={() => void handleCreateClick()}
|
||||
>
|
||||
Create New Key
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Field name="quivrApiKey" disabled={true} value={apiKey} />
|
||||
<button data-testid="copy-api-key-button" onClick={handleCopyClick}>
|
||||
<FaCopy />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</>
|
||||
<div>
|
||||
<FieldHeader iconName="key" label={`Quivr ${t("apiKey")}`} />
|
||||
{apiKey === "" ? (
|
||||
<Button
|
||||
data-testid="create-new-key"
|
||||
variant="secondary"
|
||||
onClick={() => void handleCreateClick()}
|
||||
>
|
||||
Create New Key
|
||||
</Button>
|
||||
) : (
|
||||
<div className={styles.response_wrapper}>
|
||||
<span>{apiKey}</span>
|
||||
<CopyButton handleCopy={handleCopyClick} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -63,10 +63,7 @@ export const useApiKeyConfig = () => {
|
||||
try {
|
||||
setChangeOpenAiApiKeyRequestPending(true);
|
||||
|
||||
|
||||
|
||||
await updateUserIdentity({
|
||||
});
|
||||
await updateUserIdentity({});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [USER_IDENTITY_DATA_KEY],
|
||||
});
|
||||
@ -85,8 +82,7 @@ export const useApiKeyConfig = () => {
|
||||
const removeOpenAiApiKey = async () => {
|
||||
try {
|
||||
setChangeOpenAiApiKeyRequestPending(true);
|
||||
await updateUserIdentity({
|
||||
});
|
||||
await updateUserIdentity({});
|
||||
|
||||
publish({
|
||||
variant: "success",
|
||||
@ -103,8 +99,6 @@ export const useApiKeyConfig = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return {
|
||||
handleCreateClick,
|
||||
apiKey,
|
||||
|
5
frontend/app/user/components/BrainsUsage/BrainsUsage.tsx
Normal file
5
frontend/app/user/components/BrainsUsage/BrainsUsage.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { UserStatistics } from "../UserStatistics";
|
||||
|
||||
export const BrainsUsage = (): JSX.Element => {
|
||||
return <UserStatistics />;
|
||||
};
|
@ -2,36 +2,25 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CountrySelector } from "@/lib/components/ui/CountrySelector/CountrySelector";
|
||||
|
||||
import { useLanguageHook } from "./hooks/useLanguageHook";
|
||||
|
||||
const LanguageSelect = (): JSX.Element => {
|
||||
const { t } = useTranslation(["translation"]);
|
||||
const { allLanguages, currentLanguage, change } = useLanguageHook();
|
||||
const { currentLanguage, change } = useLanguageHook();
|
||||
|
||||
if (!currentLanguage) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset name="language" className="mb-2">
|
||||
<label
|
||||
className="block text-slate-700 dark:text-slate-300 mb-2"
|
||||
htmlFor="language"
|
||||
>
|
||||
{t("languageSelect")}
|
||||
</label>
|
||||
|
||||
<select
|
||||
data-testid="language-select"
|
||||
name="language"
|
||||
id="language"
|
||||
value={currentLanguage}
|
||||
onChange={(e) => change(e.target.value)}
|
||||
className="bg-slate-50 focus-visible:ring-0 border rounded dark:bg-black dark:text-white p-2 w-full md:w-1/2 lg:w-1/3"
|
||||
>
|
||||
{Object.keys(allLanguages).map((lang) => (
|
||||
<option data-testid={`option-${lang}`} value={lang} key={lang}>
|
||||
{allLanguages[lang].label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</fieldset>
|
||||
<CountrySelector
|
||||
iconName="flag"
|
||||
label={t("languageSelect")}
|
||||
currentValue={currentLanguage}
|
||||
setCurrentValue={change}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,58 +3,70 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useEventTracking } from "@/services/analytics/june/useEventTracking";
|
||||
|
||||
export const languages = {
|
||||
en: {
|
||||
label: "English",
|
||||
},
|
||||
es: {
|
||||
label: "Español",
|
||||
},
|
||||
fr: {
|
||||
label: "Français",
|
||||
},
|
||||
ptbr: {
|
||||
label: "Português",
|
||||
},
|
||||
ru: {
|
||||
label: "Русский",
|
||||
},
|
||||
zh_cn: {
|
||||
label: "简体中文",
|
||||
},
|
||||
export type Language = {
|
||||
label: string;
|
||||
flag: string;
|
||||
shortName: string;
|
||||
};
|
||||
|
||||
export type Language = {
|
||||
[key: string]: {
|
||||
label: string;
|
||||
};
|
||||
};
|
||||
export const languages: Language[] = [
|
||||
{
|
||||
label: "English",
|
||||
flag: "🇬🇧",
|
||||
shortName: "en",
|
||||
},
|
||||
{
|
||||
label: "Español",
|
||||
flag: "🇪🇸",
|
||||
shortName: "es",
|
||||
},
|
||||
{
|
||||
label: "Français",
|
||||
flag: "🇫🇷",
|
||||
shortName: "fr",
|
||||
},
|
||||
{
|
||||
label: "Português",
|
||||
flag: "🇵🇹",
|
||||
shortName: "pt",
|
||||
},
|
||||
{
|
||||
label: "Русский",
|
||||
flag: "🇷🇺",
|
||||
shortName: "ru",
|
||||
},
|
||||
{
|
||||
label: "简体中文",
|
||||
flag: "🇨🇳",
|
||||
shortName: "zh",
|
||||
},
|
||||
];
|
||||
|
||||
export const useLanguageHook = (): {
|
||||
change: (newLanguage: string) => void;
|
||||
allLanguages: Language;
|
||||
currentLanguage: string | undefined;
|
||||
change: (newLanguage: Language) => void;
|
||||
allLanguages: Language[];
|
||||
currentLanguage: Language | undefined;
|
||||
} => {
|
||||
const { i18n } = useTranslation();
|
||||
const [allLanguages, setAllLanguages] = useState<Language>({});
|
||||
const [currentLanguage, setCurrentLanguage] = useState<string | undefined>();
|
||||
const [allLanguages, setAllLanguages] = useState<Language[]>([]);
|
||||
const [currentLanguage, setCurrentLanguage] = useState<Language>();
|
||||
const { track } = useEventTracking();
|
||||
|
||||
useEffect(() => {
|
||||
setAllLanguages(languages);
|
||||
const savedLanguage = localStorage.getItem("selectedLanguage") ?? "English";
|
||||
|
||||
// get language from localStorage
|
||||
const savedLanguage = localStorage.getItem("selectedLanguage") ?? "en";
|
||||
|
||||
setCurrentLanguage(savedLanguage);
|
||||
setCurrentLanguage(
|
||||
languages.find((language) => language.label === savedLanguage)
|
||||
);
|
||||
void i18n.changeLanguage(savedLanguage);
|
||||
}, [i18n]);
|
||||
|
||||
const change = (newLanguage: string) => {
|
||||
const change = (newLanguage: Language) => {
|
||||
void track("CHANGE_LANGUAGE");
|
||||
setCurrentLanguage(newLanguage);
|
||||
localStorage.setItem("selectedLanguage", newLanguage);
|
||||
void i18n.changeLanguage(newLanguage);
|
||||
localStorage.setItem("selectedLanguage", newLanguage.label);
|
||||
void i18n.changeLanguage(newLanguage.shortName);
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import Button from "@/lib/components/ui/Button";
|
||||
import { Modal } from "@/lib/components/ui/Modal";
|
||||
import TextButton from "@/lib/components/ui/TextButton/TextButton";
|
||||
|
||||
import { useLogoutModal } from "./hooks/useLogoutModal";
|
||||
|
||||
@ -17,9 +18,13 @@ export const LogoutModal = (): JSX.Element => {
|
||||
return (
|
||||
<Modal
|
||||
Trigger={
|
||||
<Button className="px-3 py-2" variant="secondary">
|
||||
{t("logoutButton")}
|
||||
</Button>
|
||||
<div onClick={() => void 0}>
|
||||
<TextButton
|
||||
iconName="logout"
|
||||
color="dangerous"
|
||||
label={t("logoutButton")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
isOpen={isLogoutModalOpened}
|
||||
setOpen={setIsLogoutModalOpened}
|
0
frontend/app/user/components/Plan/Plan.module.scss
Normal file
0
frontend/app/user/components/Plan/Plan.module.scss
Normal file
9
frontend/app/user/components/Plan/Plan.tsx
Normal file
9
frontend/app/user/components/Plan/Plan.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { StripePricingOrManageButton } from "../StripePricingOrManageButton";
|
||||
|
||||
export const Plan = (): JSX.Element => {
|
||||
return (
|
||||
<div>
|
||||
<StripePricingOrManageButton />
|
||||
</div>
|
||||
);
|
||||
};
|
12
frontend/app/user/components/Settings/Settings.module.scss
Normal file
12
frontend/app/user/components/Settings/Settings.module.scss
Normal file
@ -0,0 +1,12 @@
|
||||
@use "@/styles/Radius.module.scss";
|
||||
@use "@/styles/Spacings.module.scss";
|
||||
|
||||
.settings_wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: Spacings.$spacing07;
|
||||
width: auto;
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.25);
|
||||
border-radius: Radius.$big;
|
||||
padding: Spacings.$spacing05;
|
||||
}
|
24
frontend/app/user/components/Settings/Settings.tsx
Normal file
24
frontend/app/user/components/Settings/Settings.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { InfoDisplayer } from "@/lib/components/ui/InfoDisplayer/InfoDisplayer";
|
||||
|
||||
import styles from "./Settings.module.scss";
|
||||
|
||||
import { ApiKeyConfig } from "../ApiKeyConfig";
|
||||
import LanguageSelect from "../LanguageSelect/LanguageSelect";
|
||||
import { LogoutModal } from "../LogoutModal/LogoutModal";
|
||||
|
||||
type InfoDisplayerProps = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const Settings = ({ email }: InfoDisplayerProps): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.settings_wrapper}>
|
||||
<InfoDisplayer label="Email" iconName="email">
|
||||
<span>{email}</span>
|
||||
</InfoDisplayer>
|
||||
<LanguageSelect />
|
||||
<ApiKeyConfig />
|
||||
<LogoutModal />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,45 @@
|
||||
@use "@/styles/Colors.module.scss";
|
||||
@use "@/styles/Radius.module.scss";
|
||||
@use "@/styles/Spacings.module.scss";
|
||||
@use "@/styles/ScreenSizes.module.scss";
|
||||
@use "@/styles/Typography.module.scss";
|
||||
|
||||
.menu_card_container {
|
||||
padding: Spacings.$spacing05;
|
||||
border-radius: Radius.$big;
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: Spacings.$spacing03;
|
||||
width: 20%;
|
||||
|
||||
@media (max-width: ScreenSizes.$small) {
|
||||
width: auto;
|
||||
|
||||
.title,
|
||||
.subtitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.selected {
|
||||
background-color: Colors.$primary-lightest;
|
||||
}
|
||||
|
||||
.first_line_wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
@include Typography.H2;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: Typography.$small;
|
||||
color: Colors.$normal-grey;
|
||||
}
|
||||
}
|
39
frontend/app/user/components/UserMenuCard/UserMenuCard.tsx
Normal file
39
frontend/app/user/components/UserMenuCard/UserMenuCard.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Icon } from "@/lib/components/ui/Icon/Icon";
|
||||
|
||||
import styles from "./UserMenuCard.module.scss";
|
||||
|
||||
import { UserMenuCardProps } from "../types/types";
|
||||
|
||||
export const UserMenuCard = ({
|
||||
title,
|
||||
subtitle,
|
||||
iconName,
|
||||
selected,
|
||||
onClick,
|
||||
}: UserMenuCardProps): JSX.Element => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${styles.menu_card_container}
|
||||
${selected ? styles.selected : ""}
|
||||
`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={styles.first_line_wrapper}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<Icon
|
||||
name={iconName}
|
||||
size="normal"
|
||||
color={isHovered ? "primary" : "black"}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.subtitle}>{subtitle}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
7
frontend/app/user/components/types/types.ts
Normal file
7
frontend/app/user/components/types/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type UserMenuCardProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
iconName: string;
|
||||
selected: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
13
frontend/app/user/page.module.scss
Normal file
13
frontend/app/user/page.module.scss
Normal file
@ -0,0 +1,13 @@
|
||||
@use "@/styles/Spacings.module.scss";
|
||||
|
||||
.user_page_container {
|
||||
padding: Spacings.$spacing09;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: Spacings.$spacing09;
|
||||
|
||||
.left_menu_wrapper {
|
||||
display: flex;
|
||||
gap: Spacings.$spacing05;
|
||||
}
|
||||
}
|
@ -1,86 +1,76 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import Button from "@/lib/components/ui/Button";
|
||||
import Card, { CardBody, CardHeader } from "@/lib/components/ui/Card";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useSupabase } from "@/lib/context/SupabaseProvider";
|
||||
import { useUserData } from "@/lib/hooks/useUserData";
|
||||
import { redirectToLogin } from "@/lib/router/redirectToLogin";
|
||||
|
||||
import { StripePricingOrManageButton, UserStatistics } from "./components";
|
||||
import { ApiKeyConfig } from "./components/ApiKeyConfig";
|
||||
import LanguageSelect from "./components/LanguageSelect/LanguageSelect";
|
||||
import { LogoutModal } from "./components/LogoutCard/LogoutModal";
|
||||
import { BrainsUsage } from "./components/BrainsUsage/BrainsUsage";
|
||||
import { Plan } from "./components/Plan/Plan";
|
||||
import { Settings } from "./components/Settings/Settings";
|
||||
import { UserMenuCard } from "./components/UserMenuCard/UserMenuCard";
|
||||
import { UserMenuCardProps } from "./components/types/types";
|
||||
import styles from "./page.module.scss";
|
||||
|
||||
const UserPage = (): JSX.Element => {
|
||||
const { session } = useSupabase();
|
||||
const { userData } = useUserData();
|
||||
|
||||
if (!session) {
|
||||
const [userMenuCards, setUserMenuCards] = useState<UserMenuCardProps[]>([
|
||||
{
|
||||
title: "Settings",
|
||||
subtitle: "Change your settings",
|
||||
iconName: "settings",
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
title: "Brain Usage",
|
||||
subtitle: "View your brain usage",
|
||||
iconName: "graph",
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
title: "Plan",
|
||||
subtitle: "Manage your plan",
|
||||
iconName: "unlock",
|
||||
selected: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
setUserMenuCards(
|
||||
userMenuCards.map((card, i) => ({
|
||||
...card,
|
||||
selected: i === index,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
if (!session || !userData) {
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
const { user } = session;
|
||||
const { t } = useTranslation(["translation", "user", "config", "chat"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="container lg:w-2/3 mx-auto py-10 px-5">
|
||||
<Link href="/search">
|
||||
<Button className="mb-5" variant="primary">
|
||||
{t("chat:back_to_search")}
|
||||
</Button>
|
||||
</Link>
|
||||
<Card className="mb-5 shadow-sm hover:shadow-none">
|
||||
<CardHeader>
|
||||
<h2 className="font-bold text-xl">
|
||||
{t("accountSection", { ns: "config" })}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="flex flex-col items-stretch max-w-max gap-2">
|
||||
<div className="flex gap-5 items-center">
|
||||
<p>
|
||||
<strong>{t("email")}:</strong> <span>{user.email}</span>
|
||||
</p>
|
||||
|
||||
<LogoutModal />
|
||||
</div>
|
||||
<StripePricingOrManageButton />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="mb-5 shadow-sm hover:shadow-none">
|
||||
<CardHeader>
|
||||
<h2 className="font-bold text-xl">
|
||||
{t("settings", { ns: "config" })}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<LanguageSelect />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="mb-5 shadow-sm hover:shadow-none">
|
||||
<CardHeader>
|
||||
<h2 className="font-bold text-xl">
|
||||
{t("brainUsage", { ns: "user" })}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<UserStatistics />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="mb-5 shadow-sm hover:shadow-none">
|
||||
<CardHeader>
|
||||
<h2 className="font-bold text-xl">
|
||||
{t("apiKey", { ns: "config" })}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="p-3 flex flex-col">
|
||||
<ApiKeyConfig />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<main className={styles.user_page_container}>
|
||||
<div className={styles.left_menu_wrapper}>
|
||||
{userMenuCards.map((card, index) => (
|
||||
<UserMenuCard
|
||||
key={index}
|
||||
title={card.title}
|
||||
subtitle={card.subtitle}
|
||||
iconName={card.iconName}
|
||||
selected={card.selected}
|
||||
onClick={() => handleCardClick(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.content_wrapper}>
|
||||
{userMenuCards[0].selected && <Settings email={userData.email} />}
|
||||
{userMenuCards[1].selected && <BrainsUsage />}
|
||||
{userMenuCards[2].selected && <Plan />}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
@ -37,6 +37,7 @@ export const Menu = (): JSX.Element => {
|
||||
"/library",
|
||||
"/brains-management",
|
||||
"/search",
|
||||
"/user",
|
||||
];
|
||||
|
||||
const isMenuDisplayed = displayedOnPages.some((page) =>
|
||||
|
@ -10,8 +10,8 @@ export const DiscussionButton = (): JSX.Element => {
|
||||
const { setIsVisible } = useSearchModalContext();
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
setIsVisible(true);
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
|
43
frontend/lib/components/ui/CopyButton.tsx
Normal file
43
frontend/lib/components/ui/CopyButton.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Icon from "@/lib/components/ui/Icon/Icon";
|
||||
|
||||
type CopyButtonProps = {
|
||||
handleCopy: () => void;
|
||||
};
|
||||
|
||||
export const CopyButton = ({ handleCopy }: CopyButtonProps): JSX.Element => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
handleCopy();
|
||||
setIsCopied(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [isCopied]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 transition"
|
||||
onClick={handleClick}
|
||||
title={isCopied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
<Icon
|
||||
name={isCopied ? "checkCircle" : "copy"}
|
||||
color={isCopied ? "primary" : "black"}
|
||||
size="small"
|
||||
handleHover={true}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
@use "@/styles/Colors.module.scss";
|
||||
@use "@/styles/Radius.module.scss";
|
||||
|
||||
.selection {
|
||||
border-radius: Radius.$normal;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: Colors.$lightest-black;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
border-color: Colors.$primary;
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
import {
|
||||
Language,
|
||||
useLanguageHook,
|
||||
} from "@/app/user/components/LanguageSelect/hooks/useLanguageHook";
|
||||
|
||||
import styles from "./CountrySelector.module.scss";
|
||||
|
||||
import { FieldHeader } from "../FieldHeader/FieldHeader";
|
||||
|
||||
type CountrySelectorProps = {
|
||||
iconName: string;
|
||||
label: string;
|
||||
currentValue: { label: string; flag: string };
|
||||
setCurrentValue: (newCountry: Language) => void;
|
||||
};
|
||||
|
||||
export const CountrySelector = ({
|
||||
iconName,
|
||||
label,
|
||||
currentValue,
|
||||
setCurrentValue,
|
||||
}: CountrySelectorProps): JSX.Element => {
|
||||
const { allLanguages } = useLanguageHook();
|
||||
|
||||
return (
|
||||
<div className={styles.country_selector_container}>
|
||||
<FieldHeader iconName={iconName} label={label} />
|
||||
<select
|
||||
className={styles.selection}
|
||||
data-testid="language-select"
|
||||
name="language"
|
||||
id="language"
|
||||
value={currentValue.label}
|
||||
onChange={(e) =>
|
||||
setCurrentValue(
|
||||
allLanguages.find(
|
||||
(language) => language.label === e.target.value
|
||||
) ?? allLanguages[2]
|
||||
)
|
||||
}
|
||||
>
|
||||
{allLanguages.map((lang) => (
|
||||
<option
|
||||
data-testid={`option-${lang}`}
|
||||
value={lang.label}
|
||||
key={lang.shortName}
|
||||
>
|
||||
{lang.flag} {lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
@use "@/styles/Spacings.module.scss";
|
||||
|
||||
.field_header_wrapper {
|
||||
display: flex;
|
||||
gap: Spacings.$spacing03;
|
||||
font-weight: bold;
|
||||
align-items: center;
|
||||
padding-bottom: Spacings.$spacing02;
|
||||
}
|
20
frontend/lib/components/ui/FieldHeader/FieldHeader.tsx
Normal file
20
frontend/lib/components/ui/FieldHeader/FieldHeader.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import styles from "./FieldHeader.module.scss";
|
||||
|
||||
import { Icon } from "../Icon/Icon";
|
||||
|
||||
type FieldHeaderProps = {
|
||||
iconName: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const FieldHeader = ({
|
||||
iconName,
|
||||
label,
|
||||
}: FieldHeaderProps): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.field_header_wrapper}>
|
||||
<Icon name={iconName} color="black" size="small" />
|
||||
<label>{label}</label>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -53,6 +53,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dangerous {
|
||||
color: Colors.$dangerous;
|
||||
|
||||
&.hovered {
|
||||
color: Colors.$dangerous-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: Colors.$black;
|
||||
pointer-events: none;
|
||||
|
@ -40,11 +40,11 @@ export const Icon = ({
|
||||
return (
|
||||
<IconComponent
|
||||
className={`
|
||||
${classname ?? ""}
|
||||
${styles[size] ?? ""}
|
||||
${styles[color] ?? ""}
|
||||
${disabled ? styles.disabled ?? "" : ""}
|
||||
${iconHovered ? styles.hovered ?? "" : ""}
|
||||
${classname}
|
||||
${styles[size]}
|
||||
${styles[color]}
|
||||
${disabled ? styles.disabled : ""}
|
||||
${iconHovered || hovered ? styles.hovered : ""}
|
||||
`}
|
||||
onMouseEnter={() => handleHover && setIconHovered(true)}
|
||||
onMouseLeave={() => handleHover && setIconHovered(false)}
|
||||
|
@ -0,0 +1,6 @@
|
||||
@use "@/styles/Spacings.module.scss";
|
||||
|
||||
.info_displayer_container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
22
frontend/lib/components/ui/InfoDisplayer/InfoDisplayer.tsx
Normal file
22
frontend/lib/components/ui/InfoDisplayer/InfoDisplayer.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import styles from "./InfoDisplayer.module.scss";
|
||||
|
||||
import { FieldHeader } from "../FieldHeader/FieldHeader";
|
||||
|
||||
type InfoDisplayerProps = {
|
||||
iconName: string;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const InfoDisplayer = ({
|
||||
iconName,
|
||||
label,
|
||||
children,
|
||||
}: InfoDisplayerProps): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.info_displayer_container}>
|
||||
<FieldHeader iconName={iconName} label={label} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -16,3 +16,11 @@
|
||||
color: Colors.$primary;
|
||||
}
|
||||
}
|
||||
|
||||
.dangerous {
|
||||
color: Colors.$dangerous;
|
||||
|
||||
&.hovered {
|
||||
color: Colors.$dangerous-dark;
|
||||
}
|
||||
}
|
||||
|
20
frontend/lib/components/ui/TextInput/TextInput.module.scss
Normal file
20
frontend/lib/components/ui/TextInput/TextInput.module.scss
Normal file
@ -0,0 +1,20 @@
|
||||
@use "@/styles/Colors.module.scss";
|
||||
@use "@/styles/Radius.module.scss";
|
||||
@use "@/styles/Spacings.module.scss";
|
||||
|
||||
.text_input_container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: Spacings.$spacing03;
|
||||
|
||||
.text_input {
|
||||
border: 1px solid Colors.$lighter-grey;
|
||||
border-radius: Radius.$big;
|
||||
padding: Spacings.$spacing03;
|
||||
caret-color: Colors.$accent;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
29
frontend/lib/components/ui/TextInput/TextInput.tsx
Normal file
29
frontend/lib/components/ui/TextInput/TextInput.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import styles from "./TextInput.module.scss";
|
||||
|
||||
import { FieldHeader } from "../FieldHeader/FieldHeader";
|
||||
|
||||
type TextInputProps = {
|
||||
iconName: string;
|
||||
label: string;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
};
|
||||
|
||||
export const TextInput = ({
|
||||
iconName,
|
||||
label,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
}: TextInputProps): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.text_input_container}>
|
||||
<FieldHeader iconName={iconName} label={label} />
|
||||
<input
|
||||
className={styles.text_input}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,14 +1,17 @@
|
||||
import { AiOutlineLoading3Quarters } from "react-icons/ai";
|
||||
import { BsArrowRightShort } from "react-icons/bs";
|
||||
import { CiFlag1 } from "react-icons/ci";
|
||||
import {
|
||||
FaCheck,
|
||||
FaCheckCircle,
|
||||
FaKey,
|
||||
FaRegStar,
|
||||
FaRegUserCircle,
|
||||
FaUnlock,
|
||||
} from "react-icons/fa";
|
||||
import { FaArrowUpFromBracket } from "react-icons/fa6";
|
||||
import { IoIosAdd, IoMdClose } from "react-icons/io";
|
||||
import { IoHomeOutline } from "react-icons/io5";
|
||||
import { IoIosAdd, IoMdClose, IoMdLogOut } from "react-icons/io";
|
||||
import { IoHomeOutline, IoSettingsSharp } from "react-icons/io5";
|
||||
import { IconType } from "react-icons/lib";
|
||||
import {
|
||||
LuBrain,
|
||||
@ -20,8 +23,15 @@ import {
|
||||
LuPlusCircle,
|
||||
LuSearch,
|
||||
} from "react-icons/lu";
|
||||
import { MdDelete, MdEdit, MdHistory, MdUploadFile } from "react-icons/md";
|
||||
import {
|
||||
MdAlternateEmail,
|
||||
MdDelete,
|
||||
MdEdit,
|
||||
MdHistory,
|
||||
MdUploadFile,
|
||||
} from "react-icons/md";
|
||||
import { RiHashtag } from "react-icons/ri";
|
||||
import { VscGraph } from "react-icons/vsc";
|
||||
|
||||
export const iconList: { [name: string]: IconType } = {
|
||||
add: LuPlusCircle,
|
||||
@ -36,15 +46,22 @@ export const iconList: { [name: string]: IconType } = {
|
||||
copy: LuCopy,
|
||||
delete: MdDelete,
|
||||
edit: MdEdit,
|
||||
email: MdAlternateEmail,
|
||||
file: LuFile,
|
||||
flag: CiFlag1,
|
||||
followUp: FaArrowUpFromBracket,
|
||||
graph: VscGraph,
|
||||
hastag: RiHashtag,
|
||||
history: MdHistory,
|
||||
home: IoHomeOutline,
|
||||
key: FaKey,
|
||||
loader: AiOutlineLoading3Quarters,
|
||||
logout: IoMdLogOut,
|
||||
redirection: BsArrowRightShort,
|
||||
search: LuSearch,
|
||||
settings: IoSettingsSharp,
|
||||
star: FaRegStar,
|
||||
unlock: FaUnlock,
|
||||
upload: MdUploadFile,
|
||||
user: FaRegUserCircle,
|
||||
};
|
||||
|
@ -4,4 +4,5 @@ export type Color =
|
||||
| "primary"
|
||||
| "gold"
|
||||
| "accent"
|
||||
| "white";
|
||||
| "white"
|
||||
| "dangerous";
|
||||
|
@ -26,3 +26,7 @@ $dark-grey: #707070;
|
||||
|
||||
// GOLD
|
||||
$gold: #b8860b;
|
||||
|
||||
// ERROR
|
||||
$dangerous-dark: #e30c17;
|
||||
$dangerous: #9b373c;
|
||||
|
Loading…
Reference in New Issue
Block a user