Added settings layout.

This commit is contained in:
Mike Cao 2024-02-04 22:35:14 -08:00
parent d818bf5aaf
commit 6802093d69
22 changed files with 102 additions and 76 deletions

View file

@ -0,0 +1,25 @@
'use client';
import { ReactNode } from 'react';
import { useLogin, useMessages } from 'components/hooks';
import SettingsLayout from 'components/layout/SettingsLayout';
export default function Settings({ children }: { children: ReactNode }) {
const { user } = useLogin();
const { formatMessage, labels } = useMessages();
const items = [
{
key: 'websites',
label: formatMessage(labels.websites),
url: '/settings/websites',
},
{ key: 'teams', label: formatMessage(labels.teams), url: '/settings/teams' },
user.isAdmin && {
key: 'users',
label: formatMessage(labels.users),
url: '/settings/users',
},
].filter(n => n);
return <SettingsLayout items={items}>{children}</SettingsLayout>;
}

View file

@ -1,31 +0,0 @@
.layout {
display: grid;
grid-template-columns: max-content 1fr;
gap: 20px;
}
.menu {
width: 240px;
padding-top: 34px;
padding-right: 20px;
}
.content {
display: flex;
flex-direction: column;
min-height: 50vh;
}
@media only screen and (max-width: 992px) {
.layout {
grid-template-columns: 1fr;
}
.menu {
display: none;
}
.content {
margin-top: 20px;
}
}

View file

