Reworked settings screens.

This commit is contained in:
Mike Cao
2025-05-03 00:31:37 -07:00
parent c1d301ffdc
commit 0a16ab38e4
58 changed files with 362 additions and 365 deletions

View File

@@ -0,0 +1,26 @@
import { DateFilter } from '@/components/input/DateFilter';
import { Button, Row } from '@umami/react-zen';
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, saveDateRange } = useDateRange();
const { value } = dateRange;
const handleChange = (value: string | DateRange) => saveDateRange(value);
const handleReset = () => saveDateRange(DEFAULT_DATE_RANGE);
return (
<Row gap="3">
<DateFilter
value={value}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={handleChange}
/>
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
</Row>
);
}

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { Button, Select, ListItem, Row } from '@umami/react-zen';
import { useLocale, useMessages } from '@/components/hooks';
import { DEFAULT_LOCALE } from '@/lib/constants';
import { languages } from '@/lib/lang';
export function LanguageSetting() {
const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages();
const { locale, saveLocale } = useLocale();
const items = 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 handleOpen = isOpen => {
if (isOpen) {
setSearch('');
}
};
return (
<Row gap="3">
<Select
selectedKey={locale}
onChange={val => saveLocale(val as string)}
allowSearch
onSearch={setSearch}
onOpenChange={handleOpen}
listProps={{ style: { maxHeight: '300px' } }}
>
{items.map(item => (
<ListItem key={item} id={item}>
{languages[item].label}
</ListItem>
))}
{!items.length && <ListItem></ListItem>}
</Select>
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
</Row>
);
}

View File

@@ -0,0 +1,29 @@
import { Button, Icon, Text, useToast, DialogTrigger, Dialog, Modal } from '@umami/react-zen';
import { PasswordEditForm } from './PasswordEditForm';
import { Icons } from '@/components/icons';
import { useMessages } from '@/components/hooks';
export function PasswordChangeButton() {
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const handleSave = () => {
toast(formatMessage(messages.saved));
};
return (
<DialogTrigger>
<Button>
<Icon fillColor="currentColor">
<Icons.Lock />
</Icon>
<Text>{formatMessage(labels.changePassword)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.changePassword)}>
{({ close }) => <PasswordEditForm onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,70 @@
import {
Form,
FormField,
FormButtons,
PasswordField,
Button,
FormSubmitButton,
} from '@umami/react-zen';
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 handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave();
onClose();
},
});
};
const samePassword = (value: string, values: { [key: string]: any }) => {
if (value !== values.newPassword) {
return formatMessage(messages.noMatchPassword);
}
return true;
};
return (
<Form onSubmit={handleSubmit} error={error}>
<FormField
label={formatMessage(labels.currentPassword)}
name="currentPassword"
rules={{ required: 'Required' }}
>
<PasswordField autoComplete="current-password" />
</FormField>
<FormField
name="newPassword"
label={formatMessage(labels.newPassword)}
rules={{
required: 'Required',
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
}}
>
<PasswordField autoComplete="new-password" />
</FormField>
<FormField
name="confirmPassword"
label={formatMessage(labels.confirmPassword)}
rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
validate: samePassword,
}}
>
<PasswordField autoComplete="confirm-password" />
</FormField>
<FormButtons>
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
<FormSubmitButton isDisabled={isPending}>{formatMessage(labels.save)}</FormSubmitButton>
</FormButtons>
</Form>
);
}

View File

@@ -0,0 +1,8 @@
import { SectionHeader } from '@/components/common/SectionHeader';
import { useMessages } from '@/components/hooks';
export function ProfileHeader() {
const { formatMessage, labels } = useMessages();
return <SectionHeader title={formatMessage(labels.profile)}></SectionHeader>;
}

View File

@@ -0,0 +1,16 @@
'use client';
import { ProfileSettings } from './ProfileSettings';
import { useMessages } from '@/components/hooks';
import { SectionHeader } from '@/components/common/SectionHeader';
export function ProfilePage() {
const { formatMessage, labels } = useMessages();
return (
<>
<SectionHeader title={formatMessage(labels.profile)} />
<ProfileSettings />
</>
);
}

View File

@@ -0,0 +1,77 @@
import { Row, Column, Label } from '@umami/react-zen';
import { useLoginQuery, useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { TimezoneSetting } from './TimezoneSetting';
import { DateRangeSetting } from './DateRangeSetting';
import { LanguageSetting } from './LanguageSetting';
import { ThemeSetting } from './ThemeSetting';
import { PasswordChangeButton } from './PasswordChangeButton';
export function ProfileSettings() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const cloudMode = !!process.env.cloudMode;
if (!user) {
return null;
}
const { username, role } = user;
const renderRole = (value: string) => {
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 (
<Column gap="6">
<Column>
<Label>{formatMessage(labels.username)}</Label>
{username}
</Column>
<Column>
<Label>{formatMessage(labels.role)}</Label>
{renderRole(role)}
</Column>
{!cloudMode && (
<Column>
<Label>{formatMessage(labels.password)}</Label>
<Row>
<PasswordChangeButton />
</Row>
</Column>
)}
<Column>
<Label>{formatMessage(labels.defaultDateRange)}</Label>
<DateRangeSetting />
</Column>
<Column>
<Label>{formatMessage(labels.language)}</Label>
<LanguageSetting />
</Column>
<Column>
<Label>{formatMessage(labels.timezone)}</Label>
<TimezoneSetting />
</Column>
<Column>
<Label>{formatMessage(labels.theme)}</Label>
<ThemeSetting />
</Column>
</Column>
);
}

View File

@@ -0,0 +1,24 @@
import { Row, Button, Icon, useTheme } from '@umami/react-zen';
import { Icons } from '@/components/icons';
export function ThemeSetting() {
const { theme, setTheme } = useTheme();
return (
<Row gap>
<Button
variant={theme === 'light' ? 'primary' : 'secondary'}
onPress={() => setTheme('light')}
>
<Icon fillColor="currentColor">
<Icons.Sun />
</Icon>
</Button>
<Button variant={theme === 'dark' ? 'primary' : 'secondary'} onPress={() => setTheme('dark')}>
<Icon fillColor="currentColor">
<Icons.Moon />
</Icon>
</Button>
</Row>
);
}

View File

@@ -0,0 +1,44 @@
import { useState } from 'react';
import { Row, Select, ListItem, Button } from '@umami/react-zen';
import { useTimezone, useMessages } from '@/components/hooks';
import { getTimezone } from '@/lib/date';
const timezones = Intl.supportedValuesOf('timeZone');
export function TimezoneSetting() {
const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages();
const { timezone, saveTimezone } = useTimezone();
const items = search
? timezones.filter(n => n.toLowerCase().includes(search.toLowerCase()))
: timezones;
const handleReset = () => saveTimezone(getTimezone());
const handleOpen = isOpen => {
if (isOpen) {
setSearch('');
}
};
return (
<Row gap="3">
<Select
selectedKey={timezone}
onChange={(value: any) => saveTimezone(value)}
allowSearch={true}
onSearch={setSearch}
onOpenChange={handleOpen}
listProps={{ style: { maxHeight: '300px' } }}
>
{items.map((item: any) => (
<ListItem key={item} id={item}>
{item}
</ListItem>
))}
{!items.length && <ListItem></ListItem>}
</Select>
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
</Row>
);
}

View File

@@ -0,0 +1,10 @@
import { Metadata } from 'next';
import { ProfilePage } from './ProfilePage';
export default function () {
return <ProfilePage />;
}
export const metadata: Metadata = {
title: 'Profile',
};