mirror of
https://github.com/umami-software/umami.git
synced 2026-02-18 11:35:37 +01:00
Merge branch 'dev' into session-recording
Some checks failed
Node.js CI / build (push) Has been cancelled
Some checks failed
Node.js CI / build (push) Has been cancelled
This commit is contained in:
commit
d349c3aea9
381 changed files with 24834 additions and 139358 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
97
AGENTS.md
Normal file
97
AGENTS.md
Normal file
|
|
@ -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`
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
|
||||||
18
package.json
18
package.json
|
|
@ -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",
|
||||||
|
|
|
||||||
1201
pnpm-lock.yaml
generated
1201
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||||
|
|
@ -208,7 +208,7 @@ model 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
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
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);
|
||||||
|
|
|
||||||
49
scripts/bump-components.js
Normal file
49
scripts/bump-components.js
Normal file
|
|
@ -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>
|
||||||
|
<Link href={renderUrl(link.path)} onClick={close}>
|
||||||
<IconLabel icon={link.icon} label={link.label} />
|
<IconLabel icon={link.icon} label={link.label} />
|
||||||
</Link>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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,14 +83,13 @@ 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>
|
||||||
|
|
@ -90,11 +98,11 @@ export function SideNav(props: any) {
|
||||||
<PanelButton />
|
<PanelButton />
|
||||||
</Row>
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
<Row marginBottom="4" style={{ flexShrink: 0 }}>
|
<Column flexGrow="1" minHeight="0" style={{ overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
<NavButton showText={!isCollapsed} onAction={handleSelect} />
|
|
||||||
</Row>
|
|
||||||
{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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
>
|
|
||||||
<Row
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
backgroundColor="surface-raised"
|
backgroundColor="surface-raised"
|
||||||
borderRadius
|
|
||||||
>
|
>
|
||||||
<ThemeButton />
|
<Row alignItems="center">
|
||||||
<LanguageButton />
|
<TeamsButton />
|
||||||
<ProfileButton />
|
{(websiteId || linkId || pixelId || boardId) && (
|
||||||
|
<>
|
||||||
|
<Icon size="sm" color="muted" style={{ opacity: 0.7, margin: '0 6px' }}>
|
||||||
|
<Slash />
|
||||||
|
</Icon>
|
||||||
|
{websiteId && (
|
||||||
|
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
42
src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx
Normal file
42
src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx
Normal file
|
|
@ -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';
|
||||||
280
src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx
Normal file
280
src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/app/(main)/boards/[boardId]/BoardControls.tsx
Normal file
18
src/app/(main)/boards/[boardId]/BoardControls.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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`}>
|
||||||
|
<Group groupRef={rowGroupRef} orientation="vertical">
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
<Fragment key={row.id}>
|
<Fragment key={`${row.id}:${row.size ?? 'auto'}`}>
|
||||||
<Panel
|
<Panel
|
||||||
id={row.id}
|
id={row.id}
|
||||||
minSize={MIN_ROW_HEIGHT}
|
minSize={MIN_ROW_HEIGHT}
|
||||||
maxSize={MAX_ROW_HEIGHT}
|
maxSize={MAX_ROW_HEIGHT}
|
||||||
defaultSize={row.size}
|
defaultSize={row.size != null ? `${row.size}%` : undefined}
|
||||||
>
|
>
|
||||||
<BoardRow
|
<BoardEditRow
|
||||||
{...row}
|
{...row}
|
||||||
rowId={row.id}
|
rowId={row.id}
|
||||||
rowIndex={index}
|
rowIndex={index}
|
||||||
rowCount={rows?.length}
|
rowCount={rows.length}
|
||||||
editing={editing}
|
canEdit={canEdit}
|
||||||
onRemove={handleRemoveRow}
|
onRemove={handleRemoveRow}
|
||||||
onMoveUp={handleMoveRowUp}
|
onMoveUp={handleMoveRowUp}
|
||||||
onMoveDown={handleMoveRowDown}
|
onMoveDown={handleMoveRowDown}
|
||||||
onRegisterRef={registerColumnGroupRef}
|
onRegisterRef={registerColumnGroupRef}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
{index < rows?.length - 1 && <Separator />}
|
{(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>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{editing && (
|
{canEdit && (
|
||||||
<Panel minSize={BUTTON_ROW_HEIGHT}>
|
<Panel minSize={BUTTON_ROW_HEIGHT}>
|
||||||
<Row padding="3">
|
<Row paddingY="3">
|
||||||
<TooltipTrigger delay={0}>
|
<TooltipTrigger delay={0}>
|
||||||
<Button variant="outline" onPress={handleAddRow}>
|
<Button variant="outline" onPress={handle}>
|
||||||
<Icon>
|
<Icon>
|
||||||
<Plus />
|
<Plus />
|
||||||
</Icon>
|
</Icon>
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip placement="bottom">Add row</Tooltip>
|
<Tooltip placement="right">Add row</Tooltip>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</Row>
|
</Row>
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
143
src/app/(main)/boards/[boardId]/BoardEditColumn.tsx
Normal file
143
src/app/(main)/boards/[boardId]/BoardEditColumn.tsx
Normal file
|
|
@ -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>
|
||||||
|
|
|
||||||
21
src/app/(main)/boards/[boardId]/BoardEditPage.tsx
Normal file
21
src/app/(main)/boards/[boardId]/BoardEditPage.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/app/(main)/boards/[boardId]/BoardEditRow.tsx
Normal file
202
src/app/(main)/boards/[boardId]/BoardEditRow.tsx
Normal file
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue