mirror of
https://github.com/umami-software/umami.git
synced 2026-02-13 00:55:37 +01:00
Added settings layout.
This commit is contained in:
parent
d818bf5aaf
commit
6802093d69
22 changed files with 102 additions and 76 deletions
25
src/app/(main)/settings/Settings.tsx
Normal file
25
src/app/(main)/settings/Settings.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
div.menu {
|
||||
max-height: 300px;
|
||||
width: 300px;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
.buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 2px solid var(--primary400);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
div.menu {
|
||||
max-height: 300px;
|
||||
width: 300px;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue