Merge branch 'dev' into session-recording
Some checks failed
Node.js CI / build (push) Has been cancelled

This commit is contained in:
Mike Cao 2026-02-16 09:20:39 -08:00
commit d349c3aea9
381 changed files with 24834 additions and 139358 deletions

3
.gitignore vendored
View file

@ -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
View 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
View file

@ -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.

View file

@ -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];
}, },
}; });

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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);

View 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}`);

View file

@ -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);

View file

@ -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);

View file

@ -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();

View file

@ -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);
});

View file

@ -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/') && (

View file

@ -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>

View file

@ -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>
); );

View file

@ -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>
); );
} }

View file

@ -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>

View file

@ -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"
> >

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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} />
)} )}

View file

@ -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>
); );
} }

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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} />

View file

@ -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>

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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" />;

View file

@ -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>

View file

@ -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">

View file

@ -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>
);
}

View 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';

View 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>
);
}

View 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>
);
}

View file

@ -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>
); );
} }

View 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>
);
}

View file

@ -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>

View 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>
);
}

View 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