Merge branch 'dev' into session-recording
Some checks failed
Node.js CI / build (push) Has been cancelled

This commit is contained in:
Mike Cao 2026-02-16 09:20:39 -08:00
commit d349c3aea9
381 changed files with 24834 additions and 139358 deletions

View file

@ -4,6 +4,7 @@ import Script from 'next/script';
import { useEffect } from 'react';
import { MobileNav } from '@/app/(main)/MobileNav';
import { SideNav } from '@/app/(main)/SideNav';
import { TopNav } from '@/app/(main)/TopNav';
import { useConfig, useLoginQuery, useNavigation } from '@/components/hooks';
import { LAST_TEAM_CONFIG } from '@/lib/constants';
import { removeItem, setItem } from '@/lib/storage';
@ -46,11 +47,12 @@ export function App({ children }) {
<Row display={{ base: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
<MobileNav />
</Row>
<Column display={{ base: 'none', lg: 'flex' }}>
<Column display={{ base: 'none', lg: 'flex' }} minHeight="0" style={{ overflow: 'hidden' }}>
<SideNav />
</Column>
<Column alignItems="center" overflowY="auto" overflowX="hidden" position="relative">
{children}
<Column overflowX="hidden" minHeight="0" position="relative">
<TopNav />
<Column alignItems="center">{children}</Column>
</Column>
<UpdateNotice user={user} config={config} />
{process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (

View file

@ -1,37 +1,44 @@
import { Grid, Row, Text } from '@umami/react-zen';
import { Column, Grid, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
import { IconLabel } from '@/components/common/IconLabel';
import { useMessages, useNavigation } from '@/components/hooks';
import { Globe, Grid2x2, LinkIcon } from '@/components/icons';
import { Globe, Grid2x2, LayoutDashboard, LinkIcon } from '@/components/icons';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { NavButton } from '@/components/input/NavButton';
import { UserButton } from '@/components/input/UserButton';
import { Logo } from '@/components/svg';
import { AdminNav } from './admin/AdminNav';
import { SettingsNav } from './settings/SettingsNav';
export function MobileNav() {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { pathname, websiteId, renderUrl } = useNavigation();
const isAdmin = pathname.includes('/admin');
const isSettings = pathname.includes('/settings');
const isMain = !websiteId && !isAdmin && !isSettings;
const links = [
{
id: 'boards',
label: t(labels.boards),
path: '/boards',
icon: <LayoutDashboard />,
},
{
id: 'websites',
label: formatMessage(labels.websites),
label: t(labels.websites),
path: '/websites',
icon: <Globe />,
},
{
id: 'links',
label: formatMessage(labels.links),
label: t(labels.links),
path: '/links',
icon: <LinkIcon />,
},
{
id: 'pixels',
label: formatMessage(labels.pixels),
label: t(labels.pixels),
path: '/pixels',
icon: <Grid2x2 />,
},
@ -42,21 +49,24 @@ export function MobileNav() {
<MobileMenuButton>
{({ close }) => {
return (
<>
<Row padding="3" onClick={close} border="bottom">
<NavButton />
{links.map(link => {
<Column gap="2" display="flex" flex-direction="column" height="100vh" padding="1">
{isMain &&
links.map(link => {
return (
<Link key={link.id} href={renderUrl(link.path)}>
<IconLabel icon={link.icon} label={link.label} />
</Link>
<Row key={link.id} padding>
<Link href={renderUrl(link.path)} onClick={close}>
<IconLabel icon={link.icon} label={link.label} />
</Link>
</Row>
);
})}
</Row>
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
{isAdmin && <AdminNav onItemClick={close} />}
{isSettings && <SettingsNav onItemClick={close} />}
</>
<Row onClick={close} style={{ marginTop: 'auto' }}>
<UserButton />
</Row>
</Column>
);
}}
</MobileMenuButton>

View file

@ -6,67 +6,76 @@ import {
Icon,
Row,
Text,
ThemeButton,
Tooltip,
TooltipTrigger,
} from '@umami/react-zen';
import Link from 'next/link';
import type { Key } from 'react';
import { SettingsNav } from '@/app/(main)/settings/SettingsNav';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
import { IconLabel } from '@/components/common/IconLabel';
import { useGlobalState, useMessages, useNavigation } from '@/components/hooks';
import { Globe, Grid2x2, LayoutDashboard, LinkIcon, PanelLeft } from '@/components/icons';
import { LanguageButton } from '@/components/input/LanguageButton';
import { NavButton } from '@/components/input/NavButton';
import {
Globe,
Grid2x2,
LayoutDashboard,
LinkIcon,
PanelLeft,
PanelsLeftBottom,
} from '@/components/icons';
import { UserButton } from '@/components/input/UserButton';
import { Logo } from '@/components/svg';
export function SideNav(props: any) {
const { formatMessage, labels } = useMessages();
const { pathname, renderUrl, websiteId, router } = useNavigation();
const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed', false);
const hasNav = !!(websiteId || pathname.startsWith('/admin') || pathname.includes('/settings'));
const { t, labels } = useMessages();
const { pathname, renderUrl, websiteId, teamId } = useNavigation();
const [isCollapsed] = useGlobalState('sidenav-collapsed', false);
const links = [
...(!teamId
? [
{
id: 'dashboard',
label: t(labels.dashboard),
path: '/dashboard',
icon: <PanelsLeftBottom />,
},
]
: []),
{
id: 'boards',
label: formatMessage(labels.boards),
label: t(labels.boards),
path: '/boards',
icon: <LayoutDashboard />,
},
{
id: 'websites',
label: formatMessage(labels.websites),
label: t(labels.websites),
path: '/websites',
icon: <Globe />,
},
{
id: 'links',
label: formatMessage(labels.links),
label: t(labels.links),
path: '/links',
icon: <LinkIcon />,
},
{
id: 'pixels',
label: formatMessage(labels.pixels),
label: t(labels.pixels),
path: '/pixels',
icon: <Grid2x2 />,
},
];
const handleSelect = (id: Key) => {
router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`);
};
return (
<Column
{...props}
backgroundColor="surface-base"
justifyContent="space-between"
border
borderRadius
paddingX="2"
height="100%"
flexGrow="1"
minHeight="0"
margin="2"
style={{
width: isCollapsed ? '55px' : '240px',
@ -74,27 +83,26 @@ export function SideNav(props: any) {
overflow: 'hidden',
}}
>
<Column style={{ minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
<Row
alignItems="center"
justifyContent="space-between"
height="60px"
style={{ flexShrink: 0 }}
>
<Row paddingX="3" alignItems="center" justifyContent="space-between" flexGrow={1}>
{!isCollapsed && (
<IconLabel icon={<Logo />}>
<Text weight="bold">umami</Text>
</IconLabel>
)}
<PanelButton />
</Row>
</Row>
<Row marginBottom="4" style={{ flexShrink: 0 }}>
<NavButton showText={!isCollapsed} onAction={handleSelect} />
<Row
alignItems="center"
justifyContent="space-between"
height="60px"
style={{ flexShrink: 0 }}
>
<Row paddingX="3" alignItems="center" justifyContent="space-between" flexGrow="1">
{!isCollapsed && (
<IconLabel icon={<Logo />}>
<Text weight="bold">umami</Text>
</IconLabel>
)}
<PanelButton />
</Row>
</Row>
<Column flexGrow="1" minHeight="0" style={{ overflowY: 'auto', overflowX: 'hidden' }}>
{websiteId ? (
<WebsiteNav websiteId={websiteId} isCollapsed={isCollapsed} />
) : pathname.includes('/settings') ? (
<SettingsNav isCollapsed={isCollapsed} />
) : (
<Column gap="2">
{links.map(({ id, path, label, icon }) => {
@ -126,9 +134,8 @@ export function SideNav(props: any) {
</Column>
)}
</Column>
<Row alignItems="center" justifyContent="center" wrap="wrap" marginBottom="4" gap>
<LanguageButton />
<ThemeButton />
<Row marginBottom="4" style={{ flexShrink: 0 }}>
<UserButton showText={!isCollapsed} />
</Row>
</Column>
);

View file

@ -1,31 +1,110 @@
import { Row, ThemeButton } from '@umami/react-zen';
import { LanguageButton } from '@/components/input/LanguageButton';
import { ProfileButton } from '@/components/input/ProfileButton';
'use client';
import { Icon, Row } from '@umami/react-zen';
import { useNavigation } from '@/components/hooks';
import { Slash } from '@/components/icons';
import { BoardSelect } from '@/components/input/BoardSelect';
import { LinkSelect } from '@/components/input/LinkSelect';
import { PixelSelect } from '@/components/input/PixelSelect';
import { TeamsButton } from '@/components/input/TeamsButton';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
export function TopNav() {
const { websiteId, linkId, pixelId, boardId, teamId, router, renderUrl } = useNavigation();
const handleWebsiteChange = (value: string) => {
router.push(renderUrl(`/websites/${value}`));
};
const handleLinkChange = (value: string) => {
router.push(renderUrl(`/links/${value}`));
};
const handlePixelChange = (value: string) => {
router.push(renderUrl(`/pixels/${value}`));
};
const handleBoardChange = (value: string) => {
router.push(renderUrl(`/boards/${value}`));
};
return (
<Row
position="absolute"
position="sticky"
top="0"
alignItems="center"
justifyContent="flex-end"
justifyContent="flex-start"
paddingY="2"
paddingX="3"
paddingRight="5"
width="100%"
style={{ position: 'sticky', top: 0 }}
zIndex={1}
zIndex={100}
backgroundColor="surface-raised"
>
<Row
alignItems="center"
justifyContent="flex-end"
backgroundColor="surface-raised"
borderRadius
>
<ThemeButton />
<LanguageButton />
<ProfileButton />
<Row alignItems="center">
<TeamsButton />
{(websiteId || linkId || pixelId || boardId) && (
<>
<Icon size="sm" color="muted" style={{ opacity: 0.7, margin: '0 6px' }}>
<Slash />
</Icon>
{websiteId && (
<WebsiteSelect
websiteId={websiteId}
teamId={teamId}
onChange={handleWebsiteChange}
buttonProps={{
variant: 'quiet',
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
}}
/>
)}
{linkId && (
<LinkSelect
linkId={linkId}
teamId={teamId}
onChange={handleLinkChange}
buttonProps={{
variant: 'quiet',
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
}}
/>
)}
{pixelId && (
<PixelSelect
pixelId={pixelId}
teamId={teamId}
onChange={handlePixelChange}
buttonProps={{
variant: 'quiet',
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
}}
/>
)}
{boardId && (
<BoardSelect
boardId={boardId}
teamId={teamId}
onChange={handleBoardChange}
buttonProps={{
variant: 'quiet',
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
}}
/>
)}
</>
)}
</Row>
<div
style={{
position: 'absolute',
bottom: -16,
left: 0,
right: 0,
height: 16,
background: 'linear-gradient(to bottom, var(--surface-raised), transparent)',
pointerEvents: 'none',
}}
/>
</Row>
);
}

View file

@ -7,7 +7,7 @@ import { setItem } from '@/lib/storage';
import { checkVersion, useVersion } from '@/store/version';
export function UpdateNotice({ user, config }) {
const { formatMessage, labels, messages } = useMessages();
const { t, labels, messages } = useMessages();
const { latest, checked, hasUpdate, releaseUrl } = useVersion();
const pathname = usePathname();
const [dismissed, setDismissed] = useState(checked);
@ -49,11 +49,11 @@ export function UpdateNotice({ user, config }) {
return (
<Column justifyContent="center" alignItems="center" position="fixed" top="10px" width="100%">
<Row width="600px">
<AlertBanner title={formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}>
<AlertBanner title={t(messages.newVersionAvailable, { version: `v${latest}` })}>
<Button variant="primary" onPress={handleViewClick}>
{formatMessage(labels.viewDetails)}
{t(labels.viewDetails)}
</Button>
<Button onPress={handleDismissClick}>{formatMessage(labels.dismiss)}</Button>
<Button onPress={handleDismissClick}>{t(labels.dismiss)}</Button>
</AlertBanner>
</Row>
</Column>

View file

@ -19,7 +19,6 @@ export function AdminLayout({ children }: { children: ReactNode }) {
width="240px"
height="100%"
border="right"
backgroundColor
marginRight="2"
padding="3"
>

View file

@ -3,28 +3,28 @@ import { useMessages, useNavigation } from '@/components/hooks';
import { Globe, User, Users } from '@/components/icons';
export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { pathname } = useNavigation();
const items = [
{
label: formatMessage(labels.manage),
label: t(labels.manage),
items: [
{
id: 'users',
label: formatMessage(labels.users),
label: t(labels.users),
path: '/admin/users',
icon: <User />,
},
{
id: 'websites',
label: formatMessage(labels.websites),
label: t(labels.websites),
path: '/admin/websites',
icon: <Globe />,
},
{
id: 'teams',
label: formatMessage(labels.teams),
label: t(labels.teams),
path: '/admin/teams',
icon: <Users />,
},
@ -39,7 +39,7 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
return (
<NavMenu
items={items}
title={formatMessage(labels.admin)}
title={t(labels.admin)}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}

View file

@ -7,13 +7,13 @@ import { TeamsAddButton } from '../../teams/TeamsAddButton';
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
export function AdminTeamsPage() {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const handleSave = () => {};
return (
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.teams)}>
<PageHeader title={t(labels.teams)}>
<TeamsAddButton onSave={handleSave} isAdmin={true} />
</PageHeader>
<Panel>

View file

@ -14,22 +14,22 @@ export function AdminTeamsTable({
data: any[];
showActions?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const [deleteTeam, setDeleteTeam] = useState(null);
return (
<>
<DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)} width="1fr">
<DataColumn id="name" label={t(labels.name)} width="1fr">
{(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>}
</DataColumn>
<DataColumn id="websites" label={formatMessage(labels.members)} width="140px">
<DataColumn id="websites" label={t(labels.members)} width="140px">
{(row: any) => row?._count?.members}
</DataColumn>
<DataColumn id="members" label={formatMessage(labels.websites)} width="140px">
<DataColumn id="members" label={t(labels.websites)} width="140px">
{(row: any) => row?._count?.websites}
</DataColumn>
<DataColumn id="owner" label={formatMessage(labels.owner)}>
<DataColumn id="owner" label={t(labels.owner)}>
{(row: any) => {
const name = row?.members?.[0]?.user?.username;
@ -40,7 +40,7 @@ export function AdminTeamsTable({
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)} width="160px">
<DataColumn id="created" label={t(labels.created)} width="160px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
@ -55,7 +55,7 @@ export function AdminTeamsTable({
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
<Text>{t(labels.edit)}</Text>
</Row>
</MenuItem>
<MenuItem
@ -67,7 +67,7 @@ export function AdminTeamsTable({
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
<Text>{t(labels.delete)}</Text>
</Row>
</MenuItem>
</MenuButton>

View file

@ -4,12 +4,12 @@ import { Plus } from '@/components/icons';
import { UserAddForm } from './UserAddForm';
export function UserAddButton({ onSave }: { onSave?: () => void }) {
const { formatMessage, labels, messages } = useMessages();
const { t, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = () => {
toast(formatMessage(messages.saved));
toast(t(messages.saved));
touch('users');
onSave?.();
};
@ -20,10 +20,10 @@ export function UserAddButton({ onSave }: { onSave?: () => void }) {
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.createUser)}</Text>
<Text>{t(labels.createUser)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.createUser)} style={{ width: 400 }}>
<Dialog title={t(labels.createUser)} style={{ width: 400 }}>
{({ close }) => <UserAddForm onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>

View file

@ -10,12 +10,11 @@ import {
TextField,
} from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { messages } from '@/components/messages';
import { ROLES } from '@/lib/constants';
export function UserAddForm({ onSave, onClose }) {
const { mutateAsync, error, isPending } = useUpdateQuery(`/users`);
const { formatMessage, labels, getErrorMessage } = useMessages();
const { t, labels, messages, getErrorMessage } = useMessages();
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
@ -29,45 +28,41 @@ export function UserAddForm({ onSave, onClose }) {
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField
label={formatMessage(labels.username)}
label={t(labels.username)}
name="username"
rules={{ required: formatMessage(labels.required) }}
rules={{ required: t(labels.required) }}
>
<TextField autoComplete="new-username" data-test="input-username" />
</FormField>
<FormField
label={formatMessage(labels.password)}
label={t(labels.password)}
name="password"
rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
required: t(labels.required),
minLength: { value: 8, message: t(messages.minPasswordLength, { n: '8' }) },
}}
>
<PasswordField autoComplete="new-password" data-test="input-password" />
</FormField>
<FormField
label={formatMessage(labels.role)}
name="role"
rules={{ required: formatMessage(labels.required) }}
>
<FormField label={t(labels.role)} name="role" rules={{ required: t(labels.required) }}>
<Select>
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
{formatMessage(labels.viewOnly)}
{t(labels.viewOnly)}
</ListItem>
<ListItem id={ROLES.user} data-test="dropdown-item-user">
{formatMessage(labels.user)}
{t(labels.user)}
</ListItem>
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
{formatMessage(labels.admin)}
{t(labels.admin)}
</ListItem>
</Select>
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
{t(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={false}>
{formatMessage(labels.save)}
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>

View file

@ -12,7 +12,7 @@ export function UserDeleteButton({
username: string;
onDelete?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { user } = useLoginQuery();
return (
@ -21,10 +21,10 @@ export function UserDeleteButton({
<Icon size="sm">
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
<Text>{t(labels.delete)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.deleteUser)} style={{ width: 400 }}>
<Dialog title={t(labels.deleteUser)} style={{ width: 400 }}>
{({ close }) => (
<UserDeleteForm userId={userId} username={username} onSave={onDelete} onClose={close} />
)}

View file

@ -12,7 +12,7 @@ export function UserDeleteForm({
onSave?: () => void;
onClose?: () => void;
}) {
const { messages, labels, formatMessage } = useMessages();
const { messages, labels, t } = useMessages();
const { mutateAsync } = useDeleteQuery(`/users/${userId}`);
const { touch } = useModified();
@ -29,13 +29,13 @@ export function UserDeleteForm({
return (
<AlertDialog
title={formatMessage(labels.delete)}
title={t(labels.delete)}
onConfirm={handleConfirm}
onCancel={onClose}
confirmLabel={formatMessage(labels.delete)}
confirmLabel={t(labels.delete)}
isDanger
>
<Row gap="1">{formatMessage(messages.confirmDelete, { target: username })}</Row>
<Row gap="1">{t(messages.confirmDelete, { target: username })}</Row>
</AlertDialog>
);
}

View file

@ -7,13 +7,13 @@ import { UserAddButton } from './UserAddButton';
import { UsersDataTable } from './UsersDataTable';
export function UsersPage() {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const handleSave = () => {};
return (
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.users)}>
<PageHeader title={t(labels.users)}>
<UserAddButton onSave={handleSave} />
</PageHeader>
<Panel>

View file

@ -15,26 +15,24 @@ export function UsersTable({
data: any[];
showActions?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const [deleteUser, setDeleteUser] = useState(null);
return (
<>
<DataTable data={data}>
<DataColumn id="username" label={formatMessage(labels.username)} width="2fr">
<DataColumn id="username" label={t(labels.username)} width="2fr">
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
</DataColumn>
<DataColumn id="role" label={formatMessage(labels.role)}>
<DataColumn id="role" label={t(labels.role)}>
{(row: any) =>
formatMessage(
labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown,
)
t(labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown)
}
</DataColumn>
<DataColumn id="websites" label={formatMessage(labels.websites)}>
<DataColumn id="websites" label={t(labels.websites)}>
{(row: any) => row._count.websites}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)}>
<DataColumn id="created" label={t(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
@ -49,7 +47,7 @@ export function UsersTable({
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
<Text>{t(labels.edit)}</Text>
</Row>
</MenuItem>
<MenuItem
@ -61,7 +59,7 @@ export function UsersTable({
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
<Text>{t(labels.delete)}</Text>
</Row>
</MenuItem>
</MenuButton>

View file

@ -12,7 +12,7 @@ import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/component
import { ROLES } from '@/lib/constants';
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
const { formatMessage, labels, messages, getMessage } = useMessages();
const { t, labels, messages, getMessage } = useMessages();
const user = useUser();
const { user: login } = useLoginQuery();
@ -21,7 +21,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
toast(t(messages.saved));
touch('users');
touch(`user:${user.id}`);
onSave?.();
@ -31,41 +31,37 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
return (
<Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}>
<FormField name="username" label={formatMessage(labels.username)}>
<FormField name="username" label={t(labels.username)}>
<TextField data-test="input-username" />
</FormField>
<FormField
name="password"
label={formatMessage(labels.password)}
label={t(labels.password)}
rules={{
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
minLength: { value: 8, message: t(messages.minPasswordLength, { n: '8' }) },
}}
>
<PasswordField autoComplete="new-password" data-test="input-password" />
</FormField>
{user.id !== login.id && (
<FormField
name="role"
label={formatMessage(labels.role)}
rules={{ required: formatMessage(labels.required) }}
>
<FormField name="role" label={t(labels.role)} rules={{ required: t(labels.required) }}>
<Select defaultValue={user.role}>
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
{formatMessage(labels.viewOnly)}
{t(labels.viewOnly)}
</ListItem>
<ListItem id={ROLES.user} data-test="dropdown-item-user">
{formatMessage(labels.user)}
{t(labels.user)}
</ListItem>
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
{formatMessage(labels.admin)}
{t(labels.admin)}
</ListItem>
</Select>
</FormField>
)}
<FormButtons>
<FormSubmitButton data-test="button-submit" variant="primary">
{formatMessage(labels.save)}
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>

View file

@ -4,14 +4,14 @@ import { UserEditForm } from './UserEditForm';
import { UserWebsites } from './UserWebsites';
export function UserSettings({ userId }: { userId: string }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<Column gap="6">
<Tabs>
<TabList>
<Tab id="details">{formatMessage(labels.details)}</Tab>
<Tab id="websites">{formatMessage(labels.websites)}</Tab>
<Tab id="details">{t(labels.details)}</Tab>
<Tab id="websites">{t(labels.websites)}</Tab>
</TabList>
<TabPanel id="details" style={{ width: 500 }}>
<UserEditForm userId={userId} />

View file

@ -6,11 +6,11 @@ import { useMessages } from '@/components/hooks';
import { AdminWebsitesDataTable } from './AdminWebsitesDataTable';
export function AdminWebsitesPage() {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.websites)} />
<PageHeader title={t(labels.websites)} />
<Panel>
<AdminWebsitesDataTable />
</Panel>

View file

@ -8,23 +8,23 @@ import { Edit, Trash, Users } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const [deleteWebsite, setDeleteWebsite] = useState(null);
return (
<>
<DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)}>
<DataColumn id="name" label={t(labels.name)}>
{(row: any) => (
<Text truncate>
<Link href={`/admin/websites/${row.id}`}>{row.name}</Link>
</Text>
)}
</DataColumn>
<DataColumn id="domain" label={formatMessage(labels.domain)}>
<DataColumn id="domain" label={t(labels.domain)}>
{(row: any) => <Text truncate>{row.domain}</Text>}
</DataColumn>
<DataColumn id="owner" label={formatMessage(labels.owner)}>
<DataColumn id="owner" label={t(labels.owner)}>
{(row: any) => {
if (row?.team) {
return (
@ -45,7 +45,7 @@ export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)} width="180px">
<DataColumn id="created" label={t(labels.created)} width="180px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="50px">
@ -59,7 +59,7 @@ export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
<Text>{t(labels.edit)}</Text>
</Row>
</MenuItem>
<MenuItem
@ -71,7 +71,7 @@ export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
<Text>{t(labels.delete)}</Text>
</Row>
</MenuItem>
</MenuButton>

View file

@ -1,32 +0,0 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified, useNavigation } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { BoardAddForm } from './BoardAddForm';
export function BoardAddButton() {
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const { teamId } = useNavigation();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('boards');
};
return (
<DialogTrigger>
<Button data-test="button-board-add" variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addBoard)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addBoard)} style={{ width: 400 }}>
{({ close }) => <BoardAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,65 +0,0 @@
import { Button, Form, FormField, FormSubmitButton, Row, Text, TextField } from '@umami/react-zen';
import { useState } from 'react';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
export function BoardAddForm({
teamId,
onSave,
onClose,
}: {
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/boards', { teamId });
const [websiteId, setWebsiteId] = useState<string>();
const handleSubmit = async (data: any) => {
await mutateAsync(
{ type: 'board', ...data, parameters: { websiteId } },
{
onSuccess: async () => {
onSave?.();
onClose?.();
},
},
);
};
return (
<Form onSubmit={handleSubmit} error={error?.message}>
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
</FormField>
<FormField
label={formatMessage(labels.description)}
name="description"
rules={{
required: formatMessage(labels.required),
}}
>
<TextField asTextArea autoComplete="off" />
</FormField>
<Row alignItems="center" gap="3" paddingTop="3">
<Text>{formatMessage(labels.website)}</Text>
<WebsiteSelect websiteId={websiteId} teamId={teamId} onChange={setWebsiteId} />
</Row>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton data-test="button-submit" isDisabled={false}>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Form>
);
}

View file

@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid';
import { useApi, useMessages, useModified, useNavigation } from '@/components/hooks';
import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery';
import type { Board, BoardParameters } from '@/lib/types';
import { getComponentDefinition } from './boardComponentRegistry';
export type LayoutGetter = () => Partial<BoardParameters> | null;
@ -27,6 +28,29 @@ const createDefaultBoard = (): Partial<Board> => ({
},
});
function sanitizeBoardParameters(parameters?: BoardParameters): BoardParameters | undefined {
if (!parameters?.rows) {
return parameters;
}
return {
...parameters,
rows: parameters.rows.map(row => ({
...row,
columns: row.columns.map(column => {
if (column.component && !getComponentDefinition(column.component.type)) {
return {
...column,
component: null,
};
}
return column;
}),
})),
};
}
export function BoardProvider({
boardId,
editing = false,
@ -40,8 +64,8 @@ export function BoardProvider({
const { post, useMutation } = useApi();
const { touch } = useModified();
const { toast } = useToast();
const { formatMessage, labels, messages } = useMessages();
const { router, renderUrl } = useNavigation();
const { t, labels, messages } = useMessages();
const { router, renderUrl, teamId } = useNavigation();
const [board, setBoard] = useState<Partial<Board>>(data ?? createDefaultBoard());
const layoutGetterRef = useRef<LayoutGetter | null>(null);
@ -52,7 +76,10 @@ export function BoardProvider({
useEffect(() => {
if (data) {
setBoard(data);
setBoard({
...data,
parameters: sanitizeBoardParameters(data.parameters),
});
}
}, [data]);
@ -61,7 +88,7 @@ export function BoardProvider({
if (boardData.id) {
return post(`/boards/${boardData.id}`, boardData);
}
return post('/boards', { ...boardData, type: 'dashboard', slug: '' });
return post('/boards', { ...boardData, type: 'dashboard', slug: '', teamId });
},
});
@ -70,11 +97,13 @@ export function BoardProvider({
}, []);
const saveBoard = useCallback(async () => {
const defaultName = formatMessage(labels.untitled);
const defaultName = t(labels.untitled);
// Get current layout sizes from BoardBody if registered
// Get current layout sizes from BoardEditBody if registered
const layoutData = layoutGetterRef.current?.();
const parameters = layoutData ? { ...board.parameters, ...layoutData } : board.parameters;
const parameters = sanitizeBoardParameters(
layoutData ? { ...board.parameters, ...layoutData } : board.parameters,
);
const result = await mutateAsync({
...board,
@ -82,7 +111,7 @@ export function BoardProvider({
parameters,
});
toast(formatMessage(messages.saved));
toast(t(messages.saved));
touch('boards');
if (board.id) {
@ -92,17 +121,7 @@ export function BoardProvider({
}
return result;
}, [
board,
mutateAsync,
toast,
formatMessage,
labels.untitled,
messages.saved,
touch,
router,
renderUrl,
]);
}, [board, mutateAsync, toast, t, labels.untitled, messages.saved, touch, router, renderUrl]);
if (boardId && isFetching && isLoading) {
return <Loading placement="absolute" />;

View file

@ -5,19 +5,20 @@ import { LinkButton } from '@/components/common/LinkButton';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { useMessages, useNavigation } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { BoardsDataTable } from './BoardsDataTable';
export function BoardsPage() {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { renderUrl } = useNavigation();
return (
<PageBody>
<Column margin="2">
<PageHeader title={formatMessage(labels.boards)}>
<LinkButton href="/boards/create" variant="primary">
<IconLabel icon={<Plus />} label={formatMessage(labels.addBoard)} />
<PageHeader title={t(labels.boards)}>
<LinkButton href={renderUrl('/boards/create')} variant="primary">
<IconLabel icon={<Plus />} label={t(labels.addBoard)} />
</LinkButton>
</PageHeader>
<Panel>

View file

@ -4,19 +4,19 @@ import { DateDistance } from '@/components/common/DateDistance';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
export function BoardsTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { websiteId, renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('link');
return (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
<DataColumn id="name" label={t(labels.name)}>
{({ id, name }: any) => {
return <Board href={renderUrl(`/boards/${id}`)}>{name}</Board>;
}}
</DataColumn>
<DataColumn id="description" label={formatMessage(labels.description)} />
<DataColumn id="created" label={formatMessage(labels.created)} width="200px">
<DataColumn id="description" label={t(labels.description)} />
<DataColumn id="created" label={t(labels.created)} width="200px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="100px">

View file

@ -1,52 +0,0 @@
import { Box, Button, Column, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen';
import type { ReactElement } from 'react';
import { Plus, X } from '@/components/icons';
export function BoardColumn({
id,
component,
editing = false,
onRemove,
canRemove = true,
}: {
id: string;
component?: ReactElement;
editing?: boolean;
onRemove?: (id: string) => void;
canRemove?: boolean;
}) {
const handleAddComponent = () => {};
return (
<Column
marginTop="3"
marginLeft="3"
width="100%"
height="100%"
alignItems="center"
justifyContent="center"
backgroundColor="surface-sunken"
position="relative"
>
{editing && canRemove && (
<Box position="absolute" top="10px" right="20px" zIndex={100}>
<TooltipTrigger delay={0}>
<Button variant="quiet" onPress={() => onRemove?.(id)}>
<Icon size="sm">
<X />
</Icon>
</Button>
<Tooltip>Remove column</Tooltip>
</TooltipTrigger>
</Box>
)}
{editing && (
<Button variant="outline" onPress={handleAddComponent}>
<Icon>
<Plus />
</Icon>
</Button>
)}
</Column>
);
}

View file

@ -0,0 +1,42 @@
import { Column, Text } from '@umami/react-zen';
import { memo } from 'react';
import type { BoardComponentConfig } from '@/lib/types';
import { getComponentDefinition } from '../boardComponentRegistry';
function BoardComponentRendererComponent({
config,
websiteId,
}: {
config: BoardComponentConfig;
websiteId?: string;
}) {
const definition = getComponentDefinition(config.type);
if (!definition) {
return (
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
<Text color="muted">Unknown component: {config.type}</Text>
</Column>
);
}
const Component = definition.component;
if (!websiteId) {
return (
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
<Text color="muted">Select a website</Text>
</Column>
);
}
return <Component websiteId={websiteId} {...config.props} />;
}
export const BoardComponentRenderer = memo(
BoardComponentRendererComponent,
(prevProps, nextProps) =>
prevProps.websiteId === nextProps.websiteId && prevProps.config === nextProps.config,
);
BoardComponentRenderer.displayName = 'BoardComponentRenderer';

View file

@ -0,0 +1,280 @@
import {
Button,
Column,
Focusable,
ListItem,
Row,
Select,
Text,
TextField,
} from '@umami/react-zen';
import { useEffect, useMemo, useState } from 'react';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
import type { BoardComponentConfig } from '@/lib/types';
import {
CATEGORIES,
type ComponentDefinition,
type ConfigField,
getComponentsByCategory,
} from '../boardComponentRegistry';
import { BoardComponentRenderer } from './BoardComponentRenderer';
export function BoardComponentSelect({
teamId,
websiteId,
defaultWebsiteId,
initialConfig,
onSelect,
onClose,
}: {
teamId?: string;
websiteId?: string;
defaultWebsiteId?: string;
initialConfig?: BoardComponentConfig;
onSelect: (config: BoardComponentConfig) => void;
onClose: () => void;
}) {
const { t, labels, messages } = useMessages();
const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null);
const [configValues, setConfigValues] = useState<Record<string, any>>({});
const [selectedWebsiteId, setSelectedWebsiteId] = useState(
initialConfig?.websiteId || websiteId || defaultWebsiteId,
);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const allDefinitions = useMemo(
() => CATEGORIES.flatMap(category => getComponentsByCategory(category.key)),
[],
);
const getDefaultConfigValues = (def: ComponentDefinition, config?: BoardComponentConfig) => {
const defaults: Record<string, any> = {};
for (const field of def.configFields ?? []) {
defaults[field.name] = field.defaultValue;
}
if (def.defaultProps) {
Object.assign(defaults, def.defaultProps);
}
if (config?.props) {
Object.assign(defaults, config.props);
}
return defaults;
};
useEffect(() => {
if (!initialConfig) {
return;
}
const definition = allDefinitions.find(def => def.type === initialConfig.type);
if (!definition) {
return;
}
setSelectedDef(definition);
setConfigValues(getDefaultConfigValues(definition, initialConfig));
setSelectedWebsiteId(initialConfig.websiteId || websiteId || defaultWebsiteId);
setTitle(initialConfig.title ?? definition.name);
setDescription(initialConfig.description || '');
}, [initialConfig, allDefinitions, websiteId, defaultWebsiteId]);
const handleSelectComponent = (def: ComponentDefinition) => {
setSelectedDef(def);
setConfigValues(getDefaultConfigValues(def));
setTitle(def.name);
setDescription('');
};
const handleConfigChange = (name: string, value: any) => {
setConfigValues(prev => ({ ...prev, [name]: value }));
};
const handleAdd = () => {
if (!selectedDef) return;
const props: Record<string, any> = {};
if (selectedDef.defaultProps) {
Object.assign(props, selectedDef.defaultProps);
}
Object.assign(props, configValues);
for (const field of selectedDef.configFields ?? []) {
if (field.type === 'number' && props[field.name] != null && props[field.name] !== '') {
props[field.name] = Number(props[field.name]);
}
}
const config: BoardComponentConfig = {
type: selectedDef.type,
websiteId: selectedWebsiteId,
title,
description,
};
if (Object.keys(props).length > 0) {
config.props = props;
}
onSelect(config);
};
const previewConfig: BoardComponentConfig | null = selectedDef
? {
type: selectedDef.type,
title,
description,
props: { ...selectedDef.defaultProps, ...configValues },
}
: null;
return (
<Column gap="4">
<Row gap="4" style={{ height: 600 }}>
<Column gap="1" style={{ width: 280, flexShrink: 0, overflowY: 'auto' }}>
{CATEGORIES.map(category => {
const components = getComponentsByCategory(category.key);
return (
<Column key={category.key} gap="1" marginBottom="2">
<Text weight="bold">{category.name}</Text>
{components.map(def => (
<Focusable key={def.type}>
<Row
alignItems="center"
paddingX="3"
paddingY="2"
borderRadius
backgroundColor={
selectedDef?.type === def.type ? 'surface-sunken' : undefined
}
hover={{ backgroundColor: 'surface-sunken' }}
style={{ cursor: 'pointer' }}
onClick={() => handleSelectComponent(def)}
>
<Column>
<Text
size="sm"
weight={selectedDef?.type === def.type ? 'bold' : undefined}
>
{def.name}
</Text>
<Text size="xs" color="muted">
{def.description}
</Text>
</Column>
</Row>
</Focusable>
))}
</Column>
);
})}
</Column>
<Column gap="3" flexGrow={1} style={{ minWidth: 0 }}>
<Panel maxHeight="100%">
{previewConfig && selectedWebsiteId ? (
<BoardComponentRenderer config={previewConfig} websiteId={selectedWebsiteId} />
) : (
<Column alignItems="center" justifyContent="center" height="100%">
<Text color="muted">
{selectedWebsiteId
? t(messages.selectComponentPreview)
: t(messages.selectWebsiteFirst)}
</Text>
</Column>
)}
</Panel>
</Column>
<Column gap="3" style={{ width: 320, flexShrink: 0, overflowY: 'auto' }}>
<Text weight="bold">{t(labels.properties)}</Text>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.website)}
</Text>
<WebsiteSelect
websiteId={selectedWebsiteId}
teamId={teamId}
placeholder={t(labels.selectWebsite)}
onChange={setSelectedWebsiteId}
/>
</Column>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.title)}
</Text>
<TextField value={title} onChange={setTitle} autoComplete="off" />
</Column>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.description)}
</Text>
<TextField value={description} onChange={setDescription} autoComplete="off" />
</Column>
{selectedDef?.configFields && selectedDef.configFields.length > 0 && (
<Column gap="3">
{selectedDef.configFields.map((field: ConfigField) => (
<Column key={field.name} gap="2">
<Text size="sm" color="muted">
{field.label}
</Text>
{field.type === 'select' && (
<Select
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
>
{field.options?.map(option => (
<ListItem key={option.value} id={option.value}>
{option.label}
</ListItem>
))}
</Select>
)}
{field.type === 'text' && (
<TextField
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
/>
)}
{field.type === 'number' && (
<TextField
type="number"
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
/>
)}
</Column>
))}
</Column>
)}
</Column>
</Row>
<Row justifyContent="flex-end" gap="2" paddingTop="4">
<Button variant="quiet" onPress={onClose}>
{t(labels.cancel)}
</Button>
<Button variant="primary" onPress={handleAdd} isDisabled={!selectedDef}>
{t(labels.save)}
</Button>
</Row>
</Column>
);
}

View file

@ -0,0 +1,18 @@
import { Box } from '@umami/react-zen';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useBoard } from '@/components/hooks';
export function BoardControls() {
const { board } = useBoard();
const websiteId = board?.parameters?.websiteId;
if (!websiteId) {
return null;
}
return (
<Box marginBottom="4">
<WebsiteControls websiteId={websiteId} />
</Box>
);
}

View file

@ -1,19 +1,18 @@
import { Button, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { Box, Button, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { produce } from 'immer';
import { Fragment, useEffect, useRef } from 'react';
import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resizable-panels';
import { v4 as uuid } from 'uuid';
import { useBoard } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { BoardRow } from './BoardRow';
import { GripHorizontal, Plus } from '@/components/icons';
import { BoardEditRow } from './BoardEditRow';
import { BUTTON_ROW_HEIGHT, MAX_ROW_HEIGHT, MIN_ROW_HEIGHT } from './boardConstants';
export function BoardBody() {
const { board, editing, updateBoard, saveBoard, isPending, registerLayoutGetter } = useBoard();
export function BoardEditBody({ requiresBoardWebsite = true }: { requiresBoardWebsite?: boolean }) {
const { board, updateBoard, registerLayoutGetter } = useBoard();
const rowGroupRef = useRef<GroupImperativeHandle>(null);
const columnGroupRefs = useRef<Map<string, GroupImperativeHandle>>(new Map());
// Register a function to get current layout sizes on save
useEffect(() => {
registerLayoutGetter(() => {
const rows = board?.parameters?.rows;
@ -50,7 +49,7 @@ export function BoardBody() {
}
};
const handleAddRow = () => {
const handle = () => {
updateBoard({
parameters: produce(board.parameters, draft => {
if (!draft.rows) {
@ -103,48 +102,77 @@ export function BoardBody() {
});
};
const websiteId = board?.parameters?.websiteId;
const canEdit = requiresBoardWebsite ? !!websiteId : true;
const rows = board?.parameters?.rows ?? [];
const minHeight = (rows?.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT;
const minHeight = (rows.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT;
return (
<Group groupRef={rowGroupRef} orientation="vertical" style={{ minHeight }}>
{rows.map((row, index) => (
<Fragment key={row.id}>
<Panel
id={row.id}
minSize={MIN_ROW_HEIGHT}
maxSize={MAX_ROW_HEIGHT}
defaultSize={row.size}
>
<BoardRow
{...row}
rowId={row.id}
rowIndex={index}
rowCount={rows?.length}
editing={editing}
onRemove={handleRemoveRow}
onMoveUp={handleMoveRowUp}
onMoveDown={handleMoveRowDown}
onRegisterRef={registerColumnGroupRef}
/>
<Box minHeight={`${minHeight}px`}>
<Group groupRef={rowGroupRef} orientation="vertical">
{rows.map((row, index) => (
<Fragment key={`${row.id}:${row.size ?? 'auto'}`}>
<Panel
id={row.id}
minSize={MIN_ROW_HEIGHT}
maxSize={MAX_ROW_HEIGHT}
defaultSize={row.size != null ? `${row.size}%` : undefined}
>
<BoardEditRow
{...row}
rowId={row.id}
rowIndex={index}
rowCount={rows.length}
canEdit={canEdit}
onRemove={handleRemoveRow}
onMoveUp={handleMoveRowUp}
onMoveDown={handleMoveRowDown}
onRegisterRef={registerColumnGroupRef}
/>
</Panel>
{(index < rows.length - 1 || canEdit) && (
<Separator
style={{
height: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
outline: 'none',
boxShadow: 'none',
background: 'transparent',
}}
>
<Row
width="100%"
height="100%"
alignItems="center"
justifyContent="center"
style={{ cursor: 'row-resize' }}
>
<Icon size="sm">
<GripHorizontal />
</Icon>
</Row>
</Separator>
)}
</Fragment>
))}
{canEdit && (
<Panel minSize={BUTTON_ROW_HEIGHT}>
<Row paddingY="3">
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={handle}>
<Icon>
<Plus />
</Icon>
</Button>
<Tooltip placement="right">Add row</Tooltip>
</TooltipTrigger>
</Row>
</Panel>
{index < rows?.length - 1 && <Separator />}
</Fragment>
))}
{editing && (
<Panel minSize={BUTTON_ROW_HEIGHT}>
<Row padding="3">
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={handleAddRow}>
<Icon>
<Plus />
</Icon>
</Button>
<Tooltip placement="bottom">Add row</Tooltip>
</TooltipTrigger>
</Row>
</Panel>
)}
</Group>
)}
</Group>
</Box>
);
}

View file

@ -0,0 +1,143 @@
import {
Box,
Button,
Column,
Dialog,
Icon,
Modal,
Row,
Tooltip,
TooltipTrigger,
} from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { Panel } from '@/components/common/Panel';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
import { Pencil, Plus, X } from '@/components/icons';
import type { BoardComponentConfig } from '@/lib/types';
import { BoardComponentRenderer } from './BoardComponentRenderer';
import { BoardComponentSelect } from './BoardComponentSelect';
export function BoardEditColumn({
id,
component,
canEdit,
onRemove,
onSetComponent,
canRemove = true,
}: {
id: string;
component?: BoardComponentConfig;
canEdit: boolean;
onRemove: (id: string) => void;
onSetComponent: (id: string, config: BoardComponentConfig | null) => void;
canRemove?: boolean;
}) {
const [showSelect, setShowSelect] = useState(false);
const [showActions, setShowActions] = useState(false);
const { board } = useBoard();
const { t, labels } = useMessages();
const { teamId } = useNavigation();
const boardWebsiteId = board?.parameters?.websiteId;
const websiteId = component?.websiteId || boardWebsiteId;
const renderedComponent = useMemo(() => {
if (!component || !websiteId) {
return null;
}
return <BoardComponentRenderer config={component} websiteId={websiteId} />;
}, [component, websiteId]);
const handleSelect = (config: BoardComponentConfig) => {
onSetComponent(id, config);
setShowSelect(false);
};
const hasComponent = !!component;
const canRemoveAction = hasComponent || canRemove;
const title = component?.title;
const description = component?.description;
const handleRemove = () => {
if (hasComponent) {
onSetComponent(id, null);
} else {
onRemove(id);
}
};
return (
<Panel
title={title}
description={description}
width="100%"
height="100%"
position="relative"
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
{canEdit && canRemoveAction && showActions && (
<Box position="absolute" top="12px" right="12px" zIndex={100}>
<Row gap="1" padding="2" borderRadius backgroundColor="surface-sunken">
{hasComponent && (
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={() => setShowSelect(true)}>
<Icon size="sm">
<Pencil />
</Icon>
</Button>
<Tooltip>{t(labels.edit)}</Tooltip>
</TooltipTrigger>
)}
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={handleRemove} isDisabled={!canRemoveAction}>
<Icon size="sm">
<X />
</Icon>
</Button>
<Tooltip>{t(labels.remove)}</Tooltip>
</TooltipTrigger>
</Row>
</Box>
)}
{renderedComponent ? (
<Column width="100%" height="100%" style={{ minHeight: 0 }}>
<Box width="100%" flexGrow={1} overflow="auto" style={{ minHeight: 0 }}>
{renderedComponent}
</Box>
</Column>
) : (
canEdit && (
<Column width="100%" height="100%" alignItems="center" justifyContent="center">
<Button variant="outline" onPress={() => setShowSelect(true)}>
<Icon>
<Plus />
</Icon>
</Button>
</Column>
)
)}
<Modal isOpen={showSelect} onOpenChange={setShowSelect}>
<Dialog
title={t(labels.selectComponent)}
style={{
width: '1200px',
maxWidth: 'calc(100vw - 40px)',
maxHeight: 'calc(100dvh - 40px)',
padding: '32px',
}}
>
{() => (
<BoardComponentSelect
teamId={teamId}
websiteId={websiteId}
defaultWebsiteId={boardWebsiteId}
initialConfig={component}
onSelect={handleSelect}
onClose={() => setShowSelect(false)}
/>
)}
</Dialog>
</Modal>
</Panel>
);
}

View file

@ -13,9 +13,9 @@ import { WebsiteSelect } from '@/components/input/WebsiteSelect';
export function BoardEditHeader() {
const { board, updateBoard, saveBoard, isPending } = useBoard();
const { formatMessage, labels } = useMessages();
const { router, renderUrl } = useNavigation();
const defaultName = formatMessage(labels.untitled);
const { t, labels } = useMessages();
const { router, renderUrl, teamId } = useNavigation();
const defaultName = t(labels.untitled);
const handleNameChange = (value: string) => {
updateBoard({ name: value });
@ -71,7 +71,7 @@ export function BoardEditHeader() {
variant="quiet"
name="description"
value={board?.description ?? ''}
placeholder={`+ ${formatMessage(labels.addDescription)}`}
placeholder={`+ ${t(labels.addDescription)}`}
autoComplete="off"
onChange={handleDescriptionChange}
style={{ width: '100%' }}
@ -80,17 +80,21 @@ export function BoardEditHeader() {
</TextField>
</Row>
<Row alignItems="center" gap="3">
<Text>{formatMessage(labels.website)}</Text>
<WebsiteSelect websiteId={board?.parameters?.websiteId} onChange={handleWebsiteChange} />
<Text>{t(labels.website)}</Text>
<WebsiteSelect
websiteId={board?.parameters?.websiteId}
teamId={teamId}
onChange={handleWebsiteChange}
/>
</Row>
</Column>
<Column justifyContent="center" alignItems="flex-end">
<Row gap="3">
<Button variant="quiet" onPress={handleCancel}>
{formatMessage(labels.cancel)}
{t(labels.cancel)}
</Button>
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
{formatMessage(labels.save)}
{t(labels.save)}
</LoadingButton>
</Row>
</Column>

View file

@ -0,0 +1,21 @@
'use client';
import { Column } from '@umami/react-zen';
import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
import { PageBody } from '@/components/common/PageBody';
import { BoardControls } from './BoardControls';
import { BoardEditBody } from './BoardEditBody';
import { BoardEditHeader } from './BoardEditHeader';
export function BoardEditPage({ boardId }: { boardId?: string }) {
return (
<BoardProvider boardId={boardId} editing>
<PageBody>
<Column>
<BoardEditHeader />
<BoardControls />
<BoardEditBody />
</Column>
</PageBody>
</BoardProvider>
);
}

View file

@ -0,0 +1,202 @@
import { Box, Button, Column, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { produce } from 'immer';
import { Fragment, useState } from 'react';
import {
Group,
type GroupImperativeHandle,
Panel as ResizablePanel,
Separator,
} from 'react-resizable-panels';
import { v4 as uuid } from 'uuid';
import { useBoard } from '@/components/hooks';
import { ChevronDown, GripVertical, Minus, Plus } from '@/components/icons';
import type { BoardColumn as BoardColumnType, BoardComponentConfig } from '@/lib/types';
import { BoardEditColumn } from './BoardEditColumn';
import { MAX_COLUMNS, MIN_COLUMN_WIDTH } from './boardConstants';
export function BoardEditRow({
rowId,
rowIndex,
rowCount,
columns,
canEdit,
onRemove,
onMoveUp,
onMoveDown,
onRegisterRef,
}: {
rowId: string;
rowIndex: number;
rowCount: number;
columns: BoardColumnType[];
canEdit: boolean;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
onRegisterRef: (rowId: string, ref: GroupImperativeHandle | null) => void;
}) {
const { board, updateBoard } = useBoard();
const [showActions, setShowActions] = useState(false);
const moveUpDisabled = rowIndex === 0;
const addColumnDisabled = columns.length >= MAX_COLUMNS;
const moveDownDisabled = rowIndex === rowCount - 1;
const handleGroupRef = (ref: GroupImperativeHandle | null) => {
onRegisterRef(rowId, ref);
};
const handleAddColumn = () => {
updateBoard({
parameters: produce(board.parameters, draft => {
const rowIndex = draft.rows.findIndex(row => row.id === rowId);
const row = draft.rows[rowIndex];
if (!row) {
draft.rows[rowIndex] = { id: uuid(), columns: [] };
}
row.columns.push({ id: uuid(), component: null });
}),
});
};
const handleRemoveColumn = (columnId: string) => {
updateBoard({
parameters: produce(board.parameters, draft => {
const row = draft.rows.find(row => row.id === rowId);
if (row) {
row.columns = row.columns.filter(col => col.id !== columnId);
}
}),
});
};
const handleSetComponent = (columnId: string, config: BoardComponentConfig | null) => {
updateBoard({
parameters: produce(board.parameters, draft => {
const row = draft.rows.find(row => row.id === rowId);
if (row) {
const col = row.columns.find(col => col.id === columnId);
if (col) {
col.component = config;
}
}
}),
});
};
return (
<Box
position="relative"
height="100%"
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
<Group groupRef={handleGroupRef}>
{columns?.map((column, index) => (
<Fragment key={`${column.id}:${column.size ?? 'auto'}`}>
<ResizablePanel
id={column.id}
minSize={MIN_COLUMN_WIDTH}
defaultSize={column.size != null ? `${column.size}%` : undefined}
>
<BoardEditColumn
{...column}
canEdit={canEdit}
onRemove={handleRemoveColumn}
onSetComponent={handleSetComponent}
canRemove={columns.length > 1}
/>
</ResizablePanel>
{index < columns.length - 1 && (
<Separator
style={{
width: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
outline: 'none',
boxShadow: 'none',
background: 'transparent',
}}
>
<Row
width="100%"
height="100%"
alignItems="center"
justifyContent="center"
style={{ cursor: 'col-resize' }}
>
<Icon size="sm">
<GripVertical />
</Icon>
</Row>
</Separator>
)}
</Fragment>
))}
</Group>
{canEdit && showActions && (
<Column
padding="2"
gap="1"
position="absolute"
top="50%"
right="12px"
zIndex={20}
backgroundColor="surface-sunken"
borderRadius
style={{ transform: 'translateY(-50%)' }}
>
<TooltipTrigger delay={0}>
<Button
variant="outline"
onPress={() => onMoveUp(rowId)}
isDisabled={moveUpDisabled}
style={moveUpDisabled ? { pointerEvents: 'none' } : undefined}
>
<Icon rotate={180} color={moveUpDisabled ? 'muted' : undefined}>
<ChevronDown />
</Icon>
</Button>
<Tooltip placement="top">Move row up</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button
variant="outline"
onPress={handleAddColumn}
isDisabled={addColumnDisabled}
style={addColumnDisabled ? { pointerEvents: 'none' } : undefined}
>
<Icon color={addColumnDisabled ? 'muted' : undefined}>
<Plus />
</Icon>
</Button>
<Tooltip placement="left">Add column</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={() => onRemove(rowId)}>
<Icon>
<Minus />
</Icon>
</Button>
<Tooltip placement="left">Remove row</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button
variant="outline"
onPress={() => onMoveDown(rowId)}
isDisabled={moveDownDisabled}
style={moveDownDisabled ? { pointerEvents: 'none' } : undefined}
>
<Icon color={moveDownDisabled ? 'muted' : undefined}>
<ChevronDown />
</Icon>
</Button>
<Tooltip placement="bottom">Move row down</Tooltip>
</TooltipTrigger>
</Column>
)}
</Box>
);
}

View file

@ -1,13 +0,0 @@
import { useBoard } from '@/components/hooks';
import { BoardEditHeader } from './BoardEditHeader';
import { BoardViewHeader } from './BoardViewHeader';
export function BoardHeader() {
const { board, editing } = useBoard();
if (editing) {
return <BoardEditHeader />;
}
return <BoardViewHeader />;
}

View file

@ -1,33 +0,0 @@
'use client';
import { Column } from '@umami/react-zen';
import { BoardBody } from '@/app/(main)/boards/[boardId]/BoardBody';
import { BoardHeader } from '@/app/(main)/boards/[boardId]/BoardHeader';
import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { PageBody } from '@/components/common/PageBody';
import { useBoard } from '@/components/hooks';
export function BoardPage({ boardId, editing = false }: { boardId?: string; editing?: boolean }) {
return (
<BoardProvider boardId={boardId} editing={editing}>
<PageBody>
<Column>
<BoardHeader />
<BoardControls />
<BoardBody />
</Column>
</PageBody>
</BoardProvider>
);
}
function BoardControls() {
const { board, editing } = useBoard();
const websiteId = board?.parameters?.websiteId;
if (editing || !websiteId) {
return null;
}
return <WebsiteControls websiteId={websiteId} allowCompare={true} />;
}

View file

@ -1,125 +0,0 @@
import { Button, Column, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { produce } from 'immer';
import { Fragment } from 'react';
import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resizable-panels';
import { v4 as uuid } from 'uuid';
import { useBoard } from '@/components/hooks';
import { ChevronDown, Minus, Plus } from '@/components/icons';
import type { BoardColumn as BoardColumnType } from '@/lib/types';
import { BoardColumn } from './BoardColumn';
import { MAX_COLUMNS, MIN_COLUMN_WIDTH } from './boardConstants';
export function BoardRow({
rowId,
rowIndex,
rowCount,
columns,
editing = false,
onRemove,
onMoveUp,
onMoveDown,
onRegisterRef,
}: {
rowId: string;
rowIndex: number;
rowCount: number;
columns: BoardColumnType[];
editing?: boolean;
onRemove?: (id: string) => void;
onMoveUp?: (id: string) => void;
onMoveDown?: (id: string) => void;
onRegisterRef?: (rowId: string, ref: GroupImperativeHandle | null) => void;
}) {
const { board, updateBoard } = useBoard();
const handleGroupRef = (ref: GroupImperativeHandle | null) => {
onRegisterRef?.(rowId, ref);
};
const handleAddColumn = () => {
updateBoard({
parameters: produce(board.parameters, draft => {
const rowIndex = draft.rows.findIndex(row => row.id === rowId);
const row = draft.rows[rowIndex];
if (!row) {
draft.rows[rowIndex] = { id: uuid(), columns: [] };
}
row.columns.push({ id: uuid(), component: null });
}),
});
};
const handleRemoveColumn = (columnId: string) => {
updateBoard({
parameters: produce(board.parameters, draft => {
const row = draft.rows.find(row => row.id === rowId);
if (row) {
row.columns = row.columns.filter(col => col.id !== columnId);
}
}),
});
};
return (
<Group groupRef={handleGroupRef} style={{ height: '100%' }}>
{columns?.map((column, index) => (
<Fragment key={column.id}>
<Panel id={column.id} minSize={MIN_COLUMN_WIDTH} defaultSize={column.size}>
<BoardColumn
{...column}
editing={editing}
onRemove={handleRemoveColumn}
canRemove={columns?.length > 1}
/>
</Panel>
{index < columns?.length - 1 && <Separator />}
</Fragment>
))}
{editing && (
<Column alignSelf="center" padding="3" gap="1">
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={() => onMoveUp?.(rowId)} isDisabled={rowIndex === 0}>
<Icon rotate={180}>
<ChevronDown />
</Icon>
</Button>
<Tooltip placement="top">Move row up</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button
variant="outline"
onPress={handleAddColumn}
isDisabled={columns?.length >= MAX_COLUMNS}
>
<Icon>
<Plus />
</Icon>
</Button>
<Tooltip placement="left">Add column</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={() => onRemove?.(rowId)}>
<Icon>
<Minus />
</Icon>
</Button>
<Tooltip placement="left">Remove row</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button
variant="outline"
onPress={() => onMoveDown?.(rowId)}
isDisabled={rowIndex === rowCount - 1}
>
<Icon>
<ChevronDown />
</Icon>
</Button>
<Tooltip placement="bottom">Move row down</Tooltip>
</TooltipTrigger>
</Column>
)}
</Group>
);
}

View file

@ -0,0 +1,16 @@
import { Column } from '@umami/react-zen';
import { useBoard } from '@/components/hooks';
import { BoardViewRow } from './BoardViewRow';
export function BoardViewBody() {
const { board } = useBoard();
const rows = board?.parameters?.rows ?? [];
return (
<Column gap="3">
{rows.map(row => (
<BoardViewRow key={row.id} columns={row.columns} />
))}
</Column>
);
}

View file

@ -0,0 +1,27 @@
import { Box, Column } from '@umami/react-zen';
import { Panel } from '@/components/common/Panel';
import { useBoard } from '@/components/hooks';
import type { BoardComponentConfig } from '@/lib/types';
import { BoardComponentRenderer } from './BoardComponentRenderer';
export function BoardViewColumn({ component }: { component?: BoardComponentConfig }) {
const { board } = useBoard();
const websiteId = component?.websiteId || board?.parameters?.websiteId;
if (!component || !websiteId) {
return null;
}
const title = component.title;
const description = component.description;
return (
<Panel title={title} description={description} height="100%">
<Column width="100%" height="100%" style={{ minHeight: 0 }}>
<Box width="100%" flexGrow={1} style={{ minHeight: 0 }}>
<BoardComponentRenderer config={component} websiteId={websiteId} />
</Box>
</Column>
</Panel>
);
}

View file

@ -8,14 +8,14 @@ import { Edit } from '@/components/icons';
export function BoardViewHeader() {
const { board } = useBoard();
const { renderUrl } = useNavigation();
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { data: website } = useWebsiteQuery(board?.parameters?.websiteId);
return (
<PageHeader title={board?.name} description={board?.description}>
{website?.name && <Text>{website.name}</Text>}
<LinkButton href={renderUrl(`/boards/${board?.id}/edit`, false)}>
<IconLabel icon={<Edit />}>{formatMessage(labels.edit)}</IconLabel>
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
</LinkButton>
</PageHeader>
);

View file

@ -0,0 +1,21 @@
'use client';
import { Column } from '@umami/react-zen';
import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
import { PageBody } from '@/components/common/PageBody';
import { BoardControls } from './BoardControls';
import { BoardViewBody } from './BoardViewBody';
import { BoardViewHeader } from './BoardViewHeader';
export function BoardViewPage({ boardId }: { boardId: string }) {
return (
<BoardProvider boardId={boardId}>
<PageBody>
<Column>
<BoardViewHeader />
<BoardControls />
<BoardViewBody />
</Column>
</PageBody>
</BoardProvider>
);
}

View file

@ -0,0 +1,22 @@
import { Box, Row } from '@umami/react-zen';
import type { BoardColumn } from '@/lib/types';
import { BoardViewColumn } from './BoardViewColumn';
import { MIN_COLUMN_WIDTH } from './boardConstants';
export function BoardViewRow({ columns }: { columns: BoardColumn[] }) {
return (
<Row gap="3" width="100%" overflowX="auto">
{columns.map(column => (
<Box
key={column.id}
flexGrow={column.size ?? 1}
flexShrink={1}
flexBasis="0%"
minWidth={`${MIN_COLUMN_WIDTH}px`}
>
<BoardViewColumn component={column.component} />
</Box>
))}
</Row>
);
}

View file

@ -1,10 +1,10 @@
import type { Metadata } from 'next';
import { BoardPage } from '../BoardPage';
import { BoardEditPage } from '../BoardEditPage';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params;
return <BoardPage boardId={boardId} editing />;
return <BoardEditPage boardId={boardId} />;
}
export const metadata: Metadata = {

View file

@ -1,11 +1,16 @@
import type { Metadata } from 'next';
import { BoardPage } from './BoardPage';
import { BoardEditPage } from './BoardEditPage';
import { BoardViewPage } from './BoardViewPage';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params;
const isCreate = boardId === 'create';
return <BoardPage boardId={isCreate ? undefined : boardId} editing={isCreate} />;
if (isCreate) {
return <BoardEditPage />;
}
return <BoardViewPage boardId={boardId} />;
}
export const metadata: Metadata = {

View file

@ -0,0 +1,134 @@
import type { ComponentType } from 'react';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
import { WorldMap } from '@/components/metrics/WorldMap';
export interface ConfigField {
name: string;
label: string;
type: 'select' | 'number' | 'text';
options?: { label: string; value: string }[];
defaultValue?: any;
}
export interface ComponentDefinition {
type: string;
name: string;
description: string;
category: string;
component: ComponentType<any>;
defaultProps?: Record<string, any>;
configFields?: ConfigField[];
}
export const CATEGORIES = [
{ key: 'overview', name: 'Overview' },
{ key: 'tables', name: 'Tables' },
{ key: 'visualization', name: 'Visualization' },
] as const;
const METRIC_TYPES = [
{ label: 'Pages', value: 'path' },
{ label: 'Entry pages', value: 'entry' },
{ label: 'Exit pages', value: 'exit' },
{ label: 'Referrers', value: 'referrer' },
{ label: 'Channels', value: 'channel' },
{ label: 'Browsers', value: 'browser' },
{ label: 'OS', value: 'os' },
{ label: 'Devices', value: 'device' },
{ label: 'Countries', value: 'country' },
{ label: 'Regions', value: 'region' },
{ label: 'Cities', value: 'city' },
{ label: 'Languages', value: 'language' },
{ label: 'Screens', value: 'screen' },
{ label: 'Query parameters', value: 'query' },
{ label: 'Page titles', value: 'title' },
{ label: 'Hosts', value: 'host' },
{ label: 'Events', value: 'event' },
];
const LIMIT_OPTIONS = [
{ label: '5', value: '5' },
{ label: '10', value: '10' },
{ label: '20', value: '20' },
];
const componentDefinitions: ComponentDefinition[] = [
// Overview
{
type: 'WebsiteMetricsBar',
name: 'Metrics bar',
description: 'Key metrics: views, visitors, bounces, time on site',
category: 'overview',
component: WebsiteMetricsBar,
},
{
type: 'WebsiteChart',
name: 'Website chart',
description: 'Page views and visitors over time',
category: 'overview',
component: WebsiteChart,
},
// Tables
{
type: 'MetricsTable',
name: 'Metrics table',
description: 'Table of metrics by dimension',
category: 'tables',
component: MetricsTable,
defaultProps: { type: 'path', limit: 10 },
configFields: [
{
name: 'type',
label: 'Metric type',
type: 'select',
options: METRIC_TYPES,
defaultValue: 'path',
},
{
name: 'limit',
label: 'Rows',
type: 'select',
options: LIMIT_OPTIONS,
defaultValue: '10',
},
],
},
// Visualization
{
type: 'WorldMap',
name: 'World map',
description: 'Geographic distribution of visitors',
category: 'visualization',
component: WorldMap,
},
{
type: 'WeeklyTraffic',
name: 'Weekly traffic',
description: 'Traffic heatmap by day and hour',
category: 'visualization',
component: WeeklyTraffic,
},
{
type: 'EventsChart',
name: 'Events chart',
description: 'Custom events over time',
category: 'visualization',
component: EventsChart,
},
];
const definitionMap = new Map(componentDefinitions.map(def => [def.type, def]));
export function getComponentDefinition(type: string): ComponentDefinition | undefined {
return definitionMap.get(type);
}
export function getComponentsByCategory(category: string): ComponentDefinition[] {
return componentDefinitions.filter(def => def.category === category);
}

View file

@ -0,0 +1,27 @@
import { Button, LoadingButton, Row } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
export function DashboardEditHeader() {
const { saveBoard, isPending } = useBoard();
const { t, labels } = useMessages();
const { router, renderUrl } = useNavigation();
const handleSave = async () => {
await saveBoard();
router.push(renderUrl('/dashboard', false));
};
return (
<PageHeader title={t(labels.dashboard)}>
<Row gap="3">
<Button variant="quiet" onPress={() => router.push(renderUrl('/dashboard', false))}>
{t(labels.cancel)}
</Button>
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
{t(labels.save)}
</LoadingButton>
</Row>
</PageHeader>
);
}

View file

@ -0,0 +1,33 @@
'use client';
import { Column } from '@umami/react-zen';
import { useEffect } from 'react';
import { BoardEditBody } from '@/app/(main)/boards/[boardId]/BoardEditBody';
import { PageBody } from '@/components/common/PageBody';
import { useNavigation } from '@/components/hooks';
import { DashboardEditHeader } from './DashboardEditHeader';
import { DashboardProvider } from './DashboardProvider';
export function DashboardEditPage() {
const { teamId, router } = useNavigation();
useEffect(() => {
if (teamId) {
router.replace('/dashboard/edit');
}
}, [teamId, router]);
if (teamId) {
return null;
}
return (
<DashboardProvider editing>
<PageBody>
<Column>
<DashboardEditHeader />
<BoardEditBody requiresBoardWebsite={false} />
</Column>
</PageBody>
</DashboardProvider>
);
}

View file

@ -1,17 +0,0 @@
'use client';
import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages } from '@/components/hooks';
export function DashboardPage() {
const { formatMessage, labels } = useMessages();
return (
<PageBody>
<Column margin="2">
<PageHeader title={formatMessage(labels.dashboard)}></PageHeader>
</Column>
</PageBody>
);
}

View file

@ -0,0 +1,111 @@
'use client';
import { Loading, useToast } from '@umami/react-zen';
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { v4 as uuid } from 'uuid';
import { BoardContext, type LayoutGetter } from '@/app/(main)/boards/BoardProvider';
import { getComponentDefinition } from '@/app/(main)/boards/boardComponentRegistry';
import { useApi, useDashboardQuery, useMessages, useModified } from '@/components/hooks';
import type { Board, BoardParameters } from '@/lib/types';
const createDefaultBoard = (): Partial<Board> => ({
name: '',
description: '',
parameters: {
rows: [{ id: uuid(), columns: [{ id: uuid(), component: null }] }],
},
});
function sanitizeBoardParameters(parameters?: BoardParameters): BoardParameters | undefined {
if (!parameters?.rows) {
return parameters;
}
return {
...parameters,
rows: parameters.rows.map(row => ({
...row,
columns: row.columns.map(column => {
if (column.component && !getComponentDefinition(column.component.type)) {
return {
...column,
component: null,
};
}
return column;
}),
})),
};
}
export function DashboardProvider({
editing = false,
children,
}: {
editing?: boolean;
children: ReactNode;
}) {
const { data, isFetching, isLoading } = useDashboardQuery();
const { post, useMutation } = useApi();
const { touch } = useModified();
const { toast } = useToast();
const { t, labels, messages } = useMessages();
const [board, setBoard] = useState<Partial<Board>>(data ?? createDefaultBoard());
const layoutGetterRef = useRef<LayoutGetter | null>(null);
const registerLayoutGetter = useCallback((getter: LayoutGetter) => {
layoutGetterRef.current = getter;
}, []);
useEffect(() => {
if (data) {
setBoard({
...data,
parameters: sanitizeBoardParameters(data.parameters),
});
}
}, [data]);
const { mutateAsync, isPending } = useMutation({
mutationFn: (boardData: Partial<Board>) => {
return post('/dashboard', boardData);
},
});
const updateBoard = useCallback((data: Partial<Board>) => {
setBoard(current => ({ ...current, ...data }));
}, []);
const saveBoard = useCallback(async () => {
const dashboardName = t(labels.dashboard);
const layoutData = layoutGetterRef.current?.();
const parameters = sanitizeBoardParameters(
layoutData ? { ...board.parameters, ...layoutData } : board.parameters,
);
const result = await mutateAsync({
...board,
name: dashboardName,
description: '',
parameters,
});
toast(t(messages.saved));
touch('dashboard');
touch('boards');
return result;
}, [board, labels.dashboard, messages.saved, mutateAsync, t, toast, touch]);
if (isFetching && isLoading) {
return <Loading placement="absolute" />;
}
return (
<BoardContext.Provider
value={{ board, editing, updateBoard, saveBoard, isPending, registerLayoutGetter }}
>
{children}
</BoardContext.Provider>
);
}

View file

@ -0,0 +1,18 @@
import { IconLabel } from '@/components/common/IconLabel';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, useNavigation } from '@/components/hooks';
import { Edit } from '@/components/icons';
export function DashboardViewHeader() {
const { t, labels } = useMessages();
const { renderUrl } = useNavigation();
return (
<PageHeader title={t(labels.dashboard)}>
<LinkButton href={renderUrl('/dashboard/edit', false)}>
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
</LinkButton>
</PageHeader>
);
}

View file

@ -0,0 +1,47 @@
'use client';
import { Column } from '@umami/react-zen';
import { useEffect } from 'react';
import { BoardViewBody } from '@/app/(main)/boards/[boardId]/BoardViewBody';
import { Empty } from '@/components/common/Empty';
import { PageBody } from '@/components/common/PageBody';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
import { DashboardProvider } from './DashboardProvider';
import { DashboardViewHeader } from './DashboardViewHeader';
function DashboardContent() {
const { board } = useBoard();
const { t, messages } = useMessages();
const rows = board?.parameters?.rows ?? [];
const hasComponents = rows.some(row => row.columns?.some(column => !!column.component));
if (!hasComponents) {
return <Empty message={t(messages.emptyDashboard)} />;
}
return <BoardViewBody />;
}
export function DashboardViewPage() {
const { teamId, router } = useNavigation();
useEffect(() => {
if (teamId) {
router.replace('/dashboard');
}
}, [teamId, router]);
if (teamId) {
return null;
}
return (
<DashboardProvider>
<PageBody>
<Column>
<DashboardViewHeader />
<DashboardContent />
</Column>
</PageBody>
</DashboardProvider>
);
}

View file

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

View file

@ -1,8 +1,8 @@
import type { Metadata } from 'next';
import { DashboardPage } from './DashboardPage';
import { DashboardViewPage } from './DashboardViewPage';
export default async function () {
return <DashboardPage />;
return <DashboardViewPage />;
}
export const metadata: Metadata = {

View file

@ -4,15 +4,10 @@ import { DialogButton } from '@/components/input/DialogButton';
import { LinkEditForm } from './LinkEditForm';
export function LinkAddButton({ teamId }: { teamId?: string }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<DialogButton
icon={<Plus />}
label={formatMessage(labels.addLink)}
variant="primary"
width="600px"
>
<DialogButton icon={<Plus />} label={t(labels.addLink)} variant="primary" width="600px">
{({ close }) => <LinkEditForm teamId={teamId} onClose={close} />}
</DialogButton>
);

View file

@ -2,7 +2,6 @@ import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
export function LinkDeleteButton({
linkId,
@ -14,7 +13,7 @@ export function LinkDeleteButton({
name: string;
onSave?: () => void;
}) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { t, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`);
const { touch } = useModified();
@ -29,27 +28,18 @@ export function LinkDeleteButton({
};
return (
<DialogButton
icon={<Trash />}
title={formatMessage(labels.confirm)}
variant="quiet"
width="400px"
>
<DialogButton icon={<Trash />} title={t(labels.confirm)} variant="quiet" width="400px">
{({ close }) => (
<ConfirmationForm
message={
<FormattedMessage
{...messages.confirmRemove}
values={{
target: <b>{name}</b>,
}}
/>
}
message={t.rich(messages.confirmRemove, {
target: name,
b: chunks => <b>{chunks}</b>,
})}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
buttonLabel={t(labels.delete)}
buttonVariant="danger"
/>
)}

View file

@ -4,10 +4,10 @@ import { DialogButton } from '@/components/input/DialogButton';
import { LinkEditForm } from './LinkEditForm';
export function LinkEditButton({ linkId }: { linkId: string }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<DialogButton icon={<Edit />} title={formatMessage(labels.link)} variant="quiet" width="800px">
<DialogButton icon={<Edit />} title={t(labels.link)} variant="quiet" width="800px">
{({ close }) => {
return <LinkEditForm linkId={linkId} onClose={close} />;
}}

View file

@ -32,7 +32,7 @@ export function LinkEditForm({
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
const { t, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
linkId ? `/links/${linkId}` : '/links',
{
@ -48,7 +48,7 @@ export function LinkEditForm({
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
toast(t(messages.saved));
touch('links');
touch(`link:${linkId}`);
onSave?.();
@ -59,7 +59,7 @@ export function LinkEditForm({
const checkUrl = (url: string) => {
if (!isValidUrl(url)) {
return formatMessage(labels.invalidUrl);
return t(labels.invalidUrl);
}
return true;
};
@ -79,18 +79,14 @@ export function LinkEditForm({
return (
<>
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" autoFocus />
</FormField>
<FormField
label={formatMessage(labels.destinationUrl)}
label={t(labels.destinationUrl)}
name="url"
rules={{ required: formatMessage(labels.required), validate: checkUrl }}
rules={{ required: t(labels.required), validate: checkUrl }}
>
<TextField placeholder="https://example.com" autoComplete="off" />
</FormField>
@ -98,9 +94,9 @@ export function LinkEditForm({
<Grid columns="1fr auto" alignItems="end" gap>
<FormField
name="slug"
label={formatMessage({ id: 'label.slug', defaultMessage: 'Slug' })}
label={t({ id: 'label.slug', defaultMessage: 'Slug' })}
rules={{
required: formatMessage(labels.required),
required: t(labels.required),
}}
>
<TextField autoComplete="off" />
@ -116,7 +112,7 @@ export function LinkEditForm({
</Grid>
<Column>
<Label>{formatMessage(labels.link)}</Label>
<Label>{t(labels.link)}</Label>
<Row alignItems="center" gap>
<TextField
value={`${hostUrl}/${slug}`}
@ -131,10 +127,10 @@ export function LinkEditForm({
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
<FormSubmitButton>{t(labels.save)}</FormSubmitButton>
</Row>
</>
);

View file

@ -10,7 +10,7 @@ import { LinkAddButton } from './LinkAddButton';
export function LinksPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { teamId } = useNavigation();
const { data } = useTeamMembersQuery(teamId);
@ -23,7 +23,7 @@ export function LinksPage() {
return (
<PageBody>
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.links)}>
<PageHeader title={t(labels.links)}>
{showActions && <LinkAddButton teamId={teamId} />}
</PageHeader>
<Panel>

View file

@ -11,18 +11,18 @@ export interface LinksTableProps extends DataTableProps {
}
export function LinksTable({ showActions, ...props }: LinksTableProps) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { websiteId, renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('link');
return (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
<DataColumn id="name" label={t(labels.name)}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
}}
</DataColumn>
<DataColumn id="slug" label={formatMessage(labels.link)}>
<DataColumn id="slug" label={t(labels.link)}>
{({ slug }: any) => {
const url = getSlugUrl(slug);
return (
@ -32,12 +32,12 @@ export function LinksTable({ showActions, ...props }: LinksTableProps) {
);
}}
</DataColumn>
<DataColumn id="url" label={formatMessage(labels.destinationUrl)}>
<DataColumn id="url" label={t(labels.destinationUrl)}>
{({ url }: any) => {
return <ExternalLink href={url}>{url}</ExternalLink>;
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)} width="200px">
<DataColumn id="created" label={t(labels.created)} width="200px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (

View file

@ -5,14 +5,14 @@ import { useLink, useMessages, useSlug } from '@/components/hooks';
import { ExternalLink, Link } from '@/components/icons';
export function LinkHeader() {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { getSlugUrl } = useSlug('link');
const link = useLink();
return (
<PageHeader title={link.name} description={link.url} icon={<Link />}>
<LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false} asAnchor>
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
<IconLabel icon={<ExternalLink />} label={t(labels.view)} />
</LinkButton>
</PageHeader>
);

View file

@ -13,7 +13,7 @@ export function LinkMetricsBar({
compareMode?: boolean;
}) {
const { isAllTime } = useDateRange();
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(linkId);
const { pageviews, visitors, visits, comparison } = data || {};
@ -22,19 +22,19 @@ export function LinkMetricsBar({
? [
{
value: visitors,
label: formatMessage(labels.visitors),
label: t(labels.visitors),
change: visitors - comparison.visitors,
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
label: t(labels.visits),
change: visits - comparison.visits,
formatValue: formatLongNumber,
},
{
value: pageviews,
label: formatMessage(labels.views),
label: t(labels.views),
change: pageviews - comparison.pageviews,
formatValue: formatLongNumber,
},

View file

@ -6,13 +6,13 @@ import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WorldMap } from '@/components/metrics/WorldMap';
export function LinkPanels({ linkId }: { linkId: string }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const tableProps = {
websiteId: linkId,
limit: 10,
allowDownload: false,
showMore: true,
metric: formatMessage(labels.visitors),
metric: t(labels.visitors),
};
const rowProps = { minHeight: 570 };
@ -20,36 +20,36 @@ export function LinkPanels({ linkId }: { linkId: string }) {
<Grid gap="3">
<GridRow layout="two" {...rowProps}>
<Panel>
<Heading size="base">{formatMessage(labels.sources)}</Heading>
<Heading size="2xl">{t(labels.sources)}</Heading>
<Tabs>
<TabList>
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
<Tab id="referrer">{t(labels.referrers)}</Tab>
<Tab id="channel">{t(labels.channels)}</Tab>
</TabList>
<TabPanel id="referrer">
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
<MetricsTable type="referrer" title={t(labels.domain)} {...tableProps} />
</TabPanel>
<TabPanel id="channel">
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
<MetricsTable type="channel" title={t(labels.type)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
<Panel>
<Heading size="base">{formatMessage(labels.environment)}</Heading>
<Heading size="2xl">{t(labels.environment)}</Heading>
<Tabs>
<TabList>
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
<Tab id="os">{formatMessage(labels.os)}</Tab>
<Tab id="device">{formatMessage(labels.devices)}</Tab>
<Tab id="browser">{t(labels.browsers)}</Tab>
<Tab id="os">{t(labels.os)}</Tab>
<Tab id="device">{t(labels.devices)}</Tab>
</TabList>
<TabPanel id="browser">
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
<MetricsTable type="browser" title={t(labels.browser)} {...tableProps} />
</TabPanel>
<TabPanel id="os">
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
<MetricsTable type="os" title={t(labels.os)} {...tableProps} />
</TabPanel>
<TabPanel id="device">
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
<MetricsTable type="device" title={t(labels.device)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
@ -59,21 +59,21 @@ export function LinkPanels({ linkId }: { linkId: string }) {
<WorldMap websiteId={linkId} />
</Panel>
<Panel>
<Heading size="base">{formatMessage(labels.location)}</Heading>
<Heading size="2xl">{t(labels.location)}</Heading>
<Tabs>
<TabList>
<Tab id="country">{formatMessage(labels.countries)}</Tab>
<Tab id="region">{formatMessage(labels.regions)}</Tab>
<Tab id="city">{formatMessage(labels.cities)}</Tab>
<Tab id="country">{t(labels.countries)}</Tab>
<Tab id="region">{t(labels.regions)}</Tab>
<Tab id="city">{t(labels.cities)}</Tab>
</TabList>
<TabPanel id="country">
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
<MetricsTable type="country" title={t(labels.country)} {...tableProps} />
</TabPanel>
<TabPanel id="region">
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
<MetricsTable type="region" title={t(labels.region)} {...tableProps} />
</TabPanel>
<TabPanel id="city">
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
<MetricsTable type="city" title={t(labels.city)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>

View file

@ -4,15 +4,10 @@ import { DialogButton } from '@/components/input/DialogButton';
import { PixelEditForm } from './PixelEditForm';
export function PixelAddButton({ teamId }: { teamId?: string }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<DialogButton
icon={<Plus />}
label={formatMessage(labels.addPixel)}
variant="primary"
width="600px"
>
<DialogButton icon={<Plus />} label={t(labels.addPixel)} variant="primary" width="600px">
{({ close }) => <PixelEditForm teamId={teamId} onClose={close} />}
</DialogButton>
);

View file

@ -2,7 +2,6 @@ import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
export function PixelDeleteButton({
pixelId,
@ -13,7 +12,7 @@ export function PixelDeleteButton({
name: string;
onSave?: () => void;
}) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { t, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, isPending, error } = useDeleteQuery(`/pixels/${pixelId}`);
const { touch } = useModified();
@ -28,27 +27,18 @@ export function PixelDeleteButton({
};
return (
<DialogButton
icon={<Trash />}
variant="quiet"
title={formatMessage(labels.confirm)}
width="400px"
>
<DialogButton icon={<Trash />} variant="quiet" title={t(labels.confirm)} width="400px">
{({ close }) => (
<ConfirmationForm
message={
<FormattedMessage
{...messages.confirmRemove}
values={{
target: <b>{name}</b>,
}}
/>
}
message={t.rich(messages.confirmRemove, {
target: name,
b: chunks => <b>{chunks}</b>,
})}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
buttonLabel={t(labels.delete)}
buttonVariant="danger"
/>
)}

View file

@ -4,15 +4,10 @@ import { DialogButton } from '@/components/input/DialogButton';
import { PixelEditForm } from './PixelEditForm';
export function PixelEditButton({ pixelId }: { pixelId: string }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<DialogButton
icon={<Edit />}
title={formatMessage(labels.addPixel)}
variant="quiet"
width="600px"
>
<DialogButton icon={<Edit />} title={t(labels.addPixel)} variant="quiet" width="600px">
{({ close }) => {
return <PixelEditForm pixelId={pixelId} onClose={close} />;
}}

View file

@ -30,7 +30,7 @@ export function PixelEditForm({
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
const { t, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
pixelId ? `/pixels/${pixelId}` : '/pixels',
{
@ -46,7 +46,7 @@ export function PixelEditForm({
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
toast(t(messages.saved));
touch('pixels');
touch(`pixel:${pixelId}`);
onSave?.();
@ -78,18 +78,14 @@ export function PixelEditForm({
{({ setValue }) => {
return (
<>
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" />
</FormField>
<FormField
name="slug"
rules={{
required: formatMessage(labels.required),
required: t(labels.required),
}}
style={{ display: 'none' }}
>
@ -97,7 +93,7 @@ export function PixelEditForm({
</FormField>
<Column>
<Label>{formatMessage(labels.link)}</Label>
<Label>{t(labels.link)}</Label>
<Row alignItems="center" gap>
<TextField
value={`${hostUrl}/${slug}`}
@ -117,10 +113,10 @@ export function PixelEditForm({
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
<FormSubmitButton isDisabled={false}>{t(labels.save)}</FormSubmitButton>
</Row>
</>
);

View file

@ -10,7 +10,7 @@ import { PixelsDataTable } from './PixelsDataTable';
export function PixelsPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { teamId } = useNavigation();
const { data } = useTeamMembersQuery(teamId);
@ -23,7 +23,7 @@ export function PixelsPage() {
return (
<PageBody>
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.pixels)}>
<PageHeader title={t(labels.pixels)}>
{showActions && <PixelAddButton teamId={teamId} />}
</PageHeader>
<Panel>

View file

@ -11,13 +11,13 @@ export interface PixelsTableProps extends DataTableProps {
}
export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('pixel');
return (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
<DataColumn id="name" label={t(labels.name)}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
}}
@ -32,7 +32,7 @@ export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)}>
<DataColumn id="created" label={t(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (

View file

@ -5,14 +5,14 @@ import { useMessages, usePixel, useSlug } from '@/components/hooks';
import { ExternalLink, Grid2x2 } from '@/components/icons';
export function PixelHeader() {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { getSlugUrl } = useSlug('pixel');
const pixel = usePixel();
return (
<PageHeader title={pixel.name} icon={<Grid2x2 />}>
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor>
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
<IconLabel icon={<ExternalLink />} label={t(labels.view)} />
</LinkButton>
</PageHeader>
);

View file

@ -13,7 +13,7 @@ export function PixelMetricsBar({
compareMode?: boolean;
}) {
const { isAllTime } = useDateRange();
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(pixelId);
const { pageviews, visitors, visits, comparison } = data || {};
@ -22,19 +22,19 @@ export function PixelMetricsBar({
? [
{
value: visitors,
label: formatMessage(labels.visitors),
label: t(labels.visitors),
change: visitors - comparison.visitors,
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
label: t(labels.visits),
change: visits - comparison.visits,
formatValue: formatLongNumber,
},
{
value: pageviews,
label: formatMessage(labels.views),
label: t(labels.views),
change: pageviews - comparison.pageviews,
formatValue: formatLongNumber,
},

View file

@ -6,13 +6,13 @@ import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WorldMap } from '@/components/metrics/WorldMap';
export function PixelPanels({ pixelId }: { pixelId: string }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const tableProps = {
websiteId: pixelId,
limit: 10,
allowDownload: false,
showMore: true,
metric: formatMessage(labels.visitors),
metric: t(labels.visitors),
};
const rowProps = { minHeight: 570 };
@ -20,36 +20,36 @@ export function PixelPanels({ pixelId }: { pixelId: string }) {
<Grid gap="3">
<GridRow layout="two" {...rowProps}>
<Panel>
<Heading size="base">{formatMessage(labels.sources)}</Heading>
<Heading size="2xl">{t(labels.sources)}</Heading>
<Tabs>
<TabList>
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
<Tab id="referrer">{t(labels.referrers)}</Tab>
<Tab id="channel">{t(labels.channels)}</Tab>
</TabList>
<TabPanel id="referrer">
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
<MetricsTable type="referrer" title={t(labels.domain)} {...tableProps} />
</TabPanel>
<TabPanel id="channel">
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
<MetricsTable type="channel" title={t(labels.type)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
<Panel>
<Heading size="base">{formatMessage(labels.environment)}</Heading>
<Heading size="2xl">{t(labels.environment)}</Heading>
<Tabs>
<TabList>
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
<Tab id="os">{formatMessage(labels.os)}</Tab>
<Tab id="device">{formatMessage(labels.devices)}</Tab>
<Tab id="browser">{t(labels.browsers)}</Tab>
<Tab id="os">{t(labels.os)}</Tab>
<Tab id="device">{t(labels.devices)}</Tab>
</TabList>
<TabPanel id="browser">
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
<MetricsTable type="browser" title={t(labels.browser)} {...tableProps} />
</TabPanel>
<TabPanel id="os">
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
<MetricsTable type="os" title={t(labels.os)} {...tableProps} />
</TabPanel>
<TabPanel id="device">
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
<MetricsTable type="device" title={t(labels.device)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
@ -59,21 +59,21 @@ export function PixelPanels({ pixelId }: { pixelId: string }) {
<WorldMap websiteId={pixelId} />
</Panel>
<Panel>
<Heading size="base">{formatMessage(labels.location)}</Heading>
<Heading size="2xl">{t(labels.location)}</Heading>
<Tabs>
<TabList>
<Tab id="country">{formatMessage(labels.countries)}</Tab>
<Tab id="region">{formatMessage(labels.regions)}</Tab>
<Tab id="city">{formatMessage(labels.cities)}</Tab>
<Tab id="country">{t(labels.countries)}</Tab>
<Tab id="region">{t(labels.regions)}</Tab>
<Tab id="city">{t(labels.cities)}</Tab>
</TabList>
<TabPanel id="country">
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
<MetricsTable type="country" title={t(labels.country)} {...tableProps} />
</TabPanel>
<TabPanel id="region">
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
<MetricsTable type="region" title={t(labels.region)} {...tableProps} />
</TabPanel>
<TabPanel id="city">
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
<MetricsTable type="city" title={t(labels.city)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>

View file

@ -1,26 +1,12 @@
'use client';
import { Column, Grid } from '@umami/react-zen';
import { Column } 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={{ base: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Column
display={{ base: '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>
<Column gap="6" margin="2" width="100%">
<PageBody>{children}</PageBody>
</Column>
);
}

View file

@ -1,35 +1,43 @@
import { NavMenu } from '@/components/common/NavMenu';
import { Column, Focusable, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import Link from 'next/link';
import { IconLabel } from '@/components/common/IconLabel';
import { useMessages, useNavigation } from '@/components/hooks';
import { Settings2, UserCircle, Users } from '@/components/icons';
import { ArrowLeft, Settings2, UserCircle, Users } from '@/components/icons';
export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
const { formatMessage, labels } = useMessages();
export function SettingsNav({
isCollapsed,
onItemClick,
}: {
isCollapsed?: boolean;
onItemClick?: () => void;
}) {
const { t, labels } = useMessages();
const { renderUrl, pathname } = useNavigation();
const items = [
{
label: formatMessage(labels.application),
label: t(labels.application),
items: [
{
id: 'preferences',
label: formatMessage(labels.preferences),
label: t(labels.preferences),
path: renderUrl('/settings/preferences'),
icon: <Settings2 />,
},
],
},
{
label: formatMessage(labels.account),
label: t(labels.account),
items: [
{
id: 'profile',
label: formatMessage(labels.profile),
label: t(labels.profile),
path: renderUrl('/settings/profile'),
icon: <UserCircle />,
},
{
id: 'teams',
label: formatMessage(labels.teams),
label: t(labels.teams),
path: renderUrl('/settings/teams'),
icon: <Users />,
},
@ -42,12 +50,57 @@ export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
.find(({ path }) => path && pathname.includes(path.split('?')[0]))?.id;
return (
<NavMenu
items={items}
title={formatMessage(labels.settings)}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
<Column gap="2">
<Link href={renderUrl('/boards', false)} role="button" onClick={onItemClick}>
<TooltipTrigger isDisabled={!isCollapsed} delay={0}>
<Focusable>
<Row
alignItems="center"
hover={{ backgroundColor: 'surface-sunken' }}
borderRadius
minHeight="40px"
>
<IconLabel icon={<ArrowLeft />} label={isCollapsed ? '' : t(labels.back)} padding />
</Row>
</Focusable>
<Tooltip placement="right">{t(labels.back)}</Tooltip>
</TooltipTrigger>
</Link>
{items.map(({ label: sectionLabel, items: sectionItems }, index) => (
<Column key={`${sectionLabel}${index}`} gap="1" marginBottom="1">
{!isCollapsed && (
<Row padding>
<Text weight="bold">{sectionLabel}</Text>
</Row>
)}
{sectionItems.map(({ id, path, label, icon }) => {
const isSelected = selectedKey === id;
return (
<Link key={id} href={path} role="button" onClick={onItemClick}>
<TooltipTrigger isDisabled={!isCollapsed} delay={0}>
<Focusable>
<Row
alignItems="center"
hover={{ backgroundColor: 'surface-sunken' }}
backgroundColor={isSelected ? 'surface-sunken' : undefined}
borderRadius
minHeight="40px"
>
<IconLabel
icon={icon}
label={isCollapsed ? '' : label}
weight={isSelected ? 'bold' : undefined}
padding
/>
</Row>
</Focusable>
<Tooltip placement="right">{label}</Tooltip>
</TooltipTrigger>
</Link>
);
})}
</Column>
))}
</Column>
);
}

View file

@ -6,7 +6,7 @@ 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 { t, labels } = useMessages();
const [date, setDate] = useState(getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE);
const handleChange = (value: string) => {
@ -21,8 +21,13 @@ export function DateRangeSetting() {
return (
<Row gap="3">
<DateFilter value={date} onChange={handleChange} placement="bottom start" />
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
<DateFilter
value={date}
onChange={handleChange}
placement="bottom start"
style={{ minWidth: '250px' }}
/>
<Button onPress={handleReset}>{t(labels.reset)}</Button>
</Row>
);
}

View file

@ -6,7 +6,7 @@ import { languages } from '@/lib/lang';
export function LanguageSetting() {
const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { locale, saveLocale } = useLocale();
const items = search
? Object.keys(languages).filter(n => {
@ -34,6 +34,7 @@ export function LanguageSetting() {
onSearch={setSearch}
onOpenChange={handleOpen}
listProps={{ style: { maxHeight: 300 } }}
style={{ minWidth: '250px' }}
>
{items.map(item => (
<ListItem key={item} id={item}>
@ -42,7 +43,7 @@ export function LanguageSetting() {
))}
{!items.length && <ListItem></ListItem>}
</Select>
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
<Button onPress={handleReset}>{t(labels.reset)}</Button>
</Row>
);
}

View file

@ -8,32 +8,32 @@ import { VersionSetting } from './VersionSetting';
export function PreferenceSettings() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
if (!user) {
return null;
}
return (
<Column width="400px" gap="6">
<Column gap="6">
<Column>
<Label>{formatMessage(labels.defaultDateRange)}</Label>
<Label>{t(labels.defaultDateRange)}</Label>
<DateRangeSetting />
</Column>
<Column>
<Label>{formatMessage(labels.timezone)}</Label>
<Label>{t(labels.timezone)}</Label>
<TimezoneSetting />
</Column>
<Column>
<Label>{formatMessage(labels.language)}</Label>
<Label>{t(labels.language)}</Label>
<LanguageSetting />
</Column>
<Column>
<Label>{formatMessage(labels.theme)}</Label>
<Label>{t(labels.theme)}</Label>
<ThemeSetting />
</Column>
<Column>
<Label>{formatMessage(labels.version)}</Label>
<Label>{t(labels.version)}</Label>
<VersionSetting />
</Column>
</Column>

View file

@ -7,12 +7,12 @@ import { useMessages } from '@/components/hooks';
import { PreferenceSettings } from './PreferenceSettings';
export function PreferencesPage() {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<PageBody>
<Column gap="6">
<PageHeader title={formatMessage(labels.preferences)} />
<PageHeader title={t(labels.preferences)} />
<Panel>
<PreferenceSettings />
</Panel>

View file

@ -7,7 +7,7 @@ const timezones = Intl.supportedValuesOf('timeZone');
export function TimezoneSetting() {
const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { timezone, saveTimezone } = useTimezone();
const items = search
? timezones.filter(n => n.toLowerCase().includes(search.toLowerCase()))
@ -30,6 +30,7 @@ export function TimezoneSetting() {
onSearch={setSearch}
onOpenChange={handleOpen}
listProps={{ style: { maxHeight: 300 } }}
style={{ minWidth: '250px' }}
>
{items.map((item: any) => (
<ListItem key={item} id={item}>
@ -38,7 +39,7 @@ export function TimezoneSetting() {
))}
{!items.length && <ListItem></ListItem>}
</Select>
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
<Button onPress={handleReset}>{t(labels.reset)}</Button>
</Row>
);
}

View file

@ -4,11 +4,11 @@ import { LockKeyhole } from '@/components/icons';
import { PasswordEditForm } from './PasswordEditForm';
export function PasswordChangeButton() {
const { formatMessage, labels, messages } = useMessages();
const { t, labels, messages } = useMessages();
const { toast } = useToast();
const handleSave = () => {
toast(formatMessage(messages.saved));
toast(t(messages.saved));
};
return (
@ -17,10 +17,10 @@ export function PasswordChangeButton() {
<Icon>
<LockKeyhole />
</Icon>
<Text>{formatMessage(labels.changePassword)}</Text>
<Text>{t(labels.changePassword)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.changePassword)} style={{ width: 400 }}>
<Dialog title={t(labels.changePassword)} style={{ width: 400 }}>
{({ close }) => <PasswordEditForm onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>

View file

@ -9,7 +9,7 @@ import {
import { useMessages, useUpdateQuery } from '@/components/hooks';
export function PasswordEditForm({ onSave, onClose }) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
const { t, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/me/password');
const handleSubmit = async (data: any) => {
@ -23,7 +23,7 @@ export function PasswordEditForm({ onSave, onClose }) {
const samePassword = (value: string, values: Record<string, any>) => {
if (value !== values.newPassword) {
return formatMessage(messages.noMatchPassword);
return t(messages.noMatchPassword);
}
return true;
};
@ -31,7 +31,7 @@ export function PasswordEditForm({ onSave, onClose }) {
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField
label={formatMessage(labels.currentPassword)}
label={t(labels.currentPassword)}
name="currentPassword"
rules={{ required: 'Required' }}
>
@ -39,28 +39,28 @@ export function PasswordEditForm({ onSave, onClose }) {
</FormField>
<FormField
name="newPassword"
label={formatMessage(labels.newPassword)}
label={t(labels.newPassword)}
rules={{
required: 'Required',
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
minLength: { value: 8, message: t(messages.minPasswordLength, { n: '8' }) },
}}
>
<PasswordField autoComplete="new-password" />
</FormField>
<FormField
name="confirmPassword"
label={formatMessage(labels.confirmPassword)}
label={t(labels.confirmPassword)}
rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
required: t(labels.required),
minLength: { value: 8, message: t(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>
<Button onPress={onClose}>{t(labels.cancel)}</Button>
<FormSubmitButton isDisabled={isPending}>{t(labels.save)}</FormSubmitButton>
</FormButtons>
</Form>
);

View file

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

View file

@ -7,12 +7,12 @@ import { useMessages } from '@/components/hooks';
import { ProfileSettings } from './ProfileSettings';
export function ProfilePage() {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<PageBody>
<Column gap="6">
<PageHeader title={formatMessage(labels.profile)} />
<PageHeader title={t(labels.profile)} />
<Panel>
<ProfileSettings />
</Panel>

View file

@ -5,7 +5,7 @@ import { PasswordChangeButton } from './PasswordChangeButton';
export function ProfileSettings() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { cloudMode } = useConfig();
if (!user) {
@ -16,31 +16,31 @@ export function ProfileSettings() {
const renderRole = (value: string) => {
if (value === ROLES.user) {
return formatMessage(labels.user);
return t(labels.user);
}
if (value === ROLES.admin) {
return formatMessage(labels.admin);
return t(labels.admin);
}
if (value === ROLES.viewOnly) {
return formatMessage(labels.viewOnly);
return t(labels.viewOnly);
}
return formatMessage(labels.unknown);
return t(labels.unknown);
};
return (
<Column width="400px" gap="6">
<Column gap="6">
<Column>
<Label>{formatMessage(labels.username)}</Label>
<Label>{t(labels.username)}</Label>
{username}
</Column>
<Column>
<Label>{formatMessage(labels.role)}</Label>
<Label>{t(labels.role)}</Label>
{renderRole(role)}
</Column>
{!cloudMode && (
<Column>
<Label>{formatMessage(labels.password)}</Label>
<Label>{t(labels.password)}</Label>
<Row>
<PasswordChangeButton />
</Row>

View file

@ -5,11 +5,11 @@ import { SectionHeader } from '@/components/common/SectionHeader';
import { useMessages } from '@/components/hooks';
export function WebsitesSettingsPage({ teamId }: { teamId: string }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<Column gap>
<SectionHeader title={formatMessage(labels.websites)} />
<SectionHeader title={t(labels.websites)} />
<WebsitesDataTable teamId={teamId} />
</Column>
);

View file

@ -18,7 +18,7 @@ export function TeamAddForm({
onClose: () => void;
isAdmin: boolean;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { t, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
const handleSubmit = async (data: any) => {
@ -32,20 +32,20 @@ export function TeamAddForm({
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField name="name" label={formatMessage(labels.name)}>
<FormField name="name" label={t(labels.name)}>
<TextField autoComplete="off" />
</FormField>
{isAdmin && (
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
<FormField name="ownerId" label={t(labels.teamOwner)}>
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
</FormField>
)}
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
{t(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{formatMessage(labels.save)}
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>

View file

@ -9,7 +9,7 @@ import {
import { useMessages, useUpdateQuery } from '@/components/hooks';
export function TeamJoinForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { t, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, touch } = useUpdateQuery('/teams/join');
const handleSubmit = async (data: any) => {
@ -25,15 +25,15 @@ export function TeamJoinForm({ onSave, onClose }: { onSave: () => void; onClose:
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField
label={formatMessage(labels.accessCode)}
label={t(labels.accessCode)}
name="accessCode"
rules={{ required: formatMessage(labels.required) }}
rules={{ required: t(labels.required) }}
>
<TextField autoComplete="off" />
</FormField>
<FormButtons>
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
<FormSubmitButton variant="primary">{formatMessage(labels.join)}</FormSubmitButton>
<Button onPress={onClose}>{t(labels.cancel)}</Button>
<FormSubmitButton variant="primary">{t(labels.join)}</FormSubmitButton>
</FormButtons>
</Form>
);

View file

@ -5,7 +5,7 @@ import { LogOut } from '@/components/icons';
import { TeamLeaveForm } from './TeamLeaveForm';
export function TeamLeaveButton({ teamId, teamName }: { teamId: string; teamName: string }) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const router = useRouter();
const { user } = useLoginQuery();
const { touch } = useModified();
@ -21,10 +21,10 @@ export function TeamLeaveButton({ teamId, teamName }: { teamId: string; teamName
<Icon>
<LogOut />
</Icon>
<Text>{formatMessage(labels.leave)}</Text>
<Text>{t(labels.leave)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.leaveTeam)} style={{ width: 400 }}>
<Dialog title={t(labels.leaveTeam)} style={{ width: 400 }}>
{({ close }) => (
<TeamLeaveForm
teamId={teamId}

View file

@ -14,7 +14,7 @@ export function TeamLeaveForm({
onSave: () => void;
onClose: () => void;
}) {
const { formatMessage, labels, messages, getErrorMessage, FormattedMessage } = useMessages();
const { t, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useDeleteQuery(`/teams/${teamId}/users/${userId}`);
const { touch } = useModified();
@ -30,15 +30,11 @@ export function TeamLeaveForm({
return (
<ConfirmationForm
buttonLabel={formatMessage(labels.leave)}
message={
<FormattedMessage
{...messages.confirmLeave}
values={{
target: <b>{teamName}</b>,
}}
/>
}
buttonLabel={t(labels.leave)}
message={t.rich(messages.confirmLeave, {
target: teamName,
b: chunks => <b>{chunks}</b>,
})}
onConfirm={handleConfirm}
onClose={onClose}
isLoading={isPending}

View file

@ -22,7 +22,7 @@ export function TeamMemberAddForm({
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { t, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users`);
const handleSubmit = async (data: any) => {
@ -37,24 +37,20 @@ export function TeamMemberAddForm({
const renderRole = role => {
switch (role) {
case ROLES.teamManager:
return formatMessage(labels.manager);
return t(labels.manager);
case ROLES.teamMember:
return formatMessage(labels.member);
return t(labels.member);
case ROLES.teamViewOnly:
return formatMessage(labels.viewOnly);
return t(labels.viewOnly);
}
};
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField
name="userId"
label={formatMessage(labels.username)}
rules={{ required: 'Required' }}
>
<FormField name="userId" label={t(labels.username)} rules={{ required: 'Required' }}>
<UserSelect teamId={teamId} />
</FormField>
<FormField name="role" label={formatMessage(labels.role)} rules={{ required: 'Required' }}>
<FormField name="role" label={t(labels.role)} rules={{ required: 'Required' }}>
<Select renderValue={value => renderRole(value as any)}>
{roles.map(value => (
<ListItem key={value} id={value}>
@ -65,10 +61,10 @@ export function TeamMemberAddForm({
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
{t(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{formatMessage(labels.save)}
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>

View file

@ -1,7 +1,6 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { messages } from '@/components/messages';
import { TeamAddForm } from './TeamAddForm';
export function TeamsAddButton({
@ -11,12 +10,12 @@ export function TeamsAddButton({
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { t, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = async () => {
toast(formatMessage(messages.saved));
toast(t(messages.saved));
touch('teams');
onSave?.();
};
@ -27,10 +26,10 @@ export function TeamsAddButton({
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.createTeam)}</Text>
<Text>{t(labels.createTeam)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
<Dialog title={t(labels.createTeam)} style={{ width: 400 }}>
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
</Dialog>
</Modal>

View file

@ -12,11 +12,11 @@ export function TeamsHeader({
allowCreate?: boolean;
allowJoin?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const { user } = useLoginQuery();
return (
<PageHeader title={formatMessage(labels.teams)}>
<PageHeader title={t(labels.teams)}>
<Row gap="3">
{allowJoin && <TeamsJoinButton />}
{allowCreate && user.role !== ROLES.viewOnly && <TeamsAddButton />}

View file

@ -4,12 +4,12 @@ import { UserPlus } from '@/components/icons';
import { TeamJoinForm } from './TeamJoinForm';
export function TeamsJoinButton() {
const { formatMessage, labels, messages } = useMessages();
const { t, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleJoin = () => {
toast(formatMessage(messages.saved));
toast(t(messages.saved));
touch('teams');
};
@ -19,10 +19,10 @@ export function TeamsJoinButton() {
<Icon>
<UserPlus />
</Icon>
<Text>{formatMessage(labels.joinTeam)}</Text>
<Text>{t(labels.joinTeam)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.joinTeam)} style={{ width: 400 }}>
<Dialog title={t(labels.joinTeam)} style={{ width: 400 }}>
{({ close }) => <TeamJoinForm onSave={handleJoin} onClose={close} />}
</Dialog>
</Modal>

View file

@ -1,7 +1,6 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { messages } from '@/components/messages';
import { TeamMemberAddForm } from './TeamMemberAddForm';
export function TeamsMemberAddButton({
@ -12,12 +11,12 @@ export function TeamsMemberAddButton({
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { t, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = async () => {
toast(formatMessage(messages.saved));
toast(t(messages.saved));
touch('teams:members');
onSave?.();
};
@ -28,10 +27,10 @@ export function TeamsMemberAddButton({
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addMember)}</Text>
<Text>{t(labels.addMember)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addMember)} style={{ width: 400 }}>
<Dialog title={t(labels.addMember)} style={{ width: 400 }}>
{({ close }) => <TeamMemberAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>

View file

@ -8,20 +8,20 @@ export interface TeamsTableProps extends DataTableProps {
}
export function TeamsTable({ renderLink, ...props }: TeamsTableProps) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
return (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
<DataColumn id="name" label={t(labels.name)}>
{renderLink}
</DataColumn>
<DataColumn id="owner" label={formatMessage(labels.owner)}>
<DataColumn id="owner" label={t(labels.owner)}>
{(row: any) => row?.members?.find(({ role }) => role === ROLES.teamOwner)?.user?.username}
</DataColumn>
<DataColumn id="members" label={formatMessage(labels.members)} align="end">
<DataColumn id="members" label={t(labels.members)} align="end">
{(row: any) => row?._count?.members}
</DataColumn>
<DataColumn id="websites" label={formatMessage(labels.websites)} align="end">
<DataColumn id="websites" label={t(labels.websites)} align="end">
{(row: any) => row?._count?.websites}
</DataColumn>
</DataTable>

View file

@ -12,7 +12,7 @@ export function TeamDeleteForm({
onSave?: () => void;
onClose?: () => void;
}) {
const { labels, formatMessage, getErrorMessage } = useMessages();
const { labels, t, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch } = useDeleteQuery(`/teams/${teamId}`);
const handleConfirm = async () => {
@ -33,7 +33,7 @@ export function TeamDeleteForm({
onClose={onClose}
isLoading={isPending}
error={getErrorMessage(error)}
buttonLabel={formatMessage(labels.delete)}
buttonLabel={t(labels.delete)}
buttonVariant="danger"
/>
);

View file

@ -26,14 +26,14 @@ export function TeamEditForm({
onSave?: () => void;
}) {
const team = useTeam();
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
const { t, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/teams/${teamId}`);
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
toast(t(messages.saved));
touch('teams');
touch(`teams:${teamId}`);
onSave?.();
@ -46,30 +46,22 @@ export function TeamEditForm({
{({ setValue }) => {
return (
<>
<FormField name="id" label={formatMessage(labels.teamId)}>
<FormField name="id" label={t(labels.teamId)}>
<TextField isReadOnly allowCopy />
</FormField>
<FormField
name="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField isReadOnly={!allowEdit} />
</FormField>
{showAccessCode && (
<Row alignItems="flex-end" gap>
<FormField
name="accessCode"
label={formatMessage(labels.accessCode)}
style={{ flex: 1 }}
>
<FormField name="accessCode" label={t(labels.accessCode)} style={{ flex: 1 }}>
<TextField isReadOnly allowCopy />
</FormField>
{allowEdit && (
<Button
onPress={() => setValue('accessCode', generateId(), { shouldDirty: true })}
>
<IconLabel icon={<RefreshCw />} label={formatMessage(labels.regenerate)} />
<IconLabel icon={<RefreshCw />} label={t(labels.regenerate)} />
</Button>
)}
</Row>
@ -77,7 +69,7 @@ export function TeamEditForm({
{allowEdit && (
<FormButtons justifyContent="flex-end">
<FormSubmitButton variant="primary" isPending={isPending}>
{formatMessage(labels.save)}
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
)}

View file

@ -5,7 +5,7 @@ import { useMessages, useModified } from '@/components/hooks';
import { TeamDeleteForm } from './TeamDeleteForm';
export function TeamManage({ teamId }: { teamId: string }) {
const { formatMessage, labels, messages } = useMessages();
const { t, labels, messages } = useMessages();
const router = useRouter();
const { touch } = useModified();
@ -15,14 +15,11 @@ export function TeamManage({ teamId }: { teamId: string }) {
};
return (
<ActionForm
label={formatMessage(labels.deleteTeam)}
description={formatMessage(messages.deleteTeamWarning)}
>
<ActionForm label={t(labels.deleteTeam)} description={t(messages.deleteTeamWarning)}>
<DialogTrigger>
<Button variant="danger">{formatMessage(labels.delete)}</Button>
<Button variant="danger">{t(labels.delete)}</Button>
<Modal>
<Dialog title={formatMessage(labels.deleteTeam)} style={{ width: 400 }}>
<Dialog title={t(labels.deleteTeam)} style={{ width: 400 }}>
{({ close }) => <TeamDeleteForm teamId={teamId} onSave={handleLeave} onClose={close} />}
</Dialog>
</Modal>

View file

@ -15,23 +15,18 @@ export function TeamMemberEditButton({
role: string;
onSave?: () => void;
}) {
const { formatMessage, labels, messages } = useMessages();
const { t, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = () => {
touch('teams:members');
toast(formatMessage(messages.saved));
toast(t(messages.saved));
onSave?.();
};
return (
<DialogButton
icon={<Edit />}
title={formatMessage(labels.editMember)}
variant="quiet"
width="400px"
>
<DialogButton icon={<Edit />} title={t(labels.editMember)} variant="quiet" width="400px">
{({ close }) => (
<TeamMemberEditForm
teamId={teamId}

View file

@ -24,7 +24,7 @@ export function TeamMemberEditForm({
onClose?: () => void;
}) {
const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users/${userId}`);
const { formatMessage, labels, getErrorMessage } = useMessages();
const { t, labels, getErrorMessage } = useMessages();
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
@ -37,24 +37,20 @@ export function TeamMemberEditForm({
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ role }}>
<FormField
name="role"
rules={{ required: formatMessage(labels.required) }}
label={formatMessage(labels.role)}
>
<FormField name="role" rules={{ required: t(labels.required) }} label={t(labels.role)}>
<Select>
<ListItem id={ROLES.teamManager}>{formatMessage(labels.manager)}</ListItem>
<ListItem id={ROLES.teamMember}>{formatMessage(labels.member)}</ListItem>
<ListItem id={ROLES.teamViewOnly}>{formatMessage(labels.viewOnly)}</ListItem>
<ListItem id={ROLES.teamManager}>{t(labels.manager)}</ListItem>
<ListItem id={ROLES.teamMember}>{t(labels.member)}</ListItem>
<ListItem id={ROLES.teamViewOnly}>{t(labels.viewOnly)}</ListItem>
</Select>
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
{t(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={false}>
{formatMessage(labels.save)}
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>

View file

@ -2,7 +2,6 @@ import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
export function TeamMemberRemoveButton({
teamId,
@ -16,7 +15,7 @@ export function TeamMemberRemoveButton({
disabled?: boolean;
onSave?: () => void;
}) {
const { formatMessage, labels, FormattedMessage } = useMessages();
const { t, labels, messages } = useMessages();
const { mutateAsync, isPending, error } = useDeleteQuery(`/teams/${teamId}/users/${userId}`);
const { touch } = useModified();
@ -31,27 +30,18 @@ export function TeamMemberRemoveButton({
};
return (
<DialogButton
icon={<Trash />}
title={formatMessage(labels.confirm)}
variant="quiet"
width="400px"
>
<DialogButton icon={<Trash />} title={t(labels.confirm)} variant="quiet" width="400px">
{({ close }) => (
<ConfirmationForm
message={
<FormattedMessage
{...messages.confirmRemove}
values={{
target: <b>{userName}</b>,
}}
/>
}
message={t.rich(messages.confirmRemove, {
target: userName,
b: chunks => <b>{chunks}</b>,
})}
isLoading={isPending}
error={error}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.remove)}
buttonLabel={t(labels.remove)}
buttonVariant="danger"
/>
)}

View file

@ -13,21 +13,21 @@ export function TeamMembersTable({
teamId: string;
allowEdit: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { t, labels } = useMessages();
const roles = {
[ROLES.teamOwner]: formatMessage(labels.teamOwner),
[ROLES.teamManager]: formatMessage(labels.teamManager),
[ROLES.teamMember]: formatMessage(labels.teamMember),
[ROLES.teamViewOnly]: formatMessage(labels.viewOnly),
[ROLES.teamOwner]: t(labels.teamOwner),
[ROLES.teamManager]: t(labels.teamManager),
[ROLES.teamMember]: t(labels.teamMember),
[ROLES.teamViewOnly]: t(labels.viewOnly),
};
return (
<DataTable data={data}>
<DataColumn id="username" label={formatMessage(labels.username)}>
<DataColumn id="username" label={t(labels.username)}>
{(row: any) => row?.user?.username}
</DataColumn>
<DataColumn id="role" label={formatMessage(labels.role)}>
<DataColumn id="role" label={t(labels.role)}>
{(row: any) => roles[row?.role]}
</DataColumn>
{allowEdit && (

Some files were not shown because too many files have changed in this diff Show more