mirror of
https://github.com/umami-software/umami.git
synced 2026-02-12 16:45:35 +01:00
Refactored to use app folder.
This commit is contained in:
parent
40cfcd41e9
commit
9a52cdd2e1
258 changed files with 2025 additions and 2258 deletions
31
src/app/(app)/settings/SideNav.js
Normal file
31
src/app/(app)/settings/SideNav.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import classNames from 'classnames';
|
||||
import { Menu, Item } from 'react-basics';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import styles from './SideNav.module.css';
|
||||
|
||||
export function SideNav({
|
||||
selectedKey,
|
||||
items,
|
||||
shallow = true,
|
||||
scroll = false,
|
||||
onSelect = () => {},
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<Menu items={items} selectedKey={selectedKey} className={styles.menu} onSelect={onSelect}>
|
||||
{({ key, label, url }) => (
|
||||
<Item
|
||||
key={key}
|
||||
className={classNames(styles.item, { [styles.selected]: pathname.startsWith(url) })}
|
||||
>
|
||||
<Link href={url} shallow={shallow} scroll={scroll}>
|
||||
{label}
|
||||
</Link>
|
||||
</Item>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default SideNav;
|
||||
19
src/app/(app)/settings/SideNav.module.css
Normal file
19
src/app/(app)/settings/SideNav.module.css
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item a {
|
||||
color: var(--font-color100);
|
||||
flex: 1;
|
||||
padding: var(--size300) var(--size600);
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: 700;
|
||||
}
|
||||
25
src/app/(app)/settings/layout.module.css
Normal file
25
src/app/(app)/settings/layout.module.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
width: 240px;
|
||||
padding-top: 40px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
33
src/app/(app)/settings/layout.tsx
Normal file
33
src/app/(app)/settings/layout.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use client';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import SideNav from './SideNav';
|
||||
import styles from './layout.module.css';
|
||||
|
||||
export default function SettingsLayout({ children }) {
|
||||
const { user } = useUser();
|
||||
const pathname = usePathname();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const cloudMode = Boolean(process.env.cloudMode);
|
||||
|
||||
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' },
|
||||
{ key: 'profile', label: formatMessage(labels.profile), url: '/settings/profile' },
|
||||
].filter(n => n);
|
||||
|
||||
const getKey = () => items.find(({ url }) => pathname === url)?.key;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
28
src/app/(app)/settings/profile/DateRangeSetting.js
Normal file
28
src/app/(app)/settings/profile/DateRangeSetting.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import DateFilter from 'components/input/DateFilter';
|
||||
import { Button, Flexbox } from 'react-basics';
|
||||
import useDateRange from 'components/hooks/useDateRange';
|
||||
import { DEFAULT_DATE_RANGE } from 'lib/constants';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function DateRangeSetting() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [dateRange, setDateRange] = useDateRange();
|
||||
const { value } = dateRange;
|
||||
|
||||
const handleChange = value => 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;
|
||||
32
src/app/(app)/settings/profile/LanguageSetting.js
Normal file
32
src/app/(app)/settings/profile/LanguageSetting.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Button, Dropdown, Item, Flexbox } from 'react-basics';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import { DEFAULT_LOCALE } from 'lib/constants';
|
||||
import { languages } from 'lib/lang';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function LanguageSetting() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { locale, saveLocale } = useLocale();
|
||||
const options = Object.keys(languages);
|
||||
|
||||
const handleReset = () => saveLocale(DEFAULT_LOCALE);
|
||||
|
||||
const renderValue = value => languages[value].label;
|
||||
|
||||
return (
|
||||
<Flexbox gap={10}>
|
||||
<Dropdown
|
||||
items={options}
|
||||
value={locale}
|
||||
renderValue={renderValue}
|
||||
onChange={saveLocale}
|
||||
menuProps={{ style: { height: 300, width: 300 } }}
|
||||
>
|
||||
{item => <Item key={item}>{languages[item].label}</Item>}
|
||||
</Dropdown>
|
||||
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSetting;
|
||||
31
src/app/(app)/settings/profile/PasswordChangeButton.js
Normal file
31
src/app/(app)/settings/profile/PasswordChangeButton.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Button, Icon, Text, useToasts, ModalTrigger, Modal } from 'react-basics';
|
||||
import PasswordEditForm from 'app/(app)/settings/profile/PasswordEditForm';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
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;
|
||||
68
src/app/(app)/settings/profile/PasswordEditForm.js
Normal file
68
src/app/(app)/settings/profile/PasswordEditForm.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useRef } from 'react';
|
||||
import { Form, FormRow, FormInput, FormButtons, PasswordField, Button } from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function PasswordEditForm({ onSave, onClose }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error, isLoading } = useMutation(data => post('/me/password', data));
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const samePassword = value => {
|
||||
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={isLoading}>
|
||||
{formatMessage(labels.save)}
|
||||
</Button>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordEditForm;
|
||||
11
src/app/(app)/settings/profile/ProfileHeader.js
Normal file
11
src/app/(app)/settings/profile/ProfileHeader.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'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;
|
||||
62
src/app/(app)/settings/profile/ProfileSettings.js
Normal file
62
src/app/(app)/settings/profile/ProfileSettings.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use client';
|
||||
import { Form, FormRow } from 'react-basics';
|
||||
import TimezoneSetting from 'app/(app)/settings/profile/TimezoneSetting';
|
||||
import DateRangeSetting from 'app/(app)/settings/profile/DateRangeSetting';
|
||||
import LanguageSetting from 'app/(app)/settings/profile/LanguageSetting';
|
||||
import ThemeSetting from 'app/(app)/settings/profile/ThemeSetting';
|
||||
import PasswordChangeButton from './PasswordChangeButton';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { ROLES } from 'lib/constants';
|
||||
|
||||
export function ProfileSettings() {
|
||||
const { user } = useUser();
|
||||
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;
|
||||
33
src/app/(app)/settings/profile/ThemeSetting.js
Normal file
33
src/app/(app)/settings/profile/ThemeSetting.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import classNames from 'classnames';
|
||||
import { Button, Icon } from 'react-basics';
|
||||
import useTheme from 'components/hooks/useTheme';
|
||||
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;
|
||||
8
src/app/(app)/settings/profile/ThemeSetting.module.css
Normal file
8
src/app/(app)/settings/profile/ThemeSetting.module.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 2px solid var(--primary400);
|
||||
}
|
||||
30
src/app/(app)/settings/profile/TimezoneSetting.js
Normal file
30
src/app/(app)/settings/profile/TimezoneSetting.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Dropdown, Item, Button, Flexbox } from 'react-basics';
|
||||
import { listTimeZones } from 'timezone-support';
|
||||
import useTimezone from 'components/hooks/useTimezone';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { getTimezone } from 'lib/date';
|
||||
|
||||
export function TimezoneSetting() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [timezone, saveTimezone] = useTimezone();
|
||||
const options = listTimeZones();
|
||||
|
||||
const handleReset = () => saveTimezone(getTimezone());
|
||||
|
||||
return (
|
||||
<Flexbox gap={10}>
|
||||
<Dropdown
|
||||
items={options}
|
||||
value={timezone}
|
||||
onChange={saveTimezone}
|
||||
style={{ flex: 1 }}
|
||||
menuProps={{ style: { height: 300 } }}
|
||||
>
|
||||
{item => <Item key={item}>{item}</Item>}
|
||||
</Dropdown>
|
||||
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimezoneSetting;
|
||||
11
src/app/(app)/settings/profile/page.js
Normal file
11
src/app/(app)/settings/profile/page.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import ProfileHeader from './ProfileHeader';
|
||||
import ProfileSettings from './ProfileSettings';
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<>
|
||||
<ProfileHeader />
|
||||
<ProfileSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
src/app/(app)/settings/teams/TeamAddForm.js
Normal file
48
src/app/(app)/settings/teams/TeamAddForm.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useRef } from 'react';
|
||||
import {
|
||||
Form,
|
||||
FormRow,
|
||||
FormInput,
|
||||
FormButtons,
|
||||
TextField,
|
||||
Button,
|
||||
SubmitButton,
|
||||
} from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function TeamAddForm({ onSave, onClose }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error, isLoading } = useMutation(data => post('/teams', data));
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form ref={ref} onSubmit={handleSubmit} error={error}>
|
||||
<FormRow label={formatMessage(labels.name)}>
|
||||
<FormInput name="name" rules={{ required: formatMessage(labels.required) }}>
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="primary" disabled={isLoading}>
|
||||
{formatMessage(labels.save)}
|
||||
</SubmitButton>
|
||||
<Button disabled={isLoading} onClick={onClose}>
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamAddForm;
|
||||
25
src/app/(app)/settings/teams/TeamDeleteButton.js
Normal file
25
src/app/(app)/settings/teams/TeamDeleteButton.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import TeamDeleteForm from './TeamDeleteForm';
|
||||
|
||||
export function TeamDeleteButton({ teamId, teamName, onDelete }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<ModalTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.deleteTeam)}>
|
||||
{close => (
|
||||
<TeamDeleteForm teamId={teamId} teamName={teamName} onSave={onDelete} onClose={close} />
|
||||
)}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamDeleteButton;
|
||||
34
src/app/(app)/settings/teams/TeamDeleteForm.js
Normal file
34
src/app/(app)/settings/teams/TeamDeleteForm.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Button, Form, FormButtons, SubmitButton } from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function TeamDeleteForm({ teamId, teamName, onSave, onClose }) {
|
||||
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
|
||||
const { del, useMutation } = useApi();
|
||||
const { mutate, error, isLoading } = useMutation(data => del(`/teams/${teamId}`, data));
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={error}>
|
||||
<p>
|
||||
<FormattedMessage {...messages.confirmDelete} values={{ target: <b>{teamName}</b> }} />
|
||||
</p>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="danger" disabled={isLoading}>
|
||||
{formatMessage(labels.delete)}
|
||||
</SubmitButton>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamDeleteForm;
|
||||
44
src/app/(app)/settings/teams/TeamJoinForm.js
Normal file
44
src/app/(app)/settings/teams/TeamJoinForm.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useRef } from 'react';
|
||||
import {
|
||||
Form,
|
||||
FormRow,
|
||||
FormInput,
|
||||
FormButtons,
|
||||
TextField,
|
||||
Button,
|
||||
SubmitButton,
|
||||
} from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function TeamJoinForm({ onSave, onClose }) {
|
||||
const { formatMessage, labels, getMessage } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation(data => post('/teams/join', data));
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form ref={ref} onSubmit={handleSubmit} error={error && getMessage(error)}>
|
||||
<FormRow label={formatMessage(labels.accessCode)}>
|
||||
<FormInput name="accessCode" rules={{ required: formatMessage(labels.required) }}>
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="primary">{formatMessage(labels.join)}</SubmitButton>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamJoinForm;
|
||||
35
src/app/(app)/settings/teams/TeamLeaveButton.js
Normal file
35
src/app/(app)/settings/teams/TeamLeaveButton.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import TeamDeleteForm from './TeamLeaveForm';
|
||||
|
||||
export function TeamLeaveButton({ teamId, teamName, onLeave }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { dir } = useLocale();
|
||||
const { user } = useUser();
|
||||
|
||||
return (
|
||||
<ModalTrigger>
|
||||
<Button>
|
||||
<Icon rotate={dir === 'rtl' ? 180 : 0}>
|
||||
<Icons.ArrowRight />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.leave)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.leaveTeam)}>
|
||||
{close => (
|
||||
<TeamDeleteForm
|
||||
teamId={teamId}
|
||||
userId={user.id}
|
||||
teamName={teamName}
|
||||
onSave={onLeave}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamLeaveButton;
|
||||
37
src/app/(app)/settings/teams/TeamLeaveForm.js
Normal file
37
src/app/(app)/settings/teams/TeamLeaveForm.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Button, Form, FormButtons, SubmitButton } from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function TeamLeaveForm({ teamId, userId, teamName, onSave, onClose }) {
|
||||
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
|
||||
const { del, useMutation } = useApi();
|
||||
const { mutate, error, isLoading } = useMutation(() => del(`/teams/${teamId}/users/${userId}`));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
mutate(
|
||||
{},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={error}>
|
||||
<p>
|
||||
<FormattedMessage {...messages.confirmDelete} values={{ target: <b>{teamName}</b> }} />
|
||||
</p>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="danger" disabled={isLoading}>
|
||||
{formatMessage(labels.leave)}
|
||||
</SubmitButton>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamLeaveForm;
|
||||
31
src/app/(app)/settings/teams/TeamWebsiteRemoveButton.js
Normal file
31
src/app/(app)/settings/teams/TeamWebsiteRemoveButton.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { Icon, Icons, LoadingButton, Text } from 'react-basics';
|
||||
|
||||
export function TeamWebsiteRemoveButton({ teamId, websiteId, onSave }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { del, useMutation } = useApi();
|
||||
const { mutate, isLoading } = useMutation(() => del(`/teams/${teamId}/websites/${websiteId}`));
|
||||
|
||||
const handleRemoveTeamMember = () => {
|
||||
mutate(
|
||||
{},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSave();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<LoadingButton onClick={() => handleRemoveTeamMember()} isLoading={isLoading}>
|
||||
<Icon>
|
||||
<Icons.Close />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.remove)}</Text>
|
||||
</LoadingButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamWebsiteRemoveButton;
|
||||
24
src/app/(app)/settings/teams/TeamsAddButton.js
Normal file
24
src/app/(app)/settings/teams/TeamsAddButton.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Button, Icon, Modal, ModalTrigger, Text } from 'react-basics';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import TeamAddForm from './TeamAddForm';
|
||||
|
||||
export function TeamsAddButton({ onAdd }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<ModalTrigger>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.createTeam)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.createTeam)}>
|
||||
{close => <TeamAddForm onSave={onAdd} onClose={close} />}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamsAddButton;
|
||||
24
src/app/(app)/settings/teams/TeamsHeader.js
Normal file
24
src/app/(app)/settings/teams/TeamsHeader.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client';
|
||||
import { Flexbox } from 'react-basics';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import TeamsJoinButton from './TeamsJoinButton';
|
||||
import TeamsAddButton from './TeamsAddButton';
|
||||
|
||||
export function TeamsHeader() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useUser();
|
||||
|
||||
return (
|
||||
<PageHeader title={formatMessage(labels.teams)}>
|
||||
<Flexbox gap={10}>
|
||||
<TeamsJoinButton />
|
||||
{user.role !== ROLES.viewOnly && <TeamsAddButton />}
|
||||
</Flexbox>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamsHeader;
|
||||
29
src/app/(app)/settings/teams/TeamsJoinButton.js
Normal file
29
src/app/(app)/settings/teams/TeamsJoinButton.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Button, Icon, Modal, ModalTrigger, Text, useToasts } from 'react-basics';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import TeamJoinForm from './TeamJoinForm';
|
||||
|
||||
export function TeamsJoinButton() {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const handleJoin = () => {
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalTrigger>
|
||||
<Button variant="secondary">
|
||||
<Icon>
|
||||
<Icons.AddUser />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.joinTeam)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.joinTeam)}>
|
||||
{close => <TeamJoinForm onSave={handleJoin} onClose={close} />}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamsJoinButton;
|
||||
19
src/app/(app)/settings/teams/TeamsList.js
Normal file
19
src/app/(app)/settings/teams/TeamsList.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
import DataTable from 'components/common/DataTable';
|
||||
import TeamsTable from 'app/(app)/settings/teams/TeamsTable';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useFilterQuery from 'components/hooks/useFilterQuery';
|
||||
|
||||
export function TeamsList() {
|
||||
const { get } = useApi();
|
||||
const filterQuery = useFilterQuery(['teams'], params => {
|
||||
return get(`/teams`, {
|
||||
...params,
|
||||
});
|
||||
});
|
||||
const { getProps } = filterQuery;
|
||||
|
||||
return <DataTable {...getProps()}>{({ data }) => <TeamsTable data={data} />}</DataTable>;
|
||||
}
|
||||
|
||||
export default TeamsList;
|
||||
46
src/app/(app)/settings/teams/TeamsTable.js
Normal file
46
src/app/(app)/settings/teams/TeamsTable.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import Link from 'next/link';
|
||||
import { Button, Flexbox, GridColumn, GridTable, Icon, Icons, Text } from 'react-basics';
|
||||
import TeamDeleteButton from './TeamDeleteButton';
|
||||
import TeamLeaveButton from './TeamLeaveButton';
|
||||
|
||||
export function TeamsTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useUser();
|
||||
|
||||
return (
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="name" label={formatMessage(labels.name)} />
|
||||
<GridColumn name="owner" label={formatMessage(labels.owner)}>
|
||||
{row => row.teamUser.find(({ role }) => role === ROLES.teamOwner)?.user?.username}
|
||||
</GridColumn>
|
||||
<GridColumn name="action" label=" " alignment="end">
|
||||
{row => {
|
||||
const { id, name, teamUser } = row;
|
||||
const owner = teamUser.find(({ role }) => role === ROLES.teamOwner);
|
||||
const showDelete = user.id === owner?.userId;
|
||||
|
||||
return (
|
||||
<Flexbox gap={10}>
|
||||
<Link href={`/settings/teams/${id}`}>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.view)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
{showDelete && <TeamDeleteButton teamId={id} teamName={name} />}
|
||||
{!showDelete && <TeamLeaveButton teamId={id} teamName={name} />}
|
||||
</Flexbox>
|
||||
);
|
||||
}}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamsTable;
|
||||
31
src/app/(app)/settings/teams/WebsiteTags.js
Normal file
31
src/app/(app)/settings/teams/WebsiteTags.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Button, Icon, Icons, Text } from 'react-basics';
|
||||
import styles from './WebsiteTags.module.css';
|
||||
|
||||
export function WebsiteTags({ items = [], websites = [], onClick }) {
|
||||
if (websites.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
{websites.map(websiteId => {
|
||||
const website = items.find(a => a.id === websiteId);
|
||||
|
||||
return (
|
||||
<div key={websiteId} className={styles.tag}>
|
||||
<Button onClick={() => onClick(websiteId)} variant="primary" size="sm">
|
||||
<Text>
|
||||
<b>{`${website.name}`}</b>
|
||||
</Text>
|
||||
<Icon>
|
||||
<Icons.Close />
|
||||
</Icon>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteTags;
|
||||
11
src/app/(app)/settings/teams/WebsiteTags.module.css
Normal file
11
src/app/(app)/settings/teams/WebsiteTags.module.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.filters {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tag {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
67
src/app/(app)/settings/teams/[id]/TeamAddWebsiteForm.js
Normal file
67
src/app/(app)/settings/teams/[id]/TeamAddWebsiteForm.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import useApi from 'components/hooks/useApi';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button, Dropdown, Form, FormButtons, FormRow, Item, SubmitButton } from 'react-basics';
|
||||
import WebsiteTags from '../WebsiteTags';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function TeamAddWebsiteForm({ teamId, onSave, onClose }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { get, post, useQuery, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation(data => post(`/teams/${teamId}/websites`, data));
|
||||
const { data: websites } = useQuery(['websites'], () => get('/websites'));
|
||||
const [newWebsites, setNewWebsites] = useState([]);
|
||||
const formRef = useRef();
|
||||
|
||||
const hasData = websites && websites.data.length > 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
mutate(
|
||||
{ websiteIds: newWebsites },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddWebsite = value => {
|
||||
if (!newWebsites.some(a => a === value)) {
|
||||
const nextValue = [...newWebsites];
|
||||
|
||||
nextValue.push(value);
|
||||
|
||||
setNewWebsites(nextValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveWebsite = value => {
|
||||
const newValue = newWebsites.filter(a => a !== value);
|
||||
|
||||
setNewWebsites(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasData && (
|
||||
<Form onSubmit={handleSubmit} error={error} ref={formRef}>
|
||||
<FormRow label={formatMessage(labels.websites)}>
|
||||
<Dropdown items={websites.data} onChange={handleAddWebsite} style={{ width: 300 }}>
|
||||
{({ id, name }) => <Item key={id}>{name}</Item>}
|
||||
</Dropdown>
|
||||
</FormRow>
|
||||
<WebsiteTags items={websites.data} websites={newWebsites} onClick={handleRemoveWebsite} />
|
||||
<FormButtons flex>
|
||||
<SubmitButton disabled={newWebsites && newWebsites.length === 0}>
|
||||
{formatMessage(labels.addWebsite)}
|
||||
</SubmitButton>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamAddWebsiteForm;
|
||||
73
src/app/(app)/settings/teams/[id]/TeamEditForm.js
Normal file
73
src/app/(app)/settings/teams/[id]/TeamEditForm.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import {
|
||||
SubmitButton,
|
||||
Form,
|
||||
FormInput,
|
||||
FormRow,
|
||||
FormButtons,
|
||||
TextField,
|
||||
Button,
|
||||
Flexbox,
|
||||
} from 'react-basics';
|
||||
import { getRandomChars } from 'next-basics';
|
||||
import { useRef, useState } from 'react';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
const generateId = () => getRandomChars(16);
|
||||
|
||||
export function TeamEditForm({ teamId, data, onSave, readOnly }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation(data => post(`/teams/${teamId}`, data));
|
||||
const ref = useRef(null);
|
||||
const [accessCode, setAccessCode] = useState(data.accessCode);
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
ref.current.reset(data);
|
||||
onSave(data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
const code = generateId();
|
||||
ref.current.setValue('accessCode', code, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setAccessCode(code);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form ref={ref} onSubmit={handleSubmit} error={error} values={data}>
|
||||
<FormRow label={formatMessage(labels.teamId)}>
|
||||
<TextField value={teamId} readOnly allowCopy />
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.name)}>
|
||||
{!readOnly && (
|
||||
<FormInput name="name" rules={{ required: formatMessage(labels.required) }}>
|
||||
<TextField />
|
||||
</FormInput>
|
||||
)}
|
||||
{readOnly && data.name}
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.accessCode)}>
|
||||
<Flexbox gap={10}>
|
||||
<TextField value={accessCode} readOnly allowCopy />
|
||||
{!readOnly && (
|
||||
<Button onClick={handleRegenerate}>{formatMessage(labels.regenerate)}</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
</FormRow>
|
||||
{!readOnly && (
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary">{formatMessage(labels.save)}</SubmitButton>
|
||||
</FormButtons>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamEditForm;
|
||||
35
src/app/(app)/settings/teams/[id]/TeamMemberRemoveButton.js
Normal file
35
src/app/(app)/settings/teams/[id]/TeamMemberRemoveButton.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { Icon, Icons, LoadingButton, Text } from 'react-basics';
|
||||
|
||||
export function TeamMemberRemoveButton({ teamId, userId, disabled, onSave }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { del, useMutation } = useApi();
|
||||
const { mutate, isLoading } = useMutation(() => del(`/teams/${teamId}/users/${userId}`));
|
||||
|
||||
const handleRemoveTeamMember = () => {
|
||||
mutate(
|
||||
{},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSave();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<LoadingButton
|
||||
onClick={() => handleRemoveTeamMember()}
|
||||
disabled={disabled}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<Icon>
|
||||
<Icons.Close />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.remove)}</Text>
|
||||
</LoadingButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamMemberRemoveButton;
|
||||
48
src/app/(app)/settings/teams/[id]/TeamMembers.js
Normal file
48
src/app/(app)/settings/teams/[id]/TeamMembers.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Loading, useToasts } from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useApiFilter from 'components/hooks/useApiFilter';
|
||||
import TeamMembersTable from './TeamMembersTable';
|
||||
|
||||
export function TeamMembers({ teamId, readOnly }) {
|
||||
const { showToast } = useToasts();
|
||||
const { formatMessage, messages } = useMessages();
|
||||
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||
useApiFilter();
|
||||
const { get, useQuery } = useApi();
|
||||
const { data, isLoading, refetch } = useQuery(
|
||||
['teams:users', teamId, filter, page, pageSize],
|
||||
() =>
|
||||
get(`/teams/${teamId}/users`, {
|
||||
filter,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading icon="dots" style={{ minHeight: 300 }} />;
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
await refetch();
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TeamMembersTable
|
||||
onSave={handleSave}
|
||||
teamId={teamId}
|
||||
data={data}
|
||||
readOnly={readOnly}
|
||||
onFilterChange={handleFilterChange}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
filterValue={filter}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamMembers;
|
||||
68
src/app/(app)/settings/teams/[id]/TeamMembersTable.js
Normal file
68
src/app/(app)/settings/teams/[id]/TeamMembersTable.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import useMessages from 'components/hooks/useMessages';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import TeamMemberRemoveButton from './TeamMemberRemoveButton';
|
||||
import SettingsTable from 'components/common/SettingsTable';
|
||||
|
||||
export function TeamMembersTable({
|
||||
data = [],
|
||||
teamId,
|
||||
onSave,
|
||||
readOnly,
|
||||
filterValue,
|
||||
onFilterChange,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useUser();
|
||||
|
||||
const columns = [
|
||||
{ name: 'username', label: formatMessage(labels.username) },
|
||||
{ name: 'role', label: formatMessage(labels.role) },
|
||||
{ name: 'action', label: ' ' },
|
||||
];
|
||||
|
||||
const cellRender = (row, data, key) => {
|
||||
if (key === 'username') {
|
||||
return row?.username;
|
||||
}
|
||||
if (key === 'role') {
|
||||
return formatMessage(
|
||||
labels[
|
||||
Object.keys(ROLES).find(key => ROLES[key] === row?.teamUser[0]?.role) || labels.unknown
|
||||
],
|
||||
);
|
||||
}
|
||||
return data[key];
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
cellRender={cellRender}
|
||||
showSearch={true}
|
||||
showPaging={true}
|
||||
onFilterChange={onFilterChange}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
filterValue={filterValue}
|
||||
>
|
||||
{row => {
|
||||
return (
|
||||
!readOnly && (
|
||||
<TeamMemberRemoveButton
|
||||
teamId={teamId}
|
||||
userId={row.id}
|
||||
disabled={user.id === row?.user?.id || row.role === ROLES.teamOwner}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}}
|
||||
</SettingsTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamMembersTable;
|
||||
65
src/app/(app)/settings/teams/[id]/TeamSettings.js
Normal file
65
src/app/(app)/settings/teams/[id]/TeamSettings.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Item, Loading, Tabs, useToasts } from 'react-basics';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import TeamEditForm from './TeamEditForm';
|
||||
import TeamMembers from './TeamMembers';
|
||||
import TeamWebsites from './TeamWebsites';
|
||||
|
||||
export function TeamSettings({ teamId }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { user } = useUser();
|
||||
const [values, setValues] = useState(null);
|
||||
const [tab, setTab] = useState('details');
|
||||
const { get, useQuery } = useApi();
|
||||
const { showToast } = useToasts();
|
||||
const { data, isLoading } = useQuery(
|
||||
['team', teamId],
|
||||
() => {
|
||||
if (teamId) {
|
||||
return get(`/teams/${teamId}`);
|
||||
}
|
||||
},
|
||||
{ cacheTime: 0 },
|
||||
);
|
||||
const canEdit = data?.teamUser?.find(
|
||||
({ userId, role }) => role === ROLES.teamOwner && userId === user.id,
|
||||
);
|
||||
|
||||
const handleSave = data => {
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
setValues(state => ({ ...state, ...data }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setValues(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading || !values) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={values?.name} />
|
||||
<Tabs selectedKey={tab} onSelect={setTab} style={{ marginBottom: 30 }}>
|
||||
<Item key="details">{formatMessage(labels.details)}</Item>
|
||||
<Item key="members">{formatMessage(labels.members)}</Item>
|
||||
<Item key="websites">{formatMessage(labels.websites)}</Item>
|
||||
</Tabs>
|
||||
{tab === 'details' && (
|
||||
<TeamEditForm teamId={teamId} data={values} onSave={handleSave} readOnly={!canEdit} />
|
||||
)}
|
||||
{tab === 'members' && <TeamMembers teamId={teamId} readOnly={!canEdit} />}
|
||||
{tab === 'websites' && <TeamWebsites teamId={teamId} readOnly={!canEdit} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamSettings;
|
||||
76
src/app/(app)/settings/teams/[id]/TeamWebsites.js
Normal file
76
src/app/(app)/settings/teams/[id]/TeamWebsites.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
ActionForm,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
Loading,
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
Text,
|
||||
useToasts,
|
||||
} from 'react-basics';
|
||||
import TeamWebsitesTable from './TeamWebsitesTable';
|
||||
import TeamAddWebsiteForm from './TeamAddWebsiteForm';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useApiFilter from 'components/hooks/useApiFilter';
|
||||
|
||||
export function TeamWebsites({ teamId }) {
|
||||
const { showToast } = useToasts();
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||
useApiFilter();
|
||||
const { get, useQuery } = useApi();
|
||||
const { data, isLoading, refetch } = useQuery(
|
||||
['teams:websites', teamId, filter, page, pageSize],
|
||||
() =>
|
||||
get(`/teams/${teamId}/websites`, {
|
||||
filter,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
);
|
||||
const hasData = data && data.length !== 0;
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading icon="dots" style={{ minHeight: 300 }} />;
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
await refetch();
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
};
|
||||
|
||||
const addButton = (
|
||||
<ModalTrigger>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.addWebsite)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.addWebsite)}>
|
||||
{close => <TeamAddWebsiteForm teamId={teamId} onSave={handleSave} onClose={close} />}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ActionForm description={formatMessage(messages.teamWebsitesInfo)}>{addButton}</ActionForm>
|
||||
{hasData && (
|
||||
<TeamWebsitesTable
|
||||
teamId={teamId}
|
||||
data={data}
|
||||
onSave={handleSave}
|
||||
onFilterChange={handleFilterChange}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
filterValue={filter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamWebsites;
|
||||
66
src/app/(app)/settings/teams/[id]/TeamWebsitesTable.js
Normal file
66
src/app/(app)/settings/teams/[id]/TeamWebsitesTable.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import useMessages from 'components/hooks/useMessages';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import Link from 'next/link';
|
||||
import { Button, Icon, Icons, Text } from 'react-basics';
|
||||
import TeamWebsiteRemoveButton from '../TeamWebsiteRemoveButton';
|
||||
import SettingsTable from 'components/common/SettingsTable';
|
||||
|
||||
export function TeamWebsitesTable({
|
||||
data = [],
|
||||
onSave,
|
||||
filterValue,
|
||||
onFilterChange,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
openExternal = false,
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const { user } = useUser();
|
||||
const columns = [
|
||||
{ name: 'name', label: formatMessage(labels.name) },
|
||||
{ name: 'domain', label: formatMessage(labels.domain) },
|
||||
{ name: 'action', label: ' ' },
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
showSearch={true}
|
||||
showPaging={true}
|
||||
onFilterChange={onFilterChange}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
filterValue={filterValue}
|
||||
>
|
||||
{row => {
|
||||
const { id: teamId, teamUser } = row.teamWebsite[0].team;
|
||||
const { id: websiteId, name, domain, userId } = row;
|
||||
const owner = teamUser[0];
|
||||
const canRemove = user.id === userId || user.id === owner.userId;
|
||||
|
||||
row.name = name;
|
||||
row.domain = domain;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link href={`/websites/${websiteId}`} target={openExternal ? '_blank' : null}>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.External />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.view)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
{canRemove && (
|
||||
<TeamWebsiteRemoveButton teamId={teamId} websiteId={websiteId} onSave={onSave} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SettingsTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamWebsitesTable;
|
||||
9
src/app/(app)/settings/teams/[id]/page.js
Normal file
9
src/app/(app)/settings/teams/[id]/page.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import TeamSettings from './TeamSettings';
|
||||
|
||||
export default function ({ params }) {
|
||||
if (process.env.cloudMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TeamSettings teamId={params.id} />;
|
||||
}
|
||||
15
src/app/(app)/settings/teams/page.js
Normal file
15
src/app/(app)/settings/teams/page.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import TeamsList from 'app/(app)/settings/teams/TeamsList';
|
||||
import TeamsHeader from './TeamsHeader';
|
||||
|
||||
export default function () {
|
||||
if (process.env.cloudMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TeamsHeader />
|
||||
<TeamsList />
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/app/(app)/settings/users/UserAddButton.js
Normal file
27
src/app/(app)/settings/users/UserAddButton.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Button, Icon, Text, Modal, Icons, ModalTrigger } from 'react-basics';
|
||||
import UserAddForm from './UserAddForm';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function UserAddButton({ onSave }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleSave = () => {
|
||||
onSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalTrigger>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.createUser)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.createUser)}>
|
||||
{close => <UserAddForm onSave={handleSave} onClose={close} />}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserAddButton;
|
||||
76
src/app/(app)/settings/users/UserAddForm.js
Normal file
76
src/app/(app)/settings/users/UserAddForm.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
Dropdown,
|
||||
Item,
|
||||
Form,
|
||||
FormRow,
|
||||
FormButtons,
|
||||
FormInput,
|
||||
TextField,
|
||||
PasswordField,
|
||||
SubmitButton,
|
||||
Button,
|
||||
} from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function UserAddForm({ onSave, onClose }) {
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error, isLoading } = useMutation(data => post(`/users`, data));
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave(data);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderValue = 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 (
|
||||
<Form onSubmit={handleSubmit} error={error}>
|
||||
<FormRow label={formatMessage(labels.username)}>
|
||||
<FormInput name="username" rules={{ required: formatMessage(labels.required) }}>
|
||||
<TextField autoComplete="new-username" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.password)}>
|
||||
<FormInput name="password" rules={{ required: formatMessage(labels.required) }}>
|
||||
<PasswordField autoComplete="new-password" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.role)}>
|
||||
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
|
||||
<Dropdown renderValue={renderValue}>
|
||||
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item>
|
||||
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item>
|
||||
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item>
|
||||
</Dropdown>
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="primary" disabled={false}>
|
||||
{formatMessage(labels.save)}
|
||||
</SubmitButton>
|
||||
<Button disabled={isLoading} onClick={onClose}>
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserAddForm;
|
||||
27
src/app/(app)/settings/users/UserDeleteButton.js
Normal file
27
src/app/(app)/settings/users/UserDeleteButton.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import UserDeleteForm from './UserDeleteForm';
|
||||
|
||||
export function UserDeleteButton({ userId, username, onDelete }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useUser();
|
||||
|
||||
return (
|
||||
<ModalTrigger disabled={userId === user?.id}>
|
||||
<Button disabled={userId === user?.id}>
|
||||
<Icon>
|
||||
<Icons.Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.deleteUser)}>
|
||||
{close => (
|
||||
<UserDeleteForm userId={userId} username={username} onSave={onDelete} onClose={close} />
|
||||
)}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserDeleteButton;
|
||||
37
src/app/(app)/settings/users/UserDeleteForm.js
Normal file
37
src/app/(app)/settings/users/UserDeleteForm.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Button, Form, FormButtons, SubmitButton } from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function UserDeleteForm({ userId, username, onSave, onClose }) {
|
||||
const { formatMessage, FormattedMessage, labels, messages } = useMessages();
|
||||
const { del } = useApi();
|
||||
const { mutate, error, isLoading } = useMutation(() => del(`/users/${userId}`));
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={error}>
|
||||
<p>
|
||||
<FormattedMessage {...messages.confirmDelete} values={{ target: <b>{username}</b> }} />
|
||||
</p>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="danger" disabled={isLoading}>
|
||||
{formatMessage(labels.delete)}
|
||||
</SubmitButton>
|
||||
<Button disabled={isLoading} onClick={onClose}>
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserDeleteForm;
|
||||
76
src/app/(app)/settings/users/UserEditForm.js
Normal file
76
src/app/(app)/settings/users/UserEditForm.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
Dropdown,
|
||||
Item,
|
||||
Form,
|
||||
FormRow,
|
||||
FormButtons,
|
||||
FormInput,
|
||||
TextField,
|
||||
SubmitButton,
|
||||
PasswordField,
|
||||
} from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function UserEditForm({ userId, data, onSave }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation(({ username, password, role }) =>
|
||||
post(`/users/${userId}`, { username, password, role }),
|
||||
);
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave(data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderValue = 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 (
|
||||
<Form onSubmit={handleSubmit} error={error} values={data} style={{ width: 300 }}>
|
||||
<FormRow label={formatMessage(labels.username)}>
|
||||
<FormInput name="username">
|
||||
<TextField />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.password)}>
|
||||
<FormInput
|
||||
name="password"
|
||||
rules={{
|
||||
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
|
||||
}}
|
||||
>
|
||||
<PasswordField autoComplete="new-password" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.role)}>
|
||||
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
|
||||
<Dropdown renderValue={renderValue}>
|
||||
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item>
|
||||
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item>
|
||||
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item>
|
||||
</Dropdown>
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary">{formatMessage(labels.save)}</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserEditForm;
|
||||
36
src/app/(app)/settings/users/UserWebsites.js
Normal file
36
src/app/(app)/settings/users/UserWebsites.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import Page from 'components/layout/Page';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import WebsitesTable from 'app/(app)/settings/websites/WebsitesTable';
|
||||
import useApiFilter from 'components/hooks/useApiFilter';
|
||||
|
||||
export function UserWebsites({ userId }) {
|
||||
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||
useApiFilter();
|
||||
const { get, useQuery } = useApi();
|
||||
const { data, isLoading, error } = useQuery(
|
||||
['user:websites', userId, filter, page, pageSize],
|
||||
() =>
|
||||
get(`/users/${userId}/websites`, {
|
||||
filter,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
);
|
||||
const hasData = data && data.length !== 0;
|
||||
|
||||
return (
|
||||
<Page loading={isLoading} error={error}>
|
||||
{hasData && (
|
||||
<WebsitesTable
|
||||
data={data}
|
||||
onFilterChange={handleFilterChange}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
filterValue={filter}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserWebsites;
|
||||
16
src/app/(app)/settings/users/UsersHeader.js
Normal file
16
src/app/(app)/settings/users/UsersHeader.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
'use client';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import UserAddButton from './UserAddButton';
|
||||
|
||||
export function UsersHeader({ onAdd }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<PageHeader title={formatMessage(labels.users)}>
|
||||
<UserAddButton onSave={onAdd} />
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersHeader;
|
||||
25
src/app/(app)/settings/users/UsersList.js
Normal file
25
src/app/(app)/settings/users/UsersList.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useFilterQuery from 'components/hooks/useFilterQuery';
|
||||
import DataTable from 'components/common/DataTable';
|
||||
import UsersTable from './UsersTable';
|
||||
import UsersHeader from './UsersHeader';
|
||||
|
||||
export function UsersList() {
|
||||
const { get } = useApi();
|
||||
const filterQuery = useFilterQuery(['users'], params => {
|
||||
return get(`/users`, {
|
||||
...params,
|
||||
});
|
||||
});
|
||||
const { getProps } = filterQuery;
|
||||
|
||||
return (
|
||||
<>
|
||||
<UsersHeader />
|
||||
<DataTable {...getProps()}>{({ data }) => <UsersTable data={data} />}</DataTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersList;
|
||||
57
src/app/(app)/settings/users/UsersTable.js
Normal file
57
src/app/(app)/settings/users/UsersTable.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Button, Text, Icon, Icons, GridTable, GridColumn, Flexbox } from 'react-basics';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import UserDeleteButton from './UserDeleteButton';
|
||||
|
||||
export function UsersTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { dateLocale } = useLocale();
|
||||
|
||||
return (
|
||||
<GridTable data={data}>
|
||||
<GridColumn
|
||||
name="username"
|
||||
label={formatMessage(labels.username)}
|
||||
width={'minmax(200px, 2fr)'}
|
||||
/>
|
||||
<GridColumn name="role" label={formatMessage(labels.role)}>
|
||||
{row =>
|
||||
formatMessage(
|
||||
labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown,
|
||||
)
|
||||
}
|
||||
</GridColumn>
|
||||
<GridColumn name="created" label={formatMessage(labels.created)}>
|
||||
{row =>
|
||||
formatDistance(new Date(row.createdAt), new Date(), {
|
||||
addSuffix: true,
|
||||
locale: dateLocale,
|
||||
})
|
||||
}
|
||||
</GridColumn>
|
||||
<GridColumn name="action" label=" " alignment="end">
|
||||
{row => {
|
||||
const { id, username } = row;
|
||||
return (
|
||||
<Flexbox gap={10}>
|
||||
<Link href={`/settings/users/${id}`}>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
<UserDeleteButton userId={id} username={username} />
|
||||
</Flexbox>
|
||||
);
|
||||
}}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersTable;
|
||||
61
src/app/(app)/settings/users/[id]/UserSettings.js
Normal file
61
src/app/(app)/settings/users/[id]/UserSettings.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Item, Loading, Tabs, useToasts } from 'react-basics';
|
||||
import UserEditForm from '../UserEditForm';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import UserWebsites from '../UserWebsites';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function UserSettings({ userId }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [values, setValues] = useState(null);
|
||||
const [tab, setTab] = useState('details');
|
||||
const { get, useQuery } = useApi();
|
||||
const { showToast } = useToasts();
|
||||
const { data, isLoading } = useQuery(
|
||||
['user', userId],
|
||||
() => {
|
||||
if (userId) {
|
||||
return get(`/users/${userId}`);
|
||||
}
|
||||
},
|
||||
{ cacheTime: 0 },
|
||||
);
|
||||
|
||||
const handleSave = data => {
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
if (data) {
|
||||
setValues(state => ({ ...state, ...data }));
|
||||
}
|
||||
|
||||
if (edit) {
|
||||
setEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setValues(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading || !values) {
|
||||
return <Loading size="lg" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={values?.username} />
|
||||
<Tabs selectedKey={tab} onSelect={setTab} style={{ marginBottom: 30, fontSize: 14 }}>
|
||||
<Item key="details">{formatMessage(labels.details)}</Item>
|
||||
<Item key="websites">{formatMessage(labels.websites)}</Item>
|
||||
</Tabs>
|
||||
{tab === 'details' && <UserEditForm userId={userId} data={values} onSave={handleSave} />}
|
||||
{tab === 'websites' && <UserWebsites userId={userId} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserSettings;
|
||||
9
src/app/(app)/settings/users/[id]/page.js
Normal file
9
src/app/(app)/settings/users/[id]/page.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import UserSettings from './UserSettings';
|
||||
|
||||
export default function ({ params }) {
|
||||
if (process.env.cloudMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UserSettings userId={params.id} />;
|
||||
}
|
||||
13
src/app/(app)/settings/users/page.tsx
Normal file
13
src/app/(app)/settings/users/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import UsersList from 'app/(app)/settings/users/UsersList';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function () {
|
||||
if (process.env.cloudMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UsersList />;
|
||||
}
|
||||
export const metadata: Metadata = {
|
||||
title: 'Users | umami',
|
||||
};
|
||||
29
src/app/(app)/settings/websites/WebsiteAddButton.js
Normal file
29
src/app/(app)/settings/websites/WebsiteAddButton.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Button, Icon, Icons, Modal, ModalTrigger, Text, useToasts } from 'react-basics';
|
||||
import WebsiteAddForm from './WebsiteAddForm';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function WebsiteAddButton({ onSave }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const handleSave = async () => {
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
onSave?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalTrigger>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.addWebsite)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.addWebsite)}>
|
||||
{close => <WebsiteAddForm onSave={handleSave} onClose={close} />}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteAddButton;
|
||||
58
src/app/(app)/settings/websites/WebsiteAddForm.js
Normal file
58
src/app/(app)/settings/websites/WebsiteAddForm.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
Form,
|
||||
FormRow,
|
||||
FormInput,
|
||||
FormButtons,
|
||||
TextField,
|
||||
Button,
|
||||
SubmitButton,
|
||||
} from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import { DOMAIN_REGEX } from 'lib/constants';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function WebsiteAddForm({ onSave, onClose }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error, isLoading } = useMutation(data => post('/websites', data));
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={error}>
|
||||
<FormRow label={formatMessage(labels.name)}>
|
||||
<FormInput name="name" rules={{ required: formatMessage(labels.required) }}>
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.domain)}>
|
||||
<FormInput
|
||||
name="domain"
|
||||
rules={{
|
||||
required: formatMessage(labels.required),
|
||||
pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
|
||||
}}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="primary" disabled={false}>
|
||||
{formatMessage(labels.save)}
|
||||
</SubmitButton>
|
||||
<Button disabled={isLoading} onClick={onClose}>
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteAddForm;
|
||||
84
src/app/(app)/settings/websites/WebsiteSettings.js
Normal file
84
src/app/(app)/settings/websites/WebsiteSettings.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Item, Tabs, useToasts, Button, Text, Icon, Icons } from 'react-basics';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import WebsiteEditForm from './[id]/WebsiteEditForm';
|
||||
import WebsiteData from './[id]/WebsiteData';
|
||||
import TrackingCode from './[id]/TrackingCode';
|
||||
import ShareUrl from './[id]/ShareUrl';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function WebsiteSettings({ websiteId, openExternal = false, analyticsUrl }) {
|
||||
const router = useRouter();
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { get, useQuery } = useApi();
|
||||
const { showToast } = useToasts();
|
||||
const { data } = useQuery(['website', websiteId], () => get(`/websites/${websiteId}`), {
|
||||
enabled: !!websiteId,
|
||||
cacheTime: 0,
|
||||
});
|
||||
const [values, setValues] = useState(null);
|
||||
const [tab, setTab] = useState('details');
|
||||
|
||||
const showSuccess = () => {
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
};
|
||||
|
||||
const handleSave = data => {
|
||||
showSuccess();
|
||||
setValues(state => ({ ...state, ...data }));
|
||||
};
|
||||
|
||||
const handleReset = async value => {
|
||||
if (value === 'delete') {
|
||||
await router.push('/settings/websites');
|
||||
} else if (value === 'reset') {
|
||||
showSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setValues(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={values?.name}>
|
||||
<Link href={`/websites/${websiteId}`} target={openExternal ? '_blank' : null}>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Icons.External />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.view)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Tabs selectedKey={tab} onSelect={setTab} style={{ marginBottom: 30 }}>
|
||||
<Item key="details">{formatMessage(labels.details)}</Item>
|
||||
<Item key="tracking">{formatMessage(labels.trackingCode)}</Item>
|
||||
<Item key="share">{formatMessage(labels.shareUrl)}</Item>
|
||||
<Item key="data">{formatMessage(labels.data)}</Item>
|
||||
</Tabs>
|
||||
{tab === 'details' && (
|
||||
<WebsiteEditForm websiteId={websiteId} data={values} onSave={handleSave} />
|
||||
)}
|
||||
{tab === 'tracking' && <TrackingCode websiteId={websiteId} analyticsUrl={analyticsUrl} />}
|
||||
{tab === 'share' && (
|
||||
<ShareUrl
|
||||
websiteId={websiteId}
|
||||
data={values}
|
||||
analyticsUrl={analyticsUrl}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
{tab === 'data' && <WebsiteData websiteId={websiteId} onSave={handleReset} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteSettings;
|
||||
16
src/app/(app)/settings/websites/WebsitesHeader.js
Normal file
16
src/app/(app)/settings/websites/WebsitesHeader.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
'use client';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import WebsiteAddButton from './WebsiteAddButton';
|
||||
|
||||
export function WebsitesHeader() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<PageHeader title={formatMessage(labels.websites)}>
|
||||
{!process.env.cloudMode && <WebsiteAddButton />}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsitesHeader;
|
||||
43
src/app/(app)/settings/websites/WebsitesList.js
Normal file
43
src/app/(app)/settings/websites/WebsitesList.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
'use client';
|
||||
import WebsitesTable from 'app/(app)/settings/websites/WebsitesTable';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import DataTable from 'components/common/DataTable';
|
||||
import useFilterQuery from 'components/hooks/useFilterQuery';
|
||||
import WebsitesHeader from './WebsitesHeader';
|
||||
|
||||
export function WebsitesList({
|
||||
showHeader = true,
|
||||
showEditButton = true,
|
||||
showTeam,
|
||||
includeTeams,
|
||||
onlyTeams,
|
||||
}) {
|
||||
const { user } = useUser();
|
||||
const { get } = useApi();
|
||||
const filterQuery = useFilterQuery(
|
||||
['websites', { includeTeams, onlyTeams }],
|
||||
params => {
|
||||
return get(`/users/${user?.id}/websites`, {
|
||||
includeTeams,
|
||||
onlyTeams,
|
||||
...params,
|
||||
});
|
||||
},
|
||||
{ enabled: !!user },
|
||||
);
|
||||
const { getProps } = filterQuery;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showHeader && <WebsitesHeader />}
|
||||
<DataTable {...getProps()}>
|
||||
{({ data }) => (
|
||||
<WebsitesTable data={data} showTeam={showTeam} showEditButton={showEditButton} />
|
||||
)}
|
||||
</DataTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsitesList;
|
||||
11
src/app/(app)/settings/websites/WebsitesList.module.css
Normal file
11
src/app/(app)/settings/websites/WebsitesList.module.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.website {
|
||||
padding-bottom: 30px;
|
||||
border-bottom: 1px solid var(--base300);
|
||||
margin-bottom: 30px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.website:last-child {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
59
src/app/(app)/settings/websites/WebsitesTable.js
Normal file
59
src/app/(app)/settings/websites/WebsitesTable.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import Link from 'next/link';
|
||||
import { Button, Text, Icon, Icons, GridTable, GridColumn, Flexbox } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
|
||||
export function WebsitesTable({ data = [], showTeam, showEditButton }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useUser();
|
||||
|
||||
return (
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="name" label={formatMessage(labels.name)} />
|
||||
<GridColumn name="domain" label={formatMessage(labels.domain)} />
|
||||
{showTeam && (
|
||||
<GridColumn name="teamName" label={formatMessage(labels.teamName)}>
|
||||
{row => row.teamWebsite[0]?.team.name}
|
||||
</GridColumn>
|
||||
)}
|
||||
{showTeam && (
|
||||
<GridColumn name="owner" label={formatMessage(labels.owner)}>
|
||||
{row => row.user.username}
|
||||
</GridColumn>
|
||||
)}
|
||||
<GridColumn name="action" label=" " alignment="end">
|
||||
{row => {
|
||||
const {
|
||||
id,
|
||||
user: { id: ownerId },
|
||||
} = row;
|
||||
|
||||
return (
|
||||
<Flexbox gap={10}>
|
||||
{showEditButton && (!showTeam || ownerId === user.id) && (
|
||||
<Link href={`/settings/websites/${id}`}>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={`/websites/${id}`}>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.External />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.view)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
</Flexbox>
|
||||
);
|
||||
}}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsitesTable;
|
||||
13
src/app/(app)/settings/websites/WebsitesTable.module.css
Normal file
13
src/app/(app)/settings/websites/WebsitesTable.module.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
@media screen and (max-width: 992px) {
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header .actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
92
src/app/(app)/settings/websites/[id]/ShareUrl.js
Normal file
92
src/app/(app)/settings/websites/[id]/ShareUrl.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import {
|
||||
Form,
|
||||
FormRow,
|
||||
FormButtons,
|
||||
Flexbox,
|
||||
TextField,
|
||||
SubmitButton,
|
||||
Button,
|
||||
Toggle,
|
||||
} from 'react-basics';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getRandomChars } from 'next-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
const generateId = () => getRandomChars(16);
|
||||
|
||||
export function ShareUrl({ websiteId, data, analyticsUrl, onSave }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { name, shareId } = data;
|
||||
const [id, setId] = useState(shareId);
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation(({ shareId }) =>
|
||||
post(`/websites/${websiteId}`, { shareId }),
|
||||
);
|
||||
const ref = useRef(null);
|
||||
const url = useMemo(
|
||||
() =>
|
||||
`${analyticsUrl || location.origin}${process.env.basePath}/share/${id}/${encodeURIComponent(
|
||||
name,
|
||||
)}`,
|
||||
[id, name],
|
||||
);
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave(data);
|
||||
ref.current.reset(data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
const id = generateId();
|
||||
ref.current.setValue('shareId', id, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setId(id);
|
||||
};
|
||||
|
||||
const handleCheck = checked => {
|
||||
const data = { shareId: checked ? generateId() : null };
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave(data);
|
||||
},
|
||||
});
|
||||
setId(data.shareId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id && id !== shareId) {
|
||||
ref.current.setValue('shareId', id);
|
||||
}
|
||||
}, [id, shareId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toggle checked={Boolean(id)} onChecked={handleCheck} style={{ marginBottom: 30 }}>
|
||||
{formatMessage(labels.enableShareUrl)}
|
||||
</Toggle>
|
||||
{id && (
|
||||
<Form key={websiteId} ref={ref} onSubmit={handleSubmit} error={error} values={data}>
|
||||
<FormRow>
|
||||
<p>{formatMessage(messages.shareUrl)}</p>
|
||||
<Flexbox gap={10}>
|
||||
<TextField value={url} readOnly allowCopy />
|
||||
<Button onClick={handleGenerate}>{formatMessage(labels.regenerate)}</Button>
|
||||
</Flexbox>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary">{formatMessage(labels.save)}</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShareUrl;
|
||||
26
src/app/(app)/settings/websites/[id]/TrackingCode.js
Normal file
26
src/app/(app)/settings/websites/[id]/TrackingCode.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { TextArea } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useConfig from 'components/hooks/useConfig';
|
||||
|
||||
export function TrackingCode({ websiteId, analyticsUrl }) {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
const config = useConfig();
|
||||
|
||||
const trackerScriptName =
|
||||
config?.trackerScriptName?.split(',')?.map(n => n.trim())?.[0] || 'script.js';
|
||||
|
||||
const url = trackerScriptName?.startsWith('http')
|
||||
? trackerScriptName
|
||||
: `${analyticsUrl || location.origin}${process.env.basePath}/${trackerScriptName}`;
|
||||
|
||||
const code = `<script async src="${url}" data-website-id="${websiteId}"></script>`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{formatMessage(messages.trackingCode)}</p>
|
||||
<TextArea rows={4} value={code} readOnly allowCopy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrackingCode;
|
||||
49
src/app/(app)/settings/websites/[id]/WebsiteData.js
Normal file
49
src/app/(app)/settings/websites/[id]/WebsiteData.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Button, Modal, ModalTrigger, ActionForm } from 'react-basics';
|
||||
import WebsiteDeleteForm from './WebsiteDeleteForm';
|
||||
import WebsiteResetForm from './WebsiteResetForm';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function WebsiteData({ websiteId, onSave }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
|
||||
const handleReset = async () => {
|
||||
onSave('reset');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
onSave('delete');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionForm
|
||||
label={formatMessage(labels.resetWebsite)}
|
||||
description={formatMessage(messages.resetWebsiteWarning)}
|
||||
>
|
||||
<ModalTrigger>
|
||||
<Button variant="secondary">{formatMessage(labels.reset)}</Button>
|
||||
<Modal title={formatMessage(labels.resetWebsite)}>
|
||||
{close => (
|
||||
<WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} />
|
||||
)}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
</ActionForm>
|
||||
<ActionForm
|
||||
label={formatMessage(labels.deleteWebsite)}
|
||||
description={formatMessage(messages.deleteWebsiteWarning)}
|
||||
>
|
||||
<ModalTrigger>
|
||||
<Button variant="danger">{formatMessage(labels.delete)}</Button>
|
||||
<Modal title={formatMessage(labels.deleteWebsite)}>
|
||||
{close => (
|
||||
<WebsiteDeleteForm websiteId={websiteId} onSave={handleDelete} onClose={close} />
|
||||
)}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
</ActionForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteData;
|
||||
50
src/app/(app)/settings/websites/[id]/WebsiteDeleteForm.js
Normal file
50
src/app/(app)/settings/websites/[id]/WebsiteDeleteForm.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormRow,
|
||||
FormButtons,
|
||||
FormInput,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
} from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
const CONFIRM_VALUE = 'DELETE';
|
||||
|
||||
export function WebsiteDeleteForm({ websiteId, onSave, onClose }) {
|
||||
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
|
||||
const { del, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation(data => del(`/websites/${websiteId}`, data));
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={error}>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.deleteWebsite}
|
||||
values={{ confirmation: <b>{CONFIRM_VALUE}</b> }}
|
||||
/>
|
||||
</p>
|
||||
<FormRow label={formatMessage(labels.confirm)}>
|
||||
<FormInput name="confirmation" rules={{ validate: value => value === CONFIRM_VALUE }}>
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="danger">{formatMessage(labels.delete)}</SubmitButton>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteDeleteForm;
|
||||
53
src/app/(app)/settings/websites/[id]/WebsiteEditForm.js
Normal file
53
src/app/(app)/settings/websites/[id]/WebsiteEditForm.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { SubmitButton, Form, FormInput, FormRow, FormButtons, TextField } from 'react-basics';
|
||||
import { useRef } from 'react';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import { DOMAIN_REGEX } from 'lib/constants';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function WebsiteEditForm({ websiteId, data, onSave }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation(data => post(`/websites/${websiteId}`, data));
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
ref.current.reset(data);
|
||||
onSave(data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form ref={ref} onSubmit={handleSubmit} error={error} values={data}>
|
||||
<FormRow label={formatMessage(labels.websiteId)}>
|
||||
<TextField value={websiteId} readOnly allowCopy />
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.name)}>
|
||||
<FormInput name="name" rules={{ required: formatMessage(labels.required) }}>
|
||||
<TextField />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.domain)}>
|
||||
<FormInput
|
||||
name="domain"
|
||||
rules={{
|
||||
required: formatMessage(labels.required),
|
||||
pattern: {
|
||||
value: DOMAIN_REGEX,
|
||||
message: formatMessage(messages.invalidDomain),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TextField />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary">{formatMessage(labels.save)}</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteEditForm;
|
||||
50
src/app/(app)/settings/websites/[id]/WebsiteResetForm.js
Normal file
50
src/app/(app)/settings/websites/[id]/WebsiteResetForm.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormRow,
|
||||
FormButtons,
|
||||
FormInput,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
} from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
const CONFIRM_VALUE = 'RESET';
|
||||
|
||||
export function WebsiteResetForm({ websiteId, onSave, onClose }) {
|
||||
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation(data => post(`/websites/${websiteId}/reset`, data));
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={error}>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.resetWebsite}
|
||||
values={{ confirmation: <b>{CONFIRM_VALUE}</b> }}
|
||||
/>
|
||||
</p>
|
||||
<FormRow label={formatMessage(labels.confirm)}>
|
||||
<FormInput name="confirm" rules={{ validate: value => value === CONFIRM_VALUE }}>
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="danger">{formatMessage(labels.reset)}</SubmitButton>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteResetForm;
|
||||
15
src/app/(app)/settings/websites/[id]/page.js
Normal file
15
src/app/(app)/settings/websites/[id]/page.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import WebsiteSettings from '../WebsiteSettings';
|
||||
|
||||
async function getDisabled() {
|
||||
return !!process.env.CLOUD_MODE;
|
||||
}
|
||||
|
||||
export default async function WebsiteSettingsPage({ params }) {
|
||||
const disabled = await getDisabled();
|
||||
|
||||
if (!params.id || disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WebsiteSettings websiteId={params.id} />;
|
||||
}
|
||||
9
src/app/(app)/settings/websites/page.js
Normal file
9
src/app/(app)/settings/websites/page.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import WebsitesList from 'app/(app)/settings/websites/WebsitesList';
|
||||
|
||||
export default function () {
|
||||
if (process.env.cloudMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WebsitesList />;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue