Merge branch 'dev' into os-names

This commit is contained in:
Mike Cao 2024-02-14 22:15:08 -08:00 committed by GitHub
commit 37e28bbf74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
472 changed files with 5460 additions and 5026 deletions

View File

@ -22,26 +22,15 @@
"plugins": ["@typescript-eslint", "prettier"], "plugins": ["@typescript-eslint", "prettier"],
"settings": { "settings": {
"import/resolver": { "import/resolver": {
"alias": { "node": {
"map": [ "moduleDirectory": ["node_modules", "src/"]
["assets", "./src/assets"],
["components", "./src/components"],
["db", "./db"],
["hooks", "./src/components/hooks"],
["lang", "./src/lang"],
["lib", "./src/lib"],
["public", "./public"],
["queries", "./src/queries"],
["store", "./src/store"],
["styles", "./src/styles"]
],
"extensions": [".ts", ".tsx", ".js", ".jsx", ".json"]
} }
} }
}, },
"rules": { "rules": {
"no-console": "error", "no-console": "error",
"react/display-name": "off", "react/display-name": "off",
"react-hooks/exhaustive-deps": "off",
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/prop-types": "off", "react/prop-types": "off",
"import/no-anonymous-default-export": "off", "import/no-anonymous-default-export": "off",

View File

@ -1,4 +1,4 @@
name: "🐛 Bug Report" name: '🐛 Bug Report'
description: Create a bug report for Umami. description: Create a bug report for Umami.
body: body:
- type: textarea - type: textarea
@ -22,6 +22,10 @@ body:
label: Relevant log output label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell render: shell
- type: input
attributes:
label: Which Umami version are you using? (if relevant)
description: 'For example: Chrome, Edge, Firefox, etc'
- type: input - type: input
attributes: attributes:
label: Which browser are you using? (if relevant) label: Which browser are you using? (if relevant)
@ -29,4 +33,4 @@ body:
- type: input - type: input
attributes: attributes:
label: How are you deploying your application? (if relevant) label: How are you deploying your application? (if relevant)
description: 'For example: Vercel, Railway, Docker, etc' description: 'For example: Vercel, Railway, Docker, etc'

View File

@ -16,15 +16,15 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
- node-version: 18.x - node-version: 18.17
db-type: postgresql db-type: postgresql
- node-version: 18.x - node-version: 18.17
db-type: mysql db-type: mysql
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'npm' cache: 'npm'

View File

@ -49,7 +49,7 @@ mysql://username:mypassword@localhost:3306/mydb
yarn build yarn build
``` ```
The build step will also create tables in your database if you ae installing for the first time. It will also create a login user with username **admin** and password **umami**. The build step will also create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**.
### Start the application ### Start the application

View File

@ -1,5 +1,3 @@
SET allow_experimental_object_type = 1;
-- Create Event -- Create Event
CREATE TABLE umami.website_event CREATE TABLE umami.website_event
( (
@ -34,82 +32,6 @@ CREATE TABLE umami.website_event
ORDER BY (website_id, session_id, created_at) ORDER BY (website_id, session_id, created_at)
SETTINGS index_granularity = 8192; SETTINGS index_granularity = 8192;
CREATE TABLE umami.website_event_queue (
website_id UUID,
session_id UUID,
event_id UUID,
--sessions
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String),
subdivision1 LowCardinality(String),
subdivision2 LowCardinality(String),
city String,
--pageviews
url_path String,
url_query String,
referrer_path String,
referrer_query String,
referrer_domain String,
page_title String,
--events
event_type UInt32,
event_name String,
created_at DateTime('UTC'),
--virtual columns
_error String,
_raw_message String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input broker list
kafka_topic_list = 'event',
kafka_group_name = 'event_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_handle_error_mode = 'stream';
CREATE MATERIALIZED VIEW umami.website_event_queue_mv TO umami.website_event AS
SELECT website_id,
session_id,
event_id,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
subdivision2,
city,
url_path,
url_query,
referrer_path,
referrer_query,
referrer_domain,
page_title,
event_type,
event_name,
created_at
FROM umami.website_event_queue;
CREATE MATERIALIZED VIEW umami.website_event_errors_mv
(
error String,
raw String
)
ENGINE = MergeTree
ORDER BY (error, raw)
SETTINGS index_granularity = 8192 AS
SELECT _error AS error,
_raw_message AS raw
FROM umami.website_event_queue
WHERE length(_error) > 0;
CREATE TABLE umami.event_data CREATE TABLE umami.event_data
( (
website_id UUID, website_id UUID,
@ -127,55 +49,4 @@ CREATE TABLE umami.event_data
) )
engine = MergeTree engine = MergeTree
ORDER BY (website_id, event_id, event_key, created_at) ORDER BY (website_id, event_id, event_key, created_at)
SETTINGS index_granularity = 8192; SETTINGS index_granularity = 8192;
CREATE TABLE umami.event_data_queue (
website_id UUID,
session_id UUID,
event_id UUID,
url_path String,
event_name String,
event_key String,
string_value Nullable(String),
number_value Nullable(Decimal64(4)), --922337203685477.5625
date_value Nullable(DateTime('UTC')),
data_type UInt32,
created_at DateTime('UTC'),
--virtual columns
_error String,
_raw_message String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input broker list
kafka_topic_list = 'event_data',
kafka_group_name = 'event_data_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_handle_error_mode = 'stream';
CREATE MATERIALIZED VIEW umami.event_data_queue_mv TO umami.event_data AS
SELECT website_id,
session_id,
event_id,
url_path,
event_name,
event_key,
string_value,
number_value,
date_value,
data_type,
created_at
FROM umami.event_data_queue;
CREATE MATERIALIZED VIEW umami.event_data_errors_mv
(
error String,
raw String
)
ENGINE = MergeTree
ORDER BY (error, raw)
SETTINGS index_granularity = 8192 AS
SELECT _error AS error,
_raw_message AS raw
FROM umami.event_data_queue
WHERE length(_error) > 0;

View File

@ -0,0 +1,29 @@
/*
Warnings:
- You are about to drop the `team_website` table. If the table is not empty, all the data it contains will be lost.
*/
-- AlterTable
ALTER TABLE `team` ADD COLUMN `deleted_at` TIMESTAMP(0) NULL,
ADD COLUMN `logo_url` VARCHAR(2183) NULL;
-- AlterTable
ALTER TABLE `user` ADD COLUMN `display_name` VARCHAR(255) NULL,
ADD COLUMN `logo_url` VARCHAR(2183) NULL;
-- AlterTable
ALTER TABLE `website` ADD COLUMN `created_by` VARCHAR(36) NULL,
ADD COLUMN `team_id` VARCHAR(36) NULL;
-- MigrateData
UPDATE "website" SET created_by = user_id WHERE team_id IS NULL;
-- DropTable
DROP TABLE `team_website`;
-- CreateIndex
CREATE INDEX `website_team_id_idx` ON `website`(`team_id`);
-- CreateIndex
CREATE INDEX `website_created_by_idx` ON `website`(`created_by`);

View File

@ -9,15 +9,18 @@ datasource db {
} }
model User { model User {
id String @id @unique @map("user_id") @db.VarChar(36) id String @id @unique @map("user_id") @db.VarChar(36)
username String @unique @db.VarChar(255) username String @unique @db.VarChar(255)
password String @db.VarChar(60) password String @db.VarChar(60)
role String @map("role") @db.VarChar(50) role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0) logoUrl String? @map("logo_url") @db.VarChar(2183)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0) displayName String? @map("display_name") @db.VarChar(255)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
website Website[] websiteUser Website[] @relation("user")
websiteCreateUser Website[] @relation("createUser")
teamUser TeamUser[] teamUser TeamUser[]
report Report[] report Report[]
@ -64,19 +67,24 @@ model Website {
shareId String? @unique @map("share_id") @db.VarChar(50) shareId String? @unique @map("share_id") @db.VarChar(50)
resetAt DateTime? @map("reset_at") @db.Timestamp(0) resetAt DateTime? @map("reset_at") @db.Timestamp(0)
userId String? @map("user_id") @db.VarChar(36) userId String? @map("user_id") @db.VarChar(36)
teamId String? @map("team_id") @db.VarChar(36)
createdBy String? @map("created_by") @db.VarChar(36)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0) deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
user User? @relation(fields: [userId], references: [id]) user User? @relation("user", fields: [userId], references: [id])
teamWebsite TeamWebsite[] createUser User? @relation("createUser", fields: [createdBy], references: [id])
team Team? @relation(fields: [teamId], references: [id])
eventData EventData[] eventData EventData[]
report Report[] report Report[]
sessionData SessionData[] sessionData SessionData[]
@@index([userId]) @@index([userId])
@@index([teamId])
@@index([createdAt]) @@index([createdAt])
@@index([shareId]) @@index([shareId])
@@index([createdBy])
@@map("website") @@map("website")
} }
@ -157,11 +165,13 @@ model Team {
id String @id() @unique() @map("team_id") @db.VarChar(36) id String @id() @unique() @map("team_id") @db.VarChar(36)
name String @db.VarChar(50) name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50) accessCode String? @unique @map("access_code") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
website Website[]
teamUser TeamUser[] teamUser TeamUser[]
teamWebsite TeamWebsite[]
@@index([accessCode]) @@index([accessCode])
@@map("team") @@map("team")
@ -183,20 +193,6 @@ model TeamUser {
@@map("team_user") @@map("team_user")
} }
model TeamWebsite {
id String @id() @unique() @map("team_website_id") @db.VarChar(36)
teamId String @map("team_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
team Team @relation(fields: [teamId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([teamId])
@@index([websiteId])
@@map("team_website")
}
model Report { model Report {
id String @id() @unique() @map("report_id") @db.VarChar(36) id String @id() @unique() @map("report_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36) userId String @map("user_id") @db.VarChar(36)

View File

@ -0,0 +1,29 @@
/*
Warnings:
- You are about to drop the `team_website` table. If the table is not empty, all the data it contains will be lost.
*/
-- AlterTable
ALTER TABLE "team" ADD COLUMN "deleted_at" TIMESTAMPTZ(6),
ADD COLUMN "logo_url" VARCHAR(2183);
-- AlterTable
ALTER TABLE "user" ADD COLUMN "display_name" VARCHAR(255),
ADD COLUMN "logo_url" VARCHAR(2183);
-- AlterTable
ALTER TABLE "website" ADD COLUMN "created_by" UUID,
ADD COLUMN "team_id" UUID;
-- MigrateData
UPDATE "website" SET created_by = user_id WHERE team_id IS NULL;
-- DropTable
DROP TABLE "team_website";
-- CreateIndex
CREATE INDEX "website_team_id_idx" ON "website"("team_id");
-- CreateIndex
CREATE INDEX "website_created_by_idx" ON "website"("created_by");

View File

@ -9,17 +9,20 @@ datasource db {
} }
model User { model User {
id String @id @unique @map("user_id") @db.Uuid id String @id @unique @map("user_id") @db.Uuid
username String @unique @db.VarChar(255) username String @unique @db.VarChar(255)
password String @db.VarChar(60) password String @db.VarChar(60)
role String @map("role") @db.VarChar(50) role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) logoUrl String? @map("logo_url") @db.VarChar(2183)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) displayName String? @map("display_name") @db.VarChar(255)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
website Website[] websiteUser Website[] @relation("user")
teamUser TeamUser[] websiteCreateUser Website[] @relation("createUser")
report Report[] teamUser TeamUser[]
report Report[]
@@map("user") @@map("user")
} }
@ -64,19 +67,24 @@ model Website {
shareId String? @unique @map("share_id") @db.VarChar(50) shareId String? @unique @map("share_id") @db.VarChar(50)
resetAt DateTime? @map("reset_at") @db.Timestamptz(6) resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
userId String? @map("user_id") @db.Uuid userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
createdBy String? @map("created_by") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
user User? @relation(fields: [userId], references: [id]) user User? @relation("user", fields: [userId], references: [id])
teamWebsite TeamWebsite[] createUser User? @relation("createUser", fields: [createdBy], references: [id])
team Team? @relation(fields: [teamId], references: [id])
eventData EventData[] eventData EventData[]
report Report[] report Report[]
sessionData SessionData[] sessionData SessionData[]
@@index([userId]) @@index([userId])
@@index([teamId])
@@index([createdAt]) @@index([createdAt])
@@index([shareId]) @@index([shareId])
@@index([createdBy])
@@map("website") @@map("website")
} }
@ -157,11 +165,13 @@ model Team {
id String @id() @unique() @map("team_id") @db.Uuid id String @id() @unique() @map("team_id") @db.Uuid
name String @db.VarChar(50) name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50) accessCode String? @unique @map("access_code") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
teamUser TeamUser[] website Website[]
teamWebsite TeamWebsite[] teamUser TeamUser[]
@@index([accessCode]) @@index([accessCode])
@@map("team") @@map("team")
@ -183,20 +193,6 @@ model TeamUser {
@@map("team_user") @@map("team_user")
} }
model TeamWebsite {
id String @id() @unique() @map("team_website_id") @db.Uuid
teamId String @map("team_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
team Team @relation(fields: [teamId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([teamId])
@@index([websiteId])
@@map("team_website")
}
model Report { model Report {
id String @id() @unique() @map("report_id") @db.Uuid id String @id() @unique() @map("report_id") @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid

View File

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": "./src" "baseUrl": "src"
} },
"include": ["src"]
} }

View File

@ -36,6 +36,7 @@
"metrics.referrers", "metrics.referrers",
"message.powered-by" "message.powered-by"
], ],
"mn-MN": ["label.max", "label.min", "label.os", "label.query", "label.url", "label.urls"],
"nb-NO": ["label.administrator", "label.dashboard"], "nb-NO": ["label.administrator", "label.dashboard"],
"nl-NL": [ "nl-NL": [
"label.analytics", "label.analytics",

View File

@ -3,13 +3,25 @@ require('dotenv').config();
const path = require('path'); const path = require('path');
const pkg = require('./package.json'); const pkg = require('./package.json');
const basePath = process.env.BASE_PATH || '';
const forceSSL = process.env.FORCE_SSL || '';
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || '';
const defaultLocale = process.env.DEFAULT_LOCALE || '';
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME || '';
const cloudMode = process.env.CLOUD_MODE || '';
const cloudUrl = process.env.CLOUD_URL || '';
const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
const disableLogin = process.env.DISABLE_LOGIN || '';
const disableUI = process.env.DISABLE_UI || '';
const hostURL = process.env.HOST_URL || '';
const contentSecurityPolicy = [ const contentSecurityPolicy = [
`default-src 'self'`, `default-src 'self'`,
`img-src *`, `img-src *`,
`script-src 'self' 'unsafe-eval' 'unsafe-inline'`, `script-src 'self' 'unsafe-eval' 'unsafe-inline'`,
`style-src 'self' 'unsafe-inline'`, `style-src 'self' 'unsafe-inline'`,
`connect-src 'self' api.umami.is`, `connect-src 'self' api.umami.is cloud.umami.is`,
`frame-ancestors 'self' ${process.env.ALLOWED_FRAME_URLS || ''}`, `frame-ancestors 'self' ${frameAncestors}`,
]; ];
const headers = [ const headers = [
@ -26,7 +38,7 @@ const headers = [
}, },
]; ];
if (process.env.FORCE_SSL) { if (forceSSL) {
headers.push({ headers.push({
key: 'Strict-Transport-Security', key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload', value: 'max-age=63072000; includeSubDomains; preload',
@ -35,15 +47,15 @@ if (process.env.FORCE_SSL) {
const rewrites = []; const rewrites = [];
if (process.env.COLLECT_API_ENDPOINT) { if (collectApiEndpoint) {
rewrites.push({ rewrites.push({
source: process.env.COLLECT_API_ENDPOINT, source: collectApiEndpoint,
destination: '/api/send', destination: '/api/send',
}); });
} }
if (process.env.TRACKER_SCRIPT_NAME) { if (trackerScriptName) {
const names = process.env.TRACKER_SCRIPT_NAME?.split(',').map(name => name.trim()); const names = trackerScriptName?.split(',').map(name => name.trim());
if (names) { if (names) {
names.forEach(name => { names.forEach(name => {
@ -58,36 +70,56 @@ if (process.env.TRACKER_SCRIPT_NAME) {
const redirects = [ const redirects = [
{ {
source: '/settings', source: '/settings',
destination: process.env.CLOUD_MODE destination: '/settings/websites',
? `${process.env.CLOUD_URL}/settings/websites` permanent: true,
: '/settings/websites', },
{
source: '/teams/:id',
destination: '/teams/:id/dashboard',
permanent: true,
},
{
source: '/teams/:id/settings',
destination: '/teams/:id/settings/team',
permanent: true, permanent: true,
}, },
]; ];
if (process.env.CLOUD_MODE && process.env.CLOUD_URL && process.env.DISABLE_LOGIN) { if (cloudMode && cloudUrl) {
redirects.push({ redirects.push({
source: '/login', source: '/settings/:path*',
destination: process.env.CLOUD_URL, destination: `${cloudUrl}/settings/:path*`,
permanent: false, permanent: false,
}); });
}
const basePath = process.env.BASE_PATH; redirects.push({
source: '/teams/:id/settings/:path*',
destination: `${cloudUrl}/teams/:id/settings/:path*`,
permanent: false,
});
if (disableLogin) {
redirects.push({
source: '/login',
destination: cloudUrl,
permanent: false,
});
}
}
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
reactStrictMode: false, reactStrictMode: false,
env: { env: {
basePath: basePath || '', basePath,
cloudMode: process.env.CLOUD_MODE || '', cloudMode,
cloudUrl: process.env.CLOUD_URL || '', cloudUrl,
configUrl: '/config', configUrl: '/config',
currentVersion: pkg.version, currentVersion: pkg.version,
defaultLocale: process.env.DEFAULT_LOCALE || '', defaultLocale,
disableLogin: process.env.DISABLE_LOGIN || '', disableLogin,
disableUI: process.env.DISABLE_UI || '', disableUI,
hostUrl: process.env.HOST_URL || '', hostURL,
}, },
basePath, basePath,
output: 'standalone', output: 'standalone',

View File

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "2.9.0", "version": "2.10.0",
"description": "A simple, fast, privacy-focused alternative to Google Analytics.", "description": "A simple, fast, privacy-focused alternative to Google Analytics.",
"author": "Mike Cao <mike@mikecao.com>", "author": "Mike Cao <mike@mikecao.com>",
"license": "MIT", "license": "MIT",
@ -63,11 +63,11 @@
"dependencies": { "dependencies": {
"@clickhouse/client": "^0.2.2", "@clickhouse/client": "^0.2.2",
"@fontsource/inter": "^4.5.15", "@fontsource/inter": "^4.5.15",
"@prisma/client": "5.6.0", "@prisma/client": "5.7.0",
"@prisma/extension-read-replicas": "^0.3.0", "@prisma/extension-read-replicas": "^0.3.0",
"@react-spring/web": "^9.7.3", "@react-spring/web": "^9.7.3",
"@tanstack/react-query": "^5.12.2", "@tanstack/react-query": "^5.12.2",
"@umami/prisma-client": "^0.8.0", "@umami/prisma-client": "^0.14.0",
"@umami/redis-client": "^0.18.0", "@umami/redis-client": "^0.18.0",
"chalk": "^4.1.1", "chalk": "^4.1.1",
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
@ -92,14 +92,15 @@
"isbot": "^3.4.5", "isbot": "^3.4.5",
"kafkajs": "^2.1.0", "kafkajs": "^2.1.0",
"maxmind": "^4.3.6", "maxmind": "^4.3.6",
"md5": "^2.3.0",
"moment-timezone": "^0.5.35", "moment-timezone": "^0.5.35",
"next": "14.0.4", "next": "14.0.4",
"next-basics": "^0.39.0", "next-basics": "^0.39.0",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prisma": "5.6.0", "prisma": "5.7.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-basics": "^0.114.0", "react-basics": "^0.122.0",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^4.0.4", "react-error-boundary": "^4.0.4",
@ -117,9 +118,8 @@
}, },
"devDependencies": { "devDependencies": {
"@formatjs/cli": "^4.2.29", "@formatjs/cli": "^4.2.29",
"@netlify/plugin-nextjs": "^4.27.3", "@netlify/plugin-nextjs": "^4.41.3",
"@rollup/plugin-alias": "^5.0.0", "@rollup/plugin-alias": "^5.0.0",
"@rollup/plugin-buble": "^1.0.2",
"@rollup/plugin-commonjs": "^25.0.4", "@rollup/plugin-commonjs": "^25.0.4",
"@rollup/plugin-json": "^6.0.0", "@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-node-resolve": "^15.2.0", "@rollup/plugin-node-resolve": "^15.2.0",
@ -135,7 +135,7 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"esbuild": "^0.17.17", "esbuild": "^0.17.17",
"eslint": "^8.33.0", "eslint": "^8.33.0",
"eslint-config-next": "^12.2.4", "eslint-config-next": "^14.0.4",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",

View File

@ -1382,7 +1382,7 @@
"message.share-url": [ "message.share-url": [
{ {
"type": 0, "type": 0,
"value": "Your website stats are publically available at the following URL:" "value": "Your website stats are publicly available at the following URL:"
} }
], ],
"message.team-already-member": [ "message.team-already-member": [

View File

@ -1,5 +1,4 @@
import 'dotenv/config'; import 'dotenv/config';
import buble from '@rollup/plugin-buble';
import replace from '@rollup/plugin-replace'; import replace from '@rollup/plugin-replace';
import { terser } from 'rollup-plugin-terser'; import { terser } from 'rollup-plugin-terser';
@ -15,7 +14,6 @@ export default {
delimiters: ['', ''], delimiters: ['', ''],
preventAssignment: true, preventAssignment: true,
}), }),
buble({ objectAssign: true }),
terser({ compress: { evaluate: false } }), terser({ compress: { evaluate: false } }),
], ],
}; };

View File

@ -27,7 +27,7 @@ export function App({ children }) {
{children} {children}
<UpdateNotice user={user} config={config} /> <UpdateNotice user={user} config={config} />
{process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && ( {process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
<Script src={`telemetry.js`} /> <Script src={`/telemetry.js`} />
)} )}
</> </>
); );

View File

@ -26,6 +26,7 @@
padding: 0 40px; padding: 0 40px;
font-weight: 700; font-weight: 700;
max-height: 60px; max-height: 60px;
align-items: center;
} }
.links a, .links a,

View File

@ -2,35 +2,38 @@
import { Icon, Text } from 'react-basics'; import { Icon, Text } from 'react-basics';
import Link from 'next/link'; import Link from 'next/link';
import classNames from 'classnames'; import classNames from 'classnames';
import Icons from 'components/icons'; import HamburgerButton from 'components/common/HamburgerButton';
import ThemeButton from 'components/input/ThemeButton'; import ThemeButton from 'components/input/ThemeButton';
import LanguageButton from 'components/input/LanguageButton'; import LanguageButton from 'components/input/LanguageButton';
import ProfileButton from 'components/input/ProfileButton'; import ProfileButton from 'components/input/ProfileButton';
import useMessages from 'components/hooks/useMessages'; import TeamsButton from 'components/input/TeamsButton';
import HamburgerButton from 'components/common/HamburgerButton'; import Icons from 'components/icons';
import { usePathname } from 'next/navigation'; import { useLogin, useMessages, useNavigation, useTeamUrl } from 'components/hooks';
import styles from './NavBar.module.css'; import styles from './NavBar.module.css';
export function NavBar() { export function NavBar() {
const pathname = usePathname(); const { user } = useLogin();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const cloudMode = Boolean(process.env.cloudMode); const { pathname } = useNavigation();
const { teamId, renderTeamUrl } = useTeamUrl();
const cloudMode = !!process.env.cloudMode;
const links = [ const links = [
{ label: formatMessage(labels.dashboard), url: '/dashboard' }, { label: formatMessage(labels.dashboard), url: renderTeamUrl('/dashboard') },
{ label: formatMessage(labels.websites), url: '/websites' }, { label: formatMessage(labels.websites), url: renderTeamUrl('/websites') },
{ label: formatMessage(labels.reports), url: '/reports' }, { label: formatMessage(labels.reports), url: renderTeamUrl('/reports') },
{ label: formatMessage(labels.settings), url: '/settings' }, { label: formatMessage(labels.settings), url: renderTeamUrl('/settings') },
].filter(n => n); ].filter(n => n);
const menuItems = [ const menuItems = [
{ {
label: formatMessage(labels.dashboard), label: formatMessage(labels.dashboard),
url: '/dashboard', url: renderTeamUrl('/dashboard'),
}, },
!cloudMode && { !cloudMode && {
label: formatMessage(labels.settings), label: formatMessage(labels.settings),
url: '/settings', url: renderTeamUrl('/settings'),
children: [ children: [
{ {
label: formatMessage(labels.websites), label: formatMessage(labels.websites),
@ -46,13 +49,13 @@ export function NavBar() {
}, },
{ {
label: formatMessage(labels.profile), label: formatMessage(labels.profile),
url: '/settings/profile', url: '/profile',
}, },
], ],
}, },
cloudMode && { cloudMode && {
label: formatMessage(labels.profile), label: formatMessage(labels.profile),
url: '/settings/profile', url: '/profile',
}, },
!cloudMode && { label: formatMessage(labels.logout), url: '/logout' }, !cloudMode && { label: formatMessage(labels.logout), url: '/logout' },
].filter(n => n); ].filter(n => n);
@ -72,6 +75,7 @@ export function NavBar() {
key={url} key={url}
href={url} href={url}
className={classNames({ [styles.selected]: pathname.startsWith(url) })} className={classNames({ [styles.selected]: pathname.startsWith(url) })}
prefetch={url !== '/settings'}
> >
<Text>{label}</Text> <Text>{label}</Text>
</Link> </Link>
@ -79,6 +83,7 @@ export function NavBar() {
})} })}
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
{user?.teams?.length && <TeamsButton teamId={teamId} />}
<ThemeButton /> <ThemeButton />
<LanguageButton /> <LanguageButton />
<ProfileButton /> <ProfileButton />

View File

@ -6,7 +6,7 @@ import { setItem } from 'next-basics';
import useStore, { checkVersion } from 'store/version'; import useStore, { checkVersion } from 'store/version';
import { REPO_URL, VERSION_CHECK } from 'lib/constants'; import { REPO_URL, VERSION_CHECK } from 'lib/constants';
import styles from './UpdateNotice.module.css'; import styles from './UpdateNotice.module.css';
import useMessages from 'components/hooks/useMessages'; import { useMessages } from 'components/hooks';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
export function UpdateNotice({ user, config }) { export function UpdateNotice({ user, config }) {

View File

@ -0,0 +1,6 @@
'use client';
import TestConsole from './TestConsole';
export default function ConsolePage({ websiteId }) {
return <TestConsole websiteId={websiteId} />;
}

View File

@ -1,7 +1,17 @@
.test { .container {
display: grid;
gap: 30px;
padding-bottom: 40px;
}
.actions {
border: 1px solid var(--base400); border: 1px solid var(--base400);
border-radius: 5px; border-radius: 5px;
padding: 0 20px 20px 20px; padding: 0 20px 20px 20px;
display: grid;
gap: 40px;
grid-template-columns: repeat(3, minmax(300px, 1fr));
box-shadow: 0 0 0 10px var(--base100);
} }
.header { .header {
@ -9,3 +19,15 @@
font-weight: 700; font-weight: 700;
margin: 20px 0; margin: 20px 0;
} }
.group {
display: flex;
flex-direction: column;
gap: 10px;
}
.wrapped {
border: 1px solid var(--blue900);
border-radius: 4px;
padding: 8px 16px;
}

View File

@ -1,4 +1,3 @@
'use client';
import { Button } from 'react-basics'; import { Button } from 'react-basics';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
@ -7,9 +6,8 @@ import WebsiteSelect from 'components/input/WebsiteSelect';
import Page from 'components/layout/Page'; import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
import EventsChart from 'components/metrics/EventsChart'; import EventsChart from 'components/metrics/EventsChart';
import WebsiteChart from 'app/(main)/websites/[id]/WebsiteChart'; import WebsiteChart from '../websites/[websiteId]/WebsiteChart';
import useApi from 'components/hooks/useApi'; import { useApi, useNavigation } from 'components/hooks';
import useNavigation from 'components/hooks/useNavigation';
import styles from './TestConsole.module.css'; import styles from './TestConsole.module.css';
export function TestConsole({ websiteId }: { websiteId: string }) { export function TestConsole({ websiteId }: { websiteId: string }) {
@ -82,15 +80,15 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
<WebsiteSelect websiteId={website?.id} onSelect={handleChange} /> <WebsiteSelect websiteId={website?.id} onSelect={handleChange} />
</PageHeader> </PageHeader>
{website && ( {website && (
<> <div className={styles.container}>
<Script <Script
async async
data-website-id={websiteId} data-website-id={websiteId}
src={`${process.env.basePath}/script.js`} src={`${process.env.basePath}/script.js`}
data-cache="true" data-cache="true"
/> />
<div className={styles.test}> <div className={styles.actions}>
<div> <div className={styles.group}>
<div className={styles.header}>Page links</div> <div className={styles.header}>Page links</div>
<div> <div>
<Link href={`/console/${websiteId}/page/1/?q=abc`}>page one</Link> <Link href={`/console/${websiteId}/page/1/?q=abc`}>page one</Link>
@ -114,12 +112,11 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
</a> </a>
</div> </div>
</div> </div>
<div> <div className={styles.group}>
<div className={styles.header}>Click events</div> <div className={styles.header}>Click events</div>
<Button id="send-event-button" data-umami-event="button-click" variant="primary"> <Button id="send-event-button" data-umami-event="button-click" variant="primary">
Send event Send event
</Button> </Button>
<p />
<Button <Button
id="send-event-data-button" id="send-event-data-button"
data-umami-event="button-click" data-umami-event="button-click"
@ -129,23 +126,39 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
> >
Send event with data Send event with data
</Button> </Button>
<Button
id="button-with-div-button"
data-umami-event="button-click"
data-umami-event-name="bob"
data-umami-event-id="123"
variant="primary"
>
<div className={styles.wrapped}>Button with div</div>
</Button>
<div data-umami-event="div-click" className={styles.wrapped}>
DIV with attribute
</div>
<div data-umami-event="div-click-one" className={styles.wrapped}>
<div data-umami-event="div-click-two" className={styles.wrapped}>
<div data-umami-event="div-click-three" className={styles.wrapped}>
Nested DIV
</div>
</div>
</div>
</div> </div>
<div> <div className={styles.group}>
<div className={styles.header}>Javascript events</div> <div className={styles.header}>Javascript events</div>
<Button id="manual-button" variant="primary" onClick={handleClick}> <Button id="manual-button" variant="primary" onClick={handleClick}>
Run script Run script
</Button> </Button>
<p />
<Button id="manual-button" variant="primary" onClick={handleIdentifyClick}> <Button id="manual-button" variant="primary" onClick={handleIdentifyClick}>
Run identify Run identify
</Button> </Button>
</div> </div>
</div> </div>
<div> <WebsiteChart websiteId={website.id} />
<WebsiteChart websiteId={website.id} /> <EventsChart websiteId={website.id} />
<EventsChart websiteId={website.id} /> </div>
</div>
</>
)} )}
</Page> </Page>
); );

View File

@ -1,20 +1,20 @@
import TestConsole from '../TestConsole';
import { Metadata } from 'next'; import { Metadata } from 'next';
import ConsolePage from '../ConsolePage';
async function getEnabled() { async function getEnabled() {
return !!process.env.ENABLE_TEST_CONSOLE; return !!process.env.ENABLE_TEST_CONSOLE;
} }
export default async function ({ params: { id } }) { export default async function ({ params: { websiteId } }) {
const enabled = await getEnabled(); const enabled = await getEnabled();
if (!enabled) { if (!enabled) {
return null; return null;
} }
return <TestConsole websiteId={id?.[0]} />; return <ConsolePage websiteId={websiteId?.[0]} />;
} }
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Test Console | umami', title: 'Test Console | Umami',
}; };

View File

@ -1,81 +0,0 @@
'use client';
import { Button, Icon, Icons, Loading, Text } from 'react-basics';
import Link from 'next/link';
import PageHeader from 'components/layout/PageHeader';
import Pager from 'components/common/Pager';
import WebsiteChartList from '../../(main)/websites/[id]/WebsiteChartList';
import DashboardSettingsButton from 'app/(main)/dashboard/DashboardSettingsButton';
import DashboardEdit from 'app/(main)/dashboard/DashboardEdit';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import useApi from 'components/hooks/useApi';
import useDashboard from 'store/dashboard';
import useMessages from 'components/hooks/useMessages';
import useLocale from 'components/hooks/useLocale';
import useFilterQuery from 'components/hooks/useFilterQuery';
import { useUser } from 'components/hooks';
export function Dashboard() {
const { formatMessage, labels, messages } = useMessages();
const { user } = useUser();
const { showCharts, editing } = useDashboard();
const { dir } = useLocale();
const { get } = useApi();
const pageSize = 10;
const { query, params, setParams, result } = useFilterQuery({
queryKey: ['dashboard:websites'],
queryFn: (params: any) => {
return get(`/users/${user.id}/websites`, { ...params, includeTeams: true, pageSize });
},
});
const handlePageChange = (page: number) => {
setParams({ ...params, page });
};
const { data, count } = result || {};
const hasData = !!(data as any)?.length;
const { page } = params;
if (query.isLoading) {
return <Loading />;
}
return (
<>
<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 />}
{!editing && (
<>
<WebsiteChartList websites={data} showCharts={showCharts} limit={pageSize} />
<Pager
page={page}
pageSize={pageSize}
count={count}
onPageChange={handlePageChange}
/>
</>
)}
</>
)}
</>
);
}
export default Dashboard;

View File

@ -1,32 +1,30 @@
'use client';
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames'; import classNames from 'classnames';
import { Button } from 'react-basics'; import { Button, Loading } from 'react-basics';
import { firstBy } from 'thenby'; import { firstBy } from 'thenby';
import useDashboard, { saveDashboard } from 'store/dashboard'; import useDashboard, { saveDashboard } from 'store/dashboard';
import useMessages from 'components/hooks/useMessages'; import { useMessages, useWebsites } from 'components/hooks';
import useApi from 'components/hooks/useApi';
import styles from './DashboardEdit.module.css'; import styles from './DashboardEdit.module.css';
const dragId = 'dashboard-website-ordering'; const DRAG_ID = 'dashboard-website-ordering';
export function DashboardEdit() { export function DashboardEdit({ teamId }: { teamId: string }) {
const settings = useDashboard(); const settings = useDashboard();
const { websiteOrder } = settings; const { websiteOrder } = settings;
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [order, setOrder] = useState(websiteOrder || []); const [order, setOrder] = useState(websiteOrder || []);
const { get, useQuery } = useApi(); const {
const { data: result } = useQuery({ result,
queryKey: ['websites'], query: { isLoading },
queryFn: () => get('/websites', { includeTeams: 1 }), } = useWebsites({ teamId });
});
const { data: websites } = result || {}; const websites = result?.data;
const ordered = useMemo(() => { const ordered = useMemo(() => {
if (websites) { if (websites) {
return websites return websites
.map(website => ({ ...website, order: order.indexOf(website.id) })) .map((website: { id: any }) => ({ ...website, order: order.indexOf(website.id) }))
.sort(firstBy('order')); .sort(firstBy('order'));
} }
return []; return [];
@ -57,6 +55,10 @@ export function DashboardEdit() {
setOrder([]); setOrder([]);
} }
if (isLoading) {
return <Loading />;
}
return ( return (
<> <>
<div className={styles.buttons}> <div className={styles.buttons}>
@ -72,7 +74,7 @@ export function DashboardEdit() {
</div> </div>
<div className={styles.dragActive}> <div className={styles.dragActive}>
<DragDropContext onDragEnd={handleWebsiteDrag}> <DragDropContext onDragEnd={handleWebsiteDrag}>
<Droppable droppableId={dragId}> <Droppable droppableId={DRAG_ID}>
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
{...provided.droppableProps} {...provided.droppableProps}
@ -80,7 +82,7 @@ export function DashboardEdit() {
style={{ marginBottom: snapshot.isDraggingOver ? 260 : null }} style={{ marginBottom: snapshot.isDraggingOver ? 260 : null }}
> >
{ordered.map(({ id, name, domain }, index) => ( {ordered.map(({ id, name, domain }, index) => (
<Draggable key={id} draggableId={`${dragId}-${id}`} index={index}> <Draggable key={id} draggableId={`${DRAG_ID}-${id}`} index={index}>
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
ref={provided.innerRef} ref={provided.innerRef}

View File

@ -0,0 +1,71 @@
'use client';
import { Icon, Icons, Loading, Text } from 'react-basics';
import PageHeader from 'components/layout/PageHeader';
import Pager from 'components/common/Pager';
import WebsiteChartList from '../websites/[websiteId]/WebsiteChartList';
import DashboardSettingsButton from 'app/(main)/dashboard/DashboardSettingsButton';
import DashboardEdit from 'app/(main)/dashboard/DashboardEdit';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import { useMessages, useLocale, useTeamUrl, useWebsites } from 'components/hooks';
import useDashboard from 'store/dashboard';
import LinkButton from 'components/common/LinkButton';
export function DashboardPage() {
const { formatMessage, labels, messages } = useMessages();
const { teamId, renderTeamUrl } = useTeamUrl();
const { showCharts, editing } = useDashboard();
const { dir } = useLocale();
const pageSize = 10;
const { result, query, params, setParams } = useWebsites({ teamId }, { pageSize });
const { page } = params;
const hasData = !!result?.data?.length;
const handlePageChange = (page: number) => {
setParams({ ...params, page });
};
if (query.isLoading) {
return <Loading />;
}
return (
<section style={{ marginBottom: 60 }}>
<PageHeader title={formatMessage(labels.dashboard)}>
{!editing && hasData && <DashboardSettingsButton />}
</PageHeader>
{!hasData && (
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)}>
<LinkButton href={renderTeamUrl('/settings')}>
<Icon rotate={dir === 'rtl' ? 180 : 0}>
<Icons.ArrowRight />
</Icon>
<Text>{formatMessage(messages.goToSettings)}</Text>
</LinkButton>
</EmptyPlaceholder>
)}
{hasData && (
<>
{editing && <DashboardEdit teamId={teamId} />}
{!editing && (
<>
<WebsiteChartList
websites={result?.data as any}
showCharts={showCharts}
limit={pageSize}
/>
<Pager
page={page}
pageSize={pageSize}
count={result?.count}
onPageChange={handlePageChange}
/>
</>
)}
</>
)}
</section>
);
}
export default DashboardPage;

View File

@ -1,7 +1,7 @@
import { TooltipPopup, Icon, Text, Flexbox, Button } from 'react-basics'; import { TooltipPopup, Icon, Text, Flexbox, Button } from 'react-basics';
import Icons from 'components/icons'; import Icons from 'components/icons';
import { saveDashboard } from 'store/dashboard'; import { saveDashboard } from 'store/dashboard';
import useMessages from 'components/hooks/useMessages'; import { useMessages } from 'components/hooks';
export function DashboardSettingsButton() { export function DashboardSettingsButton() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();

View File

@ -1,10 +1,10 @@
import Dashboard from 'app/(main)/dashboard/Dashboard'; import DashboardPage from './DashboardPage';
import { Metadata } from 'next'; import { Metadata } from 'next';
export default function () { export default function () {
return <Dashboard />; return <DashboardPage />;
} }
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Dashboard | umami', title: 'Dashboard | Umami',
}; };

View File

@ -18,5 +18,16 @@
min-height: 0; min-height: 0;
height: calc(100vh - 60px); height: calc(100vh - 60px);
overflow-y: auto; overflow-y: auto;
padding-bottom: 60px; }
.content {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
width: 100%;
max-width: 1320px;
margin: 0 auto;
padding: 0 20px;
min-height: calc(100vh - 60px);
} }

View File

@ -1,15 +1,15 @@
import DateFilter from 'components/input/DateFilter'; import DateFilter from 'components/input/DateFilter';
import { Button, Flexbox } from 'react-basics'; import { Button, Flexbox } from 'react-basics';
import useDateRange from 'components/hooks/useDateRange'; import { useDateRange, useMessages } from 'components/hooks';
import { DEFAULT_DATE_RANGE } from 'lib/constants'; import { DEFAULT_DATE_RANGE } from 'lib/constants';
import useMessages from 'components/hooks/useMessages'; import { DateRange } from 'lib/types';
export function DateRangeSetting() { export function DateRangeSetting() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [dateRange, setDateRange] = useDateRange(); const [dateRange, setDateRange] = useDateRange();
const { value } = dateRange; const { value } = dateRange;
const handleChange = value => setDateRange(value); const handleChange = (value: string | DateRange) => setDateRange(value);
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE); const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
return ( return (

View File

@ -0,0 +1,4 @@
div.menu {
max-height: 300px;
width: 300px;
}

View File

@ -1,17 +1,26 @@
import { useState } from 'react';
import { Button, Dropdown, Item, Flexbox } from 'react-basics'; import { Button, Dropdown, Item, Flexbox } from 'react-basics';
import useLocale from 'components/hooks/useLocale'; import { useLocale, useMessages } from 'components/hooks';
import { DEFAULT_LOCALE } from 'lib/constants'; import { DEFAULT_LOCALE } from 'lib/constants';
import { languages } from 'lib/lang'; import { languages } from 'lib/lang';
import useMessages from 'components/hooks/useMessages'; import styles from './LanguageSetting.module.css';
export function LanguageSetting() { export function LanguageSetting() {
const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { locale, saveLocale } = useLocale(); const { locale, saveLocale } = useLocale();
const options = Object.keys(languages); const options = search
? Object.keys(languages).filter(n => {
return (
n.toLowerCase().includes(search.toLowerCase()) ||
languages[n].label.toLowerCase().includes(search.toLowerCase())
);
})
: Object.keys(languages);
const handleReset = () => saveLocale(DEFAULT_LOCALE); const handleReset = () => saveLocale(DEFAULT_LOCALE);
const renderValue = value => languages[value].label; const renderValue = (value: string | number) => languages[value].label;
return ( return (
<Flexbox gap={10}> <Flexbox gap={10}>
@ -19,8 +28,10 @@ export function LanguageSetting() {
items={options} items={options}
value={locale} value={locale}
renderValue={renderValue} renderValue={renderValue}
onChange={saveLocale} onChange={val => saveLocale(val as string)}
menuProps={{ style: { height: 300, width: 300 } }} allowSearch={true}
onSearch={setSearch}
menuProps={{ className: styles.menu }}
> >
{item => <Item key={item}>{languages[item].label}</Item>} {item => <Item key={item}>{languages[item].label}</Item>}
</Dropdown> </Dropdown>

View File

@ -1,7 +1,7 @@
import { Button, Icon, Text, useToasts, ModalTrigger, Modal } from 'react-basics'; import { Button, Icon, Text, useToasts, ModalTrigger, Modal } from 'react-basics';
import PasswordEditForm from 'app/(main)/settings/profile/PasswordEditForm'; import PasswordEditForm from 'app/(main)/profile/PasswordEditForm';
import Icons from 'components/icons'; import Icons from 'components/icons';
import useMessages from 'components/hooks/useMessages'; import { useMessages } from 'components/hooks';
export function PasswordChangeButton() { export function PasswordChangeButton() {
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels, messages } = useMessages();

View File

@ -1,7 +1,6 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { Form, FormRow, FormInput, FormButtons, PasswordField, Button } from 'react-basics'; import { Form, FormRow, FormInput, FormButtons, PasswordField, Button } from 'react-basics';
import useApi from 'components/hooks/useApi'; import { useApi, useMessages } from 'components/hooks';
import useMessages from 'components/hooks/useMessages';
export function PasswordEditForm({ onSave, onClose }) { export function PasswordEditForm({ onSave, onClose }) {
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels, messages } = useMessages();

View File

@ -1,4 +1,3 @@
'use client';
import PageHeader from 'components/layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';

View File

@ -0,0 +1,9 @@
.container {
margin: 0 auto;
}
@media screen and (max-width: 768px) {
.container {
margin: 0;
}
}

View File

@ -1,16 +1,13 @@
'use client';
import ProfileHeader from './ProfileHeader'; import ProfileHeader from './ProfileHeader';
import ProfileSettings from './ProfileSettings'; import ProfileSettings from './ProfileSettings';
import { Metadata } from 'next'; import styles from './ProfilePage.module.css';
export default function () { export default function () {
return ( return (
<> <div className={styles.container}>
<ProfileHeader /> <ProfileHeader />
<ProfileSettings /> <ProfileSettings />
</> </div>
); );
} }
export const metadata: Metadata = {
title: 'Profile Settings | umami',
};

View File

@ -1,18 +1,16 @@
'use client';
import { Form, FormRow } from 'react-basics'; import { Form, FormRow } from 'react-basics';
import TimezoneSetting from 'app/(main)/settings/profile/TimezoneSetting'; import TimezoneSetting from 'app/(main)/profile/TimezoneSetting';
import DateRangeSetting from 'app/(main)/settings/profile/DateRangeSetting'; import DateRangeSetting from 'app/(main)/profile/DateRangeSetting';
import LanguageSetting from 'app/(main)/settings/profile/LanguageSetting'; import LanguageSetting from 'app/(main)/profile/LanguageSetting';
import ThemeSetting from 'app/(main)/settings/profile/ThemeSetting'; import ThemeSetting from 'app/(main)/profile/ThemeSetting';
import PasswordChangeButton from './PasswordChangeButton'; import PasswordChangeButton from './PasswordChangeButton';
import useUser from 'components/hooks/useUser'; import { useLogin, useMessages } from 'components/hooks';
import useMessages from 'components/hooks/useMessages';
import { ROLES } from 'lib/constants'; import { ROLES } from 'lib/constants';
export function ProfileSettings() { export function ProfileSettings() {
const { user } = useUser(); const { user } = useLogin();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const cloudMode = Boolean(process.env.cloudMode); const cloudMode = !!process.env.cloudMode;
if (!user) { if (!user) {
return null; return null;
@ -20,12 +18,12 @@ export function ProfileSettings() {
const { username, role } = user; const { username, role } = user;
const renderRole = value => { const renderRole = (value: string) => {
if (value === ROLES.user) { if (value === ROLES.user) {
return formatMessage(labels.user); return formatMessage(labels.user);
} }
if (value === ROLES.admin) { if (value === ROLES.admin) {
return formatMessage(labels.admin); return formatMessage(labels.administrator);
} }
if (value === ROLES.viewOnly) { if (value === ROLES.viewOnly) {
return formatMessage(labels.viewOnly); return formatMessage(labels.viewOnly);

View File

@ -1,6 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { Button, Icon } from 'react-basics'; import { Button, Icon } from 'react-basics';
import useTheme from 'components/hooks/useTheme'; import { useTheme } from 'components/hooks';
import Sun from 'assets/sun.svg'; import Sun from 'assets/sun.svg';
import Moon from 'assets/moon.svg'; import Moon from 'assets/moon.svg';
import styles from './ThemeSetting.module.css'; import styles from './ThemeSetting.module.css';

View File

@ -0,0 +1,4 @@
div.menu {
max-height: 300px;
width: 300px;
}

View File

@ -1,13 +1,17 @@
import { useState } from 'react';
import { Dropdown, Item, Button, Flexbox } from 'react-basics'; import { Dropdown, Item, Button, Flexbox } from 'react-basics';
import { listTimeZones } from 'timezone-support'; import { listTimeZones } from 'timezone-support';
import useTimezone from 'components/hooks/useTimezone'; import { useTimezone, useMessages } from 'components/hooks';
import useMessages from 'components/hooks/useMessages';
import { getTimezone } from 'lib/date'; import { getTimezone } from 'lib/date';
import styles from './TimezoneSetting.module.css';
export function TimezoneSetting() { export function TimezoneSetting() {
const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [timezone, saveTimezone] = useTimezone(); const [timezone, saveTimezone] = useTimezone();
const options = listTimeZones(); const options = search
? listTimeZones().filter(n => n.toLowerCase().includes(search.toLowerCase()))
: listTimeZones();
const handleReset = () => saveTimezone(getTimezone()); const handleReset = () => saveTimezone(getTimezone());
@ -17,8 +21,9 @@ export function TimezoneSetting() {
items={options} items={options}
value={timezone} value={timezone}
onChange={saveTimezone} onChange={saveTimezone}
style={{ flex: 1 }} menuProps={{ className: styles.menu }}
menuProps={{ style: { height: 300 } }} allowSearch={true}
onSearch={setSearch}
> >
{item => <Item key={item}>{item}</Item>} {item => <Item key={item}>{item}</Item>}
</Dropdown> </Dropdown>

View File

@ -0,0 +1,10 @@
import { Metadata } from 'next';
import ProfilePage from './ProfilePage';
export default function () {
return <ProfilePage />;
}
export const metadata: Metadata = {
title: 'Profile | Umami',
};

View File

@ -1,7 +1,6 @@
import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics'; import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics';
import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm'; import { useApi, useMessages, useModified } from 'components/hooks';
import { useApi, useMessages } from 'components/hooks'; import ConfirmationForm from 'components/common/ConfirmationForm';
import { setValue } from 'store/cache';
export function ReportDeleteButton({ export function ReportDeleteButton({
reportId, reportId,
@ -12,14 +11,17 @@ export function ReportDeleteButton({
reportName: string; reportName: string;
onDelete?: () => void; onDelete?: () => void;
}) { }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const { del, useMutation } = useApi(); const { del, useMutation } = useApi();
const { mutate } = useMutation({ mutationFn: reportId => del(`/reports/${reportId}`) }); const { mutate, isPending, error } = useMutation({
mutationFn: reportId => del(`/reports/${reportId}`),
});
const { touch } = useModified();
const handleConfirm = (close: () => void) => { const handleConfirm = (close: () => void) => {
mutate(reportId as any, { mutate(reportId as any, {
onSuccess: () => { onSuccess: () => {
setValue('reports', Date.now()); touch('reports');
onDelete?.(); onDelete?.();
close(); close();
}, },
@ -34,12 +36,20 @@ export function ReportDeleteButton({
</Icon> </Icon>
<Text>{formatMessage(labels.delete)}</Text> <Text>{formatMessage(labels.delete)}</Text>
</Button> </Button>
<Modal> <Modal title={formatMessage(labels.deleteReport)}>
{close => ( {(close: () => void) => (
<ConfirmDeleteForm <ConfirmationForm
name={reportName} message={
<FormattedMessage
{...messages.confirmDelete}
values={{ target: <b>{reportName}</b> }}
/>
}
isLoading={isPending}
error={error}
onConfirm={handleConfirm.bind(null, close)} onConfirm={handleConfirm.bind(null, close)}
onClose={close} onClose={close}
buttonLabel={formatMessage(labels.delete)}
/> />
)} )}
</Modal> </Modal>

View File

@ -1,10 +1,15 @@
'use client';
import { useReports } from 'components/hooks'; import { useReports } from 'components/hooks';
import ReportsTable from './ReportsTable'; import ReportsTable from './ReportsTable';
import DataTable from 'components/common/DataTable'; import DataTable from 'components/common/DataTable';
export default function ReportsDataTable({ websiteId }: { websiteId?: string }) { export default function ReportsDataTable({
const queryResult = useReports(websiteId); websiteId,
teamId,
}: {
websiteId?: string;
teamId?: string;
}) {
const queryResult = useReports({ websiteId, teamId });
return ( return (
<DataTable queryResult={queryResult}> <DataTable queryResult={queryResult}>

View File

@ -1,23 +1,20 @@
'use client';
import PageHeader from 'components/layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
import { Button, Icon, Icons, Text } from 'react-basics'; import { Icon, Icons, Text } from 'react-basics';
import { useMessages } from 'components/hooks'; import { useMessages, useTeamUrl } from 'components/hooks';
import { useRouter } from 'next/navigation'; import LinkButton from 'components/common/LinkButton';
export function ReportsHeader() { export function ReportsHeader() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const router = useRouter(); const { renderTeamUrl } = useTeamUrl();
const handleClick = () => router.push('/reports/create');
return ( return (
<PageHeader title={formatMessage(labels.reports)}> <PageHeader title={formatMessage(labels.reports)}>
<Button variant="primary" onClick={handleClick}> <LinkButton href={renderTeamUrl('/reports/create')} variant="primary">
<Icon> <Icon>
<Icons.Plus /> <Icons.Plus />
</Icon> </Icon>
<Text>{formatMessage(labels.createReport)}</Text> <Text>{formatMessage(labels.createReport)}</Text>
</Button> </LinkButton>
</PageHeader> </PageHeader>
); );
} }

View File

@ -0,0 +1,15 @@
'use client';
import ReportsHeader from './ReportsHeader';
import ReportsDataTable from './ReportsDataTable';
export default function ReportsPage({ teamId }: { teamId: string }) {
return (
<>
<ReportsHeader />
<ReportsDataTable teamId={teamId} />
</>
);
}
export const metadata = {
title: 'Reports | Umami',
};

View File

@ -1,14 +1,14 @@
import { GridColumn, GridTable, Icon, Icons, Text, useBreakpoint } from 'react-basics'; import { GridColumn, GridTable, Icon, Icons, Text, useBreakpoint } from 'react-basics';
import LinkButton from 'components/common/LinkButton'; import LinkButton from 'components/common/LinkButton';
import { useMessages } from 'components/hooks'; import { useMessages, useLogin, useTeamUrl } from 'components/hooks';
import useUser from 'components/hooks/useUser';
import { REPORT_TYPES } from 'lib/constants'; import { REPORT_TYPES } from 'lib/constants';
import ReportDeleteButton from './ReportDeleteButton'; import ReportDeleteButton from './ReportDeleteButton';
export function ReportsTable({ data = [], showDomain }: { data: any[]; showDomain?: boolean }) { export function ReportsTable({ data = [], showDomain }: { data: any[]; showDomain?: boolean }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { user } = useUser(); const { user } = useLogin();
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();
const { renderTeamUrl } = useTeamUrl();
return ( return (
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}> <GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
@ -34,7 +34,7 @@ export function ReportsTable({ data = [], showDomain }: { data: any[]; showDomai
{(user.id === userId || user.id === website?.userId) && ( {(user.id === userId || user.id === website?.userId) && (
<ReportDeleteButton reportId={id} reportName={name} /> <ReportDeleteButton reportId={id} reportName={name} />
)} )}
<LinkButton href={`/reports/${id}`}> <LinkButton href={renderTeamUrl(`/reports/${id}`)}>
<Icon> <Icon>
<Icons.ArrowRight /> <Icons.ArrowRight />
</Icon> </Icon>

View File

@ -1,14 +0,0 @@
import ReportDetails from './ReportDetails';
import { Metadata } from 'next';
export default function ReportDetailsPage({ params: { id } }) {
if (!id) {
return null;
}
return <ReportDetails reportId={id} />;
}
export const metadata: Metadata = {
title: 'Reports | umami',
};

View File

@ -3,7 +3,7 @@ import { FormRow } from 'react-basics';
import { parseDateRange } from 'lib/date'; import { parseDateRange } from 'lib/date';
import DateFilter from 'components/input/DateFilter'; import DateFilter from 'components/input/DateFilter';
import WebsiteSelect from 'components/input/WebsiteSelect'; import WebsiteSelect from 'components/input/WebsiteSelect';
import { useMessages } from 'components/hooks'; import { useMessages, useTeamUrl, useWebsite } from 'components/hooks';
import { ReportContext } from './Report'; import { ReportContext } from './Report';
export interface BaseParametersProps { export interface BaseParametersProps {
@ -21,10 +21,12 @@ export function BaseParameters({
}: BaseParametersProps) { }: BaseParametersProps) {
const { report, updateReport } = useContext(ReportContext); const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { teamId } = useTeamUrl();
const { parameters } = report || {}; const { parameters } = report || {};
const { websiteId, dateRange } = parameters || {}; const { websiteId, dateRange } = parameters || {};
const { value, startDate, endDate } = dateRange || {}; const { value, startDate, endDate } = dateRange || {};
const { data: website } = useWebsite(websiteId);
const { name } = website || {};
const handleWebsiteSelect = (websiteId: string) => { const handleWebsiteSelect = (websiteId: string) => {
updateReport({ websiteId, parameters: { websiteId } }); updateReport({ websiteId, parameters: { websiteId } });
@ -38,8 +40,10 @@ export function BaseParameters({
<> <>
{showWebsiteSelect && ( {showWebsiteSelect && (
<FormRow label={formatMessage(labels.website)}> <FormRow label={formatMessage(labels.website)}>
{allowWebsiteSelect && ( {allowWebsiteSelect ? (
<WebsiteSelect websiteId={websiteId} onSelect={handleWebsiteSelect} /> <WebsiteSelect teamId={teamId} websiteId={websiteId} onSelect={handleWebsiteSelect} />
) : (
name
)} )}
</FormRow> </FormRow>
)} )}

View File

@ -41,7 +41,7 @@ export default function FieldFilterForm({
format(values[0]); format(values[0]);
} }
return formatted; return formatted;
}, [values]); }, [formatValue, locale, name, values]);
const renderFilterValue = value => { const renderFilterValue = value => {
return filters.find(f => f.value === value)?.label; return filters.find(f => f.value === value)?.label;

View File

@ -1,20 +1,22 @@
'use client';
import { createContext, ReactNode } from 'react'; import { createContext, ReactNode } from 'react';
import { Loading } from 'react-basics'; import { Loading } from 'react-basics';
import classNames from 'classnames';
import { useReport } from 'components/hooks'; import { useReport } from 'components/hooks';
import styles from './Report.module.css'; import styles from './Report.module.css';
import classNames from 'classnames';
export const ReportContext = createContext(null); export const ReportContext = createContext(null);
export interface ReportProps { export function Report({
reportId,
defaultParameters,
children,
className,
}: {
reportId: string; reportId: string;
defaultParameters: { [key: string]: any }; defaultParameters: { [key: string]: any };
children: ReactNode; children: ReactNode;
className?: string; className?: string;
} }) {
export function Report({ reportId, defaultParameters, children, className }: ReportProps) {
const report = useReport(reportId, defaultParameters); const report = useReport(reportId, defaultParameters);
if (!report) { if (!report) {

View File

@ -1,4 +1,3 @@
'use client';
import FunnelReport from '../funnel/FunnelReport'; import FunnelReport from '../funnel/FunnelReport';
import EventDataReport from '../event-data/EventDataReport'; import EventDataReport from '../event-data/EventDataReport';
import InsightsReport from '../insights/InsightsReport'; import InsightsReport from '../insights/InsightsReport';

View File

@ -1,7 +1,6 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { useRouter } from 'next/navigation';
import { Icon, LoadingButton, InlineEditField, useToasts } from 'react-basics'; import { Icon, LoadingButton, InlineEditField, useToasts } from 'react-basics';
import { useMessages, useApi } from 'components/hooks'; import { useMessages, useApi, useNavigation, useTeamUrl } from 'components/hooks';
import { ReportContext } from './Report'; import { ReportContext } from './Report';
import styles from './ReportHeader.module.css'; import styles from './ReportHeader.module.css';
import { REPORT_TYPES } from 'lib/constants'; import { REPORT_TYPES } from 'lib/constants';
@ -10,8 +9,10 @@ export function ReportHeader({ icon }) {
const { report, updateReport } = useContext(ReportContext); const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const { showToast } = useToasts(); const { showToast } = useToasts();
const { router } = useNavigation();
const { renderTeamUrl } = useTeamUrl();
const { post, useMutation } = useApi(); const { post, useMutation } = useApi();
const router = useRouter();
const { mutate: create, isPending: isCreating } = useMutation({ const { mutate: create, isPending: isCreating } = useMutation({
mutationFn: (data: any) => post(`/reports`, data), mutationFn: (data: any) => post(`/reports`, data),
}); });
@ -28,7 +29,7 @@ export function ReportHeader({ icon }) {
create(report, { create(report, {
onSuccess: async ({ id }) => { onSuccess: async ({ id }) => {
showToast({ message: formatMessage(messages.saved), variant: 'success' }); showToast({ message: formatMessage(messages.saved), variant: 'success' });
router.push(`/reports/${id}`); router.push(renderTeamUrl(`/reports/${id}`));
}, },
}); });
} else { } else {

View File

@ -0,0 +1,6 @@
'use client';
import ReportDetails from './ReportDetails';
export default function ReportPage({ reportId }) {
return <ReportDetails reportId={reportId} />;
}

View File

@ -0,0 +1,10 @@
import { Metadata } from 'next';
import ReportPage from './ReportPage';
export default function ({ params: { reportId } }) {
return <ReportPage reportId={reportId} />;
}
export const metadata: Metadata = {
title: 'Reports | Umami',
};

View File

@ -0,0 +1,6 @@
'use client';
import ReportTemplates from './ReportTemplates';
export default function ReportCreatePage() {
return <ReportTemplates />;
}

View File

@ -1,4 +1,3 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { Button, Icons, Text, Icon } from 'react-basics'; import { Button, Icons, Text, Icon } from 'react-basics';
import PageHeader from 'components/layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
@ -6,7 +5,46 @@ import Funnel from 'assets/funnel.svg';
import Lightbulb from 'assets/lightbulb.svg'; import Lightbulb from 'assets/lightbulb.svg';
import Magnet from 'assets/magnet.svg'; import Magnet from 'assets/magnet.svg';
import styles from './ReportTemplates.module.css'; import styles from './ReportTemplates.module.css';
import { useMessages } from 'components/hooks'; import { useMessages, useTeamUrl } from 'components/hooks';
export function ReportTemplates({ showHeader = true }: { showHeader?: boolean }) {
const { formatMessage, labels } = useMessages();
const { renderTeamUrl } = useTeamUrl();
const reports = [
{
title: formatMessage(labels.insights),
description: formatMessage(labels.insightsDescription),
url: renderTeamUrl('/reports/insights'),
icon: <Lightbulb />,
},
{
title: formatMessage(labels.funnel),
description: formatMessage(labels.funnelDescription),
url: renderTeamUrl('/reports/funnel'),
icon: <Funnel />,
},
{
title: formatMessage(labels.retention),
description: formatMessage(labels.retentionDescription),
url: renderTeamUrl('/reports/retention'),
icon: <Magnet />,
},
];
return (
<>
{showHeader && <PageHeader title={formatMessage(labels.reports)} />}
<div className={styles.reports}>
{reports.map(({ title, description, url, icon }) => {
return (
<ReportItem key={title} icon={icon} title={title} description={description} url={url} />
);
})}
</div>
</>
);
}
function ReportItem({ title, description, url, icon }) { function ReportItem({ title, description, url, icon }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -32,42 +70,4 @@ function ReportItem({ title, description, url, icon }) {
); );
} }
export function ReportTemplates({ showHeader = true }) {
const { formatMessage, labels } = useMessages();
const reports = [
{
title: formatMessage(labels.insights),
description: formatMessage(labels.insightsDescription),
url: '/reports/insights',
icon: <Lightbulb />,
},
{
title: formatMessage(labels.funnel),
description: formatMessage(labels.funnelDescription),
url: '/reports/funnel',
icon: <Funnel />,
},
{
title: formatMessage(labels.retention),
description: formatMessage(labels.retentionDescription),
url: '/reports/retention',
icon: <Magnet />,
},
];
return (
<>
{showHeader && <PageHeader title={formatMessage(labels.reports)} />}
<div className={styles.reports}>
{reports.map(({ title, description, url, icon }) => {
return (
<ReportItem key={title} icon={icon} title={title} description={description} url={url} />
);
})}
</div>
</>
);
}
export default ReportTemplates; export default ReportTemplates;

View File

@ -1,10 +1,10 @@
import ReportTemplates from './ReportTemplates'; import ReportCreatePage from './ReportCreatePage';
import { Metadata } from 'next'; import { Metadata } from 'next';
export default function ReportsCreatePage() { export default function () {
return <ReportTemplates />; return <ReportCreatePage />;
} }
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Create Report | umami', title: 'Create Report | Umami',
}; };

View File

@ -4,10 +4,10 @@ import Empty from 'components/common/Empty';
import Icons from 'components/icons'; import Icons from 'components/icons';
import { useApi, useMessages } from 'components/hooks'; import { useApi, useMessages } from 'components/hooks';
import { DATA_TYPES, REPORT_PARAMETERS } from 'lib/constants'; import { DATA_TYPES, REPORT_PARAMETERS } from 'lib/constants';
import { ReportContext } from '../[id]/Report'; import { ReportContext } from '../[reportId]/Report';
import FieldAddForm from '../[id]/FieldAddForm'; import FieldAddForm from '../[reportId]/FieldAddForm';
import ParameterList from '../[id]/ParameterList'; import ParameterList from '../[reportId]/ParameterList';
import BaseParameters from '../[id]/BaseParameters'; import BaseParameters from '../[reportId]/BaseParameters';
import styles from './EventDataParameters.module.css'; import styles from './EventDataParameters.module.css';
function useFields(websiteId, startDate, endDate) { function useFields(websiteId, startDate, endDate) {
@ -29,7 +29,7 @@ function useFields(websiteId, startDate, endDate) {
export function EventDataParameters() { export function EventDataParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const { parameters } = report || {}; const { id, parameters } = report || {};
const { websiteId, dateRange, fields, filters, groups } = parameters || {}; const { websiteId, dateRange, fields, filters, groups } = parameters || {};
const { startDate, endDate } = dateRange || {}; const { startDate, endDate } = dateRange || {};
const queryEnabled = websiteId && dateRange && fields?.length; const queryEnabled = websiteId && dateRange && fields?.length;
@ -93,7 +93,7 @@ export function EventDataParameters() {
return ( return (
<Form values={parameters} error={error} onSubmit={handleSubmit}> <Form values={parameters} error={error} onSubmit={handleSubmit}>
<BaseParameters /> <BaseParameters allowWebsiteSelect={!id} />
{!hasData && <Empty message={formatMessage(messages.noEventData)} />} {!hasData && <Empty message={formatMessage(messages.noEventData)} />}
{parametersSelected && {parametersSelected &&
hasData && hasData &&

View File

@ -1,7 +1,7 @@
import Report from '../[id]/Report'; import Report from '../[reportId]/Report';
import ReportHeader from '../[id]/ReportHeader'; import ReportHeader from '../[reportId]/ReportHeader';
import ReportMenu from '../[id]/ReportMenu'; import ReportMenu from '../[reportId]/ReportMenu';
import ReportBody from '../[id]/ReportBody'; import ReportBody from '../[reportId]/ReportBody';
import EventDataParameters from './EventDataParameters'; import EventDataParameters from './EventDataParameters';
import EventDataTable from './EventDataTable'; import EventDataTable from './EventDataTable';
import Nodes from 'assets/nodes.svg'; import Nodes from 'assets/nodes.svg';
@ -11,7 +11,7 @@ const defaultParameters = {
parameters: { fields: [], filters: [] }, parameters: { fields: [], filters: [] },
}; };
export default function EventDataReport({ reportId }: { reportId: string }) { export default function EventDataReport({ reportId }: { reportId?: string }) {
return ( return (
<Report reportId={reportId} defaultParameters={defaultParameters}> <Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Nodes />} /> <ReportHeader icon={<Nodes />} />

View File

@ -0,0 +1,6 @@
'use client';
import EventDataReport from './EventDataReport';
export default function EventDataReportPage() {
return <EventDataReport />;
}

View File

@ -1,7 +1,7 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { GridTable, GridColumn } from 'react-basics'; import { GridTable, GridColumn } from 'react-basics';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import { ReportContext } from '../[id]/Report'; import { ReportContext } from '../[reportId]/Report';
export function EventDataTable() { export function EventDataTable() {
const { report } = useContext(ReportContext); const { report } = useContext(ReportContext);

View File

@ -0,0 +1,10 @@
import { Metadata } from 'next';
import EventDataReportPage from './EventDataReportPage';
export default function () {
return <EventDataReportPage />;
}
export const metadata: Metadata = {
title: 'Event Data Report | Umami',
};

View File

@ -1,10 +1,9 @@
import { JSX, useCallback, useContext, useMemo } from 'react'; import { JSX, useCallback, useContext, useMemo } from 'react';
import { Loading, StatusLight } from 'react-basics'; import { Loading, StatusLight } from 'react-basics';
import useMessages from 'components/hooks/useMessages'; import { useMessages, useTheme } from 'components/hooks';
import useTheme from 'components/hooks/useTheme';
import BarChart from 'components/metrics/BarChart'; import BarChart from 'components/metrics/BarChart';
import { formatLongNumber } from 'lib/format'; import { formatLongNumber } from 'lib/format';
import { ReportContext } from '../[id]/Report'; import { ReportContext } from '../[reportId]/Report';
import styles from './FunnelChart.module.css'; import styles from './FunnelChart.module.css';
export interface FunnelChartProps { export interface FunnelChartProps {

View File

@ -13,16 +13,16 @@ import {
} from 'react-basics'; } from 'react-basics';
import Icons from 'components/icons'; import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm'; import UrlAddForm from './UrlAddForm';
import { ReportContext } from '../[id]/Report'; import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[id]/BaseParameters'; import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[id]/ParameterList'; import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[id]/PopupForm'; import PopupForm from '../[reportId]/PopupForm';
export function FunnelParameters() { export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { parameters } = report || {}; const { id, parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {}; const { websiteId, dateRange, urls } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2; const queryDisabled = !websiteId || !dateRange || urls?.length < 2;
@ -62,7 +62,7 @@ export function FunnelParameters() {
return ( return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}> <Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters /> <BaseParameters allowWebsiteSelect={!id} />
<FormRow label={formatMessage(labels.window)}> <FormRow label={formatMessage(labels.window)}>
<FormInput <FormInput
name="window" name="window"

View File

@ -1,11 +1,10 @@
'use client';
import FunnelChart from './FunnelChart'; import FunnelChart from './FunnelChart';
import FunnelTable from './FunnelTable'; import FunnelTable from './FunnelTable';
import FunnelParameters from './FunnelParameters'; import FunnelParameters from './FunnelParameters';
import Report from '../[id]/Report'; import Report from '../[reportId]/Report';
import ReportHeader from '../[id]/ReportHeader'; import ReportHeader from '../[reportId]/ReportHeader';
import ReportMenu from '../[id]/ReportMenu'; import ReportMenu from '../[reportId]/ReportMenu';
import ReportBody from '../[id]/ReportBody'; import ReportBody from '../[reportId]/ReportBody';
import Funnel from 'assets/funnel.svg'; import Funnel from 'assets/funnel.svg';
import { REPORT_TYPES } from 'lib/constants'; import { REPORT_TYPES } from 'lib/constants';
@ -14,7 +13,7 @@ const defaultParameters = {
parameters: { window: 60, urls: [] }, parameters: { window: 60, urls: [] },
}; };
export default function FunnelReport({ reportId }) { export default function FunnelReport({ reportId }: { reportId?: string }) {
return ( return (
<Report reportId={reportId} defaultParameters={defaultParameters}> <Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Funnel />} /> <ReportHeader icon={<Funnel />} />

View File

@ -0,0 +1,6 @@
'use client';
import FunnelReport from './FunnelReport';
export default function FunnelReportPage() {
return <FunnelReport />;
}

View File

@ -1,7 +1,7 @@
import { useContext } from 'react'; import { useContext } from 'react';
import ListTable from 'components/metrics/ListTable'; import ListTable from 'components/metrics/ListTable';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import { ReportContext } from '../[id]/Report'; import { ReportContext } from '../[reportId]/Report';
export function FunnelTable() { export function FunnelTable() {
const { report } = useContext(ReportContext); const { report } = useContext(ReportContext);

View File

@ -1,10 +1,10 @@
import FunnelReport from './FunnelReport'; import FunnelReportPage from './FunnelReportPage';
import { Metadata } from 'next'; import { Metadata } from 'next';
export default function FunnelReportPage() { export default function () {
return <FunnelReport reportId={null} />; return <FunnelReportPage />;
} }
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Funnel Report | umami', title: 'Funnel Report | Umami',
}; };

View File

@ -1,22 +1,22 @@
import { useFilters, useFormat, useMessages } from 'components/hooks';
import Icons from 'components/icons';
import { useContext } from 'react'; import { useContext } from 'react';
import { useFormat, useMessages, useFilters } from 'components/hooks';
import { import {
Form, Form,
FormRow,
FormButtons, FormButtons,
SubmitButton, FormRow,
PopupTrigger,
Icon, Icon,
Popup, Popup,
PopupTrigger,
SubmitButton,
TooltipPopup, TooltipPopup,
} from 'react-basics'; } from 'react-basics';
import Icons from 'components/icons'; import BaseParameters from '../[reportId]/BaseParameters';
import BaseParameters from '../[id]/BaseParameters'; import FieldSelectForm from '../[reportId]/FieldSelectForm';
import { ReportContext } from '../[id]/Report'; import FilterSelectForm from '../[reportId]/FilterSelectForm';
import ParameterList from '../[id]/ParameterList'; import ParameterList from '../[reportId]/ParameterList';
import FilterSelectForm from '../[id]/FilterSelectForm'; import PopupForm from '../[reportId]/PopupForm';
import FieldSelectForm from '../[id]/FieldSelectForm'; import { ReportContext } from '../[reportId]/Report';
import PopupForm from '../[id]/PopupForm';
import styles from './InsightsParameters.module.css'; import styles from './InsightsParameters.module.css';
export function InsightsParameters() { export function InsightsParameters() {
@ -24,7 +24,7 @@ export function InsightsParameters() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat(); const { formatValue } = useFormat();
const { filterLabels } = useFilters(); const { filterLabels } = useFilters();
const { parameters } = report || {}; const { id, parameters } = report || {};
const { websiteId, dateRange, fields, filters } = parameters || {}; const { websiteId, dateRange, fields, filters } = parameters || {};
const { startDate, endDate } = dateRange || {}; const { startDate, endDate } = dateRange || {};
const parametersSelected = websiteId && startDate && endDate; const parametersSelected = websiteId && startDate && endDate;
@ -103,7 +103,7 @@ export function InsightsParameters() {
return ( return (
<Form values={parameters} onSubmit={handleSubmit}> <Form values={parameters} onSubmit={handleSubmit}>
<BaseParameters /> <BaseParameters allowWebsiteSelect={!id} />
{parametersSelected && {parametersSelected &&
parameterGroups.map(({ id, label }) => { parameterGroups.map(({ id, label }) => {
return ( return (

View File

@ -1,8 +1,7 @@
'use client'; import Report from '../[reportId]/Report';
import Report from '../[id]/Report'; import ReportHeader from '../[reportId]/ReportHeader';
import ReportHeader from '../[id]/ReportHeader'; import ReportMenu from '../[reportId]/ReportMenu';
import ReportMenu from '../[id]/ReportMenu'; import ReportBody from '../[reportId]/ReportBody';
import ReportBody from '../[id]/ReportBody';
import InsightsParameters from './InsightsParameters'; import InsightsParameters from './InsightsParameters';
import InsightsTable from './InsightsTable'; import InsightsTable from './InsightsTable';
import Lightbulb from 'assets/lightbulb.svg'; import Lightbulb from 'assets/lightbulb.svg';
@ -13,7 +12,7 @@ const defaultParameters = {
parameters: { fields: [], filters: [] }, parameters: { fields: [], filters: [] },
}; };
export default function InsightsReport({ reportId }: { reportId: string }) { export default function InsightsReport({ reportId }: { reportId?: string }) {
return ( return (
<Report reportId={reportId} defaultParameters={defaultParameters}> <Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Lightbulb />} /> <ReportHeader icon={<Lightbulb />} />

View File

@ -0,0 +1,6 @@
'use client';
import InsightsReport from './InsightsReport';
export default function InsightsReportPage() {
return <InsightsReport />;
}

View File

@ -1,7 +1,7 @@
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { GridTable, GridColumn } from 'react-basics'; import { GridTable, GridColumn } from 'react-basics';
import { useFormat, useMessages } from 'components/hooks'; import { useFormat, useMessages } from 'components/hooks';
import { ReportContext } from '../[id]/Report'; import { ReportContext } from '../[reportId]/Report';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
export function InsightsTable() { export function InsightsTable() {

View File

@ -1,10 +1,10 @@
import InsightsReport from './InsightsReport'; import InsightsReportPage from './InsightsReportPage';
import { Metadata } from 'next'; import { Metadata } from 'next';
export default function InsightsReportPage() { export default function () {
return <InsightsReport reportId={null} />; return <InsightsReportPage />;
} }
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Insights Report | umami', title: 'Insights Report | Umami',
}; };

View File

@ -1,14 +1,10 @@
import ReportsHeader from './ReportsHeader'; import ReportsPage from './ReportsPage';
import ReportsDataTable from './ReportsDataTable'; import { Metadata } from 'next';
export default function () { export default function ({ params: { teamId } }: { params: { teamId: string } }) {
return ( return <ReportsPage teamId={teamId} />;
<>
<ReportsHeader />
<ReportsDataTable />
</>
);
} }
export const metadata = {
title: 'Reports | umami', export const metadata: Metadata = {
title: 'Reports | Umami',
}; };

View File

@ -1,16 +1,16 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import { Form, FormButtons, FormRow, SubmitButton } from 'react-basics'; import { Form, FormButtons, FormRow, SubmitButton } from 'react-basics';
import { ReportContext } from '../[id]/Report'; import { ReportContext } from '../[reportId]/Report';
import { MonthSelect } from 'components/input/MonthSelect'; import { MonthSelect } from 'components/input/MonthSelect';
import BaseParameters from '../[id]/BaseParameters'; import BaseParameters from '../[reportId]/BaseParameters';
import { parseDateRange } from 'lib/date'; import { parseDateRange } from 'lib/date';
export function RetentionParameters() { export function RetentionParameters() {
const { report, runReport, isRunning, updateReport } = useContext(ReportContext); const { report, runReport, isRunning, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { parameters } = report || {}; const { id, parameters } = report || {};
const { websiteId, dateRange } = parameters || {}; const { websiteId, dateRange } = parameters || {};
const { startDate } = dateRange || {}; const { startDate } = dateRange || {};
const queryDisabled = !websiteId || !dateRange; const queryDisabled = !websiteId || !dateRange;
@ -30,7 +30,7 @@ export function RetentionParameters() {
return ( return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}> <Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters showDateSelect={false} /> <BaseParameters showDateSelect={false} allowWebsiteSelect={!id} />
<FormRow label={formatMessage(labels.date)}> <FormRow label={formatMessage(labels.date)}>
<MonthSelect date={startDate} onChange={handleDateChange} /> <MonthSelect date={startDate} onChange={handleDateChange} />
</FormRow> </FormRow>

View File

@ -1,10 +1,9 @@
'use client';
import RetentionTable from './RetentionTable'; import RetentionTable from './RetentionTable';
import RetentionParameters from './RetentionParameters'; import RetentionParameters from './RetentionParameters';
import Report from '../[id]/Report'; import Report from '../[reportId]/Report';
import ReportHeader from '../[id]/ReportHeader'; import ReportHeader from '../[reportId]/ReportHeader';
import ReportMenu from '../[id]/ReportMenu'; import ReportMenu from '../[reportId]/ReportMenu';
import ReportBody from '../[id]/ReportBody'; import ReportBody from '../[reportId]/ReportBody';
import Magnet from 'assets/magnet.svg'; import Magnet from 'assets/magnet.svg';
import { REPORT_TYPES } from 'lib/constants'; import { REPORT_TYPES } from 'lib/constants';
import { parseDateRange } from 'lib/date'; import { parseDateRange } from 'lib/date';
@ -19,7 +18,7 @@ const defaultParameters = {
}, },
}; };
export default function RetentionReport({ reportId }: { reportId: string }) { export default function RetentionReport({ reportId }: { reportId?: string }) {
return ( return (
<Report reportId={reportId} defaultParameters={defaultParameters}> <Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Magnet />} /> <ReportHeader icon={<Magnet />} />

View File

@ -0,0 +1,6 @@
'use client';
import RetentionReport from './RetentionReport';
export default function RetentionReportPage() {
return <RetentionReport />;
}

View File

@ -1,6 +1,6 @@
import { useContext } from 'react'; import { useContext } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { ReportContext } from '../[id]/Report'; import { ReportContext } from '../[reportId]/Report';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import { useMessages, useLocale } from 'components/hooks'; import { useMessages, useLocale } from 'components/hooks';
import { formatDate } from 'lib/date'; import { formatDate } from 'lib/date';

View File

@ -1,9 +0,0 @@
import RetentionReport from './RetentionReport';
export default function RetentionReportPage() {
return <RetentionReport reportId={null} />;
}
export const metadata = {
title: 'Create Report | umami',
};

View File

@ -0,0 +1,10 @@
import { Metadata } from 'next';
import RetentionReportPage from './RetentionReportPage';
export default function () {
return <RetentionReportPage />;
}
export const metadata: Metadata = {
title: 'Create Report | Umami',
};

View File

@ -1,5 +0,0 @@
import { createContext } from 'react';
export const SettingsContext = createContext(null);
export default SettingsContext;

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