@ -1,56 +1,5 @@
'use client';
import { usePathname } from 'next/navigation';
import { useLogin, useMessages, useTeamUrl } from 'components/hooks';
import SideNav from 'components/layout/SideNav';
import styles from './layout.module.css';
import Settings from './Settings';
export default function SettingsLayout({ children }) {
const { user } = useLogin();
const pathname = usePathname();
const { formatMessage, labels } = useMessages();
const cloudMode = !!process.env.cloudMode;
const { teamId, renderTeamUrl } = useTeamUrl();
const items = [
teamId && {
key: 'team',
label: formatMessage(labels.team),
url: renderTeamUrl('/settings/team'),
},
teamId && {
key: 'members',
label: formatMessage(labels.members),
url: renderTeamUrl('/settings/members'),
},
{
key: 'websites',
label: formatMessage(labels.websites),
url: renderTeamUrl('/settings/websites'),
},
!teamId && { key: 'teams', label: formatMessage(labels.teams), url: '/settings/teams' },
!teamId &&
user.isAdmin && {
key: 'users',
label: formatMessage(labels.users),
url: renderTeamUrl('/settings/users'),
},
!teamId && { key: 'profile', label: formatMessage(labels.profile), url: '/settings/profile' },
].filter(n => n);
const getKey = () => items.find(({ url }) => pathname === url)?.key;
if (cloudMode && pathname !== '/settings/profile') {
return null;
}
return (
<div className={styles.layout}>
{!cloudMode && (
<div className={styles.menu}>
<SideNav items={items} shallow={true} selectedKey={getKey()} />
</div>
)}
<div className={styles.content}>{children}</div>
</div>
);
export default function ({ children }) {
return <Settings>{children}</Settings>;
}

View file

@ -1,29 +0,0 @@
'use client';
import DateFilter from 'components/input/DateFilter';
import { Button, Flexbox } from 'react-basics';
import { useDateRange, useMessages } from 'components/hooks';
import { DEFAULT_DATE_RANGE } from 'lib/constants';
import { DateRange } from 'lib/types';
export function DateRangeSetting() {
const { formatMessage, labels } = useMessages();
const [dateRange, setDateRange] = useDateRange();
const { value } = dateRange;
const handleChange = (value: string | DateRange) => setDateRange(value);
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
return (
<Flexbox gap={10}>
<DateFilter
value={value}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={handleChange}
/>
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
</Flexbox>
);
}
export default DateRangeSetting;

View file

@ -1,4 +0,0 @@
div.menu {
max-height: 300px;
width: 300px;
}

View file

@ -1,44 +0,0 @@
'use client';
import { useState } from 'react';
import { Button, Dropdown, Item, Flexbox } from 'react-basics';
import { useLocale, useMessages } from 'components/hooks';
import { DEFAULT_LOCALE } from 'lib/constants';
import { languages } from 'lib/lang';
import styles from './LanguageSetting.module.css';
export function LanguageSetting() {
const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages();
const { locale, saveLocale } = useLocale();
const options = search
? Object.keys(languages).filter(n => {
return (
n.toLowerCase().includes(search.toLowerCase()) ||
languages[n].label.toLowerCase().includes(search.toLowerCase())
);
})
: Object.keys(languages);
const handleReset = () => saveLocale(DEFAULT_LOCALE);
const renderValue = (value: string | number) => languages[value].label;
return (
<Flexbox gap={10}>
<Dropdown
items={options}
value={locale}
renderValue={renderValue}
onChange={val => saveLocale(val as string)}
allowSearch={true}
onSearch={setSearch}
menuProps={{ className: styles.menu }}
>
{item => <Item key={item}>{languages[item].label}</Item>}
</Dropdown>
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
</Flexbox>
);
}
export default LanguageSetting;

View file

@ -1,32 +0,0 @@
'use client';
import { Button, Icon, Text, useToasts, ModalTrigger, Modal } from 'react-basics';
import PasswordEditForm from 'app/(main)/settings/profile/PasswordEditForm';
import Icons from 'components/icons';
import { useMessages } from 'components/hooks';
export function PasswordChangeButton() {
const { formatMessage, labels, messages } = useMessages();
const { showToast } = useToasts();
const handleSave = () => {
showToast({ message: formatMessage(messages.saved), variant: 'success' });
};
return (
<>
<ModalTrigger>
<Button>
<Icon>
<Icons.Lock />
</Icon>
<Text>{formatMessage(labels.changePassword)}</Text>
</Button>
<Modal title={formatMessage(labels.changePassword)}>
{close => <PasswordEditForm onSave={handleSave} onClose={close} />}
</Modal>
</ModalTrigger>
</>
);
}
export default PasswordChangeButton;

View file

@ -1,70 +0,0 @@
'use client';
import { useRef } from 'react';
import { Form, FormRow, FormInput, FormButtons, PasswordField, Button } from 'react-basics';
import { useApi, useMessages } from 'components/hooks';
export function PasswordEditForm({ onSave, onClose }) {
const { formatMessage, labels, messages } = useMessages();
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post('/me/password', data),
});
const ref = useRef(null);
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave();
onClose();
},
});
};
const samePassword = (value: string) => {
if (value !== ref?.current?.getValues('newPassword')) {
return formatMessage(messages.noMatchPassword);
}
return true;
};
return (
<Form ref={ref} onSubmit={handleSubmit} error={error}>
<FormRow label={formatMessage(labels.currentPassword)}>
<FormInput name="currentPassword" rules={{ required: 'Required' }}>
<PasswordField autoComplete="current-password" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.newPassword)}>
<FormInput
name="newPassword"
rules={{
required: 'Required',
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
}}
>
<PasswordField autoComplete="new-password" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.confirmPassword)}>
<FormInput
name="confirmPassword"
rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
validate: samePassword,
}}
>
<PasswordField autoComplete="confirm-password" />
</FormInput>
</FormRow>
<FormButtons flex>
<Button type="submit" variant="primary" disabled={isPending}>
{formatMessage(labels.save)}
</Button>
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}
export default PasswordEditForm;

View file

@ -1,11 +0,0 @@
'use client';
import PageHeader from 'components/layout/PageHeader';
import { useMessages } from 'components/hooks';
export function ProfileHeader() {
const { formatMessage, labels } = useMessages();
return <PageHeader title={formatMessage(labels.profile)}></PageHeader>;
}
export default ProfileHeader;

View file

