diff options
Diffstat (limited to 'src/app/(main)/settings')
24 files changed, 597 insertions, 0 deletions
diff --git a/src/app/(main)/settings/SettingsLayout.tsx b/src/app/(main)/settings/SettingsLayout.tsx new file mode 100644 index 0000000..f658872 --- /dev/null +++ b/src/app/(main)/settings/SettingsLayout.tsx @@ -0,0 +1,26 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { PageBody } from '@/components/common/PageBody'; +import { SettingsNav } from './SettingsNav'; + +export function SettingsLayout({ children }: { children: ReactNode }) { + return ( + <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%"> + <Column + display={{ xs: 'none', lg: 'flex' }} + width="240px" + height="100%" + border="right" + backgroundColor + marginRight="2" + padding="3" + > + <SettingsNav /> + </Column> + <Column gap="6" margin="2"> + <PageBody>{children}</PageBody> + </Column> + </Grid> + ); +} diff --git a/src/app/(main)/settings/SettingsNav.tsx b/src/app/(main)/settings/SettingsNav.tsx new file mode 100644 index 0000000..4b35c82 --- /dev/null +++ b/src/app/(main)/settings/SettingsNav.tsx @@ -0,0 +1,53 @@ +import { SideMenu } from '@/components/common/SideMenu'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { Settings2, UserCircle, Users } from '@/components/icons'; + +export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) { + const { formatMessage, labels } = useMessages(); + const { renderUrl, pathname } = useNavigation(); + + const items = [ + { + label: formatMessage(labels.application), + items: [ + { + id: 'preferences', + label: formatMessage(labels.preferences), + path: renderUrl('/settings/preferences'), + icon: <Settings2 />, + }, + ], + }, + { + label: formatMessage(labels.account), + items: [ + { + id: 'profile', + label: formatMessage(labels.profile), + path: renderUrl('/settings/profile'), + icon: <UserCircle />, + }, + { + id: 'teams', + label: formatMessage(labels.teams), + path: renderUrl('/settings/teams'), + icon: <Users />, + }, + ], + }, + ]; + + const selectedKey = items + .flatMap(e => e.items) + .find(({ path }) => path && pathname.includes(path.split('?')[0]))?.id; + + return ( + <SideMenu + items={items} + title={formatMessage(labels.settings)} + selectedKey={selectedKey} + allowMinimize={false} + onItemClick={onItemClick} + /> + ); +} diff --git a/src/app/(main)/settings/layout.tsx b/src/app/(main)/settings/layout.tsx new file mode 100644 index 0000000..4e773a3 --- /dev/null +++ b/src/app/(main)/settings/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; +import { SettingsLayout } from './SettingsLayout'; + +export default function ({ children }) { + if (process.env.cloudMode) { + return null; + } + + return <SettingsLayout>{children}</SettingsLayout>; +} + +export const metadata: Metadata = { + title: { + template: '%s | Settings | Umami', + default: 'Settings | Umami', + }, +}; diff --git a/src/app/(main)/settings/preferences/DateRangeSetting.tsx b/src/app/(main)/settings/preferences/DateRangeSetting.tsx new file mode 100644 index 0000000..3f5e664 --- /dev/null +++ b/src/app/(main)/settings/preferences/DateRangeSetting.tsx @@ -0,0 +1,28 @@ +import { Button, Row } from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { DateFilter } from '@/components/input/DateFilter'; +import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants'; +import { getItem, setItem } from '@/lib/storage'; + +export function DateRangeSetting() { + const { formatMessage, labels } = useMessages(); + const [date, setDate] = useState(getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE); + + const handleChange = (value: string) => { + setItem(DATE_RANGE_CONFIG, value); + setDate(value); + }; + + const handleReset = () => { + setItem(DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE); + setDate(DEFAULT_DATE_RANGE_VALUE); + }; + + return ( + <Row gap="3"> + <DateFilter value={date} onChange={handleChange} placement="bottom start" /> + <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button> + </Row> + ); +} diff --git a/src/app/(main)/settings/preferences/LanguageSetting.tsx b/src/app/(main)/settings/preferences/LanguageSetting.tsx new file mode 100644 index 0000000..00a2d74 --- /dev/null +++ b/src/app/(main)/settings/preferences/LanguageSetting.tsx @@ -0,0 +1,48 @@ +import { Button, ListItem, Row, Select } from '@umami/react-zen'; +import { useState } from 'react'; +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: boolean) => { + if (isOpen) { + setSearch(''); + } + }; + + return ( + <Row gap> + <Select + value={locale} + onChange={val => saveLocale(val as string)} + allowSearch + onSearch={setSearch} + onOpenChange={handleOpen} + listProps={{ style: { maxHeight: 300 } }} + > + {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> + ); +} diff --git a/src/app/(main)/settings/preferences/PreferenceSettings.tsx b/src/app/(main)/settings/preferences/PreferenceSettings.tsx new file mode 100644 index 0000000..a2890ce --- /dev/null +++ b/src/app/(main)/settings/preferences/PreferenceSettings.tsx @@ -0,0 +1,36 @@ +import { Column, Label } from '@umami/react-zen'; +import { useLoginQuery, useMessages } from '@/components/hooks'; +import { DateRangeSetting } from './DateRangeSetting'; +import { LanguageSetting } from './LanguageSetting'; +import { ThemeSetting } from './ThemeSetting'; +import { TimezoneSetting } from './TimezoneSetting'; + +export function PreferenceSettings() { + const { user } = useLoginQuery(); + const { formatMessage, labels } = useMessages(); + + if (!user) { + return null; + } + + return ( + <Column width="400px" gap="6"> + <Column> + <Label>{formatMessage(labels.defaultDateRange)}</Label> + <DateRangeSetting /> + </Column> + <Column> + <Label>{formatMessage(labels.timezone)}</Label> + <TimezoneSetting /> + </Column> + <Column> + <Label>{formatMessage(labels.language)}</Label> + <LanguageSetting /> + </Column> + <Column> + <Label>{formatMessage(labels.theme)}</Label> + <ThemeSetting /> + </Column> + </Column> + ); +} diff --git a/src/app/(main)/settings/preferences/PreferencesPage.tsx b/src/app/(main)/settings/preferences/PreferencesPage.tsx new file mode 100644 index 0000000..61e2669 --- /dev/null +++ b/src/app/(main)/settings/preferences/PreferencesPage.tsx @@ -0,0 +1,22 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { PreferenceSettings } from './PreferenceSettings'; + +export function PreferencesPage() { + const { formatMessage, labels } = useMessages(); + + return ( + <PageBody> + <Column gap="6"> + <PageHeader title={formatMessage(labels.preferences)} /> + <Panel> + <PreferenceSettings /> + </Panel> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/settings/preferences/ThemeSetting.tsx b/src/app/(main)/settings/preferences/ThemeSetting.tsx new file mode 100644 index 0000000..03bd6a6 --- /dev/null +++ b/src/app/(main)/settings/preferences/ThemeSetting.tsx @@ -0,0 +1,21 @@ +import { Button, Icon, Row, useTheme } from '@umami/react-zen'; +import { Moon, Sun } from '@/components/icons'; + +export function ThemeSetting() { + const { theme, setTheme } = useTheme(); + + return ( + <Row gap> + <Button variant={theme === 'light' ? 'primary' : undefined} onPress={() => setTheme('light')}> + <Icon> + <Sun /> + </Icon> + </Button> + <Button variant={theme === 'dark' ? 'primary' : undefined} onPress={() => setTheme('dark')}> + <Icon> + <Moon /> + </Icon> + </Button> + </Row> + ); +} diff --git a/src/app/(main)/settings/preferences/TimezoneSetting.tsx b/src/app/(main)/settings/preferences/TimezoneSetting.tsx new file mode 100644 index 0000000..cf20b20 --- /dev/null +++ b/src/app/(main)/settings/preferences/TimezoneSetting.tsx @@ -0,0 +1,44 @@ +import { Button, ListItem, Row, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages, useTimezone } 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> + <Select + value={timezone} + onChange={(value: any) => saveTimezone(value)} + allowSearch={true} + onSearch={setSearch} + onOpenChange={handleOpen} + listProps={{ style: { maxHeight: 300 } }} + > + {items.map((item: any) => ( + <ListItem key={item} id={item}> + {item} + </ListItem> + ))} + {!items.length && <ListItem></ListItem>} + </Select> + <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button> + </Row> + ); +} diff --git a/src/app/(main)/settings/preferences/page.tsx b/src/app/(main)/settings/preferences/page.tsx new file mode 100644 index 0000000..dd16870 --- /dev/null +++ b/src/app/(main)/settings/preferences/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { PreferencesPage } from './PreferencesPage'; + +export default function () { + return <PreferencesPage />; +} + +export const metadata: Metadata = { + title: 'Preferences', +}; diff --git a/src/app/(main)/settings/profile/PasswordChangeButton.tsx b/src/app/(main)/settings/profile/PasswordChangeButton.tsx new file mode 100644 index 0000000..6ce8ef8 --- /dev/null +++ b/src/app/(main)/settings/profile/PasswordChangeButton.tsx @@ -0,0 +1,29 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { LockKeyhole } from '@/components/icons'; +import { PasswordEditForm } from './PasswordEditForm'; + +export function PasswordChangeButton() { + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + + const handleSave = () => { + toast(formatMessage(messages.saved)); + }; + + return ( + <DialogTrigger> + <Button> + <Icon> + <LockKeyhole /> + </Icon> + <Text>{formatMessage(labels.changePassword)}</Text> + </Button> + <Modal> + <Dialog title={formatMessage(labels.changePassword)} style={{ width: 400 }}> + {({ close }) => <PasswordEditForm onSave={handleSave} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/settings/profile/PasswordEditForm.tsx b/src/app/(main)/settings/profile/PasswordEditForm.tsx new file mode 100644 index 0000000..6f782e4 --- /dev/null +++ b/src/app/(main)/settings/profile/PasswordEditForm.tsx @@ -0,0 +1,67 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + PasswordField, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; + +export function PasswordEditForm({ onSave, onClose }) { + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending } = useUpdateQuery('/me/password'); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + onSave(); + onClose(); + }, + }); + }; + + const samePassword = (value: string, values: Record<string, any>) => { + if (value !== values.newPassword) { + return formatMessage(messages.noMatchPassword); + } + return true; + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(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> + ); +} diff --git a/src/app/(main)/settings/profile/ProfileHeader.tsx b/src/app/(main)/settings/profile/ProfileHeader.tsx new file mode 100644 index 0000000..05f7996 --- /dev/null +++ b/src/app/(main)/settings/profile/ProfileHeader.tsx @@ -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>; +} diff --git a/src/app/(main)/settings/profile/ProfilePage.tsx b/src/app/(main)/settings/profile/ProfilePage.tsx new file mode 100644 index 0000000..f03499a --- /dev/null +++ b/src/app/(main)/settings/profile/ProfilePage.tsx @@ -0,0 +1,22 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { ProfileSettings } from './ProfileSettings'; + +export function ProfilePage() { + const { formatMessage, labels } = useMessages(); + + return ( + <PageBody> + <Column gap="6"> + <PageHeader title={formatMessage(labels.profile)} /> + <Panel> + <ProfileSettings /> + </Panel> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/settings/profile/ProfileSettings.tsx b/src/app/(main)/settings/profile/ProfileSettings.tsx new file mode 100644 index 0000000..fae73a5 --- /dev/null +++ b/src/app/(main)/settings/profile/ProfileSettings.tsx @@ -0,0 +1,51 @@ +import { Column, Label, Row } from '@umami/react-zen'; +import { useConfig, useLoginQuery, useMessages } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; +import { PasswordChangeButton } from './PasswordChangeButton'; + +export function ProfileSettings() { + const { user } = useLoginQuery(); + const { formatMessage, labels } = useMessages(); + const { cloudMode } = useConfig(); + + 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 width="400px" 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> + ); +} diff --git a/src/app/(main)/settings/profile/page.tsx b/src/app/(main)/settings/profile/page.tsx new file mode 100644 index 0000000..6060b91 --- /dev/null +++ b/src/app/(main)/settings/profile/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { ProfilePage } from './ProfilePage'; + +export default function () { + return <ProfilePage />; +} + +export const metadata: Metadata = { + title: 'Profile', +}; diff --git a/src/app/(main)/settings/teams/TeamsSettingsPage.tsx b/src/app/(main)/settings/teams/TeamsSettingsPage.tsx new file mode 100644 index 0000000..dc3e3bc --- /dev/null +++ b/src/app/(main)/settings/teams/TeamsSettingsPage.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { TeamsDataTable } from '@/app/(main)/teams/TeamsDataTable'; +import { TeamsHeader } from '@/app/(main)/teams/TeamsHeader'; +import { Panel } from '@/components/common/Panel'; + +export function TeamsSettingsPage() { + return ( + <Column gap="6"> + <TeamsHeader /> + <Panel> + <TeamsDataTable /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx b/src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx new file mode 100644 index 0000000..9539625 --- /dev/null +++ b/src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx @@ -0,0 +1,11 @@ +'use client'; +import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings'; +import { TeamProvider } from '@/app/(main)/teams/TeamProvider'; + +export function TeamSettingsPage({ teamId }: { teamId: string }) { + return ( + <TeamProvider teamId={teamId}> + <TeamSettings teamId={teamId} /> + </TeamProvider> + ); +} diff --git a/src/app/(main)/settings/teams/[teamId]/page.tsx b/src/app/(main)/settings/teams/[teamId]/page.tsx new file mode 100644 index 0000000..58a380b --- /dev/null +++ b/src/app/(main)/settings/teams/[teamId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { TeamSettingsPage } from './TeamSettingsPage'; + +export default async function ({ params }: { params: Promise<{ teamId: string }> }) { + const { teamId } = await params; + + return <TeamSettingsPage teamId={teamId} />; +} + +export const metadata: Metadata = { + title: 'Teams', +}; diff --git a/src/app/(main)/settings/teams/page.tsx b/src/app/(main)/settings/teams/page.tsx new file mode 100644 index 0000000..a0913f4 --- /dev/null +++ b/src/app/(main)/settings/teams/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { TeamsSettingsPage } from './TeamsSettingsPage'; + +export default function () { + return <TeamsSettingsPage />; +} + +export const metadata: Metadata = { + title: 'Teams', +}; diff --git a/src/app/(main)/settings/websites/WebsitesSettingsPage.tsx b/src/app/(main)/settings/websites/WebsitesSettingsPage.tsx new file mode 100644 index 0000000..5009ec6 --- /dev/null +++ b/src/app/(main)/settings/websites/WebsitesSettingsPage.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsitesDataTable } from '@/app/(main)/websites/WebsitesDataTable'; +import { SectionHeader } from '@/components/common/SectionHeader'; +import { useMessages } from '@/components/hooks'; + +export function WebsitesSettingsPage({ teamId }: { teamId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <Column gap> + <SectionHeader title={formatMessage(labels.websites)} /> + <WebsitesDataTable teamId={teamId} /> + </Column> + ); +} diff --git a/src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx new file mode 100644 index 0000000..53b4cd9 --- /dev/null +++ b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings'; +import { WebsiteSettingsHeader } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader'; +import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; + +export function WebsiteSettingsPage({ websiteId }: { websiteId: string }) { + return ( + <WebsiteProvider websiteId={websiteId}> + <Column margin="2"> + <WebsiteSettingsHeader /> + <WebsiteSettings websiteId={websiteId} /> + </Column> + </WebsiteProvider> + ); +} diff --git a/src/app/(main)/settings/websites/[websiteId]/page.tsx b/src/app/(main)/settings/websites/[websiteId]/page.tsx new file mode 100644 index 0000000..9adfc91 --- /dev/null +++ b/src/app/(main)/settings/websites/[websiteId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { WebsiteSettingsPage } from './WebsiteSettingsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <WebsiteSettingsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Website', +}; diff --git a/src/app/(main)/settings/websites/page.tsx b/src/app/(main)/settings/websites/page.tsx new file mode 100644 index 0000000..19c14fd --- /dev/null +++ b/src/app/(main)/settings/websites/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { WebsitesSettingsPage } from './WebsitesSettingsPage'; + +export default async function ({ params }: { params: Promise<{ teamId: string }> }) { + const { teamId } = await params; + + return <WebsitesSettingsPage teamId={teamId} />; +} + +export const metadata: Metadata = { + title: 'Websites', +}; |