-
Notifications
You must be signed in to change notification settings - Fork 0
[_]: feature/settings dialog #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bd64cac
4c7d721
6e56c87
8340098
df459d1
33cdfcf
1b80892
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { type ReactNode } from 'react'; | ||
| import { CaretLeftIcon, XIcon } from '@phosphor-icons/react'; | ||
|
|
||
| interface SectionProps { | ||
| className?: string; | ||
| children: ReactNode; | ||
| title: string; | ||
| onBackButtonClicked?: () => void; | ||
| onClose: () => void; | ||
| } | ||
|
|
||
| const Section = ({ className = '', children, title, onBackButtonClicked, onClose }: SectionProps) => { | ||
| return ( | ||
| <div className={`relative w-full rounded-tr-2xl ${className}`}> | ||
| <div className="absolute z-50 flex w-full items-center justify-between rounded-tr-2xl p-2.5 pl-6 before:absolute before:inset-0 before:-z-1 before:bg-surface/85 before:backdrop-blur-3xl before:transition-colors"> | ||
| <div className="flex items-center"> | ||
| {onBackButtonClicked && ( | ||
| <button onClick={onBackButtonClicked}> | ||
| <div className="mr-2.5 flex h-9 w-9 items-center justify-center rounded-lg text-gray-100 hover:bg-highlight/4 active:bg-highlight/8"> | ||
| <CaretLeftIcon size={22} /> | ||
| </div> | ||
| </button> | ||
| )} | ||
| <h2 className="text-base font-medium text-gray-100">{title}</h2> | ||
| </div> | ||
| <button | ||
| className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-highlight/4 active:bg-highlight/8" | ||
| onClick={() => onClose()} | ||
| > | ||
| <XIcon size={22} /> | ||
| </button> | ||
| </div> | ||
| <div className="flex max-h-640 flex-col space-y-8 overflow-y-auto p-6 pt-16">{children}</div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default Section; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| export interface SectionItemProps { | ||
| text: string; | ||
| isActive?: boolean; | ||
| isSection?: boolean; | ||
| isDisabled?: boolean; | ||
| onClick?: () => void; | ||
| } | ||
xabg2 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const SectionItem = ({ text, isActive, isDisabled, isSection, onClick }: SectionItemProps) => { | ||
| const isClickable = !!onClick && !isDisabled; | ||
| const clickableContainerClass = isClickable ? 'hover:bg-gray-5' : ''; | ||
| const activeContainerClass = isActive ? 'bg-primary' : clickableContainerClass; | ||
| const containerClass = isDisabled ? '' : activeContainerClass; | ||
| const clickableClass = isClickable ? 'hover:cursor-pointer' : ''; | ||
| const activeTextClass = isActive ? 'text-white' : 'text-gray-80'; | ||
| const disabledTextClass = isDisabled ? 'text-gray-40' : activeTextClass; | ||
| const sectionTextClass = isSection ? 'font-semibold' : ''; | ||
|
|
||
| const Element = isClickable ? 'button' : 'div'; | ||
|
|
||
| return ( | ||
| <Element | ||
| className={`flex h-10 w-full items-center justify-between rounded-lg px-3 py-2 | ||
| ${clickableClass} ${containerClass}`} | ||
| onClick={isDisabled ? undefined : onClick} | ||
| {...(isClickable ? { type: 'button' as const } : {})} | ||
| > | ||
| <div className="flex items-center"> | ||
| <span className={`text-base font-normal ${disabledTextClass} ${sectionTextClass}`}>{text}</span> | ||
| </div> | ||
| </Element> | ||
| ); | ||
| }; | ||
|
|
||
| export default SectionItem; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import type { PreferencesSection, PreferencesSectionItem } from '@/types/preferences'; | ||
| import SectionItem from './SectionItem'; | ||
|
|
||
| interface SectionListProps { | ||
| sectionItems: (PreferencesSectionItem & { text: string })[]; | ||
| activeSection: PreferencesSection; | ||
| onSelectSection: (section: PreferencesSection) => void; | ||
| } | ||
|
|
||
| const SectionList = ({ sectionItems, activeSection, onSelectSection }: SectionListProps) => { | ||
| return ( | ||
| <div className="overflow-x-auto"> | ||
| {sectionItems.map((item) => ( | ||
| <SectionItem | ||
| key={item.id} | ||
| text={item.text} | ||
| isActive={item.id === activeSection} | ||
| onClick={() => onSelectSection(item.id)} | ||
| /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default SectionList; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { useTranslationContext } from '@/i18n'; | ||
| import { PREFERENCES_SECTIONS, type PreferencesSection } from '@/types/preferences'; | ||
| import SectionList from './SectionList'; | ||
|
|
||
| interface SectionListWrapperProps { | ||
| activeSection: PreferencesSection; | ||
| onSelectSection: (section: PreferencesSection) => void; | ||
| } | ||
|
|
||
| const SectionListWrapper = ({ activeSection, onSelectSection }: SectionListWrapperProps) => { | ||
| const { translate } = useTranslationContext(); | ||
|
|
||
| const sectionItems = PREFERENCES_SECTIONS.map((item) => ({ | ||
| ...item, | ||
| text: translate(`modals.preferences.sections.${item.id}.title`), | ||
| })); | ||
|
|
||
| return <SectionList sectionItems={sectionItems} activeSection={activeSection} onSelectSection={onSelectSection} />; | ||
| }; | ||
|
|
||
| export default SectionListWrapper; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { usePreferencesNavigation } from '@/hooks/preferences/usePreferencesNavigation'; | ||
| import { useTranslationContext } from '@/i18n'; | ||
| import type { PreferencesSection } from '@/types/preferences'; | ||
| import { Modal } from '@internxt/ui'; | ||
| import type { FC } from 'react'; | ||
| import { useEffect } from 'react'; | ||
| import SectionListWrapper from './components/SectionListWrapper'; | ||
| import GeneralSection from './sections/general'; | ||
|
|
||
| const SECTION_COMPONENTS: Record<PreferencesSection, FC<{ onClose: () => void }>> = { | ||
| general: GeneralSection, | ||
| }; | ||
|
|
||
| export const PreferencesDialog = () => { | ||
| const { translate } = useTranslationContext(); | ||
| const { isOpen, activeSection, openSection, close } = usePreferencesNavigation(); | ||
|
|
||
| const title = translate(`modals.preferences.sections.${activeSection}.title`); | ||
|
|
||
| useEffect(() => { | ||
| if (!isOpen) return; | ||
| const previousTitle = document.title; | ||
| document.title = `${title} | Internxt Mail`; | ||
| return () => { | ||
| document.title = previousTitle; | ||
| }; | ||
| }, [isOpen, title]); | ||
xabg2 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const ActiveSectionComponent = SECTION_COMPONENTS[activeSection]; | ||
|
|
||
| return ( | ||
| <Modal | ||
| maxWidth="max-w-4xl" | ||
| className="m-0 flex max-h-640 h-screen overflow-hidden shadow-sm" | ||
| isOpen={isOpen} | ||
| onClose={close} | ||
| > | ||
| <section className="w-56 shrink-0 border-r border-gray-10 px-2.5"> | ||
| <h1 className="py-3 pl-4 text-xl font-semibold">{translate('modals.preferences.title')}</h1> | ||
| <SectionListWrapper activeSection={activeSection} onSelectSection={openSection} /> | ||
| </section> | ||
|
|
||
| <ActiveSectionComponent onClose={close} /> | ||
| </Modal> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { useTranslationContext } from '@/i18n'; | ||
| import Section from '../../components/Section'; | ||
|
|
||
| const GeneralSection = ({ onClose }: { onClose: () => void }) => { | ||
| const { translate } = useTranslationContext(); | ||
| return ( | ||
| <Section className="max-w-2xl" title={translate('modals.preferences.sections.general.title')} onClose={onClose}> | ||
| {/* TODO: Add appearance, language and support components */} | ||
|
Check warning on line 8 in src/components/preferences/sections/general/index.tsx
|
||
xabg2 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| <p>{translate('modals.preferences.sections.general.title')}</p> | ||
| </Section> | ||
| ); | ||
| }; | ||
|
|
||
| export default GeneralSection; | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,85 @@ | ||||||
| import { useTranslationContext } from '@/i18n'; | ||||||
| import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; | ||||||
| import { Avatar, Popover } from '@internxt/ui'; | ||||||
| import { GearIcon, SignOutIcon } from '@phosphor-icons/react'; | ||||||
| import type { ReactNode } from 'react'; | ||||||
|
|
||||||
| interface AccountPopoverProps { | ||||||
| className?: string; | ||||||
| user: UserSettings; | ||||||
| percentageUsed: number; | ||||||
| onLogout: () => void; | ||||||
| openPreferences: () => void; | ||||||
| } | ||||||
|
|
||||||
| export default function AccountPopover({ | ||||||
| className = '', | ||||||
| user, | ||||||
| percentageUsed, | ||||||
| onLogout, | ||||||
| openPreferences, | ||||||
| }: Readonly<AccountPopoverProps>) { | ||||||
| const { translate } = useTranslationContext(); | ||||||
| const name = user?.name ?? ''; | ||||||
| const lastName = user?.lastname ?? ''; | ||||||
| const fullName = name + ' ' + lastName; | ||||||
|
|
||||||
| const avatarWrapper = <Avatar diameter={36} style={{ minWidth: 36 }} fullName={fullName} src={user.avatar} />; | ||||||
|
|
||||||
| const separator = <div className="border-translate mx-3 my-0.5 border-gray-10" />; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typo: This appears to be a typo. If you intend to render a horizontal divider with a top border, use 🐛 Proposed fix- const separator = <div className="border-translate mx-3 my-0.5 border-gray-10" />;
+ const separator = <div className="border-t mx-3 my-0.5 border-gray-10" />;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
|
|
||||||
| const panel = ( | ||||||
| <div className="w-52"> | ||||||
| <div className="flex items-center p-3"> | ||||||
| {avatarWrapper} | ||||||
| <div className="ml-2 min-w-0"> | ||||||
| <p className="truncate font-medium text-gray-80" title={fullName} style={{ lineHeight: 1 }}> | ||||||
| {fullName} | ||||||
| </p> | ||||||
| <p className="truncate text-sm text-gray-50" title={user.email}> | ||||||
| {user.email} | ||||||
| </p> | ||||||
| </div> | ||||||
| </div> | ||||||
|
|
||||||
| <div className="flex items-center justify-between px-3 pb-1"> | ||||||
| <p className="text-sm text-gray-50">{translate('accountPopover.spaceUsed', { space: percentageUsed })}</p> | ||||||
| </div> | ||||||
xabg2 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| {separator} | ||||||
| <button | ||||||
| className="flex w-full cursor-pointer items-center px-3 py-2 text-gray-80 no-underline hover:bg-gray-1 hover:text-gray-80 dark:hover:bg-gray-10" | ||||||
| onClick={openPreferences} | ||||||
| > | ||||||
| <GearIcon size={20} /> | ||||||
| <p className="ml-3">{translate('accountPopover.settings')}</p> | ||||||
| </button> | ||||||
| <Item onClick={onLogout}> | ||||||
| <SignOutIcon size={20} /> | ||||||
| <p className="ml-3 truncate" data-test="logout"> | ||||||
| {translate('accountPopover.logout')} | ||||||
| </p> | ||||||
| </Item> | ||||||
| </div> | ||||||
| ); | ||||||
|
|
||||||
| return ( | ||||||
| <Popover className={className} childrenButton={avatarWrapper} panel={() => panel} data-test="app-header-dropdown" /> | ||||||
|
Check warning on line 66 in src/features/mail/components/settings/components/account-popover/index.tsx
|
||||||
| ); | ||||||
xabg2 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| } | ||||||
|
|
||||||
| interface ItemProps { | ||||||
| children: ReactNode; | ||||||
| onClick: () => void; | ||||||
| } | ||||||
|
|
||||||
| function Item({ children, onClick }: Readonly<ItemProps>) { | ||||||
| return ( | ||||||
| <button | ||||||
| className="flex cursor-pointer items-center px-3 py-2 text-gray-80 hover:bg-gray-1 dark:hover:bg-gray-10" | ||||||
| style={{ lineHeight: 1.25 }} | ||||||
| onClick={onClick} | ||||||
| > | ||||||
| {children} | ||||||
| </button> | ||||||
| ); | ||||||
xabg2 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { usePreferencesNavigation } from '@/hooks/preferences/usePreferencesNavigation'; | ||
| import { useAppDispatch, useAppSelector } from '@/store/hooks'; | ||
| import { useGetStorageLimitQuery, useGetStorageUsageQuery } from '@/store/queries/storage/storage.query'; | ||
| import { Button } from '@internxt/ui'; | ||
| import { GearIcon } from '@phosphor-icons/react'; | ||
| import AccountPopover from './components/account-popover'; | ||
| import { logoutThunk } from '@/store/slices/user/thunks'; | ||
|
|
||
| const Settings = () => { | ||
| const dispatch = useAppDispatch(); | ||
| const user = useAppSelector((state) => state.user.user); | ||
| const { data: usage } = useGetStorageUsageQuery(); | ||
| const { data: limit } = useGetStorageLimitQuery(); | ||
| const { openSection } = usePreferencesNavigation(); | ||
|
|
||
| const percentageUsed = usage != null && limit != null && limit > 0 ? Math.round((usage / limit) * 100) : 0; | ||
|
|
||
| if (!user) return null; | ||
|
|
||
| const openPreferences = () => { | ||
| openSection('general'); | ||
| }; | ||
|
|
||
| const onLogout = () => { | ||
| dispatch(logoutThunk()); | ||
| }; | ||
|
Comment on lines
+24
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. **Consider handling logout errors gracefully.**Looking at the The current implementation silently fails if the network request errors. Consider either:
🔧 Option 1: Clear local state even if API fails (in logoutThunk)// In src/store/slices/user/thunks/logOutThunk/index.ts
export const logoutThunk = createAsyncThunk<void, void, { state: RootState }>(
'user/logout',
async (_: void, { dispatch }) => {
try {
await AuthService.instance.logOut();
} catch {
// Log error but continue with local logout
console.error('Server logout failed, clearing local session');
}
dispatch(userActions.resetState());
NavigationService.instance.navigate({ id: AppView.Welcome });
},
);🔧 Option 2: Handle rejection in the component const onLogout = () => {
- dispatch(logoutThunk());
+ dispatch(logoutThunk())
+ .unwrap()
+ .catch(() => {
+ // Show toast or error message
+ });
};🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <div className="flex flex-row w-full gap-1 items-center justify-end p-4"> | ||
| <Button variant="ghost" onClick={openPreferences} className="px-2!"> | ||
| <GearIcon size={24} /> | ||
| </Button> | ||
| <AccountPopover | ||
| user={user} | ||
| percentageUsed={percentageUsed} | ||
| openPreferences={openPreferences} | ||
| onLogout={onLogout} | ||
| /> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default Settings; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { useSearchParams } from 'react-router-dom'; | ||
| import { useCallback, useMemo } from 'react'; | ||
| import { PREFERENCES_SECTIONS, type PreferencesSection } from '@/types/preferences'; | ||
|
|
||
| const DEFAULT_SECTION: PreferencesSection = 'general'; | ||
|
|
||
| const isValidSection = (value: string): value is PreferencesSection => { | ||
| return PREFERENCES_SECTIONS.some((item) => item.id === value); | ||
| }; | ||
|
|
||
| export const usePreferencesNavigation = () => { | ||
| const [searchParams, setSearchParams] = useSearchParams(); | ||
|
|
||
| const isOpen = searchParams.get('preferences') === 'open'; | ||
|
|
||
| const activeSection: PreferencesSection = useMemo(() => { | ||
| const section = searchParams.get('section'); | ||
|
|
||
| if (section && isValidSection(section)) { | ||
| return section; | ||
| } | ||
|
|
||
| return DEFAULT_SECTION; | ||
| }, [searchParams]); | ||
|
|
||
| const openSection = useCallback( | ||
| (section: PreferencesSection) => { | ||
| setSearchParams({ preferences: 'open', section }, { replace: true }); | ||
| }, | ||
| [setSearchParams], | ||
| ); | ||
|
|
||
| const close = useCallback(() => { | ||
| setSearchParams({}, { replace: true }); | ||
| }, [setSearchParams]); | ||
|
|
||
| return { isOpen, activeSection, openSection, close }; | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.