@ -1,61 +0,0 @@
'use client';
import { Form, FormRow } from 'react-basics';
import TimezoneSetting from 'app/(main)/settings/profile/TimezoneSetting';
import DateRangeSetting from 'app/(main)/settings/profile/DateRangeSetting';
import LanguageSetting from 'app/(main)/settings/profile/LanguageSetting';
import ThemeSetting from 'app/(main)/settings/profile/ThemeSetting';
import PasswordChangeButton from './PasswordChangeButton';
import { useLogin, useMessages } from 'components/hooks';
import { ROLES } from 'lib/constants';
export function ProfileSettings() {
const { user } = useLogin();
const { formatMessage, labels } = useMessages();
const cloudMode = Boolean(process.env.cloudMode);
if (!user) {
return null;
}
const { username, role } = user;
const renderRole = value => {
if (value === ROLES.user) {
return formatMessage(labels.user);
}
if (value === ROLES.admin) {
return formatMessage(labels.admin);
}
if (value === ROLES.viewOnly) {
return formatMessage(labels.viewOnly);
}
return formatMessage(labels.unknown);
};
return (
<Form>
<FormRow label={formatMessage(labels.username)}>{username}</FormRow>
<FormRow label={formatMessage(labels.role)}>{renderRole(role)}</FormRow>
{!cloudMode && (
<FormRow label={formatMessage(labels.password)}>
<PasswordChangeButton />
</FormRow>
)}
<FormRow label={formatMessage(labels.defaultDateRange)}>
<DateRangeSetting />
</FormRow>
<FormRow label={formatMessage(labels.language)}>
<LanguageSetting />
</FormRow>
<FormRow label={formatMessage(labels.timezone)}>
<TimezoneSetting />
</FormRow>
<FormRow label={formatMessage(labels.theme)}>
<ThemeSetting />
</FormRow>
</Form>
);
}
export default ProfileSettings;

View file

@ -1,8 +0,0 @@
.buttons {
display: flex;
gap: 10px;
}
.active {
border: 2px solid var(--primary400);
}

View file

@ -1,34 +0,0 @@
'use client';
import classNames from 'classnames';
import { Button, Icon } from 'react-basics';
import { useTheme } from 'components/hooks';
import Sun from 'assets/sun.svg';
import Moon from 'assets/moon.svg';
import styles from './ThemeSetting.module.css';
export function ThemeSetting() {
const { theme, saveTheme } = useTheme();
return (
<div className={styles.buttons}>
<Button
className={classNames({ [styles.active]: theme === 'light' })}
onClick={() => saveTheme('light')}
>
<Icon>
<Sun />
</Icon>
</Button>
<Button
className={classNames({ [styles.active]: theme === 'dark' })}
onClick={() => saveTheme('dark')}
>
<Icon>
<Moon />
</Icon>
</Button>
</div>
);
}
export default ThemeSetting;

View file

@ -1,4 +0,0 @@
div.menu {
max-height: 300px;
width: 300px;
}

View file

@ -1,36 +0,0 @@
'use client';
import { useState } from 'react';
import { Dropdown, Item, Button, Flexbox } from 'react-basics';
import { listTimeZones } from 'timezone-support';
import { useTimezone, useMessages } from 'components/hooks';
import { getTimezone } from 'lib/date';
import styles from './TimezoneSetting.module.css';
export function TimezoneSetting() {
const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages();
const [timezone, saveTimezone] = useTimezone();
const options = search
? listTimeZones().filter(n => n.toLowerCase().includes(search.toLowerCase()))
: listTimeZones();
const handleReset = () => saveTimezone(getTimezone());
return (
<Flexbox gap={10}>
<Dropdown
items={options}
value={timezone}
onChange={saveTimezone}
menuProps={{ className: styles.menu }}
allowSearch={true}
onSearch={setSearch}
>
{item => <Item key={item}>{item}</Item>}
</Dropdown>
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
</Flexbox>
);
}
export default TimezoneSetting;

View file

@ -1,16 +0,0 @@
import ProfileHeader from './ProfileHeader';
import ProfileSettings from './ProfileSettings';
import { Metadata } from 'next';
export default function () {
return (
<>
<ProfileHeader />
<ProfileSettings />
</>
);
}
export const metadata: Metadata = {
title: 'Profile Settings | Umami',
};