mirror of
https://github.com/umami-software/umami.git
synced 2026-02-19 20:15:41 +01:00
Merge branch 'umami-software:master' into master
This commit is contained in:
commit
33bf420a78
755 changed files with 36258 additions and 21478 deletions
|
|
@ -4,14 +4,6 @@
|
||||||
"es2020": true,
|
"es2020": true,
|
||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaFeatures": {
|
|
||||||
"jsx": true
|
|
||||||
},
|
|
||||||
"ecmaVersion": 11,
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:prettier/recommended",
|
"plugin:prettier/recommended",
|
||||||
|
|
@ -19,22 +11,29 @@
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"next"
|
"next"
|
||||||
],
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"ecmaVersion": 11,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
"plugins": ["@typescript-eslint", "prettier"],
|
"plugins": ["@typescript-eslint", "prettier"],
|
||||||
"settings": {
|
"settings": {
|
||||||
"import/resolver": {
|
"import/resolver": {
|
||||||
"alias": {
|
"alias": {
|
||||||
"map": [
|
"map": [
|
||||||
["assets", "./assets"],
|
["assets", "./src/assets"],
|
||||||
["components", "./components"],
|
["components", "./src/components"],
|
||||||
["db", "./db"],
|
["db", "./db"],
|
||||||
["hooks", "./hooks"],
|
["hooks", "./src/components/hooks"],
|
||||||
["lang", "./lang"],
|
["lang", "./src/lang"],
|
||||||
["lib", "./lib"],
|
["lib", "./src/lib"],
|
||||||
["public", "./public"],
|
["public", "./public"],
|
||||||
["queries", "./queries"],
|
["queries", "./src/queries"],
|
||||||
["store", "./store"],
|
["store", "./src/store"],
|
||||||
["styles", "./styles"]
|
["styles", "./src/styles"]
|
||||||
],
|
],
|
||||||
"extensions": [".ts", ".tsx", ".js", ".jsx", ".json"]
|
"extensions": [".ts", ".tsx", ".js", ".jsx", ".json"]
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +49,9 @@
|
||||||
"@next/next/no-img-element": "off",
|
"@next/next/no-img-element": "off",
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-var-requires": "off"
|
"@typescript-eslint/no-var-requires": "off",
|
||||||
|
"@typescript-eslint/no-empty-interface": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }]
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"React": "writable"
|
"React": "writable"
|
||||||
|
|
|
||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -16,10 +16,6 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- node-version: 16.x
|
|
||||||
db-type: postgresql
|
|
||||||
- node-version: 16.x
|
|
||||||
db-type: mysql
|
|
||||||
- node-version: 18.x
|
- node-version: 18.x
|
||||||
db-type: postgresql
|
db-type: postgresql
|
||||||
- node-version: 18.x
|
- node-version: 18.x
|
||||||
|
|
|
||||||
3
.github/workflows/stale-issues.yml
vendored
3
.github/workflows/stale-issues.yml
vendored
|
|
@ -19,4 +19,7 @@ jobs:
|
||||||
close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.'
|
close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.'
|
||||||
days-before-pr-stale: -1
|
days-before-pr-stale: -1
|
||||||
days-before-pr-close: -1
|
days-before-pr-close: -1
|
||||||
|
operations-per-run: 200
|
||||||
|
ascending: true
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
exempt-issue-labels: bug,enhancement
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -34,9 +34,7 @@ yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env
|
.env
|
||||||
.env.development.local
|
.env.*
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
*.dev.yml
|
*.dev.yml
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ RUN yarn install --frozen-lockfile
|
||||||
FROM node:18-alpine AS builder
|
FROM node:18-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY docker/middleware.js .
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY docker/middleware.js ./src
|
||||||
|
|
||||||
ARG DATABASE_TYPE
|
ARG DATABASE_TYPE
|
||||||
ARG BASE_PATH
|
ARG BASE_PATH
|
||||||
|
|
@ -35,7 +35,9 @@ ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
RUN yarn add npm-run-all dotenv prisma
|
RUN set -x \
|
||||||
|
&& apk add --no-cache curl \
|
||||||
|
&& yarn add npm-run-all dotenv prisma semver
|
||||||
|
|
||||||
# You only need to copy next.config.js if you are NOT using the default configuration
|
# You only need to copy next.config.js if you are NOT using the default configuration
|
||||||
COPY --from=builder /app/next.config.js .
|
COPY --from=builder /app/next.config.js .
|
||||||
|
|
@ -53,6 +55,7 @@ USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV HOSTNAME 0.0.0.0
|
||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
|
|
||||||
CMD ["yarn", "start-docker"]
|
CMD ["yarn", "start-docker"]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ A detailed getting started guide can be found at [https://umami.is/docs/](https:
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- A server with Node.js version 12 or newer
|
- A server with Node.js version 16.13 or newer
|
||||||
- A database. Umami supports [MySQL](https://www.mysql.com/) and [Postgresql](https://www.postgresql.org/) databases.
|
- A database. Umami supports [MySQL](https://www.mysql.com/) and [Postgresql](https://www.postgresql.org/) databases.
|
||||||
|
|
||||||
### Install Yarn
|
### Install Yarn
|
||||||
|
|
@ -72,13 +72,13 @@ docker compose up -d
|
||||||
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
|
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull docker.umami.dev/umami-software/umami:postgresql-latest
|
docker pull ghcr.io/umami-software/umami:postgresql-latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with MySQL support:
|
Or with MySQL support:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull docker.umami.dev/umami-software/umami:mysql-latest
|
docker pull ghcr.io/umami-software/umami:mysql-latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Getting updates
|
## Getting updates
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { ButtonGroup, Button, Flexbox } from 'react-basics';
|
|
||||||
|
|
||||||
export function FilterButtons({ items, selectedKey, onSelect }) {
|
|
||||||
return (
|
|
||||||
<Flexbox justifyContent="center">
|
|
||||||
<ButtonGroup items={items} selectedKey={selectedKey} onSelect={onSelect}>
|
|
||||||
{({ key, label }) => <Button key={key}>{label}</Button>}
|
|
||||||
</ButtonGroup>
|
|
||||||
</Flexbox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FilterButtons;
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { Button, Icon } from 'react-basics';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import MobileMenu from './MobileMenu';
|
|
||||||
import Icons from 'components/icons';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import useConfig from 'hooks/useConfig';
|
|
||||||
|
|
||||||
export function HamburgerButton() {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const [active, setActive] = useState(false);
|
|
||||||
const { cloudMode } = useConfig();
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{
|
|
||||||
label: formatMessage(labels.dashboard),
|
|
||||||
url: '/dashboard',
|
|
||||||
},
|
|
||||||
!cloudMode && {
|
|
||||||
label: formatMessage(labels.settings),
|
|
||||||
url: '/settings',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: formatMessage(labels.websites),
|
|
||||||
url: '/settings/websites',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: formatMessage(labels.teams),
|
|
||||||
url: '/settings/teams',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: formatMessage(labels.users),
|
|
||||||
url: '/settings/users',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: formatMessage(labels.profile),
|
|
||||||
url: '/settings/profile',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
cloudMode && {
|
|
||||||
label: formatMessage(labels.profile),
|
|
||||||
url: '/settings/profile',
|
|
||||||
},
|
|
||||||
!cloudMode && { label: formatMessage(labels.logout), url: '/logout' },
|
|
||||||
].filter(n => n);
|
|
||||||
|
|
||||||
const handleClick = () => setActive(state => !state);
|
|
||||||
const handleClose = () => setActive(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant="quiet" onClick={handleClick}>
|
|
||||||
<Icon>{active ? <Icons.Close /> : <Icons.Menu />}</Icon>
|
|
||||||
</Button>
|
|
||||||
{active && <MobileMenu items={menuItems} onClose={handleClose} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HamburgerButton;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
import { Icon, Icons, Text } from 'react-basics';
|
|
||||||
import styles from './LinkButton.module.css';
|
|
||||||
|
|
||||||
export default function LinkButton({ href, icon, children }) {
|
|
||||||
return (
|
|
||||||
<Link className={styles.button} href={href}>
|
|
||||||
<Icon>{icon || <Icons.ArrowRight />}</Icon>
|
|
||||||
<Text>{children}</Text>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
.button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
align-self: flex-start;
|
|
||||||
white-space: nowrap;
|
|
||||||
gap: var(--size200);
|
|
||||||
font-family: inherit;
|
|
||||||
color: var(--base900);
|
|
||||||
background: var(--base100);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
min-height: var(--base-height);
|
|
||||||
padding: 0 var(--size600);
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
background: var(--base200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:active {
|
|
||||||
background: var(--base300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:visited {
|
|
||||||
color: var(--base900);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableColumn } from 'react-basics';
|
|
||||||
import styles from './SettingsTable.module.css';
|
|
||||||
|
|
||||||
export function SettingsTable({ columns = [], data = [], children, cellRender }) {
|
|
||||||
return (
|
|
||||||
<Table columns={columns} rows={data}>
|
|
||||||
<TableHeader className={styles.header}>
|
|
||||||
{(column, index) => {
|
|
||||||
return (
|
|
||||||
<TableColumn key={index} className={styles.cell} style={columns[index].style}>
|
|
||||||
{column.label}
|
|
||||||
</TableColumn>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className={styles.body}>
|
|
||||||
{(row, keys, rowIndex) => {
|
|
||||||
row.action = children(row, keys, rowIndex);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={rowIndex} data={row} keys={keys} className={styles.row}>
|
|
||||||
{(data, key, colIndex) => {
|
|
||||||
return (
|
|
||||||
<TableCell key={colIndex} className={styles.cell} style={columns[colIndex].style}>
|
|
||||||
<label className={styles.label}>{columns[colIndex].label}</label>
|
|
||||||
{cellRender ? cellRender(row, data, key, colIndex) : data[key]}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SettingsTable;
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
.cell {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .cell:last-child {
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: none;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 992px) {
|
|
||||||
.header .cell {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .cell {
|
|
||||||
padding-left: 0;
|
|
||||||
flex-basis: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 1200px) {
|
|
||||||
.row {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header .cell:last-child {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .cell:last-child {
|
|
||||||
padding-left: 0;
|
|
||||||
flex-basis: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import useDateRange from 'hooks/useDateRange';
|
|
||||||
import DateFilter from './DateFilter';
|
|
||||||
import styles from './WebsiteDateFilter.module.css';
|
|
||||||
|
|
||||||
export default function WebsiteDateFilter({ websiteId }) {
|
|
||||||
const [dateRange, setDateRange] = useDateRange(websiteId);
|
|
||||||
const { value, startDate, endDate } = dateRange;
|
|
||||||
|
|
||||||
const handleChange = async value => {
|
|
||||||
setDateRange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DateFilter
|
|
||||||
className={styles.dropdown}
|
|
||||||
value={value}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
onChange={handleChange}
|
|
||||||
showAllTime={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
.dropdown {
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Dropdown, Item } from 'react-basics';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function WebsiteSelect({ websiteId, onSelect }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { data } = useQuery(['websites:me'], () => get('/me/websites'));
|
|
||||||
|
|
||||||
const renderValue = value => {
|
|
||||||
return data?.find(({ id }) => id === value)?.name;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
items={data}
|
|
||||||
value={websiteId}
|
|
||||||
renderValue={renderValue}
|
|
||||||
onChange={onSelect}
|
|
||||||
alignment="end"
|
|
||||||
placeholder={formatMessage(labels.selectWebsite)}
|
|
||||||
>
|
|
||||||
{({ id, name }) => <Item key={id}>{name}</Item>}
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WebsiteSelect;
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { Container } from 'react-basics';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import NavBar from 'components/layout/NavBar';
|
|
||||||
import UpdateNotice from 'components/common/UpdateNotice';
|
|
||||||
import useRequireLogin from 'hooks/useRequireLogin';
|
|
||||||
import useConfig from 'hooks/useConfig';
|
|
||||||
import { CURRENT_VERSION } from 'lib/constants';
|
|
||||||
import styles from './AppLayout.module.css';
|
|
||||||
|
|
||||||
export function AppLayout({ title, children }) {
|
|
||||||
const { user } = useRequireLogin();
|
|
||||||
const config = useConfig();
|
|
||||||
|
|
||||||
if (!user || !config) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.layout} data-app-version={CURRENT_VERSION}>
|
|
||||||
<UpdateNotice user={user} config={config} />
|
|
||||||
<Head>
|
|
||||||
<title>{title ? `${title} | umami` : 'umami'}</title>
|
|
||||||
</Head>
|
|
||||||
<nav className={styles.nav}>
|
|
||||||
<NavBar />
|
|
||||||
</nav>
|
|
||||||
<main className={styles.body}>
|
|
||||||
<Container>{children}</Container>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AppLayout;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { Row, Column } from 'react-basics';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import styles from './Grid.module.css';
|
|
||||||
|
|
||||||
export function GridRow(props) {
|
|
||||||
const { className, ...otherProps } = props;
|
|
||||||
return <Row {...otherProps} className={classNames(styles.row, className)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GridColumn(props) {
|
|
||||||
const { className, ...otherProps } = props;
|
|
||||||
return <Column {...otherProps} className={classNames(styles.col, className)} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
.col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
border-top: 1px solid var(--base300);
|
|
||||||
min-height: 430px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row > .col {
|
|
||||||
border-inline-start: 1px solid var(--base300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row > .col:first-child {
|
|
||||||
border-inline-start: 0;
|
|
||||||
padding-inline-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row > .col:last-child {
|
|
||||||
padding-inline-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 992px) {
|
|
||||||
.row {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row > .col {
|
|
||||||
border-top: 1px solid var(--base300);
|
|
||||||
border-inline-start: 0;
|
|
||||||
border-inline-end: 0;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { Column, Icon, Row, Text } from 'react-basics';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import LanguageButton from 'components/input/LanguageButton';
|
|
||||||
import ThemeButton from 'components/input/ThemeButton';
|
|
||||||
import SettingsButton from 'components/input/SettingsButton';
|
|
||||||
import Icons from 'components/icons';
|
|
||||||
import styles from './Header.module.css';
|
|
||||||
|
|
||||||
export function Header() {
|
|
||||||
return (
|
|
||||||
<header className={styles.header}>
|
|
||||||
<Row className={styles.row}>
|
|
||||||
<Column>
|
|
||||||
<Link href="https://umami.is" target="_blank" className={styles.title}>
|
|
||||||
<Icon size="lg">
|
|
||||||
<Icons.Logo />
|
|
||||||
</Icon>
|
|
||||||
<Text>umami</Text>
|
|
||||||
</Link>
|
|
||||||
</Column>
|
|
||||||
<Column className={styles.buttons}>
|
|
||||||
<ThemeButton tooltipPosition="bottom" />
|
|
||||||
<LanguageButton tooltipPosition="bottom" menuPosition="bottom" />
|
|
||||||
<SettingsButton />
|
|
||||||
</Column>
|
|
||||||
</Row>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { Icon, Text, Row, Column } from 'react-basics';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import Icons from 'components/icons';
|
|
||||||
import ThemeButton from 'components/input/ThemeButton';
|
|
||||||
import LanguageButton from 'components/input/LanguageButton';
|
|
||||||
import ProfileButton from 'components/input/ProfileButton';
|
|
||||||
import styles from './NavBar.module.css';
|
|
||||||
import useConfig from 'hooks/useConfig';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import HamburgerButton from '../common/HamburgerButton';
|
|
||||||
|
|
||||||
export function NavBar() {
|
|
||||||
const { pathname } = useRouter();
|
|
||||||
const { cloudMode } = useConfig();
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
|
|
||||||
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
|
|
||||||
].filter(n => n);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.navbar)}>
|
|
||||||
<Row>
|
|
||||||
<Column className={styles.left}>
|
|
||||||
<div className={styles.logo}>
|
|
||||||
<Icon size="lg">
|
|
||||||
<Icons.Logo />
|
|
||||||
</Icon>
|
|
||||||
<Text className={styles.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) })}
|
|
||||||
>
|
|
||||||
<Text>{label}</Text>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Column>
|
|
||||||
<Column className={styles.right}>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<ThemeButton />
|
|
||||||
<LanguageButton />
|
|
||||||
<ProfileButton />
|
|
||||||
</div>
|
|
||||||
<div className={styles.mobile}>
|
|
||||||
<HamburgerButton />
|
|
||||||
</div>
|
|
||||||
</Column>
|
|
||||||
</Row>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NavBar;
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
.page {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--base50);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { Column, Row } from 'react-basics';
|
|
||||||
import styles from './ReportsLayout.module.css';
|
|
||||||
|
|
||||||
export function SettingsLayout({ children, filter, header }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Row>{header}</Row>
|
|
||||||
<Row>
|
|
||||||
{filter && (
|
|
||||||
<Column className={styles.filter} defaultSize={12} md={4} lg={3} xl={3}>
|
|
||||||
<h2>Filters</h2>
|
|
||||||
{filter}
|
|
||||||
</Column>
|
|
||||||
)}
|
|
||||||
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={9}>
|
|
||||||
{children}
|
|
||||||
</Column>
|
|
||||||
</Row>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SettingsLayout;
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
.filter {
|
|
||||||
margin-top: 30px;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 100vw;
|
|
||||||
padding: 10px;
|
|
||||||
background: var(--base50);
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter h2 {
|
|
||||||
padding-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
min-height: 50vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
.menu {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
.menu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
min-height: 50vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
.menu {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { Container } from 'react-basics';
|
|
||||||
import Header from './Header';
|
|
||||||
import Footer from './Footer';
|
|
||||||
|
|
||||||
export function ShareLayout({ children }) {
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Header />
|
|
||||||
<main>{children}</main>
|
|
||||||
<Footer />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ShareLayout;
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Menu, Item } from 'react-basics';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import styles from './SideNav.module.css';
|
|
||||||
|
|
||||||
export function SideNav({ selectedKey, items, shallow, onSelect = () => {} }) {
|
|
||||||
const { asPath } = useRouter();
|
|
||||||
return (
|
|
||||||
<Menu items={items} selectedKey={selectedKey} className={styles.menu} onSelect={onSelect}>
|
|
||||||
{({ key, label, url }) => (
|
|
||||||
<Item
|
|
||||||
key={key}
|
|
||||||
className={classNames(styles.item, { [styles.selected]: asPath.startsWith(url) })}
|
|
||||||
>
|
|
||||||
<Link href={url} shallow={shallow}>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
</Item>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SideNav;
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import MetricsTable from './MetricsTable';
|
|
||||||
import { emptyFilter } from 'lib/filters';
|
|
||||||
import FilterLink from 'components/common/FilterLink';
|
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function CitiesTable({ websiteId, ...props }) {
|
|
||||||
const { locale } = useLocale();
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
function renderLink({ x }) {
|
|
||||||
return (
|
|
||||||
<div className={locale}>
|
|
||||||
<FilterLink id="city" value={x} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.cities)}
|
|
||||||
type="city"
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
websiteId={websiteId}
|
|
||||||
dataFilter={emptyFilter}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CitiesTable;
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import FilterLink from 'components/common/FilterLink';
|
|
||||||
import useCountryNames from 'hooks/useCountryNames';
|
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import MetricsTable from './MetricsTable';
|
|
||||||
|
|
||||||
export function CountriesTable({ websiteId, ...props }) {
|
|
||||||
const { locale } = useLocale();
|
|
||||||
const countryNames = useCountryNames(locale);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { basePath } = useRouter();
|
|
||||||
|
|
||||||
function renderLink({ x: code }) {
|
|
||||||
return (
|
|
||||||
<FilterLink
|
|
||||||
id="country"
|
|
||||||
className={locale}
|
|
||||||
value={countryNames[code] && code}
|
|
||||||
label={countryNames[code]}
|
|
||||||
>
|
|
||||||
<img src={`${basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`} alt={code} />
|
|
||||||
</FilterLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.countries)}
|
|
||||||
type="country"
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
websiteId={websiteId}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CountriesTable;
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Loading, cloneChildren } from 'react-basics';
|
|
||||||
import ErrorMessage from 'components/common/ErrorMessage';
|
|
||||||
import styles from './MetricsBar.module.css';
|
|
||||||
import { formatLongNumber, formatNumber } from 'lib/format';
|
|
||||||
|
|
||||||
export function MetricsBar({ children, isLoading, isFetched, error }) {
|
|
||||||
const [format, setFormat] = useState(true);
|
|
||||||
|
|
||||||
const formatFunc = format
|
|
||||||
? n => (n >= 0 ? formatLongNumber(n) : `-${formatLongNumber(Math.abs(n))}`)
|
|
||||||
: formatNumber;
|
|
||||||
|
|
||||||
const handleSetFormat = () => {
|
|
||||||
setFormat(state => !state);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.bar} onClick={handleSetFormat}>
|
|
||||||
{isLoading && !isFetched && <Loading icon="dots" />}
|
|
||||||
{error && <ErrorMessage />}
|
|
||||||
{cloneChildren(children, child => {
|
|
||||||
return { format: child.props.format || formatFunc };
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MetricsBar;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
cursor: pointer;
|
|
||||||
min-height: 110px;
|
|
||||||
gap: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
justify-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 992px) {
|
|
||||||
.card {
|
|
||||||
flex-basis: calc(50% - 20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { Loading, Icon, Text, Button } from 'react-basics';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import firstBy from 'thenby';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import { percentFilter } from 'lib/filters';
|
|
||||||
import useDateRange from 'hooks/useDateRange';
|
|
||||||
import usePageQuery from 'hooks/usePageQuery';
|
|
||||||
import ErrorMessage from 'components/common/ErrorMessage';
|
|
||||||
import DataTable from './DataTable';
|
|
||||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
|
||||||
import Icons from 'components/icons';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import styles from './MetricsTable.module.css';
|
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
|
|
||||||
export function MetricsTable({
|
|
||||||
websiteId,
|
|
||||||
type,
|
|
||||||
className,
|
|
||||||
dataFilter,
|
|
||||||
filterOptions,
|
|
||||||
limit,
|
|
||||||
onDataLoad,
|
|
||||||
delay = null,
|
|
||||||
...props
|
|
||||||
}) {
|
|
||||||
const [{ startDate, endDate, modified }] = useDateRange(websiteId);
|
|
||||||
const {
|
|
||||||
resolveUrl,
|
|
||||||
router,
|
|
||||||
query: { url, referrer, title, os, browser, device, country, region, city },
|
|
||||||
} = usePageQuery();
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
|
|
||||||
const { data, isLoading, isFetched, error } = useQuery(
|
|
||||||
[
|
|
||||||
'websites:metrics',
|
|
||||||
{
|
|
||||||
websiteId,
|
|
||||||
type,
|
|
||||||
modified,
|
|
||||||
url,
|
|
||||||
referrer,
|
|
||||||
os,
|
|
||||||
title,
|
|
||||||
browser,
|
|
||||||
device,
|
|
||||||
country,
|
|
||||||
region,
|
|
||||||
city,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
() =>
|
|
||||||
get(`/websites/${websiteId}/metrics`, {
|
|
||||||
type,
|
|
||||||
startAt: +startDate,
|
|
||||||
endAt: +endDate,
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
referrer,
|
|
||||||
os,
|
|
||||||
browser,
|
|
||||||
device,
|
|
||||||
country,
|
|
||||||
region,
|
|
||||||
city,
|
|
||||||
}),
|
|
||||||
{ onSuccess: onDataLoad, retryDelay: delay || DEFAULT_ANIMATION_DURATION },
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
if (data) {
|
|
||||||
let items = data;
|
|
||||||
|
|
||||||
if (dataFilter) {
|
|
||||||
if (Array.isArray(dataFilter)) {
|
|
||||||
items = dataFilter.reduce((arr, filter) => {
|
|
||||||
return filter(arr);
|
|
||||||
}, items);
|
|
||||||
} else {
|
|
||||||
items = dataFilter(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items = percentFilter(items);
|
|
||||||
|
|
||||||
if (limit) {
|
|
||||||
items = items.filter((e, i) => i < limit);
|
|
||||||
}
|
|
||||||
if (filterOptions?.sort === false) {
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.sort(firstBy('y', -1).thenBy('x'));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [data, error, dataFilter, filterOptions]);
|
|
||||||
const { dir } = useLocale();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.container, className)}>
|
|
||||||
{!data && isLoading && !isFetched && <Loading icon="dots" />}
|
|
||||||
{error && <ErrorMessage />}
|
|
||||||
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
|
|
||||||
<div className={styles.footer}>
|
|
||||||
{data && !error && limit && (
|
|
||||||
<Link href={router.pathname} as={resolveUrl({ view: type })}>
|
|
||||||
<Button variant="quiet">
|
|
||||||
<Text>{formatMessage(labels.more)}</Text>
|
|
||||||
<Icon size="sm" rotate={dir === 'rtl' ? 180 : 0}>
|
|
||||||
<Icons.ArrowRight />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MetricsTable;
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import FilterLink from 'components/common/FilterLink';
|
|
||||||
import FilterButtons from 'components/common/FilterButtons';
|
|
||||||
import MetricsTable from './MetricsTable';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import usePageQuery from 'hooks/usePageQuery';
|
|
||||||
import { emptyFilter } from 'lib/filters';
|
|
||||||
|
|
||||||
export function PagesTable({ websiteId, showFilters, ...props }) {
|
|
||||||
const {
|
|
||||||
router,
|
|
||||||
resolveUrl,
|
|
||||||
query: { view = 'url' },
|
|
||||||
} = usePageQuery();
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const handleSelect = key => {
|
|
||||||
router.push(resolveUrl({ view: key }), null, { shallow: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttons = [
|
|
||||||
{
|
|
||||||
label: 'URL',
|
|
||||||
key: 'url',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: formatMessage(labels.title),
|
|
||||||
key: 'title',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderLink = ({ x }) => {
|
|
||||||
return <FilterLink id={view} value={x} label={!x && formatMessage(labels.none)} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showFilters && <FilterButtons items={buttons} selectedKey={view} onSelect={handleSelect} />}
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.pages)}
|
|
||||||
type={view}
|
|
||||||
metric={formatMessage(labels.views)}
|
|
||||||
websiteId={websiteId}
|
|
||||||
dataFilter={emptyFilter}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PagesTable;
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { safeDecodeURI } from 'next-basics';
|
|
||||||
import FilterButtons from 'components/common/FilterButtons';
|
|
||||||
import { emptyFilter, paramFilter } from 'lib/filters';
|
|
||||||
import { FILTER_RAW, FILTER_COMBINED } from 'lib/constants';
|
|
||||||
import MetricsTable from './MetricsTable';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import styles from './QueryParametersTable.module.css';
|
|
||||||
|
|
||||||
const filters = {
|
|
||||||
[FILTER_RAW]: emptyFilter,
|
|
||||||
[FILTER_COMBINED]: [emptyFilter, paramFilter],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function QueryParametersTable({ websiteId, showFilters, ...props }) {
|
|
||||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const buttons = [
|
|
||||||
{
|
|
||||||
label: formatMessage(labels.filterCombined),
|
|
||||||
key: FILTER_COMBINED,
|
|
||||||
},
|
|
||||||
{ label: formatMessage(labels.filterRaw), key: FILTER_RAW },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showFilters && <FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />}
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.query)}
|
|
||||||
type="query"
|
|
||||||
metric={formatMessage(labels.views)}
|
|
||||||
websiteId={websiteId}
|
|
||||||
dataFilter={filters[filter]}
|
|
||||||
renderLabel={({ x, p, v }) =>
|
|
||||||
filter === FILTER_RAW ? (
|
|
||||||
x
|
|
||||||
) : (
|
|
||||||
<div className={styles.item}>
|
|
||||||
<div className={styles.param}>{safeDecodeURI(p)}</div>
|
|
||||||
<div className={styles.value}>{safeDecodeURI(v)}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay={0}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default QueryParametersTable;
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Button, Icon, Icons, Text, Flexbox } from 'react-basics';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
|
||||||
import WebsiteChartList from 'components/pages/websites/WebsiteChartList';
|
|
||||||
import DashboardSettingsButton from 'components/pages/dashboard/DashboardSettingsButton';
|
|
||||||
import DashboardEdit from 'components/pages/dashboard/DashboardEdit';
|
|
||||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useDashboard from 'store/dashboard';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
|
|
||||||
export function Dashboard({ userId }) {
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const dashboard = useDashboard();
|
|
||||||
const { showCharts, limit, editing } = dashboard;
|
|
||||||
const [max, setMax] = useState(limit);
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { data, isLoading, error } = useQuery(['websites'], () =>
|
|
||||||
get('/websites', { userId, includeTeams: 1 }),
|
|
||||||
);
|
|
||||||
const hasData = data && data.length !== 0;
|
|
||||||
const { dir } = useLocale();
|
|
||||||
|
|
||||||
function handleMore() {
|
|
||||||
setMax(max + limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page loading={isLoading} error={error}>
|
|
||||||
<PageHeader title={formatMessage(labels.dashboard)}>
|
|
||||||
{!editing && hasData && <DashboardSettingsButton />}
|
|
||||||
</PageHeader>
|
|
||||||
{!hasData && (
|
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)}>
|
|
||||||
<Link href="/settings/websites">
|
|
||||||
<Button>
|
|
||||||
<Icon rotate={dir === 'rtl' ? 180 : 0}>
|
|
||||||
<Icons.ArrowRight />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(messages.goToSettings)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</EmptyPlaceholder>
|
|
||||||
)}
|
|
||||||
{hasData && (
|
|
||||||
<>
|
|
||||||
{editing && <DashboardEdit websites={data} />}
|
|
||||||
{!editing && <WebsiteChartList websites={data} showCharts={showCharts} limit={max} />}
|
|
||||||
{max < data.length && (
|
|
||||||
<Flexbox justifyContent="center">
|
|
||||||
<Button onClick={handleMore}>
|
|
||||||
<Icon rotate={dir === 'rtl' ? 180 : 0}>
|
|
||||||
<Icons.More />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.more)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Flexbox>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Dashboard;
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import { Column, Row } from 'react-basics';
|
|
||||||
import { useApi, useDateRange } from 'hooks';
|
|
||||||
import MetricCard from 'components/metrics/MetricCard';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
|
|
||||||
import MetricsBar from 'components/metrics/MetricsBar';
|
|
||||||
import styles from './EventDataMetricsBar.module.css';
|
|
||||||
|
|
||||||
export function EventDataMetricsBar({ websiteId }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const [dateRange] = useDateRange(websiteId);
|
|
||||||
const { startDate, endDate, modified } = dateRange;
|
|
||||||
|
|
||||||
const { data, error, isLoading, isFetched } = useQuery(
|
|
||||||
['event-data:stats', { websiteId, startDate, endDate, modified }],
|
|
||||||
() =>
|
|
||||||
get(`/event-data/stats`, {
|
|
||||||
websiteId,
|
|
||||||
startAt: +startDate,
|
|
||||||
endAt: +endDate,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row className={styles.row}>
|
|
||||||
<Column defaultSize={12} xl={8}>
|
|
||||||
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
|
|
||||||
{!error && isFetched && (
|
|
||||||
<>
|
|
||||||
<MetricCard
|
|
||||||
className={styles.card}
|
|
||||||
label={formatMessage(labels.fields)}
|
|
||||||
value={data?.fields}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
className={styles.card}
|
|
||||||
label={formatMessage(labels.totalRecords)}
|
|
||||||
value={data?.records}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</MetricsBar>
|
|
||||||
</Column>
|
|
||||||
<Column defaultSize={12} xl={4}>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<WebsiteDateFilter websiteId={websiteId} />
|
|
||||||
</div>
|
|
||||||
</Column>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EventDataMetricsBar;
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 0;
|
|
||||||
min-height: 90px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
background: var(--base50);
|
|
||||||
z-index: var(--z-index-above);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
cursor: pointer;
|
|
||||||
min-height: 110px;
|
|
||||||
gap: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
justify-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 992px) {
|
|
||||||
.card {
|
|
||||||
flex-basis: calc(50% - 20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import Head from 'next/head';
|
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
import styles from './LoginLayout.module.css';
|
|
||||||
|
|
||||||
export function LoginLayout({ children }) {
|
|
||||||
const { dir } = useLocale();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.layout} dir={dir}>
|
|
||||||
<Head>
|
|
||||||
<title>{`Login | umami`}</title>
|
|
||||||
</Head>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LoginLayout;
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { subMinutes, startOfMinute } from 'date-fns';
|
|
||||||
import firstBy from 'thenby';
|
|
||||||
import { GridRow, GridColumn } from 'components/layout/Grid';
|
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import RealtimeChart from 'components/metrics/RealtimeChart';
|
|
||||||
import WorldMap from 'components/common/WorldMap';
|
|
||||||
import RealtimeLog from 'components/pages/realtime/RealtimeLog';
|
|
||||||
import RealtimeHeader from 'components/pages/realtime/RealtimeHeader';
|
|
||||||
import RealtimeUrls from 'components/pages/realtime/RealtimeUrls';
|
|
||||||
import RealtimeCountries from 'components/pages/realtime/RealtimeCountries';
|
|
||||||
import WebsiteHeader from 'components/pages/websites/WebsiteHeader';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import { percentFilter } from 'lib/filters';
|
|
||||||
import { REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
|
|
||||||
import styles from './RealtimePage.module.css';
|
|
||||||
import { useWebsite } from 'hooks';
|
|
||||||
|
|
||||||
function mergeData(state = [], data = [], time) {
|
|
||||||
const ids = state.map(({ __id }) => __id);
|
|
||||||
return state
|
|
||||||
.concat(data.filter(({ __id }) => !ids.includes(__id)))
|
|
||||||
.filter(({ timestamp }) => timestamp >= time);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RealtimePage({ websiteId }) {
|
|
||||||
const [currentData, setCurrentData] = useState();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { data: website } = useWebsite(websiteId);
|
|
||||||
const { data, isLoading, error } = useQuery(
|
|
||||||
['realtime', websiteId],
|
|
||||||
() => get(`/realtime/${websiteId}`, { startAt: currentData?.timestamp || 0 }),
|
|
||||||
{
|
|
||||||
enabled: !!(websiteId && website),
|
|
||||||
refetchInterval: REALTIME_INTERVAL,
|
|
||||||
cache: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
const date = subMinutes(startOfMinute(new Date()), REALTIME_RANGE);
|
|
||||||
const time = date.getTime();
|
|
||||||
|
|
||||||
setCurrentData(state => ({
|
|
||||||
pageviews: mergeData(state?.pageviews, data.pageviews, time),
|
|
||||||
sessions: mergeData(state?.sessions, data.sessions, time),
|
|
||||||
events: mergeData(state?.events, data.events, time),
|
|
||||||
timestamp: data.timestamp,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const realtimeData = useMemo(() => {
|
|
||||||
if (!currentData) {
|
|
||||||
return { pageviews: [], sessions: [], events: [], countries: [], visitors: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
currentData.countries = percentFilter(
|
|
||||||
currentData.sessions
|
|
||||||
.reduce((arr, data) => {
|
|
||||||
if (!arr.find(({ id }) => id === data.id)) {
|
|
||||||
return arr.concat(data);
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}, [])
|
|
||||||
.reduce((arr, { country }) => {
|
|
||||||
if (country) {
|
|
||||||
const row = arr.find(({ x }) => x === country);
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
arr.push({ x: country, y: 1 });
|
|
||||||
} else {
|
|
||||||
row.y += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}, [])
|
|
||||||
.sort(firstBy('y', -1)),
|
|
||||||
);
|
|
||||||
|
|
||||||
currentData.visitors = currentData.sessions.reduce((arr, val) => {
|
|
||||||
if (!arr.find(({ id }) => id === val.id)) {
|
|
||||||
return arr.concat(val);
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return currentData;
|
|
||||||
}, [currentData]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page loading={isLoading} error={error}>
|
|
||||||
<WebsiteHeader websiteId={websiteId} />
|
|
||||||
<RealtimeHeader websiteId={websiteId} data={currentData} />
|
|
||||||
<RealtimeChart className={styles.chart} data={realtimeData} unit="minute" />
|
|
||||||
<GridRow>
|
|
||||||
<GridColumn xs={12} sm={12} md={12} lg={4} xl={4}>
|
|
||||||
<RealtimeUrls websiteId={websiteId} websiteDomain={website?.domain} data={realtimeData} />
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn xs={12} sm={12} md={12} lg={8} xl={8}>
|
|
||||||
<RealtimeLog websiteId={websiteId} websiteDomain={website?.domain} data={realtimeData} />
|
|
||||||
</GridColumn>
|
|
||||||
</GridRow>
|
|
||||||
<GridRow>
|
|
||||||
<GridColumn xs={12} lg={4}>
|
|
||||||
<RealtimeCountries data={realtimeData?.countries} />
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn xs={12} lg={8}>
|
|
||||||
<WorldMap data={realtimeData?.countries} />
|
|
||||||
</GridColumn>
|
|
||||||
</GridRow>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RealtimePage;
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import { FormRow } from 'react-basics';
|
|
||||||
import DateFilter from 'components/input/DateFilter';
|
|
||||||
import WebsiteSelect from 'components/input/WebsiteSelect';
|
|
||||||
import { parseDateRange } from 'lib/date';
|
|
||||||
import { useContext } from 'react';
|
|
||||||
import { ReportContext } from './Report';
|
|
||||||
import { useMessages } from 'hooks';
|
|
||||||
|
|
||||||
export function BaseParameters() {
|
|
||||||
const { report, updateReport } = useContext(ReportContext);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const { parameters } = report || {};
|
|
||||||
const { websiteId, dateRange } = parameters || {};
|
|
||||||
const { value, startDate, endDate } = dateRange || {};
|
|
||||||
|
|
||||||
const handleWebsiteSelect = websiteId => {
|
|
||||||
updateReport({ websiteId, parameters: { websiteId } });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateChange = value => {
|
|
||||||
updateReport({ parameters: { dateRange: { ...parseDateRange(value) } } });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormRow label={formatMessage(labels.website)}>
|
|
||||||
<WebsiteSelect websiteId={websiteId} onSelect={handleWebsiteSelect} />
|
|
||||||
</FormRow>
|
|
||||||
<FormRow label={formatMessage(labels.dateRange)}>
|
|
||||||
<DateFilter
|
|
||||||
value={value}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
onChange={handleDateChange}
|
|
||||||
/>
|
|
||||||
</FormRow>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BaseParameters;
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Form, FormRow, Menu, Item, Flexbox, Dropdown, TextField, Button } from 'react-basics';
|
|
||||||
import { useFilters } from 'hooks';
|
|
||||||
import styles from './FieldFilterForm.module.css';
|
|
||||||
|
|
||||||
export default function FieldFilterForm({ name, type, onSelect }) {
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
const [value, setValue] = useState('');
|
|
||||||
const { filters, types } = useFilters();
|
|
||||||
const items = types[type];
|
|
||||||
|
|
||||||
const renderValue = value => {
|
|
||||||
return filters[value];
|
|
||||||
};
|
|
||||||
|
|
||||||
if (type === 'boolean') {
|
|
||||||
return (
|
|
||||||
<Form>
|
|
||||||
<FormRow label={name}>
|
|
||||||
<Menu onSelect={value => onSelect({ name, type, value: ['eq', value] })}>
|
|
||||||
{items.map(value => {
|
|
||||||
return <Item key={value}>{filters[value]}</Item>;
|
|
||||||
})}
|
|
||||||
</Menu>
|
|
||||||
</FormRow>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form>
|
|
||||||
<FormRow label={name} className={styles.filter}>
|
|
||||||
<Flexbox gap={10}>
|
|
||||||
<Dropdown
|
|
||||||
className={styles.dropdown}
|
|
||||||
items={items}
|
|
||||||
value={filter}
|
|
||||||
renderValue={renderValue}
|
|
||||||
onChange={setFilter}
|
|
||||||
>
|
|
||||||
{value => {
|
|
||||||
return <Item key={value}>{filters[value]}</Item>;
|
|
||||||
}}
|
|
||||||
</Dropdown>
|
|
||||||
<TextField value={value} onChange={e => setValue(e.target.value)} autoFocus={true} />
|
|
||||||
</Flexbox>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => onSelect({ name, type, value: [filter, value] })}
|
|
||||||
disabled={!filter || !value}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</FormRow>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useDocumentClick, useKeyDown } from 'react-basics';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import styles from './PopupForm.module.css';
|
|
||||||
|
|
||||||
export function PopupForm({ element, className, children, onClose }) {
|
|
||||||
const { right, top } = element.getBoundingClientRect();
|
|
||||||
const style = { position: 'absolute', left: right, top };
|
|
||||||
|
|
||||||
useKeyDown('Escape', onClose);
|
|
||||||
|
|
||||||
useDocumentClick(e => {
|
|
||||||
if (e.target !== element && !element?.parentElement?.contains(e.target)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClick = e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div className={classNames(styles.form, className)} style={style} onClick={handleClick}>
|
|
||||||
{children}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PopupForm;
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { createContext } from 'react';
|
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import styles from './reports.module.css';
|
|
||||||
import { useReport } from 'hooks';
|
|
||||||
|
|
||||||
export const ReportContext = createContext(null);
|
|
||||||
|
|
||||||
export function Report({ reportId, defaultParameters, children, ...props }) {
|
|
||||||
const report = useReport(reportId, defaultParameters);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReportContext.Provider value={{ ...report }}>
|
|
||||||
<Page {...props} className={styles.container}>
|
|
||||||
{children}
|
|
||||||
</Page>
|
|
||||||
</ReportContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Report;
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import styles from './reports.module.css';
|
|
||||||
|
|
||||||
export function ReportBody({ children }) {
|
|
||||||
return <div className={styles.body}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReportBody;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import FunnelReport from './funnel/FunnelReport';
|
|
||||||
import EventDataReport from './event-data/EventDataReport';
|
|
||||||
|
|
||||||
const reports = {
|
|
||||||
funnel: FunnelReport,
|
|
||||||
'event-data': EventDataReport,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ReportDetails({ reportId, reportType }) {
|
|
||||||
const Report = reports[reportType];
|
|
||||||
|
|
||||||
return <Report reportId={reportId} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { useContext } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { Icon, LoadingButton, InlineEditField, useToasts } from 'react-basics';
|
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
|
||||||
import { useMessages, useApi } from 'hooks';
|
|
||||||
import { ReportContext } from './Report';
|
|
||||||
import styles from './ReportHeader.module.css';
|
|
||||||
import reportStyles from './reports.module.css';
|
|
||||||
|
|
||||||
export function ReportHeader({ icon }) {
|
|
||||||
const { report, updateReport } = useContext(ReportContext);
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const { showToast } = useToasts();
|
|
||||||
const { post, useMutation } = useApi();
|
|
||||||
const router = useRouter();
|
|
||||||
const { mutate: create, isLoading: isCreating } = useMutation(data => post(`/reports`, data));
|
|
||||||
const { mutate: update, isLoading: isUpdating } = useMutation(data =>
|
|
||||||
post(`/reports/${data.id}`, data),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { name, description, parameters } = report || {};
|
|
||||||
const { websiteId, dateRange } = parameters || {};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!report.id) {
|
|
||||||
create(report, {
|
|
||||||
onSuccess: async ({ id }) => {
|
|
||||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
|
||||||
router.push(`/reports/${id}`, null, { shallow: true });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
update(report, {
|
|
||||||
onSuccess: async () => {
|
|
||||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameChange = name => {
|
|
||||||
updateReport({ name: name || 'Untitled' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDescriptionChange = description => {
|
|
||||||
updateReport({ description });
|
|
||||||
};
|
|
||||||
|
|
||||||
const Title = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Icon size="lg">{icon}</Icon>
|
|
||||||
<InlineEditField
|
|
||||||
key={name}
|
|
||||||
name="name"
|
|
||||||
value={name}
|
|
||||||
placeholder={formatMessage(labels.untitled)}
|
|
||||||
onCommit={handleNameChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={reportStyles.header}>
|
|
||||||
<PageHeader title={<Title />}>
|
|
||||||
<LoadingButton
|
|
||||||
variant="primary"
|
|
||||||
loading={isCreating || isUpdating}
|
|
||||||
disabled={!websiteId || !dateRange?.value || !name}
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{formatMessage(labels.save)}
|
|
||||||
</LoadingButton>
|
|
||||||
</PageHeader>
|
|
||||||
<div className={styles.description}>
|
|
||||||
<InlineEditField
|
|
||||||
key={description}
|
|
||||||
name="description"
|
|
||||||
value={description}
|
|
||||||
placeholder={`+ ${formatMessage(labels.addDescription)}`}
|
|
||||||
onCommit={handleDescriptionChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReportHeader;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
.description {
|
|
||||||
color: var(--font-color300);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import styles from './reports.module.css';
|
|
||||||
|
|
||||||
export function ReportMenu({ children }) {
|
|
||||||
return <div className={styles.menu}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReportMenu;
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Button, Icon, Icons, Text } from 'react-basics';
|
|
||||||
import { useMessages, useReports } from 'hooks';
|
|
||||||
import ReportsTable from './ReportsTable';
|
|
||||||
|
|
||||||
export function ReportsPage() {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { reports, error, isLoading } = useReports();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page loading={isLoading} error={error}>
|
|
||||||
<PageHeader title={formatMessage(labels.reports)}>
|
|
||||||
<Link href="/reports/create">
|
|
||||||
<Button variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Icons.Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.createReport)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</PageHeader>
|
|
||||||
<ReportsTable data={reports} />
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReportsPage;
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Flexbox, Icon, Icons, Text, Button, Modal } from 'react-basics';
|
|
||||||
import LinkButton from 'components/common/LinkButton';
|
|
||||||
import SettingsTable from 'components/common/SettingsTable';
|
|
||||||
import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm';
|
|
||||||
import { useMessages } from 'hooks';
|
|
||||||
|
|
||||||
export function ReportsTable({ data = [], onDelete = () => {} }) {
|
|
||||||
const [report, setReport] = useState(null);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ name: 'name', label: formatMessage(labels.name) },
|
|
||||||
{ name: 'description', label: formatMessage(labels.description) },
|
|
||||||
{ name: 'type', label: formatMessage(labels.type) },
|
|
||||||
{ name: 'action', label: ' ' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
onDelete(report.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SettingsTable columns={columns} data={data}>
|
|
||||||
{row => {
|
|
||||||
const { id } = row;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flexbox gap={10}>
|
|
||||||
<LinkButton href={`/reports/${id}`}>{formatMessage(labels.view)}</LinkButton>
|
|
||||||
<Button onClick={() => setReport(row)}>
|
|
||||||
<Icon>
|
|
||||||
<Icons.Trash />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.delete)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Flexbox>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</SettingsTable>
|
|
||||||
{report && (
|
|
||||||
<Modal>
|
|
||||||
<ConfirmDeleteForm
|
|
||||||
name={report.name}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onClose={() => setReport(null)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReportsTable;
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { useCallback, useContext, useMemo } from 'react';
|
|
||||||
import { Loading } from 'react-basics';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import useTheme from 'hooks/useTheme';
|
|
||||||
import BarChart from 'components/metrics/BarChart';
|
|
||||||
import { formatLongNumber } from 'lib/format';
|
|
||||||
import styles from './FunnelChart.module.css';
|
|
||||||
import { ReportContext } from '../Report';
|
|
||||||
|
|
||||||
export function FunnelChart({ className, loading }) {
|
|
||||||
const { report } = useContext(ReportContext);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { colors } = useTheme();
|
|
||||||
|
|
||||||
const { parameters, data } = report || {};
|
|
||||||
|
|
||||||
const renderXLabel = useCallback(
|
|
||||||
(label, index) => {
|
|
||||||
return parameters.urls[index];
|
|
||||||
},
|
|
||||||
[parameters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTooltipPopup = useCallback((setTooltipPopup, model) => {
|
|
||||||
const { opacity, dataPoints } = model.tooltip;
|
|
||||||
|
|
||||||
if (!dataPoints?.length || !opacity) {
|
|
||||||
setTooltipPopup(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTooltipPopup(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const datasets = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: formatMessage(labels.uniqueVisitors),
|
|
||||||
data: data,
|
|
||||||
borderWidth: 1,
|
|
||||||
...colors.chart.visitors,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loading icon="dots" className={styles.loading} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BarChart
|
|
||||||
className={className}
|
|
||||||
datasets={datasets}
|
|
||||||
unit="day"
|
|
||||||
loading={loading}
|
|
||||||
renderXLabel={renderXLabel}
|
|
||||||
renderTooltipPopup={renderTooltipPopup}
|
|
||||||
XAxisType="category"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FunnelChart;
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { useMessages } from 'hooks';
|
|
||||||
import { Button, Form, FormRow, TextField, Flexbox } from 'react-basics';
|
|
||||||
import styles from './UrlAddForm.module.css';
|
|
||||||
import PopupForm from '../PopupForm';
|
|
||||||
|
|
||||||
export function UrlAddForm({ defaultValue = '', element, onAdd, onClose }) {
|
|
||||||
const [url, setUrl] = useState(defaultValue);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
onAdd(url);
|
|
||||||
setUrl('');
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = e => {
|
|
||||||
setUrl(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = e => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PopupForm element={element}>
|
|
||||||
<Form>
|
|
||||||
<FormRow label={formatMessage(labels.url)}>
|
|
||||||
<Flexbox gap={10}>
|
|
||||||
<TextField
|
|
||||||
className={styles.input}
|
|
||||||
value={url}
|
|
||||||
onChange={handleChange}
|
|
||||||
autoFocus={true}
|
|
||||||
autoComplete="off"
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
<Button variant="primary" onClick={handleSave}>
|
|
||||||
{formatMessage(labels.add)}
|
|
||||||
</Button>
|
|
||||||
</Flexbox>
|
|
||||||
</FormRow>
|
|
||||||
</Form>
|
|
||||||
</PopupForm>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UrlAddForm;
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import { useContext, useRef } from 'react';
|
|
||||||
import { useMessages } from 'hooks';
|
|
||||||
import { Form, FormRow, FormButtons, SubmitButton, PopupTrigger, Icon, Popup } from 'react-basics';
|
|
||||||
import { ReportContext } from 'components/pages/reports/Report';
|
|
||||||
import { REPORT_PARAMETERS, WEBSITE_EVENT_FIELDS } from 'lib/constants';
|
|
||||||
import Icons from 'components/icons';
|
|
||||||
import BaseParameters from '../BaseParameters';
|
|
||||||
import FieldAddForm from '../FieldAddForm';
|
|
||||||
import ParameterList from '../ParameterList';
|
|
||||||
import styles from './InsightsParameters.module.css';
|
|
||||||
|
|
||||||
export function InsightsParameters() {
|
|
||||||
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const ref = useRef(null);
|
|
||||||
const { parameters } = report || {};
|
|
||||||
const { websiteId, dateRange, fields, filters, groups } = parameters || {};
|
|
||||||
const queryEnabled = websiteId && dateRange && fields?.length;
|
|
||||||
const fieldOptions = Object.keys(WEBSITE_EVENT_FIELDS).map(key => WEBSITE_EVENT_FIELDS[key]);
|
|
||||||
|
|
||||||
const parameterGroups = [
|
|
||||||
{ label: formatMessage(labels.fields), group: REPORT_PARAMETERS.fields },
|
|
||||||
{ label: formatMessage(labels.filters), group: REPORT_PARAMETERS.filters },
|
|
||||||
{ label: formatMessage(labels.breakdown), group: REPORT_PARAMETERS.groups },
|
|
||||||
];
|
|
||||||
|
|
||||||
const parameterData = {
|
|
||||||
fields,
|
|
||||||
filters,
|
|
||||||
groups,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = values => {
|
|
||||||
runReport(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = (group, value) => {
|
|
||||||
const data = parameterData[group];
|
|
||||||
|
|
||||||
if (!data.find(({ name }) => name === value.name)) {
|
|
||||||
updateReport({ parameters: { [group]: data.concat(value) } });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = (group, index) => {
|
|
||||||
const data = [...parameterData[group]];
|
|
||||||
data.splice(index, 1);
|
|
||||||
updateReport({ parameters: { [group]: data } });
|
|
||||||
};
|
|
||||||
|
|
||||||
const AddButton = ({ group }) => {
|
|
||||||
return (
|
|
||||||
<PopupTrigger>
|
|
||||||
<Icon>
|
|
||||||
<Icons.Plus />
|
|
||||||
</Icon>
|
|
||||||
<Popup position="bottom" alignment="start">
|
|
||||||
{(close, element) => {
|
|
||||||
return (
|
|
||||||
<FieldAddForm
|
|
||||||
fields={fieldOptions}
|
|
||||||
group={group}
|
|
||||||
element={element}
|
|
||||||
onAdd={handleAdd}
|
|
||||||
onClose={close}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Popup>
|
|
||||||
</PopupTrigger>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form ref={ref} values={parameters} onSubmit={handleSubmit}>
|
|
||||||
<BaseParameters />
|
|
||||||
{parameterGroups.map(({ label, group }) => {
|
|
||||||
return (
|
|
||||||
<FormRow key={label} label={label} action={<AddButton group={group} onAdd={handleAdd} />}>
|
|
||||||
<ParameterList
|
|
||||||
items={parameterData[group]}
|
|
||||||
onRemove={index => handleRemove(group, index)}
|
|
||||||
>
|
|
||||||
{({ name, value }) => {
|
|
||||||
return (
|
|
||||||
<div className={styles.parameter}>
|
|
||||||
{group === REPORT_PARAMETERS.fields && (
|
|
||||||
<>
|
|
||||||
<div>{name}</div>
|
|
||||||
<div className={styles.op}>{value}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{group === REPORT_PARAMETERS.filters && (
|
|
||||||
<>
|
|
||||||
<div>{name}</div>
|
|
||||||
<div className={styles.op}>{value[0]}</div>
|
|
||||||
<div>{value[1]}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{group === REPORT_PARAMETERS.groups && (
|
|
||||||
<>
|
|
||||||
<div>{name}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</ParameterList>
|
|
||||||
</FormRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<FormButtons>
|
|
||||||
<SubmitButton variant="primary" disabled={!queryEnabled} loading={isRunning}>
|
|
||||||
{formatMessage(labels.runQuery)}
|
|
||||||
</SubmitButton>
|
|
||||||
</FormButtons>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default InsightsParameters;
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { useContext } from 'react';
|
|
||||||
import { GridTable, GridColumn } from 'react-basics';
|
|
||||||
import { useMessages } from 'hooks';
|
|
||||||
import { ReportContext } from '../Report';
|
|
||||||
|
|
||||||
export function InsightsTable() {
|
|
||||||
const { report } = useContext(ReportContext);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GridTable data={report?.data || []}>
|
|
||||||
<GridColumn name="field" label={formatMessage(labels.field)} />
|
|
||||||
<GridColumn name="value" label={formatMessage(labels.value)} />
|
|
||||||
<GridColumn name="total" label={formatMessage(labels.total)} />
|
|
||||||
</GridTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default InsightsTable;
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
.container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: max-content 1fr;
|
|
||||||
grid-template-columns: max-content 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
grid-row: 1 / 2;
|
|
||||||
grid-column: 1 / 3;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
width: 300px;
|
|
||||||
padding-right: 20px;
|
|
||||||
border-right: 1px solid var(--base300);
|
|
||||||
grid-row: 2/3;
|
|
||||||
grid-column: 1 / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
padding-left: 20px;
|
|
||||||
grid-row: 2/3;
|
|
||||||
grid-column: 2 / 3;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
|
||||||
import ProfileDetails from './ProfileDetails';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function ProfileSettings() {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader title={formatMessage(labels.profile)} />
|
|
||||||
<ProfileDetails />
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ProfileSettings;
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
import { Button, Dropdown, Form, FormButtons, FormRow, Item, SubmitButton } from 'react-basics';
|
|
||||||
import WebsiteTags from './WebsiteTags';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function TeamAddWebsiteForm({ teamId, onSave, onClose }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { get, post, useQuery, useMutation } = useApi();
|
|
||||||
const { mutate, error } = useMutation(data => post(`/teams/${teamId}/websites`, data));
|
|
||||||
const { data: websites } = useQuery(['websites'], () => get('/websites'));
|
|
||||||
const [newWebsites, setNewWebsites] = useState([]);
|
|
||||||
const formRef = useRef();
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
mutate(
|
|
||||||
{ websiteIds: newWebsites },
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
onSave();
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddWebsite = value => {
|
|
||||||
if (!newWebsites.some(a => a === value)) {
|
|
||||||
const nextValue = [...newWebsites];
|
|
||||||
|
|
||||||
nextValue.push(value);
|
|
||||||
|
|
||||||
setNewWebsites(nextValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveWebsite = value => {
|
|
||||||
const newValue = newWebsites.filter(a => a !== value);
|
|
||||||
|
|
||||||
setNewWebsites(newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form onSubmit={handleSubmit} error={error} ref={formRef}>
|
|
||||||
<FormRow label={formatMessage(labels.websites)}>
|
|
||||||
<Dropdown items={websites} onChange={handleAddWebsite} style={{ width: 300 }}>
|
|
||||||
{({ id, name }) => <Item key={id}>{name}</Item>}
|
|
||||||
</Dropdown>
|
|
||||||
</FormRow>
|
|
||||||
<WebsiteTags items={websites} websites={newWebsites} onClick={handleRemoveWebsite} />
|
|
||||||
<FormButtons flex>
|
|
||||||
<SubmitButton disabled={newWebsites && newWebsites.length === 0}>
|
|
||||||
{formatMessage(labels.addWebsite)}
|
|
||||||
</SubmitButton>
|
|
||||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
|
||||||
</FormButtons>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamAddWebsiteForm;
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { Button, Form, FormButtons, SubmitButton } from 'react-basics';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function TeamLeaveForm({ teamId, userId, teamName, onSave, onClose }) {
|
|
||||||
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
|
|
||||||
const { del, useMutation } = useApi();
|
|
||||||
const { mutate, error, isLoading } = useMutation(() => del(`/teams/${teamId}/users/${userId}`));
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
mutate(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
onSave();
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form onSubmit={handleSubmit} error={error}>
|
|
||||||
<p>
|
|
||||||
<FormattedMessage {...messages.confirmDelete} values={{ target: <b>{teamName}</b> }} />
|
|
||||||
</p>
|
|
||||||
<FormButtons flex>
|
|
||||||
<SubmitButton variant="danger" disabled={isLoading}>
|
|
||||||
{formatMessage(labels.leave)}
|
|
||||||
</SubmitButton>
|
|
||||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
|
||||||
</FormButtons>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamLeaveForm;
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import { Icon, Icons, LoadingButton, Text } from 'react-basics';
|
|
||||||
|
|
||||||
export function TeamMemberRemoveButton({ teamId, userId, disabled, onSave }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { del, useMutation } = useApi();
|
|
||||||
const { mutate, isLoading } = useMutation(() => del(`/teams/${teamId}/users/${userId}`));
|
|
||||||
|
|
||||||
const handleRemoveTeamMember = () => {
|
|
||||||
mutate(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
onSave();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoadingButton onClick={() => handleRemoveTeamMember()} disabled={disabled} loading={isLoading}>
|
|
||||||
<Icon>
|
|
||||||
<Icons.Close />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.remove)}</Text>
|
|
||||||
</LoadingButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamMemberRemoveButton;
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { Loading, useToasts } from 'react-basics';
|
|
||||||
import TeamMembersTable from 'components/pages/settings/teams/TeamMembersTable';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function TeamMembers({ teamId, readOnly }) {
|
|
||||||
const { showToast } = useToasts();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { formatMessage, messages } = useMessages();
|
|
||||||
const { data, isLoading, refetch } = useQuery(['teams:users', teamId], () =>
|
|
||||||
get(`/teams/${teamId}/users`),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading icon="dots" style={{ minHeight: 300 }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
await refetch();
|
|
||||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TeamMembersTable onSave={handleSave} data={data} readOnly={readOnly} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamMembers;
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import useUser from 'hooks/useUser';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
import TeamMemberRemoveButton from './TeamMemberRemoveButton';
|
|
||||||
import SettingsTable from 'components/common/SettingsTable';
|
|
||||||
|
|
||||||
export function TeamMembersTable({ data = [], onSave, readOnly }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { user } = useUser();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ name: 'username', label: formatMessage(labels.username) },
|
|
||||||
{ name: 'role', label: formatMessage(labels.role) },
|
|
||||||
{ name: 'action', label: ' ' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const cellRender = (row, data, key) => {
|
|
||||||
if (key === 'username') {
|
|
||||||
return row?.user?.username;
|
|
||||||
}
|
|
||||||
if (key === 'role') {
|
|
||||||
return formatMessage(
|
|
||||||
labels[Object.keys(ROLES).find(key => ROLES[key] === row.role) || labels.unknown],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return data[key];
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsTable data={data} columns={columns} cellRender={cellRender}>
|
|
||||||
{row => {
|
|
||||||
return (
|
|
||||||
!readOnly && (
|
|
||||||
<TeamMemberRemoveButton
|
|
||||||
teamId={row.teamId}
|
|
||||||
userId={row.userId}
|
|
||||||
disabled={user.id === row?.user?.id || row.role === ROLES.teamOwner}
|
|
||||||
onSave={onSave}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</SettingsTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamMembersTable;
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import { Icon, Icons, LoadingButton, Text } from 'react-basics';
|
|
||||||
|
|
||||||
export function TeamWebsiteRemoveButton({ teamId, websiteId, onSave }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { del, useMutation } = useApi();
|
|
||||||
const { mutate, isLoading } = useMutation(() => del(`/teams/${teamId}/websites/${websiteId}`));
|
|
||||||
|
|
||||||
const handleRemoveTeamMember = () => {
|
|
||||||
mutate(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
onSave();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoadingButton onClick={() => handleRemoveTeamMember()} loading={isLoading}>
|
|
||||||
<Icon>
|
|
||||||
<Icons.Close />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.remove)}</Text>
|
|
||||||
</LoadingButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamWebsiteRemoveButton;
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import {
|
|
||||||
ActionForm,
|
|
||||||
Button,
|
|
||||||
Icon,
|
|
||||||
Icons,
|
|
||||||
Loading,
|
|
||||||
Modal,
|
|
||||||
ModalTrigger,
|
|
||||||
Text,
|
|
||||||
useToasts,
|
|
||||||
} from 'react-basics';
|
|
||||||
import TeamWebsitesTable from 'components/pages/settings/teams/TeamWebsitesTable';
|
|
||||||
import TeamAddWebsiteForm from 'components/pages/settings/teams/TeamAddWebsiteForm';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function TeamWebsites({ teamId }) {
|
|
||||||
const { showToast } = useToasts();
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { data, isLoading, refetch } = useQuery(['teams:websites', teamId], () =>
|
|
||||||
get(`/teams/${teamId}/websites`),
|
|
||||||
);
|
|
||||||
const hasData = data && data.length !== 0;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading icon="dots" style={{ minHeight: 300 }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
await refetch();
|
|
||||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const addButton = (
|
|
||||||
<ModalTrigger>
|
|
||||||
<Button variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Icons.Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.addWebsite)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal title={formatMessage(labels.addWebsite)}>
|
|
||||||
{close => <TeamAddWebsiteForm teamId={teamId} onSave={handleSave} onClose={close} />}
|
|
||||||
</Modal>
|
|
||||||
</ModalTrigger>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ActionForm description={formatMessage(messages.teamWebsitesInfo)}>{addButton}</ActionForm>
|
|
||||||
{hasData && <TeamWebsitesTable teamId={teamId} data={data} onSave={handleSave} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamWebsites;
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import useUser from 'hooks/useUser';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Button, Icon, Icons, Text } from 'react-basics';
|
|
||||||
import TeamWebsiteRemoveButton from './TeamWebsiteRemoveButton';
|
|
||||||
import SettingsTable from 'components/common/SettingsTable';
|
|
||||||
import useConfig from 'hooks/useConfig';
|
|
||||||
|
|
||||||
export function TeamWebsitesTable({ data = [], onSave }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { openExternal } = useConfig();
|
|
||||||
const { user } = useUser();
|
|
||||||
const columns = [
|
|
||||||
{ name: 'name', label: formatMessage(labels.name) },
|
|
||||||
{ name: 'domain', label: formatMessage(labels.domain) },
|
|
||||||
{ name: 'action', label: ' ' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsTable columns={columns} data={data}>
|
|
||||||
{row => {
|
|
||||||
const { teamId } = row;
|
|
||||||
const { id: websiteId, name, domain, userId } = row.website;
|
|
||||||
const { teamUser } = row.team;
|
|
||||||
const owner = teamUser[0];
|
|
||||||
const canRemove = user.id === userId || user.id === owner.userId;
|
|
||||||
|
|
||||||
row.name = name;
|
|
||||||
row.domain = domain;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Link href={`/websites/${websiteId}`} target={openExternal ? '_blank' : null}>
|
|
||||||
<Button>
|
|
||||||
<Icon>
|
|
||||||
<Icons.External />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.view)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
{canRemove && (
|
|
||||||
<TeamWebsiteRemoveButton
|
|
||||||
teamId={teamId}
|
|
||||||
websiteId={websiteId}
|
|
||||||
onSave={onSave}
|
|
||||||
></TeamWebsiteRemoveButton>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</SettingsTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamWebsitesTable;
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Button, Icon, Modal, ModalTrigger, useToasts, Text, Flexbox } from 'react-basics';
|
|
||||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
|
||||||
import TeamAddForm from 'components/pages/settings/teams/TeamAddForm';
|
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
|
||||||
import TeamsTable from 'components/pages/settings/teams/TeamsTable';
|
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import Icons from 'components/icons';
|
|
||||||
import TeamJoinForm from './TeamJoinForm';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
import useUser from 'hooks/useUser';
|
|
||||||
|
|
||||||
export default function TeamsList() {
|
|
||||||
const { user } = useUser();
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const [update, setUpdate] = useState(0);
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { data, isLoading, error } = useQuery(['teams', update], () => get(`/teams`));
|
|
||||||
const hasData = data && data.length !== 0;
|
|
||||||
const { showToast } = useToasts();
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
setUpdate(state => state + 1);
|
|
||||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoin = () => {
|
|
||||||
setUpdate(state => state + 1);
|
|
||||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
setUpdate(state => state + 1);
|
|
||||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const joinButton = (
|
|
||||||
<ModalTrigger>
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Icon>
|
|
||||||
<Icons.AddUser />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.joinTeam)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal title={formatMessage(labels.joinTeam)}>
|
|
||||||
{close => <TeamJoinForm onSave={handleJoin} onClose={close} />}
|
|
||||||
</Modal>
|
|
||||||
</ModalTrigger>
|
|
||||||
);
|
|
||||||
|
|
||||||
const createButton = (
|
|
||||||
<>
|
|
||||||
{user.role !== ROLES.viewOnly && (
|
|
||||||
<ModalTrigger>
|
|
||||||
<Button variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Icons.Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.createTeam)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal title={formatMessage(labels.createTeam)}>
|
|
||||||
{close => <TeamAddForm onSave={handleSave} onClose={close} />}
|
|
||||||
</Modal>
|
|
||||||
</ModalTrigger>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page loading={isLoading} error={error}>
|
|
||||||
<PageHeader title={formatMessage(labels.teams)}>
|
|
||||||
{hasData && (
|
|
||||||
<Flexbox gap={10}>
|
|
||||||
{joinButton}
|
|
||||||
{createButton}
|
|
||||||
</Flexbox>
|
|
||||||
)}
|
|
||||||
</PageHeader>
|
|
||||||
{hasData && <TeamsTable data={data} onDelete={handleDelete} />}
|
|
||||||
{!hasData && (
|
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noTeams)}>
|
|
||||||
<Flexbox gap={10}>
|
|
||||||
{joinButton}
|
|
||||||
{createButton}
|
|
||||||
</Flexbox>
|
|
||||||
</EmptyPlaceholder>
|
|
||||||
)}
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics';
|
|
||||||
import TeamDeleteForm from './TeamDeleteForm';
|
|
||||||
import TeamLeaveForm from './TeamLeaveForm';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import useUser from 'hooks/useUser';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
import SettingsTable from 'components/common/SettingsTable';
|
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
|
|
||||||
export function TeamsTable({ data = [], onDelete }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { user } = useUser();
|
|
||||||
const { dir } = useLocale();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ name: 'name', label: formatMessage(labels.name) },
|
|
||||||
{ name: 'owner', label: formatMessage(labels.owner) },
|
|
||||||
{ name: 'action', label: ' ' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const cellRender = (row, data, key) => {
|
|
||||||
if (key === 'owner') {
|
|
||||||
return row.teamUser.find(({ role }) => role === ROLES.teamOwner)?.user?.username;
|
|
||||||
}
|
|
||||||
return data[key];
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsTable data={data} columns={columns} cellRender={cellRender}>
|
|
||||||
{row => {
|
|
||||||
const { id, teamUser } = row;
|
|
||||||
const owner = teamUser.find(({ role }) => role === ROLES.teamOwner);
|
|
||||||
const showDelete = user.id === owner?.userId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Link href={`/settings/teams/${id}`}>
|
|
||||||
<Button>
|
|
||||||
<Icon>
|
|
||||||
<Icons.Show />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.view)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
{showDelete && (
|
|
||||||
<ModalTrigger>
|
|
||||||
<Button>
|
|
||||||
<Icon>
|
|
||||||
<Icons.Trash />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.delete)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal title={formatMessage(labels.deleteTeam)}>
|
|
||||||
{close => (
|
|
||||||
<TeamDeleteForm
|
|
||||||
teamId={row.id}
|
|
||||||
teamName={row.name}
|
|
||||||
onSave={onDelete}
|
|
||||||
onClose={close}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</ModalTrigger>
|
|
||||||
)}
|
|
||||||
{!showDelete && (
|
|
||||||
<ModalTrigger>
|
|
||||||
<Button>
|
|
||||||
<Icon rotate={dir === 'rtl' ? 180 : 0}>
|
|
||||||
<Icons.ArrowRight />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.leave)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal title={formatMessage(labels.leaveTeam)}>
|
|
||||||
{close => (
|
|
||||||
<TeamLeaveForm
|
|
||||||
teamId={id}
|
|
||||||
userId={user.id}
|
|
||||||
teamName={row.name}
|
|
||||||
onSave={onDelete}
|
|
||||||
onClose={close}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</ModalTrigger>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</SettingsTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamsTable;
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { Button, Icon, Text, Modal, Icons, ModalTrigger } from 'react-basics';
|
|
||||||
import UserAddForm from './UserAddForm';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function UserAddButton({ onSave }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
onSave();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalTrigger>
|
|
||||||
<Button variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Icons.Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.createUser)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal title={formatMessage(labels.createUser)}>
|
|
||||||
{close => <UserAddForm onSave={handleSave} onClose={close} />}
|
|
||||||
</Modal>
|
|
||||||
</ModalTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserAddButton;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { Loading } from 'react-basics';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import WebsitesTable from 'components/pages/settings/websites/WebsitesTable';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function UserWebsites({ userId }) {
|
|
||||||
const { formatMessage, messages } = useMessages();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { data, isLoading } = useQuery(['user:websites', userId], () =>
|
|
||||||
get(`/users/${userId}/websites`),
|
|
||||||
);
|
|
||||||
const hasData = data && data.length !== 0;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading icon="dots" style={{ minHeight: 300 }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{hasData && <WebsitesTable data={data} />}
|
|
||||||
{!hasData && formatMessage(messages.noDataAvailable)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserWebsites;
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import { useToasts } from 'react-basics';
|
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
|
||||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
|
||||||
import UsersTable from './UsersTable';
|
|
||||||
import UserAddButton from './UserAddButton';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useUser from 'hooks/useUser';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
|
|
||||||
export function UsersList() {
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const { user } = useUser();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { data, isLoading, error, refetch } = useQuery(['user'], () => get(`/users`), {
|
|
||||||
enabled: !!user,
|
|
||||||
});
|
|
||||||
const { showToast } = useToasts();
|
|
||||||
const hasData = data && data.length !== 0;
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
refetch().then(() => showToast({ message: formatMessage(messages.saved), variant: 'success' }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
refetch().then(() =>
|
|
||||||
showToast({ message: formatMessage(messages.userDeleted), variant: 'success' }),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page loading={isLoading} error={error}>
|
|
||||||
<PageHeader title={formatMessage(labels.users)}>
|
|
||||||
<UserAddButton onSave={handleSave} />
|
|
||||||
</PageHeader>
|
|
||||||
{hasData && <UsersTable data={data} onDelete={handleDelete} />}
|
|
||||||
{!hasData && (
|
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noUsers)}>
|
|
||||||
<UserAddButton onSave={handleSave} />
|
|
||||||
</EmptyPlaceholder>
|
|
||||||
)}
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UsersList;
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { Button, Text, Icon, Icons, ModalTrigger, Modal } from 'react-basics';
|
|
||||||
import { formatDistance } from 'date-fns';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import useUser from 'hooks/useUser';
|
|
||||||
import UserDeleteForm from './UserDeleteForm';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import SettingsTable from 'components/common/SettingsTable';
|
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
|
|
||||||
export function UsersTable({ data = [], onDelete }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { user } = useUser();
|
|
||||||
const { dateLocale } = useLocale();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ name: 'username', label: formatMessage(labels.username) },
|
|
||||||
{ name: 'role', label: formatMessage(labels.role) },
|
|
||||||
{ name: 'created', label: formatMessage(labels.created) },
|
|
||||||
{ name: 'action', label: ' ' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const cellRender = (row, data, key) => {
|
|
||||||
if (key === 'created') {
|
|
||||||
return formatDistance(new Date(row.createdAt), new Date(), {
|
|
||||||
addSuffix: true,
|
|
||||||
locale: dateLocale,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (key === 'role') {
|
|
||||||
return formatMessage(
|
|
||||||
labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return data[key];
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsTable data={data} columns={columns} cellRender={cellRender}>
|
|
||||||
{(row, keys, rowIndex) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Link href={`/settings/users/${row.id}`}>
|
|
||||||
<Button>
|
|
||||||
<Icon>
|
|
||||||
<Icons.Edit />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.edit)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<ModalTrigger disabled={row.id === user.id}>
|
|
||||||
<Button disabled={row.id === user.id}>
|
|
||||||
<Icon>
|
|
||||||
<Icons.Trash />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.delete)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal title={formatMessage(labels.deleteUser)}>
|
|
||||||
{close => (
|
|
||||||
<UserDeleteForm
|
|
||||||
userId={row.id}
|
|
||||||
username={row.username}
|
|
||||||
onSave={onDelete}
|
|
||||||
onClose={close}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</ModalTrigger>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</SettingsTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UsersTable;
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { TextArea } from 'react-basics';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import useConfig from 'hooks/useConfig';
|
|
||||||
|
|
||||||
export function TrackingCode({ websiteId }) {
|
|
||||||
const { formatMessage, messages } = useMessages();
|
|
||||||
const { basePath, trackerScriptName } = useConfig();
|
|
||||||
const url = trackerScriptName?.startsWith('http')
|
|
||||||
? trackerScriptName
|
|
||||||
: `${location.origin}${basePath}/${
|
|
||||||
trackerScriptName?.split(',')?.map(n => n.trim())?.[0] || 'script.js'
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const code = `<script async src="${url}" data-website-id="${websiteId}"></script>`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>{formatMessage(messages.trackingCode)}</p>
|
|
||||||
<TextArea rows={4} value={code} readOnly allowCopy />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TrackingCode;
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { Button, Icon, Text, Modal, ModalTrigger, useToasts, Icons } from 'react-basics';
|
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
|
||||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
|
||||||
import WebsiteAddForm from 'components/pages/settings/websites/WebsiteAddForm';
|
|
||||||
import WebsitesTable from 'components/pages/settings/websites/WebsitesTable';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useUser from 'hooks/useUser';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
|
|
||||||
export function WebsitesList() {
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const { user } = useUser();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { data, isLoading, error, refetch } = useQuery(
|
|
||||||
['websites', user?.id],
|
|
||||||
() => get(`/users/${user?.id}/websites`),
|
|
||||||
{ enabled: !!user },
|
|
||||||
);
|
|
||||||
const { showToast } = useToasts();
|
|
||||||
const hasData = data && data.length !== 0;
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
await refetch();
|
|
||||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const addButton = (
|
|
||||||
<>
|
|
||||||
{user.role !== ROLES.viewOnly && (
|
|
||||||
<ModalTrigger>
|
|
||||||
<Button variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Icons.Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.addWebsite)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal title={formatMessage(labels.addWebsite)}>
|
|
||||||
{close => <WebsiteAddForm onSave={handleSave} onClose={close} />}
|
|
||||||
</Modal>
|
|
||||||
</ModalTrigger>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page loading={isLoading} error={error}>
|
|
||||||
<PageHeader title={formatMessage(labels.websites)}>{addButton}</PageHeader>
|
|
||||||
{hasData && <WebsitesTable data={data} />}
|
|
||||||
{!hasData && (
|
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)}>
|
|
||||||
{addButton}
|
|
||||||
</EmptyPlaceholder>
|
|
||||||
)}
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WebsitesList;
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
import { Button, Text, Icon, Icons } from 'react-basics';
|
|
||||||
import SettingsTable from 'components/common/SettingsTable';
|
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import useConfig from 'hooks/useConfig';
|
|
||||||
|
|
||||||
export function WebsitesTable({ data = [] }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { openExternal } = useConfig();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ name: 'name', label: formatMessage(labels.name) },
|
|
||||||
{ name: 'domain', label: formatMessage(labels.domain) },
|
|
||||||
{ name: 'action', label: ' ' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsTable columns={columns} data={data}>
|
|
||||||
{row => {
|
|
||||||
const { id } = row;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Link href={`/settings/websites/${id}`}>
|
|
||||||
<Button>
|
|
||||||
<Icon>
|
|
||||||
<Icons.Edit />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.edit)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href={`/websites/${id}`} target={openExternal ? '_blank' : null}>
|
|
||||||
<Button>
|
|
||||||
<Icon>
|
|
||||||
<Icons.External />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.view)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</SettingsTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WebsitesTable;
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { Loading } from 'react-basics';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import WebsiteChart from 'components/pages/websites/WebsiteChart';
|
|
||||||
import FilterTags from 'components/metrics/FilterTags';
|
|
||||||
import usePageQuery from 'hooks/usePageQuery';
|
|
||||||
import WebsiteTableView from './WebsiteTableView';
|
|
||||||
import WebsiteMenuView from './WebsiteMenuView';
|
|
||||||
import { useWebsite } from 'hooks';
|
|
||||||
import WebsiteHeader from './WebsiteHeader';
|
|
||||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
|
||||||
|
|
||||||
export default function WebsiteDetailsPage({ websiteId }) {
|
|
||||||
const { data: website, isLoading, error } = useWebsite(websiteId);
|
|
||||||
const { pathname } = useRouter();
|
|
||||||
const showLinks = !pathname.includes('/share/');
|
|
||||||
|
|
||||||
const {
|
|
||||||
query: { view, url, referrer, os, browser, device, country, region, city, title },
|
|
||||||
} = usePageQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page loading={isLoading} error={error}>
|
|
||||||
<WebsiteHeader websiteId={websiteId} showLinks={showLinks} />
|
|
||||||
<WebsiteMetricsBar websiteId={websiteId} sticky={true} />
|
|
||||||
<WebsiteChart websiteId={websiteId} />
|
|
||||||
<FilterTags
|
|
||||||
websiteId={websiteId}
|
|
||||||
params={{ url, referrer, os, browser, device, country, region, city, title }}
|
|
||||||
/>
|
|
||||||
{!website && <Loading icon="dots" style={{ minHeight: 300 }} />}
|
|
||||||
{website && (
|
|
||||||
<>
|
|
||||||
{!view && <WebsiteTableView websiteId={websiteId} />}
|
|
||||||
{view && <WebsiteMenuView websiteId={websiteId} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { Flexbox } from 'react-basics';
|
|
||||||
import EventDataTable from 'components/pages/event-data/EventDataTable';
|
|
||||||
import EventDataValueTable from 'components/pages/event-data/EventDataValueTable';
|
|
||||||
import { EventDataMetricsBar } from 'components/pages/event-data/EventDataMetricsBar';
|
|
||||||
import { useDateRange, useApi, usePageQuery } from 'hooks';
|
|
||||||
import styles from './WebsiteEventData.module.css';
|
|
||||||
|
|
||||||
function useData(websiteId, event) {
|
|
||||||
const [dateRange] = useDateRange(websiteId);
|
|
||||||
const { startDate, endDate } = dateRange;
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { data, error, isLoading } = useQuery(
|
|
||||||
['event-data:events', { websiteId, startDate, endDate, event }],
|
|
||||||
() =>
|
|
||||||
get('/event-data/events', {
|
|
||||||
websiteId,
|
|
||||||
startAt: +startDate,
|
|
||||||
endAt: +endDate,
|
|
||||||
event,
|
|
||||||
}),
|
|
||||||
{ enabled: !!(websiteId && startDate && endDate) },
|
|
||||||
);
|
|
||||||
|
|
||||||
return { data, error, isLoading };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WebsiteEventData({ websiteId }) {
|
|
||||||
const {
|
|
||||||
query: { event },
|
|
||||||
} = usePageQuery();
|
|
||||||
const { data } = useData(websiteId, event);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flexbox className={styles.container} direction="column" gap={20}>
|
|
||||||
<EventDataMetricsBar websiteId={websiteId} />
|
|
||||||
{!event && <EventDataTable data={data} />}
|
|
||||||
{event && <EventDataValueTable event={event} data={data} />}
|
|
||||||
</Flexbox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import WebsiteHeader from './WebsiteHeader';
|
|
||||||
import WebsiteEventData from './WebsiteEventData';
|
|
||||||
|
|
||||||
export default function WebsiteEventDataPage({ websiteId }) {
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<WebsiteHeader websiteId={websiteId} />
|
|
||||||
<WebsiteEventData websiteId={websiteId} />
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
.menu {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
min-height: 800px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Row, Column } from 'react-basics';
|
|
||||||
import { formatShortTime } from 'lib/format';
|
|
||||||
import MetricCard from 'components/metrics/MetricCard';
|
|
||||||
import RefreshButton from 'components/input/RefreshButton';
|
|
||||||
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
|
|
||||||
import MetricsBar from 'components/metrics/MetricsBar';
|
|
||||||
import { useApi, useDateRange, usePageQuery, useMessages, useSticky } from 'hooks';
|
|
||||||
import styles from './WebsiteMetricsBar.module.css';
|
|
||||||
|
|
||||||
export function WebsiteMetricsBar({ websiteId, sticky }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const [dateRange] = useDateRange(websiteId);
|
|
||||||
const { startDate, endDate, modified } = dateRange;
|
|
||||||
const { ref, isSticky } = useSticky({ enabled: sticky });
|
|
||||||
const {
|
|
||||||
query: { url, referrer, title, os, browser, device, country, region, city },
|
|
||||||
} = usePageQuery();
|
|
||||||
|
|
||||||
const { data, error, isLoading, isFetched } = useQuery(
|
|
||||||
[
|
|
||||||
'websites:stats',
|
|
||||||
{ websiteId, modified, url, referrer, title, os, browser, device, country, region, city },
|
|
||||||
],
|
|
||||||
() =>
|
|
||||||
get(`/websites/${websiteId}/stats`, {
|
|
||||||
startAt: +startDate,
|
|
||||||
endAt: +endDate,
|
|
||||||
url,
|
|
||||||
referrer,
|
|
||||||
title,
|
|
||||||
os,
|
|
||||||
browser,
|
|
||||||
device,
|
|
||||||
country,
|
|
||||||
region,
|
|
||||||
city,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { pageviews, uniques, bounces, totaltime } = data || {};
|
|
||||||
const num = Math.min(data && uniques.value, data && bounces.value);
|
|
||||||
const diffs = data && {
|
|
||||||
pageviews: pageviews.value - pageviews.change,
|
|
||||||
uniques: uniques.value - uniques.change,
|
|
||||||
bounces: bounces.value - bounces.change,
|
|
||||||
totaltime: totaltime.value - totaltime.change,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row
|
|
||||||
ref={ref}
|
|
||||||
className={classNames(styles.container, {
|
|
||||||
[styles.sticky]: sticky,
|
|
||||||
[styles.isSticky]: isSticky,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Column defaultSize={12} xl={8}>
|
|
||||||
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
|
|
||||||
{!error && isFetched && (
|
|
||||||
<>
|
|
||||||
<MetricCard
|
|
||||||
className={styles.card}
|
|
||||||
label={formatMessage(labels.views)}
|
|
||||||
value={pageviews.value}
|
|
||||||
change={pageviews.change}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
className={styles.card}
|
|
||||||
label={formatMessage(labels.visitors)}
|
|
||||||
value={uniques.value}
|
|
||||||
change={uniques.change}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
className={styles.card}
|
|
||||||
label={formatMessage(labels.bounceRate)}
|
|
||||||
value={uniques.value ? (num / uniques.value) * 100 : 0}
|
|
||||||
change={
|
|
||||||
uniques.value && uniques.change
|
|
||||||
? (num / uniques.value) * 100 -
|
|
||||||
(Math.min(diffs.uniques, diffs.bounces) / diffs.uniques) * 100 || 0
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
format={n => Number(n).toFixed(0) + '%'}
|
|
||||||
reverseColors
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
className={styles.card}
|
|
||||||
label={formatMessage(labels.averageVisitTime)}
|
|
||||||
value={
|
|
||||||
totaltime.value && pageviews.value
|
|
||||||
? totaltime.value / (pageviews.value - bounces.value)
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
change={
|
|
||||||
totaltime.value && pageviews.value
|
|
||||||
? (diffs.totaltime / (diffs.pageviews - diffs.bounces) -
|
|
||||||
totaltime.value / (pageviews.value - bounces.value)) *
|
|
||||||
-1 || 0
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
format={n =>
|
|
||||||
`${n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</MetricsBar>
|
|
||||||
</Column>
|
|
||||||
<Column defaultSize={12} xl={4}>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<RefreshButton websiteId={websiteId} />
|
|
||||||
<WebsiteDateFilter websiteId={websiteId} />
|
|
||||||
</div>
|
|
||||||
</Column>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WebsiteMetricsBar;
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Button, Icon, Icons, Text, Flexbox } from 'react-basics';
|
|
||||||
import { useMessages, useReports } from 'hooks';
|
|
||||||
import ReportsTable from 'components/pages/reports/ReportsTable';
|
|
||||||
import WebsiteHeader from './WebsiteHeader';
|
|
||||||
|
|
||||||
export function WebsiteReportsPage({ websiteId }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { reports, error, isLoading, deleteReport } = useReports(websiteId);
|
|
||||||
|
|
||||||
const handleDelete = async id => {
|
|
||||||
await deleteReport(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page loading={isLoading} error={error}>
|
|
||||||
<WebsiteHeader websiteId={websiteId} />
|
|
||||||
<Flexbox alignItems="center" justifyContent="end">
|
|
||||||
<Link href="/reports/create">
|
|
||||||
<Button variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Icons.Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.createReport)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</Flexbox>
|
|
||||||
<ReportsTable data={reports} onDelete={handleDelete} />
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WebsiteReportsPage;
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { GridRow, GridColumn } from 'components/layout/Grid';
|
|
||||||
import PagesTable from 'components/metrics/PagesTable';
|
|
||||||
import ReferrersTable from 'components/metrics/ReferrersTable';
|
|
||||||
import BrowsersTable from 'components/metrics/BrowsersTable';
|
|
||||||
import OSTable from 'components/metrics/OSTable';
|
|
||||||
import DevicesTable from 'components/metrics/DevicesTable';
|
|
||||||
import WorldMap from 'components/common/WorldMap';
|
|
||||||
import CountriesTable from 'components/metrics/CountriesTable';
|
|
||||||
import EventsTable from 'components/metrics/EventsTable';
|
|
||||||
import EventsChart from 'components/metrics/EventsChart';
|
|
||||||
|
|
||||||
export default function WebsiteTableView({ websiteId }) {
|
|
||||||
const [countryData, setCountryData] = useState();
|
|
||||||
const tableProps = {
|
|
||||||
websiteId,
|
|
||||||
limit: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GridRow>
|
|
||||||
<GridColumn variant="two">
|
|
||||||
<PagesTable {...tableProps} />
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn variant="two">
|
|
||||||
<ReferrersTable {...tableProps} />
|
|
||||||
</GridColumn>
|
|
||||||
</GridRow>
|
|
||||||
<GridRow>
|
|
||||||
<GridColumn variant="three">
|
|
||||||
<BrowsersTable {...tableProps} />
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn variant="three">
|
|
||||||
<OSTable {...tableProps} />
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn variant="three">
|
|
||||||
<DevicesTable {...tableProps} />
|
|
||||||
</GridColumn>
|
|
||||||
</GridRow>
|
|
||||||
<GridRow>
|
|
||||||
<GridColumn xs={12} sm={12} md={12} defaultSize={8}>
|
|
||||||
<WorldMap data={countryData} />
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn xs={12} sm={12} md={12} defaultSize={4}>
|
|
||||||
<CountriesTable {...tableProps} onDataLoad={setCountryData} />
|
|
||||||
</GridColumn>
|
|
||||||
</GridRow>
|
|
||||||
<GridRow>
|
|
||||||
<GridColumn xs={12} sm={12} md={12} lg={4} defaultSize={4}>
|
|
||||||
<EventsTable {...tableProps} />
|
|
||||||
</GridColumn>
|
|
||||||
<GridColumn xs={12} sm={12} md={12} lg={8} defaultSize={8}>
|
|
||||||
<EventsChart websiteId={websiteId} />
|
|
||||||
</GridColumn>
|
|
||||||
</GridRow>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
.col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
border-top: 1px solid var(--base300);
|
|
||||||
min-height: 430px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row > .col {
|
|
||||||
border-left: 1px solid var(--base300);
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row > .col:first-child {
|
|
||||||
border-left: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row > .col:last-child {
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 992px) {
|
|
||||||
.row {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row > .col {
|
|
||||||
border-top: 1px solid var(--base300);
|
|
||||||
border-left: 0;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -66,7 +66,7 @@ CREATE TABLE umami.website_event_queue (
|
||||||
)
|
)
|
||||||
ENGINE = Kafka
|
ENGINE = Kafka
|
||||||
SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input broker list
|
SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input broker list
|
||||||
kafka_topic_list = 'events',
|
kafka_topic_list = 'event',
|
||||||
kafka_group_name = 'event_consumer_group',
|
kafka_group_name = 'event_consumer_group',
|
||||||
kafka_format = 'JSONEachRow',
|
kafka_format = 'JSONEachRow',
|
||||||
kafka_max_block_size = 1048576,
|
kafka_max_block_size = 1048576,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE `event_data` RENAME COLUMN `event_data_type` TO `data_type`;
|
ALTER TABLE `event_data` CHANGE `event_data_type` `data_type` INTEGER UNSIGNED NOT NULL;
|
||||||
ALTER TABLE `event_data` RENAME COLUMN `event_date_value` TO `date_value`;
|
ALTER TABLE `event_data` CHANGE `event_date_value` `date_value` TIMESTAMP(0) NULL;
|
||||||
ALTER TABLE `event_data` RENAME COLUMN `event_id` TO `event_data_id`;
|
ALTER TABLE `event_data` CHANGE `event_id` `event_data_id` VARCHAR(36) NOT NULL;
|
||||||
ALTER TABLE `event_data` RENAME COLUMN `event_numeric_value` TO `number_value`;
|
ALTER TABLE `event_data` CHANGE `event_numeric_value` `number_value` DECIMAL(19,4) NULL;
|
||||||
ALTER TABLE `event_data` RENAME COLUMN `event_string_value` TO `string_value`;
|
ALTER TABLE `event_data` CHANGE `event_string_value` `string_value` VARCHAR(500) NULL;
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE `session_data` (
|
CREATE TABLE `session_data` (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `event_data_website_id_created_at_idx` ON `event_data`(`website_id`, `created_at`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `event_data_website_id_created_at_event_key_idx` ON `event_data`(`website_id`, `created_at`, `event_key`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_idx` ON `session`(`website_id`, `created_at`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_hostname_idx` ON `session`(`website_id`, `created_at`, `hostname`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_browser_idx` ON `session`(`website_id`, `created_at`, `browser`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_os_idx` ON `session`(`website_id`, `created_at`, `os`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_device_idx` ON `session`(`website_id`, `created_at`, `device`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_screen_idx` ON `session`(`website_id`, `created_at`, `screen`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_language_idx` ON `session`(`website_id`, `created_at`, `language`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_country_idx` ON `session`(`website_id`, `created_at`, `country`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_subdivision1_idx` ON `session`(`website_id`, `created_at`, `subdivision1`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `session_website_id_created_at_city_idx` ON `session`(`website_id`, `created_at`, `city`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `website_event_website_id_created_at_url_path_idx` ON `website_event`(`website_id`, `created_at`, `url_path`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `website_event_website_id_created_at_url_query_idx` ON `website_event`(`website_id`, `created_at`, `url_query`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `website_event_website_id_created_at_referrer_domain_idx` ON `website_event`(`website_id`, `created_at`, `referrer_domain`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `website_event_website_id_created_at_page_title_idx` ON `website_event`(`website_id`, `created_at`, `page_title`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `website_event_website_id_created_at_event_name_idx` ON `website_event`(`website_id`, `created_at`, `event_name`);
|
||||||
|
|
@ -44,6 +44,16 @@ model Session {
|
||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, hostname])
|
||||||
|
@@index([websiteId, createdAt, browser])
|
||||||
|
@@index([websiteId, createdAt, os])
|
||||||
|
@@index([websiteId, createdAt, device])
|
||||||
|
@@index([websiteId, createdAt, screen])
|
||||||
|
@@index([websiteId, createdAt, language])
|
||||||
|
@@index([websiteId, createdAt, country])
|
||||||
|
@@index([websiteId, createdAt, subdivision1])
|
||||||
|
@@index([websiteId, createdAt, city])
|
||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,6 +101,11 @@ model WebsiteEvent {
|
||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
@@index([websiteId, createdAt])
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, urlPath])
|
||||||
|
@@index([websiteId, createdAt, urlQuery])
|
||||||
|
@@index([websiteId, createdAt, referrerDomain])
|
||||||
|
@@index([websiteId, createdAt, pageTitle])
|
||||||
|
@@index([websiteId, createdAt, eventName])
|
||||||
@@index([websiteId, sessionId, createdAt])
|
@@index([websiteId, sessionId, createdAt])
|
||||||
@@map("website_event")
|
@@map("website_event")
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +128,8 @@ model EventData {
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
@@index([websiteEventId])
|
@@index([websiteEventId])
|
||||||
@@index([websiteId, websiteEventId, createdAt])
|
@@index([websiteId, websiteEventId, createdAt])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, eventKey])
|
||||||
@@map("event_data")
|
@@map("event_data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "event_data_website_id_created_at_idx" ON "event_data"("website_id", "created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "event_data_website_id_created_at_event_key_idx" ON "event_data"("website_id", "created_at", "event_key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_idx" ON "session"("website_id", "created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_hostname_idx" ON "session"("website_id", "created_at", "hostname");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_browser_idx" ON "session"("website_id", "created_at", "browser");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_os_idx" ON "session"("website_id", "created_at", "os");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_device_idx" ON "session"("website_id", "created_at", "device");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_screen_idx" ON "session"("website_id", "created_at", "screen");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_language_idx" ON "session"("website_id", "created_at", "language");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_country_idx" ON "session"("website_id", "created_at", "country");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_subdivision1_idx" ON "session"("website_id", "created_at", "subdivision1");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_website_id_created_at_city_idx" ON "session"("website_id", "created_at", "city");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "website_event_website_id_created_at_url_path_idx" ON "website_event"("website_id", "created_at", "url_path");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "website_event_website_id_created_at_url_query_idx" ON "website_event"("website_id", "created_at", "url_query");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "website_event_website_id_created_at_referrer_domain_idx" ON "website_event"("website_id", "created_at", "referrer_domain");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "website_event_website_id_created_at_page_title_idx" ON "website_event"("website_id", "created_at", "page_title");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "website_event_website_id_created_at_event_name_idx" ON "website_event"("website_id", "created_at", "event_name");
|
||||||
|
|
@ -44,6 +44,16 @@ model Session {
|
||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, hostname])
|
||||||
|
@@index([websiteId, createdAt, browser])
|
||||||
|
@@index([websiteId, createdAt, os])
|
||||||
|
@@index([websiteId, createdAt, device])
|
||||||
|
@@index([websiteId, createdAt, screen])
|
||||||
|
@@index([websiteId, createdAt, language])
|
||||||
|
@@index([websiteId, createdAt, country])
|
||||||
|
@@index([websiteId, createdAt, subdivision1])
|
||||||
|
@@index([websiteId, createdAt, city])
|
||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,6 +101,11 @@ model WebsiteEvent {
|
||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
@@index([websiteId, createdAt])
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, urlPath])
|
||||||
|
@@index([websiteId, createdAt, urlQuery])
|
||||||
|
@@index([websiteId, createdAt, referrerDomain])
|
||||||
|
@@index([websiteId, createdAt, pageTitle])
|
||||||
|
@@index([websiteId, createdAt, eventName])
|
||||||
@@index([websiteId, sessionId, createdAt])
|
@@index([websiteId, sessionId, createdAt])
|
||||||
@@map("website_event")
|
@@map("website_event")
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +127,8 @@ model EventData {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
@@index([websiteEventId])
|
@@index([websiteEventId])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, eventKey])
|
||||||
@@map("event_data")
|
@@map("event_data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ services:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: always
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
db:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { useEffect, useCallback } from 'react';
|
|
||||||
|
|
||||||
export function useEscapeKey(handler) {
|
|
||||||
const escFunction = useCallback(event => {
|
|
||||||
if (event.keyCode === 27) {
|
|
||||||
handler(event);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('keydown', escFunction, false);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', escFunction, false);
|
|
||||||
};
|
|
||||||
}, [escFunction]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useEscapeKey;
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { useMessages } from 'hooks';
|
|
||||||
|
|
||||||
export function useFilters() {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const filters = {
|
|
||||||
eq: formatMessage(labels.equals),
|
|
||||||
neq: formatMessage(labels.doesNotEqual),
|
|
||||||
c: formatMessage(labels.contains),
|
|
||||||
dnc: formatMessage(labels.doesNotContain),
|
|
||||||
t: formatMessage(labels.true),
|
|
||||||
f: formatMessage(labels.false),
|
|
||||||
gt: formatMessage(labels.greaterThan),
|
|
||||||
lt: formatMessage(labels.lessThan),
|
|
||||||
gte: formatMessage(labels.greaterThanEquals),
|
|
||||||
lte: formatMessage(labels.lessThanEquals),
|
|
||||||
be: formatMessage(labels.before),
|
|
||||||
af: formatMessage(labels.after),
|
|
||||||
};
|
|
||||||
|
|
||||||
const types = {
|
|
||||||
string: ['eq', 'neq'],
|
|
||||||
array: ['c', 'dnc'],
|
|
||||||
boolean: ['t', 'f'],
|
|
||||||
number: ['eq', 'neq', 'gt', 'lt', 'gte', 'lte'],
|
|
||||||
date: ['be', 'af'],
|
|
||||||
uuid: ['eq'],
|
|
||||||
};
|
|
||||||
|
|
||||||
return { filters, types };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useFilters;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { useIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { messages, labels } from 'components/messages';
|
|
||||||
|
|
||||||
export function useMessages() {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
|
|
||||||
function getMessage(id) {
|
|
||||||
const message = Object.values(messages).find(value => value.id === id);
|
|
||||||
|
|
||||||
return message ? formatMessage(message) : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { formatMessage, FormattedMessage, messages, labels, getMessage };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useMessages;
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { buildUrl } from 'next-basics';
|
|
||||||
|
|
||||||
export function usePageQuery() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { pathname, search } = location;
|
|
||||||
const { asPath } = router;
|
|
||||||
|
|
||||||
const query = useMemo(() => {
|
|
||||||
if (!search) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = search.substring(1).split('&');
|
|
||||||
|
|
||||||
return params.reduce((obj, item) => {
|
|
||||||
const [key, value] = item.split('=');
|
|
||||||
|
|
||||||
obj[key] = decodeURIComponent(value);
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
function resolveUrl(params, reset) {
|
|
||||||
return buildUrl(asPath.split('?')[0], { ...(reset ? {} : query), ...params });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { pathname, query, resolveUrl, router };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default usePageQuery;
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import useApi from './useApi';
|
|
||||||
|
|
||||||
export function useReports(websiteId) {
|
|
||||||
const [modified, setModified] = useState(Date.now());
|
|
||||||
const { get, useQuery, del, useMutation } = useApi();
|
|
||||||
const { mutate } = useMutation(reportId => del(`/reports/${reportId}`));
|
|
||||||
const { data, error, isLoading } = useQuery(['reports:website', { websiteId, modified }], () =>
|
|
||||||
get(`/reports`, { websiteId }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteReport = id => {
|
|
||||||
mutate(id, {
|
|
||||||
onSuccess: () => {
|
|
||||||
setModified(Date.now());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return { reports: data, error, isLoading, deleteReport };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useReports;
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import useApi from 'hooks/useApi';
|
|
||||||
import useUser from 'hooks/useUser';
|
|
||||||
|
|
||||||
export function useRequireLogin() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { get } = useApi();
|
|
||||||
const { user, setUser } = useUser();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadUser() {
|
|
||||||
try {
|
|
||||||
const { user } = await get('/auth/verify');
|
|
||||||
|
|
||||||
setUser(user);
|
|
||||||
} catch {
|
|
||||||
await router.push('/login');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
loadUser();
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
return { user };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useRequireLogin;
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import useStore, { setShareToken } from 'store/app';
|
|
||||||
import useApi from './useApi';
|
|
||||||
|
|
||||||
const selector = state => state.shareToken;
|
|
||||||
|
|
||||||
export function useShareToken(shareId) {
|
|
||||||
const shareToken = useStore(selector);
|
|
||||||
const { get } = useApi();
|
|
||||||
|
|
||||||
async function loadToken(id) {
|
|
||||||
const data = await get(`/share/${id}`);
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
setShareToken(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shareId) {
|
|
||||||
loadToken(shareId);
|
|
||||||
}
|
|
||||||
}, [shareId]);
|
|
||||||
|
|
||||||
return shareToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useShareToken;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import useApi from './useApi';
|
|
||||||
|
|
||||||
export function useWebsite(websiteId) {
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
return useQuery(['websites', websiteId], () => get(`/websites/${websiteId}`), {
|
|
||||||
enabled: !!websiteId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useWebsite;
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "."
|
"baseUrl": "./src"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
195
lang/ja-JP.json
195
lang/ja-JP.json
|
|
@ -1,195 +0,0 @@
|
||||||
{
|
|
||||||
"label.access-code": "Access code",
|
|
||||||
"label.actions": "アクション",
|
|
||||||
"label.activity-log": "Activity log",
|
|
||||||
"label.add": "Add",
|
|
||||||
"label.add-description": "Add description",
|
|
||||||
"label.add-website": "Webサイトの追加",
|
|
||||||
"label.admin": "管理者",
|
|
||||||
"label.all": "すべて表示",
|
|
||||||
"label.all-time": "All time",
|
|
||||||
"label.analytics": "Analytics",
|
|
||||||
"label.average-visit-time": "平均滞在時間",
|
|
||||||
"label.back": "戻る",
|
|
||||||
"label.bounce-rate": "直帰率",
|
|
||||||
"label.browsers": "ブラウザ",
|
|
||||||
"label.cancel": "キャンセル",
|
|
||||||
"label.change-password": "パスワード変更",
|
|
||||||
"label.cities": "Cities",
|
|
||||||
"label.clear-all": "Clear all",
|
|
||||||
"label.confirm": "Confirm",
|
|
||||||
"label.confirm-password": "パスワード(確認)",
|
|
||||||
"label.continue": "Continue",
|
|
||||||
"label.countries": "国",
|
|
||||||
"label.create-team": "Create team",
|
|
||||||
"label.create-user": "Create user",
|
|
||||||
"label.created": "Created",
|
|
||||||
"label.current-password": "現在のパスワード",
|
|
||||||
"label.custom-range": "期間を指定する",
|
|
||||||
"label.dashboard": "ダッシュボード",
|
|
||||||
"label.data": "Data",
|
|
||||||
"label.date-range": "範囲指定",
|
|
||||||
"label.default-date-range": "最初に表示する期間",
|
|
||||||
"label.delete": "削除",
|
|
||||||
"label.delete-team": "Delete team",
|
|
||||||
"label.delete-user": "Delete user",
|
|
||||||
"label.delete-website": "Webサイトの削除",
|
|
||||||
"label.desktop": "デスクトップ",
|
|
||||||
"label.details": "Details",
|
|
||||||
"label.devices": "デバイス",
|
|
||||||
"label.dismiss": "無視する",
|
|
||||||
"label.domain": "ドメイン",
|
|
||||||
"label.dropoff": "Dropoff",
|
|
||||||
"label.edit": "編集",
|
|
||||||
"label.edit-dashboard": "Edit dashboard",
|
|
||||||
"label.enable-share-url": "共有リンクを有効にする",
|
|
||||||
"label.event": "Event",
|
|
||||||
"label.event-data": "Event data",
|
|
||||||
"label.events": "イベント",
|
|
||||||
"label.field": "Field",
|
|
||||||
"label.fields": "Fields",
|
|
||||||
"label.filter-combined": "パスまで",
|
|
||||||
"label.filter-raw": "すべて表示",
|
|
||||||
"label.funnel": "Funnel",
|
|
||||||
"label.insights": "Insights",
|
|
||||||
"label.join": "Join",
|
|
||||||
"label.join-team": "Join team",
|
|
||||||
"label.language": "Language",
|
|
||||||
"label.languages": "Languages",
|
|
||||||
"label.laptop": "ノートPC",
|
|
||||||
"label.last-days": "過去{x}日間",
|
|
||||||
"label.last-hours": "過去{x}時間",
|
|
||||||
"label.leave": "Leave",
|
|
||||||
"label.leave-team": "Leave team",
|
|
||||||
"label.login": "ログイン",
|
|
||||||
"label.logout": "ログアウト",
|
|
||||||
"label.members": "Members",
|
|
||||||
"label.mobile": "携帯電話",
|
|
||||||
"label.more": "さらに表示",
|
|
||||||
"label.name": "名前",
|
|
||||||
"label.new-password": "新しいパスワード",
|
|
||||||
"label.none": "None",
|
|
||||||
"label.operating-systems": "OS",
|
|
||||||
"label.owner": "Owner",
|
|
||||||
"label.page-views": "閲覧数",
|
|
||||||
"label.pages": "ページ",
|
|
||||||
"label.password": "パスワード",
|
|
||||||
"label.powered-by": "このシステムは {name} で実行されています。",
|
|
||||||
"label.profile": "プロファイル",
|
|
||||||
"label.queries": "Queries",
|
|
||||||
"label.query": "Query",
|
|
||||||
"label.query-parameters": "Query parameters",
|
|
||||||
"label.realtime": "リアルタイム",
|
|
||||||
"label.referrers": "リファラー",
|
|
||||||
"label.refresh": "更新",
|
|
||||||
"label.regenerate": "Regenerate",
|
|
||||||
"label.regions": "Regions",
|
|
||||||
"label.remove": "Remove",
|
|
||||||
"label.reports": "Reports",
|
|
||||||
"label.required": "必須",
|
|
||||||
"label.reset": "リセット",
|
|
||||||
"label.reset-website": "Reset statistics",
|
|
||||||
"label.role": "Role",
|
|
||||||
"label.run-query": "Run query",
|
|
||||||
"label.save": "保存",
|
|
||||||
"label.screens": "Screens",
|
|
||||||
"label.select-date": "Select date",
|
|
||||||
"label.select-website": "Select website",
|
|
||||||
"label.sessions": "Sessions",
|
|
||||||
"label.settings": "設定",
|
|
||||||
"label.share-url": "共有リンク",
|
|
||||||
"label.single-day": "一日のみ",
|
|
||||||
"label.tablet": "タブレット",
|
|
||||||
"label.team": "Team",
|
|
||||||
"label.team-guest": "Team guest",
|
|
||||||
"label.team-id": "Team ID",
|
|
||||||
"label.team-member": "Team member",
|
|
||||||
"label.team-owner": "Team owner",
|
|
||||||
"label.teams": "Teams",
|
|
||||||
"label.theme": "Theme",
|
|
||||||
"label.this-month": "今月",
|
|
||||||
"label.this-week": "今週",
|
|
||||||
"label.this-year": "今年",
|
|
||||||
"label.timezone": "タイムゾーン",
|
|
||||||
"label.title": "Title",
|
|
||||||
"label.today": "今日",
|
|
||||||
"label.toggle-charts": "Toggle charts",
|
|
||||||
"label.tracking-code": "トラッキングコード",
|
|
||||||
"label.unique-visitors": "ユニーク訪問者数",
|
|
||||||
"label.unknown": "不明",
|
|
||||||
"label.url": "URL",
|
|
||||||
"label.urls": "URLs",
|
|
||||||
"label.user": "User",
|
|
||||||
"label.username": "ユーザー名",
|
|
||||||
"label.users": "Users",
|
|
||||||
"label.view": "View",
|
|
||||||
"label.view-details": "詳細を見る",
|
|
||||||
"label.view-only": "View only",
|
|
||||||
"label.views": "閲覧数",
|
|
||||||
"label.visitors": "訪問者数",
|
|
||||||
"label.website": "Website",
|
|
||||||
"label.website-id": "Website ID",
|
|
||||||
"label.websites": "Webサイト",
|
|
||||||
"label.window": "Window",
|
|
||||||
"label.yesterday": "Yesterday",
|
|
||||||
"labels.after": "After",
|
|
||||||
"labels.average": "Average",
|
|
||||||
"labels.before": "Before",
|
|
||||||
"labels.breakdown": "Breakdown",
|
|
||||||
"labels.contains": "Contains",
|
|
||||||
"labels.create-report": "Create report",
|
|
||||||
"labels.description": "Description",
|
|
||||||
"labels.does-not-contain": "Does not contain",
|
|
||||||
"labels.does-not-equal": "Does not equal",
|
|
||||||
"labels.equals": "Equals",
|
|
||||||
"labels.false": "False",
|
|
||||||
"labels.filters": "Filters",
|
|
||||||
"labels.greater-than": "Greater than",
|
|
||||||
"labels.greater-than-equals": "Greater than or equals",
|
|
||||||
"labels.less-than": "Less than",
|
|
||||||
"labels.less-than-equals": "Less than or equals",
|
|
||||||
"labels.max": "Max",
|
|
||||||
"labels.min": "Min",
|
|
||||||
"labels.overview": "Overview",
|
|
||||||
"labels.sum": "Sum",
|
|
||||||
"labels.total": "Total",
|
|
||||||
"labels.total-records": "Total records",
|
|
||||||
"labels.true": "True",
|
|
||||||
"labels.type": "Type",
|
|
||||||
"labels.unique": "Unique",
|
|
||||||
"labels.untitled": "Untitled",
|
|
||||||
"labels.value": "Value",
|
|
||||||
"message.active-users": "{x}人が閲覧中です。",
|
|
||||||
"message.confirm-delete": "{target}を削除してもよろしいですか?",
|
|
||||||
"message.confirm-leave": "Are you sure you want to leave {target}?",
|
|
||||||
"message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
|
|
||||||
"message.delete-account": "To delete this account, type {confirmation} in the box below to confirm.",
|
|
||||||
"message.delete-website": "To delete this website, type {confirmation} in the box below to confirm.",
|
|
||||||
"message.delete-website-warning": "関連するすべてのデータも削除されます。",
|
|
||||||
"message.error": "問題が発生しました。",
|
|
||||||
"message.event-log": "{event} on {url}",
|
|
||||||
"message.go-to-settings": "設定する",
|
|
||||||
"message.incorrect-username-password": "ユーザー名/パスワードが正しくありません。",
|
|
||||||
"message.invalid-domain": "無効なドメイン",
|
|
||||||
"message.min-password-length": "Minimum length of {n} characters",
|
|
||||||
"message.no-data-available": "データがありません。",
|
|
||||||
"message.no-event-data": "No event data is available.",
|
|
||||||
"message.no-match-password": "パスワードが一致しません",
|
|
||||||
"message.no-teams": "You have not created any teams.",
|
|
||||||
"message.no-users": "There are no users.",
|
|
||||||
"message.page-not-found": "ページが見つかりません。",
|
|
||||||
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
|
|
||||||
"message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
|
|
||||||
"message.saved": "正常に保存されました。",
|
|
||||||
"message.share-url": "これは{target}の共有リンクです。",
|
|
||||||
"message.team-already-member": "You are already a member of the team.",
|
|
||||||
"message.team-not-found": "Team not found.",
|
|
||||||
"message.tracking-code": "トラッキングコード",
|
|
||||||
"message.user-deleted": "User deleted.",
|
|
||||||
"message.visitor-log": "{os}({device})で{browser}を使用している{country}からの訪問者",
|
|
||||||
"message.no-results-found": "No results were found.",
|
|
||||||
"message.no-team-websites": "This team does not have any websites.",
|
|
||||||
"message.no-websites-configured": "Webサイトが設定されていません。",
|
|
||||||
"message.team-websites-info": "Websites can be viewed by anyone on the team.",
|
|
||||||
"message.new-version-available": "A new version of Umami {version} is available!"
|
|
||||||
}
|
|
||||||
195
lang/sl-SI.json
195
lang/sl-SI.json
|
|
@ -1,195 +0,0 @@
|
||||||
{
|
|
||||||
"label.access-code": "Access code",
|
|
||||||
"label.actions": "Dejanja",
|
|
||||||
"label.activity-log": "Activity log",
|
|
||||||
"label.add": "Add",
|
|
||||||
"label.add-description": "Add description",
|
|
||||||
"label.add-website": "Dodaj spletno mesto",
|
|
||||||
"label.admin": "Administrator",
|
|
||||||
"label.all": "Vse",
|
|
||||||
"label.all-time": "All time",
|
|
||||||
"label.analytics": "Analytics",
|
|
||||||
"label.average-visit-time": "Povprečni čas obiska",
|
|
||||||
"label.back": "Nazaj",
|
|
||||||
"label.bounce-rate": "Zapustna stopnja",
|
|
||||||
"label.browsers": "Brskalniki",
|
|
||||||
"label.cancel": "Prekliči",
|
|
||||||
"label.change-password": "Zamenjaj geslo",
|
|
||||||
"label.cities": "Cities",
|
|
||||||
"label.clear-all": "Clear all",
|
|
||||||
"label.confirm": "Confirm",
|
|
||||||
"label.confirm-password": "Potrditev gesla",
|
|
||||||
"label.continue": "Continue",
|
|
||||||
"label.countries": "Države",
|
|
||||||
"label.create-team": "Create team",
|
|
||||||
"label.create-user": "Create user",
|
|
||||||
"label.created": "Created",
|
|
||||||
"label.current-password": "Trenutno geslo",
|
|
||||||
"label.custom-range": "Razpon po meri",
|
|
||||||
"label.dashboard": "Nadzorna plošča",
|
|
||||||
"label.data": "Data",
|
|
||||||
"label.date-range": "Časovni razpon",
|
|
||||||
"label.default-date-range": "Privzeti časovni razpon",
|
|
||||||
"label.delete": "Izbriši",
|
|
||||||
"label.delete-team": "Delete team",
|
|
||||||
"label.delete-user": "Delete user",
|
|
||||||
"label.delete-website": "Izbriši spletno mesto",
|
|
||||||
"label.desktop": "Namizni računalnik",
|
|
||||||
"label.details": "Details",
|
|
||||||
"label.devices": "Naprave",
|
|
||||||
"label.dismiss": "Opusti",
|
|
||||||
"label.domain": "Domena",
|
|
||||||
"label.dropoff": "Dropoff",
|
|
||||||
"label.edit": "Uredi",
|
|
||||||
"label.edit-dashboard": "Edit dashboard",
|
|
||||||
"label.enable-share-url": "Omogoči URL za skupno rabo",
|
|
||||||
"label.event": "Event",
|
|
||||||
"label.event-data": "Event data",
|
|
||||||
"label.events": "Dogodki",
|
|
||||||
"label.field": "Field",
|
|
||||||
"label.fields": "Fields",
|
|
||||||
"label.filter-combined": "Skupno",
|
|
||||||
"label.filter-raw": "Neobdelane meritve",
|
|
||||||
"label.funnel": "Funnel",
|
|
||||||
"label.insights": "Insights",
|
|
||||||
"label.join": "Join",
|
|
||||||
"label.join-team": "Join team",
|
|
||||||
"label.language": "Language",
|
|
||||||
"label.languages": "Languages",
|
|
||||||
"label.laptop": "Prenosni računalnik",
|
|
||||||
"label.last-days": "Zadnjih {x} dni",
|
|
||||||
"label.last-hours": "Zadnjih {x} ur",
|
|
||||||
"label.leave": "Leave",
|
|
||||||
"label.leave-team": "Leave team",
|
|
||||||
"label.login": "Prijava",
|
|
||||||
"label.logout": "Odjava",
|
|
||||||
"label.members": "Members",
|
|
||||||
"label.mobile": "Mobilni telefon",
|
|
||||||
"label.more": "Več",
|
|
||||||
"label.name": "Ime",
|
|
||||||
"label.new-password": "Novo geslo",
|
|
||||||
"label.none": "None",
|
|
||||||
"label.operating-systems": "Operacijski sistemi",
|
|
||||||
"label.owner": "Owner",
|
|
||||||
"label.page-views": "Ogledi strani",
|
|
||||||
"label.pages": "Strani",
|
|
||||||
"label.password": "Geslo",
|
|
||||||
"label.powered-by": "Zagotavlja {name}",
|
|
||||||
"label.profile": "Profil",
|
|
||||||
"label.queries": "Queries",
|
|
||||||
"label.query": "Query",
|
|
||||||
"label.query-parameters": "Query parameters",
|
|
||||||
"label.realtime": "V realnem času",
|
|
||||||
"label.referrers": "Viri",
|
|
||||||
"label.refresh": "Osveži",
|
|
||||||
"label.regenerate": "Regenerate",
|
|
||||||
"label.regions": "Regions",
|
|
||||||
"label.remove": "Remove",
|
|
||||||
"label.reports": "Reports",
|
|
||||||
"label.required": "Zahtevano",
|
|
||||||
"label.reset": "Ponastavi",
|
|
||||||
"label.reset-website": "Reset statistics",
|
|
||||||
"label.role": "Role",
|
|
||||||
"label.run-query": "Run query",
|
|
||||||
"label.save": "Shrani",
|
|
||||||
"label.screens": "Screens",
|
|
||||||
"label.select-date": "Select date",
|
|
||||||
"label.select-website": "Select website",
|
|
||||||
"label.sessions": "Sessions",
|
|
||||||
"label.settings": "Nastavitve",
|
|
||||||
"label.share-url": "Deli URL",
|
|
||||||
"label.single-day": "En dan",
|
|
||||||
"label.tablet": "Tablični računalnik",
|
|
||||||
"label.team": "Team",
|
|
||||||
"label.team-guest": "Team guest",
|
|
||||||
"label.team-id": "Team ID",
|
|
||||||
"label.team-member": "Team member",
|
|
||||||
"label.team-owner": "Team owner",
|
|
||||||
"label.teams": "Teams",
|
|
||||||
"label.theme": "Theme",
|
|
||||||
"label.this-month": "Ta mesec",
|
|
||||||
"label.this-week": "Ta teden",
|
|
||||||
"label.this-year": "Letos",
|
|
||||||
"label.timezone": "Časovni pas",
|
|
||||||
"label.title": "Title",
|
|
||||||
"label.today": "Danes",
|
|
||||||
"label.toggle-charts": "Toggle charts",
|
|
||||||
"label.tracking-code": "Koda za sledenje",
|
|
||||||
"label.unique-visitors": "Unikatni obiskovalci",
|
|
||||||
"label.unknown": "Neznano",
|
|
||||||
"label.url": "URL",
|
|
||||||
"label.urls": "URLs",
|
|
||||||
"label.user": "User",
|
|
||||||
"label.username": "Uporabniško ime",
|
|
||||||
"label.users": "Users",
|
|
||||||
"label.view": "View",
|
|
||||||
"label.view-details": "Prikaži podrobnosti",
|
|
||||||
"label.view-only": "View only",
|
|
||||||
"label.views": "Ogledi",
|
|
||||||
"label.visitors": "Obiskovalci",
|
|
||||||
"label.website": "Website",
|
|
||||||
"label.website-id": "Website ID",
|
|
||||||
"label.websites": "Spletna mesta",
|
|
||||||
"label.window": "Window",
|
|
||||||
"label.yesterday": "Yesterday",
|
|
||||||
"labels.after": "After",
|
|
||||||
"labels.average": "Average",
|
|
||||||
"labels.before": "Before",
|
|
||||||
"labels.breakdown": "Breakdown",
|
|
||||||
"labels.contains": "Contains",
|
|
||||||
"labels.create-report": "Create report",
|
|
||||||
"labels.description": "Description",
|
|
||||||
"labels.does-not-contain": "Does not contain",
|
|
||||||
"labels.does-not-equal": "Does not equal",
|
|
||||||
"labels.equals": "Equals",
|
|
||||||
"labels.false": "False",
|
|
||||||
"labels.filters": "Filters",
|
|
||||||
"labels.greater-than": "Greater than",
|
|
||||||
"labels.greater-than-equals": "Greater than or equals",
|
|
||||||
"labels.less-than": "Less than",
|
|
||||||
"labels.less-than-equals": "Less than or equals",
|
|
||||||
"labels.max": "Max",
|
|
||||||
"labels.min": "Min",
|
|
||||||
"labels.overview": "Overview",
|
|
||||||
"labels.sum": "Sum",
|
|
||||||
"labels.total": "Total",
|
|
||||||
"labels.total-records": "Total records",
|
|
||||||
"labels.true": "True",
|
|
||||||
"labels.type": "Type",
|
|
||||||
"labels.unique": "Unique",
|
|
||||||
"labels.untitled": "Untitled",
|
|
||||||
"labels.value": "Value",
|
|
||||||
"message.active-users": "{x} trenutni {x, plural, one {obiskovalec} other {obiskovalcev}}",
|
|
||||||
"message.confirm-delete": "Ste prepričani, da želite izbrisati {target}?",
|
|
||||||
"message.confirm-leave": "Are you sure you want to leave {target}?",
|
|
||||||
"message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
|
|
||||||
"message.delete-account": "To delete this account, type {confirmation} in the box below to confirm.",
|
|
||||||
"message.delete-website": "To delete this website, type {confirmation} in the box below to confirm.",
|
|
||||||
"message.delete-website-warning": "Izbrisani bodo tudi vsi povezani podatki.",
|
|
||||||
"message.error": "Prišlo je do napake.",
|
|
||||||
"message.event-log": "{event} on {url}",
|
|
||||||
"message.go-to-settings": "Pojdi v nastavitve",
|
|
||||||
"message.incorrect-username-password": "Nepravilno uporabniško ime/geslo",
|
|
||||||
"message.invalid-domain": "Neveljavna domena",
|
|
||||||
"message.min-password-length": "Minimum length of {n} characters",
|
|
||||||
"message.no-data-available": "Podatki niso na voljo.",
|
|
||||||
"message.no-event-data": "No event data is available.",
|
|
||||||
"message.no-match-password": "Gesli se ne ujemata",
|
|
||||||
"message.no-teams": "You have not created any teams.",
|
|
||||||
"message.no-users": "There are no users.",
|
|
||||||
"message.page-not-found": "Stran ni bila najdena.",
|
|
||||||
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
|
|
||||||
"message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
|
|
||||||
"message.saved": "Uspešno shranjeno.",
|
|
||||||
"message.share-url": "To je javno dostopen naslov URL za {target}.",
|
|
||||||
"message.team-already-member": "You are already a member of the team.",
|
|
||||||
"message.team-not-found": "Team not found.",
|
|
||||||
"message.tracking-code": "Koda za sledenje",
|
|
||||||
"message.user-deleted": "User deleted.",
|
|
||||||
"message.visitor-log": "Obiskovalec iz {country} uporablja {browser} na {os} {device}",
|
|
||||||
"message.no-results-found": "No results were found.",
|
|
||||||
"message.no-team-websites": "This team does not have any websites.",
|
|
||||||
"message.no-websites-configured": "Ni nastavljenih spletnih mest.",
|
|
||||||
"message.team-websites-info": "Websites can be viewed by anyone on the team.",
|
|
||||||
"message.new-version-available": "A new version of Umami {version} is available!"
|
|
||||||
}
|
|
||||||
195
lang/zh-TW.json
195
lang/zh-TW.json
|
|
@ -1,195 +0,0 @@
|
||||||
{
|
|
||||||
"label.access-code": "Access code",
|
|
||||||
"label.actions": "用戶行為",
|
|
||||||
"label.activity-log": "Activity log",
|
|
||||||
"label.add": "Add",
|
|
||||||
"label.add-description": "Add description",
|
|
||||||
"label.add-website": "增加網站",
|
|
||||||
"label.admin": "管理員",
|
|
||||||
"label.all": "所有",
|
|
||||||
"label.all-time": "所有時間段",
|
|
||||||
"label.analytics": "Analytics",
|
|
||||||
"label.average-visit-time": "平均訪問時間",
|
|
||||||
"label.back": "返回",
|
|
||||||
"label.bounce-rate": "跳出率",
|
|
||||||
"label.browsers": "瀏覽器",
|
|
||||||
"label.cancel": "取消",
|
|
||||||
"label.change-password": "更新密碼",
|
|
||||||
"label.cities": "Cities",
|
|
||||||
"label.clear-all": "Clear all",
|
|
||||||
"label.confirm": "Confirm",
|
|
||||||
"label.confirm-password": "確認密碼",
|
|
||||||
"label.continue": "Continue",
|
|
||||||
"label.countries": "國家/地區",
|
|
||||||
"label.create-team": "Create team",
|
|
||||||
"label.create-user": "Create user",
|
|
||||||
"label.created": "Created",
|
|
||||||
"label.current-password": "目前密碼",
|
|
||||||
"label.custom-range": "自定義時段",
|
|
||||||
"label.dashboard": "管理面板",
|
|
||||||
"label.data": "Data",
|
|
||||||
"label.date-range": "多日",
|
|
||||||
"label.default-date-range": "默認日期範圍",
|
|
||||||
"label.delete": "刪除",
|
|
||||||
"label.delete-team": "Delete team",
|
|
||||||
"label.delete-user": "Delete user",
|
|
||||||
"label.delete-website": "刪除網站",
|
|
||||||
"label.desktop": "桌機",
|
|
||||||
"label.details": "Details",
|
|
||||||
"label.devices": "裝置",
|
|
||||||
"label.dismiss": "關閉",
|
|
||||||
"label.domain": "域名",
|
|
||||||
"label.dropoff": "Dropoff",
|
|
||||||
"label.edit": "編輯",
|
|
||||||
"label.edit-dashboard": "編輯管理面板",
|
|
||||||
"label.enable-share-url": "啟用分享連結",
|
|
||||||
"label.event": "Event",
|
|
||||||
"label.event-data": "Event data",
|
|
||||||
"label.events": "行為類別",
|
|
||||||
"label.field": "Field",
|
|
||||||
"label.fields": "Fields",
|
|
||||||
"label.filter-combined": "總和",
|
|
||||||
"label.filter-raw": "原始",
|
|
||||||
"label.funnel": "Funnel",
|
|
||||||
"label.insights": "Insights",
|
|
||||||
"label.join": "Join",
|
|
||||||
"label.join-team": "Join team",
|
|
||||||
"label.language": "語言",
|
|
||||||
"label.languages": "語言",
|
|
||||||
"label.laptop": "筆記本",
|
|
||||||
"label.last-days": "最近 {x} 天",
|
|
||||||
"label.last-hours": "最近 {x} 小時",
|
|
||||||
"label.leave": "Leave",
|
|
||||||
"label.leave-team": "Leave team",
|
|
||||||
"label.login": "登入",
|
|
||||||
"label.logout": "退出",
|
|
||||||
"label.members": "Members",
|
|
||||||
"label.mobile": "手機",
|
|
||||||
"label.more": "更多",
|
|
||||||
"label.name": "名字",
|
|
||||||
"label.new-password": "新密碼",
|
|
||||||
"label.none": "無",
|
|
||||||
"label.operating-systems": "操作系統",
|
|
||||||
"label.owner": "擁有者",
|
|
||||||
"label.page-views": "網頁流量",
|
|
||||||
"label.pages": "網頁",
|
|
||||||
"label.password": "密碼",
|
|
||||||
"label.powered-by": "運行 {name}",
|
|
||||||
"label.profile": "個人資料",
|
|
||||||
"label.queries": "Queries",
|
|
||||||
"label.query": "Query",
|
|
||||||
"label.query-parameters": "查詢參數",
|
|
||||||
"label.realtime": "實時",
|
|
||||||
"label.referrers": "指入域名",
|
|
||||||
"label.refresh": "刷新",
|
|
||||||
"label.regenerate": "Regenerate",
|
|
||||||
"label.regions": "Regions",
|
|
||||||
"label.remove": "Remove",
|
|
||||||
"label.reports": "Reports",
|
|
||||||
"label.required": "必填",
|
|
||||||
"label.reset": "重置",
|
|
||||||
"label.reset-website": "重置統計數據",
|
|
||||||
"label.role": "Role",
|
|
||||||
"label.run-query": "Run query",
|
|
||||||
"label.save": "保存",
|
|
||||||
"label.screens": "屏幕尺寸",
|
|
||||||
"label.select-date": "Select date",
|
|
||||||
"label.select-website": "Select website",
|
|
||||||
"label.sessions": "Sessions",
|
|
||||||
"label.settings": "設置",
|
|
||||||
"label.share-url": "分享連結",
|
|
||||||
"label.single-day": "單日",
|
|
||||||
"label.tablet": "平板",
|
|
||||||
"label.team": "Team",
|
|
||||||
"label.team-guest": "Team guest",
|
|
||||||
"label.team-id": "Team ID",
|
|
||||||
"label.team-member": "Team member",
|
|
||||||
"label.team-owner": "Team owner",
|
|
||||||
"label.teams": "Teams",
|
|
||||||
"label.theme": "主題",
|
|
||||||
"label.this-month": "本月",
|
|
||||||
"label.this-week": "本週",
|
|
||||||
"label.this-year": "今年",
|
|
||||||
"label.timezone": "時區",
|
|
||||||
"label.title": "Title",
|
|
||||||
"label.today": "今天",
|
|
||||||
"label.toggle-charts": "切換圖表",
|
|
||||||
"label.tracking-code": "追蹤代碼",
|
|
||||||
"label.unique-visitors": "獨立訪客",
|
|
||||||
"label.unknown": "未知",
|
|
||||||
"label.url": "URL",
|
|
||||||
"label.urls": "URLs",
|
|
||||||
"label.user": "User",
|
|
||||||
"label.username": "用户名",
|
|
||||||
"label.users": "Users",
|
|
||||||
"label.view": "View",
|
|
||||||
"label.view-details": "查看更多",
|
|
||||||
"label.view-only": "View only",
|
|
||||||
"label.views": "頁面流量",
|
|
||||||
"label.visitors": "獨立訪客",
|
|
||||||
"label.website": "Website",
|
|
||||||
"label.website-id": "Website ID",
|
|
||||||
"label.websites": "網站",
|
|
||||||
"label.window": "Window",
|
|
||||||
"label.yesterday": "Yesterday",
|
|
||||||
"labels.after": "After",
|
|
||||||
"labels.average": "Average",
|
|
||||||
"labels.before": "Before",
|
|
||||||
"labels.breakdown": "Breakdown",
|
|
||||||
"labels.contains": "Contains",
|
|
||||||
"labels.create-report": "Create report",
|
|
||||||
"labels.description": "Description",
|
|
||||||
"labels.does-not-contain": "Does not contain",
|
|
||||||
"labels.does-not-equal": "Does not equal",
|
|
||||||
"labels.equals": "Equals",
|
|
||||||
"labels.false": "False",
|
|
||||||
"labels.filters": "Filters",
|
|
||||||
"labels.greater-than": "Greater than",
|
|
||||||
"labels.greater-than-equals": "Greater than or equals",
|
|
||||||
"labels.less-than": "Less than",
|
|
||||||
"labels.less-than-equals": "Less than or equals",
|
|
||||||
"labels.max": "Max",
|
|
||||||
"labels.min": "Min",
|
|
||||||
"labels.overview": "Overview",
|
|
||||||
"labels.sum": "Sum",
|
|
||||||
"labels.total": "Total",
|
|
||||||
"labels.total-records": "Total records",
|
|
||||||
"labels.true": "True",
|
|
||||||
"labels.type": "Type",
|
|
||||||
"labels.unique": "Unique",
|
|
||||||
"labels.untitled": "Untitled",
|
|
||||||
"labels.value": "Value",
|
|
||||||
"message.active-users": "當前線上 {x} 人",
|
|
||||||
"message.confirm-delete": "你確定要刪除 {target} 嗎?",
|
|
||||||
"message.confirm-leave": "Are you sure you want to leave {target}?",
|
|
||||||
"message.confirm-reset": "您確定要重置 {target} 的數據嗎?",
|
|
||||||
"message.delete-account": "To delete this account, type {confirmation} in the box below to confirm.",
|
|
||||||
"message.delete-website": "To delete this website, type {confirmation} in the box below to confirm.",
|
|
||||||
"message.delete-website-warning": "所有相關數據將會被刪除。",
|
|
||||||
"message.error": "出現錯誤。",
|
|
||||||
"message.event-log": "{event} on {url}",
|
|
||||||
"message.go-to-settings": "去設定",
|
|
||||||
"message.incorrect-username-password": "用户名或密碼不正確。",
|
|
||||||
"message.invalid-domain": "無效域名",
|
|
||||||
"message.min-password-length": "Minimum length of {n} characters",
|
|
||||||
"message.no-data-available": "無可用數據。",
|
|
||||||
"message.no-event-data": "No event data is available.",
|
|
||||||
"message.no-match-password": "密碼不一致",
|
|
||||||
"message.no-teams": "You have not created any teams.",
|
|
||||||
"message.no-users": "There are no users.",
|
|
||||||
"message.page-not-found": "網頁未找到。",
|
|
||||||
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
|
|
||||||
"message.reset-website-warning": "本網站的所有統計數據將被刪除,但您的跟蹤代碼將保持不變。",
|
|
||||||
"message.saved": "成功保存。",
|
|
||||||
"message.share-url": "這是 {target} 的分享連結。",
|
|
||||||
"message.team-already-member": "You are already a member of the team.",
|
|
||||||
"message.team-not-found": "Team not found.",
|
|
||||||
"message.tracking-code": "追蹤代碼",
|
|
||||||
"message.user-deleted": "User deleted.",
|
|
||||||
"message.visitor-log": "來自{country}的訪客在搭載 {os} 的{device}上使用 {browser} 進行訪問。",
|
|
||||||
"message.no-results-found": "No results were found.",
|
|
||||||
"message.no-team-websites": "This team does not have any websites.",
|
|
||||||
"message.no-websites-configured": "目前無任何網站設定。",
|
|
||||||
"message.team-websites-info": "Websites can be viewed by anyone on the team.",
|
|
||||||
"message.new-version-available": "A new version of Umami {version} is available!"
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import { ClickHouse } from 'clickhouse';
|
|
||||||
import dateFormat from 'dateformat';
|
|
||||||
import debug from 'debug';
|
|
||||||
import { CLICKHOUSE } from 'lib/db';
|
|
||||||
import { WebsiteMetricFilter } from './types';
|
|
||||||
import { FILTER_COLUMNS } from './constants';
|
|
||||||
|
|
||||||
export const CLICKHOUSE_DATE_FORMATS = {
|
|
||||||
minute: '%Y-%m-%d %H:%M:00',
|
|
||||||
hour: '%Y-%m-%d %H:00:00',
|
|
||||||
day: '%Y-%m-%d',
|
|
||||||
month: '%Y-%m-01',
|
|
||||||
year: '%Y-01-01',
|
|
||||||
};
|
|
||||||
|
|
||||||
const log = debug('umami:clickhouse');
|
|
||||||
|
|
||||||
let clickhouse: ClickHouse;
|
|
||||||
const enabled = Boolean(process.env.CLICKHOUSE_URL);
|
|
||||||
|
|
||||||
function getClient() {
|
|
||||||
const {
|
|
||||||
hostname,
|
|
||||||
port,
|
|
||||||
pathname,
|
|
||||||
username = 'default',
|
|
||||||
password,
|
|
||||||
} = new URL(process.env.CLICKHOUSE_URL);
|
|
||||||
|
|
||||||
const client = new ClickHouse({
|
|
||||||
url: hostname,
|
|
||||||
port: Number(port),
|
|
||||||
format: 'json',
|
|
||||||
config: {
|
|
||||||
database: pathname.replace('/', ''),
|
|
||||||
},
|
|
||||||
basicAuth: password ? { username, password } : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
global[CLICKHOUSE] = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Clickhouse initialized');
|
|
||||||
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateStringQuery(data, unit) {
|
|
||||||
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateQuery(field, unit, timezone?) {
|
|
||||||
if (timezone) {
|
|
||||||
return `date_trunc('${unit}', ${field}, '${timezone}')`;
|
|
||||||
}
|
|
||||||
return `date_trunc('${unit}', ${field})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateFormat(date) {
|
|
||||||
return `'${dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss')}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilterQuery(filters = {}, params = {}) {
|
|
||||||
const query = Object.keys(filters).reduce((arr, key) => {
|
|
||||||
const filter = filters[key];
|
|
||||||
|
|
||||||
if (filter !== undefined) {
|
|
||||||
const column = FILTER_COLUMNS[key] || key;
|
|
||||||
arr.push(`and ${column} = {${key}:String}`);
|
|
||||||
params[key] = decodeURIComponent(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return arr;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return query.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFilters(filters: WebsiteMetricFilter = {}, params: any = {}) {
|
|
||||||
return {
|
|
||||||
filterQuery: getFilterQuery(filters, params),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rawQuery<T>(query: string, params: object = {}): Promise<T> {
|
|
||||||
if (process.env.LOG_QUERY) {
|
|
||||||
log('QUERY:\n', query);
|
|
||||||
log('PARAMETERS:\n', params);
|
|
||||||
}
|
|
||||||
|
|
||||||
await connect();
|
|
||||||
|
|
||||||
return clickhouse.query(query, { params }).toPromise() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findUnique(data) {
|
|
||||||
if (data.length > 1) {
|
|
||||||
throw `${data.length} records found when expecting 1.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return findFirst(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findFirst(data) {
|
|
||||||
return data[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connect() {
|
|
||||||
if (enabled && !clickhouse) {
|
|
||||||
clickhouse = process.env.CLICKHOUSE_URL && (global[CLICKHOUSE] || getClient());
|
|
||||||
}
|
|
||||||
|
|
||||||
return clickhouse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
enabled,
|
|
||||||
client: clickhouse,
|
|
||||||
log,
|
|
||||||
connect,
|
|
||||||
getDateStringQuery,
|
|
||||||
getDateQuery,
|
|
||||||
getDateFormat,
|
|
||||||
getFilterQuery,
|
|
||||||
parseFilters,
|
|
||||||
findUnique,
|
|
||||||
findFirst,
|
|
||||||
rawQuery,
|
|
||||||
};
|
|
||||||
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