Merge branch 'dev' into session-recording
Some checks are pending
Node.js CI / build (push) Waiting to run

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

3
.gitignore vendored
View file

@ -11,6 +11,7 @@ package-lock.json
/coverage
# 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
View file

@ -0,0 +1,97 @@
# AGENTS.md
This file provides guidance for AI coding agents working in this repository.
## Project overview
Umami is a privacy-focused web analytics platform built with Next.js 15, React 19, and TypeScript.
- Primary database: PostgreSQL
- Optional analytics backend: ClickHouse
- Optional cache/session backend: Redis
## Development rules
- Assume a dev server is already running on port `3001`.
- Do **not** start another dev server.
- Use `pnpm` (not `npm` or `yarn`).
- Avoid destructive shell commands unless explicitly requested.
- Ask before running `git commit` or `git push`.
## Common commands
```bash
# Development
pnpm dev
pnpm build
pnpm start
# Database
pnpm build-db
pnpm update-db
pnpm check-db
pnpm seed-data
# Code quality
pnpm lint
pnpm format
pnpm check
pnpm test
# Build specific parts
pnpm build-tracker
pnpm build-geo
```
## Architecture
- `src/app/`: Next.js App Router routes and API endpoints
- `src/components/`: UI components and hooks
- `src/lib/`: shared utilities and infrastructure helpers
- `src/queries/`: data access layer (Prisma + raw SQL)
- `src/store/`: Zustand stores
- `src/tracker/`: standalone client tracking script
- `prisma/`: schema and migrations
## Key implementation patterns
### API request validation
Use Zod + `parseRequest` in API handlers:
```ts
const schema = z.object({ /* fields */ });
const { body, error } = await parseRequest(request, schema);
if (error) return error();
```
### Authentication
- JWT via `Authorization: Bearer <token>`
- Share token via `x-umami-share-token`
- Role model: `admin`, `manager`, `user`
### Client data fetching
- React Query defaults: `staleTime` 60s, no retry, no refetch on window focus
### Styling
- CSS Modules with CSS variables and theme support
## Environment variables
Common env vars:
- `DATABASE_URL`
- `APP_SECRET`
- `CLICKHOUSE_URL`
- `REDIS_URL`
- `BASE_PATH`
- `DEBUG`
## Runtime requirements
- Node.js 18.18+
- PostgreSQL 12.14+
- `pnpm`

2
next-env.d.ts vendored
View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/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.

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -0,0 +1,49 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const distDir = path.resolve(process.cwd(), 'dist');
const packageFile = path.join(distDir, 'package.json');
const defaultPackage = {
name: '@umami/components',
version: '0.0.0',
description: 'Umami React components.',
author: 'Mike Cao <mike@mikecao.com>',
license: 'MIT',
type: 'module',
main: './index.js',
types: './index.d.ts',
dependencies: {
'chart.js': '^4.5.0',
'chartjs-adapter-date-fns': '^3.0.0',
colord: '^2.9.2',
jsonwebtoken: '^9.0.2',
'lucide-react': '^0.542.0',
'pure-rand': '^7.0.1',
'react-simple-maps': '^2.3.0',
'react-use-measure': '^2.0.4',
'react-window': '^1.8.6',
'serialize-error': '^12.0.0',
thenby: '^1.3.4',
uuid: '^11.1.0',
},
};
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir);
}
const pkg = fs.existsSync(packageFile)
? JSON.parse(fs.readFileSync(packageFile, 'utf8'))
: defaultPackage;
const published = execSync(`npm view ${pkg.name} version`, { encoding: 'utf8' }).trim();
const [major, minor] = published.split('.').map(Number);
const next = `${major}.${minor + 1}.0`;
pkg.version = next;
fs.writeFileSync(packageFile, `${JSON.stringify(pkg, null, 2)}\n`);
console.log(`Bumped ${pkg.name} version: ${published} -> ${next}`);

View file

@ -1,11 +1,11 @@
/* eslint-disable no-console */
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);

View file

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

View file

@ -1,35 +0,0 @@
import path from 'node:path';
import del from 'del';
import fs from 'fs-extra';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const src = path.resolve(process.cwd(), 'src/lang');
const dest = path.resolve(process.cwd(), 'build/messages');
const files = fs.readdirSync(src);
del.sync([path.join(dest)]);
/*
This script takes the files from the `lang` folder and formats them into
the format that format-js expects.
*/
async function run() {
await fs.ensureDir(dest);
files.forEach(file => {
const lang = require(path.resolve(process.cwd(), `src/lang/${file}`));
const keys = Object.keys(lang).sort();
const formatted = keys.reduce((obj, key) => {
obj[key] = { defaultMessage: lang[key] };
return obj;
}, {});
const json = JSON.stringify(formatted, null, 2);
fs.writeFileSync(path.resolve(dest, file), json);
});
}
run();

View file

@ -1,43 +0,0 @@
/* eslint-disable no-console */
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'module';
import prettier from 'prettier';
const require = createRequire(import.meta.url);
const messages = require('../build/extracted-messages.json');
const dest = path.resolve(process.cwd(), 'src/lang');
const files = fs.readdirSync(dest);
const keys = Object.keys(messages).sort();
/*
This script takes extracted messages and merges them
with the existing files under `lang`. Any newly added
keys will be printed to the console.
*/
files.forEach(file => {
const lang = require(path.resolve(process.cwd(), `src/lang/${file}`));
console.log(`Merging ${file}`);
const merged = keys.reduce((obj, key) => {
const message = lang[key];
if (file === 'en-US.json') {
obj[key] = messages[key].defaultMessage;
} else {
obj[key] = message || messages[key].defaultMessage;
}
if (!message) {
console.log(`* Added key ${key}`);
}
return obj;
}, {});
const json = prettier.format(JSON.stringify(merged), { parser: 'json' });
fs.writeFileSync(path.resolve(dest, file), json);
});

View file

@ -4,6 +4,7 @@ import Script from 'next/script';
import { useEffect } from 'react';
import { 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/') && (

View file

@ -1,37 +1,44 @@
import { Grid, Row, Text } from '@umami/react-zen';
import { Column, Grid, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import { 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>

View file

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

View file

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

View file

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

View file

@ -19,7 +19,6 @@ export function AdminLayout({ children }: { children: ReactNode }) {
width="240px"
height="100%"
border="right"
backgroundColor
marginRight="2"
padding="3"
>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,32 +0,0 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified, useNavigation } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { BoardAddForm } from './BoardAddForm';
export function BoardAddButton() {
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const { teamId } = useNavigation();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('boards');
};
return (
<DialogTrigger>
<Button data-test="button-board-add" variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addBoard)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addBoard)} style={{ width: 400 }}>
{({ close }) => <BoardAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,65 +0,0 @@
import { Button, Form, FormField, FormSubmitButton, Row, Text, TextField } from '@umami/react-zen';
import { useState } from 'react';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
export function BoardAddForm({
teamId,
onSave,
onClose,
}: {
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/boards', { teamId });
const [websiteId, setWebsiteId] = useState<string>();
const handleSubmit = async (data: any) => {
await mutateAsync(
{ type: 'board', ...data, parameters: { websiteId } },
{
onSuccess: async () => {
onSave?.();
onClose?.();
},
},
);
};
return (
<Form onSubmit={handleSubmit} error={error?.message}>
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
</FormField>
<FormField
label={formatMessage(labels.description)}
name="description"
rules={{
required: formatMessage(labels.required),
}}
>
<TextField asTextArea autoComplete="off" />
</FormField>
<Row alignItems="center" gap="3" paddingTop="3">
<Text>{formatMessage(labels.website)}</Text>
<WebsiteSelect websiteId={websiteId} teamId={teamId} onChange={setWebsiteId} />
</Row>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton data-test="button-submit" isDisabled={false}>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Form>
);
}

View file

@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid';
import { useApi, useMessages, useModified, useNavigation } from '@/components/hooks';
import { 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" />;

View file

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

View file

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

View file

@ -1,52 +0,0 @@
import { Box, Button, Column, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen';
import type { ReactElement } from 'react';
import { Plus, X } from '@/components/icons';
export function BoardColumn({
id,
component,
editing = false,
onRemove,
canRemove = true,
}: {
id: string;
component?: ReactElement;
editing?: boolean;
onRemove?: (id: string) => void;
canRemove?: boolean;
}) {
const handleAddComponent = () => {};
return (
<Column
marginTop="3"
marginLeft="3"
width="100%"
height="100%"
alignItems="center"
justifyContent="center"
backgroundColor="surface-sunken"
position="relative"
>
{editing && canRemove && (
<Box position="absolute" top="10px" right="20px" zIndex={100}>
<TooltipTrigger delay={0}>
<Button variant="quiet" onPress={() => onRemove?.(id)}>
<Icon size="sm">
<X />
</Icon>
</Button>
<Tooltip>Remove column</Tooltip>
</TooltipTrigger>
</Box>
)}
{editing && (
<Button variant="outline" onPress={handleAddComponent}>
<Icon>
<Plus />
</Icon>
</Button>
)}
</Column>
);
}

View file

@ -0,0 +1,42 @@
import { Column, Text } from '@umami/react-zen';
import { memo } from 'react';
import type { BoardComponentConfig } from '@/lib/types';
import { getComponentDefinition } from '../boardComponentRegistry';
function BoardComponentRendererComponent({
config,
websiteId,
}: {
config: BoardComponentConfig;
websiteId?: string;
}) {
const definition = getComponentDefinition(config.type);
if (!definition) {
return (
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
<Text color="muted">Unknown component: {config.type}</Text>
</Column>
);
}
const Component = definition.component;
if (!websiteId) {
return (
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
<Text color="muted">Select a website</Text>
</Column>
);
}
return <Component websiteId={websiteId} {...config.props} />;
}
export const BoardComponentRenderer = memo(
BoardComponentRendererComponent,
(prevProps, nextProps) =>
prevProps.websiteId === nextProps.websiteId && prevProps.config === nextProps.config,
);
BoardComponentRenderer.displayName = 'BoardComponentRenderer';

View file

@ -0,0 +1,280 @@
import {
Button,
Column,
Focusable,
ListItem,
Row,
Select,
Text,
TextField,
} from '@umami/react-zen';
import { useEffect, useMemo, useState } from 'react';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
import type { BoardComponentConfig } from '@/lib/types';
import {
CATEGORIES,
type ComponentDefinition,
type ConfigField,
getComponentsByCategory,
} from '../boardComponentRegistry';
import { BoardComponentRenderer } from './BoardComponentRenderer';
export function BoardComponentSelect({
teamId,
websiteId,
defaultWebsiteId,
initialConfig,
onSelect,
onClose,
}: {
teamId?: string;
websiteId?: string;
defaultWebsiteId?: string;
initialConfig?: BoardComponentConfig;
onSelect: (config: BoardComponentConfig) => void;
onClose: () => void;
}) {
const { t, labels, messages } = useMessages();
const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null);
const [configValues, setConfigValues] = useState<Record<string, any>>({});
const [selectedWebsiteId, setSelectedWebsiteId] = useState(
initialConfig?.websiteId || websiteId || defaultWebsiteId,
);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const allDefinitions = useMemo(
() => CATEGORIES.flatMap(category => getComponentsByCategory(category.key)),
[],
);
const getDefaultConfigValues = (def: ComponentDefinition, config?: BoardComponentConfig) => {
const defaults: Record<string, any> = {};
for (const field of def.configFields ?? []) {
defaults[field.name] = field.defaultValue;
}
if (def.defaultProps) {
Object.assign(defaults, def.defaultProps);
}
if (config?.props) {
Object.assign(defaults, config.props);
}
return defaults;
};
useEffect(() => {
if (!initialConfig) {
return;
}
const definition = allDefinitions.find(def => def.type === initialConfig.type);
if (!definition) {
return;
}
setSelectedDef(definition);
setConfigValues(getDefaultConfigValues(definition, initialConfig));
setSelectedWebsiteId(initialConfig.websiteId || websiteId || defaultWebsiteId);
setTitle(initialConfig.title ?? definition.name);
setDescription(initialConfig.description || '');
}, [initialConfig, allDefinitions, websiteId, defaultWebsiteId]);
const handleSelectComponent = (def: ComponentDefinition) => {
setSelectedDef(def);
setConfigValues(getDefaultConfigValues(def));
setTitle(def.name);
setDescription('');
};
const handleConfigChange = (name: string, value: any) => {
setConfigValues(prev => ({ ...prev, [name]: value }));
};
const handleAdd = () => {
if (!selectedDef) return;
const props: Record<string, any> = {};
if (selectedDef.defaultProps) {
Object.assign(props, selectedDef.defaultProps);
}
Object.assign(props, configValues);
for (const field of selectedDef.configFields ?? []) {
if (field.type === 'number' && props[field.name] != null && props[field.name] !== '') {
props[field.name] = Number(props[field.name]);
}
}
const config: BoardComponentConfig = {
type: selectedDef.type,
websiteId: selectedWebsiteId,
title,
description,
};
if (Object.keys(props).length > 0) {
config.props = props;
}
onSelect(config);
};
const previewConfig: BoardComponentConfig | null = selectedDef
? {
type: selectedDef.type,
title,
description,
props: { ...selectedDef.defaultProps, ...configValues },
}
: null;
return (
<Column gap="4">
<Row gap="4" style={{ height: 600 }}>
<Column gap="1" style={{ width: 280, flexShrink: 0, overflowY: 'auto' }}>
{CATEGORIES.map(category => {
const components = getComponentsByCategory(category.key);
return (
<Column key={category.key} gap="1" marginBottom="2">
<Text weight="bold">{category.name}</Text>
{components.map(def => (
<Focusable key={def.type}>
<Row
alignItems="center"
paddingX="3"
paddingY="2"
borderRadius
backgroundColor={
selectedDef?.type === def.type ? 'surface-sunken' : undefined
}
hover={{ backgroundColor: 'surface-sunken' }}
style={{ cursor: 'pointer' }}
onClick={() => handleSelectComponent(def)}
>
<Column>
<Text
size="sm"
weight={selectedDef?.type === def.type ? 'bold' : undefined}
>
{def.name}
</Text>
<Text size="xs" color="muted">
{def.description}
</Text>
</Column>
</Row>
</Focusable>
))}
</Column>
);
})}
</Column>
<Column gap="3" flexGrow={1} style={{ minWidth: 0 }}>
<Panel maxHeight="100%">
{previewConfig && selectedWebsiteId ? (
<BoardComponentRenderer config={previewConfig} websiteId={selectedWebsiteId} />
) : (
<Column alignItems="center" justifyContent="center" height="100%">
<Text color="muted">
{selectedWebsiteId
? t(messages.selectComponentPreview)
: t(messages.selectWebsiteFirst)}
</Text>
</Column>
)}
</Panel>
</Column>
<Column gap="3" style={{ width: 320, flexShrink: 0, overflowY: 'auto' }}>
<Text weight="bold">{t(labels.properties)}</Text>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.website)}
</Text>
<WebsiteSelect
websiteId={selectedWebsiteId}
teamId={teamId}
placeholder={t(labels.selectWebsite)}
onChange={setSelectedWebsiteId}
/>
</Column>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.title)}
</Text>
<TextField value={title} onChange={setTitle} autoComplete="off" />
</Column>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.description)}
</Text>
<TextField value={description} onChange={setDescription} autoComplete="off" />
</Column>
{selectedDef?.configFields && selectedDef.configFields.length > 0 && (
<Column gap="3">
{selectedDef.configFields.map((field: ConfigField) => (
<Column key={field.name} gap="2">
<Text size="sm" color="muted">
{field.label}
</Text>
{field.type === 'select' && (
<Select
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
>
{field.options?.map(option => (
<ListItem key={option.value} id={option.value}>
{option.label}
</ListItem>
))}
</Select>
)}
{field.type === 'text' && (
<TextField
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
/>
)}
{field.type === 'number' && (
<TextField
type="number"
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
/>
)}
</Column>
))}
</Column>
)}
</Column>
</Row>
<Row justifyContent="flex-end" gap="2" paddingTop="4">
<Button variant="quiet" onPress={onClose}>
{t(labels.cancel)}
</Button>
<Button variant="primary" onPress={handleAdd} isDisabled={!selectedDef}>
{t(labels.save)}
</Button>
</Row>
</Column>
);
}

