mirror of
https://github.com/umami-software/umami.git
synced 2026-02-19 12:05:41 +01:00
Merge branch 'dev' into master
This commit is contained in:
commit
05cc18882c
1113 changed files with 50574 additions and 28130 deletions
68
src/app/(collect)/p/[slug]/route.ts
Normal file
68
src/app/(collect)/p/[slug]/route.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { notFound } from '@/lib/response';
|
||||
import redis from '@/lib/redis';
|
||||
import { findPixel } from '@/queries/prisma';
|
||||
import { Pixel } from '@/generated/prisma/client';
|
||||
import { POST } from '@/app/api/send/route';
|
||||
|
||||
const image = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw', 'base64');
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
let pixel: Pixel;
|
||||
|
||||
if (redis.enabled) {
|
||||
pixel = await redis.client.fetch(
|
||||
`pixel:${slug}`,
|
||||
async () => {
|
||||
return findPixel({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
},
|
||||
86400,
|
||||
);
|
||||
|
||||
if (!pixel) {
|
||||
return notFound();
|
||||
}
|
||||
} else {
|
||||
pixel = await findPixel({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
if (!pixel) {
|
||||
return notFound();
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: 'event',
|
||||
payload: {
|
||||
pixel: pixel.id,
|
||||
url: request.url,
|
||||
referrer: request.headers.get('referer'),
|
||||
},
|
||||
};
|
||||
|
||||
const req = new Request(request.url, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
await POST(req);
|
||||
|
||||
return new NextResponse(image, {
|
||||
headers: {
|
||||
'Content-Type': 'image/gif',
|
||||
'Content-Length': image.length.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
61
src/app/(collect)/q/[slug]/route.ts
Normal file
61
src/app/(collect)/q/[slug]/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { notFound } from '@/lib/response';
|
||||
import { findLink } from '@/queries/prisma';
|
||||
import { POST } from '@/app/api/send/route';
|
||||
import { Link } from '@/generated/prisma/client';
|
||||
import redis from '@/lib/redis';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
let link: Link;
|
||||
|
||||
if (redis.enabled) {
|
||||
link = await redis.client.fetch(
|
||||
`link:${slug}`,
|
||||
async () => {
|
||||
return findLink({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
},
|
||||
86400,
|
||||
);
|
||||
|
||||
if (!link) {
|
||||
return notFound();
|
||||
}
|
||||
} else {
|
||||
link = await findLink({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
if (!link) {
|
||||
return notFound();
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: 'event',
|
||||
payload: {
|
||||
link: link.id,
|
||||
url: request.url,
|
||||
referrer: request.headers.get('referer'),
|
||||
},
|
||||
};
|
||||
|
||||
const req = new Request(request.url, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
await POST(req);
|
||||
|
||||
return NextResponse.redirect(link.url);
|
||||
}
|
||||
|
|
@ -1,21 +1,26 @@
|
|||
'use client';
|
||||
import { Loading } from 'react-basics';
|
||||
import { Grid, Loading, Column } from '@umami/react-zen';
|
||||
import Script from 'next/script';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useLogin, useConfig } from '@/components/hooks';
|
||||
import UpdateNotice from './UpdateNotice';
|
||||
import { UpdateNotice } from './UpdateNotice';
|
||||
import { SideNav } from '@/app/(main)/SideNav';
|
||||
import { useLoginQuery, useConfig, useNavigation } from '@/components/hooks';
|
||||
|
||||
export function App({ children }) {
|
||||
const { user, isLoading, error } = useLogin();
|
||||
const { user, isLoading, error } = useLoginQuery();
|
||||
const config = useConfig();
|
||||
const pathname = usePathname();
|
||||
const { pathname, router } = useNavigation();
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
if (isLoading || !config) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
window.location.href = `${process.env.basePath || ''}/login`;
|
||||
if (process.env.cloudMode) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user || !config) {
|
||||
|
|
@ -23,14 +28,17 @@ export function App({ children }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Grid height="100vh" width="100%" columns="auto 1fr" backgroundColor="2">
|
||||
<Column>
|
||||
<SideNav />
|
||||
</Column>
|
||||
<Column alignItems="center" overflow="auto" position="relative">
|
||||
{children}
|
||||
</Column>
|
||||
<UpdateNotice user={user} config={config} />
|
||||
{process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
|
||||
<Script src={`${process.env.basePath || ''}/telemetry.js`} />
|
||||
)}
|
||||
</>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
.navbar {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr 1fr;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
background: var(--base75);
|
||||
border-bottom: 1px solid var(--base300);
|
||||
padding: 0 20px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 30px;
|
||||
padding: 0 40px;
|
||||
font-weight: 700;
|
||||
max-height: 60px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.links a,
|
||||
.links a:active,
|
||||
.links a:visited {
|
||||
color: var(--font-color200);
|
||||
line-height: 60px;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
color: var(--font-color100);
|
||||
border-bottom: 2px solid var(--primary400);
|
||||
}
|
||||
|
||||
.links a.selected {
|
||||
color: var(--font-color100);
|
||||
border-bottom: 2px solid var(--primary400);
|
||||
}
|
||||
|
||||
.actions,
|
||||
.mobile {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.navbar {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.links,
|
||||
.actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { Icon, Text } from 'react-basics';
|
||||
import Link from 'next/link';
|
||||
import classNames from 'classnames';
|
||||
import HamburgerButton from '@/components/common/HamburgerButton';
|
||||
import ThemeButton from '@/components/input/ThemeButton';
|
||||
import LanguageButton from '@/components/input/LanguageButton';
|
||||
import ProfileButton from '@/components/input/ProfileButton';
|
||||
import TeamsButton from '@/components/input/TeamsButton';
|
||||
import Icons from '@/components/icons';
|
||||
import { useMessages, useNavigation, useTeamUrl } from '@/components/hooks';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
import styles from './NavBar.module.css';
|
||||
|
||||
export function NavBar() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { pathname, router } = useNavigation();
|
||||
const { teamId, renderTeamUrl } = useTeamUrl();
|
||||
|
||||
const cloudMode = !!process.env.cloudMode;
|
||||
|
||||
const links = [
|
||||
{ label: formatMessage(labels.dashboard), url: renderTeamUrl('/dashboard') },
|
||||
{ label: formatMessage(labels.websites), url: renderTeamUrl('/websites') },
|
||||
{ label: formatMessage(labels.reports), url: renderTeamUrl('/reports') },
|
||||
{ label: formatMessage(labels.settings), url: renderTeamUrl('/settings') },
|
||||
].filter(n => n);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: formatMessage(labels.dashboard),
|
||||
url: renderTeamUrl('/dashboard'),
|
||||
},
|
||||
!cloudMode && {
|
||||
label: formatMessage(labels.settings),
|
||||
url: renderTeamUrl('/settings'),
|
||||
children: [
|
||||
...(teamId
|
||||
? [
|
||||
{
|
||||
label: formatMessage(labels.team),
|
||||
url: renderTeamUrl('/settings/team'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: formatMessage(labels.websites),
|
||||
url: renderTeamUrl('/settings/websites'),
|
||||
},
|
||||
...(!teamId
|
||||
? [
|
||||
{
|
||||
label: formatMessage(labels.teams),
|
||||
url: renderTeamUrl('/settings/teams'),
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.users),
|
||||
url: '/settings/users',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: formatMessage(labels.members),
|
||||
url: renderTeamUrl('/settings/members'),
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.profile),
|
||||
url: '/profile',
|
||||
},
|
||||
!cloudMode && { label: formatMessage(labels.logout), url: '/logout' },
|
||||
].filter(n => n);
|
||||
|
||||
const handleTeamChange = (teamId: string) => {
|
||||
const url = teamId ? `/teams/${teamId}` : '/';
|
||||
if (!cloudMode) {
|
||||
setItem('umami.team', { id: teamId });
|
||||
}
|
||||
router.push(cloudMode ? `${process.env.cloudUrl}${url}` : url);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!cloudMode) {
|
||||
const teamIdLocal = getItem('umami.team')?.id;
|
||||
|
||||
if (teamIdLocal && teamIdLocal !== teamId) {
|
||||
router.push(
|
||||
pathname !== '/' && pathname !== '/dashboard' ? '/' : `/teams/${teamIdLocal}/dashboard`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [cloudMode]);
|
||||
|
||||
return (
|
||||
<div className={styles.navbar}>
|
||||
<div className={styles.logo}>
|
||||
<Icon size="lg">
|
||||
<Icons.Logo />
|
||||
</Icon>
|
||||
<Text>umami</Text>
|
||||
</div>
|
||||
<div className={styles.links}>
|
||||
{links.map(({ url, label }) => {
|
||||
return (
|
||||
<Link
|
||||
key={url}
|
||||
href={url}
|
||||
className={classNames({ [styles.selected]: pathname.startsWith(url) })}
|
||||
prefetch={url !== '/settings'}
|
||||
>
|
||||
<Text>{label}</Text>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<TeamsButton onChange={handleTeamChange} />
|
||||
<ThemeButton />
|
||||
<LanguageButton />
|
||||
<ProfileButton />
|
||||
</div>
|
||||
<div className={styles.mobile}>
|
||||
<TeamsButton onChange={handleTeamChange} showText={false} />
|
||||
<HamburgerButton menuItems={menuItems} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavBar;
|
||||
89
src/app/(main)/SideNav.tsx
Normal file
89
src/app/(main)/SideNav.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Key } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarSection,
|
||||
SidebarItem,
|
||||
SidebarHeader,
|
||||
Row,
|
||||
SidebarProps,
|
||||
ThemeButton,
|
||||
} from '@umami/react-zen';
|
||||
import { Globe, LinkIcon, Grid2x2, PanelLeft } from '@/components/icons';
|
||||
import { Logo } from '@/components/svg';
|
||||
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
|
||||
import { NavButton } from '@/components/input/NavButton';
|
||||
import { PanelButton } from '@/components/input/PanelButton';
|
||||
import { LanguageButton } from '@/components/input/LanguageButton';
|
||||
|
||||
export function SideNav(props: SidebarProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { pathname, renderUrl, websiteId, router } = useNavigation();
|
||||
const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed');
|
||||
|
||||
const hasNav = !!(websiteId || pathname.startsWith('/admin') || pathname.includes('/settings'));
|
||||
|
||||
const links = [
|
||||
{
|
||||
id: 'websites',
|
||||
label: formatMessage(labels.websites),
|
||||
path: '/websites',
|
||||
icon: <Globe />,
|
||||
},
|
||||
{
|
||||
id: 'links',
|
||||
label: formatMessage(labels.links),
|
||||
path: '/links',
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
id: 'pixels',
|
||||
label: formatMessage(labels.pixels),
|
||||
path: '/pixels',
|
||||
icon: <Grid2x2 />,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = (id: Key) => {
|
||||
router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Row height="100%" backgroundColor>
|
||||
<Sidebar {...props} isCollapsed={isCollapsed || hasNav}>
|
||||
<SidebarSection onClick={() => setIsCollapsed(false)}>
|
||||
<SidebarHeader
|
||||
label="umami"
|
||||
icon={isCollapsed && !hasNav ? <PanelLeft /> : <Logo />}
|
||||
style={{ maxHeight: 40 }}
|
||||
>
|
||||
{!isCollapsed && !hasNav && <PanelButton />}
|
||||
</SidebarHeader>
|
||||
</SidebarSection>
|
||||
<SidebarSection paddingTop="0" paddingBottom="0" justifyContent="center">
|
||||
<NavButton showText={!hasNav && !isCollapsed} onAction={handleSelect} />
|
||||
</SidebarSection>
|
||||
<SidebarSection flexGrow={1}>
|
||||
{links.map(({ id, path, label, icon }) => {
|
||||
return (
|
||||
<Link key={id} href={renderUrl(path, false)} role="button">
|
||||
<SidebarItem
|
||||
label={label}
|
||||
icon={icon}
|
||||
isSelected={pathname.includes(path)}
|
||||
role="button"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</SidebarSection>
|
||||
<SidebarSection justifyContent="flex-start">
|
||||
<Row wrap="wrap">
|
||||
<LanguageButton />
|
||||
<ThemeButton />
|
||||
</Row>
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
26
src/app/(main)/TopNav.tsx
Normal file
26
src/app/(main)/TopNav.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { ThemeButton, Row } from '@umami/react-zen';
|
||||
import { LanguageButton } from '@/components/input/LanguageButton';
|
||||
import { ProfileButton } from '@/components/input/ProfileButton';
|
||||
|
||||
export function TopNav() {
|
||||
return (
|
||||
<Row
|
||||
position="absolute"
|
||||
top="0"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
paddingY="2"
|
||||
paddingX="3"
|
||||
paddingRight="5"
|
||||
width="100%"
|
||||
style={{ position: 'sticky', top: 0 }}
|
||||
zIndex={1}
|
||||
>
|
||||
<Row alignItems="center" justifyContent="flex-end" backgroundColor="2" borderRadius>
|
||||
<ThemeButton />
|
||||
<LanguageButton />
|
||||
<ProfileButton />
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
.notice {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
gap: 20px;
|
||||
margin: 60px auto;
|
||||
align-self: center;
|
||||
background: var(--base50);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--base300);
|
||||
border-radius: var(--border-radius);
|
||||
z-index: 9999;
|
||||
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--font-color100);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.message {
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from 'react-basics';
|
||||
import { Button, AlertBanner, Flexbox } from '@umami/react-zen';
|
||||
import { setItem } from '@/lib/storage';
|
||||
import useStore, { checkVersion } from '@/store/version';
|
||||
import { useVersion, checkVersion } from '@/store/version';
|
||||
import { REPO_URL, VERSION_CHECK } from '@/lib/constants';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import styles from './UpdateNotice.module.css';
|
||||
|
||||
export function UpdateNotice({ user, config }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { latest, checked, hasUpdate, releaseUrl } = useStore();
|
||||
const { latest, checked, hasUpdate, releaseUrl } = useVersion();
|
||||
const pathname = usePathname();
|
||||
const [dismissed, setDismissed] = useState(checked);
|
||||
|
||||
|
|
@ -48,20 +46,14 @@ export function UpdateNotice({ user, config }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.notice}>
|
||||
<div className={styles.message}>
|
||||
{formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button variant="primary" onClick={handleViewClick}>
|
||||
return (
|
||||
<Flexbox justifyContent="space-between" alignItems="center">
|
||||
<AlertBanner title={formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}>
|
||||
<Button variant="primary" onPress={handleViewClick}>
|
||||
{formatMessage(labels.viewDetails)}
|
||||
</Button>
|
||||
<Button onClick={handleDismissClick}>{formatMessage(labels.dismiss)}</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
<Button onPress={handleDismissClick}>{formatMessage(labels.dismiss)}</Button>
|
||||
</AlertBanner>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpdateNotice;
|
||||
|
|
|
|||
61
src/app/(main)/admin/AdminLayout.tsx
Normal file
61
src/app/(main)/admin/AdminLayout.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use client';
|
||||
import { ReactNode } from 'react';
|
||||
import { Grid, Column } from '@umami/react-zen';
|
||||
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { User, Users, Globe } from '@/components/icons';
|
||||
import { SideMenu } from '@/components/common/SideMenu';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
|
||||
export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const { user } = useLoginQuery();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { pathname } = useNavigation();
|
||||
|
||||
if (!user.isAdmin || process.env.cloudMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: formatMessage(labels.manage),
|
||||
items: [
|
||||
{
|
||||
id: 'users',
|
||||
label: formatMessage(labels.users),
|
||||
path: '/admin/users',
|
||||
icon: <User />,
|
||||
},
|
||||
{
|
||||
id: 'websites',
|
||||
label: formatMessage(labels.websites),
|
||||
path: '/admin/websites',
|
||||
icon: <Globe />,
|
||||
},
|
||||
{
|
||||
id: 'teams',
|
||||
label: formatMessage(labels.teams),
|
||||
path: '/admin/teams',
|
||||
icon: <Users />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const selectedKey = items
|
||||
.flatMap(e => e.items)
|
||||
?.find(({ path }) => path && pathname.startsWith(path))?.id;
|
||||
|
||||
return (
|
||||
<Grid columns="auto 1fr" width="100%" height="100%">
|
||||
<Column height="100%" border="right" backgroundColor>
|
||||
<SideMenu
|
||||
items={items}
|
||||
title={formatMessage(labels.admin)}
|
||||
selectedKey={selectedKey}
|
||||
allowMinimize={false}
|
||||
/>
|
||||
</Column>
|
||||
<PageBody>{children}</PageBody>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
17
src/app/(main)/admin/layout.tsx
Normal file
17
src/app/(main)/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from 'next';
|
||||
import { AdminLayout } from './AdminLayout';
|
||||
|
||||
export default function ({ children }) {
|
||||
if (process.env.cloudMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s | Admin | Umami',
|
||||
default: 'Admin | Umami',
|
||||
},
|
||||
};
|
||||
19
src/app/(main)/admin/teams/AdminTeamsDataTable.tsx
Normal file
19
src/app/(main)/admin/teams/AdminTeamsDataTable.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { DataGrid } from '@/components/common/DataGrid';
|
||||
import { useTeamsQuery } from '@/components/hooks';
|
||||
import { AdminTeamsTable } from './AdminTeamsTable';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function AdminTeamsDataTable({
|
||||
showActions,
|
||||
}: {
|
||||
showActions?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const queryResult = useTeamsQuery();
|
||||
|
||||
return (
|
||||
<DataGrid query={queryResult} allowSearch={true}>
|
||||
{({ data }) => <AdminTeamsTable data={data} showActions={showActions} />}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
19
src/app/(main)/admin/teams/AdminTeamsPage.tsx
Normal file
19
src/app/(main)/admin/teams/AdminTeamsPage.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function AdminTeamsPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.teams)} />
|
||||
<Panel>
|
||||
<AdminTeamsDataTable />
|
||||
</Panel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
82
src/app/(main)/admin/teams/AdminTeamsTable.tsx
Normal file
82
src/app/(main)/admin/teams/AdminTeamsTable.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { useState } from 'react';
|
||||
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal } from '@umami/react-zen';
|
||||
import Link from 'next/link';
|
||||
import { Trash } from '@/components/icons';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Edit } from '@/components/icons';
|
||||
import { MenuButton } from '@/components/input/MenuButton';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
|
||||
export function AdminTeamsTable({
|
||||
data = [],
|
||||
showActions = true,
|
||||
}: {
|
||||
data: any[];
|
||||
showActions?: boolean;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [deleteUser, setDeleteUser] = useState(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="name" label={formatMessage(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">
|
||||
{(row: any) => row?._count?.members}
|
||||
</DataColumn>
|
||||
<DataColumn id="members" label={formatMessage(labels.websites)} width="140px">
|
||||
{(row: any) => row?._count?.websites}
|
||||
</DataColumn>
|
||||
<DataColumn id="owner" label={formatMessage(labels.owner)}>
|
||||
{(row: any) => {
|
||||
const name = row?.members?.[0]?.user?.username;
|
||||
|
||||
return (
|
||||
<Text title={name} truncate>
|
||||
<Link href={`/admin/users/${row?.members?.[0]?.user?.id}`}>{name}</Link>
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={formatMessage(labels.created)} width="160px">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
{showActions && (
|
||||
<DataColumn id="action" align="end" width="50px">
|
||||
{(row: any) => {
|
||||
const { id } = row;
|
||||
|
||||
return (
|
||||
<MenuButton>
|
||||
<MenuItem href={`/admin/teams/${id}`} data-test="link-button-edit">
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</Row>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
id="delete"
|
||||
onAction={() => setDeleteUser(row)}
|
||||
data-test="link-button-delete"
|
||||
>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Row>
|
||||
</MenuItem>
|
||||
</MenuButton>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
)}
|
||||
</DataTable>
|
||||
<Modal isOpen={!!deleteUser}></Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx
Normal file
11
src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'use client';
|
||||
import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
|
||||
import { TeamProvider } from '@/app/(main)/teams/TeamProvider';
|
||||
|
||||
export function AdminTeamPage({ teamId }: { teamId: string }) {
|
||||
return (
|
||||
<TeamProvider teamId={teamId}>
|
||||
<TeamSettings teamId={teamId} />
|
||||
</TeamProvider>
|
||||
);
|
||||
}
|
||||
12
src/app/(main)/admin/teams/[teamId]/page.tsx
Normal file
12
src/app/(main)/admin/teams/[teamId]/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { AdminTeamPage } from './AdminTeamPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ teamId: string }> }) {
|
||||
const { teamId } = await params;
|
||||
|
||||
return <AdminTeamPage teamId={teamId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Team',
|
||||
};
|
||||
9
src/app/(main)/admin/teams/page.tsx
Normal file
9
src/app/(main)/admin/teams/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Metadata } from 'next';
|
||||
import { AdminTeamsPage } from './AdminTeamsPage';
|
||||
|
||||
export default function () {
|
||||
return <AdminTeamsPage />;
|
||||
}
|
||||
export const metadata: Metadata = {
|
||||
title: 'Teams',
|
||||
};
|
||||
32
src/app/(main)/admin/users/UserAddButton.tsx
Normal file
32
src/app/(main)/admin/users/UserAddButton.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Button, Icon, Text, Modal, DialogTrigger, Dialog, useToast } from '@umami/react-zen';
|
||||
import { UserAddForm } from './UserAddForm';
|
||||
import { useMessages, useModified } from '@/components/hooks';
|
||||
import { Plus } from '@/components/icons';
|
||||
|
||||
export function UserAddButton({ onSave }: { onSave?: () => void }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { toast } = useToast();
|
||||
const { touch } = useModified();
|
||||
|
||||
const handleSave = () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('users');
|
||||
onSave?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button variant="primary" data-test="button-create-user">
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.createUser)}</Text>
|
||||
</Button>
|
||||
<Modal>
|
||||
<Dialog title={formatMessage(labels.createUser)} style={{ width: 400 }}>
|
||||
{({ close }) => <UserAddForm onSave={handleSave} onClose={close} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
71
src/app/(main)/admin/users/UserAddForm.tsx
Normal file
71
src/app/(main)/admin/users/UserAddForm.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import {
|
||||
Select,
|
||||
ListItem,
|
||||
Form,
|
||||
FormField,
|
||||
FormButtons,
|
||||
FormSubmitButton,
|
||||
TextField,
|
||||
PasswordField,
|
||||
Button,
|
||||
} from '@umami/react-zen';
|
||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
|
||||
export function UserAddForm({ onSave, onClose }) {
|
||||
const { mutateAsync, error, isPending } = useUpdateQuery(`/users`);
|
||||
const { formatMessage, labels, getErrorMessage } = useMessages();
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await mutateAsync(data, {
|
||||
onSuccess: async () => {
|
||||
onSave(data);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
|
||||
<FormField
|
||||
label={formatMessage(labels.username)}
|
||||
name="username"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField autoComplete="new-username" data-test="input-username" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={formatMessage(labels.password)}
|
||||
name="password"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<PasswordField autoComplete="new-password" data-test="input-password" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={formatMessage(labels.role)}
|
||||
name="role"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<Select>
|
||||
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
|
||||
{formatMessage(labels.viewOnly)}
|
||||
</ListItem>
|
||||
<ListItem id={ROLES.user} data-test="dropdown-item-user">
|
||||
{formatMessage(labels.user)}
|
||||
</ListItem>
|
||||
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
|
||||
{formatMessage(labels.admin)}
|
||||
</ListItem>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormButtons>
|
||||
<Button isDisabled={isPending} onPress={onClose}>
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={false}>
|
||||
{formatMessage(labels.save)}
|
||||
</FormSubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
35
src/app/(main)/admin/users/UserDeleteButton.tsx
Normal file
35
src/app/(main)/admin/users/UserDeleteButton.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Button, Icon, Modal, DialogTrigger, Dialog, Text } from '@umami/react-zen';
|
||||
import { useMessages, useLoginQuery } from '@/components/hooks';
|
||||
import { Trash } from '@/components/icons';
|
||||
import { UserDeleteForm } from './UserDeleteForm';
|
||||
|
||||
export function UserDeleteButton({
|
||||
userId,
|
||||
username,
|
||||
onDelete,
|
||||
}: {
|
||||
userId: string;
|
||||
username: string;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useLoginQuery();
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button isDisabled={userId === user?.id} data-test="button-delete">
|
||||
<Icon size="sm">
|
||||
<Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Button>
|
||||
<Modal>
|
||||
<Dialog title={formatMessage(labels.deleteUser)} style={{ width: 400 }}>
|
||||
{({ close }) => (
|
||||
<UserDeleteForm userId={userId} username={username} onSave={onDelete} onClose={close} />
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
41
src/app/(main)/admin/users/UserDeleteForm.tsx
Normal file
41
src/app/(main)/admin/users/UserDeleteForm.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { AlertDialog, Row } from '@umami/react-zen';
|
||||
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
|
||||
|
||||
export function UserDeleteForm({
|
||||
userId,
|
||||
username,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
userId: string;
|
||||
username: string;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { messages, labels, formatMessage } = useMessages();
|
||||
const { mutateAsync } = useDeleteQuery(`/users/${userId}`);
|
||||
const { touch } = useModified();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await mutateAsync(null, {
|
||||
onSuccess: async () => {
|
||||
touch('users');
|
||||
touch(`users:${userId}`);
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
title={formatMessage(labels.delete)}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={onClose}
|
||||
confirmLabel={formatMessage(labels.delete)}
|
||||
isDanger
|
||||
>
|
||||
<Row gap="1">{formatMessage(messages.confirmDelete, { target: username })}</Row>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
14
src/app/(main)/admin/users/UsersDataTable.tsx
Normal file
14
src/app/(main)/admin/users/UsersDataTable.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { DataGrid } from '@/components/common/DataGrid';
|
||||
import { useUsersQuery } from '@/components/hooks';
|
||||
import { UsersTable } from './UsersTable';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function UsersDataTable({ showActions }: { showActions?: boolean; children?: ReactNode }) {
|
||||
const queryResult = useUsersQuery();
|
||||
|
||||
return (
|
||||
<DataGrid query={queryResult} allowSearch={true}>
|
||||
{({ data }) => <UsersTable data={data} showActions={showActions} />}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
24
src/app/(main)/admin/users/UsersPage.tsx
Normal file
24
src/app/(main)/admin/users/UsersPage.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client';
|
||||
import { UsersDataTable } from './UsersDataTable';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { UserAddButton } from './UserAddButton';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function UsersPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleSave = () => {};
|
||||
|
||||
return (
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.users)}>
|
||||
<UserAddButton onSave={handleSave} />
|
||||
</PageHeader>
|
||||
<Panel>
|
||||
<UsersDataTable />
|
||||
</Panel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
85
src/app/(main)/admin/users/UsersTable.tsx
Normal file
85
src/app/(main)/admin/users/UsersTable.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useState } from 'react';
|
||||
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal } from '@umami/react-zen';
|
||||
import Link from 'next/link';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { Trash } from '@/components/icons';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Edit } from '@/components/icons';
|
||||
import { MenuButton } from '@/components/input/MenuButton';
|
||||
import { UserDeleteForm } from './UserDeleteForm';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
|
||||
export function UsersTable({
|
||||
data = [],
|
||||
showActions = true,
|
||||
}: {
|
||||
data: any[];
|
||||
showActions?: boolean;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [deleteUser, setDeleteUser] = useState(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="username" label={formatMessage(labels.username)} width="2fr">
|
||||
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
|
||||
</DataColumn>
|
||||
<DataColumn id="role" label={formatMessage(labels.role)}>
|
||||
{(row: any) =>
|
||||
formatMessage(
|
||||
labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown,
|
||||
)
|
||||
}
|
||||
</DataColumn>
|
||||
<DataColumn id="websites" label={formatMessage(labels.websites)}>
|
||||
{(row: any) => row._count.websites}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={formatMessage(labels.created)}>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
{showActions && (
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
{(row: any) => {
|
||||
const { id } = row;
|
||||
|
||||
return (
|
||||
<MenuButton>
|
||||
<MenuItem href={`/admin/users/${id}`} data-test="link-button-edit">
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</Row>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
id="delete"
|
||||
onAction={() => setDeleteUser(row)}
|
||||
data-test="link-button-delete"
|
||||
>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Row>
|
||||
</MenuItem>
|
||||
</MenuButton>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
)}
|
||||
</DataTable>
|
||||
<Modal isOpen={!!deleteUser}>
|
||||
<UserDeleteForm
|
||||
userId={deleteUser?.id}
|
||||
username={deleteUser?.username}
|
||||
onClose={() => {
|
||||
setDeleteUser(null);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
src/app/(main)/admin/users/[userId]/UserEditForm.tsx
Normal file
72
src/app/(main)/admin/users/[userId]/UserEditForm.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import {
|
||||
Select,
|
||||
ListItem,
|
||||
Form,
|
||||
FormField,
|
||||
FormButtons,
|
||||
TextField,
|
||||
FormSubmitButton,
|
||||
PasswordField,
|
||||
} from '@umami/react-zen';
|
||||
import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/components/hooks';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
|
||||
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
|
||||
const { formatMessage, labels, messages, getMessage } = useMessages();
|
||||
const user = useUser();
|
||||
const { user: login } = useLoginQuery();
|
||||
|
||||
const { mutateAsync, error, toast, touch } = useUpdateQuery(`/users/${userId}`);
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await mutateAsync(data, {
|
||||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch(`user:${user.id}`);
|
||||
onSave?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={getMessage(error?.['code'])} values={user}>
|
||||
<FormField name="username" label={formatMessage(labels.username)}>
|
||||
<TextField data-test="input-username" />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="password"
|
||||
label={formatMessage(labels.password)}
|
||||
rules={{
|
||||
minLength: { value: 8, message: formatMessage(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) }}
|
||||
>
|
||||
<Select defaultSelectedKey={user.role}>
|
||||
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
|
||||
{formatMessage(labels.viewOnly)}
|
||||
</ListItem>
|
||||
<ListItem id={ROLES.user} data-test="dropdown-item-user">
|
||||
{formatMessage(labels.user)}
|
||||
</ListItem>
|
||||
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
|
||||
{formatMessage(labels.admin)}
|
||||
</ListItem>
|
||||
</Select>
|
||||
</FormField>
|
||||
)}
|
||||
<FormButtons>
|
||||
<FormSubmitButton data-test="button-submit" variant="primary">
|
||||
{formatMessage(labels.save)}
|
||||
</FormSubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
9
src/app/(main)/admin/users/[userId]/UserHeader.tsx
Normal file
9
src/app/(main)/admin/users/[userId]/UserHeader.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { User } from '@/components/icons';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { useUser } from '@/components/hooks';
|
||||
|
||||
export function UserHeader() {
|
||||
const user = useUser();
|
||||
|
||||
return <PageHeader title={user?.username} icon={<User />} />;
|
||||
}
|
||||
19
src/app/(main)/admin/users/[userId]/UserPage.tsx
Normal file
19
src/app/(main)/admin/users/[userId]/UserPage.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { UserSettings } from './UserSettings';
|
||||
import { UserProvider } from './UserProvider';
|
||||
import { UserHeader } from '@/app/(main)/admin/users/[userId]/UserHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function UserPage({ userId }: { userId: string }) {
|
||||
return (
|
||||
<UserProvider userId={userId}>
|
||||
<Column gap="6">
|
||||
<UserHeader />
|
||||
<Panel>
|
||||
<UserSettings userId={userId} />
|
||||
</Panel>
|
||||
</Column>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
20
src/app/(main)/admin/users/[userId]/UserProvider.tsx
Normal file
20
src/app/(main)/admin/users/[userId]/UserProvider.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { createContext, ReactNode } from 'react';
|
||||
import { Loading } from '@umami/react-zen';
|
||||
import { User } from '@/generated/prisma/client';
|
||||
import { useUserQuery } from '@/components/hooks/queries/useUserQuery';
|
||||
|
||||
export const UserContext = createContext<User>(null);
|
||||
|
||||
export function UserProvider({ userId, children }: { userId: string; children: ReactNode }) {
|
||||
const { data: user, isFetching, isLoading } = useUserQuery(userId);
|
||||
|
||||
if (isFetching && isLoading) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
|
||||
}
|
||||
25
src/app/(main)/admin/users/[userId]/UserSettings.tsx
Normal file
25
src/app/(main)/admin/users/[userId]/UserSettings.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Column, Tabs, Tab, TabList, TabPanel } from '@umami/react-zen';
|
||||
import { UserEditForm } from './UserEditForm';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { UserWebsites } from './UserWebsites';
|
||||
|
||||
export function UserSettings({ userId }: { userId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<Column gap="6">
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="details">{formatMessage(labels.details)}</Tab>
|
||||
<Tab id="websites">{formatMessage(labels.websites)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="details" style={{ width: 500 }}>
|
||||
<UserEditForm userId={userId} />
|
||||
</TabPanel>
|
||||
<TabPanel id="websites">
|
||||
<UserWebsites userId={userId} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
15
src/app/(main)/admin/users/[userId]/UserWebsites.tsx
Normal file
15
src/app/(main)/admin/users/[userId]/UserWebsites.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { DataGrid } from '@/components/common/DataGrid';
|
||||
import { useUserWebsitesQuery } from '@/components/hooks';
|
||||
import { WebsitesTable } from '@/app/(main)/websites/WebsitesTable';
|
||||
|
||||
export function UserWebsites({ userId }) {
|
||||
const queryResult = useUserWebsitesQuery({ userId });
|
||||
|
||||
return (
|
||||
<DataGrid query={queryResult}>
|
||||
{({ data }) => (
|
||||
<WebsitesTable data={data} showActions={true} allowEdit={true} allowView={true} />
|
||||
)}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import UserPage from './UserPage';
|
||||
import { UserPage } from './UserPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default async function ({ params }: { params: { userId: string } }) {
|
||||
export default async function ({ params }: { params: Promise<{ userId: string }> }) {
|
||||
const { userId } = await params;
|
||||
|
||||
return <UserPage userId={userId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'User Settings',
|
||||
title: 'User',
|
||||
};
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { Metadata } from 'next';
|
||||
import UsersSettingsPage from './UsersSettingsPage';
|
||||
import { UsersPage } from './UsersPage';
|
||||
|
||||
export default function () {
|
||||
return <UsersSettingsPage />;
|
||||
return <UsersPage />;
|
||||
}
|
||||
export const metadata: Metadata = {
|
||||
title: 'Users',
|
||||
13
src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx
Normal file
13
src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { DataGrid } from '@/components/common/DataGrid';
|
||||
import { useWebsitesQuery } from '@/components/hooks';
|
||||
import { AdminWebsitesTable } from './AdminWebsitesTable';
|
||||
|
||||
export function AdminWebsitesDataTable() {
|
||||
const query = useWebsitesQuery();
|
||||
|
||||
return (
|
||||
<DataGrid query={query} allowSearch={true}>
|
||||
{props => <AdminWebsitesTable {...props} />}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
19
src/app/(main)/admin/websites/AdminWebsitesPage.tsx
Normal file
19
src/app/(main)/admin/websites/AdminWebsitesPage.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
import { AdminWebsitesDataTable } from './AdminWebsitesDataTable';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function AdminWebsitesPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.websites)} />
|
||||
<Panel>
|
||||
<AdminWebsitesDataTable />
|
||||
</Panel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
90
src/app/(main)/admin/websites/AdminWebsitesTable.tsx
Normal file
90
src/app/(main)/admin/websites/AdminWebsitesTable.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal, Dialog } from '@umami/react-zen';
|
||||
import { Trash, Users } from '@/components/icons';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Edit } from '@/components/icons';
|
||||
import { MenuButton } from '@/components/input/MenuButton';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { WebsiteDeleteForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
|
||||
|
||||
export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [deleteWebsite, setDeleteWebsite] = useState(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||
{(row: any) => (
|
||||
<Text truncate>
|
||||
<Link href={`/admin/websites/${row.id}`}>{row.name}</Link>
|
||||
</Text>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="domain" label={formatMessage(labels.domain)}>
|
||||
{(row: any) => <Text truncate>{row.domain}</Text>}
|
||||
</DataColumn>
|
||||
<DataColumn id="owner" label={formatMessage(labels.owner)}>
|
||||
{(row: any) => {
|
||||
if (row?.team) {
|
||||
return (
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Users />
|
||||
</Icon>
|
||||
<Text truncate>
|
||||
<Link href={`/admin/teams/${row?.team?.id}`}>{row?.team?.name}</Link>
|
||||
</Text>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text truncate>
|
||||
<Link href={`/admin/users/${row?.user?.id}`}>{row?.user?.username}</Link>
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={formatMessage(labels.created)} width="180px">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="50px">
|
||||
{(row: any) => {
|
||||
const { id } = row;
|
||||
|
||||
return (
|
||||
<MenuButton>
|
||||
<MenuItem href={`/admin/websites/${id}`} data-test="link-button-edit">
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</Row>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
id="delete"
|
||||
onAction={() => setDeleteWebsite(id)}
|
||||
data-test="link-button-delete"
|
||||
>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Row>
|
||||
</MenuItem>
|
||||
</MenuButton>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
<Modal isOpen={!!deleteWebsite}>
|
||||
<Dialog style={{ width: 400 }}>
|
||||
<WebsiteDeleteForm websiteId={deleteWebsite} onClose={() => setDeleteWebsite(null)} />
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
'use client';
|
||||
import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function AdminWebsitePage({ websiteId }: { websiteId: string }) {
|
||||
return (
|
||||
<WebsiteProvider websiteId={websiteId}>
|
||||
<Panel>
|
||||
<WebsiteSettings websiteId={websiteId} />
|
||||
</Panel>
|
||||
</WebsiteProvider>
|
||||
);
|
||||
}
|
||||
12
src/app/(main)/admin/websites/[websiteId]/page.tsx
Normal file
12
src/app/(main)/admin/websites/[websiteId]/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Metadata } from 'next';
|
||||
import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||
const { websiteId } = await params;
|
||||
|
||||
return <WebsiteSettingsPage websiteId={websiteId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Website',
|
||||
};
|
||||
9
src/app/(main)/admin/websites/page.tsx
Normal file
9
src/app/(main)/admin/websites/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Metadata } from 'next';
|
||||
import { AdminWebsitesPage } from './AdminWebsitesPage';
|
||||
|
||||
export default function () {
|
||||
return <AdminWebsitesPage />;
|
||||
}
|
||||
export const metadata: Metadata = {
|
||||
title: 'Websites',
|
||||
};
|
||||
32
src/app/(main)/boards/BoardAddButton.tsx
Normal file
32
src/app/(main)/boards/BoardAddButton.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useMessages, useModified, useNavigation } from '@/components/hooks';
|
||||
import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen';
|
||||
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-website-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>
|
||||
);
|
||||
}
|
||||
60
src/app/(main)/boards/BoardAddForm.tsx
Normal file
60
src/app/(main)/boards/BoardAddForm.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Form, FormField, FormSubmitButton, Row, TextField, Button } from '@umami/react-zen';
|
||||
import { useUpdateQuery, useMessages } from '@/components/hooks';
|
||||
import { DOMAIN_REGEX } from '@/lib/constants';
|
||||
|
||||
export function BoardAddForm({
|
||||
teamId,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
teamId?: string;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { mutateAsync, error, isPending } = useUpdateQuery('/websites', { teamId });
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await mutateAsync(data, {
|
||||
onSuccess: async () => {
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={error?.message}>
|
||||
<FormField
|
||||
label={formatMessage(labels.name)}
|
||||
data-test="input-name"
|
||||
name="name"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={formatMessage(labels.domain)}
|
||||
data-test="input-domain"
|
||||
name="domain"
|
||||
rules={{
|
||||
required: formatMessage(labels.required),
|
||||
pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
|
||||
}}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
</FormField>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
17
src/app/(main)/boards/BoardsPage.tsx
Normal file
17
src/app/(main)/boards/BoardsPage.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { BoardAddButton } from './BoardAddButton';
|
||||
|
||||
export function BoardsPage() {
|
||||
return (
|
||||
<PageBody>
|
||||
<Column margin="2">
|
||||
<PageHeader title="My Boards">
|
||||
<BoardAddButton />
|
||||
</PageHeader>
|
||||
</Column>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
10
src/app/(main)/boards/[boardId]/Board.tsx
Normal file
10
src/app/(main)/boards/[boardId]/Board.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Column, Heading } from '@umami/react-zen';
|
||||
|
||||
export function Board({ boardId }: { boardId: string }) {
|
||||
return (
|
||||
<Column>
|
||||
<Heading>Board title</Heading>
|
||||
<div>{boardId}</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
12
src/app/(main)/boards/[boardId]/page.tsx
Normal file
12
src/app/(main)/boards/[boardId]/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Metadata } from 'next';
|
||||
import { Board } from './Board';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
|
||||
const { boardId } = await params;
|
||||
|
||||
return <Board boardId={boardId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Board',
|
||||
};
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import ReportsPage from './ReportsPage';
|
||||
import { Metadata } from 'next';
|
||||
import { BoardsPage } from './BoardsPage';
|
||||
|
||||
export default function () {
|
||||
return <ReportsPage />;
|
||||
return <BoardsPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Reports',
|
||||
title: 'Boards',
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
.container {
|
||||
display: grid;
|
||||
gap: 30px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
border: 1px solid var(--base400);
|
||||
border-radius: 5px;
|
||||
padding: 0 20px 20px 20px;
|
||||
display: grid;
|
||||
gap: 40px;
|
||||
grid-template-columns: repeat(3, minmax(300px, 1fr));
|
||||
box-shadow: 0 0 0 10px var(--base100);
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wrapped {
|
||||
border: 1px solid var(--blue900);
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
|
@ -1,26 +1,16 @@
|
|||
'use client';
|
||||
import { Button } from 'react-basics';
|
||||
import { Button, Grid, Column, Heading } from '@umami/react-zen';
|
||||
import Link from 'next/link';
|
||||
import Script from 'next/script';
|
||||
import WebsiteSelect from '@/components/input/WebsiteSelect';
|
||||
import Page from '@/components/layout/Page';
|
||||
import PageHeader from '@/components/layout/PageHeader';
|
||||
import EventsChart from '@/components/metrics/EventsChart';
|
||||
import WebsiteChart from '../websites/[websiteId]/WebsiteChart';
|
||||
import { useApi, useNavigation } from '@/components/hooks';
|
||||
import styles from './TestConsole.module.css';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { EventsChart } from '@/components/metrics/EventsChart';
|
||||
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||
import { useWebsiteQuery } from '@/components/hooks';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
|
||||
export function TestConsole({ websiteId }: { websiteId?: string }) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['websites:me'],
|
||||
queryFn: () => get('/me/websites'),
|
||||
});
|
||||
const { router } = useNavigation();
|
||||
|
||||
function handleChange(value: string) {
|
||||
router.push(`/console/${value}`);
|
||||
}
|
||||
export function TestConsolePage({ websiteId }: { websiteId: string }) {
|
||||
const { data } = useWebsiteQuery(websiteId);
|
||||
|
||||
function handleRunScript() {
|
||||
window['umami'].track(props => ({
|
||||
|
|
@ -114,29 +104,27 @@ export function TestConsole({ websiteId }: { websiteId?: string }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const website = data?.data.find(({ id }) => websiteId === id);
|
||||
|
||||
return (
|
||||
<Page isLoading={isLoading} error={error}>
|
||||
<PageBody>
|
||||
<PageHeader title="Test console">
|
||||
<WebsiteSelect websiteId={website?.id} onSelect={handleChange} />
|
||||
<Column>{data.name}</Column>
|
||||
</PageHeader>
|
||||
{website && (
|
||||
<div className={styles.container}>
|
||||
<Script
|
||||
async
|
||||
data-website-id={websiteId}
|
||||
src={`${process.env.basePath || ''}/script.js`}
|
||||
data-cache="true"
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.group}>
|
||||
<div className={styles.header}>Page links</div>
|
||||
<Column gap="6" paddingY="6">
|
||||
<Script
|
||||
async
|
||||
data-website-id={websiteId}
|
||||
src={`${process.env.basePath || ''}/script.js`}
|
||||
data-cache="true"
|
||||
/>
|
||||
<Panel>
|
||||
<Grid columns="1fr 1fr 1fr" gap>
|
||||
<Column gap>
|
||||
<Heading>Page links</Heading>
|
||||
<div>
|
||||
<Link href={`/console/${websiteId}/page/1/?q=abc`}>page one</Link>
|
||||
<Link href={`/console/${websiteId}?page=1`}>page one</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link href={`/console/${websiteId}/page/2/?q=123 `}>page two</Link>
|
||||
<Link href={`/console/${websiteId}?page=2 `}>page two</Link>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://www.google.com" data-umami-event="external-link-direct">
|
||||
|
|
@ -153,9 +141,9 @@ export function TestConsole({ websiteId }: { websiteId?: string }) {
|
|||
external link (tab)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.group}>
|
||||
<div className={styles.header}>Click events</div>
|
||||
</Column>
|
||||
<Column gap>
|
||||
<Heading>Click events</Heading>
|
||||
<Button id="send-event-button" data-umami-event="button-click" variant="primary">
|
||||
Send event
|
||||
</Button>
|
||||
|
|
@ -184,21 +172,17 @@ export function TestConsole({ websiteId }: { websiteId?: string }) {
|
|||
data-umami-event-id="123"
|
||||
variant="primary"
|
||||
>
|
||||
<div className={styles.wrapped}>Button with div</div>
|
||||
<div>Button with div</div>
|
||||
</Button>
|
||||
<div data-umami-event="div-click" className={styles.wrapped}>
|
||||
DIV with attribute
|
||||
</div>
|
||||
<div data-umami-event="div-click-one" className={styles.wrapped}>
|
||||
<div data-umami-event="div-click-two" className={styles.wrapped}>
|
||||
<div data-umami-event="div-click-three" className={styles.wrapped}>
|
||||
Nested DIV
|
||||
</div>
|
||||
<div data-umami-event="div-click">DIV with attribute</div>
|
||||
<div data-umami-event="div-click-one">
|
||||
<div data-umami-event="div-click-two">
|
||||
<div data-umami-event="div-click-three">Nested DIV</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.group}>
|
||||
<div className={styles.header}>Javascript events</div>
|
||||
</Column>
|
||||
<Column gap>
|
||||
<Heading>Javascript events</Heading>
|
||||
<Button id="manual-button" variant="primary" onClick={handleRunScript}>
|
||||
Run script
|
||||
</Button>
|
||||
|
|
@ -208,14 +192,16 @@ export function TestConsole({ websiteId }: { websiteId?: string }) {
|
|||
<Button id="manual-button" variant="primary" onClick={handleRunRevenue}>
|
||||
Revenue script
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WebsiteChart websiteId={website.id} />
|
||||
<EventsChart websiteId={website.id} />
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
</Column>
|
||||
</Grid>
|
||||
</Panel>
|
||||
<Heading>Pageviews</Heading>
|
||||
<WebsiteChart websiteId={websiteId} />
|
||||
<Heading>Events</Heading>
|
||||
<Panel>
|
||||
<EventsChart websiteId={websiteId} />
|
||||
</Panel>
|
||||
</Column>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestConsole;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Metadata } from 'next';
|
||||
import TestConsole from '../TestConsole';
|
||||
import { TestConsolePage } from './TestConsolePage';
|
||||
|
||||
async function getEnabled() {
|
||||
return !!process.env.ENABLE_TEST_CONSOLE;
|
||||
|
|
@ -14,7 +14,7 @@ export default async function ({ params }: { params: Promise<{ websiteId: string
|
|||
return null;
|
||||
}
|
||||
|
||||
return <TestConsole websiteId={websiteId?.[0]} />;
|
||||
return <TestConsolePage websiteId={websiteId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--base400);
|
||||
background: var(--base50);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.text {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.domain {
|
||||
font-size: 14px;
|
||||
color: var(--base700);
|
||||
}
|
||||
|
||||
.dragActive {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.dragActive:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.search {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: var(--base600);
|
||||
box-shadow: 4px 4px 4px var(--base100);
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import classNames from 'classnames';
|
||||
import { Button, Loading, Toggle, SearchField } from 'react-basics';
|
||||
import { firstBy } from 'thenby';
|
||||
import useDashboard, { saveDashboard } from '@/store/dashboard';
|
||||
import { useMessages, useWebsites } from '@/components/hooks';
|
||||
import styles from './DashboardEdit.module.css';
|
||||
|
||||
const DRAG_ID = 'dashboard-website-ordering';
|
||||
|
||||
export function DashboardEdit({ teamId }: { teamId: string }) {
|
||||
const settings = useDashboard();
|
||||
const { websiteOrder, websiteActive, isEdited } = settings;
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [order, setOrder] = useState(websiteOrder || []);
|
||||
const [active, setActive] = useState(websiteActive || []);
|
||||
const [edited, setEdited] = useState(isEdited);
|
||||
const [websites, setWebsites] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const {
|
||||
result,
|
||||
query: { isLoading },
|
||||
setParams,
|
||||
} = useWebsites({ teamId });
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.data) {
|
||||
setWebsites(prevWebsites => {
|
||||
const newWebsites = [...prevWebsites, ...result.data];
|
||||
if (newWebsites.length < result.count) {
|
||||
setParams(prevParams => ({ ...prevParams, page: prevParams.page + 1 }));
|
||||
}
|
||||
return newWebsites;
|
||||
});
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
const ordered = useMemo(() => {
|
||||
if (websites) {
|
||||
return websites
|
||||
.map((website: { id: any; name: string; domain: string }) => ({
|
||||
...website,
|
||||
order: order.indexOf(website.id),
|
||||
}))
|
||||
.sort(firstBy('order'));
|
||||
}
|
||||
return [];
|
||||
}, [websites, order]);
|
||||
|
||||
function handleWebsiteDrag({ destination, source }) {
|
||||
if (!destination || destination.index === source.index) return;
|
||||
|
||||
const orderedWebsites = [...ordered];
|
||||
const [removed] = orderedWebsites.splice(source.index, 1);
|
||||
orderedWebsites.splice(destination.index, 0, removed);
|
||||
|
||||
setOrder(orderedWebsites.map(website => website?.id || 0));
|
||||
setEdited(true);
|
||||
}
|
||||
|
||||
function handleActiveWebsites(id: string) {
|
||||
setActive(prevActive =>
|
||||
prevActive.includes(id) ? prevActive.filter(a => a !== id) : [...prevActive, id],
|
||||
);
|
||||
setEdited(true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
saveDashboard({
|
||||
editing: false,
|
||||
isEdited: edited,
|
||||
websiteOrder: order,
|
||||
websiteActive: active,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
saveDashboard({ editing: false, websiteOrder, websiteActive, isEdited });
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setOrder([]);
|
||||
setActive([]);
|
||||
setEdited(false);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<SearchField className={styles.search} value={search} onSearch={setSearch} />
|
||||
<div className={styles.buttons}>
|
||||
<Button onClick={handleSave} variant="primary" size="sm">
|
||||
{formatMessage(labels.save)}
|
||||
</Button>
|
||||
<Button onClick={handleCancel} size="sm">
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
<Button onClick={handleReset} size="sm">
|
||||
{formatMessage(labels.reset)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.dragActive}>
|
||||
<DragDropContext onDragEnd={handleWebsiteDrag}>
|
||||
<Droppable droppableId={DRAG_ID}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
style={{ marginBottom: snapshot.isDraggingOver ? 260 : null }}
|
||||
>
|
||||
{ordered.map(({ id, name, domain }, index) => {
|
||||
if (
|
||||
search &&
|
||||
!`${name.toLowerCase()}${domain.toLowerCase()}`.includes(search.toLowerCase())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Draggable key={id} draggableId={`${DRAG_ID}-${id}`} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
className={classNames(styles.item, {
|
||||
[styles.active]: snapshot.isDragging,
|
||||
})}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<div className={styles.text}>
|
||||
<div className={styles.name}>{name}</div>
|
||||
<div className={styles.domain}>{domain}</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={active.includes(id)}
|
||||
onChange={() => handleActiveWebsites(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardEdit;
|
||||
|
|
@ -1,33 +1,11 @@
|
|||
'use client';
|
||||
import { Icon, Icons, Loading, Text } from 'react-basics';
|
||||
import PageHeader from '@/components/layout/PageHeader';
|
||||
import Pager from '@/components/common/Pager';
|
||||
import WebsiteChartList from '../websites/[websiteId]/WebsiteChartList';
|
||||
import DashboardSettingsButton from '@/app/(main)/dashboard/DashboardSettingsButton';
|
||||
import DashboardEdit from '@/app/(main)/dashboard/DashboardEdit';
|
||||
import EmptyPlaceholder from '@/components/common/EmptyPlaceholder';
|
||||
import { useMessages, useLocale, useTeamUrl, useWebsites } from '@/components/hooks';
|
||||
import useDashboard from '@/store/dashboard';
|
||||
import LinkButton from '@/components/common/LinkButton';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { teamId, renderTeamUrl } = useTeamUrl();
|
||||
const { showCharts, editing, isEdited } = useDashboard();
|
||||
const { dir } = useLocale();
|
||||
const pageSize = isEdited ? 200 : 10;
|
||||
|
||||
const { result, query, params, setParams } = useWebsites({ teamId }, { pageSize });
|
||||
const { page } = params;
|
||||
const hasData = !!result?.data?.length;
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setParams({ ...params, page });
|
||||
};
|
||||
|
||||
if (query.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<section style={{ marginBottom: 60 }}>
|
||||
|
|
@ -67,5 +45,3 @@ export function DashboardPage() {
|
|||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
.buttonGroup {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { TooltipPopup, Icon, Text, Flexbox, Button } from 'react-basics';
|
||||
import Icons from '@/components/icons';
|
||||
import { saveDashboard } from '@/store/dashboard';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function DashboardSettingsButton() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleToggleCharts = () => {
|
||||
saveDashboard(state => ({ showCharts: !state.showCharts }));
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
saveDashboard({ editing: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={10}>
|
||||
<TooltipPopup label={formatMessage(labels.toggleCharts)} position="bottom">
|
||||
<Button onClick={handleToggleCharts}>
|
||||
<Icon>
|
||||
<Icons.BarChart />
|
||||
</Icon>
|
||||
</Button>
|
||||
</TooltipPopup>
|
||||
<Button onClick={handleEdit}>
|
||||
<Icon>
|
||||
<Icons.Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardSettingsButton;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import DashboardPage from './DashboardPage';
|
||||
import { Metadata } from 'next';
|
||||
import { DashboardPage } from './DashboardPage';
|
||||
|
||||
export default function () {
|
||||
export default async function () {
|
||||
return <DashboardPage />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
.layout {
|
||||
display: grid;
|
||||
grid-template-rows: max-content 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.body {
|
||||
grid-column: 1;
|
||||
grid-row: 2 / 3;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 60px);
|
||||
height: calc(100dvh - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
@ -1,21 +1,12 @@
|
|||
import { Suspense } from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import App from './App';
|
||||
import NavBar from './NavBar';
|
||||
import Page from '@/components/layout/Page';
|
||||
import styles from './layout.module.css';
|
||||
import { App } from './App';
|
||||
|
||||
export default async function ({ children }) {
|
||||
export default function ({ children }) {
|
||||
return (
|
||||
<App>
|
||||
<main className={styles.layout}>
|
||||
<nav className={styles.nav}>
|
||||
<NavBar />
|
||||
</nav>
|
||||
<section className={styles.body}>
|
||||
<Page>{children}</Page>
|
||||
</section>
|
||||
</main>
|
||||
</App>
|
||||
<Suspense>
|
||||
<App>{children}</App>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
24
src/app/(main)/links/LinkAddButton.tsx
Normal file
24
src/app/(main)/links/LinkAddButton.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useMessages } from '@/components/hooks';
|
||||
import { Button, Icon, Modal, Dialog, DialogTrigger, Text } from '@umami/react-zen';
|
||||
import { Plus } from '@/components/icons';
|
||||
import { LinkEditForm } from './LinkEditForm';
|
||||
|
||||
export function LinkAddButton({ teamId }: { teamId?: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button data-test="button-website-add" variant="primary">
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.addLink)}</Text>
|
||||
</Button>
|
||||
<Modal>
|
||||
<Dialog title={formatMessage(labels.addLink)} style={{ width: 600 }}>
|
||||
{({ close }) => <LinkEditForm teamId={teamId} onClose={close} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
55
src/app/(main)/links/LinkDeleteButton.tsx
Normal file
55
src/app/(main)/links/LinkDeleteButton.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Dialog } from '@umami/react-zen';
|
||||
import { ActionButton } from '@/components/input/ActionButton';
|
||||
import { Trash } from '@/components/icons';
|
||||
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
||||
import { messages } from '@/components/messages';
|
||||
import { useDeleteQuery, useMessages } from '@/components/hooks';
|
||||
|
||||
export function LinkDeleteButton({
|
||||
linkId,
|
||||
name,
|
||||
onSave,
|
||||
}: {
|
||||
linkId: string;
|
||||
websiteId: string;
|
||||
name: string;
|
||||
onSave?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
|
||||
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`);
|
||||
|
||||
const handleConfirm = async (close: () => void) => {
|
||||
await mutateAsync(null, {
|
||||
onSuccess: () => {
|
||||
touch('links');
|
||||
onSave?.();
|
||||
close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton title={formatMessage(labels.delete)} icon={<Trash />}>
|
||||
<Dialog title={formatMessage(labels.confirm)} style={{ width: 400 }}>
|
||||
{({ close }) => (
|
||||
<ConfirmationForm
|
||||
message={
|
||||
<FormattedMessage
|
||||
{...messages.confirmRemove}
|
||||
values={{
|
||||
target: <b>{name}</b>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isLoading={isPending}
|
||||
error={getErrorMessage(error)}
|
||||
onConfirm={handleConfirm.bind(null, close)}
|
||||
onClose={close}
|
||||
buttonLabel={formatMessage(labels.delete)}
|
||||
buttonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
19
src/app/(main)/links/LinkEditButton.tsx
Normal file
19
src/app/(main)/links/LinkEditButton.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { ActionButton } from '@/components/input/ActionButton';
|
||||
import { Edit } from '@/components/icons';
|
||||
import { Dialog } from '@umami/react-zen';
|
||||
import { LinkEditForm } from './LinkEditForm';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function LinkEditButton({ linkId }: { linkId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
|
||||
<Dialog title={formatMessage(labels.link)} style={{ width: 800, minHeight: 300 }}>
|
||||
{({ close }) => {
|
||||
return <LinkEditForm linkId={linkId} onClose={close} />;
|
||||
}}
|
||||
</Dialog>
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
149
src/app/(main)/links/LinkEditForm.tsx
Normal file
149
src/app/(main)/links/LinkEditForm.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormSubmitButton,
|
||||
Row,
|
||||
TextField,
|
||||
Button,
|
||||
Label,
|
||||
Column,
|
||||
Icon,
|
||||
Loading,
|
||||
} from '@umami/react-zen';
|
||||
import { useConfig, useLinkQuery } from '@/components/hooks';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { RefreshCw } from '@/components/icons';
|
||||
import { getRandomChars } from '@/lib/generate';
|
||||
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
|
||||
import { LINKS_URL } from '@/lib/constants';
|
||||
import { isValidUrl } from '@/lib/url';
|
||||
|
||||
const generateId = () => getRandomChars(9);
|
||||
|
||||
export function LinkEditForm({
|
||||
linkId,
|
||||
teamId,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
linkId?: string;
|
||||
teamId?: string;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
|
||||
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
|
||||
linkId ? `/links/${linkId}` : '/links',
|
||||
{
|
||||
id: linkId,
|
||||
teamId,
|
||||
},
|
||||
);
|
||||
const { linksUrl } = useConfig();
|
||||
const hostUrl = linksUrl || LINKS_URL;
|
||||
const { data, isLoading } = useLinkQuery(linkId);
|
||||
const [slug, setSlug] = useState(generateId());
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await mutateAsync(data, {
|
||||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('links');
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSlug = () => {
|
||||
const slug = generateId();
|
||||
|
||||
setSlug(slug);
|
||||
|
||||
return slug;
|
||||
};
|
||||
|
||||
const checkUrl = (url: string) => {
|
||||
if (!isValidUrl(url)) {
|
||||
return formatMessage(labels.invalidUrl);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSlug(data.slug);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (linkId && isLoading) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
|
||||
{({ setValue }) => {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
label={formatMessage(labels.name)}
|
||||
name="name"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField autoComplete="off" autoFocus />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={formatMessage(labels.destinationUrl)}
|
||||
name="url"
|
||||
rules={{ required: formatMessage(labels.required), validate: checkUrl }}
|
||||
>
|
||||
<TextField placeholder="https://example.com" autoComplete="off" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
name="slug"
|
||||
rules={{
|
||||
required: formatMessage(labels.required),
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<input type="hidden" />
|
||||
</FormField>
|
||||
|
||||
<Column>
|
||||
<Label>{formatMessage(labels.link)}</Label>
|
||||
<Row alignItems="center" gap>
|
||||
<TextField
|
||||
value={`${hostUrl}/${slug}`}
|
||||
autoComplete="off"
|
||||
isReadOnly
|
||||
allowCopy
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button
|
||||
variant="quiet"
|
||||
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}
|
||||
>
|
||||
<Icon>
|
||||
<RefreshCw />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
|
||||
<Row justifyContent="flex-end" paddingTop="3" gap="3">
|
||||
{onClose && (
|
||||
<Button isDisabled={isPending} onPress={onClose}>
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
)}
|
||||
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
21
src/app/(main)/links/LinkProvider.tsx
Normal file
21
src/app/(main)/links/LinkProvider.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
import { createContext, ReactNode } from 'react';
|
||||
import { Loading } from '@umami/react-zen';
|
||||
import { Link } from '@/generated/prisma/client';
|
||||
import { useLinkQuery } from '@/components/hooks/queries/useLinkQuery';
|
||||
|
||||
export const LinkContext = createContext<Link>(null);
|
||||
|
||||
export function LinkProvider({ linkId, children }: { linkId?: string; children: ReactNode }) {
|
||||
const { data: link, isLoading, isFetching } = useLinkQuery(linkId);
|
||||
|
||||
if (isFetching && isLoading) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <LinkContext.Provider value={link}>{children}</LinkContext.Provider>;
|
||||
}
|
||||
14
src/app/(main)/links/LinksDataTable.tsx
Normal file
14
src/app/(main)/links/LinksDataTable.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { useLinksQuery, useNavigation } from '@/components/hooks';
|
||||
import { LinksTable } from './LinksTable';
|
||||
import { DataGrid } from '@/components/common/DataGrid';
|
||||
|
||||
export function LinksDataTable() {
|
||||
const { teamId } = useNavigation();
|
||||
const query = useLinksQuery({ teamId });
|
||||
|
||||
return (
|
||||
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
|
||||
{({ data }) => <LinksTable data={data} />}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
26
src/app/(main)/links/LinksPage.tsx
Normal file
26
src/app/(main)/links/LinksPage.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use client';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { LinkAddButton } from './LinkAddButton';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function LinksPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { teamId } = useNavigation();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.links)}>
|
||||
<LinkAddButton teamId={teamId} />
|
||||
</PageHeader>
|
||||
<Panel>
|
||||
<LinksDataTable />
|
||||
</Panel>
|
||||
</Column>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
52
src/app/(main)/links/LinksTable.tsx
Normal file
52
src/app/(main)/links/LinksTable.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import Link from 'next/link';
|
||||
import { DataTable, DataColumn, Row } from '@umami/react-zen';
|
||||
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||
import { LinkEditButton } from './LinkEditButton';
|
||||
import { LinkDeleteButton } from './LinkDeleteButton';
|
||||
|
||||
export function LinksTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { websiteId, renderUrl } = useNavigation();
|
||||
const { getSlugUrl } = useSlug('link');
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||
{({ id, name }: any) => {
|
||||
return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="slug" label={formatMessage(labels.link)}>
|
||||
{({ slug }: any) => {
|
||||
const url = getSlugUrl(slug);
|
||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="url" label={formatMessage(labels.destinationUrl)}>
|
||||
{({ url }: any) => {
|
||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={formatMessage(labels.created)} width="200px">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
{({ id, name }: any) => {
|
||||
return (
|
||||
<Row>
|
||||
<LinkEditButton linkId={id} />
|
||||
<LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
32
src/app/(main)/links/[linkId]/LinkControls.tsx
Normal file
32
src/app/(main)/links/[linkId]/LinkControls.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Column, Row } from '@umami/react-zen';
|
||||
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
|
||||
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
||||
import { FilterBar } from '@/components/input/FilterBar';
|
||||
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
|
||||
import { ExportButton } from '@/components/input/ExportButton';
|
||||
|
||||
export function LinkControls({
|
||||
linkId: websiteId,
|
||||
allowFilter = true,
|
||||
allowDateFilter = true,
|
||||
allowMonthFilter,
|
||||
allowDownload = false,
|
||||
}: {
|
||||
linkId: string;
|
||||
allowFilter?: boolean;
|
||||
allowDateFilter?: boolean;
|
||||
allowMonthFilter?: boolean;
|
||||
allowDownload?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="3">
|
||||
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
|
||||
{allowDownload && <ExportButton websiteId={websiteId} />}
|
||||
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
|
||||
</Row>
|
||||
{allowFilter && <FilterBar websiteId={websiteId} />}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
22
src/app/(main)/links/[linkId]/LinkHeader.tsx
Normal file
22
src/app/(main)/links/[linkId]/LinkHeader.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { useLink, useMessages, useSlug } from '@/components/hooks';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Icon, Text } from '@umami/react-zen';
|
||||
import { ExternalLink, Link } from '@/components/icons';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
|
||||
export function LinkHeader() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { getSlugUrl } = useSlug('link');
|
||||
const link = useLink();
|
||||
|
||||
return (
|
||||
<PageHeader title={link.name} description={link.url} icon={<Link />} marginBottom="3">
|
||||
<LinkButton href={getSlugUrl(link.slug)} target="_blank">
|
||||
<Icon>
|
||||
<ExternalLink />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.view)}</Text>
|
||||
</LinkButton>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
71
src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
Normal file
71
src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
|
||||
export function LinkMetricsBar({
|
||||
linkId,
|
||||
}: {
|
||||
linkId: string;
|
||||
showChange?: boolean;
|
||||
compareMode?: boolean;
|
||||
}) {
|
||||
const { dateRange } = useDateRange(linkId);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(linkId);
|
||||
const isAllTime = dateRange.value === 'all';
|
||||
|
||||
const { pageviews, visitors, visits, comparison } = data || {};
|
||||
|
||||
const metrics = data
|
||||
? [
|
||||
{
|
||||
value: visitors,
|
||||
label: formatMessage(labels.visitors),
|
||||
change: visitors - comparison.visitors,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: visits,
|
||||
label: formatMessage(labels.visits),
|
||||
change: visits - comparison.visits,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: pageviews,
|
||||
label: formatMessage(labels.views),
|
||||
change: pageviews - comparison.pageviews,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<LoadingPanel
|
||||
data={metrics}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
minHeight="136px"
|
||||
>
|
||||
<MetricsBar>
|
||||
{metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={label}
|
||||
value={value}
|
||||
previousValue={prev}
|
||||
label={label}
|
||||
change={change}
|
||||
formatValue={formatValue}
|
||||
reverseColors={reverseColors}
|
||||
showChange={!isAllTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MetricsBar>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
30
src/app/(main)/links/[linkId]/LinkPage.tsx
Normal file
30
src/app/(main)/links/[linkId]/LinkPage.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
'use client';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { LinkProvider } from '@/app/(main)/links/LinkProvider';
|
||||
import { LinkHeader } from '@/app/(main)/links/[linkId]/LinkHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
|
||||
import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
|
||||
import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
|
||||
import { Column, Grid } from '@umami/react-zen';
|
||||
|
||||
export function LinkPage({ linkId }: { linkId: string }) {
|
||||
return (
|
||||
<LinkProvider linkId={linkId}>
|
||||
<Grid width="100%" height="100%">
|
||||
<Column margin="2">
|
||||
<PageBody gap>
|
||||
<LinkHeader />
|
||||
<LinkControls linkId={linkId} />
|
||||
<LinkMetricsBar linkId={linkId} showChange={true} />
|
||||
<Panel>
|
||||
<WebsiteChart websiteId={linkId} />
|
||||
</Panel>
|
||||
<LinkPanels linkId={linkId} />
|
||||
</PageBody>
|
||||
</Column>
|
||||
</Grid>
|
||||
</LinkProvider>
|
||||
);
|
||||
}
|
||||
83
src/app/(main)/links/[linkId]/LinkPanels.tsx
Normal file
83
src/app/(main)/links/[linkId]/LinkPanels.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Grid, Tabs, Tab, TabList, TabPanel, Heading } from '@umami/react-zen';
|
||||
import { GridRow } from '@/components/common/GridRow';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function LinkPanels({ linkId }: { linkId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const tableProps = {
|
||||
websiteId: linkId,
|
||||
limit: 10,
|
||||
allowDownload: false,
|
||||
showMore: true,
|
||||
metric: formatMessage(labels.visitors),
|
||||
};
|
||||
const rowProps = { minHeight: 570 };
|
||||
|
||||
return (
|
||||
<Grid gap="3">
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.sources)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
|
||||
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="referrer">
|
||||
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="channel">
|
||||
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(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>
|
||||
</TabList>
|
||||
<TabPanel id="browser">
|
||||
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="os">
|
||||
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="device">
|
||||
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel noPadding>
|
||||
<WorldMap websiteId={linkId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(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>
|
||||
</TabList>
|
||||
<TabPanel id="country">
|
||||
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="region">
|
||||
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="city">
|
||||
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
12
src/app/(main)/links/[linkId]/page.tsx
Normal file
12
src/app/(main)/links/[linkId]/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { LinkPage } from './LinkPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
|
||||
const { linkId } = await params;
|
||||
|
||||
return <LinkPage linkId={linkId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Link',
|
||||
};
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { LinksPage } from './LinksPage';
|
||||
import { Metadata } from 'next';
|
||||
import UTMReportPage from './UTMReportPage';
|
||||
|
||||
export default function () {
|
||||
return <UTMReportPage />;
|
||||
return <LinksPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Goals Report',
|
||||
title: 'Links',
|
||||
};
|
||||
24
src/app/(main)/pixels/PixelAddButton.tsx
Normal file
24
src/app/(main)/pixels/PixelAddButton.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useMessages } from '@/components/hooks';
|
||||
import { Button, Icon, Modal, Dialog, DialogTrigger, Text } from '@umami/react-zen';
|
||||
import { Plus } from '@/components/icons';
|
||||
import { PixelEditForm } from './PixelEditForm';
|
||||
|
||||
export function PixelAddButton({ teamId }: { teamId?: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button data-test="button-website-add" variant="primary">
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.addPixel)}</Text>
|
||||
</Button>
|
||||
<Modal>
|
||||
<Dialog title={formatMessage(labels.addPixel)} style={{ width: 600 }}>
|
||||
{({ close }) => <PixelEditForm teamId={teamId} onClose={close} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
54
src/app/(main)/pixels/PixelDeleteButton.tsx
Normal file
54
src/app/(main)/pixels/PixelDeleteButton.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Dialog } from '@umami/react-zen';
|
||||
import { ActionButton } from '@/components/input/ActionButton';
|
||||
import { Trash } from '@/components/icons';
|
||||
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
||||
import { messages } from '@/components/messages';
|
||||
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
|
||||
export function PixelDeleteButton({
|
||||
pixelId,
|
||||
name,
|
||||
onSave,
|
||||
}: {
|
||||
pixelId: string;
|
||||
name: string;
|
||||
onSave?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
|
||||
const { mutateAsync, isPending, error } = useDeleteQuery(`/pixels/${pixelId}`);
|
||||
const { touch } = useModified();
|
||||
|
||||
const handleConfirm = async (close: () => void) => {
|
||||
await mutateAsync(null, {
|
||||
onSuccess: () => {
|
||||
touch('pixels');
|
||||
onSave?.();
|
||||
close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton title={formatMessage(labels.delete)} icon={<Trash />}>
|
||||
<Dialog title={formatMessage(labels.confirm)} style={{ width: 400 }}>
|
||||
{({ close }) => (
|
||||
<ConfirmationForm
|
||||
message={
|
||||
<FormattedMessage
|
||||
{...messages.confirmRemove}
|
||||
values={{
|
||||
target: <b>{name}</b>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isLoading={isPending}
|
||||
error={getErrorMessage(error)}
|
||||
onConfirm={handleConfirm.bind(null, close)}
|
||||
onClose={close}
|
||||
buttonLabel={formatMessage(labels.delete)}
|
||||
buttonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
19
src/app/(main)/pixels/PixelEditButton.tsx
Normal file
19
src/app/(main)/pixels/PixelEditButton.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { ActionButton } from '@/components/input/ActionButton';
|
||||
import { Edit } from '@/components/icons';
|
||||
import { Dialog } from '@umami/react-zen';
|
||||
import { PixelEditForm } from './PixelEditForm';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function PixelEditButton({ pixelId }: { pixelId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
|
||||
<Dialog title={formatMessage(labels.pixel)} style={{ width: 600, minHeight: 300 }}>
|
||||
{({ close }) => {
|
||||
return <PixelEditForm pixelId={pixelId} onClose={close} />;
|
||||
}}
|
||||
</Dialog>
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
130
src/app/(main)/pixels/PixelEditForm.tsx
Normal file
130
src/app/(main)/pixels/PixelEditForm.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormSubmitButton,
|
||||
Row,
|
||||
TextField,
|
||||
Button,
|
||||
Label,
|
||||
Column,
|
||||
Icon,
|
||||
Loading,
|
||||
} from '@umami/react-zen';
|
||||
import { useConfig, usePixelQuery } from '@/components/hooks';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { RefreshCw } from '@/components/icons';
|
||||
import { getRandomChars } from '@/lib/generate';
|
||||
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PIXELS_URL } from '@/lib/constants';
|
||||
|
||||
const generateId = () => getRandomChars(9);
|
||||
|
||||
export function PixelEditForm({
|
||||
pixelId,
|
||||
teamId,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
pixelId?: string;
|
||||
teamId?: string;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
|
||||
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
|
||||
pixelId ? `/pixels/${pixelId}` : '/pixels',
|
||||
{
|
||||
id: pixelId,
|
||||
teamId,
|
||||
},
|
||||
);
|
||||
const { pixelsUrl } = useConfig();
|
||||
const hostUrl = pixelsUrl || PIXELS_URL;
|
||||
const { data, isLoading } = usePixelQuery(pixelId);
|
||||
const [slug, setSlug] = useState(generateId());
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await mutateAsync(data, {
|
||||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('pixels');
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSlug = () => {
|
||||
const slug = generateId();
|
||||
|
||||
setSlug(slug);
|
||||
|
||||
return slug;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSlug(data.slug);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (pixelId && isLoading) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
|
||||
{({ setValue }) => {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
label={formatMessage(labels.name)}
|
||||
name="name"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
name="slug"
|
||||
rules={{
|
||||
required: formatMessage(labels.required),
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<input type="hidden" />
|
||||
</FormField>
|
||||
|
||||
<Column>
|
||||
<Label>{formatMessage(labels.link)}</Label>
|
||||
<Row alignItems="center" gap>
|
||||
<TextField
|
||||
value={`${hostUrl}/${slug}`}
|
||||
autoComplete="off"
|
||||
isReadOnly
|
||||
allowCopy
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}>
|
||||
<Icon>
|
||||
<RefreshCw />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
|
||||
<Row justifyContent="flex-end" paddingTop="3" gap="3">
|
||||
{onClose && (
|
||||
<Button isDisabled={isPending} onPress={onClose}>
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
)}
|
||||
<FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
21
src/app/(main)/pixels/PixelProvider.tsx
Normal file
21
src/app/(main)/pixels/PixelProvider.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
import { createContext, ReactNode } from 'react';
|
||||
import { Loading } from '@umami/react-zen';
|
||||
import { Pixel } from '@/generated/prisma/client';
|
||||
import { usePixelQuery } from '@/components/hooks/queries/usePixelQuery';
|
||||
|
||||
export const PixelContext = createContext<Pixel>(null);
|
||||
|
||||
export function PixelProvider({ pixelId, children }: { pixelId?: string; children: ReactNode }) {
|
||||
const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId);
|
||||
|
||||
if (isFetching && isLoading) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
if (!pixel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <PixelContext.Provider value={pixel}>{children}</PixelContext.Provider>;
|
||||
}
|
||||
14
src/app/(main)/pixels/PixelsDataTable.tsx
Normal file
14
src/app/(main)/pixels/PixelsDataTable.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { usePixelsQuery, useNavigation } from '@/components/hooks';
|
||||
import { PixelsTable } from './PixelsTable';
|
||||
import { DataGrid } from '@/components/common/DataGrid';
|
||||
|
||||
export function PixelsDataTable() {
|
||||
const { teamId } = useNavigation();
|
||||
const query = usePixelsQuery({ teamId });
|
||||
|
||||
return (
|
||||
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
|
||||
{({ data }) => <PixelsTable data={data} />}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
26
src/app/(main)/pixels/PixelsPage.tsx
Normal file
26
src/app/(main)/pixels/PixelsPage.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use client';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { PixelAddButton } from './PixelAddButton';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { PixelsDataTable } from './PixelsDataTable';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function PixelsPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { teamId } = useNavigation();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.pixels)}>
|
||||
<PixelAddButton teamId={teamId} />
|
||||
</PageHeader>
|
||||
<Panel>
|
||||
<PixelsDataTable />
|
||||
</Panel>
|
||||
</Column>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
49
src/app/(main)/pixels/PixelsTable.tsx
Normal file
49
src/app/(main)/pixels/PixelsTable.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import Link from 'next/link';
|
||||
import { DataTable, DataColumn, Row } from '@umami/react-zen';
|
||||
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { PixelEditButton } from './PixelEditButton';
|
||||
import { PixelDeleteButton } from './PixelDeleteButton';
|
||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||
|
||||
export function PixelsTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { renderUrl } = useNavigation();
|
||||
const { getSlugUrl } = useSlug('pixel');
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||
{({ id, name }: any) => {
|
||||
return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="url" label="URL">
|
||||
{({ slug }: any) => {
|
||||
const url = getSlugUrl(slug);
|
||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={formatMessage(labels.created)}>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
{(row: any) => {
|
||||
const { id, name } = row;
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<PixelEditButton pixelId={id} />
|
||||
<PixelDeleteButton pixelId={id} name={name} />
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
32
src/app/(main)/pixels/[pixelId]/PixelControls.tsx
Normal file
32
src/app/(main)/pixels/[pixelId]/PixelControls.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Column, Row } from '@umami/react-zen';
|
||||
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
|
||||
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
||||
import { FilterBar } from '@/components/input/FilterBar';
|
||||
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
|
||||
import { ExportButton } from '@/components/input/ExportButton';
|
||||
|
||||
export function PixelControls({
|
||||
pixelId: websiteId,
|
||||
allowFilter = true,
|
||||
allowDateFilter = true,
|
||||
allowMonthFilter,
|
||||
allowDownload = false,
|
||||
}: {
|
||||
pixelId: string;
|
||||
allowFilter?: boolean;
|
||||
allowDateFilter?: boolean;
|
||||
allowMonthFilter?: boolean;
|
||||
allowDownload?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="3">
|
||||
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
|
||||
{allowDownload && <ExportButton websiteId={websiteId} />}
|
||||
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
|
||||
</Row>
|
||||
{allowFilter && <FilterBar websiteId={websiteId} />}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
22
src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
Normal file
22
src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { usePixel, useMessages, useSlug } from '@/components/hooks';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Icon, Text } from '@umami/react-zen';
|
||||
import { ExternalLink, Grid2x2 } from '@/components/icons';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
|
||||
export function PixelHeader() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { getSlugUrl } = useSlug('pixel');
|
||||
const pixel = usePixel();
|
||||
|
||||
return (
|
||||
<PageHeader title={pixel.name} icon={<Grid2x2 />} marginBottom="3">
|
||||
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank">
|
||||
<Icon>
|
||||
<ExternalLink />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.view)}</Text>
|
||||
</LinkButton>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
71
src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
Normal file
71
src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
|
||||
export function PixelMetricsBar({
|
||||
pixelId,
|
||||
}: {
|
||||
pixelId: string;
|
||||
showChange?: boolean;
|
||||
compareMode?: boolean;
|
||||
}) {
|
||||
const { dateRange } = useDateRange(pixelId);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(pixelId);
|
||||
const isAllTime = dateRange.value === 'all';
|
||||
|
||||
const { pageviews, visitors, visits, comparison } = data || {};
|
||||
|
||||
const metrics = data
|
||||
? [
|
||||
{
|
||||
value: visitors,
|
||||
label: formatMessage(labels.visitors),
|
||||
change: visitors - comparison.visitors,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: visits,
|
||||
label: formatMessage(labels.visits),
|
||||
change: visits - comparison.visits,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: pageviews,
|
||||
label: formatMessage(labels.views),
|
||||
change: pageviews - comparison.pageviews,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<LoadingPanel
|
||||
data={metrics}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
minHeight="136px"
|
||||
>
|
||||
<MetricsBar>
|
||||
{metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={label}
|
||||
value={value}
|
||||
previousValue={prev}
|
||||
label={label}
|
||||
change={change}
|
||||
formatValue={formatValue}
|
||||
reverseColors={reverseColors}
|
||||
showChange={!isAllTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MetricsBar>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
30
src/app/(main)/pixels/[pixelId]/PixelPage.tsx
Normal file
30
src/app/(main)/pixels/[pixelId]/PixelPage.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
'use client';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { PixelProvider } from '@/app/(main)/pixels/PixelProvider';
|
||||
import { PixelHeader } from '@/app/(main)/pixels/[pixelId]/PixelHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||
import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
|
||||
import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls';
|
||||
import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels';
|
||||
import { Column, Grid } from '@umami/react-zen';
|
||||
|
||||
export function PixelPage({ pixelId }: { pixelId: string }) {
|
||||
return (
|
||||
<PixelProvider pixelId={pixelId}>
|
||||
<Grid width="100%" height="100%">
|
||||
<Column margin="2">
|
||||
<PageBody gap>
|
||||
<PixelHeader />
|
||||
<PixelControls pixelId={pixelId} />
|
||||
<PixelMetricsBar pixelId={pixelId} showChange={true} />
|
||||
<Panel>
|
||||
<WebsiteChart websiteId={pixelId} />
|
||||
</Panel>
|
||||
<PixelPanels pixelId={pixelId} />
|
||||
</PageBody>
|
||||
</Column>
|
||||
</Grid>
|
||||
</PixelProvider>
|
||||
);
|
||||
}
|
||||
83
src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
Normal file
83
src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Grid, Tabs, Tab, TabList, TabPanel, Heading } from '@umami/react-zen';
|
||||
import { GridRow } from '@/components/common/GridRow';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function PixelPanels({ pixelId }: { pixelId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const tableProps = {
|
||||
websiteId: pixelId,
|
||||
limit: 10,
|
||||
allowDownload: false,
|
||||
showMore: true,
|
||||
metric: formatMessage(labels.visitors),
|
||||
};
|
||||
const rowProps = { minHeight: 570 };
|
||||
|
||||
return (
|
||||
<Grid gap="3">
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.sources)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
|
||||
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="referrer">
|
||||
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="channel">
|
||||
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(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>
|
||||
</TabList>
|
||||
<TabPanel id="browser">
|
||||
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="os">
|
||||
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="device">
|
||||
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel noPadding>
|
||||
<WorldMap websiteId={pixelId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(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>
|
||||
</TabList>
|
||||
<TabPanel id="country">
|
||||
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="region">
|
||||
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="city">
|
||||
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
12
src/app/(main)/pixels/[pixelId]/page.tsx
Normal file
12
src/app/(main)/pixels/[pixelId]/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { PixelPage } from './PixelPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default async function ({ params }: { params: { pixelId: string } }) {
|
||||
const { pixelId } = await params;
|
||||
|
||||
return <PixelPage pixelId={pixelId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pixel',
|
||||
};
|
||||
10
src/app/(main)/pixels/page.tsx
Normal file
10
src/app/(main)/pixels/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { PixelsPage } from './PixelsPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function () {
|
||||
return <PixelsPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pixels',
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.field {
|
||||
width: 200px;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import DateFilter from '@/components/input/DateFilter';
|
||||
import { Button, Flexbox } from 'react-basics';
|
||||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { DEFAULT_DATE_RANGE } from '@/lib/constants';
|
||||
import { DateRange } from '@/lib/types';
|
||||
import styles from './DateRangeSetting.module.css';
|
||||
|
||||
export function DateRangeSetting() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { dateRange, saveDateRange } = useDateRange();
|
||||
const { value } = dateRange;
|
||||
|
||||
const handleChange = (value: string | DateRange) => saveDateRange(value);
|
||||
const handleReset = () => saveDateRange(DEFAULT_DATE_RANGE);
|
||||
|
||||
return (
|
||||
<Flexbox gap={10} width={300}>
|
||||
<DateFilter
|
||||
className={styles.field}
|
||||
value={value}
|
||||
startDate={dateRange.startDate}
|
||||
endDate={dateRange.endDate}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default DateRangeSetting;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
div.menu {
|
||||
max-height: 300px;
|
||||
width: 300px;
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { Button, Icon, Text, useToasts, ModalTrigger, Modal } from 'react-basics';
|
||||
import PasswordEditForm from '@/app/(main)/profile/PasswordEditForm';
|
||||
import Icons from '@/components/icons';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function PasswordChangeButton() {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const handleSave = () => {
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Lock />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.changePassword)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.changePassword)}>
|
||||
{close => <PasswordEditForm onSave={handleSave} onClose={close} />}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordChangeButton;
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { useRef } from 'react';
|
||||
import { Form, FormRow, FormInput, FormButtons, PasswordField, Button } from 'react-basics';
|
||||
import { useApi, useMessages } from '@/components/hooks';
|
||||
|
||||
export function PasswordEditForm({ onSave, onClose }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error, isPending } = useMutation({
|
||||
mutationFn: (data: any) => post('/me/password', data),
|
||||
});
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const samePassword = (value: string) => {
|
||||
if (value !== ref?.current?.getValues('newPassword')) {
|
||||
return formatMessage(messages.noMatchPassword);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<Form ref={ref} onSubmit={handleSubmit} error={error}>
|
||||
<FormRow label={formatMessage(labels.currentPassword)}>
|
||||
<FormInput name="currentPassword" rules={{ required: 'Required' }}>
|
||||
<PasswordField autoComplete="current-password" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.newPassword)}>
|
||||
<FormInput
|
||||
name="newPassword"
|
||||
rules={{
|
||||
required: 'Required',
|
||||
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
|
||||
}}
|
||||
>
|
||||
<PasswordField autoComplete="new-password" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.confirmPassword)}>
|
||||
<FormInput
|
||||
name="confirmPassword"
|
||||
rules={{
|
||||
required: formatMessage(labels.required),
|
||||
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
|
||||
validate: samePassword,
|
||||
}}
|
||||
>
|
||||
<PasswordField autoComplete="confirm-password" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons flex>
|
||||
<Button type="submit" variant="primary" disabled={isPending}>
|
||||
{formatMessage(labels.save)}
|
||||
</Button>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordEditForm;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import PageHeader from '@/components/layout/PageHeader';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function ProfileHeader() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return <PageHeader title={formatMessage(labels.profile)}></PageHeader>;
|
||||
}
|
||||
|
||||
export default ProfileHeader;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
.container {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.container {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
'use client';
|
||||
import ProfileHeader from './ProfileHeader';
|
||||
import ProfileSettings from './ProfileSettings';
|
||||
import styles from './ProfilePage.module.css';
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<ProfileHeader />
|
||||
<ProfileSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { Form, FormRow } from 'react-basics';
|
||||
import TimezoneSetting from '@/app/(main)/profile/TimezoneSetting';
|
||||
import DateRangeSetting from '@/app/(main)/profile/DateRangeSetting';
|
||||
import LanguageSetting from '@/app/(main)/profile/LanguageSetting';
|
||||
import ThemeSetting from '@/app/(main)/profile/ThemeSetting';
|
||||
import PasswordChangeButton from './PasswordChangeButton';
|
||||
import { useLogin, useMessages } from '@/components/hooks';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
|
||||
export function ProfileSettings() {
|
||||
const { user } = useLogin();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const cloudMode = !!process.env.cloudMode;
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { username, role } = user;
|
||||
|
||||
const renderRole = (value: string) => {
|
||||
if (value === ROLES.user) {
|
||||
return formatMessage(labels.user);
|
||||
}
|
||||
if (value === ROLES.admin) {
|
||||
return formatMessage(labels.admin);
|
||||
}
|
||||
if (value === ROLES.viewOnly) {
|
||||
return formatMessage(labels.viewOnly);
|
||||
}
|
||||
|
||||
return formatMessage(labels.unknown);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<FormRow label={formatMessage(labels.username)}>{username}</FormRow>
|
||||
<FormRow label={formatMessage(labels.role)}>{renderRole(role)}</FormRow>
|
||||
{!cloudMode && (
|
||||
<FormRow label={formatMessage(labels.password)}>
|
||||
<PasswordChangeButton />
|
||||
</FormRow>
|
||||
)}
|
||||
<FormRow label={formatMessage(labels.defaultDateRange)}>
|
||||
<DateRangeSetting />
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.language)}>
|
||||
<LanguageSetting />
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.timezone)}>
|
||||
<TimezoneSetting />
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.theme)}>
|
||||
<ThemeSetting />
|
||||
</FormRow>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileSettings;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
.buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 2px solid var(--primary400);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import { Button, Icon } from 'react-basics';
|
||||
import { useTheme } from '@/components/hooks';
|
||||
import Sun from '@/assets/sun.svg';
|
||||
import Moon from '@/assets/moon.svg';
|
||||
import styles from './ThemeSetting.module.css';
|
||||
|
||||
export function ThemeSetting() {
|
||||
const { theme, saveTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
className={classNames({ [styles.active]: theme === 'light' })}
|
||||
onClick={() => saveTheme('light')}
|
||||
>
|
||||
<Icon>
|
||||
<Sun />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Button
|
||||
className={classNames({ [styles.active]: theme === 'dark' })}
|
||||
onClick={() => saveTheme('dark')}
|
||||
>
|
||||
<Icon>
|
||||
<Moon />
|
||||
</Icon>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeSetting;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
.dropdown {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
div.menu {
|
||||
max-height: 300px;
|
||||
width: 300px;
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics';
|
||||
import { useApi, useMessages, useModified } from '@/components/hooks';
|
||||
import ConfirmationForm from '@/components/common/ConfirmationForm';
|
||||
|
||||
export function ReportDeleteButton({
|
||||
reportId,
|
||||
reportName,
|
||||
onDelete,
|
||||
}: {
|
||||
reportId: string;
|
||||
reportName: string;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { del, useMutation } = useApi();
|
||||
const { mutate, isPending, error } = useMutation({
|
||||
mutationFn: reportId => del(`/reports/${reportId}`),
|
||||
});
|
||||
const { touch } = useModified();
|
||||
|
||||
const handleConfirm = (close: () => void) => {
|
||||
mutate(reportId as any, {
|
||||
onSuccess: () => {
|
||||
touch('reports');
|
||||
onDelete?.();
|
||||
close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.deleteReport)}>
|
||||
{(close: () => void) => (
|
||||
<ConfirmationForm
|
||||
message={formatMessage(messages.confirmDelete, {
|
||||
target: <b key={messages.confirmDelete.id}>{reportName}</b>,
|
||||
})}
|
||||
isLoading={isPending}
|
||||
error={error}
|
||||
onConfirm={handleConfirm.bind(null, close)}
|
||||
onClose={close}
|
||||
buttonLabel={formatMessage(labels.delete)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportDeleteButton;
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { useReports } from '@/components/hooks';
|
||||
import ReportsTable from './ReportsTable';
|
||||
import DataTable from '@/components/common/DataTable';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function ReportsDataTable({
|
||||
websiteId,
|
||||
teamId,
|
||||
children,
|
||||
}: {
|
||||
websiteId?: string;
|
||||
teamId?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const queryResult = useReports({ websiteId, teamId });
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult} renderEmpty={() => children}>
|
||||
{({ data }) => <ReportsTable data={data} showDomain={!websiteId} />}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import PageHeader from '@/components/layout/PageHeader';
|
||||
import { Icon, Icons, Text } from 'react-basics';
|
||||
import { useLogin, useMessages, useTeamUrl } from '@/components/hooks';
|
||||
import LinkButton from '@/components/common/LinkButton';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
|
||||
export function ReportsHeader() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { renderTeamUrl } = useTeamUrl();
|
||||
const { user } = useLogin();
|
||||
const canEdit = user.role !== ROLES.viewOnly;
|
||||
|
||||
return (
|
||||
<PageHeader title={formatMessage(labels.reports)}>
|
||||
{canEdit && (
|
||||
<LinkButton href={renderTeamUrl('/reports/create')} variant="primary">
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.createReport)}</Text>
|
||||
</LinkButton>
|
||||
)}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportsHeader;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue