Merge branch 'dev' into session-recording
This commit is contained in:
+2
-1
@@ -11,6 +11,7 @@ package-lock.json
|
|||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
|
next-env.d.ts
|
||||||
/.next
|
/.next
|
||||||
/out
|
/out
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ pm2.yml
|
|||||||
.vscode
|
.vscode
|
||||||
.tool-versions
|
.tool-versions
|
||||||
.claude
|
.claude
|
||||||
|
.agents
|
||||||
tmpclaude*
|
tmpclaude*
|
||||||
nul
|
nul
|
||||||
|
|
||||||
@@ -47,4 +49,3 @@ yarn-error.log*
|
|||||||
*.env.*
|
*.env.*
|
||||||
|
|
||||||
*.dev.yml
|
*.dev.yml
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance for AI coding agents working in this repository.
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
Umami is a privacy-focused web analytics platform built with Next.js 15, React 19, and TypeScript.
|
||||||
|
|
||||||
|
- Primary database: PostgreSQL
|
||||||
|
- Optional analytics backend: ClickHouse
|
||||||
|
- Optional cache/session backend: Redis
|
||||||
|
|
||||||
|
## Development rules
|
||||||
|
|
||||||
|
- Assume a dev server is already running on port `3001`.
|
||||||
|
- Do **not** start another dev server.
|
||||||
|
- Use `pnpm` (not `npm` or `yarn`).
|
||||||
|
- Avoid destructive shell commands unless explicitly requested.
|
||||||
|
- Ask before running `git commit` or `git push`.
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
pnpm dev
|
||||||
|
pnpm build
|
||||||
|
pnpm start
|
||||||
|
|
||||||
|
# Database
|
||||||
|
pnpm build-db
|
||||||
|
pnpm update-db
|
||||||
|
pnpm check-db
|
||||||
|
pnpm seed-data
|
||||||
|
|
||||||
|
# Code quality
|
||||||
|
pnpm lint
|
||||||
|
pnpm format
|
||||||
|
pnpm check
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Build specific parts
|
||||||
|
pnpm build-tracker
|
||||||
|
pnpm build-geo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `src/app/`: Next.js App Router routes and API endpoints
|
||||||
|
- `src/components/`: UI components and hooks
|
||||||
|
- `src/lib/`: shared utilities and infrastructure helpers
|
||||||
|
- `src/queries/`: data access layer (Prisma + raw SQL)
|
||||||
|
- `src/store/`: Zustand stores
|
||||||
|
- `src/tracker/`: standalone client tracking script
|
||||||
|
- `prisma/`: schema and migrations
|
||||||
|
|
||||||
|
## Key implementation patterns
|
||||||
|
|
||||||
|
### API request validation
|
||||||
|
|
||||||
|
Use Zod + `parseRequest` in API handlers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const schema = z.object({ /* fields */ });
|
||||||
|
const { body, error } = await parseRequest(request, schema);
|
||||||
|
if (error) return error();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- JWT via `Authorization: Bearer <token>`
|
||||||
|
- Share token via `x-umami-share-token`
|
||||||
|
- Role model: `admin`, `manager`, `user`
|
||||||
|
|
||||||
|
### Client data fetching
|
||||||
|
|
||||||
|
- React Query defaults: `staleTime` 60s, no retry, no refetch on window focus
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- CSS Modules with CSS variables and theme support
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
Common env vars:
|
||||||
|
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `APP_SECRET`
|
||||||
|
- `CLICKHOUSE_URL`
|
||||||
|
- `REDIS_URL`
|
||||||
|
- `BASE_PATH`
|
||||||
|
- `DEBUG`
|
||||||
|
|
||||||
|
## Runtime requirements
|
||||||
|
|
||||||
|
- Node.js 18.18+
|
||||||
|
- PostgreSQL 12.14+
|
||||||
|
- `pnpm`
|
||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import './.next/dev/types/routes.d.ts';
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
+15
-5
@@ -1,6 +1,9 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
import pkg from './package.json' with { type: 'json' };
|
import pkg from './package.json' with { type: 'json' };
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||||
|
|
||||||
const TRACKER_SCRIPT = '/script.js';
|
const TRACKER_SCRIPT = '/script.js';
|
||||||
|
|
||||||
const basePath = process.env.BASE_PATH || '';
|
const basePath = process.env.BASE_PATH || '';
|
||||||
@@ -113,6 +116,16 @@ if (collectApiEndpoint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redirects = [
|
const redirects = [
|
||||||
|
{
|
||||||
|
source: '/teams/:id/dashboard/edit',
|
||||||
|
destination: '/dashboard/edit',
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/teams/:id/dashboard',
|
||||||
|
destination: '/dashboard',
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '/settings',
|
source: '/settings',
|
||||||
destination: '/settings/preferences',
|
destination: '/settings/preferences',
|
||||||
@@ -164,7 +177,7 @@ if (cloudMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
export default {
|
export default withNextIntl({
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
env: {
|
env: {
|
||||||
basePath,
|
basePath,
|
||||||
@@ -176,9 +189,6 @@ export default {
|
|||||||
},
|
},
|
||||||
basePath,
|
basePath,
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
@@ -202,4 +212,4 @@ export default {
|
|||||||
async redirects() {
|
async redirects() {
|
||||||
return [...redirects];
|
return [...redirects];
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
+6
-12
@@ -20,11 +20,11 @@
|
|||||||
"start-server": "node server.js",
|
"start-server": "node server.js",
|
||||||
"build-app": "next build --turbo",
|
"build-app": "next build --turbo",
|
||||||
"build-icons": "svgr ./src/assets --out-dir src/components/svg --typescript",
|
"build-icons": "svgr ./src/assets --out-dir src/components/svg --typescript",
|
||||||
"build-components": "tsup",
|
"build-components": "node scripts/bump-components.js && tsup",
|
||||||
"build-tracker": "rollup -c rollup.tracker.config.js",
|
"build-tracker": "rollup -c rollup.tracker.config.js",
|
||||||
"build-recorder": "rollup -c rollup.recorder.config.js",
|
"build-recorder": "rollup -c rollup.recorder.config.js",
|
||||||
"build-prisma-client": "node scripts/build-prisma-client.js",
|
"build-prisma-client": "node scripts/build-prisma-client.js",
|
||||||
"build-lang": "npm-run-all format-lang compile-lang download-country-names download-language-names clean-lang",
|
"build-lang": "npm-run-all download-country-names download-language-names",
|
||||||
"build-geo": "node scripts/build-geo.js",
|
"build-geo": "node scripts/build-geo.js",
|
||||||
"build-db": "npm-run-all build-db-client build-prisma-client",
|
"build-db": "npm-run-all build-db-client build-prisma-client",
|
||||||
"build-db-schema": "prisma db pull",
|
"build-db-schema": "prisma db pull",
|
||||||
@@ -34,12 +34,6 @@
|
|||||||
"check-db": "node scripts/check-db.js",
|
"check-db": "node scripts/check-db.js",
|
||||||
"check-env": "node scripts/check-env.js",
|
"check-env": "node scripts/check-env.js",
|
||||||
"copy-db-files": "node scripts/copy-db-files.js",
|
"copy-db-files": "node scripts/copy-db-files.js",
|
||||||
"extract-messages": "formatjs extract \"src/components/messages.ts\" --out-file build/extracted-messages.json",
|
|
||||||
"merge-messages": "node scripts/merge-messages.js",
|
|
||||||
"generate-lang": "npm-run-all extract-messages merge-messages",
|
|
||||||
"format-lang": "node scripts/format-lang.js",
|
|
||||||
"compile-lang": "formatjs compile-folder --ast build/messages public/intl/messages",
|
|
||||||
"clean-lang": "prettier --write ./public/intl/**/*.json",
|
|
||||||
"download-country-names": "node scripts/download-country-names.js",
|
"download-country-names": "node scripts/download-country-names.js",
|
||||||
"download-language-names": "node scripts/download-language-names.js",
|
"download-language-names": "node scripts/download-language-names.js",
|
||||||
"change-password": "node scripts/change-password.js",
|
"change-password": "node scripts/change-password.js",
|
||||||
@@ -73,7 +67,7 @@
|
|||||||
"@react-spring/web": "^10.0.3",
|
"@react-spring/web": "^10.0.3",
|
||||||
"@svgr/cli": "^8.1.0",
|
"@svgr/cli": "^8.1.0",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@umami/react-zen": "^0.242.0",
|
"@umami/react-zen": "^0.245.0",
|
||||||
"@umami/redis-client": "^0.30.0",
|
"@umami/redis-client": "^0.30.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
@@ -103,6 +97,7 @@
|
|||||||
"lucide-react": "^0.543.0",
|
"lucide-react": "^0.543.0",
|
||||||
"maxmind": "^5.0.5",
|
"maxmind": "^5.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"next-intl": "^4.8.2",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
@@ -112,7 +107,6 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-error-boundary": "^4.0.4",
|
"react-error-boundary": "^4.0.4",
|
||||||
"react-intl": "^7.1.14",
|
|
||||||
"react-resizable-panels": "^4.6.0",
|
"react-resizable-panels": "^4.6.0",
|
||||||
"react-simple-maps": "^2.3.0",
|
"react-simple-maps": "^2.3.0",
|
||||||
"react-use-measure": "^2.0.4",
|
"react-use-measure": "^2.0.4",
|
||||||
@@ -130,7 +124,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.14",
|
"@biomejs/biome": "^2.3.14",
|
||||||
"@formatjs/cli": "^4.2.29",
|
|
||||||
"@netlify/plugin-nextjs": "^5.15.7",
|
"@netlify/plugin-nextjs": "^5.15.7",
|
||||||
"@rollup/plugin-alias": "^5.0.0",
|
"@rollup/plugin-alias": "^5.0.0",
|
||||||
"@rollup/plugin-commonjs": "^25.0.4",
|
"@rollup/plugin-commonjs": "^25.0.4",
|
||||||
@@ -146,7 +139,7 @@
|
|||||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"cypress": "^13.6.6",
|
"cypress": "^13.6.6",
|
||||||
"extract-react-intl-messages": "^4.1.1",
|
"dotenv-cli": "^11.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"lint-staged": "^16.2.6",
|
"lint-staged": "^16.2.6",
|
||||||
@@ -168,6 +161,7 @@
|
|||||||
"stylelint-config-recommended": "^14.0.0",
|
"stylelint-config-recommended": "^14.0.0",
|
||||||
"tar": "^7.5.7",
|
"tar": "^7.5.7",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
|
"ts-morph": "^27.0.2",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
|
|||||||
Generated
+540
-661
File diff suppressed because it is too large
Load Diff
+14
-13
@@ -10,7 +10,7 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @unique @map("user_id") @db.Uuid
|
id String @id() @map("user_id") @db.Uuid
|
||||||
username String @unique @db.VarChar(255)
|
username String @unique @db.VarChar(255)
|
||||||
password String @db.VarChar(60)
|
password String @db.VarChar(60)
|
||||||
role String @map("role") @db.VarChar(50)
|
role String @map("role") @db.VarChar(50)
|
||||||
@@ -32,7 +32,7 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @unique @map("session_id") @db.Uuid
|
id String @id() @map("session_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
browser String? @db.VarChar(20)
|
browser String? @db.VarChar(20)
|
||||||
os String? @db.VarChar(20)
|
os String? @db.VarChar(20)
|
||||||
@@ -64,7 +64,7 @@ model Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Website {
|
model Website {
|
||||||
id String @id @unique @map("website_id") @db.Uuid
|
id String @id() @map("website_id") @db.Uuid
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
domain String? @db.VarChar(500)
|
domain String? @db.VarChar(500)
|
||||||
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
||||||
@@ -189,7 +189,7 @@ model SessionData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
id String @id() @unique() @map("team_id") @db.Uuid
|
id String @id() @map("team_id") @db.Uuid
|
||||||
name String @db.VarChar(50)
|
name String @db.VarChar(50)
|
||||||
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
||||||
logoUrl String? @map("logo_url") @db.VarChar(2183)
|
logoUrl String? @map("logo_url") @db.VarChar(2183)
|
||||||
@@ -201,14 +201,14 @@ model Team {
|
|||||||
members TeamUser[]
|
members TeamUser[]
|
||||||
links Link[]
|
links Link[]
|
||||||
pixels Pixel[]
|
pixels Pixel[]
|
||||||
boards Board[]
|
boards Board[]
|
||||||
|
|
||||||
@@index([accessCode])
|
@@index([accessCode])
|
||||||
@@map("team")
|
@@map("team")
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamUser {
|
model TeamUser {
|
||||||
id String @id() @unique() @map("team_user_id") @db.Uuid
|
id String @id() @map("team_user_id") @db.Uuid
|
||||||
teamId String @map("team_id") @db.Uuid
|
teamId String @map("team_id") @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
role String @db.VarChar(50)
|
role String @db.VarChar(50)
|
||||||
@@ -224,7 +224,7 @@ model TeamUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Report {
|
model Report {
|
||||||
id String @id() @unique() @map("report_id") @db.Uuid
|
id String @id() @map("report_id") @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
type String @db.VarChar(50)
|
type String @db.VarChar(50)
|
||||||
@@ -245,7 +245,7 @@ model Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Segment {
|
model Segment {
|
||||||
id String @id() @unique() @map("segment_id") @db.Uuid
|
id String @id() @map("segment_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
type String @db.VarChar(50)
|
type String @db.VarChar(50)
|
||||||
name String @db.VarChar(200)
|
name String @db.VarChar(200)
|
||||||
@@ -260,7 +260,7 @@ model Segment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Revenue {
|
model Revenue {
|
||||||
id String @id() @unique() @map("revenue_id") @db.Uuid
|
id String @id() @map("revenue_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
sessionId String @map("session_id") @db.Uuid
|
sessionId String @map("session_id") @db.Uuid
|
||||||
eventId String @map("event_id") @db.Uuid
|
eventId String @map("event_id") @db.Uuid
|
||||||
@@ -280,7 +280,7 @@ model Revenue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Link {
|
model Link {
|
||||||
id String @id() @unique() @map("link_id") @db.Uuid
|
id String @id() @map("link_id") @db.Uuid
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
url String @db.VarChar(500)
|
url String @db.VarChar(500)
|
||||||
slug String @unique() @db.VarChar(100)
|
slug String @unique() @db.VarChar(100)
|
||||||
@@ -301,7 +301,7 @@ model Link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Pixel {
|
model Pixel {
|
||||||
id String @id() @unique() @map("pixel_id") @db.Uuid
|
id String @id() @map("pixel_id") @db.Uuid
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
slug String @unique() @db.VarChar(100)
|
slug String @unique() @db.VarChar(100)
|
||||||
userId String? @map("user_id") @db.Uuid
|
userId String? @map("user_id") @db.Uuid
|
||||||
@@ -321,7 +321,7 @@ model Pixel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Board {
|
model Board {
|
||||||
id String @id() @unique() @map("board_id") @db.Uuid
|
id String @id() @map("board_id") @db.Uuid
|
||||||
type String @db.VarChar(50)
|
type String @db.VarChar(50)
|
||||||
name String @db.VarChar(200)
|
name String @db.VarChar(200)
|
||||||
description String @db.VarChar(500)
|
description String @db.VarChar(500)
|
||||||
@@ -343,8 +343,9 @@ model Board {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Share {
|
model Share {
|
||||||
id String @id() @unique() @map("share_id") @db.Uuid
|
id String @id() @map("share_id") @db.Uuid
|
||||||
entityId String @map("entity_id") @db.Uuid
|
entityId String @map("entity_id") @db.Uuid
|
||||||
|
name String @db.VarChar(200)
|
||||||
shareType Int @map("share_type") @db.Integer
|
shareType Int @map("share_type") @db.Integer
|
||||||
slug String @unique() @db.VarChar(100)
|
slug String @unique() @db.VarChar(100)
|
||||||
parameters Json
|
parameters Json
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+386
-2212
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2226
File diff suppressed because it is too large
Load Diff
+386
-2228
File diff suppressed because it is too large
Load Diff
+386
-2226
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2212
File diff suppressed because it is too large
Load Diff
+386
-2208
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+390
-2226
File diff suppressed because it is too large
Load Diff
+388
-2226
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+386
-2200
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2204
File diff suppressed because it is too large
Load Diff
+386
-2228
File diff suppressed because it is too large
Load Diff
+386
-2204
File diff suppressed because it is too large
Load Diff
+386
-2212
File diff suppressed because it is too large
Load Diff
+386
-2234
File diff suppressed because it is too large
Load Diff
+386
-2224
File diff suppressed because it is too large
Load Diff
+386
-2184
File diff suppressed because it is too large
Load Diff
+386
-2224
File diff suppressed because it is too large
Load Diff
+386
-2210
File diff suppressed because it is too large
Load Diff
+386
-2210
File diff suppressed because it is too large
Load Diff
+386
-2166
File diff suppressed because it is too large
Load Diff
+386
-2345
File diff suppressed because it is too large
Load Diff
+386
-2234
File diff suppressed because it is too large
Load Diff
+386
-2212
File diff suppressed because it is too large
Load Diff
+386
-2200
File diff suppressed because it is too large
Load Diff
+386
-2224
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2204
File diff suppressed because it is too large
Load Diff
+386
-2224
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+386
-2188
File diff suppressed because it is too large
Load Diff
+386
-2226
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+390
-2094
File diff suppressed because it is too large
Load Diff
+386
-2224
File diff suppressed because it is too large
Load Diff
+386
-2216
File diff suppressed because it is too large
Load Diff
+386
-2216
File diff suppressed because it is too large
Load Diff
+386
-2176
File diff suppressed because it is too large
Load Diff
+386
-2196
File diff suppressed because it is too large
Load Diff
+386
-2220
File diff suppressed because it is too large
Load Diff
+392
-1856
File diff suppressed because it is too large
Load Diff
+392
-1858
File diff suppressed because it is too large
Load Diff
+386
-2404
File diff suppressed because it is too large
Load Diff
+386
-2198
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,11 @@ import https from 'https';
|
|||||||
import { list } from 'tar';
|
import { list } from 'tar';
|
||||||
import zlib from 'zlib';
|
import zlib from 'zlib';
|
||||||
|
|
||||||
|
if (process.env.SKIP_BUILD_GEO) {
|
||||||
|
console.log('SKIP_BUILD_GEO is set. Skipping geo setup.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.VERCEL && !process.env.BUILD_GEO) {
|
if (process.env.VERCEL && !process.env.BUILD_GEO) {
|
||||||
console.log('Vercel environment detected. Skipping geo setup.');
|
console.log('Vercel environment detected. Skipping geo setup.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const distDir = path.resolve(process.cwd(), 'dist');
|
||||||
|
const packageFile = path.join(distDir, 'package.json');
|
||||||
|
|
||||||
|
const defaultPackage = {
|
||||||
|
name: '@umami/components',
|
||||||
|
version: '0.0.0',
|
||||||
|
description: 'Umami React components.',
|
||||||
|
author: 'Mike Cao <mike@mikecao.com>',
|
||||||
|
license: 'MIT',
|
||||||
|
type: 'module',
|
||||||
|
main: './index.js',
|
||||||
|
types: './index.d.ts',
|
||||||
|
dependencies: {
|
||||||
|
'chart.js': '^4.5.0',
|
||||||
|
'chartjs-adapter-date-fns': '^3.0.0',
|
||||||
|
colord: '^2.9.2',
|
||||||
|
jsonwebtoken: '^9.0.2',
|
||||||
|
'lucide-react': '^0.542.0',
|
||||||
|
'pure-rand': '^7.0.1',
|
||||||
|
'react-simple-maps': '^2.3.0',
|
||||||
|
'react-use-measure': '^2.0.4',
|
||||||
|
'react-window': '^1.8.6',
|
||||||
|
'serialize-error': '^12.0.0',
|
||||||
|
thenby: '^1.3.4',
|
||||||
|
uuid: '^11.1.0',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fs.existsSync(distDir)) {
|
||||||
|
fs.mkdirSync(distDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = fs.existsSync(packageFile)
|
||||||
|
? JSON.parse(fs.readFileSync(packageFile, 'utf8'))
|
||||||
|
: defaultPackage;
|
||||||
|
|
||||||
|
const published = execSync(`npm view ${pkg.name} version`, { encoding: 'utf8' }).trim();
|
||||||
|
const [major, minor] = published.split('.').map(Number);
|
||||||
|
const next = `${major}.${minor + 1}.0`;
|
||||||
|
|
||||||
|
pkg.version = next;
|
||||||
|
|
||||||
|
fs.writeFileSync(packageFile, `${JSON.stringify(pkg, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log(`Bumped ${pkg.name} version: ${published} -> ${next}`);
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import https from 'node:https';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import https from 'https';
|
|
||||||
|
|
||||||
const src = path.resolve(process.cwd(), 'src/lang');
|
const src = path.resolve(process.cwd(), 'public/intl/messages');
|
||||||
const dest = path.resolve(process.cwd(), 'public/intl/country');
|
const dest = path.resolve(process.cwd(), 'public/intl/country');
|
||||||
const files = fs.readdirSync(src);
|
const files = fs.readdirSync(src);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import https from 'node:https';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import https from 'https';
|
|
||||||
|
|
||||||
const src = path.resolve(process.cwd(), 'src/lang');
|
const src = path.resolve(process.cwd(), 'public/intl/messages');
|
||||||
const dest = path.resolve(process.cwd(), 'public/intl/language');
|
const dest = path.resolve(process.cwd(), 'public/intl/language');
|
||||||
const files = fs.readdirSync(src);
|
const files = fs.readdirSync(src);
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import del from 'del';
|
|
||||||
import fs from 'fs-extra';
|
|
||||||
import { createRequire } from 'module';
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const src = path.resolve(process.cwd(), 'src/lang');
|
|
||||||
const dest = path.resolve(process.cwd(), 'build/messages');
|
|
||||||
const files = fs.readdirSync(src);
|
|
||||||
|
|
||||||
del.sync([path.join(dest)]);
|
|
||||||
|
|
||||||
/*
|
|
||||||
This script takes the files from the `lang` folder and formats them into
|
|
||||||
the format that format-js expects.
|
|
||||||
*/
|
|
||||||
async function run() {
|
|
||||||
await fs.ensureDir(dest);
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
const lang = require(path.resolve(process.cwd(), `src/lang/${file}`));
|
|
||||||
const keys = Object.keys(lang).sort();
|
|
||||||
|
|
||||||
const formatted = keys.reduce((obj, key) => {
|
|
||||||
obj[key] = { defaultMessage: lang[key] };
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const json = JSON.stringify(formatted, null, 2);
|
|
||||||
|
|
||||||
fs.writeFileSync(path.resolve(dest, file), json);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { createRequire } from 'module';
|
|
||||||
import prettier from 'prettier';
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
|
|
||||||
const messages = require('../build/extracted-messages.json');
|
|
||||||
const dest = path.resolve(process.cwd(), 'src/lang');
|
|
||||||
const files = fs.readdirSync(dest);
|
|
||||||
const keys = Object.keys(messages).sort();
|
|
||||||
|
|
||||||
/*
|
|
||||||
This script takes extracted messages and merges them
|
|
||||||
with the existing files under `lang`. Any newly added
|
|
||||||
keys will be printed to the console.
|
|
||||||
*/
|
|
||||||
files.forEach(file => {
|
|
||||||
const lang = require(path.resolve(process.cwd(), `src/lang/${file}`));
|
|
||||||
|
|
||||||
console.log(`Merging ${file}`);
|
|
||||||
|
|
||||||
const merged = keys.reduce((obj, key) => {
|
|
||||||
const message = lang[key];
|
|
||||||
|
|
||||||
if (file === 'en-US.json') {
|
|
||||||
obj[key] = messages[key].defaultMessage;
|
|
||||||
} else {
|
|
||||||
obj[key] = message || messages[key].defaultMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
console.log(`* Added key ${key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const json = prettier.format(JSON.stringify(merged), { parser: 'json' });
|
|
||||||
|
|
||||||
fs.writeFileSync(path.resolve(dest, file), json);
|
|
||||||
});
|
|
||||||
@@ -4,6 +4,7 @@ import Script from 'next/script';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { MobileNav } from '@/app/(main)/MobileNav';
|
import { MobileNav } from '@/app/(main)/MobileNav';
|
||||||
import { SideNav } from '@/app/(main)/SideNav';
|
import { SideNav } from '@/app/(main)/SideNav';
|
||||||
|
import { TopNav } from '@/app/(main)/TopNav';
|
||||||
import { useConfig, useLoginQuery, useNavigation } from '@/components/hooks';
|
import { useConfig, useLoginQuery, useNavigation } from '@/components/hooks';
|
||||||
import { LAST_TEAM_CONFIG } from '@/lib/constants';
|
import { LAST_TEAM_CONFIG } from '@/lib/constants';
|
||||||
import { removeItem, setItem } from '@/lib/storage';
|
import { removeItem, setItem } from '@/lib/storage';
|
||||||
@@ -46,11 +47,12 @@ export function App({ children }) {
|
|||||||
<Row display={{ base: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
|
<Row display={{ base: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
</Row>
|
</Row>
|
||||||
<Column display={{ base: 'none', lg: 'flex' }}>
|
<Column display={{ base: 'none', lg: 'flex' }} minHeight="0" style={{ overflow: 'hidden' }}>
|
||||||
<SideNav />
|
<SideNav />
|
||||||
</Column>
|
</Column>
|
||||||
<Column alignItems="center" overflowY="auto" overflowX="hidden" position="relative">
|
<Column overflowX="hidden" minHeight="0" position="relative">
|
||||||
{children}
|
<TopNav />
|
||||||
|
<Column alignItems="center">{children}</Column>
|
||||||
</Column>
|
</Column>
|
||||||
<UpdateNotice user={user} config={config} />
|
<UpdateNotice user={user} config={config} />
|
||||||
{process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
|
{process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
|
||||||
|
|||||||
@@ -1,37 +1,44 @@
|
|||||||
import { Grid, Row, Text } from '@umami/react-zen';
|
import { Column, Grid, Row, Text } from '@umami/react-zen';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
|
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
|
||||||
import { IconLabel } from '@/components/common/IconLabel';
|
import { IconLabel } from '@/components/common/IconLabel';
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { Globe, Grid2x2, LinkIcon } from '@/components/icons';
|
import { Globe, Grid2x2, LayoutDashboard, LinkIcon } from '@/components/icons';
|
||||||
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
|
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
|
||||||
import { NavButton } from '@/components/input/NavButton';
|
import { UserButton } from '@/components/input/UserButton';
|
||||||
import { Logo } from '@/components/svg';
|
import { Logo } from '@/components/svg';
|
||||||
import { AdminNav } from './admin/AdminNav';
|
import { AdminNav } from './admin/AdminNav';
|
||||||
import { SettingsNav } from './settings/SettingsNav';
|
import { SettingsNav } from './settings/SettingsNav';
|
||||||
|
|
||||||
export function MobileNav() {
|
export function MobileNav() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const { pathname, websiteId, renderUrl } = useNavigation();
|
const { pathname, websiteId, renderUrl } = useNavigation();
|
||||||
const isAdmin = pathname.includes('/admin');
|
const isAdmin = pathname.includes('/admin');
|
||||||
const isSettings = pathname.includes('/settings');
|
const isSettings = pathname.includes('/settings');
|
||||||
|
const isMain = !websiteId && !isAdmin && !isSettings;
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
|
{
|
||||||
|
id: 'boards',
|
||||||
|
label: t(labels.boards),
|
||||||
|
path: '/boards',
|
||||||
|
icon: <LayoutDashboard />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'websites',
|
id: 'websites',
|
||||||
label: formatMessage(labels.websites),
|
label: t(labels.websites),
|
||||||
path: '/websites',
|
path: '/websites',
|
||||||
icon: <Globe />,
|
icon: <Globe />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'links',
|
id: 'links',
|
||||||
label: formatMessage(labels.links),
|
label: t(labels.links),
|
||||||
path: '/links',
|
path: '/links',
|
||||||
icon: <LinkIcon />,
|
icon: <LinkIcon />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pixels',
|
id: 'pixels',
|
||||||
label: formatMessage(labels.pixels),
|
label: t(labels.pixels),
|
||||||
path: '/pixels',
|
path: '/pixels',
|
||||||
icon: <Grid2x2 />,
|
icon: <Grid2x2 />,
|
||||||
},
|
},
|
||||||
@@ -42,21 +49,24 @@ export function MobileNav() {
|
|||||||
<MobileMenuButton>
|
<MobileMenuButton>
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Column gap="2" display="flex" flex-direction="column" height="100vh" padding="1">
|
||||||
<Row padding="3" onClick={close} border="bottom">
|
{isMain &&
|
||||||
<NavButton />
|
links.map(link => {
|
||||||
{links.map(link => {
|
|
||||||
return (
|
return (
|
||||||
<Link key={link.id} href={renderUrl(link.path)}>
|
<Row key={link.id} padding>
|
||||||
<IconLabel icon={link.icon} label={link.label} />
|
<Link href={renderUrl(link.path)} onClick={close}>
|
||||||
</Link>
|
<IconLabel icon={link.icon} label={link.label} />
|
||||||
|
</Link>
|
||||||
|
</Row>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Row>
|
|
||||||
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
|
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
|
||||||
{isAdmin && <AdminNav onItemClick={close} />}
|
{isAdmin && <AdminNav onItemClick={close} />}
|
||||||
{isSettings && <SettingsNav onItemClick={close} />}
|
{isSettings && <SettingsNav onItemClick={close} />}
|
||||||
</>
|
<Row onClick={close} style={{ marginTop: 'auto' }}>
|
||||||
|
<UserButton />
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</MobileMenuButton>
|
</MobileMenuButton>
|
||||||
|
|||||||
+48
-41
@@ -6,67 +6,76 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
Row,
|
Row,
|
||||||
Text,
|
Text,
|
||||||
ThemeButton,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Key } from 'react';
|
import { SettingsNav } from '@/app/(main)/settings/SettingsNav';
|
||||||
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
|
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
|
||||||
import { IconLabel } from '@/components/common/IconLabel';
|
import { IconLabel } from '@/components/common/IconLabel';
|
||||||
import { useGlobalState, useMessages, useNavigation } from '@/components/hooks';
|
import { useGlobalState, useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { Globe, Grid2x2, LayoutDashboard, LinkIcon, PanelLeft } from '@/components/icons';
|
import {
|
||||||
import { LanguageButton } from '@/components/input/LanguageButton';
|
Globe,
|
||||||
import { NavButton } from '@/components/input/NavButton';
|
Grid2x2,
|
||||||
|
LayoutDashboard,
|
||||||
|
LinkIcon,
|
||||||
|
PanelLeft,
|
||||||
|
PanelsLeftBottom,
|
||||||
|
} from '@/components/icons';
|
||||||
|
import { UserButton } from '@/components/input/UserButton';
|
||||||
import { Logo } from '@/components/svg';
|
import { Logo } from '@/components/svg';
|
||||||
|
|
||||||
export function SideNav(props: any) {
|
export function SideNav(props: any) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const { pathname, renderUrl, websiteId, router } = useNavigation();
|
const { pathname, renderUrl, websiteId, teamId } = useNavigation();
|
||||||
const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed', false);
|
const [isCollapsed] = useGlobalState('sidenav-collapsed', false);
|
||||||
|
|
||||||
const hasNav = !!(websiteId || pathname.startsWith('/admin') || pathname.includes('/settings'));
|
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
|
...(!teamId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: t(labels.dashboard),
|
||||||
|
path: '/dashboard',
|
||||||
|
icon: <PanelsLeftBottom />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: 'boards',
|
id: 'boards',
|
||||||
label: formatMessage(labels.boards),
|
label: t(labels.boards),
|
||||||
path: '/boards',
|
path: '/boards',
|
||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'websites',
|
id: 'websites',
|
||||||
label: formatMessage(labels.websites),
|
label: t(labels.websites),
|
||||||
path: '/websites',
|
path: '/websites',
|
||||||
icon: <Globe />,
|
icon: <Globe />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'links',
|
id: 'links',
|
||||||
label: formatMessage(labels.links),
|
label: t(labels.links),
|
||||||
path: '/links',
|
path: '/links',
|
||||||
icon: <LinkIcon />,
|
icon: <LinkIcon />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pixels',
|
id: 'pixels',
|
||||||
label: formatMessage(labels.pixels),
|
label: t(labels.pixels),
|
||||||
path: '/pixels',
|
path: '/pixels',
|
||||||
icon: <Grid2x2 />,
|
icon: <Grid2x2 />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSelect = (id: Key) => {
|
|
||||||
router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column
|
<Column
|
||||||
{...props}
|
{...props}
|
||||||
backgroundColor="surface-base"
|
backgroundColor="surface-base"
|
||||||
justifyContent="space-between"
|
|
||||||
border
|
border
|
||||||
borderRadius
|
borderRadius
|
||||||
paddingX="2"
|
paddingX="2"
|
||||||
height="100%"
|
flexGrow="1"
|
||||||
|
minHeight="0"
|
||||||
margin="2"
|
margin="2"
|
||||||
style={{
|
style={{
|
||||||
width: isCollapsed ? '55px' : '240px',
|
width: isCollapsed ? '55px' : '240px',
|
||||||
@@ -74,27 +83,26 @@ export function SideNav(props: any) {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Column style={{ minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
<Row
|
||||||
<Row
|
alignItems="center"
|
||||||
alignItems="center"
|
justifyContent="space-between"
|
||||||
justifyContent="space-between"
|
height="60px"
|
||||||
height="60px"
|
style={{ flexShrink: 0 }}
|
||||||
style={{ flexShrink: 0 }}
|
>
|
||||||
>
|
<Row paddingX="3" alignItems="center" justifyContent="space-between" flexGrow="1">
|
||||||
<Row paddingX="3" alignItems="center" justifyContent="space-between" flexGrow={1}>
|
{!isCollapsed && (
|
||||||
{!isCollapsed && (
|
<IconLabel icon={<Logo />}>
|
||||||
<IconLabel icon={<Logo />}>
|
<Text weight="bold">umami</Text>
|
||||||
<Text weight="bold">umami</Text>
|
</IconLabel>
|
||||||
</IconLabel>
|
)}
|
||||||
)}
|
<PanelButton />
|
||||||
<PanelButton />
|
|
||||||
</Row>
|
|
||||||
</Row>
|
|
||||||
<Row marginBottom="4" style={{ flexShrink: 0 }}>
|
|
||||||
<NavButton showText={!isCollapsed} onAction={handleSelect} />
|
|
||||||
</Row>
|
</Row>
|
||||||
|
</Row>
|
||||||
|
<Column flexGrow="1" minHeight="0" style={{ overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
{websiteId ? (
|
{websiteId ? (
|
||||||
<WebsiteNav websiteId={websiteId} isCollapsed={isCollapsed} />
|
<WebsiteNav websiteId={websiteId} isCollapsed={isCollapsed} />
|
||||||
|
) : pathname.includes('/settings') ? (
|
||||||
|
<SettingsNav isCollapsed={isCollapsed} />
|
||||||
) : (
|
) : (
|
||||||
<Column gap="2">
|
<Column gap="2">
|
||||||
{links.map(({ id, path, label, icon }) => {
|
{links.map(({ id, path, label, icon }) => {
|
||||||
@@ -126,9 +134,8 @@ export function SideNav(props: any) {
|
|||||||
</Column>
|
</Column>
|
||||||
)}
|
)}
|
||||||
</Column>
|
</Column>
|
||||||
<Row alignItems="center" justifyContent="center" wrap="wrap" marginBottom="4" gap>
|
<Row marginBottom="4" style={{ flexShrink: 0 }}>
|
||||||
<LanguageButton />
|
<UserButton showText={!isCollapsed} />
|
||||||
<ThemeButton />
|
|
||||||
</Row>
|
</Row>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|||||||
+95
-16
@@ -1,31 +1,110 @@
|
|||||||
import { Row, ThemeButton } from '@umami/react-zen';
|
'use client';
|
||||||
import { LanguageButton } from '@/components/input/LanguageButton';
|
import { Icon, Row } from '@umami/react-zen';
|
||||||
import { ProfileButton } from '@/components/input/ProfileButton';
|
import { useNavigation } from '@/components/hooks';
|
||||||
|
import { Slash } from '@/components/icons';
|
||||||
|
import { BoardSelect } from '@/components/input/BoardSelect';
|
||||||
|
import { LinkSelect } from '@/components/input/LinkSelect';
|
||||||
|
import { PixelSelect } from '@/components/input/PixelSelect';
|
||||||
|
import { TeamsButton } from '@/components/input/TeamsButton';
|
||||||
|
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
|
||||||
|
|
||||||
export function TopNav() {
|
export function TopNav() {
|
||||||
|
const { websiteId, linkId, pixelId, boardId, teamId, router, renderUrl } = useNavigation();
|
||||||
|
|
||||||
|
const handleWebsiteChange = (value: string) => {
|
||||||
|
router.push(renderUrl(`/websites/${value}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkChange = (value: string) => {
|
||||||
|
router.push(renderUrl(`/links/${value}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePixelChange = (value: string) => {
|
||||||
|
router.push(renderUrl(`/pixels/${value}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBoardChange = (value: string) => {
|
||||||
|
router.push(renderUrl(`/boards/${value}`));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Row
|
||||||
position="absolute"
|
position="sticky"
|
||||||
top="0"
|
top="0"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="flex-end"
|
justifyContent="flex-start"
|
||||||
paddingY="2"
|
paddingY="2"
|
||||||
paddingX="3"
|
paddingX="3"
|
||||||
paddingRight="5"
|
paddingRight="5"
|
||||||
width="100%"
|
width="100%"
|
||||||
style={{ position: 'sticky', top: 0 }}
|
zIndex={100}
|
||||||
zIndex={1}
|
backgroundColor="surface-raised"
|
||||||
>
|
>
|
||||||
<Row
|
<Row alignItems="center">
|
||||||
alignItems="center"
|
<TeamsButton />
|
||||||
justifyContent="flex-end"
|
{(websiteId || linkId || pixelId || boardId) && (
|
||||||
backgroundColor="surface-raised"
|
<>
|
||||||
borderRadius
|
<Icon size="sm" color="muted" style={{ opacity: 0.7, margin: '0 6px' }}>
|
||||||
>
|
<Slash />
|
||||||
<ThemeButton />
|
</Icon>
|
||||||
<LanguageButton />
|
{websiteId && (
|
||||||
<ProfileButton />
|
<WebsiteSelect
|
||||||
|
websiteId={websiteId}
|
||||||
|
teamId={teamId}
|
||||||
|
onChange={handleWebsiteChange}
|
||||||
|
buttonProps={{
|
||||||
|
variant: 'quiet',
|
||||||
|
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{linkId && (
|
||||||
|
<LinkSelect
|
||||||
|
linkId={linkId}
|
||||||
|
teamId={teamId}
|
||||||
|
onChange={handleLinkChange}
|
||||||
|
buttonProps={{
|
||||||
|
variant: 'quiet',
|
||||||
|
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{pixelId && (
|
||||||
|
<PixelSelect
|
||||||
|
pixelId={pixelId}
|
||||||
|
teamId={teamId}
|
||||||
|
onChange={handlePixelChange}
|
||||||
|
buttonProps={{
|
||||||
|
variant: 'quiet',
|
||||||
|
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{boardId && (
|
||||||
|
<BoardSelect
|
||||||
|
boardId={boardId}
|
||||||
|
teamId={teamId}
|
||||||
|
onChange={handleBoardChange}
|
||||||
|
buttonProps={{
|
||||||
|
variant: 'quiet',
|
||||||
|
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -16,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 16,
|
||||||
|
background: 'linear-gradient(to bottom, var(--surface-raised), transparent)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { setItem } from '@/lib/storage';
|
|||||||
import { checkVersion, useVersion } from '@/store/version';
|
import { checkVersion, useVersion } from '@/store/version';
|
||||||
|
|
||||||
export function UpdateNotice({ user, config }) {
|
export function UpdateNotice({ user, config }) {
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { t, labels, messages } = useMessages();
|
||||||
const { latest, checked, hasUpdate, releaseUrl } = useVersion();
|
const { latest, checked, hasUpdate, releaseUrl } = useVersion();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [dismissed, setDismissed] = useState(checked);
|
const [dismissed, setDismissed] = useState(checked);
|
||||||
@@ -49,11 +49,11 @@ export function UpdateNotice({ user, config }) {
|
|||||||
return (
|
return (
|
||||||
<Column justifyContent="center" alignItems="center" position="fixed" top="10px" width="100%">
|
<Column justifyContent="center" alignItems="center" position="fixed" top="10px" width="100%">
|
||||||
<Row width="600px">
|
<Row width="600px">
|
||||||
<AlertBanner title={formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}>
|
<AlertBanner title={t(messages.newVersionAvailable, { version: `v${latest}` })}>
|
||||||
<Button variant="primary" onPress={handleViewClick}>
|
<Button variant="primary" onPress={handleViewClick}>
|
||||||
{formatMessage(labels.viewDetails)}
|
{t(labels.viewDetails)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onPress={handleDismissClick}>{formatMessage(labels.dismiss)}</Button>
|
<Button onPress={handleDismissClick}>{t(labels.dismiss)}</Button>
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
</Row>
|
</Row>
|
||||||
</Column>
|
</Column>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
width="240px"
|
width="240px"
|
||||||
height="100%"
|
height="100%"
|
||||||
border="right"
|
border="right"
|
||||||
backgroundColor
|
|
||||||
marginRight="2"
|
marginRight="2"
|
||||||
padding="3"
|
padding="3"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,28 +3,28 @@ import { useMessages, useNavigation } from '@/components/hooks';
|
|||||||
import { Globe, User, Users } from '@/components/icons';
|
import { Globe, User, Users } from '@/components/icons';
|
||||||
|
|
||||||
export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
|
export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const { pathname } = useNavigation();
|
const { pathname } = useNavigation();
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: formatMessage(labels.manage),
|
label: t(labels.manage),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'users',
|
id: 'users',
|
||||||
label: formatMessage(labels.users),
|
label: t(labels.users),
|
||||||
path: '/admin/users',
|
path: '/admin/users',
|
||||||
icon: <User />,
|
icon: <User />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'websites',
|
id: 'websites',
|
||||||
label: formatMessage(labels.websites),
|
label: t(labels.websites),
|
||||||
path: '/admin/websites',
|
path: '/admin/websites',
|
||||||
icon: <Globe />,
|
icon: <Globe />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'teams',
|
id: 'teams',
|
||||||
label: formatMessage(labels.teams),
|
label: t(labels.teams),
|
||||||
path: '/admin/teams',
|
path: '/admin/teams',
|
||||||
icon: <Users />,
|
icon: <Users />,
|
||||||
},
|
},
|
||||||
@@ -39,7 +39,7 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<NavMenu
|
<NavMenu
|
||||||
items={items}
|
items={items}
|
||||||
title={formatMessage(labels.admin)}
|
title={t(labels.admin)}
|
||||||
selectedKey={selectedKey}
|
selectedKey={selectedKey}
|
||||||
allowMinimize={false}
|
allowMinimize={false}
|
||||||
onItemClick={onItemClick}
|
onItemClick={onItemClick}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { TeamsAddButton } from '../../teams/TeamsAddButton';
|
|||||||
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
|
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
|
||||||
|
|
||||||
export function AdminTeamsPage() {
|
export function AdminTeamsPage() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
|
|
||||||
const handleSave = () => {};
|
const handleSave = () => {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap="6" margin="2">
|
<Column gap="6" margin="2">
|
||||||
<PageHeader title={formatMessage(labels.teams)}>
|
<PageHeader title={t(labels.teams)}>
|
||||||
<TeamsAddButton onSave={handleSave} isAdmin={true} />
|
<TeamsAddButton onSave={handleSave} isAdmin={true} />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|||||||
@@ -14,22 +14,22 @@ export function AdminTeamsTable({
|
|||||||
data: any[];
|
data: any[];
|
||||||
showActions?: boolean;
|
showActions?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const [deleteTeam, setDeleteTeam] = useState(null);
|
const [deleteTeam, setDeleteTeam] = useState(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable data={data}>
|
<DataTable data={data}>
|
||||||
<DataColumn id="name" label={formatMessage(labels.name)} width="1fr">
|
<DataColumn id="name" label={t(labels.name)} width="1fr">
|
||||||
{(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>}
|
{(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="websites" label={formatMessage(labels.members)} width="140px">
|
<DataColumn id="websites" label={t(labels.members)} width="140px">
|
||||||
{(row: any) => row?._count?.members}
|
{(row: any) => row?._count?.members}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="members" label={formatMessage(labels.websites)} width="140px">
|
<DataColumn id="members" label={t(labels.websites)} width="140px">
|
||||||
{(row: any) => row?._count?.websites}
|
{(row: any) => row?._count?.websites}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="owner" label={formatMessage(labels.owner)}>
|
<DataColumn id="owner" label={t(labels.owner)}>
|
||||||
{(row: any) => {
|
{(row: any) => {
|
||||||
const name = row?.members?.[0]?.user?.username;
|
const name = row?.members?.[0]?.user?.username;
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export function AdminTeamsTable({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="created" label={formatMessage(labels.created)} width="160px">
|
<DataColumn id="created" label={t(labels.created)} width="160px">
|
||||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
{showActions && (
|
{showActions && (
|
||||||
@@ -55,7 +55,7 @@ export function AdminTeamsTable({
|
|||||||
<Icon>
|
<Icon>
|
||||||
<Edit />
|
<Edit />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.edit)}</Text>
|
<Text>{t(labels.edit)}</Text>
|
||||||
</Row>
|
</Row>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -67,7 +67,7 @@ export function AdminTeamsTable({
|
|||||||
<Icon>
|
<Icon>
|
||||||
<Trash />
|
<Trash />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.delete)}</Text>
|
<Text>{t(labels.delete)}</Text>
|
||||||
</Row>
|
</Row>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { Plus } from '@/components/icons';
|
|||||||
import { UserAddForm } from './UserAddForm';
|
import { UserAddForm } from './UserAddForm';
|
||||||
|
|
||||||
export function UserAddButton({ onSave }: { onSave?: () => void }) {
|
export function UserAddButton({ onSave }: { onSave?: () => void }) {
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { t, labels, messages } = useMessages();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { touch } = useModified();
|
const { touch } = useModified();
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
toast(formatMessage(messages.saved));
|
toast(t(messages.saved));
|
||||||
touch('users');
|
touch('users');
|
||||||
onSave?.();
|
onSave?.();
|
||||||
};
|
};
|
||||||
@@ -20,10 +20,10 @@ export function UserAddButton({ onSave }: { onSave?: () => void }) {
|
|||||||
<Icon>
|
<Icon>
|
||||||
<Plus />
|
<Plus />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.createUser)}</Text>
|
<Text>{t(labels.createUser)}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog title={formatMessage(labels.createUser)} style={{ width: 400 }}>
|
<Dialog title={t(labels.createUser)} style={{ width: 400 }}>
|
||||||
{({ close }) => <UserAddForm onSave={handleSave} onClose={close} />}
|
{({ close }) => <UserAddForm onSave={handleSave} onClose={close} />}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||||
import { messages } from '@/components/messages';
|
|
||||||
import { ROLES } from '@/lib/constants';
|
import { ROLES } from '@/lib/constants';
|
||||||
|
|
||||||
export function UserAddForm({ onSave, onClose }) {
|
export function UserAddForm({ onSave, onClose }) {
|
||||||
const { mutateAsync, error, isPending } = useUpdateQuery(`/users`);
|
const { mutateAsync, error, isPending } = useUpdateQuery(`/users`);
|
||||||
const { formatMessage, labels, getErrorMessage } = useMessages();
|
const { t, labels, messages, getErrorMessage } = useMessages();
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: any) => {
|
||||||
await mutateAsync(data, {
|
await mutateAsync(data, {
|
||||||
@@ -29,45 +28,41 @@ export function UserAddForm({ onSave, onClose }) {
|
|||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
|
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
|
||||||
<FormField
|
<FormField
|
||||||
label={formatMessage(labels.username)}
|
label={t(labels.username)}
|
||||||
name="username"
|
name="username"
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
rules={{ required: t(labels.required) }}
|
||||||
>
|
>
|
||||||
<TextField autoComplete="new-username" data-test="input-username" />
|
<TextField autoComplete="new-username" data-test="input-username" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label={formatMessage(labels.password)}
|
label={t(labels.password)}
|
||||||
name="password"
|
name="password"
|
||||||
rules={{
|
rules={{
|
||||||
required: formatMessage(labels.required),
|
required: t(labels.required),
|
||||||
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
|
minLength: { value: 8, message: t(messages.minPasswordLength, { n: '8' }) },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PasswordField autoComplete="new-password" data-test="input-password" />
|
<PasswordField autoComplete="new-password" data-test="input-password" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField label={t(labels.role)} name="role" rules={{ required: t(labels.required) }}>
|
||||||
label={formatMessage(labels.role)}
|
|
||||||
name="role"
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<Select>
|
<Select>
|
||||||
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
|
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
|
||||||
{formatMessage(labels.viewOnly)}
|
{t(labels.viewOnly)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem id={ROLES.user} data-test="dropdown-item-user">
|
<ListItem id={ROLES.user} data-test="dropdown-item-user">
|
||||||
{formatMessage(labels.user)}
|
{t(labels.user)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
|
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
|
||||||
{formatMessage(labels.admin)}
|
{t(labels.admin)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button isDisabled={isPending} onPress={onClose}>
|
<Button isDisabled={isPending} onPress={onClose}>
|
||||||
{formatMessage(labels.cancel)}
|
{t(labels.cancel)}
|
||||||
</Button>
|
</Button>
|
||||||
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={false}>
|
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={false}>
|
||||||
{formatMessage(labels.save)}
|
{t(labels.save)}
|
||||||
</FormSubmitButton>
|
</FormSubmitButton>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function UserDeleteButton({
|
|||||||
username: string;
|
username: string;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const { user } = useLoginQuery();
|
const { user } = useLoginQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,10 +21,10 @@ export function UserDeleteButton({
|
|||||||
<Icon size="sm">
|
<Icon size="sm">
|
||||||
<Trash />
|
<Trash />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.delete)}</Text>
|
<Text>{t(labels.delete)}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog title={formatMessage(labels.deleteUser)} style={{ width: 400 }}>
|
<Dialog title={t(labels.deleteUser)} style={{ width: 400 }}>
|
||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<UserDeleteForm userId={userId} username={username} onSave={onDelete} onClose={close} />
|
<UserDeleteForm userId={userId} username={username} onSave={onDelete} onClose={close} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function UserDeleteForm({
|
|||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { messages, labels, formatMessage } = useMessages();
|
const { messages, labels, t } = useMessages();
|
||||||
const { mutateAsync } = useDeleteQuery(`/users/${userId}`);
|
const { mutateAsync } = useDeleteQuery(`/users/${userId}`);
|
||||||
const { touch } = useModified();
|
const { touch } = useModified();
|
||||||
|
|
||||||
@@ -29,13 +29,13 @@ export function UserDeleteForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
title={formatMessage(labels.delete)}
|
title={t(labels.delete)}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
confirmLabel={formatMessage(labels.delete)}
|
confirmLabel={t(labels.delete)}
|
||||||
isDanger
|
isDanger
|
||||||
>
|
>
|
||||||
<Row gap="1">{formatMessage(messages.confirmDelete, { target: username })}</Row>
|
<Row gap="1">{t(messages.confirmDelete, { target: username })}</Row>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,13 +7,13 @@ import { UserAddButton } from './UserAddButton';
|
|||||||
import { UsersDataTable } from './UsersDataTable';
|
import { UsersDataTable } from './UsersDataTable';
|
||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
|
|
||||||
const handleSave = () => {};
|
const handleSave = () => {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap="6" margin="2">
|
<Column gap="6" margin="2">
|
||||||
<PageHeader title={formatMessage(labels.users)}>
|
<PageHeader title={t(labels.users)}>
|
||||||
<UserAddButton onSave={handleSave} />
|
<UserAddButton onSave={handleSave} />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|||||||
@@ -15,26 +15,24 @@ export function UsersTable({
|
|||||||
data: any[];
|
data: any[];
|
||||||
showActions?: boolean;
|
showActions?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const [deleteUser, setDeleteUser] = useState(null);
|
const [deleteUser, setDeleteUser] = useState(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable data={data}>
|
<DataTable data={data}>
|
||||||
<DataColumn id="username" label={formatMessage(labels.username)} width="2fr">
|
<DataColumn id="username" label={t(labels.username)} width="2fr">
|
||||||
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
|
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="role" label={formatMessage(labels.role)}>
|
<DataColumn id="role" label={t(labels.role)}>
|
||||||
{(row: any) =>
|
{(row: any) =>
|
||||||
formatMessage(
|
t(labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown)
|
||||||
labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="websites" label={formatMessage(labels.websites)}>
|
<DataColumn id="websites" label={t(labels.websites)}>
|
||||||
{(row: any) => row._count.websites}
|
{(row: any) => row._count.websites}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="created" label={formatMessage(labels.created)}>
|
<DataColumn id="created" label={t(labels.created)}>
|
||||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
{showActions && (
|
{showActions && (
|
||||||
@@ -49,7 +47,7 @@ export function UsersTable({
|
|||||||
<Icon>
|
<Icon>
|
||||||
<Edit />
|
<Edit />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.edit)}</Text>
|
<Text>{t(labels.edit)}</Text>
|
||||||
</Row>
|
</Row>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -61,7 +59,7 @@ export function UsersTable({
|
|||||||
<Icon>
|
<Icon>
|
||||||
<Trash />
|
<Trash />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.delete)}</Text>
|
<Text>{t(labels.delete)}</Text>
|
||||||
</Row>
|
</Row>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/component
|
|||||||
import { ROLES } from '@/lib/constants';
|
import { ROLES } from '@/lib/constants';
|
||||||
|
|
||||||
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
|
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
|
||||||
const { formatMessage, labels, messages, getMessage } = useMessages();
|
const { t, labels, messages, getMessage } = useMessages();
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const { user: login } = useLoginQuery();
|
const { user: login } = useLoginQuery();
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
|
|||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: any) => {
|
||||||
await mutateAsync(data, {
|
await mutateAsync(data, {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast(formatMessage(messages.saved));
|
toast(t(messages.saved));
|
||||||
touch('users');
|
touch('users');
|
||||||
touch(`user:${user.id}`);
|
touch(`user:${user.id}`);
|
||||||
onSave?.();
|
onSave?.();
|
||||||
@@ -31,41 +31,37 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}>
|
<Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}>
|
||||||
<FormField name="username" label={formatMessage(labels.username)}>
|
<FormField name="username" label={t(labels.username)}>
|
||||||
<TextField data-test="input-username" />
|
<TextField data-test="input-username" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
name="password"
|
name="password"
|
||||||
label={formatMessage(labels.password)}
|
label={t(labels.password)}
|
||||||
rules={{
|
rules={{
|
||||||
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
|
minLength: { value: 8, message: t(messages.minPasswordLength, { n: '8' }) },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PasswordField autoComplete="new-password" data-test="input-password" />
|
<PasswordField autoComplete="new-password" data-test="input-password" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{user.id !== login.id && (
|
{user.id !== login.id && (
|
||||||
<FormField
|
<FormField name="role" label={t(labels.role)} rules={{ required: t(labels.required) }}>
|
||||||
name="role"
|
|
||||||
label={formatMessage(labels.role)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<Select defaultValue={user.role}>
|
<Select defaultValue={user.role}>
|
||||||
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
|
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
|
||||||
{formatMessage(labels.viewOnly)}
|
{t(labels.viewOnly)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem id={ROLES.user} data-test="dropdown-item-user">
|
<ListItem id={ROLES.user} data-test="dropdown-item-user">
|
||||||
{formatMessage(labels.user)}
|
{t(labels.user)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
|
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
|
||||||
{formatMessage(labels.admin)}
|
{t(labels.admin)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<FormSubmitButton data-test="button-submit" variant="primary">
|
<FormSubmitButton data-test="button-submit" variant="primary">
|
||||||
{formatMessage(labels.save)}
|
{t(labels.save)}
|
||||||
</FormSubmitButton>
|
</FormSubmitButton>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { UserEditForm } from './UserEditForm';
|
|||||||
import { UserWebsites } from './UserWebsites';
|
import { UserWebsites } from './UserWebsites';
|
||||||
|
|
||||||
export function UserSettings({ userId }: { userId: string }) {
|
export function UserSettings({ userId }: { userId: string }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap="6">
|
<Column gap="6">
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab id="details">{formatMessage(labels.details)}</Tab>
|
<Tab id="details">{t(labels.details)}</Tab>
|
||||||
<Tab id="websites">{formatMessage(labels.websites)}</Tab>
|
<Tab id="websites">{t(labels.websites)}</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel id="details" style={{ width: 500 }}>
|
<TabPanel id="details" style={{ width: 500 }}>
|
||||||
<UserEditForm userId={userId} />
|
<UserEditForm userId={userId} />
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { useMessages } from '@/components/hooks';
|
|||||||
import { AdminWebsitesDataTable } from './AdminWebsitesDataTable';
|
import { AdminWebsitesDataTable } from './AdminWebsitesDataTable';
|
||||||
|
|
||||||
export function AdminWebsitesPage() {
|
export function AdminWebsitesPage() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap="6" margin="2">
|
<Column gap="6" margin="2">
|
||||||
<PageHeader title={formatMessage(labels.websites)} />
|
<PageHeader title={t(labels.websites)} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<AdminWebsitesDataTable />
|
<AdminWebsitesDataTable />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -8,23 +8,23 @@ import { Edit, Trash, Users } from '@/components/icons';
|
|||||||
import { MenuButton } from '@/components/input/MenuButton';
|
import { MenuButton } from '@/components/input/MenuButton';
|
||||||
|
|
||||||
export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
|
export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const [deleteWebsite, setDeleteWebsite] = useState(null);
|
const [deleteWebsite, setDeleteWebsite] = useState(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable data={data}>
|
<DataTable data={data}>
|
||||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
<DataColumn id="name" label={t(labels.name)}>
|
||||||
{(row: any) => (
|
{(row: any) => (
|
||||||
<Text truncate>
|
<Text truncate>
|
||||||
<Link href={`/admin/websites/${row.id}`}>{row.name}</Link>
|
<Link href={`/admin/websites/${row.id}`}>{row.name}</Link>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="domain" label={formatMessage(labels.domain)}>
|
<DataColumn id="domain" label={t(labels.domain)}>
|
||||||
{(row: any) => <Text truncate>{row.domain}</Text>}
|
{(row: any) => <Text truncate>{row.domain}</Text>}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="owner" label={formatMessage(labels.owner)}>
|
<DataColumn id="owner" label={t(labels.owner)}>
|
||||||
{(row: any) => {
|
{(row: any) => {
|
||||||
if (row?.team) {
|
if (row?.team) {
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +45,7 @@ export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="created" label={formatMessage(labels.created)} width="180px">
|
<DataColumn id="created" label={t(labels.created)} width="180px">
|
||||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="action" align="end" width="50px">
|
<DataColumn id="action" align="end" width="50px">
|
||||||
@@ -59,7 +59,7 @@ export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
|
|||||||
<Icon>
|
<Icon>
|
||||||
<Edit />
|
<Edit />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.edit)}</Text>
|
<Text>{t(labels.edit)}</Text>
|
||||||
</Row>
|
</Row>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -71,7 +71,7 @@ export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
|
|||||||
<Icon>
|
<Icon>
|
||||||
<Trash />
|
<Trash />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.delete)}</Text>
|
<Text>{t(labels.delete)}</Text>
|
||||||
</Row>
|
</Row>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
|
|
||||||
import { useMessages, useModified, useNavigation } from '@/components/hooks';
|
|
||||||
import { Plus } from '@/components/icons';
|
|
||||||
import { BoardAddForm } from './BoardAddForm';
|
|
||||||
|
|
||||||
export function BoardAddButton() {
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { touch } = useModified();
|
|
||||||
const { teamId } = useNavigation();
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
toast(formatMessage(messages.saved));
|
|
||||||
touch('boards');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button data-test="button-board-add" variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.addBoard)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal>
|
|
||||||
<Dialog title={formatMessage(labels.addBoard)} style={{ width: 400 }}>
|
|
||||||
{({ close }) => <BoardAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</DialogTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { Button, Form, FormField, FormSubmitButton, Row, Text, TextField } from '@umami/react-zen';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
|
||||||
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
|
|
||||||
|
|
||||||
export function BoardAddForm({
|
|
||||||
teamId,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
teamId?: string;
|
|
||||||
onSave?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { mutateAsync, error, isPending } = useUpdateQuery('/boards', { teamId });
|
|
||||||
const [websiteId, setWebsiteId] = useState<string>();
|
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
|
||||||
await mutateAsync(
|
|
||||||
{ type: 'board', ...data, parameters: { websiteId } },
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
onSave?.();
|
|
||||||
onClose?.();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form onSubmit={handleSubmit} error={error?.message}>
|
|
||||||
<FormField
|
|
||||||
label={formatMessage(labels.name)}
|
|
||||||
name="name"
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<TextField autoComplete="off" />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={formatMessage(labels.description)}
|
|
||||||
name="description"
|
|
||||||
rules={{
|
|
||||||
required: formatMessage(labels.required),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField asTextArea autoComplete="off" />
|
|
||||||
</FormField>
|
|
||||||
<Row alignItems="center" gap="3" paddingTop="3">
|
|
||||||
<Text>{formatMessage(labels.website)}</Text>
|
|
||||||
<WebsiteSelect websiteId={websiteId} teamId={teamId} onChange={setWebsiteId} />
|
|
||||||
</Row>
|
|
||||||
<Row justifyContent="flex-end" paddingTop="3" gap="3">
|
|
||||||
{onClose && (
|
|
||||||
<Button isDisabled={isPending} onPress={onClose}>
|
|
||||||
{formatMessage(labels.cancel)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<FormSubmitButton data-test="button-submit" isDisabled={false}>
|
|
||||||
{formatMessage(labels.save)}
|
|
||||||
</FormSubmitButton>
|
|
||||||
</Row>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid';
|
|||||||
import { useApi, useMessages, useModified, useNavigation } from '@/components/hooks';
|
import { useApi, useMessages, useModified, useNavigation } from '@/components/hooks';
|
||||||
import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery';
|
import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery';
|
||||||
import type { Board, BoardParameters } from '@/lib/types';
|
import type { Board, BoardParameters } from '@/lib/types';
|
||||||
|
import { getComponentDefinition } from './boardComponentRegistry';
|
||||||
|
|
||||||
export type LayoutGetter = () => Partial<BoardParameters> | null;
|
export type LayoutGetter = () => Partial<BoardParameters> | null;
|
||||||
|
|
||||||
@@ -27,6 +28,29 @@ const createDefaultBoard = (): Partial<Board> => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function sanitizeBoardParameters(parameters?: BoardParameters): BoardParameters | undefined {
|
||||||
|
if (!parameters?.rows) {
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parameters,
|
||||||
|
rows: parameters.rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
columns: row.columns.map(column => {
|
||||||
|
if (column.component && !getComponentDefinition(column.component.type)) {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
component: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return column;
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function BoardProvider({
|
export function BoardProvider({
|
||||||
boardId,
|
boardId,
|
||||||
editing = false,
|
editing = false,
|
||||||
@@ -40,8 +64,8 @@ export function BoardProvider({
|
|||||||
const { post, useMutation } = useApi();
|
const { post, useMutation } = useApi();
|
||||||
const { touch } = useModified();
|
const { touch } = useModified();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { t, labels, messages } = useMessages();
|
||||||
const { router, renderUrl } = useNavigation();
|
const { router, renderUrl, teamId } = useNavigation();
|
||||||
|
|
||||||
const [board, setBoard] = useState<Partial<Board>>(data ?? createDefaultBoard());
|
const [board, setBoard] = useState<Partial<Board>>(data ?? createDefaultBoard());
|
||||||
const layoutGetterRef = useRef<LayoutGetter | null>(null);
|
const layoutGetterRef = useRef<LayoutGetter | null>(null);
|
||||||
@@ -52,7 +76,10 @@ export function BoardProvider({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setBoard(data);
|
setBoard({
|
||||||
|
...data,
|
||||||
|
parameters: sanitizeBoardParameters(data.parameters),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -61,7 +88,7 @@ export function BoardProvider({
|
|||||||
if (boardData.id) {
|
if (boardData.id) {
|
||||||
return post(`/boards/${boardData.id}`, boardData);
|
return post(`/boards/${boardData.id}`, boardData);
|
||||||
}
|
}
|
||||||
return post('/boards', { ...boardData, type: 'dashboard', slug: '' });
|
return post('/boards', { ...boardData, type: 'dashboard', slug: '', teamId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,11 +97,13 @@ export function BoardProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveBoard = useCallback(async () => {
|
const saveBoard = useCallback(async () => {
|
||||||
const defaultName = formatMessage(labels.untitled);
|
const defaultName = t(labels.untitled);
|
||||||
|
|
||||||
// Get current layout sizes from BoardBody if registered
|
// Get current layout sizes from BoardEditBody if registered
|
||||||
const layoutData = layoutGetterRef.current?.();
|
const layoutData = layoutGetterRef.current?.();
|
||||||
const parameters = layoutData ? { ...board.parameters, ...layoutData } : board.parameters;
|
const parameters = sanitizeBoardParameters(
|
||||||
|
layoutData ? { ...board.parameters, ...layoutData } : board.parameters,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await mutateAsync({
|
const result = await mutateAsync({
|
||||||
...board,
|
...board,
|
||||||
@@ -82,7 +111,7 @@ export function BoardProvider({
|
|||||||
parameters,
|
parameters,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast(formatMessage(messages.saved));
|
toast(t(messages.saved));
|
||||||
touch('boards');
|
touch('boards');
|
||||||
|
|
||||||
if (board.id) {
|
if (board.id) {
|
||||||
@@ -92,17 +121,7 @@ export function BoardProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [
|
}, [board, mutateAsync, toast, t, labels.untitled, messages.saved, touch, router, renderUrl]);
|
||||||
board,
|
|
||||||
mutateAsync,
|
|
||||||
toast,
|
|
||||||
formatMessage,
|
|
||||||
labels.untitled,
|
|
||||||
messages.saved,
|
|
||||||
touch,
|
|
||||||
router,
|
|
||||||
renderUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (boardId && isFetching && isLoading) {
|
if (boardId && isFetching && isLoading) {
|
||||||
return <Loading placement="absolute" />;
|
return <Loading placement="absolute" />;
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import { LinkButton } from '@/components/common/LinkButton';
|
|||||||
import { PageBody } from '@/components/common/PageBody';
|
import { PageBody } from '@/components/common/PageBody';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { Plus } from '@/components/icons';
|
import { Plus } from '@/components/icons';
|
||||||
import { BoardsDataTable } from './BoardsDataTable';
|
import { BoardsDataTable } from './BoardsDataTable';
|
||||||
|
|
||||||
export function BoardsPage() {
|
export function BoardsPage() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
|
const { renderUrl } = useNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<Column margin="2">
|
<Column margin="2">
|
||||||
<PageHeader title={formatMessage(labels.boards)}>
|
<PageHeader title={t(labels.boards)}>
|
||||||
<LinkButton href="/boards/create" variant="primary">
|
<LinkButton href={renderUrl('/boards/create')} variant="primary">
|
||||||
<IconLabel icon={<Plus />} label={formatMessage(labels.addBoard)} />
|
<IconLabel icon={<Plus />} label={t(labels.addBoard)} />
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ import { DateDistance } from '@/components/common/DateDistance';
|
|||||||
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||||
|
|
||||||
export function BoardsTable(props: DataTableProps) {
|
export function BoardsTable(props: DataTableProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const { websiteId, renderUrl } = useNavigation();
|
const { websiteId, renderUrl } = useNavigation();
|
||||||
const { getSlugUrl } = useSlug('link');
|
const { getSlugUrl } = useSlug('link');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable {...props}>
|
<DataTable {...props}>
|
||||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
<DataColumn id="name" label={t(labels.name)}>
|
||||||
{({ id, name }: any) => {
|
{({ id, name }: any) => {
|
||||||
return <Board href={renderUrl(`/boards/${id}`)}>{name}</Board>;
|
return <Board href={renderUrl(`/boards/${id}`)}>{name}</Board>;
|
||||||
}}
|
}}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="description" label={formatMessage(labels.description)} />
|
<DataColumn id="description" label={t(labels.description)} />
|
||||||
<DataColumn id="created" label={formatMessage(labels.created)} width="200px">
|
<DataColumn id="created" label={t(labels.created)} width="200px">
|
||||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="action" align="end" width="100px">
|
<DataColumn id="action" align="end" width="100px">
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Box, Button, Column, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import { Plus, X } from '@/components/icons';
|
|
||||||
|
|
||||||
export function BoardColumn({
|
|
||||||
id,
|
|
||||||
component,
|
|
||||||
editing = false,
|
|
||||||
onRemove,
|
|
||||||
canRemove = true,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
component?: ReactElement;
|
|
||||||
editing?: boolean;
|
|
||||||
onRemove?: (id: string) => void;
|
|
||||||
canRemove?: boolean;
|
|
||||||
}) {
|
|
||||||
const handleAddComponent = () => {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column
|
|
||||||
marginTop="3"
|
|
||||||
marginLeft="3"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
backgroundColor="surface-sunken"
|
|
||||||
position="relative"
|
|
||||||
>
|
|
||||||
{editing && canRemove && (
|
|
||||||
<Box position="absolute" top="10px" right="20px" zIndex={100}>
|
|
||||||
<TooltipTrigger delay={0}>
|
|
||||||
<Button variant="quiet" onPress={() => onRemove?.(id)}>
|
|
||||||
<Icon size="sm">
|
|
||||||
<X />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
<Tooltip>Remove column</Tooltip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{editing && (
|
|
||||||
<Button variant="outline" onPress={handleAddComponent}>
|
|
||||||
<Icon>
|
|
||||||
<Plus />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Column, Text } from '@umami/react-zen';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import type { BoardComponentConfig } from '@/lib/types';
|
||||||
|
import { getComponentDefinition } from '../boardComponentRegistry';
|
||||||
|
|
||||||
|
function BoardComponentRendererComponent({
|
||||||
|
config,
|
||||||
|
websiteId,
|
||||||
|
}: {
|
||||||
|
config: BoardComponentConfig;
|
||||||
|
websiteId?: string;
|
||||||
|
}) {
|
||||||
|
const definition = getComponentDefinition(config.type);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
return (
|
||||||
|
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
|
||||||
|
<Text color="muted">Unknown component: {config.type}</Text>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = definition.component;
|
||||||
|
|
||||||
|
if (!websiteId) {
|
||||||
|
return (
|
||||||
|
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
|
||||||
|
<Text color="muted">Select a website</Text>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component websiteId={websiteId} {...config.props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardComponentRenderer = memo(
|
||||||
|
BoardComponentRendererComponent,
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
prevProps.websiteId === nextProps.websiteId && prevProps.config === nextProps.config,
|
||||||
|
);
|
||||||
|
|
||||||
|
BoardComponentRenderer.displayName = 'BoardComponentRenderer';
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Column,
|
||||||
|
Focusable,
|
||||||
|
ListItem,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Text,
|
||||||
|
TextField,
|
||||||
|
} from '@umami/react-zen';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
|
||||||
|
import type { BoardComponentConfig } from '@/lib/types';
|
||||||
|
import {
|
||||||
|
CATEGORIES,
|
||||||
|
type ComponentDefinition,
|
||||||
|
type ConfigField,
|
||||||
|
getComponentsByCategory,
|
||||||
|
} from '../boardComponentRegistry';
|
||||||
|
import { BoardComponentRenderer } from './BoardComponentRenderer';
|
||||||
|
|
||||||
|
export function BoardComponentSelect({
|
||||||
|
teamId,
|
||||||
|
websiteId,
|
||||||
|
defaultWebsiteId,
|
||||||
|
initialConfig,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
teamId?: string;
|
||||||
|
websiteId?: string;
|
||||||
|
defaultWebsiteId?: string;
|
||||||
|
initialConfig?: BoardComponentConfig;
|
||||||
|
onSelect: (config: BoardComponentConfig) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t, labels, messages } = useMessages();
|
||||||
|
const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null);
|
||||||
|
const [configValues, setConfigValues] = useState<Record<string, any>>({});
|
||||||
|
const [selectedWebsiteId, setSelectedWebsiteId] = useState(
|
||||||
|
initialConfig?.websiteId || websiteId || defaultWebsiteId,
|
||||||
|
);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
const allDefinitions = useMemo(
|
||||||
|
() => CATEGORIES.flatMap(category => getComponentsByCategory(category.key)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDefaultConfigValues = (def: ComponentDefinition, config?: BoardComponentConfig) => {
|
||||||
|
const defaults: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const field of def.configFields ?? []) {
|
||||||
|
defaults[field.name] = field.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.defaultProps) {
|
||||||
|
Object.assign(defaults, def.defaultProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.props) {
|
||||||
|
Object.assign(defaults, config.props);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = allDefinitions.find(def => def.type === initialConfig.type);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDef(definition);
|
||||||
|
setConfigValues(getDefaultConfigValues(definition, initialConfig));
|
||||||
|
setSelectedWebsiteId(initialConfig.websiteId || websiteId || defaultWebsiteId);
|
||||||
|
setTitle(initialConfig.title ?? definition.name);
|
||||||
|
setDescription(initialConfig.description || '');
|
||||||
|
}, [initialConfig, allDefinitions, websiteId, defaultWebsiteId]);
|
||||||
|
|
||||||
|
const handleSelectComponent = (def: ComponentDefinition) => {
|
||||||
|
setSelectedDef(def);
|
||||||
|
setConfigValues(getDefaultConfigValues(def));
|
||||||
|
setTitle(def.name);
|
||||||
|
setDescription('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigChange = (name: string, value: any) => {
|
||||||
|
setConfigValues(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!selectedDef) return;
|
||||||
|
|
||||||
|
const props: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (selectedDef.defaultProps) {
|
||||||
|
Object.assign(props, selectedDef.defaultProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(props, configValues);
|
||||||
|
|
||||||
|
for (const field of selectedDef.configFields ?? []) {
|
||||||
|
if (field.type === 'number' && props[field.name] != null && props[field.name] !== '') {
|
||||||
|
props[field.name] = Number(props[field.name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: BoardComponentConfig = {
|
||||||
|
type: selectedDef.type,
|
||||||
|
websiteId: selectedWebsiteId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(props).length > 0) {
|
||||||
|
config.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewConfig: BoardComponentConfig | null = selectedDef
|
||||||
|
? {
|
||||||
|
type: selectedDef.type,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
props: { ...selectedDef.defaultProps, ...configValues },
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column gap="4">
|
||||||
|
<Row gap="4" style={{ height: 600 }}>
|
||||||
|
<Column gap="1" style={{ width: 280, flexShrink: 0, overflowY: 'auto' }}>
|
||||||
|
{CATEGORIES.map(category => {
|
||||||
|
const components = getComponentsByCategory(category.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column key={category.key} gap="1" marginBottom="2">
|
||||||
|
<Text weight="bold">{category.name}</Text>
|
||||||
|
{components.map(def => (
|
||||||
|
<Focusable key={def.type}>
|
||||||
|
<Row
|
||||||
|
alignItems="center"
|
||||||
|
paddingX="3"
|
||||||
|
paddingY="2"
|
||||||
|
borderRadius
|
||||||
|
backgroundColor={
|
||||||
|
selectedDef?.type === def.type ? 'surface-sunken' : undefined
|
||||||
|
}
|
||||||
|
hover={{ backgroundColor: 'surface-sunken' }}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => handleSelectComponent(def)}
|
||||||
|
>
|
||||||
|
<Column>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
weight={selectedDef?.type === def.type ? 'bold' : undefined}
|
||||||
|
>
|
||||||
|
{def.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" color="muted">
|
||||||
|
{def.description}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Focusable>
|
||||||
|
))}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column gap="3" flexGrow={1} style={{ minWidth: 0 }}>
|
||||||
|
<Panel maxHeight="100%">
|
||||||
|
{previewConfig && selectedWebsiteId ? (
|
||||||
|
<BoardComponentRenderer config={previewConfig} websiteId={selectedWebsiteId} />
|
||||||
|
) : (
|
||||||
|
<Column alignItems="center" justifyContent="center" height="100%">
|
||||||
|
<Text color="muted">
|
||||||
|
{selectedWebsiteId
|
||||||
|
? t(messages.selectComponentPreview)
|
||||||
|
: t(messages.selectWebsiteFirst)}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column gap="3" style={{ width: 320, flexShrink: 0, overflowY: 'auto' }}>
|
||||||
|
<Text weight="bold">{t(labels.properties)}</Text>
|
||||||
|
|
||||||
|
<Column gap="2">
|
||||||
|
<Text size="sm" color="muted">
|
||||||
|
{t(labels.website)}
|
||||||
|
</Text>
|
||||||
|
<WebsiteSelect
|
||||||
|
websiteId={selectedWebsiteId}
|
||||||
|
teamId={teamId}
|
||||||
|
placeholder={t(labels.selectWebsite)}
|
||||||
|
onChange={setSelectedWebsiteId}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column gap="2">
|
||||||
|
<Text size="sm" color="muted">
|
||||||
|
{t(labels.title)}
|
||||||
|
</Text>
|
||||||
|
<TextField value={title} onChange={setTitle} autoComplete="off" />
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column gap="2">
|
||||||
|
<Text size="sm" color="muted">
|
||||||
|
{t(labels.description)}
|
||||||
|
</Text>
|
||||||
|
<TextField value={description} onChange={setDescription} autoComplete="off" />
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
{selectedDef?.configFields && selectedDef.configFields.length > 0 && (
|
||||||
|
<Column gap="3">
|
||||||
|
{selectedDef.configFields.map((field: ConfigField) => (
|
||||||
|
<Column key={field.name} gap="2">
|
||||||
|
<Text size="sm" color="muted">
|
||||||
|
{field.label}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{field.type === 'select' && (
|
||||||
|
<Select
|
||||||
|
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
|
||||||
|
onChange={(value: string) => handleConfigChange(field.name, value)}
|
||||||
|
>
|
||||||
|
{field.options?.map(option => (
|
||||||
|
<ListItem key={option.value} id={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'text' && (
|
||||||
|
<TextField
|
||||||
|
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
|
||||||
|
onChange={(value: string) => handleConfigChange(field.name, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'number' && (
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
|
||||||
|
onChange={(value: string) => handleConfigChange(field.name, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
))}
|
||||||
|
</Column>
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row justifyContent="flex-end" gap="2" paddingTop="4">
|
||||||
|
<Button variant="quiet" onPress={onClose}>
|
||||||
|
{t(labels.cancel)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onPress={handleAdd} isDisabled={!selectedDef}>
|
||||||
|
{t(labels.save)}
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Box } from '@umami/react-zen';
|
||||||
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
import { useBoard } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function BoardControls() {
|
||||||
|
const { board } = useBoard();
|
||||||
|
const websiteId = board?.parameters?.websiteId;
|
||||||
|
|
||||||
|
if (!websiteId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box marginBottom="4">
|
||||||
|
<WebsiteControls websiteId={websiteId} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
+74
-46
@@ -1,19 +1,18 @@
|
|||||||
import { Button, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
import { Box, Button, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { Fragment, useEffect, useRef } from 'react';
|
import { Fragment, useEffect, useRef } from 'react';
|
||||||
import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resizable-panels';
|
import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resizable-panels';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { useBoard } from '@/components/hooks';
|
import { useBoard } from '@/components/hooks';
|
||||||
import { Plus } from '@/components/icons';
|
import { GripHorizontal, Plus } from '@/components/icons';
|
||||||
import { BoardRow } from './BoardRow';
|
import { BoardEditRow } from './BoardEditRow';
|
||||||
import { BUTTON_ROW_HEIGHT, MAX_ROW_HEIGHT, MIN_ROW_HEIGHT } from './boardConstants';
|
import { BUTTON_ROW_HEIGHT, MAX_ROW_HEIGHT, MIN_ROW_HEIGHT } from './boardConstants';
|
||||||
|
|
||||||
export function BoardBody() {
|
export function BoardEditBody({ requiresBoardWebsite = true }: { requiresBoardWebsite?: boolean }) {
|
||||||
const { board, editing, updateBoard, saveBoard, isPending, registerLayoutGetter } = useBoard();
|
const { board, updateBoard, registerLayoutGetter } = useBoard();
|
||||||
const rowGroupRef = useRef<GroupImperativeHandle>(null);
|
const rowGroupRef = useRef<GroupImperativeHandle>(null);
|
||||||
const columnGroupRefs = useRef<Map<string, GroupImperativeHandle>>(new Map());
|
const columnGroupRefs = useRef<Map<string, GroupImperativeHandle>>(new Map());
|
||||||
|
|
||||||
// Register a function to get current layout sizes on save
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerLayoutGetter(() => {
|
registerLayoutGetter(() => {
|
||||||
const rows = board?.parameters?.rows;
|
const rows = board?.parameters?.rows;
|
||||||
@@ -50,7 +49,7 @@ export function BoardBody() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddRow = () => {
|
const handle = () => {
|
||||||
updateBoard({
|
updateBoard({
|
||||||
parameters: produce(board.parameters, draft => {
|
parameters: produce(board.parameters, draft => {
|
||||||
if (!draft.rows) {
|
if (!draft.rows) {
|
||||||
@@ -103,48 +102,77 @@ export function BoardBody() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const websiteId = board?.parameters?.websiteId;
|
||||||
|
const canEdit = requiresBoardWebsite ? !!websiteId : true;
|
||||||
const rows = board?.parameters?.rows ?? [];
|
const rows = board?.parameters?.rows ?? [];
|
||||||
const minHeight = (rows?.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT;
|
const minHeight = (rows.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group groupRef={rowGroupRef} orientation="vertical" style={{ minHeight }}>
|
<Box minHeight={`${minHeight}px`}>
|
||||||
{rows.map((row, index) => (
|
<Group groupRef={rowGroupRef} orientation="vertical">
|
||||||
<Fragment key={row.id}>
|
{rows.map((row, index) => (
|
||||||
<Panel
|
<Fragment key={`${row.id}:${row.size ?? 'auto'}`}>
|
||||||
id={row.id}
|
<Panel
|
||||||
minSize={MIN_ROW_HEIGHT}
|
id={row.id}
|
||||||
maxSize={MAX_ROW_HEIGHT}
|
minSize={MIN_ROW_HEIGHT}
|
||||||
defaultSize={row.size}
|
maxSize={MAX_ROW_HEIGHT}
|
||||||
>
|
defaultSize={row.size != null ? `${row.size}%` : undefined}
|
||||||
<BoardRow
|
>
|
||||||
{...row}
|
<BoardEditRow
|
||||||
rowId={row.id}
|
{...row}
|
||||||
rowIndex={index}
|
rowId={row.id}
|
||||||
rowCount={rows?.length}
|
rowIndex={index}
|
||||||
editing={editing}
|
rowCount={rows.length}
|
||||||
onRemove={handleRemoveRow}
|
canEdit={canEdit}
|
||||||
onMoveUp={handleMoveRowUp}
|
onRemove={handleRemoveRow}
|
||||||
onMoveDown={handleMoveRowDown}
|
onMoveUp={handleMoveRowUp}
|
||||||
onRegisterRef={registerColumnGroupRef}
|
onMoveDown={handleMoveRowDown}
|
||||||
/>
|
onRegisterRef={registerColumnGroupRef}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
{(index < rows.length - 1 || canEdit) && (
|
||||||
|
<Separator
|
||||||
|
style={{
|
||||||
|
height: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
style={{ cursor: 'row-resize' }}
|
||||||
|
>
|
||||||
|
<Icon size="sm">
|
||||||
|
<GripHorizontal />
|
||||||
|
</Icon>
|
||||||
|
</Row>
|
||||||
|
</Separator>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{canEdit && (
|
||||||
|
<Panel minSize={BUTTON_ROW_HEIGHT}>
|
||||||
|
<Row paddingY="3">
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button variant="outline" onPress={handle}>
|
||||||
|
<Icon>
|
||||||
|
<Plus />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Tooltip placement="right">Add row</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Row>
|
||||||
</Panel>
|
</Panel>
|
||||||
{index < rows?.length - 1 && <Separator />}
|
)}
|
||||||
</Fragment>
|
</Group>
|
||||||
))}
|
</Box>
|
||||||
{editing && (
|
|
||||||
<Panel minSize={BUTTON_ROW_HEIGHT}>
|
|
||||||
<Row padding="3">
|
|
||||||
<TooltipTrigger delay={0}>
|
|
||||||
<Button variant="outline" onPress={handleAddRow}>
|
|
||||||
<Icon>
|
|
||||||
<Plus />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
<Tooltip placement="bottom">Add row</Tooltip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</Row>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Column,
|
||||||
|
Dialog,
|
||||||
|
Icon,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@umami/react-zen';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
|
||||||
|
import { Pencil, Plus, X } from '@/components/icons';
|
||||||
|
import type { BoardComponentConfig } from '@/lib/types';
|
||||||
|
import { BoardComponentRenderer } from './BoardComponentRenderer';
|
||||||
|
import { BoardComponentSelect } from './BoardComponentSelect';
|
||||||
|
|
||||||
|
export function BoardEditColumn({
|
||||||
|
id,
|
||||||
|
component,
|
||||||
|
canEdit,
|
||||||
|
onRemove,
|
||||||
|
onSetComponent,
|
||||||
|
canRemove = true,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
component?: BoardComponentConfig;
|
||||||
|
canEdit: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onSetComponent: (id: string, config: BoardComponentConfig | null) => void;
|
||||||
|
canRemove?: boolean;
|
||||||
|
}) {
|
||||||
|
const [showSelect, setShowSelect] = useState(false);
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
const { board } = useBoard();
|
||||||
|
const { t, labels } = useMessages();
|
||||||
|
const { teamId } = useNavigation();
|
||||||
|
const boardWebsiteId = board?.parameters?.websiteId;
|
||||||
|
const websiteId = component?.websiteId || boardWebsiteId;
|
||||||
|
const renderedComponent = useMemo(() => {
|
||||||
|
if (!component || !websiteId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BoardComponentRenderer config={component} websiteId={websiteId} />;
|
||||||
|
}, [component, websiteId]);
|
||||||
|
|
||||||
|
const handleSelect = (config: BoardComponentConfig) => {
|
||||||
|
onSetComponent(id, config);
|
||||||
|
setShowSelect(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasComponent = !!component;
|
||||||
|
const canRemoveAction = hasComponent || canRemove;
|
||||||
|
const title = component?.title;
|
||||||
|
const description = component?.description;
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
if (hasComponent) {
|
||||||
|
onSetComponent(id, null);
|
||||||
|
} else {
|
||||||
|
onRemove(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
position="relative"
|
||||||
|
onMouseEnter={() => setShowActions(true)}
|
||||||
|
onMouseLeave={() => setShowActions(false)}
|
||||||
|
>
|
||||||
|
{canEdit && canRemoveAction && showActions && (
|
||||||
|
<Box position="absolute" top="12px" right="12px" zIndex={100}>
|
||||||
|
<Row gap="1" padding="2" borderRadius backgroundColor="surface-sunken">
|
||||||
|
{hasComponent && (
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button variant="outline" onPress={() => setShowSelect(true)}>
|
||||||
|
<Icon size="sm">
|
||||||
|
<Pencil />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Tooltip>{t(labels.edit)}</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
)}
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button variant="outline" onPress={handleRemove} isDisabled={!canRemoveAction}>
|
||||||
|
<Icon size="sm">
|
||||||
|
<X />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Tooltip>{t(labels.remove)}</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Row>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{renderedComponent ? (
|
||||||
|
<Column width="100%" height="100%" style={{ minHeight: 0 }}>
|
||||||
|
<Box width="100%" flexGrow={1} overflow="auto" style={{ minHeight: 0 }}>
|
||||||
|
{renderedComponent}
|
||||||
|
</Box>
|
||||||
|
</Column>
|
||||||
|
) : (
|
||||||
|
canEdit && (
|
||||||
|
<Column width="100%" height="100%" alignItems="center" justifyContent="center">
|
||||||
|
<Button variant="outline" onPress={() => setShowSelect(true)}>
|
||||||
|
<Icon>
|
||||||
|
<Plus />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</Column>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Modal isOpen={showSelect} onOpenChange={setShowSelect}>
|
||||||
|
<Dialog
|
||||||
|
title={t(labels.selectComponent)}
|
||||||
|
style={{
|
||||||
|
width: '1200px',
|
||||||
|
maxWidth: 'calc(100vw - 40px)',
|
||||||
|
maxHeight: 'calc(100dvh - 40px)',
|
||||||
|
padding: '32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<BoardComponentSelect
|
||||||
|
teamId={teamId}
|
||||||
|
websiteId={websiteId}
|
||||||
|
defaultWebsiteId={boardWebsiteId}
|
||||||
|
initialConfig={component}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onClose={() => setShowSelect(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,9 +13,9 @@ import { WebsiteSelect } from '@/components/input/WebsiteSelect';
|
|||||||
|
|
||||||
export function BoardEditHeader() {
|
export function BoardEditHeader() {
|
||||||
const { board, updateBoard, saveBoard, isPending } = useBoard();
|
const { board, updateBoard, saveBoard, isPending } = useBoard();
|
||||||
const { formatMessage, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const { router, renderUrl } = useNavigation();
|
const { router, renderUrl, teamId } = useNavigation();
|
||||||
const defaultName = formatMessage(labels.untitled);
|
const defaultName = t(labels.untitled);
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
const handleNameChange = (value: string) => {
|
||||||
updateBoard({ name: value });
|
updateBoard({ name: value });
|
||||||
@@ -71,7 +71,7 @@ export function BoardEditHeader() {
|
|||||||
variant="quiet"
|
variant="quiet"
|
||||||
name="description"
|
name="description"
|
||||||
value={board?.description ?? ''}
|
value={board?.description ?? ''}
|
||||||
placeholder={`+ ${formatMessage(labels.addDescription)}`}
|
placeholder={`+ ${t(labels.addDescription)}`}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={handleDescriptionChange}
|
onChange={handleDescriptionChange}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -80,17 +80,21 @@ export function BoardEditHeader() {
|
|||||||
</TextField>
|
</TextField>
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center" gap="3">
|
<Row alignItems="center" gap="3">
|
||||||
<Text>{formatMessage(labels.website)}</Text>
|
<Text>{t(labels.website)}</Text>
|
||||||
<WebsiteSelect websiteId={board?.parameters?.websiteId} onChange={handleWebsiteChange} />
|
<WebsiteSelect
|
||||||
|
websiteId={board?.parameters?.websiteId}
|
||||||
|
teamId={teamId}
|
||||||
|
onChange={handleWebsiteChange}
|
||||||
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
</Column>
|
</Column>
|
||||||
<Column justifyContent="center" alignItems="flex-end">
|
<Column justifyContent="center" alignItems="flex-end">
|
||||||
<Row gap="3">
|
<Row gap="3">
|
||||||
<Button variant="quiet" onPress={handleCancel}>
|
<Button variant="quiet" onPress={handleCancel}>
|
||||||
{formatMessage(labels.cancel)}
|
{t(labels.cancel)}
|
||||||
</Button>
|
</Button>
|
||||||
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
|
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
|
||||||
{formatMessage(labels.save)}
|
{t(labels.save)}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</Row>
|
</Row>
|
||||||
</Column>
|
</Column>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
import { Column } from '@umami/react-zen';
|
||||||
|
import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
|
||||||
|
import { PageBody } from '@/components/common/PageBody';
|
||||||
|
import { BoardControls } from './BoardControls';
|
||||||
|
import { BoardEditBody } from './BoardEditBody';
|
||||||
|
import { BoardEditHeader } from './BoardEditHeader';
|
||||||
|
|
||||||
|
export function BoardEditPage({ boardId }: { boardId?: string }) {
|
||||||
|
return (
|
||||||
|
<BoardProvider boardId={boardId} editing>
|
||||||
|
<PageBody>
|
||||||
|
<Column>
|
||||||
|
<BoardEditHeader />
|
||||||
|
<BoardControls />
|
||||||
|
<BoardEditBody />
|
||||||
|
</Column>
|
||||||
|
</PageBody>
|
||||||
|
</BoardProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { Box, Button, Column, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
||||||
|
import { produce } from 'immer';
|
||||||
|
import { Fragment, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Group,
|
||||||
|
type GroupImperativeHandle,
|
||||||
|
Panel as ResizablePanel,
|
||||||
|
Separator,
|
||||||
|
} from 'react-resizable-panels';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { useBoard } from '@/components/hooks';
|
||||||
|
import { ChevronDown, GripVertical, Minus, Plus } from '@/components/icons';
|
||||||
|
import type { BoardColumn as BoardColumnType, BoardComponentConfig } from '@/lib/types';
|
||||||
|
import { BoardEditColumn } from './BoardEditColumn';
|
||||||
|
import { MAX_COLUMNS, MIN_COLUMN_WIDTH } from './boardConstants';
|
||||||
|
|
||||||
|
export function BoardEditRow({
|
||||||
|
rowId,
|
||||||
|
rowIndex,
|
||||||
|
rowCount,
|
||||||
|
columns,
|
||||||
|
canEdit,
|
||||||
|
onRemove,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onRegisterRef,
|
||||||
|
}: {
|
||||||
|
rowId: string;
|
||||||
|
rowIndex: number;
|
||||||
|
rowCount: number;
|
||||||
|
columns: BoardColumnType[];
|
||||||
|
canEdit: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onMoveUp: (id: string) => void;
|
||||||
|
onMoveDown: (id: string) => void;
|
||||||
|
onRegisterRef: (rowId: string, ref: GroupImperativeHandle | null) => void;
|
||||||
|
}) {
|
||||||
|
const { board, updateBoard } = useBoard();
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
const moveUpDisabled = rowIndex === 0;
|
||||||
|
const addColumnDisabled = columns.length >= MAX_COLUMNS;
|
||||||
|
const moveDownDisabled = rowIndex === rowCount - 1;
|
||||||
|
|
||||||
|
const handleGroupRef = (ref: GroupImperativeHandle | null) => {
|
||||||
|
onRegisterRef(rowId, ref);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddColumn = () => {
|
||||||
|
updateBoard({
|
||||||
|
parameters: produce(board.parameters, draft => {
|
||||||
|
const rowIndex = draft.rows.findIndex(row => row.id === rowId);
|
||||||
|
const row = draft.rows[rowIndex];
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
draft.rows[rowIndex] = { id: uuid(), columns: [] };
|
||||||
|
}
|
||||||
|
row.columns.push({ id: uuid(), component: null });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveColumn = (columnId: string) => {
|
||||||
|
updateBoard({
|
||||||
|
parameters: produce(board.parameters, draft => {
|
||||||
|
const row = draft.rows.find(row => row.id === rowId);
|
||||||
|
if (row) {
|
||||||
|
row.columns = row.columns.filter(col => col.id !== columnId);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetComponent = (columnId: string, config: BoardComponentConfig | null) => {
|
||||||
|
updateBoard({
|
||||||
|
parameters: produce(board.parameters, draft => {
|
||||||
|
const row = draft.rows.find(row => row.id === rowId);
|
||||||
|
if (row) {
|
||||||
|
const col = row.columns.find(col => col.id === columnId);
|
||||||
|
if (col) {
|
||||||
|
col.component = config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="relative"
|
||||||
|
height="100%"
|
||||||
|
onMouseEnter={() => setShowActions(true)}
|
||||||
|
onMouseLeave={() => setShowActions(false)}
|
||||||
|
>
|
||||||
|
<Group groupRef={handleGroupRef}>
|
||||||
|
{columns?.map((column, index) => (
|
||||||
|
<Fragment key={`${column.id}:${column.size ?? 'auto'}`}>
|
||||||
|
<ResizablePanel
|
||||||
|
id={column.id}
|
||||||
|
minSize={MIN_COLUMN_WIDTH}
|
||||||
|
defaultSize={column.size != null ? `${column.size}%` : undefined}
|
||||||
|
>
|
||||||
|
<BoardEditColumn
|
||||||
|
{...column}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onRemove={handleRemoveColumn}
|
||||||
|
onSetComponent={handleSetComponent}
|
||||||
|
canRemove={columns.length > 1}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
{index < columns.length - 1 && (
|
||||||
|
<Separator
|
||||||
|
style={{
|
||||||
|
width: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
style={{ cursor: 'col-resize' }}
|
||||||
|
>
|
||||||
|
<Icon size="sm">
|
||||||
|
<GripVertical />
|
||||||
|
</Icon>
|
||||||
|
</Row>
|
||||||
|
</Separator>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
{canEdit && showActions && (
|
||||||
|
<Column
|
||||||
|
padding="2"
|
||||||
|
gap="1"
|
||||||
|
position="absolute"
|
||||||
|
top="50%"
|
||||||
|
right="12px"
|
||||||
|
zIndex={20}
|
||||||
|
backgroundColor="surface-sunken"
|
||||||
|
borderRadius
|
||||||
|
style={{ transform: 'translateY(-50%)' }}
|
||||||
|
>
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => onMoveUp(rowId)}
|
||||||
|
isDisabled={moveUpDisabled}
|
||||||
|
style={moveUpDisabled ? { pointerEvents: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
<Icon rotate={180} color={moveUpDisabled ? 'muted' : undefined}>
|
||||||
|
<ChevronDown />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Tooltip placement="top">Move row up</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onPress={handleAddColumn}
|
||||||
|
isDisabled={addColumnDisabled}
|
||||||
|
style={addColumnDisabled ? { pointerEvents: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
<Icon color={addColumnDisabled ? 'muted' : undefined}>
|
||||||
|
<Plus />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Tooltip placement="left">Add column</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button variant="outline" onPress={() => onRemove(rowId)}>
|
||||||
|
<Icon>
|
||||||
|
<Minus />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Tooltip placement="left">Remove row</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => onMoveDown(rowId)}
|
||||||
|
isDisabled={moveDownDisabled}
|
||||||
|
style={moveDownDisabled ? { pointerEvents: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
<Icon color={moveDownDisabled ? 'muted' : undefined}>
|
||||||
|
<ChevronDown />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Tooltip placement="bottom">Move row down</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Column>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user