View file

@ -0,0 +1,18 @@
import { Box } from '@umami/react-zen';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useBoard } from '@/components/hooks';
export function BoardControls() {
const { board } = useBoard();
const websiteId = board?.parameters?.websiteId;
if (!websiteId) {
return null;
}
return (
<Box marginBottom="4">
<WebsiteControls websiteId={websiteId} />
</Box>
);
}

View file

@ -1,19 +1,18 @@
import { Button, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { Box, Button, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { produce } from 'immer';
import { 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>
);
}

View file

@ -0,0 +1,143 @@
import {
Box,
Button,
Column,
Dialog,
Icon,
Modal,
Row,
Tooltip,
TooltipTrigger,
} from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { Panel } from '@/components/common/Panel';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
import { Pencil, Plus, X } from '@/components/icons';
import type { BoardComponentConfig } from '@/lib/types';
import { BoardComponentRenderer } from './BoardComponentRenderer';
import { BoardComponentSelect } from './BoardComponentSelect';
export function BoardEditColumn({
id,
component,
canEdit,
onRemove,
onSetComponent,
canRemove = true,
}: {
id: string;
component?: BoardComponentConfig;
canEdit: boolean;
onRemove: (id: string) => void;
onSetComponent: (id: string, config: BoardComponentConfig | null) => void;
canRemove?: boolean;
}) {
const [showSelect, setShowSelect] = useState(false);
const [showActions, setShowActions] = useState(false);
const { board } = useBoard();
const { t, labels } = useMessages();
const { teamId } = useNavigation();
const boardWebsiteId = board?.parameters?.websiteId;
const websiteId = component?.websiteId || boardWebsiteId;
const renderedComponent = useMemo(() => {
if (!component || !websiteId) {
return null;
}
return <BoardComponentRenderer config={component} websiteId={websiteId} />;
}, [component, websiteId]);
const handleSelect = (config: BoardComponentConfig) => {
onSetComponent(id, config);
setShowSelect(false);
};
const hasComponent = !!component;
const canRemoveAction = hasComponent || canRemove;
const title = component?.title;
const description = component?.description;
const handleRemove = () => {
if (hasComponent) {
onSetComponent(id, null);
} else {
onRemove(id);
}
};
return (
<Panel
title={title}
description={description}
width="100%"
height="100%"
position="relative"
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
{canEdit && canRemoveAction && showActions && (
<Box position="absolute" top="12px" right="12px" zIndex={100}>
<Row gap="1" padding="2" borderRadius backgroundColor="surface-sunken">
{hasComponent && (
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={() => setShowSelect(true)}>
<Icon size="sm">
<Pencil />
</Icon>
</Button>
<Tooltip>{t(labels.edit)}</Tooltip>
</TooltipTrigger>
)}
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={handleRemove} isDisabled={!canRemoveAction}>
<Icon size="sm">
<X />
</Icon>
</Button>
<Tooltip>{t(labels.remove)}</Tooltip>
</TooltipTrigger>
</Row>
</Box>
)}
{renderedComponent ? (
<Column width="100%" height="100%" style={{ minHeight: 0 }}>
<Box width="100%" flexGrow={1} overflow="auto" style={{ minHeight: 0 }}>
{renderedComponent}
</Box>
</Column>
) : (
canEdit && (
<Column width="100%" height="100%" alignItems="center" justifyContent="center">
<Button variant="outline" onPress={() => setShowSelect(true)}>
<Icon>
<Plus />
</Icon>
</Button>
</Column>
)
)}
<Modal isOpen={showSelect} onOpenChange={setShowSelect}>
<Dialog
title={t(labels.selectComponent)}
style={{
width: '1200px',
maxWidth: 'calc(100vw - 40px)',
maxHeight: 'calc(100dvh - 40px)',
padding: '32px',
}}
>
{() => (
<BoardComponentSelect
teamId={teamId}
websiteId={websiteId}
defaultWebsiteId={boardWebsiteId}
initialConfig={component}
onSelect={handleSelect}
onClose={() => setShowSelect(false)}
/>
)}
</Dialog>
</Modal>
</Panel>
);
}

View file

@ -13,9 +13,9 @@ import { WebsiteSelect } from '@/components/input/WebsiteSelect';
export function BoardEditHeader() {
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>

View file

@ -0,0 +1,21 @@
'use client';
import { Column } from '@umami/react-zen';
import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
import { PageBody } from '@/components/common/PageBody';
import { BoardControls } from './BoardControls';
import { BoardEditBody } from './BoardEditBody';
import { BoardEditHeader } from './BoardEditHeader';
export function BoardEditPage({ boardId }: { boardId?: string }) {
return (
<BoardProvider boardId={boardId} editing>
<PageBody>
<Column>
<BoardEditHeader />
<BoardControls />
<BoardEditBody />
</Column>
</PageBody>
</BoardProvider>
);
}

View file

@ -0,0 +1,202 @@
import { Box, Button, Column, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { produce } from 'immer';
import { Fragment, useState } from 'react';
import {
Group,
type GroupImperativeHandle,
Panel as ResizablePanel,
Separator,
} from 'react-resizable-panels';
import { v4 as uuid } from 'uuid';
import { useBoard } from '@/components/hooks';
import { ChevronDown, GripVertical, Minus, Plus } from '@/components/icons';
import type { BoardColumn as BoardColumnType, BoardComponentConfig } from '@/lib/types';
import { BoardEditColumn } from './BoardEditColumn';
import { MAX_COLUMNS, MIN_COLUMN_WIDTH } from './boardConstants';
export function BoardEditRow({
rowId,
rowIndex,
rowCount,
columns,
canEdit,
onRemove,
onMoveUp,
onMoveDown,
onRegisterRef,
}: {
rowId: string;
rowIndex: number;
rowCount: number;
columns: BoardColumnType[];
canEdit: boolean;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
onRegisterRef: (rowId: string, ref: GroupImperativeHandle | null) => void;
}) {
const { board, updateBoard } = useBoard();
const [showActions, setShowActions] = useState(false);
const moveUpDisabled = rowIndex === 0;
const addColumnDisabled = columns.length >= MAX_COLUMNS;
const moveDownDisabled = rowIndex === rowCount - 1;
const handleGroupRef = (ref: GroupImperativeHandle | null) => {
onRegisterRef(rowId, ref);
};
const handleAddColumn = () => {
updateBoard({
parameters: produce(board.parameters, draft => {
const rowIndex = draft.rows.findIndex(row => row.id === rowId);
const row = draft.rows[rowIndex];
if (!row) {
draft.rows[rowIndex] = { id: uuid(), columns: [] };
}
row.columns.push({ id: uuid(), component: null });
}),
});
};
const handleRemoveColumn = (columnId: string) => {
updateBoard({
parameters: produce(board.parameters, draft => {
const row = draft.rows.find(row => row.id === rowId);
if (row) {
row.columns = row.columns.filter(col => col.id !== columnId);
}
}),
});
};
const handleSetComponent = (columnId: string, config: BoardComponentConfig | null) => {
updateBoard({
parameters: produce(board.parameters, draft => {
const row = draft.rows.find(row => row.id === rowId);
if (row) {
const col = row.columns.find(col => col.id === columnId);
if (col) {
col.component = config;
}
}
}),
});
};
return (
<Box
position="relative"
height="100%"
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
<Group groupRef={handleGroupRef}>
{columns?.map((column, index) => (
<Fragment key={`${column.id}:${column.size ?? 'auto'}`}>
<ResizablePanel
id={column.id}
minSize={MIN_COLUMN_WIDTH}
defaultSize={column.size != null ? `${column.size}%` : undefined}
>
<BoardEditColumn
{...column}
canEdit={canEdit}
onRemove={handleRemoveColumn}
onSetComponent={handleSetComponent}
canRemove={columns.length > 1}
/>
</ResizablePanel>
{index < columns.length - 1 && (
<Separator
style={{
width: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
outline: 'none',
boxShadow: 'none',
background: 'transparent',
}}
>
<Row
width="100%"
height="100%"
alignItems="center"
justifyContent="center"
style={{ cursor: 'col-resize' }}
>
<Icon size="sm">
<GripVertical />
</Icon>
</Row>
</Separator>
)}
</Fragment>
))}
</Group>
{canEdit && showActions && (
<Column
padding="2"
gap="1"
position="absolute"
top="50%"
right="12px"
zIndex={20}
backgroundColor="surface-sunken"
borderRadius
style={{ transform: 'translateY(-50%)' }}
>
<TooltipTrigger delay={0}>
<Button
variant="outline"
onPress={() => onMoveUp(rowId)}
isDisabled={moveUpDisabled}
style={moveUpDisabled ? { pointerEvents: 'none' } : undefined}
>
<Icon rotate={180} color={moveUpDisabled ? 'muted' : undefined}>
<ChevronDown />
</Icon>
</Button>
<Tooltip placement="top">Move row up</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button
variant="outline"
onPress={handleAddColumn}
isDisabled={addColumnDisabled}
style={addColumnDisabled ? { pointerEvents: 'none' } : undefined}
>
<Icon color={addColumnDisabled ? 'muted' : undefined}>
<Plus />
</Icon>
</Button>
<Tooltip placement="left">Add column</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={() => onRemove(rowId)}>
<Icon>
<Minus />
</Icon>
</Button>
<Tooltip placement="left">Remove row</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button
variant="outline"
onPress={() => onMoveDown(rowId)}
isDisabled={moveDownDisabled}
style={moveDownDisabled ? { pointerEvents: 'none' } : undefined}
>
<Icon color={moveDownDisabled ? 'muted' : undefined}>
<ChevronDown />
</Icon>
</Button>
<Tooltip placement="bottom">Move row down</Tooltip>
</TooltipTrigger>
</Column>
)}
</Box>
);
}

Some files were not shown because too many files have changed in this diff Show more