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