diff --git a/.eslintrc.json b/.eslintrc.json index a77ed5bd..9d747b87 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,14 +4,6 @@ "es2020": true, "node": true }, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 11, - "sourceType": "module" - }, "extends": [ "eslint:recommended", "plugin:prettier/recommended", @@ -19,6 +11,14 @@ "plugin:@typescript-eslint/recommended", "next" ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 11, + "sourceType": "module" + }, "plugins": ["@typescript-eslint", "prettier"], "settings": { "import/resolver": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 775f9ecf..66e16a03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,10 +16,6 @@ jobs: strategy: matrix: include: - - node-version: 16.x - db-type: postgresql - - node-version: 16.x - db-type: mysql - node-version: 18.x db-type: postgresql - node-version: 18.x diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml index 24711fba..f1604014 100644 --- a/.github/workflows/stale-issues.yml +++ b/.github/workflows/stale-issues.yml @@ -22,3 +22,4 @@ jobs: operations-per-run: 200 ascending: true repo-token: ${{ secrets.GITHUB_TOKEN }} + exempt-issue-labels: bug,enhancement diff --git a/Dockerfile b/Dockerfile index 6674163a..801b2bc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,9 @@ ENV NEXT_TELEMETRY_DISABLED 1 RUN addgroup --system --gid 1001 nodejs 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 COPY --from=builder /app/next.config.js . diff --git a/README.md b/README.md index 02a44e1e..32e78e31 100644 --- a/README.md +++ b/README.md @@ -72,13 +72,13 @@ docker compose up -d Alternatively, to pull just the Umami Docker image with PostgreSQL support: ```bash -docker pull docker.umami.dev/umami-software/umami:postgresql-latest +docker pull ghcr.io/umami-software/umami:postgresql-latest ``` Or with MySQL support: ```bash -docker pull docker.umami.dev/umami-software/umami:mysql-latest +docker pull ghcr.io/umami-software/umami:mysql-latest ``` ## Getting updates diff --git a/db/mysql/migrations/02_report_schema_session_data/migration.sql b/db/mysql/migrations/02_report_schema_session_data/migration.sql index 49708899..1649ace2 100644 --- a/db/mysql/migrations/02_report_schema_session_data/migration.sql +++ b/db/mysql/migrations/02_report_schema_session_data/migration.sql @@ -1,9 +1,9 @@ -- AlterTable -ALTER TABLE `event_data` RENAME COLUMN `event_data_type` TO `data_type`; -ALTER TABLE `event_data` RENAME COLUMN `event_date_value` TO `date_value`; -ALTER TABLE `event_data` RENAME COLUMN `event_id` TO `event_data_id`; -ALTER TABLE `event_data` RENAME COLUMN `event_numeric_value` TO `number_value`; -ALTER TABLE `event_data` RENAME COLUMN `event_string_value` TO `string_value`; +ALTER TABLE `event_data` CHANGE `event_data_type` `data_type` INTEGER UNSIGNED NOT NULL; +ALTER TABLE `event_data` CHANGE `event_date_value` `date_value` TIMESTAMP(0) NULL; +ALTER TABLE `event_data` CHANGE `event_id` `event_data_id` VARCHAR(36) NOT NULL; +ALTER TABLE `event_data` CHANGE `event_numeric_value` `number_value` DECIMAL(19,4) NULL; +ALTER TABLE `event_data` CHANGE `event_string_value` `string_value` VARCHAR(500) NULL; -- CreateTable CREATE TABLE `session_data` ( @@ -50,4 +50,4 @@ WHERE data_type = 2; UPDATE event_data SET string_value = CONCAT(REPLACE(DATE_FORMAT(date_value, '%Y-%m-%d %T'), ' ', 'T'), 'Z') -WHERE data_type = 4; \ No newline at end of file +WHERE data_type = 4; diff --git a/docker-compose.yml b/docker-compose.yml index b8da9373..08f00b7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,11 @@ services: db: condition: service_healthy restart: always + healthcheck: + test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"] + interval: 5s + timeout: 5s + retries: 5 db: image: postgres:15-alpine environment: diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03d..fd36f949 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js index cc3cde7c..a155ece7 100644 --- a/next.config.js +++ b/next.config.js @@ -3,27 +3,26 @@ require('dotenv').config(); const path = require('path'); const pkg = require('./package.json'); -const contentSecurityPolicy = ` - default-src 'self'; - img-src *; - script-src 'self' 'unsafe-eval'; - style-src 'self' 'unsafe-inline'; - connect-src 'self' api.umami.is; - frame-ancestors 'self' ${process.env.ALLOWED_FRAME_URLS}; -`; +const contentSecurityPolicy = [ + `default-src 'self'`, + `img-src *`, + `script-src 'self' 'unsafe-eval' 'unsafe-inline'`, + `style-src 'self' 'unsafe-inline'`, + `connect-src 'self' api.umami.is`, + `frame-ancestors 'self' ${process.env.ALLOWED_FRAME_URLS || ''}`, +]; const headers = [ { key: 'X-DNS-Prefetch-Control', value: 'on', }, - { - key: 'X-Frame-Options', - value: 'SAMEORIGIN', - }, { key: 'Content-Security-Policy', - value: contentSecurityPolicy.replace(/\s{2,}/g, ' ').trim(), + value: contentSecurityPolicy + .join(';') + .replace(/\s{2,}/g, ' ') + .trim(), }, ]; @@ -74,16 +73,23 @@ if (process.env.CLOUD_MODE && process.env.CLOUD_URL && process.env.DISABLE_LOGIN }); } +const basePath = process.env.BASE_PATH; + +/** @type {import('next').NextConfig} */ const config = { + reactStrictMode: false, env: { - cloudMode: process.env.CLOUD_MODE, - cloudUrl: process.env.CLOUD_URL, + basePath: basePath || '', + cloudMode: process.env.CLOUD_MODE || '', + cloudUrl: process.env.CLOUD_URL || '', configUrl: '/config', currentVersion: pkg.version, - defaultLocale: process.env.DEFAULT_LOCALE, - isProduction: process.env.NODE_ENV === 'production', + defaultLocale: process.env.DEFAULT_LOCALE || '', + disableLogin: process.env.DISABLE_LOGIN || '', + disableUI: process.env.DISABLE_UI || '', + hostUrl: process.env.HOST_URL || '', }, - basePath: process.env.BASE_PATH, + basePath, output: 'standalone', eslint: { ignoreDuringBuilds: true, @@ -92,11 +98,23 @@ const config = { ignoreBuildErrors: true, }, webpack(config) { - config.module.rules.push({ - test: /\.svg$/, - issuer: /\.{js|jsx|ts|tsx}$/, - use: ['@svgr/webpack'], - }); + const fileLoaderRule = config.module.rules.find(rule => rule.test?.test?.('.svg')); + + config.module.rules.push( + { + ...fileLoaderRule, + test: /\.svg$/i, + resourceQuery: /url/, + }, + { + test: /\.svg$/i, + issuer: fileLoaderRule.issuer, + resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, + use: ['@svgr/webpack'], + }, + ); + + fileLoaderRule.exclude = /\.svg$/i; config.resolve.alias['public'] = path.resolve('./public'); diff --git a/package.json b/package.json index 79960eb2..0f437c35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umami", - "version": "2.7.0", + "version": "2.9.0", "description": "A simple, fast, privacy-focused alternative to Google Analytics.", "author": "Mike Cao ", "license": "MIT", @@ -61,16 +61,18 @@ ".next/cache" ], "dependencies": { + "@clickhouse/client": "^0.2.2", "@fontsource/inter": "^4.5.15", - "@prisma/client": "5.3.1", - "@tanstack/react-query": "^4.33.0", - "@umami/prisma-client": "^0.2.0", - "@umami/redis-client": "^0.15.0", + "@prisma/client": "5.6.0", + "@prisma/extension-read-replicas": "^0.3.0", + "@react-spring/web": "^9.7.3", + "@tanstack/react-query": "^5.12.2", + "@umami/prisma-client": "^0.8.0", + "@umami/redis-client": "^0.18.0", "chalk": "^4.1.1", "chart.js": "^4.2.1", "chartjs-adapter-date-fns": "^3.0.0", "classnames": "^2.3.1", - "clickhouse": "^2.5.0", "colord": "^2.9.2", "cors": "^2.8.5", "cross-spawn": "^7.0.3", @@ -91,22 +93,22 @@ "kafkajs": "^2.1.0", "maxmind": "^4.3.6", "moment-timezone": "^0.5.35", - "next": "13.5.2", - "next-basics": "^0.36.0", + "next": "14.0.4", + "next-basics": "^0.39.0", "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", + "prisma": "5.6.0", "react": "^18.2.0", - "react-basics": "^0.100.0", + "react-basics": "^0.114.0", "react-beautiful-dnd": "^13.1.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.4", - "react-intl": "^5.24.7", + "react-intl": "^6.5.5", "react-simple-maps": "^2.3.0", - "react-spring": "^9.4.4", "react-use-measure": "^2.0.4", "react-window": "^1.8.6", "request-ip": "^3.3.0", - "semver": "^7.5.2", + "semver": "^7.5.4", "thenby": "^1.3.4", "timezone-support": "^2.0.2", "uuid": "^9.0.0", @@ -123,12 +125,13 @@ "@rollup/plugin-node-resolve": "^15.2.0", "@rollup/plugin-replace": "^5.0.2", "@svgr/rollup": "^8.1.0", - "@svgr/webpack": "^6.2.1", - "@types/node": "^18.11.9", - "@types/react": "^18.0.25", - "@types/react-dom": "^18.0.8", - "@typescript-eslint/eslint-plugin": "^5.50.0", - "@typescript-eslint/parser": "^5.50.0", + "@svgr/webpack": "^8.1.0", + "@types/node": "^20.9.0", + "@types/react": "^18.2.41", + "@types/react-dom": "^18.2.17", + "@types/react-window": "^1.8.8", + "@typescript-eslint/eslint-plugin": "^6.7.3", + "@typescript-eslint/parser": "^6.7.3", "cross-env": "^7.0.3", "esbuild": "^0.17.17", "eslint": "^8.33.0", @@ -138,15 +141,14 @@ "eslint-plugin-import": "^2.26.0", "eslint-plugin-prettier": "^4.0.0", "extract-react-intl-messages": "^4.1.1", - "husky": "^7.0.0", - "lint-staged": "^11.0.0", - "postcss": "^8.4.21", + "husky": "^8.0.3", + "lint-staged": "^14.0.1", + "postcss": "^8.4.31", "postcss-flexbugs-fixes": "^5.0.2", "postcss-import": "^15.1.0", "postcss-preset-env": "7.8.3", "postcss-rtlcss": "^4.0.1", "prettier": "^2.6.2", - "prisma": "5.3.1", "prompts": "2.4.2", "rollup": "^3.28.0", "rollup-plugin-copy": "^3.4.0", diff --git a/public/images/os/windows-mobile.png b/public/images/os/windows-mobile.png new file mode 100644 index 00000000..4a899a30 Binary files /dev/null and b/public/images/os/windows-mobile.png differ diff --git a/public/intl/messages/es-ES.json b/public/intl/messages/es-ES.json index 0e131621..5fd90efd 100644 --- a/public/intl/messages/es-ES.json +++ b/public/intl/messages/es-ES.json @@ -104,7 +104,7 @@ "label.browser": [ { "type": 0, - "value": "Browser" + "value": "Navegador" } ], "label.browsers": [ @@ -134,7 +134,7 @@ "label.city": [ { "type": 0, - "value": "City" + "value": "Ciudad" } ], "label.clear-all": [ @@ -176,19 +176,19 @@ "label.country": [ { "type": 0, - "value": "Country" + "value": "País" } ], "label.create": [ { "type": 0, - "value": "Create" + "value": "Crear" } ], "label.create-report": [ { "type": 0, - "value": "Crear reporte" + "value": "Crear informe" } ], "label.create-team": [ @@ -236,7 +236,7 @@ "label.date": [ { "type": 0, - "value": "Date" + "value": "Fecha" } ], "label.date-range": [ @@ -248,7 +248,7 @@ "label.day": [ { "type": 0, - "value": "Day" + "value": "Día" } ], "label.default-date-range": [ @@ -284,7 +284,7 @@ "label.description": [ { "type": 0, - "value": "Descripciones" + "value": "Descripción" } ], "label.desktop": [ @@ -302,7 +302,7 @@ "label.device": [ { "type": 0, - "value": "Device" + "value": "Dispositivo" } ], "label.devices": [ @@ -314,7 +314,7 @@ "label.dismiss": [ { "type": 0, - "value": "Ignorar" + "value": "Cerrar" } ], "label.does-not-contain": [ @@ -332,7 +332,7 @@ "label.dropoff": [ { "type": 0, - "value": "Dropoff" + "value": "Abandono" } ], "label.edit": [ @@ -374,7 +374,7 @@ "label.false": [ { "type": 0, - "value": "False" + "value": "Falso" } ], "label.field": [ @@ -392,7 +392,7 @@ "label.filter": [ { "type": 0, - "value": "Filter" + "value": "Filtro" } ], "label.filter-combined": [ @@ -422,7 +422,7 @@ "label.funnel-description": [ { "type": 0, - "value": "Understand the conversion and drop-off rate of users." + "value": "Comprender conversión y abandono de usuarios." } ], "label.greater-than": [ @@ -470,7 +470,7 @@ "label.is-set": [ { "type": 0, - "value": "Is set" + "value": "Está establecido" } ], "label.join": [ @@ -600,7 +600,7 @@ "label.my-websites": [ { "type": 0, - "value": "My websites" + "value": "Mis sitios web" } ], "label.name": [ @@ -624,7 +624,7 @@ "label.os": [ { "type": 0, - "value": "OS" + "value": "Sistema" } ], "label.overview": [ @@ -642,7 +642,7 @@ "label.page-of": [ { "type": 0, - "value": "Page " + "value": "Página " }, { "type": 1, @@ -650,7 +650,7 @@ }, { "type": 0, - "value": " of " + "value": " de " }, { "type": 1, @@ -666,7 +666,7 @@ "label.pageTitle": [ { "type": 0, - "value": "Page title" + "value": "Título de página" } ], "label.pages": [ @@ -684,7 +684,7 @@ "label.powered-by": [ { "type": 0, - "value": "Con la ayuda de " + "value": "Analíticas de " }, { "type": 1, @@ -706,7 +706,7 @@ "label.query": [ { "type": 0, - "value": "Query" + "value": "Consulta" } ], "label.query-parameters": [ @@ -724,7 +724,7 @@ "label.referrer": [ { "type": 0, - "value": "Referrer" + "value": "Referido" } ], "label.referrers": [ @@ -766,7 +766,7 @@ "label.reports": [ { "type": 0, - "value": "Reportes" + "value": "Informes" } ], "label.required": [ @@ -784,19 +784,19 @@ "label.reset-website": [ { "type": 0, - "value": "Reiniciar estadísticas" + "value": "Reiniciar analíticas" } ], "label.retention": [ { "type": 0, - "value": "Retention" + "value": "Retención" } ], "label.retention-description": [ { "type": 0, - "value": "Measure your website stickiness by tracking how often users return." + "value": "Medir la frecuencia con la que los usuarios vuelven a tu sitio web." } ], "label.role": [ @@ -826,7 +826,7 @@ "label.search": [ { "type": 0, - "value": "Search" + "value": "Buscar" } ], "label.select-date": [ @@ -850,7 +850,7 @@ "label.settings": [ { "type": 0, - "value": "Configuraciones" + "value": "Ajustes" } ], "label.share-url": [ @@ -892,7 +892,7 @@ "label.team-id": [ { "type": 0, - "value": "ID de equipo" + "value": "ID del equipo" } ], "label.team-member": [ @@ -904,7 +904,7 @@ "label.team-name": [ { "type": 0, - "value": "Team name" + "value": "Nombre del equipo" } ], "label.team-owner": [ @@ -916,7 +916,7 @@ "label.team-websites": [ { "type": 0, - "value": "Team websites" + "value": "Sitios web del equipo" } ], "label.teams": [ @@ -1288,7 +1288,7 @@ "message.new-version-available": [ { "type": 0, - "value": "A new version of Umami " + "value": "Una nueva versión de Umami " }, { "type": 1, @@ -1296,7 +1296,7 @@ }, { "type": 0, - "value": " is available!" + "value": " está disponible" } ], "message.no-data-available": [ @@ -1376,7 +1376,7 @@ "message.saved": [ { "type": 0, - "value": "Guardado." + "value": "Guardado" } ], "message.share-url": [ diff --git a/public/intl/messages/mn-MN.json b/public/intl/messages/mn-MN.json index fd7294f3..1a221045 100644 --- a/public/intl/messages/mn-MN.json +++ b/public/intl/messages/mn-MN.json @@ -20,13 +20,13 @@ "label.add": [ { "type": 0, - "value": "Add" + "value": "Нэмэх" } ], "label.add-description": [ { "type": 0, - "value": "Add description" + "value": "Тайлбар нэмэх" } ], "label.add-website": [ @@ -44,7 +44,7 @@ "label.after": [ { "type": 0, - "value": "After" + "value": "Хойно" } ], "label.all": [ @@ -68,7 +68,7 @@ "label.average": [ { "type": 0, - "value": "Average" + "value": "Дундаж" } ], "label.average-visit-time": [ @@ -86,7 +86,7 @@ "label.before": [ { "type": 0, - "value": "Before" + "value": "Өмнө" } ], "label.bounce-rate": [ @@ -98,13 +98,13 @@ "label.breakdown": [ { "type": 0, - "value": "Breakdown" + "value": "Задаргаа" } ], "label.browser": [ { "type": 0, - "value": "Browser" + "value": "Хөтөч" } ], "label.browsers": [ @@ -134,7 +134,7 @@ "label.city": [ { "type": 0, - "value": "City" + "value": "Хот" } ], "label.clear-all": [ @@ -158,7 +158,7 @@ "label.contains": [ { "type": 0, - "value": "Contains" + "value": "Агуулах" } ], "label.continue": [ @@ -176,19 +176,19 @@ "label.country": [ { "type": 0, - "value": "Country" + "value": "Улс" } ], "label.create": [ { "type": 0, - "value": "Create" + "value": "Үүсгэх" } ], "label.create-report": [ { "type": 0, - "value": "Create report" + "value": "Тайлан үүсгэх" } ], "label.create-team": [ @@ -236,7 +236,7 @@ "label.date": [ { "type": 0, - "value": "Date" + "value": "Огноо" } ], "label.date-range": [ @@ -248,7 +248,7 @@ "label.day": [ { "type": 0, - "value": "Day" + "value": "Өдөр" } ], "label.default-date-range": [ @@ -284,7 +284,7 @@ "label.description": [ { "type": 0, - "value": "Description" + "value": "Тайлбар" } ], "label.desktop": [ @@ -302,7 +302,7 @@ "label.device": [ { "type": 0, - "value": "Device" + "value": "Төхөөрөмж" } ], "label.devices": [ @@ -320,7 +320,7 @@ "label.does-not-contain": [ { "type": 0, - "value": "Does not contain" + "value": "Агуулахгүй" } ], "label.domain": [ @@ -332,7 +332,7 @@ "label.dropoff": [ { "type": 0, - "value": "Dropoff" + "value": "Уналт" } ], "label.edit": [ @@ -356,13 +356,13 @@ "label.event": [ { "type": 0, - "value": "Event" + "value": "Үйлдэл" } ], "label.event-data": [ { "type": 0, - "value": "Event data" + "value": "Үйлдлийн өгөгдөл" } ], "label.events": [ @@ -374,25 +374,25 @@ "label.false": [ { "type": 0, - "value": "False" + "value": "Худал" } ], "label.field": [ { "type": 0, - "value": "Field" + "value": "Талбар" } ], "label.fields": [ { "type": 0, - "value": "Fields" + "value": "Талбар" } ], "label.filter": [ { "type": 0, - "value": "Filter" + "value": "Шүүлтүүр" } ], "label.filter-combined": [ @@ -410,67 +410,67 @@ "label.filters": [ { "type": 0, - "value": "Filters" + "value": "Шүүлтүүр" } ], "label.funnel": [ { "type": 0, - "value": "Funnel" + "value": "Цутгал" } ], "label.funnel-description": [ { "type": 0, - "value": "Understand the conversion and drop-off rate of users." + "value": "Хэрэглэгчдийн шилжилт, уналтын хэмжээг шижнлэх." } ], "label.greater-than": [ { "type": 0, - "value": "Greater than" + "value": "Их" } ], "label.greater-than-equals": [ { "type": 0, - "value": "Greater than or equals" + "value": "Их буюу тэнцүү" } ], "label.insights": [ { "type": 0, - "value": "Insights" + "value": "Шинжлэх" } ], "label.insights-description": [ { "type": 0, - "value": "Dive deeper into your data by using segments and filters." + "value": "Өгөгдлөө хэсэгчлэн хуваах, шүүх байдлаар задлах шинжлэх." } ], "label.is": [ { "type": 0, - "value": "Is" + "value": "Бол" } ], "label.is-not": [ { "type": 0, - "value": "Is not" + "value": "Биш" } ], "label.is-not-set": [ { "type": 0, - "value": "Is not set" + "value": "Утга оноогоогүй" } ], "label.is-set": [ { "type": 0, - "value": "Is set" + "value": "Утга оноосон" } ], "label.join": [ @@ -546,13 +546,13 @@ "label.less-than": [ { "type": 0, - "value": "Less than" + "value": "Бага" } ], "label.less-than-equals": [ { "type": 0, - "value": "Less than or equals" + "value": "Бага буюу тэнцүү" } ], "label.login": [ @@ -600,7 +600,7 @@ "label.my-websites": [ { "type": 0, - "value": "My websites" + "value": "Миний вебүүд" } ], "label.name": [ @@ -630,7 +630,7 @@ "label.overview": [ { "type": 0, - "value": "Overview" + "value": "Тойм" } ], "label.owner": [ @@ -642,19 +642,19 @@ "label.page-of": [ { "type": 0, - "value": "Page " - }, - { - "type": 1, - "value": "current" - }, - { - "type": 0, - "value": " of " + "value": "Хуудас " }, { "type": 1, "value": "total" + }, + { + "type": 0, + "value": "-с " + }, + { + "type": 1, + "value": "current" } ], "label.page-views": [ @@ -666,7 +666,7 @@ "label.pageTitle": [ { "type": 0, - "value": "Page title" + "value": "Хуудасны гарчиг" } ], "label.pages": [ @@ -724,7 +724,7 @@ "label.referrer": [ { "type": 0, - "value": "Referrer" + "value": "Чиглүүлэгч" } ], "label.referrers": [ @@ -748,7 +748,7 @@ "label.region": [ { "type": 0, - "value": "Region" + "value": "Бүс" } ], "label.regions": [ @@ -766,7 +766,7 @@ "label.reports": [ { "type": 0, - "value": "Reports" + "value": "Тайлан" } ], "label.required": [ @@ -790,13 +790,13 @@ "label.retention": [ { "type": 0, - "value": "Retention" + "value": "Барилт" } ], "label.retention-description": [ { "type": 0, - "value": "Measure your website stickiness by tracking how often users return." + "value": "Хэрэглэгчид таны веб рүү дахин хандах буюу хэрэглэгчидээ хэр тогтоож буйг хэмжих." } ], "label.role": [ @@ -808,7 +808,7 @@ "label.run-query": [ { "type": 0, - "value": "Run query" + "value": "Query ажиллуулах" } ], "label.save": [ @@ -826,13 +826,13 @@ "label.search": [ { "type": 0, - "value": "Search" + "value": "Хайх" } ], "label.select-date": [ { "type": 0, - "value": "Select date" + "value": "Огноо сонгох" } ], "label.select-website": [ @@ -868,7 +868,7 @@ "label.sum": [ { "type": 0, - "value": "Sum" + "value": "Нийлбэр" } ], "label.tablet": [ @@ -904,7 +904,7 @@ "label.team-name": [ { "type": 0, - "value": "Team name" + "value": "Багийн нэр" } ], "label.team-owner": [ @@ -916,7 +916,7 @@ "label.team-websites": [ { "type": 0, - "value": "Team websites" + "value": "Багийн вебүүд" } ], "label.teams": [ @@ -976,13 +976,13 @@ "label.total": [ { "type": 0, - "value": "Total" + "value": "Нийт" } ], "label.total-records": [ { "type": 0, - "value": "Total records" + "value": "Нийт мөриийн тоо" } ], "label.tracking-code": [ @@ -994,13 +994,13 @@ "label.true": [ { "type": 0, - "value": "True" + "value": "Үнэн" } ], "label.type": [ { "type": 0, - "value": "Type" + "value": "Төрөл" } ], "label.unique": [ @@ -1024,7 +1024,7 @@ "label.untitled": [ { "type": 0, - "value": "Untitled" + "value": "Гарчиггүй" } ], "label.url": [ @@ -1060,7 +1060,7 @@ "label.value": [ { "type": 0, - "value": "Value" + "value": "Утга" } ], "label.view": [ @@ -1078,7 +1078,7 @@ "label.view-only": [ { "type": 0, - "value": "View only" + "value": "Зөвхөн үзэх" } ], "label.views": [ @@ -1096,7 +1096,7 @@ "label.website": [ { "type": 0, - "value": "Website" + "value": "Веб" } ], "label.website-id": [ @@ -1114,7 +1114,7 @@ "label.window": [ { "type": 0, - "value": "Window" + "value": "Цонх" } ], "label.yesterday": [ @@ -1210,7 +1210,7 @@ "message.delete-account": [ { "type": 0, - "value": "To delete this account, type " + "value": "Энэ бүртгэлийг устгахын тулд доорх хэсэгт " }, { "type": 1, @@ -1218,13 +1218,13 @@ }, { "type": 0, - "value": " in the box below to confirm." + "value": " гэж бичиж баталгаажуулна уу." } ], "message.delete-website": [ { "type": 0, - "value": "To delete this website, type " + "value": "Энэ вебийг устгахын тулд доорх хэсэгт " }, { "type": 1, @@ -1232,7 +1232,7 @@ }, { "type": 0, - "value": " in the box below to confirm." + "value": " гэж бичиж баталгаажуулна уу." } ], "message.delete-website-warning": [ @@ -1296,7 +1296,7 @@ "message.new-version-available": [ { "type": 0, - "value": "A new version of Umami " + "value": "Umami-н шинэ хувилбар " }, { "type": 1, @@ -1304,7 +1304,7 @@ }, { "type": 0, - "value": " is available!" + "value": " гарсан байна!" } ], "message.no-data-available": [ @@ -1316,7 +1316,7 @@ "message.no-event-data": [ { "type": 0, - "value": "No event data is available." + "value": "Үйлдлийн өгөгдөл алга." } ], "message.no-match-password": [ @@ -1328,7 +1328,7 @@ "message.no-results-found": [ { "type": 0, - "value": "No results were found." + "value": "Ямар ч үр дүн олдсонгүй." } ], "message.no-team-websites": [ diff --git a/public/intl/messages/zh-CN.json b/public/intl/messages/zh-CN.json index acc98be2..6441e763 100644 --- a/public/intl/messages/zh-CN.json +++ b/public/intl/messages/zh-CN.json @@ -182,7 +182,7 @@ "label.create": [ { "type": 0, - "value": "Create" + "value": "创建" } ], "label.create-report": [ @@ -380,19 +380,19 @@ "label.field": [ { "type": 0, - "value": "Field" + "value": "字段" } ], "label.fields": [ { "type": 0, - "value": "Fields" + "value": "字段" } ], "label.filter": [ { "type": 0, - "value": "Filter" + "value": "筛选器" } ], "label.filter-combined": [ @@ -422,19 +422,19 @@ "label.funnel-description": [ { "type": 0, - "value": "Understand the conversion and drop-off rate of users." + "value": "了解用户的转换率和退出率。" } ], "label.greater-than": [ { "type": 0, - "value": "Greater than" + "value": "大于" } ], "label.greater-than-equals": [ { "type": 0, - "value": "Greater than or equals" + "value": "大于或等于" } ], "label.insights": [ @@ -446,7 +446,7 @@ "label.insights-description": [ { "type": 0, - "value": "Dive deeper into your data by using segments and filters." + "value": "通过使用筛选器和划分时间段来更深入地研究数据。" } ], "label.is": [ @@ -804,7 +804,7 @@ "label.retention-description": [ { "type": 0, - "value": "Measure your website stickiness by tracking how often users return." + "value": "通过跟踪用户返回的频率来衡量网站的用户粘性。" } ], "label.role": [ @@ -834,7 +834,7 @@ "label.search": [ { "type": 0, - "value": "Search" + "value": "搜索" } ], "label.select-date": [ diff --git a/public/intl/messages/zh-TW.json b/public/intl/messages/zh-TW.json index 49e43821..82f54a66 100644 --- a/public/intl/messages/zh-TW.json +++ b/public/intl/messages/zh-TW.json @@ -182,7 +182,7 @@ "label.create": [ { "type": 0, - "value": "Create" + "value": "建立" } ], "label.create-report": [ @@ -392,7 +392,7 @@ "label.filter": [ { "type": 0, - "value": "Filter" + "value": "篩選器" } ], "label.filter-combined": [ @@ -422,7 +422,7 @@ "label.funnel-description": [ { "type": 0, - "value": "Understand the conversion and drop-off rate of users." + "value": "瞭解使用者的轉換率和退出率" } ], "label.greater-than": [ @@ -446,7 +446,7 @@ "label.insights-description": [ { "type": 0, - "value": "Dive deeper into your data by using segments and filters." + "value": "透過使用區段和篩選器來深入探索你的數據" } ], "label.is": [ @@ -800,7 +800,7 @@ "label.retention-description": [ { "type": 0, - "value": "Measure your website stickiness by tracking how often users return." + "value": "透過追蹤使用者回訪的頻率來衡量您的網站黏著度。" } ], "label.role": [ diff --git a/public/manifest/manifest.webmanifest b/public/manifest/manifest.webmanifest deleted file mode 100644 index 3237da05..00000000 --- a/public/manifest/manifest.webmanifest +++ /dev/null @@ -1 +0,0 @@ -{"name":"analytics","short_name":"analytics","display":"standalone","start_url":"/","icons":[{"src":"/manifest/favicon-192.png","type":"image/png","sizes":"192x192"},{"src":"/manifest/favicon-512.png","type":"image/png","sizes":"512x512"}]} \ No newline at end of file diff --git a/public/manifest/site.webmanifest b/public/manifest/site.webmanifest new file mode 100644 index 00000000..769970cf --- /dev/null +++ b/public/manifest/site.webmanifest @@ -0,0 +1,10 @@ +{ + "name": "analytics", + "short_name": "analytics", + "display": "standalone", + "start_url": "/", + "icons": [ + { "src": "/manifest/favicon-192.png", "type": "image/png", "sizes": "192x192" }, + { "src": "/manifest/favicon-512.png", "type": "image/png", "sizes": "512x512" } + ] +} diff --git a/rollup.components.config.mjs b/rollup.components.config.mjs index c4481d0e..9be07390 100644 --- a/rollup.components.config.mjs +++ b/rollup.components.config.mjs @@ -19,6 +19,7 @@ const customResolver = resolve({ const aliasConfig = { entries: [ + { find: /^app/, replacement: path.resolve('./src/app') }, { find: /^components/, replacement: path.resolve('./src/components') }, { find: /^hooks/, replacement: path.resolve('./src/hooks') }, { find: /^lib/, replacement: path.resolve('./src/lib') }, diff --git a/scripts/start-env.js b/scripts/start-env.js index bfaf1330..e9fe2a4b 100644 --- a/scripts/start-env.js +++ b/scripts/start-env.js @@ -1,4 +1,8 @@ require('dotenv').config(); const cli = require('next/dist/cli/next-start'); -cli.nextStart(['-p', process.env.PORT || 3000, '-H', process.env.HOSTNAME || '0.0.0.0']); +cli.nextStart({ + '--port': process.env.PORT || 3000, + '--hostname': process.env.HOSTNAME || '0.0.0.0', + _: [], +}); diff --git a/src/app/(main)/App.tsx b/src/app/(main)/App.tsx new file mode 100644 index 00000000..4b093165 --- /dev/null +++ b/src/app/(main)/App.tsx @@ -0,0 +1,36 @@ +'use client'; +import { Loading } from 'react-basics'; +import Script from 'next/script'; +import { usePathname } from 'next/navigation'; +import { useLogin, useConfig } from 'components/hooks'; +import UpdateNotice from './UpdateNotice'; + +export function App({ children }) { + const { user, isLoading, error } = useLogin(); + const config = useConfig(); + const pathname = usePathname(); + + if (isLoading) { + return ; + } + + if (error) { + window.location.href = `${process.env.basePath || ''}/login`; + } + + if (!user || !config) { + return null; + } + + return ( + <> + {children} + + {process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && ( + `; diff --git a/src/components/pages/settings/websites/WebsiteData.js b/src/app/(main)/settings/websites/[id]/WebsiteData.tsx similarity index 81% rename from src/components/pages/settings/websites/WebsiteData.js rename to src/app/(main)/settings/websites/[id]/WebsiteData.tsx index 08d6702e..0ad3b559 100644 --- a/src/components/pages/settings/websites/WebsiteData.js +++ b/src/app/(main)/settings/websites/[id]/WebsiteData.tsx @@ -1,9 +1,15 @@ import { Button, Modal, ModalTrigger, ActionForm } from 'react-basics'; -import WebsiteDeleteForm from 'components/pages/settings/websites/WebsiteDeleteForm'; -import WebsiteResetForm from 'components/pages/settings/websites/WebsiteResetForm'; +import WebsiteDeleteForm from './WebsiteDeleteForm'; +import WebsiteResetForm from './WebsiteResetForm'; import useMessages from 'components/hooks/useMessages'; -export function WebsiteData({ websiteId, onSave }) { +export function WebsiteData({ + websiteId, + onSave, +}: { + websiteId: string; + onSave?: (value: string) => void; +}) { const { formatMessage, labels, messages } = useMessages(); const handleReset = async () => { @@ -23,7 +29,7 @@ export function WebsiteData({ websiteId, onSave }) { - {close => ( + {(close: () => void) => ( )} @@ -36,7 +42,7 @@ export function WebsiteData({ websiteId, onSave }) { - {close => ( + {(close: () => void) => ( )} diff --git a/src/components/pages/settings/websites/WebsiteDeleteForm.js b/src/app/(main)/settings/websites/[id]/WebsiteDeleteForm.tsx similarity index 72% rename from src/components/pages/settings/websites/WebsiteDeleteForm.js rename to src/app/(main)/settings/websites/[id]/WebsiteDeleteForm.tsx index 1548bddb..e0f71041 100644 --- a/src/components/pages/settings/websites/WebsiteDeleteForm.js +++ b/src/app/(main)/settings/websites/[id]/WebsiteDeleteForm.tsx @@ -9,15 +9,28 @@ import { } from 'react-basics'; import useApi from 'components/hooks/useApi'; import useMessages from 'components/hooks/useMessages'; +import { useContext } from 'react'; +import SettingsContext from '../../SettingsContext'; const CONFIRM_VALUE = 'DELETE'; -export function WebsiteDeleteForm({ websiteId, onSave, onClose }) { +export function WebsiteDeleteForm({ + websiteId, + onSave, + onClose, +}: { + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { const { formatMessage, labels, messages, FormattedMessage } = useMessages(); + const { websitesUrl } = useContext(SettingsContext); const { del, useMutation } = useApi(); - const { mutate, error } = useMutation(data => del(`/websites/${websiteId}`, data)); + const { mutate, error } = useMutation({ + mutationFn: (data: any) => del(`${websitesUrl}/${websiteId}`, data), + }); - const handleSubmit = async data => { + const handleSubmit = async (data: any) => { mutate(data, { onSuccess: async () => { onSave(); diff --git a/src/components/pages/settings/websites/WebsiteEditForm.js b/src/app/(main)/settings/websites/[id]/WebsiteEditForm.tsx similarity index 76% rename from src/components/pages/settings/websites/WebsiteEditForm.js rename to src/app/(main)/settings/websites/[id]/WebsiteEditForm.tsx index 18ad0ac9..80b36cae 100644 --- a/src/components/pages/settings/websites/WebsiteEditForm.js +++ b/src/app/(main)/settings/websites/[id]/WebsiteEditForm.tsx @@ -1,16 +1,28 @@ import { SubmitButton, Form, FormInput, FormRow, FormButtons, TextField } from 'react-basics'; -import { useRef } from 'react'; +import { useContext, useRef } from 'react'; import useApi from 'components/hooks/useApi'; import { DOMAIN_REGEX } from 'lib/constants'; import useMessages from 'components/hooks/useMessages'; +import SettingsContext from '../../SettingsContext'; -export function WebsiteEditForm({ websiteId, data, onSave }) { +export function WebsiteEditForm({ + websiteId, + data, + onSave, +}: { + websiteId: string; + data: any[]; + onSave?: (data: any) => void; +}) { const { formatMessage, labels, messages } = useMessages(); + const { websitesUrl } = useContext(SettingsContext); const { post, useMutation } = useApi(); - const { mutate, error } = useMutation(data => post(`/websites/${websiteId}`, data)); + const { mutate, error } = useMutation({ + mutationFn: (data: any) => post(`${websitesUrl}/${websiteId}`, data), + }); const ref = useRef(null); - const handleSubmit = async data => { + const handleSubmit = async (data: any) => { mutate(data, { onSuccess: async () => { ref.current.reset(data); diff --git a/src/components/pages/settings/websites/WebsiteResetForm.js b/src/app/(main)/settings/websites/[id]/WebsiteResetForm.tsx similarity index 71% rename from src/components/pages/settings/websites/WebsiteResetForm.js rename to src/app/(main)/settings/websites/[id]/WebsiteResetForm.tsx index 9886429b..0c02c77b 100644 --- a/src/components/pages/settings/websites/WebsiteResetForm.js +++ b/src/app/(main)/settings/websites/[id]/WebsiteResetForm.tsx @@ -9,15 +9,28 @@ import { } from 'react-basics'; import useApi from 'components/hooks/useApi'; import useMessages from 'components/hooks/useMessages'; +import { useContext } from 'react'; +import SettingsContext from '../../SettingsContext'; const CONFIRM_VALUE = 'RESET'; -export function WebsiteResetForm({ websiteId, onSave, onClose }) { +export function WebsiteResetForm({ + websiteId, + onSave, + onClose, +}: { + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { const { formatMessage, labels, messages, FormattedMessage } = useMessages(); + const { websitesUrl } = useContext(SettingsContext); const { post, useMutation } = useApi(); - const { mutate, error } = useMutation(data => post(`/websites/${websiteId}/reset`, data)); + const { mutate, error } = useMutation({ + mutationFn: (data: any) => post(`${websitesUrl}/${websiteId}/reset`, data), + }); - const handleSubmit = async data => { + const handleSubmit = async (data: any) => { mutate(data, { onSuccess: async () => { onSave(); diff --git a/src/app/(main)/settings/websites/[id]/page.tsx b/src/app/(main)/settings/websites/[id]/page.tsx new file mode 100644 index 00000000..37324659 --- /dev/null +++ b/src/app/(main)/settings/websites/[id]/page.tsx @@ -0,0 +1,9 @@ +import WebsiteSettings from '../WebsiteSettings'; + +export default async function WebsiteSettingsPage({ params: { id } }) { + if (process.env.cloudMode) { + return null; + } + + return ; +} diff --git a/src/app/(main)/settings/websites/page.tsx b/src/app/(main)/settings/websites/page.tsx new file mode 100644 index 00000000..d6d11898 --- /dev/null +++ b/src/app/(main)/settings/websites/page.tsx @@ -0,0 +1,10 @@ +import { Metadata } from 'next'; +import Websites from './Websites'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Websites Settings | umami', +}; diff --git a/src/app/(main)/websites/WebsitesBrowse.tsx b/src/app/(main)/websites/WebsitesBrowse.tsx new file mode 100644 index 00000000..c426cc06 --- /dev/null +++ b/src/app/(main)/websites/WebsitesBrowse.tsx @@ -0,0 +1,37 @@ +'use client'; +import WebsitesDataTable from '../settings/websites/WebsitesDataTable'; +import { useMessages, useUser } from 'components/hooks'; +import { useState } from 'react'; +import { Item, Tabs } from 'react-basics'; + +const TABS = { + myWebsites: 'my-websites', + teamWebsites: 'team-websites', +}; + +export function WebsitesBrowse() { + const { user } = useUser(); + const { formatMessage, labels } = useMessages(); + const [tab, setTab] = useState(TABS.myWebsites); + const allowEdit = !process.env.cloudMode; + + return ( + <> + setTab(tab)} style={{ marginBottom: 30 }}> + {formatMessage(labels.myWebsites)} + {formatMessage(labels.teamWebsites)} + + {tab === TABS.myWebsites && } + {tab === TABS.teamWebsites && ( + + )} + + ); +} + +export default WebsitesBrowse; diff --git a/src/components/pages/websites/WebsiteChart.module.css b/src/app/(main)/websites/[id]/WebsiteChart.module.css similarity index 100% rename from src/components/pages/websites/WebsiteChart.module.css rename to src/app/(main)/websites/[id]/WebsiteChart.module.css diff --git a/src/components/pages/websites/WebsiteChart.js b/src/app/(main)/websites/[id]/WebsiteChart.tsx similarity index 77% rename from src/components/pages/websites/WebsiteChart.js rename to src/app/(main)/websites/[id]/WebsiteChart.tsx index 7e20e785..eba155c1 100644 --- a/src/components/pages/websites/WebsiteChart.js +++ b/src/app/(main)/websites/[id]/WebsiteChart.tsx @@ -1,23 +1,23 @@ import { useMemo } from 'react'; import PageviewsChart from 'components/metrics/PageviewsChart'; -import { useApi, useDateRange, useTimezone, usePageQuery } from 'components/hooks'; +import { useApi, useDateRange, useTimezone, useNavigation } from 'components/hooks'; import { getDateArray } from 'lib/date'; -export function WebsiteChart({ websiteId }) { +export function WebsiteChart({ websiteId }: { websiteId: string }) { const [dateRange] = useDateRange(websiteId); const { startDate, endDate, unit, modified } = dateRange; const [timezone] = useTimezone(); const { query: { url, referrer, os, browser, device, country, region, city, title }, - } = usePageQuery(); + } = useNavigation(); const { get, useQuery } = useApi(); - const { data, isLoading } = useQuery( - [ + const { data, isLoading } = useQuery({ + queryKey: [ 'websites:pageviews', { websiteId, modified, url, referrer, os, browser, device, country, region, city, title }, ], - () => + queryFn: () => get(`/websites/${websiteId}/pageviews`, { startAt: +startDate, endAt: +endDate, @@ -33,7 +33,7 @@ export function WebsiteChart({ websiteId }) { city, title, }), - ); + }); const chartData = useMemo(() => { if (data) { @@ -45,7 +45,7 @@ export function WebsiteChart({ websiteId }) { return { pageviews: [], sessions: [] }; }, [data, startDate, endDate, unit]); - return ; + return ; } export default WebsiteChart; diff --git a/src/components/pages/websites/WebsiteChartList.js b/src/app/(main)/websites/[id]/WebsiteChartList.tsx similarity index 80% rename from src/components/pages/websites/WebsiteChartList.js rename to src/app/(main)/websites/[id]/WebsiteChartList.tsx index 56cbe157..b35b6f1f 100644 --- a/src/components/pages/websites/WebsiteChartList.js +++ b/src/app/(main)/websites/[id]/WebsiteChartList.tsx @@ -1,16 +1,22 @@ -import { Button, Text, Icon } from 'react-basics'; +import { Button, Text, Icon, Icons } from 'react-basics'; import { useMemo } from 'react'; import { firstBy } from 'thenby'; import Link from 'next/link'; -import WebsiteChart from 'components/pages/websites/WebsiteChart'; +import WebsiteChart from './WebsiteChart'; import useDashboard from 'store/dashboard'; -import styles from './WebsiteList.module.css'; import WebsiteHeader from './WebsiteHeader'; import { WebsiteMetricsBar } from './WebsiteMetricsBar'; import { useMessages, useLocale } from 'components/hooks'; -import Icons from 'components/icons'; -export default function WebsiteChartList({ websites, showCharts, limit }) { +export default function WebsiteChartList({ + websites, + showCharts, + limit, +}: { + websites: any[]; + showCharts?: boolean; + limit?: number; +}) { const { formatMessage, labels } = useMessages(); const { websiteOrder } = useDashboard(); const { dir } = useLocale(); @@ -27,7 +33,7 @@ export default function WebsiteChartList({ websites, showCharts, limit }) {
{ordered.map(({ id }, index) => { return index < limit ? ( -
+
- - - - - +
+
+ + + + + {formatMessage(labels.back)} + + + + {({ key, label }) => {label}} + +
+
- - +
+
); } diff --git a/src/app/(main)/websites/[id]/WebsiteFilterButton.tsx b/src/app/(main)/websites/[id]/WebsiteFilterButton.tsx new file mode 100644 index 00000000..6a02cd47 --- /dev/null +++ b/src/app/(main)/websites/[id]/WebsiteFilterButton.tsx @@ -0,0 +1,60 @@ +import { Button, Icon, Icons, Popup, PopupTrigger, Text } from 'react-basics'; +import PopupForm from 'app/(main)/reports/[id]/PopupForm'; +import FilterSelectForm from 'app/(main)/reports/[id]/FilterSelectForm'; +import { useMessages, useNavigation } from 'components/hooks'; + +export function WebsiteFilterButton({ + websiteId, + className, +}: { + websiteId: string; + className?: string; +}) { + const { formatMessage, labels } = useMessages(); + const { makeUrl, router } = useNavigation(); + + const fieldOptions = [ + { name: 'url', type: 'string', label: formatMessage(labels.url) }, + { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, + { name: 'browser', type: 'string', label: formatMessage(labels.browser) }, + { name: 'os', type: 'string', label: formatMessage(labels.os) }, + { name: 'device', type: 'string', label: formatMessage(labels.device) }, + { name: 'country', type: 'string', label: formatMessage(labels.country) }, + { name: 'region', type: 'string', label: formatMessage(labels.region) }, + { name: 'city', type: 'string', label: formatMessage(labels.city) }, + ]; + + const handleAddFilter = ({ name, value }) => { + router.push(makeUrl({ [name]: value })); + }; + + return ( + + + + {(close: () => void) => { + return ( + + { + handleAddFilter(value); + close(); + }} + allowFilterSelect={false} + /> + + ); + }} + + + ); +} + +export default WebsiteFilterButton; diff --git a/src/components/pages/websites/WebsiteHeader.module.css b/src/app/(main)/websites/[id]/WebsiteHeader.module.css similarity index 83% rename from src/components/pages/websites/WebsiteHeader.module.css rename to src/app/(main)/websites/[id]/WebsiteHeader.module.css index 93e622d9..3e58c8a3 100644 --- a/src/components/pages/websites/WebsiteHeader.module.css +++ b/src/app/(main)/websites/[id]/WebsiteHeader.module.css @@ -1,6 +1,6 @@ .header { - display: flex; - flex-direction: row; + display: grid; + grid-template-columns: 1fr max-content; align-items: center; } @@ -35,6 +35,10 @@ } @media only screen and (max-width: 768px) { + .header { + grid-template-columns: 1fr; + } + .links { justify-content: space-evenly; flex: 1; @@ -49,7 +53,7 @@ .icon, .icon svg { - width: 30px; - height: 30px; + width: 20px; + height: 20px; } } diff --git a/src/components/pages/websites/WebsiteHeader.js b/src/app/(main)/websites/[id]/WebsiteHeader.tsx similarity index 74% rename from src/components/pages/websites/WebsiteHeader.js rename to src/app/(main)/websites/[id]/WebsiteHeader.tsx index fb4e0986..0af9e596 100644 --- a/src/components/pages/websites/WebsiteHeader.js +++ b/src/app/(main)/websites/[id]/WebsiteHeader.tsx @@ -1,16 +1,26 @@ +'use client'; +import { ReactNode } from 'react'; import classNames from 'classnames'; -import { Row, Column, Text, Button, Icon } from 'react-basics'; +import { Text, Button, Icon } from 'react-basics'; import Link from 'next/link'; -import { useRouter } from 'next/router'; +import { usePathname } from 'next/navigation'; import Favicon from 'components/common/Favicon'; import ActiveUsers from 'components/metrics/ActiveUsers'; import Icons from 'components/icons'; import { useMessages, useWebsite } from 'components/hooks'; import styles from './WebsiteHeader.module.css'; -export function WebsiteHeader({ websiteId, showLinks = true, children }) { +export function WebsiteHeader({ + websiteId, + showLinks = true, + children, +}: { + websiteId: string; + showLinks?: boolean; + children?: ReactNode; +}) { const { formatMessage, labels } = useMessages(); - const { pathname } = useRouter(); + const pathname = usePathname(); const { data: website } = useWebsite(websiteId); const { name, domain } = website || {}; @@ -38,17 +48,19 @@ export function WebsiteHeader({ websiteId, showLinks = true, children }) { ]; return ( - - +
+
{name} - - +
+
{showLinks && (
{links.map(({ label, icon, path }) => { - const selected = path ? pathname.endsWith(path) : pathname === '/websites/[id]'; + const selected = path + ? pathname.endsWith(path) + : pathname.match(/^\/websites\/[\w-]+$/); return ( @@ -67,8 +79,8 @@ export function WebsiteHeader({ websiteId, showLinks = true, children }) {
)} {children} - - +
+
); } diff --git a/src/app/(main)/websites/[id]/WebsiteMetricsBar.module.css b/src/app/(main)/websites/[id]/WebsiteMetricsBar.module.css new file mode 100644 index 00000000..db48bd55 --- /dev/null +++ b/src/app/(main)/websites/[id]/WebsiteMetricsBar.module.css @@ -0,0 +1,46 @@ +.container { + display: grid; + grid-template-columns: 1fr max-content; + justify-content: space-between; + align-items: center; + background: var(--base50); + z-index: var(--z-index-above); + min-height: 120px; + padding-bottom: 20px; +} + +.actions { + display: flex; + align-items: center; + flex-direction: row; + justify-content: flex-end; + gap: 10px; +} + +@media screen and (max-width: 1200px) { + .container { + grid-template-columns: 1fr; + } + + .actions { + margin: 20px 0; + } +} + +@media screen and (min-width: 992px) { + .sticky { + position: sticky; + top: -1px; + } + + .isSticky { + padding: 10px 0; + border-bottom: 1px solid var(--base300); + } +} + +@media screen and (max-width: 768px) { + .button { + display: none; + } +} diff --git a/src/app/(main)/websites/[id]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[id]/WebsiteMetricsBar.tsx new file mode 100644 index 00000000..e40dd11b --- /dev/null +++ b/src/app/(main)/websites/[id]/WebsiteMetricsBar.tsx @@ -0,0 +1,120 @@ +import classNames from 'classnames'; +import { useApi, useDateRange, useMessages, useNavigation, useSticky } from 'components/hooks'; +import WebsiteDateFilter from 'components/input/WebsiteDateFilter'; +import MetricCard from 'components/metrics/MetricCard'; +import MetricsBar from 'components/metrics/MetricsBar'; +import { formatShortTime } from 'lib/format'; +import WebsiteFilterButton from './WebsiteFilterButton'; +import styles from './WebsiteMetricsBar.module.css'; + +export function WebsiteMetricsBar({ + websiteId, + showFilter = true, + sticky, +}: { + websiteId: string; + showFilter?: boolean; + sticky?: boolean; +}) { + 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 }, + } = useNavigation(); + + const { data, error, isLoading, isFetched } = useQuery({ + queryKey: [ + 'websites:stats', + { websiteId, modified, url, referrer, title, os, browser, device, country, region, city }, + ], + queryFn: () => + 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 ( +
+ + {pageviews && uniques && ( + <> + + + Number(n).toFixed(0) + '%'} + reverseColors + /> + `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`} + /> + + )} + +
+ {showFilter && } + +
+
+ ); +} + +export default WebsiteMetricsBar; diff --git a/src/app/(main)/websites/[id]/WebsiteTableView.tsx b/src/app/(main)/websites/[id]/WebsiteTableView.tsx new file mode 100644 index 00000000..7cc415e5 --- /dev/null +++ b/src/app/(main)/websites/[id]/WebsiteTableView.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { Grid, GridRow } 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/metrics/WorldMap'; +import CountriesTable from 'components/metrics/CountriesTable'; +import EventsTable from 'components/metrics/EventsTable'; +import EventsChart from 'components/metrics/EventsChart'; + +export default function WebsiteTableView({ + websiteId, + domainName, +}: { + websiteId: string; + domainName: string; +}) { + const [countryData, setCountryData] = useState(); + const tableProps = { + websiteId, + domainName, + limit: 10, + }; + + return ( + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/pages/websites/WebsiteMetricsBar.module.css b/src/app/(main)/websites/[id]/event-data/EventDataMetricsBar.module.css similarity index 53% rename from src/components/pages/websites/WebsiteMetricsBar.module.css rename to src/app/(main)/websites/[id]/event-data/EventDataMetricsBar.module.css index 52decfc6..408396c3 100644 --- a/src/components/pages/websites/WebsiteMetricsBar.module.css +++ b/src/app/(main)/websites/[id]/event-data/EventDataMetricsBar.module.css @@ -1,5 +1,6 @@ .container { - display: flex; + display: grid; + grid-template-columns: 1fr 1fr; justify-content: space-between; align-items: center; padding: 10px 0; @@ -11,25 +12,15 @@ .actions { display: flex; - align-items: center; flex-direction: row; + align-items: center; justify-content: flex-end; - gap: 10px; + flex: 1; } -@media only screen and (max-width: 1200px) { - .actions { - margin-top: 40px; - } -} - -@media only screen and (min-width: 992px) { - .sticky { - position: sticky; - top: -1px; - } - - .isSticky { - border-bottom: 1px solid var(--base300); +@media only screen and (max-width: 992px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; } } diff --git a/src/app/(main)/websites/[id]/event-data/EventDataMetricsBar.tsx b/src/app/(main)/websites/[id]/event-data/EventDataMetricsBar.tsx new file mode 100644 index 00000000..419472d5 --- /dev/null +++ b/src/app/(main)/websites/[id]/event-data/EventDataMetricsBar.tsx @@ -0,0 +1,38 @@ +import { useApi, useDateRange } from 'components/hooks'; +import MetricCard from 'components/metrics/MetricCard'; +import useMessages from 'components/hooks/useMessages'; +import WebsiteDateFilter from 'components/input/WebsiteDateFilter'; +import MetricsBar from 'components/metrics/MetricsBar'; +import styles from './EventDataMetricsBar.module.css'; + +export function EventDataMetricsBar({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { get, useQuery } = useApi(); + const [dateRange] = useDateRange(websiteId); + const { startDate, endDate, modified } = dateRange; + + const { data, error, isLoading, isFetched } = useQuery({ + queryKey: ['event-data:stats', { websiteId, startDate, endDate, modified }], + queryFn: () => + get(`/event-data/stats`, { + websiteId, + startAt: +startDate, + endAt: +endDate, + }), + }); + + return ( +
+ + + + + +
+ +
+
+ ); +} + +export default EventDataMetricsBar; diff --git a/src/components/pages/event-data/EventDataTable.js b/src/app/(main)/websites/[id]/event-data/EventDataTable.tsx similarity index 84% rename from src/components/pages/event-data/EventDataTable.js rename to src/app/(main)/websites/[id]/event-data/EventDataTable.tsx index c79916ce..fb98e7e7 100644 --- a/src/components/pages/event-data/EventDataTable.js +++ b/src/app/(main)/websites/[id]/event-data/EventDataTable.tsx @@ -1,12 +1,12 @@ import Link from 'next/link'; import { GridTable, GridColumn } from 'react-basics'; -import { useMessages, usePageQuery } from 'components/hooks'; +import { useMessages, useNavigation } from 'components/hooks'; import Empty from 'components/common/Empty'; import { DATA_TYPES } from 'lib/constants'; export function EventDataTable({ data = [] }) { const { formatMessage, labels } = useMessages(); - const { resolveUrl } = usePageQuery(); + const { makeUrl } = useNavigation(); if (data.length === 0) { return ; @@ -16,7 +16,7 @@ export function EventDataTable({ data = [] }) { {row => ( - + {row.eventName} )} diff --git a/src/components/pages/event-data/EventDataValueTable.js b/src/app/(main)/websites/[id]/event-data/EventDataValueTable.tsx similarity index 84% rename from src/components/pages/event-data/EventDataValueTable.js rename to src/app/(main)/websites/[id]/event-data/EventDataValueTable.tsx index 75c11e32..7976ce36 100644 --- a/src/components/pages/event-data/EventDataValueTable.js +++ b/src/app/(main)/websites/[id]/event-data/EventDataValueTable.tsx @@ -1,19 +1,19 @@ import { GridTable, GridColumn, Button, Icon, Text } from 'react-basics'; -import { useMessages, usePageQuery } from 'components/hooks'; +import { useMessages, useNavigation } from 'components/hooks'; import Link from 'next/link'; import Icons from 'components/icons'; import PageHeader from 'components/layout/PageHeader'; import Empty from 'components/common/Empty'; import { DATA_TYPES } from 'lib/constants'; -export function EventDataValueTable({ data = [], event }) { +export function EventDataValueTable({ data = [], event }: { data: any[]; event: string }) { const { formatMessage, labels } = useMessages(); - const { resolveUrl } = usePageQuery(); + const { makeUrl } = useNavigation(); const Title = () => { return ( <> - + + + + + + ); +} + +export default WebsiteReports; diff --git a/src/app/(main)/websites/[id]/reports/page.tsx b/src/app/(main)/websites/[id]/reports/page.tsx new file mode 100644 index 00000000..bf564025 --- /dev/null +++ b/src/app/(main)/websites/[id]/reports/page.tsx @@ -0,0 +1,9 @@ +import WebsiteReports from './WebsiteReports'; + +export default function WebsiteReportsPage({ params: { id } }) { + if (!id) { + return null; + } + + return ; +} diff --git a/src/app/(main)/websites/page.tsx b/src/app/(main)/websites/page.tsx new file mode 100644 index 00000000..a1542510 --- /dev/null +++ b/src/app/(main)/websites/page.tsx @@ -0,0 +1,16 @@ +import WebsitesHeader from 'app/(main)/settings/websites/WebsitesHeader'; +import WebsitesBrowse from './WebsitesBrowse'; +import { Metadata } from 'next'; + +export default function WebsitesPage() { + return ( + <> + + + + ); +} + +export const metadata: Metadata = { + title: 'Websites | umami', +}; diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx new file mode 100644 index 00000000..f1460b05 --- /dev/null +++ b/src/app/Providers.tsx @@ -0,0 +1,61 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { IntlProvider } from 'react-intl'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactBasicsProvider } from 'react-basics'; +import ErrorBoundary from 'components/common/ErrorBoundary'; +import SettingsContext from 'app/(main)/settings/SettingsContext'; +import useLocale from 'components/hooks/useLocale'; +import 'chartjs-adapter-date-fns'; + +const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +function MessagesProvider({ children }) { + const { locale, messages } = useLocale(); + return ( + null}> + {children} + + ); +} + +function SettingsProvider({ children }) { + const [config, setConfig] = useState({}); + + useEffect(() => { + const hostUrl = process.env.hostUrl || window?.location.origin; + + setConfig({ + shareUrl: hostUrl, + trackingCodeUrl: hostUrl, + websitesUrl: '/websites', + settingsPath: '/settings/websites', + websitesPath: `/websites`, + }); + }, []); + + return {children}; +} + +export function Providers({ children }) { + return ( + + + + + {children} + + + + + ); +} + +export default Providers; diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000..f9d14f7d --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,34 @@ +import { Metadata } from 'next'; +import Providers from './Providers'; +import '@fontsource/inter/400.css'; +import '@fontsource/inter/700.css'; +import '@fontsource/inter/800.css'; +import 'react-basics/dist/styles.css'; +import 'styles/locale.css'; +import 'styles/index.css'; +import 'styles/variables.css'; + +export default function ({ children }) { + return ( + + + + + + {/* */} + + + + + + + + {children} + + + ); +} + +export const metadata: Metadata = { + title: 'umami', +}; diff --git a/src/components/pages/login/LoginForm.module.css b/src/app/login/LoginForm.module.css similarity index 100% rename from src/components/pages/login/LoginForm.module.css rename to src/app/login/LoginForm.module.css diff --git a/src/components/pages/login/LoginForm.js b/src/app/login/LoginForm.tsx similarity index 87% rename from src/components/pages/login/LoginForm.js rename to src/app/login/LoginForm.tsx index 797eea14..78cf3dd3 100644 --- a/src/components/pages/login/LoginForm.js +++ b/src/app/login/LoginForm.tsx @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; +'use client'; import { Form, FormRow, @@ -9,7 +9,7 @@ import { SubmitButton, Icon, } from 'react-basics'; -import { useRouter } from 'next/router'; +import { useRouter } from 'next/navigation'; import useApi from 'components/hooks/useApi'; import { setUser } from 'store/app'; import { setClientAuthToken } from 'lib/client'; @@ -20,8 +20,10 @@ import styles from './LoginForm.module.css'; export function LoginForm() { const { formatMessage, labels, getMessage } = useMessages(); const router = useRouter(); - const { post } = useApi(); - const { mutate, error, isLoading } = useMutation(data => post('/auth/login', data)); + const { post, useMutation } = useApi(); + const { mutate, error, isPending } = useMutation({ + mutationFn: (data: any) => post('/auth/login', data), + }); const handleSubmit = async data => { mutate(data, { @@ -52,7 +54,7 @@ export function LoginForm() { - + {formatMessage(labels.login)} diff --git a/src/components/pages/login/LoginLayout.module.css b/src/app/login/page.module.css similarity index 76% rename from src/components/pages/login/LoginLayout.module.css rename to src/app/login/page.module.css index d12306ea..45115d5b 100644 --- a/src/components/pages/login/LoginLayout.module.css +++ b/src/app/login/page.module.css @@ -1,6 +1,5 @@ -.layout { +.page { display: flex; - flex-direction: column; align-items: center; justify-content: center; height: 100vh; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 00000000..2ac3f724 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,25 @@ +import LoginForm from './LoginForm'; +import { Metadata } from 'next'; +import styles from './page.module.css'; + +async function getDisabled() { + return !!process.env.LOGIN_DISABLED; +} + +export default async function LoginPage() { + const disabled = await getDisabled(); + + if (disabled) { + return null; + } + + return ( +
+ +
+ ); +} + +export const metadata: Metadata = { + title: 'Login | umami', +}; diff --git a/src/pages/logout.js b/src/app/logout/Logout.tsx similarity index 68% rename from src/pages/logout.js rename to src/app/logout/Logout.tsx index ef89080c..e9da0373 100644 --- a/src/pages/logout.js +++ b/src/app/logout/Logout.tsx @@ -1,10 +1,12 @@ +'use client'; import { useEffect } from 'react'; -import { useRouter } from 'next/router'; +import { useRouter } from 'next/navigation'; import useApi from 'components/hooks/useApi'; import { setUser } from 'store/app'; import { removeClientAuthToken } from 'lib/client'; -export default function ({ disabled }) { +export function Logout() { + const disabled = !!(process.env.disableLogin || process.env.cloudMode); const router = useRouter(); const { post } = useApi(); @@ -27,10 +29,4 @@ export default function ({ disabled }) { return null; } -export async function getServerSideProps() { - return { - props: { - disabled: !!(process.env.DISABLE_LOGIN || process.env.CLOUD_MODE), - }, - }; -} +export default Logout; diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx new file mode 100644 index 00000000..89a3bce9 --- /dev/null +++ b/src/app/logout/page.tsx @@ -0,0 +1,10 @@ +import Logout from './Logout'; +import { Metadata } from 'next'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Logout | umami', +}; diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 00000000..16c5bbcb --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,13 @@ +'use client'; +import { Flexbox } from 'react-basics'; +import useMessages from 'components/hooks/useMessages'; + +export default function () { + const { formatMessage, labels } = useMessages(); + + return ( + +

{formatMessage(labels.pageNotFound)}

+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 00000000..6a146801 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,6 @@ +'use client'; +import { redirect } from 'next/navigation'; + +export default function RootPage() { + redirect('/dashboard'); +} diff --git a/src/components/layout/Footer.module.css b/src/app/share/[...id]/Footer.module.css similarity index 80% rename from src/components/layout/Footer.module.css rename to src/app/share/[...id]/Footer.module.css index 348c92d8..5dc2d584 100644 --- a/src/components/layout/Footer.module.css +++ b/src/app/share/[...id]/Footer.module.css @@ -1,10 +1,10 @@ .footer { display: flex; flex-direction: row; + align-items: center; justify-content: flex-end; font-size: var(--font-size-sm); - line-height: 30px; - margin: 40px 0; + height: 100px; } .footer a { diff --git a/src/components/layout/Footer.js b/src/app/share/[...id]/Footer.tsx similarity index 95% rename from src/components/layout/Footer.js rename to src/app/share/[...id]/Footer.tsx index 3a07c12a..84d4162f 100644 --- a/src/components/layout/Footer.js +++ b/src/app/share/[...id]/Footer.tsx @@ -1,3 +1,4 @@ +'use client'; import { CURRENT_VERSION, HOMEPAGE_URL } from 'lib/constants'; import styles from './Footer.module.css'; diff --git a/src/components/layout/Header.module.css b/src/app/share/[...id]/Header.module.css similarity index 86% rename from src/components/layout/Header.module.css rename to src/app/share/[...id]/Header.module.css index 26f30552..d353d79a 100644 --- a/src/components/layout/Header.module.css +++ b/src/app/share/[...id]/Header.module.css @@ -2,6 +2,7 @@ display: flex; flex-direction: row; align-items: center; + justify-content: space-between; width: 100%; height: 100px; } @@ -38,10 +39,3 @@ min-width: 100%; } } - -@media only screen and (max-width: 768px) { - .buttons, - .links { - display: none; - } -} diff --git a/src/app/share/[...id]/Header.tsx b/src/app/share/[...id]/Header.tsx new file mode 100644 index 00000000..2b82908d --- /dev/null +++ b/src/app/share/[...id]/Header.tsx @@ -0,0 +1,30 @@ +'use client'; +import { Icon, 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 ( +
+
+ + + + + umami + +
+
+ + + +
+
+ ); +} + +export default Header; diff --git a/src/app/share/[...id]/Share.module.css b/src/app/share/[...id]/Share.module.css new file mode 100644 index 00000000..d985435c --- /dev/null +++ b/src/app/share/[...id]/Share.module.css @@ -0,0 +1,4 @@ +.container { + flex: 1; + min-height: calc(100vh - 200px); +} diff --git a/src/app/share/[...id]/Share.tsx b/src/app/share/[...id]/Share.tsx new file mode 100644 index 00000000..99ba6407 --- /dev/null +++ b/src/app/share/[...id]/Share.tsx @@ -0,0 +1,25 @@ +'use client'; +import WebsiteDetails from 'app/(main)/websites/[id]/WebsiteDetails'; +import useShareToken from 'components/hooks/useShareToken'; +import styles from './Share.module.css'; +import Page from 'components/layout/Page'; +import Header from './Header'; +import Footer from './Footer'; + +export default function Share({ shareId }) { + const { shareToken, isLoading } = useShareToken(shareId); + + if (isLoading || !shareToken) { + return null; + } + + return ( +
+ +
+ +
+ +
+ ); +} diff --git a/src/app/share/[...id]/page.tsx b/src/app/share/[...id]/page.tsx new file mode 100644 index 00000000..2a69f406 --- /dev/null +++ b/src/app/share/[...id]/page.tsx @@ -0,0 +1,10 @@ +import Share from './Share'; +import { Metadata } from 'next'; + +export default function ({ params: { id } }) { + return ; +} + +export const metadata: Metadata = { + title: 'umami', +}; diff --git a/src/pages/sso.js b/src/app/sso/page.tsx similarity index 55% rename from src/pages/sso.js rename to src/app/sso/page.tsx index 6e635206..e577767a 100644 --- a/src/pages/sso.js +++ b/src/app/sso/page.tsx @@ -1,11 +1,14 @@ +'use client'; import { useEffect } from 'react'; import { Loading } from 'react-basics'; -import { useRouter } from 'next/router'; +import { useRouter, useSearchParams } from 'next/navigation'; import { setClientAuthToken } from 'lib/client'; -export default function () { +export default function SSOPage() { const router = useRouter(); - const { token, url } = router.query; + const search = useSearchParams(); + const url = search.get('url'); + const token = search.get('token'); useEffect(() => { if (url && token) { @@ -15,5 +18,5 @@ export default function () { } }, [router, url, token]); - return ; + return ; } diff --git a/src/components/common/ConfirmDeleteForm.js b/src/components/common/ConfirmDeleteForm.tsx similarity index 80% rename from src/components/common/ConfirmDeleteForm.js rename to src/components/common/ConfirmDeleteForm.tsx index 3d2c383d..d4cbf203 100644 --- a/src/components/common/ConfirmDeleteForm.js +++ b/src/components/common/ConfirmDeleteForm.tsx @@ -2,7 +2,13 @@ import { useState } from 'react'; import { Button, LoadingButton, Form, FormButtons } from 'react-basics'; import useMessages from 'components/hooks/useMessages'; -export function ConfirmDeleteForm({ name, onConfirm, onClose }) { +export interface ConfirmDeleteFormProps { + name: string; + onConfirm?: () => void; + onClose?: () => void; +} + +export function ConfirmDeleteForm({ name, onConfirm, onClose }: ConfirmDeleteFormProps) { const [loading, setLoading] = useState(false); const { formatMessage, labels, messages, FormattedMessage } = useMessages(); diff --git a/src/components/common/DataTable.module.css b/src/components/common/DataTable.module.css new file mode 100644 index 00000000..e738c895 --- /dev/null +++ b/src/components/common/DataTable.module.css @@ -0,0 +1,52 @@ +.table { + grid-template-rows: repeat(auto-fit, max-content); +} + +.table td { + align-items: center; + max-height: max-content; +} + +.search { + max-width: 300px; + margin: 20px 0; +} + +.action { + justify-content: flex-end; + gap: 5px; +} + +.body { + display: flex; + flex-direction: column; + position: relative; + overflow-x: auto; +} + +.body td { + display: flex; + gap: 10px; + min-height: 70px; + align-items: center; + min-width: min-content; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.body > div > div > div { + display: flex; + gap: 10px; +} + +.pager { + margin: 20px 0; +} + +.status { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; +} diff --git a/src/components/common/DataTable.tsx b/src/components/common/DataTable.tsx new file mode 100644 index 00000000..00aba09c --- /dev/null +++ b/src/components/common/DataTable.tsx @@ -0,0 +1,84 @@ +import { ReactNode } from 'react'; +import classNames from 'classnames'; +import { Banner, Loading, SearchField } from 'react-basics'; +import { useMessages } from 'components/hooks'; +import Empty from 'components/common/Empty'; +import Pager from 'components/common/Pager'; +import styles from './DataTable.module.css'; +import { FilterQueryResult } from 'components/hooks/useFilterQuery'; + +const DEFAULT_SEARCH_DELAY = 600; + +export interface DataTableProps { + queryResult: FilterQueryResult; + searchDelay?: number; + allowSearch?: boolean; + allowPaging?: boolean; + children: ReactNode | ((data: any) => ReactNode); +} + +export function DataTable({ + queryResult, + searchDelay = 600, + allowSearch = true, + allowPaging = true, + children, +}: DataTableProps) { + const { formatMessage, labels, messages } = useMessages(); + const { + result, + params, + setParams, + query: { error, isLoading }, + } = queryResult || {}; + const { page, pageSize, count, data } = result || {}; + const { query } = params || {}; + const hasData = Boolean(!isLoading && data?.length); + const noResults = Boolean(!isLoading && query && !hasData); + + const handleSearch = (query: string) => { + setParams({ ...params, query, page: params.page ? page : 1 }); + }; + + const handlePageChange = (page: number) => { + setParams({ ...params, query, page }); + }; + + if (error) { + return {formatMessage(messages.error)}; + } + + return ( + <> + {allowSearch && (hasData || query) && ( + + )} +
+ {hasData ? (typeof children === 'function' ? children(result) : children) : null} + {isLoading && } + {!isLoading && !hasData && !query && } + {noResults && } +
+ {allowPaging && hasData && ( + + )} + + ); +} + +export default DataTable; diff --git a/src/components/common/Empty.js b/src/components/common/Empty.tsx similarity index 72% rename from src/components/common/Empty.js rename to src/components/common/Empty.tsx index c0be761a..4e2677f8 100644 --- a/src/components/common/Empty.js +++ b/src/components/common/Empty.tsx @@ -1,8 +1,13 @@ import classNames from 'classnames'; -import styles from './Empty.module.css'; import useMessages from 'components/hooks/useMessages'; +import styles from './Empty.module.css'; -export function Empty({ message, className }) { +export interface EmptyProps { + message?: string; + className?: string; +} + +export function Empty({ message, className }: EmptyProps) { const { formatMessage, messages } = useMessages(); return ( diff --git a/src/components/common/EmptyPlaceholder.js b/src/components/common/EmptyPlaceholder.tsx similarity index 64% rename from src/components/common/EmptyPlaceholder.js rename to src/components/common/EmptyPlaceholder.tsx index 8834a1db..640e45d5 100644 --- a/src/components/common/EmptyPlaceholder.js +++ b/src/components/common/EmptyPlaceholder.tsx @@ -1,7 +1,13 @@ +import { ReactNode } from 'react'; import { Icon, Text, Flexbox } from 'react-basics'; import Logo from 'assets/logo.svg'; -export function EmptyPlaceholder({ message, children }) { +export interface EmptyPlaceholderProps { + message?: string; + children?: ReactNode; +} + +export function EmptyPlaceholder({ message, children }: EmptyPlaceholderProps) { return ( diff --git a/src/components/common/ErrorBoundry.module.css b/src/components/common/ErrorBoundary.module.css similarity index 100% rename from src/components/common/ErrorBoundry.module.css rename to src/components/common/ErrorBoundary.module.css diff --git a/src/components/common/ErrorBoundary.js b/src/components/common/ErrorBoundary.tsx similarity index 73% rename from src/components/common/ErrorBoundary.js rename to src/components/common/ErrorBoundary.tsx index 32cedb39..49b7e671 100644 --- a/src/components/common/ErrorBoundary.js +++ b/src/components/common/ErrorBoundary.tsx @@ -1,14 +1,15 @@ -/* eslint-disable no-console */ +import { ErrorInfo, ReactNode } from 'react'; import { ErrorBoundary as Boundary } from 'react-error-boundary'; import { Button } from 'react-basics'; import useMessages from 'components/hooks/useMessages'; -import styles from './ErrorBoundry.module.css'; +import styles from './ErrorBoundary.module.css'; -const logError = (error, info) => { +const logError = (error: Error, info: ErrorInfo) => { + // eslint-disable-next-line no-console console.error(error, info.componentStack); }; -export function ErrorBoundary({ children }) { +export function ErrorBoundary({ children }: { children: ReactNode }) { const { formatMessage, messages } = useMessages(); const fallbackRender = ({ error, resetErrorBoundary }) => { diff --git a/src/components/common/ErrorMessage.js b/src/components/common/ErrorMessage.tsx similarity index 89% rename from src/components/common/ErrorMessage.js rename to src/components/common/ErrorMessage.tsx index f8129c6b..0deb6f92 100644 --- a/src/components/common/ErrorMessage.js +++ b/src/components/common/ErrorMessage.tsx @@ -7,7 +7,7 @@ export function ErrorMessage() { return (
- + {formatMessage(messages.error)} diff --git a/src/components/common/Favicon.js b/src/components/common/Favicon.tsx similarity index 93% rename from src/components/common/Favicon.js rename to src/components/common/Favicon.tsx index 55059cc0..2bf43c77 100644 --- a/src/components/common/Favicon.js +++ b/src/components/common/Favicon.tsx @@ -1,6 +1,6 @@ import styles from './Favicon.module.css'; -function getHostName(url) { +function getHostName(url: string) { const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im); return match && match.length > 1 ? match[1] : null; } diff --git a/src/components/common/FilterButtons.js b/src/components/common/FilterButtons.js deleted file mode 100644 index f5a54fb6..00000000 --- a/src/components/common/FilterButtons.js +++ /dev/null @@ -1,13 +0,0 @@ -import { ButtonGroup, Button, Flexbox } from 'react-basics'; - -export function FilterButtons({ items, selectedKey, onSelect }) { - return ( - - - {({ key, label }) => } - - - ); -} - -export default FilterButtons; diff --git a/src/components/common/FilterButtons.tsx b/src/components/common/FilterButtons.tsx new file mode 100644 index 00000000..a64a6482 --- /dev/null +++ b/src/components/common/FilterButtons.tsx @@ -0,0 +1,20 @@ +import { Key } from 'react'; +import { ButtonGroup, Button, Flexbox } from 'react-basics'; + +export interface FilterButtonsProps { + items: any[]; + selectedKey?: Key; + onSelect: (key: any) => void; +} + +export function FilterButtons({ items, selectedKey, onSelect }: FilterButtonsProps) { + return ( + + + {({ key, label }) => } + + + ); +} + +export default FilterButtons; diff --git a/src/components/common/FilterLink.js b/src/components/common/FilterLink.tsx similarity index 67% rename from src/components/common/FilterLink.js rename to src/components/common/FilterLink.tsx index 2a95e011..bc0a4d48 100644 --- a/src/components/common/FilterLink.js +++ b/src/components/common/FilterLink.tsx @@ -1,14 +1,31 @@ +import { ReactNode } from 'react'; import { Icon, Icons } from 'react-basics'; import classNames from 'classnames'; import Link from 'next/link'; import { safeDecodeURI } from 'next-basics'; -import usePageQuery from 'components/hooks/usePageQuery'; +import useNavigation from 'components/hooks/useNavigation'; import useMessages from 'components/hooks/useMessages'; import styles from './FilterLink.module.css'; -export function FilterLink({ id, value, label, externalUrl, children, className }) { +export interface FilterLinkProps { + id: string; + value: string; + label?: string; + externalUrl?: string; + className?: string; + children?: ReactNode; +} + +export function FilterLink({ + id, + value, + label, + externalUrl, + children, + className, +}: FilterLinkProps) { const { formatMessage, labels } = useMessages(); - const { resolveUrl, query } = usePageQuery(); + const { makeUrl, query } = useNavigation(); const active = query[id] !== undefined; const selected = query[id] === value; @@ -22,7 +39,7 @@ export function FilterLink({ id, value, label, externalUrl, children, className {children} {!value && `(${label || formatMessage(labels.unknown)})`} {value && ( - + {safeDecodeURI(label || value)} )} diff --git a/src/components/common/HamburgerButton.js b/src/components/common/HamburgerButton.js deleted file mode 100644 index f97006ef..00000000 --- a/src/components/common/HamburgerButton.js +++ /dev/null @@ -1,59 +0,0 @@ -import { Button, Icon } from 'react-basics'; -import { useState } from 'react'; -import MobileMenu from './MobileMenu'; -import Icons from 'components/icons'; -import useMessages from 'components/hooks/useMessages'; - -export function HamburgerButton() { - const { formatMessage, labels } = useMessages(); - const [active, setActive] = useState(false); - const cloudMode = Boolean(process.env.cloudMode); - - 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 ( - <> - - {active && } - - ); -} - -export default HamburgerButton; diff --git a/src/components/common/HamburgerButton.tsx b/src/components/common/HamburgerButton.tsx new file mode 100644 index 00000000..5a81f3a3 --- /dev/null +++ b/src/components/common/HamburgerButton.tsx @@ -0,0 +1,21 @@ +import { Button, Icon, Icons } from 'react-basics'; +import { useState } from 'react'; +import MobileMenu from './MobileMenu'; + +export function HamburgerButton({ menuItems }: { menuItems: any[] }) { + const [active, setActive] = useState(false); + + const handleClick = () => setActive(state => !state); + const handleClose = () => setActive(false); + + return ( + <> + + {active && } + + ); +} + +export default HamburgerButton; diff --git a/src/components/common/HoverTooltip.js b/src/components/common/HoverTooltip.tsx similarity index 82% rename from src/components/common/HoverTooltip.js rename to src/components/common/HoverTooltip.tsx index 614841df..e5e31219 100644 --- a/src/components/common/HoverTooltip.js +++ b/src/components/common/HoverTooltip.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; +import { ReactNode, useEffect, useState } from 'react'; import { Tooltip } from 'react-basics'; import styles from './HoverTooltip.module.css'; -export function HoverTooltip({ children }) { +export function HoverTooltip({ children }: { children: ReactNode }) { const [position, setPosition] = useState({ x: -1000, y: -1000 }); useEffect(() => { diff --git a/src/components/common/LinkButton.js b/src/components/common/LinkButton.js deleted file mode 100644 index 54c7fa63..00000000 --- a/src/components/common/LinkButton.js +++ /dev/null @@ -1,14 +0,0 @@ -import Link from 'next/link'; -import { Icon, Icons, Text } from 'react-basics'; -import styles from './LinkButton.module.css'; - -export function LinkButton({ href, icon, children }) { - return ( - - {icon || } - {children} - - ); -} - -export default LinkButton; diff --git a/src/components/common/LinkButton.module.css b/src/components/common/LinkButton.module.css index ae8a3b62..5561f536 100644 --- a/src/components/common/LinkButton.module.css +++ b/src/components/common/LinkButton.module.css @@ -26,3 +26,82 @@ .button:visited { color: var(--base900); } + +.button.disabled { + color: var(--disabled-color) !important; + background-color: var(--disabled-background) !important; + border-color: transparent !important; + pointer-events: none; +} + +.button.primary { + color: var(--light50); + background: var(--primary400); +} + +.button.primary:hover { + color: var(--light50); + background: var(--primary500); +} + +.button.primary:active { + color: var(--light50); + background: var(--primary600); +} + +.button.secondary { + border: 1px solid var(--border-color); + background: var(--base50); +} + +.button.secondary:hover { + background: var(--base75); +} + +.button.secondary:active { + background: var(--base100); +} + +.button.quiet { + color: var(--base900); + background: transparent; +} + +.button.quiet:hover { + background: var(--base100); +} + +.button.quiet:active { + background: var(--base200); +} + +.button.danger { + color: var(--light50); + background: var(--red800); +} + +.button.danger:hover { + color: var(--light50); + background: var(--red900); +} + +.button.danger:active { + color: var(--light50); + background: var(--red1000); +} + +.button.size-sm { + font-size: var(--font-size-sm); + height: calc(var(--base-height) * 0.75); + padding: 0 calc(var(--size600) * 0.75); +} + +.button.size-md { + font-size: var(--font-size-md); +} + +.button.size-lg { + font-size: var(--font-size-lg); + height: calc(var(--base-height) * 1.25); + padding: 0 calc(var(--size600) * 1.25); +} diff --git a/src/components/common/LinkButton.tsx b/src/components/common/LinkButton.tsx new file mode 100644 index 00000000..83d95151 --- /dev/null +++ b/src/components/common/LinkButton.tsx @@ -0,0 +1,30 @@ +import classNames from 'classnames'; +import Link from 'next/link'; +import { useLocale } from 'components/hooks'; +import styles from './LinkButton.module.css'; +import { ReactNode } from 'react'; + +export interface LinkButtonProps { + href: string; + className?: string; + variant?: string; + scroll?: boolean; + children?: ReactNode; +} + +export function LinkButton({ href, className, variant, scroll = true, children }: LinkButtonProps) { + const { dir } = useLocale(); + + return ( + + {children} + + ); +} + +export default LinkButton; diff --git a/src/components/common/MobileMenu.js b/src/components/common/MobileMenu.tsx similarity index 67% rename from src/components/common/MobileMenu.js rename to src/components/common/MobileMenu.tsx index de1e9ffa..e14f0b83 100644 --- a/src/components/common/MobileMenu.js +++ b/src/components/common/MobileMenu.tsx @@ -1,15 +1,22 @@ import { createPortal } from 'react-dom'; import classNames from 'classnames'; -import { useRouter } from 'next/router'; +import { usePathname } from 'next/navigation'; import Link from 'next/link'; import styles from './MobileMenu.module.css'; -export function MobileMenu({ items = [], onClose }) { - const { pathname } = useRouter(); +export function MobileMenu({ + items = [], + onClose, +}: { + items: any[]; + className?: string; + onClose: () => void; +}): any { + const pathname = usePathname(); - const Items = ({ items, className }) => ( + const Items = ({ items, className }: { items: any[]; className?: string }): any => (
- {items.map(({ label, url, children }) => { + {items.map(({ label, url, children }: { label: string; url: string; children: any[] }) => { const selected = pathname.startsWith(url); return ( diff --git a/src/components/common/Pager.js b/src/components/common/Pager.js deleted file mode 100644 index 7a5e7ed5..00000000 --- a/src/components/common/Pager.js +++ /dev/null @@ -1,45 +0,0 @@ -import styles from './Pager.module.css'; -import { Button, Flexbox, Icon, Icons } from 'react-basics'; -import useMessages from 'components/hooks/useMessages'; - -export function Pager({ page, pageSize, count, onPageChange }) { - const { formatMessage, labels } = useMessages(); - const maxPage = Math.ceil(count / pageSize); - const lastPage = page === maxPage; - const firstPage = page === 1; - - if (count === 0) { - return null; - } - - const handlePageChange = value => { - const nextPage = page + value; - if (nextPage > 0 && nextPage <= maxPage) { - onPageChange(nextPage); - } - }; - - if (maxPage === 1) { - return null; - } - - return ( - - - - {formatMessage(labels.pageOf, { current: page, total: maxPage })} - - - - ); -} - -export default Pager; diff --git a/src/components/common/Pager.module.css b/src/components/common/Pager.module.css index 99eb70ce..880c1b40 100644 --- a/src/components/common/Pager.module.css +++ b/src/components/common/Pager.module.css @@ -1,7 +1,32 @@ -.container { - margin-top: 20px; +.pager { + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: center; +} + +.nav { + display: flex; + align-items: center; + justify-content: center; } .text { + font-size: var(--font-size-md); margin: 0 16px; + justify-content: center; +} + +.count { + color: var(--base600); + font-weight: 700; +} + +@media only screen and (max-width: 992px) { + .pager { + grid-template-columns: repeat(2, 1fr); + } + + .nav { + justify-content: end; + } } diff --git a/src/components/common/Pager.tsx b/src/components/common/Pager.tsx new file mode 100644 index 00000000..2fe7c6db --- /dev/null +++ b/src/components/common/Pager.tsx @@ -0,0 +1,58 @@ +import classNames from 'classnames'; +import { Button, Icon, Icons } from 'react-basics'; +import useMessages from 'components/hooks/useMessages'; +import styles from './Pager.module.css'; + +export interface PagerProps { + page: number; + pageSize: number; + count: number; + onPageChange: (nextPage: number) => void; + className?: string; +} + +export function Pager({ page, pageSize, count, onPageChange, className }: PagerProps) { + const { formatMessage, labels } = useMessages(); + const maxPage = pageSize && count ? Math.ceil(count / pageSize) : 0; + const lastPage = page === maxPage; + const firstPage = page === 1; + + if (count === 0 || !maxPage) { + return null; + } + + const handlePageChange = (value: number) => { + const nextPage = page + value; + if (nextPage > 0 && nextPage <= maxPage) { + onPageChange(nextPage); + } + }; + + if (maxPage === 1) { + return null; + } + + return ( +
+
{formatMessage(labels.numberOfRecords, { x: count })}
+
+ +
+ {formatMessage(labels.pageOf, { current: page, total: maxPage })} +
+ +
+
+
+ ); +} + +export default Pager; diff --git a/src/components/common/SettingsTable.js b/src/components/common/SettingsTable.js deleted file mode 100644 index 701dbe13..00000000 --- a/src/components/common/SettingsTable.js +++ /dev/null @@ -1,100 +0,0 @@ -import Empty from 'components/common/Empty'; -import useMessages from 'components/hooks/useMessages'; -import { useState } from 'react'; -import { - SearchField, - Table, - TableBody, - TableCell, - TableColumn, - TableHeader, - TableRow, -} from 'react-basics'; -import styles from './SettingsTable.module.css'; -import Pager from 'components/common/Pager'; - -export function SettingsTable({ - columns = [], - data, - children, - cellRender, - showSearch, - showPaging, - onFilterChange, - onPageChange, - onPageSizeChange, - filterValue, -}) { - const { formatMessage, labels, messages } = useMessages(); - const [filter, setFilter] = useState(filterValue); - const { data: value, page, count, pageSize } = data; - - const handleFilterChange = value => { - setFilter(value); - onFilterChange(value); - }; - - return ( - <> - {showSearch && (value.length > 0 || filterValue) && ( - - )} - {value.length === 0 && filterValue && ( - - )} - {value.length > 0 && ( - - - {(column, index) => { - return ( - - {column.label} - - ); - }} - - - {(row, keys, rowIndex) => { - row.action = children(row, keys, rowIndex); - - return ( - - {(data, key, colIndex) => { - return ( - - - {cellRender ? cellRender(row, data, key, colIndex) : data[key]} - - ); - }} - - ); - }} - - {showPaging && ( - - )} -
- )} - - ); -} - -export default SettingsTable; diff --git a/src/components/common/SettingsTable.module.css b/src/components/common/SettingsTable.module.css deleted file mode 100644 index fd6cddfa..00000000 --- a/src/components/common/SettingsTable.module.css +++ /dev/null @@ -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%; - } -} diff --git a/src/components/declarations.d.ts b/src/components/declarations.d.ts index 31e44ff3..ca55157b 100644 --- a/src/components/declarations.d.ts +++ b/src/components/declarations.d.ts @@ -1,2 +1,4 @@ declare module '*.css'; declare module '*.svg'; +declare module '*.json'; +declare module 'uuid'; diff --git a/src/components/hooks/index.js b/src/components/hooks/index.js index 2596ba57..b851eeb7 100644 --- a/src/components/hooks/index.js +++ b/src/components/hooks/index.js @@ -10,14 +10,13 @@ export * from './useFormat'; export * from './useLanguageNames'; export * from './useLocale'; export * from './useMessages'; -export * from './usePageQuery'; +export * from './useNavigation'; export * from './useReport'; export * from './useReports'; -export * from './useRequireLogin'; +export * from './useLogin'; export * from './useShareToken'; export * from './useSticky'; export * from './useTheme'; export * from './useTimezone'; export * from './useUser'; export * from './useWebsite'; -export * from './useWebsiteReports'; diff --git a/src/components/hooks/useApi.ts b/src/components/hooks/useApi.ts index f41547a9..75a928d5 100644 --- a/src/components/hooks/useApi.ts +++ b/src/components/hooks/useApi.ts @@ -1,4 +1,3 @@ -import { useRouter } from 'next/router'; import * as reactQuery from '@tanstack/react-query'; import { useApi as nextUseApi } from 'next-basics'; import { getClientAuthToken } from 'lib/client'; @@ -8,12 +7,11 @@ import useStore from 'store/app'; const selector = state => state.shareToken; export function useApi() { - const { basePath } = useRouter(); const shareToken = useStore(selector); const { get, post, put, del } = nextUseApi( { authorization: `Bearer ${getClientAuthToken()}`, [SHARE_TOKEN_HEADER]: shareToken?.token }, - basePath, + process.env.basePath, ); return { get, post, put, del, ...reactQuery }; diff --git a/src/components/hooks/useApiFilter.ts b/src/components/hooks/useApiFilter.ts deleted file mode 100644 index d411fd43..00000000 --- a/src/components/hooks/useApiFilter.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useState } from 'react'; - -export function useApiFilter() { - const [filter, setFilter] = useState(); - const [filterType, setFilterType] = useState('All'); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - - const handleFilterChange = value => setFilter(value); - const handlePageChange = value => setPage(value); - const handlePageSizeChange = value => setPageSize(value); - - return { - filter, - setFilter, - filterType, - setFilterType, - page, - setPage, - pageSize, - setPageSize, - handleFilterChange, - handlePageChange, - handlePageSizeChange, - }; -} - -export default useApiFilter; diff --git a/src/components/hooks/useConfig.js b/src/components/hooks/useConfig.ts similarity index 100% rename from src/components/hooks/useConfig.js rename to src/components/hooks/useConfig.ts diff --git a/src/components/hooks/useCountryNames.js b/src/components/hooks/useCountryNames.ts similarity index 71% rename from src/components/hooks/useCountryNames.js rename to src/components/hooks/useCountryNames.ts index 51cabf34..22f20666 100644 --- a/src/components/hooks/useCountryNames.js +++ b/src/components/hooks/useCountryNames.ts @@ -1,5 +1,4 @@ import { useState, useEffect } from 'react'; -import { useRouter } from 'next/router'; import { httpGet } from 'next-basics'; import enUS from 'public/intl/country/en-US.json'; @@ -7,12 +6,11 @@ const countryNames = { 'en-US': enUS, }; -export function useCountryNames(locale) { +export function useCountryNames(locale: string) { const [list, setList] = useState(countryNames[locale] || enUS); - const { basePath } = useRouter(); - async function loadData(locale) { - const { data } = await httpGet(`${basePath}/intl/country/${locale}.json`); + async function loadData(locale: string) { + const { data } = await httpGet(`${process.env.basePath}/intl/country/${locale}.json`); if (data) { countryNames[locale] = data; diff --git a/src/components/hooks/useDateRange.js b/src/components/hooks/useDateRange.ts similarity index 72% rename from src/components/hooks/useDateRange.js rename to src/components/hooks/useDateRange.ts index 1e1b0616..efaa717f 100644 --- a/src/components/hooks/useDateRange.js +++ b/src/components/hooks/useDateRange.ts @@ -1,12 +1,13 @@ import { getMinimumUnit, parseDateRange } from 'lib/date'; import { setItem } from 'next-basics'; import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE } from 'lib/constants'; -import useLocale from './useLocale'; import websiteStore, { setWebsiteDateRange } from 'store/websites'; import appStore, { setDateRange } from 'store/app'; +import { DateRange } from 'lib/types'; +import useLocale from './useLocale'; import useApi from './useApi'; -export function useDateRange(websiteId) { +export function useDateRange(websiteId?: string) { const { get } = useApi(); const { locale } = useLocale(); const websiteConfig = websiteStore(state => state[websiteId]?.dateRange); @@ -14,13 +15,13 @@ export function useDateRange(websiteId) { const globalConfig = appStore(state => state.dateRange); const dateRange = parseDateRange(websiteConfig || globalConfig || defaultConfig, locale); - const saveDateRange = async value => { + const saveDateRange = async (value: DateRange | string) => { if (websiteId) { - let dateRange = value; + let dateRange: DateRange | string = value; if (typeof value === 'string') { if (value === 'all') { - const result = await get(`/websites/${websiteId}/daterange`); + const result: any = await get(`/websites/${websiteId}/daterange`); const { mindate, maxdate } = result; const startDate = new Date(mindate); @@ -37,14 +38,17 @@ export function useDateRange(websiteId) { } } - setWebsiteDateRange(websiteId, dateRange); + setWebsiteDateRange(websiteId, dateRange as DateRange); } else { setItem(DATE_RANGE_CONFIG, value); setDateRange(value); } }; - return [dateRange, saveDateRange]; + return [dateRange, saveDateRange] as [ + { startDate: Date; endDate: Date; modified?: number }, + (value: string | DateRange) => void, + ]; } export default useDateRange; diff --git a/src/components/hooks/useDocumentClick.js b/src/components/hooks/useDocumentClick.ts similarity index 77% rename from src/components/hooks/useDocumentClick.js rename to src/components/hooks/useDocumentClick.ts index be3d09be..eefd9366 100644 --- a/src/components/hooks/useDocumentClick.js +++ b/src/components/hooks/useDocumentClick.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -export function useDocumentClick(handler) { +export function useDocumentClick(handler: (event: MouseEvent) => any) { useEffect(() => { document.addEventListener('click', handler); diff --git a/src/components/hooks/useEscapeKey.js b/src/components/hooks/useEscapeKey.js deleted file mode 100644 index 1a17f18f..00000000 --- a/src/components/hooks/useEscapeKey.js +++ /dev/null @@ -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; diff --git a/src/components/hooks/useEscapeKey.ts b/src/components/hooks/useEscapeKey.ts new file mode 100644 index 00000000..5c3350e7 --- /dev/null +++ b/src/components/hooks/useEscapeKey.ts @@ -0,0 +1,21 @@ +import { useEffect, useCallback, KeyboardEvent } from 'react'; + +export function useEscapeKey(handler: (event: KeyboardEvent) => void) { + const escFunction = useCallback((event: KeyboardEvent) => { + if (event.key === 'Escape') { + handler(event); + } + }, []); + + useEffect(() => { + document.addEventListener('keydown', escFunction as any, false); + + return () => { + document.removeEventListener('keydown', escFunction as any, false); + }; + }, [escFunction]); + + return null; +} + +export default useEscapeKey; diff --git a/src/components/hooks/useFilterQuery.ts b/src/components/hooks/useFilterQuery.ts new file mode 100644 index 00000000..030da27d --- /dev/null +++ b/src/components/hooks/useFilterQuery.ts @@ -0,0 +1,38 @@ +import { UseQueryOptions } from '@tanstack/react-query'; +import { useState, Dispatch, SetStateAction } from 'react'; +import { useApi } from 'components/hooks/useApi'; +import { FilterResult, SearchFilter } from 'lib/types'; + +export interface FilterQueryResult { + result: FilterResult; + query: any; + params: SearchFilter; + setParams: Dispatch>; +} + +export function useFilterQuery({ + queryKey, + queryFn, + ...options +}: UseQueryOptions): FilterQueryResult { + const [params, setParams] = useState({ + query: '', + page: 1, + }); + + const { useQuery } = useApi(); + const { data, ...query } = useQuery({ + queryKey: [{ ...queryKey, ...params }], + queryFn: () => queryFn(params as any), + ...options, + }); + + return { + result: data as FilterResult, + query, + params, + setParams, + }; +} + +export default useFilterQuery; diff --git a/src/components/hooks/useFilters.js b/src/components/hooks/useFilters.ts similarity index 100% rename from src/components/hooks/useFilters.js rename to src/components/hooks/useFilters.ts diff --git a/src/components/hooks/useForceUpdate.js b/src/components/hooks/useForceUpdate.ts similarity index 100% rename from src/components/hooks/useForceUpdate.js rename to src/components/hooks/useForceUpdate.ts diff --git a/src/components/hooks/useFormat.js b/src/components/hooks/useFormat.ts similarity index 60% rename from src/components/hooks/useFormat.js rename to src/components/hooks/useFormat.ts index 0e609c48..06585e49 100644 --- a/src/components/hooks/useFormat.js +++ b/src/components/hooks/useFormat.ts @@ -9,23 +9,28 @@ export function useFormat() { const { locale } = useLocale(); const countryNames = useCountryNames(locale); - const formatBrowser = value => { + const formatBrowser = (value: string): string => { return BROWSERS[value] || value; }; - const formatCountry = value => { + const formatCountry = (value: string): string => { return countryNames[value] || value; }; - const formatRegion = value => { - return regions[value] ? regions[value] : value; + const formatRegion = (value: string): string => { + const [country] = value.split('-'); + return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value; }; - const formatDevice = value => { + const formatCity = (value: string, country?: string): string => { + return `${value}, ${countryNames[country]}`; + }; + + const formatDevice = (value: string): string => { return formatMessage(labels[value] || labels.unknown); }; - const formatValue = (value, type) => { + const formatValue = (value: string, type: string, data?: { [key: string]: any }): string => { switch (type) { case 'browser': return formatBrowser(value); @@ -33,6 +38,8 @@ export function useFormat() { return formatCountry(value); case 'region': return formatRegion(value); + case 'city': + return formatCity(value, data?.country); case 'device': return formatDevice(value); default: diff --git a/src/components/hooks/useLanguageNames.js b/src/components/hooks/useLanguageNames.ts similarity index 80% rename from src/components/hooks/useLanguageNames.js rename to src/components/hooks/useLanguageNames.ts index ff59e93d..3823a26b 100644 --- a/src/components/hooks/useLanguageNames.js +++ b/src/components/hooks/useLanguageNames.ts @@ -1,5 +1,4 @@ import { useState, useEffect } from 'react'; -import { useRouter } from 'next/router'; import { httpGet } from 'next-basics'; import enUS from 'public/intl/language/en-US.json'; @@ -9,10 +8,9 @@ const languageNames = { export function useLanguageNames(locale) { const [list, setList] = useState(languageNames[locale] || enUS); - const { basePath } = useRouter(); async function loadData(locale) { - const { data } = await httpGet(`${basePath}/intl/language/${locale}.json`); + const { data } = await httpGet(`${process.env.basePath}/intl/language/${locale}.json`); if (data) { languageNames[locale] = data; diff --git a/src/components/hooks/useLocale.js b/src/components/hooks/useLocale.ts similarity index 89% rename from src/components/hooks/useLocale.js rename to src/components/hooks/useLocale.ts index 1374af81..71574d86 100644 --- a/src/components/hooks/useLocale.js +++ b/src/components/hooks/useLocale.ts @@ -1,5 +1,4 @@ import { useEffect } from 'react'; -import { useRouter } from 'next/router'; import { httpGet, setItem } from 'next-basics'; import { LOCALE_CONFIG } from 'lib/constants'; import { getDateLocale, getTextDirection } from 'lib/lang'; @@ -15,13 +14,12 @@ const selector = state => state.locale; export function useLocale() { const locale = useStore(selector); - const { basePath } = useRouter(); const forceUpdate = useForceUpdate(); const dir = getTextDirection(locale); const dateLocale = getDateLocale(locale); async function loadMessages(locale) { - const { ok, data } = await httpGet(`${basePath}/intl/messages/${locale}.json`); + const { ok, data } = await httpGet(`${process.env.basePath}/intl/messages/${locale}.json`); if (ok) { messages[locale] = data; diff --git a/src/components/hooks/useLogin.ts b/src/components/hooks/useLogin.ts new file mode 100644 index 00000000..a4ac9d3b --- /dev/null +++ b/src/components/hooks/useLogin.ts @@ -0,0 +1,22 @@ +import useApi from 'components/hooks/useApi'; +import useUser from 'components/hooks/useUser'; + +export function useLogin() { + const { get, useQuery } = useApi(); + const { user, setUser } = useUser(); + + const query = useQuery({ + queryKey: ['login'], + queryFn: async () => { + const data = await get('/auth/verify'); + + setUser(data); + + return data; + }, + }); + + return { user, ...query }; +} + +export default useLogin; diff --git a/src/components/hooks/useMessages.js b/src/components/hooks/useMessages.js deleted file mode 100644 index e3a6c20b..00000000 --- a/src/components/hooks/useMessages.js +++ /dev/null @@ -1,20 +0,0 @@ -import { useIntl, FormattedMessage } from 'react-intl'; -import { messages, labels } from 'components/messages'; - -export function useMessages() { - const intl = useIntl(); - - const getMessage = id => { - const message = Object.values(messages).find(value => value.id === id); - - return message ? formatMessage(message) : id; - }; - - const formatMessage = (descriptor, values, opts) => { - return descriptor ? intl.formatMessage(descriptor, values, opts) : null; - }; - - return { formatMessage, FormattedMessage, messages, labels, getMessage }; -} - -export default useMessages; diff --git a/src/components/hooks/useMessages.ts b/src/components/hooks/useMessages.ts new file mode 100644 index 00000000..594a3c61 --- /dev/null +++ b/src/components/hooks/useMessages.ts @@ -0,0 +1,30 @@ +import { useIntl, FormattedMessage, MessageDescriptor, PrimitiveType } from 'react-intl'; +import { messages, labels } from 'components/messages'; +import { FormatXMLElementFn, Options } from 'intl-messageformat'; + +export function useMessages(): any { + const intl = useIntl(); + + const getMessage = (id: string) => { + const message = Object.values(messages).find(value => value.id === id); + + return message ? formatMessage(message) : id; + }; + + const formatMessage = ( + descriptor: + | MessageDescriptor + | { + id: string; + defaultMessage: string; + }, + values?: Record>, + opts?: Options, + ) => { + return descriptor ? intl.formatMessage(descriptor, values, opts) : null; + }; + + return { formatMessage, FormattedMessage, messages, labels, getMessage }; +} + +export default useMessages; diff --git a/src/components/hooks/useNavigation.ts b/src/components/hooks/useNavigation.ts new file mode 100644 index 00000000..fb9bffc5 --- /dev/null +++ b/src/components/hooks/useNavigation.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { buildUrl } from 'next-basics'; + +export function useNavigation(): { + pathname: string; + query: { [key: string]: string }; + router: any; + makeUrl: (params: any, reset?: boolean) => string; +} { + const router = useRouter(); + const pathname = usePathname(); + const params = useSearchParams(); + + const query = useMemo(() => { + const obj = {}; + + for (const [key, value] of params.entries()) { + obj[key] = decodeURIComponent(value); + } + + return obj; + }, [params]); + + function makeUrl(params: any, reset?: boolean) { + return reset ? pathname : buildUrl(pathname, { ...query, ...params }); + } + + return { pathname, query, router, makeUrl }; +} + +export default useNavigation; diff --git a/src/components/hooks/usePageQuery.js b/src/components/hooks/usePageQuery.js deleted file mode 100644 index b275d580..00000000 --- a/src/components/hooks/usePageQuery.js +++ /dev/null @@ -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; diff --git a/src/components/hooks/useReport.js b/src/components/hooks/useReport.ts similarity index 81% rename from src/components/hooks/useReport.js rename to src/components/hooks/useReport.ts index 7c698b4e..7769ed6c 100644 --- a/src/components/hooks/useReport.js +++ b/src/components/hooks/useReport.ts @@ -4,7 +4,7 @@ import { useTimezone } from './useTimezone'; import useApi from './useApi'; import useMessages from './useMessages'; -export function useReport(reportId, defaultParameters) { +export function useReport(reportId: string, defaultParameters: { [key: string]: any }) { const [report, setReport] = useState(null); const [isRunning, setIsRunning] = useState(false); const { get, post } = useApi(); @@ -17,8 +17,8 @@ export function useReport(reportId, defaultParameters) { parameters: {}, }; - const loadReport = async id => { - const data = await get(`/reports/${id}`); + const loadReport = async (id: string) => { + const data: any = await get(`/reports/${id}`); const { dateRange } = data?.parameters || {}; const { startDate, endDate } = dateRange || {}; @@ -32,7 +32,7 @@ export function useReport(reportId, defaultParameters) { }; const runReport = useCallback( - async parameters => { + async (parameters: { [key: string]: any }) => { setIsRunning(true); const { type } = report; @@ -40,7 +40,7 @@ export function useReport(reportId, defaultParameters) { const data = await post(`/reports/${type}`, { ...parameters, timezone }); setReport( - produce(state => { + produce((state: any) => { state.parameters = parameters; state.data = data; @@ -50,13 +50,13 @@ export function useReport(reportId, defaultParameters) { setIsRunning(false); }, - [report], + [report, timezone], ); const updateReport = useCallback( - async data => { + async (data: { [x: string]: any; parameters: any }) => { setReport( - produce(state => { + produce((state: any) => { const { parameters, ...rest } = data; if (parameters) { diff --git a/src/components/hooks/useReports.js b/src/components/hooks/useReports.js deleted file mode 100644 index d9292aeb..00000000 --- a/src/components/hooks/useReports.js +++ /dev/null @@ -1,38 +0,0 @@ -import { useState } from 'react'; -import useApi from './useApi'; -import useApiFilter from 'components/hooks/useApiFilter'; - -export function useReports() { - const [modified, setModified] = useState(Date.now()); - const { get, useQuery, del, useMutation } = useApi(); - const { mutate } = useMutation(reportId => del(`/reports/${reportId}`)); - const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = - useApiFilter(); - const { data, error, isLoading } = useQuery( - ['reports', { modified, filter, page, pageSize }], - () => get(`/reports`, { filter, page, pageSize }), - ); - - const deleteReport = id => { - mutate(id, { - onSuccess: () => { - setModified(Date.now()); - }, - }); - }; - - return { - reports: data, - error, - isLoading, - deleteReport, - filter, - page, - pageSize, - handleFilterChange, - handlePageChange, - handlePageSizeChange, - }; -} - -export default useReports; diff --git a/src/components/hooks/useReports.ts b/src/components/hooks/useReports.ts new file mode 100644 index 00000000..d2473002 --- /dev/null +++ b/src/components/hooks/useReports.ts @@ -0,0 +1,30 @@ +import { useState } from 'react'; +import useApi from './useApi'; +import useFilterQuery from 'components/hooks/useFilterQuery'; + +export function useReports(websiteId?: string) { + const [modified, setModified] = useState(Date.now()); + const { get, del, useMutation } = useApi(); + const { mutate } = useMutation({ mutationFn: (reportId: string) => del(`/reports/${reportId}`) }); + const queryResult = useFilterQuery({ + queryKey: ['reports', { websiteId, modified }], + queryFn: (params: any) => { + return get(websiteId ? `/websites/${websiteId}/reports` : `/reports`, params); + }, + }); + + const deleteReport = (id: any) => { + mutate(id, { + onSuccess: () => { + setModified(Date.now()); + }, + }); + }; + + return { + ...queryResult, + deleteReport, + }; +} + +export default useReports; diff --git a/src/components/hooks/useRequireLogin.ts b/src/components/hooks/useRequireLogin.ts deleted file mode 100644 index d2f540d4..00000000 --- a/src/components/hooks/useRequireLogin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect } from 'react'; -import { useRouter } from 'next/router'; -import useApi from 'components/hooks/useApi'; -import useUser from 'components/hooks/useUser'; - -export function useRequireLogin(handler: (data?: object) => void) { - const { basePath } = useRouter(); - const { get } = useApi(); - const { user, setUser } = useUser(); - - useEffect(() => { - async function loadUser() { - try { - const data = await get('/auth/verify'); - - setUser(typeof handler === 'function' ? handler(data) : (data as any)?.user); - } catch { - location.href = `${basePath}/login`; - } - } - - if (!user) { - loadUser(); - } - }, [user]); - - return { user }; -} - -export default useRequireLogin; diff --git a/src/components/hooks/useShareToken.js b/src/components/hooks/useShareToken.js deleted file mode 100644 index 3d6b9698..00000000 --- a/src/components/hooks/useShareToken.js +++ /dev/null @@ -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; diff --git a/src/components/hooks/useShareToken.ts b/src/components/hooks/useShareToken.ts new file mode 100644 index 00000000..189657be --- /dev/null +++ b/src/components/hooks/useShareToken.ts @@ -0,0 +1,27 @@ +import useStore, { setShareToken } from 'store/app'; +import useApi from './useApi'; + +const selector = (state: { shareToken: string }) => state.shareToken; + +export function useShareToken(shareId: string): { + shareToken: any; + isLoading?: boolean; + error?: Error; +} { + const shareToken = useStore(selector); + const { get, useQuery } = useApi(); + const { isLoading, error } = useQuery({ + queryKey: ['share', shareId], + queryFn: async () => { + const data = await get(`/share/${shareId}`); + + setShareToken(data); + + return data; + }, + }); + + return { shareToken, isLoading, error }; +} + +export default useShareToken; diff --git a/src/components/hooks/useSticky.js b/src/components/hooks/useSticky.ts similarity index 76% rename from src/components/hooks/useSticky.js rename to src/components/hooks/useSticky.ts index be33f6ed..459c489a 100644 --- a/src/components/hooks/useSticky.js +++ b/src/components/hooks/useSticky.ts @@ -5,8 +5,9 @@ export function useSticky({ enabled = true, threshold = 1 }) { const ref = useRef(null); useEffect(() => { - let observer; - const handler = ([entry]) => setIsSticky(entry.intersectionRatio < threshold); + let observer: IntersectionObserver | undefined; + const handler: IntersectionObserverCallback = ([entry]) => + setIsSticky(entry.intersectionRatio < threshold); if (enabled && ref.current) { observer = new IntersectionObserver(handler, { threshold: [threshold] }); diff --git a/src/components/hooks/useTheme.js b/src/components/hooks/useTheme.ts similarity index 97% rename from src/components/hooks/useTheme.js rename to src/components/hooks/useTheme.ts index 7e40f601..099bf962 100644 --- a/src/components/hooks/useTheme.js +++ b/src/components/hooks/useTheme.ts @@ -4,7 +4,7 @@ import { getItem, setItem } from 'next-basics'; import { THEME_COLORS, THEME_CONFIG } from 'lib/constants'; import { colord } from 'colord'; -const selector = state => state.theme; +const selector = (state: { theme: string }) => state.theme; export function useTheme() { const defaultTheme = diff --git a/src/components/hooks/useTimezone.js b/src/components/hooks/useTimezone.ts similarity index 100% rename from src/components/hooks/useTimezone.js rename to src/components/hooks/useTimezone.ts diff --git a/src/components/hooks/useWebsite.js b/src/components/hooks/useWebsite.js deleted file mode 100644 index 5315f0dc..00000000 --- a/src/components/hooks/useWebsite.js +++ /dev/null @@ -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; diff --git a/src/components/hooks/useWebsite.ts b/src/components/hooks/useWebsite.ts new file mode 100644 index 00000000..d18e96ba --- /dev/null +++ b/src/components/hooks/useWebsite.ts @@ -0,0 +1,12 @@ +import useApi from './useApi'; + +export function useWebsite(websiteId: string) { + const { get, useQuery } = useApi(); + return useQuery({ + queryKey: ['websites', websiteId], + queryFn: () => get(`/websites/${websiteId}`), + enabled: !!websiteId, + }); +} + +export default useWebsite; diff --git a/src/components/hooks/useWebsiteReports.js b/src/components/hooks/useWebsiteReports.js deleted file mode 100644 index c637bc76..00000000 --- a/src/components/hooks/useWebsiteReports.js +++ /dev/null @@ -1,38 +0,0 @@ -import { useState } from 'react'; -import useApi from './useApi'; -import useApiFilter from 'components/hooks/useApiFilter'; - -export function useWebsiteReports(websiteId) { - const [modified, setModified] = useState(Date.now()); - const { get, useQuery, del, useMutation } = useApi(); - const { mutate } = useMutation(reportId => del(`/reports/${reportId}`)); - const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = - useApiFilter(); - const { data, error, isLoading } = useQuery( - ['reports:website', { websiteId, modified, filter, page, pageSize }], - () => get(`/websites/${websiteId}/reports`, { websiteId, filter, page, pageSize }), - ); - - const deleteReport = id => { - mutate(id, { - onSuccess: () => { - setModified(Date.now()); - }, - }); - }; - - return { - reports: data, - error, - isLoading, - deleteReport, - filter, - page, - pageSize, - handleFilterChange, - handlePageChange, - handlePageSizeChange, - }; -} - -export default useWebsiteReports; diff --git a/src/components/icons.ts b/src/components/icons.ts index 8eb1f8b0..01d7caf5 100644 --- a/src/components/icons.ts +++ b/src/components/icons.ts @@ -22,7 +22,7 @@ import User from 'assets/user.svg'; import Users from 'assets/users.svg'; import Visitor from 'assets/visitor.svg'; -const icons: any = { +const icons = { ...Icons, AddUser, Bars, diff --git a/src/components/input/DateFilter.js b/src/components/input/DateFilter.tsx similarity index 89% rename from src/components/input/DateFilter.js rename to src/components/input/DateFilter.tsx index 9fde27ca..f7739f17 100644 --- a/src/components/input/DateFilter.js +++ b/src/components/input/DateFilter.tsx @@ -3,9 +3,20 @@ import { Icon, Modal, Dropdown, Item, Text, Flexbox } from 'react-basics'; import { endOfYear, isSameDay } from 'date-fns'; import DatePickerForm from 'components/metrics/DatePickerForm'; import useLocale from 'components/hooks/useLocale'; -import { formatDate } from 'lib/date'; -import Icons from 'components/icons'; import useMessages from 'components/hooks/useMessages'; +import Icons from 'components/icons'; +import { formatDate } from 'lib/date'; + +export interface DateFilterProps { + value: string; + startDate: Date; + endDate: Date; + className?: string; + onChange?: (value: string) => void; + selectedUnit?: string; + showAllTime?: boolean; + alignment?: 'start' | 'center' | 'end'; +} export function DateFilter({ value, @@ -16,7 +27,7 @@ export function DateFilter({ selectedUnit, showAllTime = false, alignment = 'end', -}) { +}: DateFilterProps) { const { formatMessage, labels } = useMessages(); const [showPicker, setShowPicker] = useState(false); @@ -65,7 +76,7 @@ export function DateFilter({ }, ].filter(n => n); - const renderValue = value => { + const renderValue = (value: string) => { return value.startsWith('range') ? ( { + const handleChange = (value: string) => { if (value === 'custom') { setShowPicker(true); return; @@ -86,7 +97,7 @@ export function DateFilter({ onChange(value); }; - const handlePickerChange = value => { + const handlePickerChange = (value: string) => { setShowPicker(false); onChange(value); }; @@ -102,7 +113,7 @@ export function DateFilter({ value={value} alignment={alignment} placeholder={formatMessage(labels.selectDate)} - onChange={handleChange} + onChange={key => handleChange(key as any)} > {({ label, value, divider }) => ( diff --git a/src/components/input/LanguageButton.module.css b/src/components/input/LanguageButton.module.css index 3d4c0c56..cc5d649a 100644 --- a/src/components/input/LanguageButton.module.css +++ b/src/components/input/LanguageButton.module.css @@ -1,7 +1,6 @@ .menu { - display: flex; - flex-flow: row wrap; - min-width: 640px; + display: grid; + grid-template-columns: repeat(3, 1fr); padding: 10px; background: var(--base50); z-index: var(--z-index-popup); @@ -14,7 +13,7 @@ display: flex; align-items: center; justify-content: space-between; - min-width: calc(100% / 3); + min-width: 200px; border-radius: 5px; padding: 5px 10px; } @@ -32,3 +31,15 @@ .icon { color: var(--primary400); } + +@media screen and (max-width: 992px) { + .menu { + grid-template-columns: repeat(2, 1fr); + } +} + +@media screen and (max-width: 768px) { + .menu { + transform: translateX(40px); + } +} diff --git a/src/components/input/LanguageButton.js b/src/components/input/LanguageButton.tsx similarity index 88% rename from src/components/input/LanguageButton.js rename to src/components/input/LanguageButton.tsx index 3c0d0cd6..1151da0b 100644 --- a/src/components/input/LanguageButton.js +++ b/src/components/input/LanguageButton.tsx @@ -9,7 +9,7 @@ export function LanguageButton() { const { locale, saveLocale, dir } = useLocale(); const items = Object.keys(languages).map(key => ({ ...languages[key], value: key })); - function handleSelect(value, close, e) { + function handleSelect(value: string, close: () => void, e: MouseEvent) { e.stopPropagation(); saveLocale(value); close(); @@ -23,7 +23,7 @@ export function LanguageButton() { - {close => { + {(close: () => void) => { return (
{items.map(({ value, label }) => { @@ -31,7 +31,7 @@ export function LanguageButton() {
handleSelect(value, close, e)} > {label} {value === locale && ( diff --git a/src/components/input/LogoutButton.js b/src/components/input/LogoutButton.tsx similarity index 74% rename from src/components/input/LogoutButton.js rename to src/components/input/LogoutButton.tsx index 2b04a78a..c787f229 100644 --- a/src/components/input/LogoutButton.js +++ b/src/components/input/LogoutButton.tsx @@ -2,10 +2,14 @@ import { Button, Icon, Icons, TooltipPopup } from 'react-basics'; import Link from 'next/link'; import useMessages from 'components/hooks/useMessages'; -export function LogoutButton({ tooltipPosition = 'top' }) { +export function LogoutButton({ + tooltipPosition = 'top', +}: { + tooltipPosition?: 'top' | 'bottom' | 'left' | 'right'; +}) { const { formatMessage, labels } = useMessages(); return ( - + - {close => ( - + {(close: any) => ( + )} diff --git a/src/components/input/ProfileButton.js b/src/components/input/ProfileButton.tsx similarity index 97% rename from src/components/input/ProfileButton.js rename to src/components/input/ProfileButton.tsx index 35b0eb45..2c3f8629 100644 --- a/src/components/input/ProfileButton.js +++ b/src/components/input/ProfileButton.tsx @@ -1,5 +1,5 @@ import { Icon, Button, PopupTrigger, Popup, Menu, Item, Text } from 'react-basics'; -import { useRouter } from 'next/router'; +import { useRouter } from 'next/navigation'; import Icons from 'components/icons'; import useMessages from 'components/hooks/useMessages'; import useUser from 'components/hooks/useUser'; diff --git a/src/components/input/RefreshButton.js b/src/components/input/RefreshButton.tsx similarity index 87% rename from src/components/input/RefreshButton.js rename to src/components/input/RefreshButton.tsx index 8b40cafa..01e80378 100644 --- a/src/components/input/RefreshButton.js +++ b/src/components/input/RefreshButton.tsx @@ -4,7 +4,13 @@ import useDateRange from 'components/hooks/useDateRange'; import Icons from 'components/icons'; import useMessages from 'components/hooks/useMessages'; -export function RefreshButton({ websiteId, isLoading }) { +export function RefreshButton({ + websiteId, + isLoading, +}: { + websiteId: string; + isLoading?: boolean; +}) { const { formatMessage, labels } = useMessages(); const [dateRange] = useDateRange(websiteId); diff --git a/src/components/input/SettingsButton.js b/src/components/input/SettingsButton.tsx similarity index 71% rename from src/components/input/SettingsButton.js rename to src/components/input/SettingsButton.tsx index a6d72a2b..2a076d42 100644 --- a/src/components/input/SettingsButton.js +++ b/src/components/input/SettingsButton.tsx @@ -1,6 +1,6 @@ import { Button, Icon, PopupTrigger, Popup, Form, FormRow } from 'react-basics'; -import TimezoneSetting from 'components/pages/settings/profile/TimezoneSetting'; -import DateRangeSetting from 'components/pages/settings/profile/DateRangeSetting'; +import TimezoneSetting from 'app/(main)/settings/profile/TimezoneSetting'; +import DateRangeSetting from 'app/(main)/settings/profile/DateRangeSetting'; import Icons from 'components/icons'; import useMessages from 'components/hooks/useMessages'; import styles from './SettingsButton.module.css'; @@ -15,12 +15,7 @@ export function SettingsButton() { - e.stopPropagation()} - > +
diff --git a/src/components/input/ThemeButton.js b/src/components/input/ThemeButton.tsx similarity index 94% rename from src/components/input/ThemeButton.js rename to src/components/input/ThemeButton.tsx index 3a6a9d14..76d1b370 100644 --- a/src/components/input/ThemeButton.js +++ b/src/components/input/ThemeButton.tsx @@ -1,4 +1,4 @@ -import { useTransition, animated } from 'react-spring'; +import { useTransition, animated } from '@react-spring/web'; import { Button, Icon } from 'react-basics'; import useTheme from 'components/hooks/useTheme'; import Icons from 'components/icons'; diff --git a/src/components/input/WebsiteDateFilter.module.css b/src/components/input/WebsiteDateFilter.module.css index 986f5c17..6f2e822d 100644 --- a/src/components/input/WebsiteDateFilter.module.css +++ b/src/components/input/WebsiteDateFilter.module.css @@ -1,7 +1,17 @@ +.container { + display: flex; + align-items: center; + gap: 10px; +} + .dropdown { min-width: 200px; } +.buttons { + display: flex; +} + .buttons button:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; diff --git a/src/components/input/WebsiteDateFilter.js b/src/components/input/WebsiteDateFilter.tsx similarity index 83% rename from src/components/input/WebsiteDateFilter.js rename to src/components/input/WebsiteDateFilter.tsx index 6903a708..cf1beaa1 100644 --- a/src/components/input/WebsiteDateFilter.js +++ b/src/components/input/WebsiteDateFilter.tsx @@ -1,11 +1,11 @@ import useDateRange from 'components/hooks/useDateRange'; import { isAfter } from 'date-fns'; import { incrementDateRange } from 'lib/date'; -import { Button, Flexbox, Icon, Icons } from 'react-basics'; +import { Button, Icon, Icons } from 'react-basics'; import DateFilter from './DateFilter'; import styles from './WebsiteDateFilter.module.css'; -export function WebsiteDateFilter({ websiteId }) { +export function WebsiteDateFilter({ websiteId }: { websiteId: string }) { const [dateRange, setDateRange] = useDateRange(websiteId); const { value, startDate, endDate, selectedUnit } = dateRange; const isFutureDate = @@ -22,9 +22,9 @@ export function WebsiteDateFilter({ websiteId }) { }; return ( - +
{value !== 'all' && selectedUnit && ( - +
- +
)} -
+
); } diff --git a/src/components/input/WebsiteSelect.module.css b/src/components/input/WebsiteSelect.module.css new file mode 100644 index 00000000..42e2911a --- /dev/null +++ b/src/components/input/WebsiteSelect.module.css @@ -0,0 +1,4 @@ +.dropdown { + max-height: 400px; + overflow-y: auto; +} diff --git a/src/components/input/WebsiteSelect.js b/src/components/input/WebsiteSelect.tsx similarity index 65% rename from src/components/input/WebsiteSelect.js rename to src/components/input/WebsiteSelect.tsx index 1bdc4608..e125e258 100644 --- a/src/components/input/WebsiteSelect.js +++ b/src/components/input/WebsiteSelect.tsx @@ -1,11 +1,21 @@ import { Dropdown, Item } from 'react-basics'; import useApi from 'components/hooks/useApi'; import useMessages from 'components/hooks/useMessages'; +import styles from './WebsiteSelect.module.css'; -export function WebsiteSelect({ websiteId, onSelect }) { +export function WebsiteSelect({ + websiteId, + onSelect, +}: { + websiteId: string; + onSelect?: (key: any) => void; +}) { const { formatMessage, labels } = useMessages(); const { get, useQuery } = useApi(); - const { data } = useQuery(['websites:me'], () => get('/me/websites')); + const { data } = useQuery({ + queryKey: ['websites:me'], + queryFn: () => get('/me/websites', { pageSize: 100 }), + }); const renderValue = value => { return data?.data?.find(({ id }) => id === value)?.name; @@ -13,6 +23,7 @@ export function WebsiteSelect({ websiteId, onSelect }) { return ( - - - {title ? `${title} | umami` : 'umami'} - - -
- {children} -
-
- ); -} - -export default AppLayout; diff --git a/src/components/layout/Grid.js b/src/components/layout/Grid.js deleted file mode 100644 index 0276063b..00000000 --- a/src/components/layout/Grid.js +++ /dev/null @@ -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 ; -} - -export function GridColumn(props) { - const { className, ...otherProps } = props; - return ; -} diff --git a/src/components/layout/Grid.module.css b/src/components/layout/Grid.module.css index dc2e8ff6..f72a5f12 100644 --- a/src/components/layout/Grid.module.css +++ b/src/components/layout/Grid.module.css @@ -1,27 +1,52 @@ -.col { - display: flex; - flex-direction: column; - padding: 20px; +.grid { + display: grid; } .row { + display: grid; + grid-template-columns: repeat(6, 1fr); border-top: 1px solid var(--base300); - min-height: 430px; } -.row > .col { +.col { + padding: 20px; + min-height: 430px; border-inline-start: 1px solid var(--base300); } -.row > .col:first-child { +.col:first-child { border-inline-start: 0; padding-inline-start: 0; } -.row > .col:last-child { +.col:last-child { padding-inline-end: 0; } +.col.two { + grid-column: span 3; +} + +.col.three { + grid-column: span 2; +} + +.col.two-one:first-child { + grid-column: span 4; +} + +.col.two-one:last-child { + grid-column: span 2; +} + +.col.one-two:first-child { + grid-column: span 2; +} + +.col.one-two:last-child { + grid-column: span 4; +} + @media only screen and (max-width: 992px) { .row { border: 0; @@ -33,4 +58,11 @@ border-inline-end: 0; padding: 20px 0; } + + .col.two, + .col.three, + .col.one-two, + .col.two-one { + grid-column: span 6 !important; + } } diff --git a/src/components/layout/Grid.tsx b/src/components/layout/Grid.tsx new file mode 100644 index 00000000..2a34fdc4 --- /dev/null +++ b/src/components/layout/Grid.tsx @@ -0,0 +1,34 @@ +import { CSSProperties } from 'react'; +import classNames from 'classnames'; +import { mapChildren } from 'react-basics'; +import styles from './Grid.module.css'; + +export interface GridProps { + className?: string; + style?: CSSProperties; + children?: any; +} + +export function Grid({ className, style, children }: GridProps) { + return ( +
+ {children} +
+ ); +} + +export function GridRow(props: { + [x: string]: any; + columns?: 'one' | 'two' | 'three' | 'one-two' | 'two-one'; + className?: string; + children?: any; +}) { + const { columns = 'two', className, children, ...otherProps } = props; + return ( +
+ {mapChildren(children, child => { + return
{child}
; + })} +
+ ); +} diff --git a/src/components/layout/Header.js b/src/components/layout/Header.js deleted file mode 100644 index 3997269f..00000000 --- a/src/components/layout/Header.js +++ /dev/null @@ -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 ( -
- - - - - - - umami - - - - - - - - -
- ); -} - -export default Header; diff --git a/src/components/layout/NavBar.js b/src/components/layout/NavBar.js deleted file mode 100644 index 07627e2a..00000000 --- a/src/components/layout/NavBar.js +++ /dev/null @@ -1,63 +0,0 @@ -import { Icon, Text, Row, Column } from 'react-basics'; -import Link from 'next/link'; -import { useRouter } from 'next/router'; -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 useMessages from 'components/hooks/useMessages'; -import HamburgerButton from 'components/common/HamburgerButton'; -import styles from './NavBar.module.css'; - -export function NavBar() { - const { pathname } = useRouter(); - const { formatMessage, labels } = useMessages(); - - const links = [ - { label: formatMessage(labels.dashboard), url: '/dashboard' }, - { label: formatMessage(labels.websites), url: '/websites' }, - { label: formatMessage(labels.reports), url: '/reports' }, - { label: formatMessage(labels.settings), url: '/settings' }, - ].filter(n => n); - - return ( -
- - -
- - - - umami -
-
- {links.map(({ url, label }) => { - return ( - - {label} - - ); - })} -
-
- -
- - - -
-
- -
-
-
-
- ); -} - -export default NavBar; diff --git a/src/components/layout/NavGroup.js b/src/components/layout/NavGroup.tsx similarity index 86% rename from src/components/layout/NavGroup.js rename to src/components/layout/NavGroup.tsx index 94f9d8e6..e95b61fa 100644 --- a/src/components/layout/NavGroup.js +++ b/src/components/layout/NavGroup.tsx @@ -1,19 +1,27 @@ import { useState } from 'react'; import { Icon, Text, TooltipPopup } from 'react-basics'; import classNames from 'classnames'; -import { useRouter } from 'next/router'; +import { usePathname } from 'next/navigation'; import Link from 'next/link'; import Icons from 'components/icons'; import styles from './NavGroup.module.css'; +export interface NavGroupProps { + title: string; + items: any[]; + defaultExpanded?: boolean; + allowExpand?: boolean; + minimized?: boolean; +} + export function NavGroup({ title, items, defaultExpanded = true, allowExpand = true, minimized = false, -}) { - const { pathname } = useRouter(); +}: NavGroupProps) { + const pathname = usePathname(); const [expanded, setExpanded] = useState(defaultExpanded); const handleExpand = () => setExpanded(state => !state); diff --git a/src/components/layout/Page.module.css b/src/components/layout/Page.module.css index c546971b..52893157 100644 --- a/src/components/layout/Page.module.css +++ b/src/components/layout/Page.module.css @@ -2,6 +2,10 @@ flex: 1; display: flex; flex-direction: column; - background: var(--base50); position: relative; + width: 100%; + max-width: 1320px; + margin: 0 auto; + padding: 0 20px; + min-height: calc(100vh - 60px); } diff --git a/src/components/layout/Page.js b/src/components/layout/Page.tsx similarity index 61% rename from src/components/layout/Page.js rename to src/components/layout/Page.tsx index 4f42aa55..e32a09a3 100644 --- a/src/components/layout/Page.js +++ b/src/components/layout/Page.tsx @@ -1,17 +1,29 @@ +'use client'; +import { ReactNode } from 'react'; import classNames from 'classnames'; import { Banner, Loading } from 'react-basics'; import useMessages from 'components/hooks/useMessages'; import styles from './Page.module.css'; -export function Page({ className, error, loading, children }) { +export function Page({ + className, + error, + isLoading, + children, +}: { + className?: string; + error?: unknown; + isLoading?: boolean; + children?: ReactNode; +}) { const { formatMessage, messages } = useMessages(); if (error) { return {formatMessage(messages.error)}; } - if (loading) { - return ; + if (isLoading) { + return ; } return
{children}
; diff --git a/src/components/layout/PageHeader.module.css b/src/components/layout/PageHeader.module.css index 8e615b93..a4eeb4c6 100644 --- a/src/components/layout/PageHeader.module.css +++ b/src/components/layout/PageHeader.module.css @@ -36,9 +36,4 @@ .header { margin-bottom: 10px; } - - .actions { - flex-basis: 100%; - order: -1; - } } diff --git a/src/components/layout/PageHeader.js b/src/components/layout/PageHeader.tsx similarity index 58% rename from src/components/layout/PageHeader.js rename to src/components/layout/PageHeader.tsx index f1363140..2261bebc 100644 --- a/src/components/layout/PageHeader.js +++ b/src/components/layout/PageHeader.tsx @@ -1,8 +1,14 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { ReactNode } from 'react'; import styles from './PageHeader.module.css'; -export function PageHeader({ title, children, className }) { +export interface PageHeaderProps { + title?: ReactNode; + className?: string; + children?: ReactNode; +} + +export function PageHeader({ title, className, children }: PageHeaderProps) { return (
{title &&
{title}
} diff --git a/src/components/layout/ReportsLayout.js b/src/components/layout/ReportsLayout.js deleted file mode 100644 index 374da263..00000000 --- a/src/components/layout/ReportsLayout.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Column, Row } from 'react-basics'; -import styles from './ReportsLayout.module.css'; - -export function ReportsLayout({ children, filter, header }) { - return ( - <> - {header} - - {filter && ( - -

Filters

- {filter} -
- )} - - {children} - -
- - ); -} - -export default ReportsLayout; diff --git a/src/components/layout/ReportsLayout.module.css b/src/components/layout/ReportsLayout.module.css deleted file mode 100644 index 6922665f..00000000 --- a/src/components/layout/ReportsLayout.module.css +++ /dev/null @@ -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; - } -} diff --git a/src/components/layout/SettingsLayout.module.css b/src/components/layout/SettingsLayout.module.css deleted file mode 100644 index 08ff02aa..00000000 --- a/src/components/layout/SettingsLayout.module.css +++ /dev/null @@ -1,20 +0,0 @@ -.menu { - display: flex; - flex-direction: column; - padding-top: 40px; - padding-right: 20px; -} - -.content { - min-height: 50vh; -} - -@media only screen and (max-width: 768px) { - .menu { - display: none; - } - - .content { - margin-top: 20px; - } -} diff --git a/src/components/layout/ShareLayout.js b/src/components/layout/ShareLayout.js deleted file mode 100644 index c634e1b6..00000000 --- a/src/components/layout/ShareLayout.js +++ /dev/null @@ -1,15 +0,0 @@ -import { Container } from 'react-basics'; -import Header from './Header'; -import Footer from './Footer'; - -export function ShareLayout({ children }) { - return ( - -
-
{children}
-