mirror of
https://github.com/umami-software/umami.git
synced 2026-02-22 21:45:36 +01:00
Compare commits
No commits in common. "6367d94552e0c8e1b27e629d67fe9f45eca2c7ae" and "aefc36b476e050de210a96269ded0d15f3c046ba" have entirely different histories.
6367d94552
...
aefc36b476
60 changed files with 1934 additions and 2538 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -31,9 +31,6 @@ pm2.yml
|
||||||
*.log
|
*.log
|
||||||
.vscode
|
.vscode
|
||||||
.tool-versions
|
.tool-versions
|
||||||
.claude
|
|
||||||
tmpclaude*
|
|
||||||
nul
|
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
|
||||||
96
CLAUDE.md
96
CLAUDE.md
|
|
@ -1,96 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Umami is a privacy-focused web analytics platform built with Next.js 15, React 19, and TypeScript. It serves as an alternative to Google Analytics, storing data in PostgreSQL (primary) with optional ClickHouse for time-series analytics.
|
|
||||||
|
|
||||||
## Common Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
npm run dev # Start dev server on port 3001 with Turbopack
|
|
||||||
npm run build # Full production build (includes db setup, tracker, geo data)
|
|
||||||
npm run start # Start production server
|
|
||||||
|
|
||||||
# Database
|
|
||||||
npm run build-db # Generate Prisma client
|
|
||||||
npm run update-db # Run Prisma migrations
|
|
||||||
npm run check-db # Verify database connection
|
|
||||||
npm run seed-data # Seed test data
|
|
||||||
|
|
||||||
# Code Quality
|
|
||||||
npm run lint # Lint with Biome
|
|
||||||
npm run format # Format with Biome
|
|
||||||
npm run check # Format and lint with Biome
|
|
||||||
npm run test # Run Jest tests
|
|
||||||
|
|
||||||
# Building specific parts
|
|
||||||
npm run build-tracker # Build client-side tracking script (Rollup)
|
|
||||||
npm run build-geo # Build geolocation database
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
- `src/app/` - Next.js App Router (routes and API endpoints)
|
|
||||||
- `(main)/` - Authenticated app routes (dashboard, websites, teams, boards, etc.)
|
|
||||||
- `(collect)/` - Data collection routes
|
|
||||||
- `api/` - REST API endpoints
|
|
||||||
- `src/components/` - React components (charts, forms, common UI, hooks)
|
|
||||||
- `src/lib/` - Core utilities (auth, crypto, date, prisma helpers, redis)
|
|
||||||
- `src/queries/` - Data access layer (Prisma queries and raw SQL)
|
|
||||||
- `src/store/` - Zustand state stores (app, dashboard, websites, cache)
|
|
||||||
- `src/tracker/` - Client-side tracking script (lightweight IIFE)
|
|
||||||
- `prisma/` - Database schema and migrations
|
|
||||||
|
|
||||||
### Key Patterns
|
|
||||||
|
|
||||||
**API Request Handling** - All API endpoints use Zod validation with `parseRequest`:
|
|
||||||
```typescript
|
|
||||||
const schema = z.object({ /* fields */ });
|
|
||||||
const { body, error } = await parseRequest(request, schema);
|
|
||||||
if (error) return error();
|
|
||||||
```
|
|
||||||
|
|
||||||
**Authentication** - JWT tokens via Bearer header, share tokens via `x-umami-share-token` header for public dashboards. Role-based access: admin, manager, user.
|
|
||||||
|
|
||||||
**Data Fetching** - React Query with 60s stale time, no retry, no refetch on window focus.
|
|
||||||
|
|
||||||
**State Management** - Zustand for client state, localStorage for user preferences.
|
|
||||||
|
|
||||||
**Styling** - CSS Modules with CSS variables for theming (light/dark mode).
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
- **ORM**: Prisma 7.x with PostgreSQL adapter
|
|
||||||
- **Schema**: `prisma/schema.prisma` - 14 models (User, Team, Website, Session, WebsiteEvent, EventData, etc.)
|
|
||||||
- **Query helpers**: `src/lib/prisma.ts` has dynamic SQL generation functions (`getDateSQL`, `mapFilter`, `getSearchSQL`)
|
|
||||||
- **Raw SQL**: Complex analytics queries use `{{param}}` template placeholders for safe binding
|
|
||||||
|
|
||||||
### Tracker Script
|
|
||||||
|
|
||||||
The tracking script in `src/tracker/index.js` is a standalone IIFE (~3-4KB) built with Rollup. It sends events to `/api/send`. Alternative script names can be configured via `TRACKER_SCRIPT_NAME` env var.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Key variables in `.env`:
|
|
||||||
```
|
|
||||||
DATABASE_URL # PostgreSQL connection string (required)
|
|
||||||
APP_SECRET # Encryption/signing secret
|
|
||||||
CLICKHOUSE_URL # Optional ClickHouse for analytics
|
|
||||||
REDIS_URL # Optional Redis for caching/sessions
|
|
||||||
BASE_PATH # App base path (e.g., /analytics)
|
|
||||||
DEBUG # Debug namespaces (e.g., umami:*)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Node.js 18.18+
|
|
||||||
- PostgreSQL 12.14+
|
|
||||||
- pnpm (package manager)
|
|
||||||
|
|
||||||
## Git Workflow
|
|
||||||
|
|
||||||
Always ask for confirmation before running `git commit` or `git push`.
|
|
||||||
37
package.json
37
package.json
|
|
@ -61,18 +61,18 @@
|
||||||
".next/cache"
|
".next/cache"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clickhouse/client": "^1.16.0",
|
"@clickhouse/client": "^1.12.0",
|
||||||
"@date-fns/utc": "^1.2.0",
|
"@date-fns/utc": "^1.2.0",
|
||||||
"@dicebear/collection": "^9.2.3",
|
"@dicebear/collection": "^9.2.3",
|
||||||
"@dicebear/core": "^9.2.3",
|
"@dicebear/core": "^9.2.3",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.1.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.1.0",
|
||||||
"@prisma/extension-read-replicas": "^0.5.0",
|
"@prisma/extension-read-replicas": "^0.5.0",
|
||||||
"@react-spring/web": "^10.0.3",
|
"@react-spring/web": "^10.0.3",
|
||||||
"@svgr/cli": "^8.1.0",
|
"@svgr/cli": "^8.1.0",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@umami/react-zen": "^0.216.0",
|
"@umami/react-zen": "^0.216.0",
|
||||||
"@umami/redis-client": "^0.30.0",
|
"@umami/redis-client": "^0.30.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
"detect-browser": "^5.2.0",
|
"detect-browser": "^5.2.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"esbuild": "^0.25.11",
|
"esbuild": "^0.25.11",
|
||||||
"fs-extra": "^11.3.3",
|
"fs-extra": "^11.3.2",
|
||||||
"immer": "^10.2.0",
|
"immer": "^10.2.0",
|
||||||
"ipaddr.js": "^2.3.0",
|
"ipaddr.js": "^2.3.0",
|
||||||
"is-ci": "^3.0.1",
|
"is-ci": "^3.0.1",
|
||||||
|
|
@ -101,19 +101,18 @@
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"kafkajs": "^2.1.0",
|
"kafkajs": "^2.1.0",
|
||||||
"lucide-react": "^0.543.0",
|
"lucide-react": "^0.543.0",
|
||||||
"maxmind": "^5.0.3",
|
"maxmind": "^5.0.0",
|
||||||
"next": "^15.5.9",
|
"next": "^15.5.9",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"pg": "^8.17.0",
|
"pg": "^8.16.3",
|
||||||
"prisma": "^7.2.0",
|
"prisma": "^7.1.0",
|
||||||
"pure-rand": "^7.0.1",
|
"pure-rand": "^7.0.1",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-error-boundary": "^4.0.4",
|
"react-error-boundary": "^4.0.4",
|
||||||
"react-intl": "^7.1.14",
|
"react-intl": "^7.1.14",
|
||||||
"react-resizable-panels": "^4.4.1",
|
|
||||||
"react-simple-maps": "^2.3.0",
|
"react-simple-maps": "^2.3.0",
|
||||||
"react-use-measure": "^2.0.4",
|
"react-use-measure": "^2.0.4",
|
||||||
"react-window": "^1.8.6",
|
"react-window": "^1.8.6",
|
||||||
|
|
@ -121,15 +120,15 @@
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"serialize-error": "^12.0.0",
|
"serialize-error": "^12.0.0",
|
||||||
"thenby": "^1.3.4",
|
"thenby": "^1.3.4",
|
||||||
"ua-parser-js": "^2.0.8",
|
"ua-parser-js": "^2.0.6",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.1.13",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.11",
|
"@biomejs/biome": "^2.3.8",
|
||||||
"@formatjs/cli": "^4.2.29",
|
"@formatjs/cli": "^4.2.29",
|
||||||
"@netlify/plugin-nextjs": "^5.15.5",
|
"@netlify/plugin-nextjs": "^5.15.1",
|
||||||
"@rollup/plugin-alias": "^5.0.0",
|
"@rollup/plugin-alias": "^5.0.0",
|
||||||
"@rollup/plugin-commonjs": "^25.0.4",
|
"@rollup/plugin-commonjs": "^25.0.4",
|
||||||
"@rollup/plugin-json": "^6.0.0",
|
"@rollup/plugin-json": "^6.0.0",
|
||||||
|
|
@ -138,8 +137,8 @@
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@rollup/plugin-typescript": "^12.3.0",
|
"@rollup/plugin-typescript": "^12.3.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.10.8",
|
"@types/node": "^24.9.2",
|
||||||
"@types/react": "^19.2.8",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
|
|
@ -154,9 +153,9 @@
|
||||||
"postcss-import": "^15.1.0",
|
"postcss-import": "^15.1.0",
|
||||||
"postcss-preset-env": "7.8.3",
|
"postcss-preset-env": "7.8.3",
|
||||||
"prompts": "2.4.2",
|
"prompts": "2.4.2",
|
||||||
"rollup": "^4.55.1",
|
"rollup": "^4.52.5",
|
||||||
"rollup-plugin-copy": "^3.4.0",
|
"rollup-plugin-copy": "^3.4.0",
|
||||||
"rollup-plugin-delete": "^3.0.2",
|
"rollup-plugin-delete": "^3.0.1",
|
||||||
"rollup-plugin-dts": "^6.3.0",
|
"rollup-plugin-dts": "^6.3.0",
|
||||||
"rollup-plugin-node-externals": "^8.1.1",
|
"rollup-plugin-node-externals": "^8.1.1",
|
||||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||||
|
|
@ -165,7 +164,7 @@
|
||||||
"stylelint-config-css-modules": "^4.5.1",
|
"stylelint-config-css-modules": "^4.5.1",
|
||||||
"stylelint-config-prettier": "^9.0.3",
|
"stylelint-config-prettier": "^9.0.3",
|
||||||
"stylelint-config-recommended": "^14.0.0",
|
"stylelint-config-recommended": "^14.0.0",
|
||||||
"tar": "^7.5.3",
|
"tar": "^6.1.2",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.0",
|
||||||
|
|
|
||||||
3378
pnpm-lock.yaml
generated
3378
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,41 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "share" (
|
|
||||||
"share_id" UUID NOT NULL,
|
|
||||||
"entity_id" UUID NOT NULL,
|
|
||||||
"share_type" INTEGER NOT NULL,
|
|
||||||
"slug" VARCHAR(100) NOT NULL,
|
|
||||||
"parameters" JSONB NOT NULL,
|
|
||||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMPTZ(6),
|
|
||||||
|
|
||||||
CONSTRAINT "share_pkey" PRIMARY KEY ("share_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "share_share_id_key" ON "share"("share_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "share_entity_id_idx" ON "share"("entity_id");
|
|
||||||
|
|
||||||
-- MigrateData
|
|
||||||
INSERT INTO "share" (share_id, entity_id, share_type, slug, parameters, created_at)
|
|
||||||
SELECT gen_random_uuid(),
|
|
||||||
website_id,
|
|
||||||
1,
|
|
||||||
share_id,
|
|
||||||
'{}'::jsonb,
|
|
||||||
now()
|
|
||||||
FROM "website"
|
|
||||||
WHERE share_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "website_share_id_idx";
|
|
||||||
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "website_share_id_key";
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "website" DROP COLUMN "share_id";
|
|
||||||
|
|
@ -67,6 +67,7 @@ model Website {
|
||||||
id String @id @unique @map("website_id") @db.Uuid
|
id String @id @unique @map("website_id") @db.Uuid
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
domain String? @db.VarChar(500)
|
domain String? @db.VarChar(500)
|
||||||
|
shareId String? @unique @map("share_id") @db.VarChar(50)
|
||||||
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
||||||
userId String? @map("user_id") @db.Uuid
|
userId String? @map("user_id") @db.Uuid
|
||||||
teamId String? @map("team_id") @db.Uuid
|
teamId String? @map("team_id") @db.Uuid
|
||||||
|
|
@ -87,6 +88,7 @@ model Website {
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([shareId])
|
||||||
@@index([createdBy])
|
@@index([createdBy])
|
||||||
@@map("website")
|
@@map("website")
|
||||||
}
|
}
|
||||||
|
|
@ -337,16 +339,3 @@ model Board {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("board")
|
@@map("board")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Share {
|
|
||||||
id String @id() @unique() @map("share_id") @db.Uuid
|
|
||||||
entityId String @map("entity_id") @db.Uuid
|
|
||||||
shareType Int @map("share_type") @db.Integer
|
|
||||||
slug String @unique() @db.VarChar(100)
|
|
||||||
parameters Json
|
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
|
||||||
|
|
||||||
@@index([entityId])
|
|
||||||
@@map("share")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ async function checkConnection() {
|
||||||
|
|
||||||
success('Database connection successful.');
|
success('Database connection successful.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Unable to connect to the database: ${e.message}`);
|
throw new Error('Unable to connect to the database: ' + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,111 +1,21 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Loading, useToast } from '@umami/react-zen';
|
import { Loading } from '@umami/react-zen';
|
||||||
import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
import { createContext, type ReactNode } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { useApi, useMessages, useModified, useNavigation } from '@/components/hooks';
|
|
||||||
import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery';
|
import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery';
|
||||||
import type { Board, BoardParameters } from '@/lib/types';
|
import type { Board } from '@/generated/prisma/client';
|
||||||
|
|
||||||
export type LayoutGetter = () => Partial<BoardParameters> | null;
|
export const BoardContext = createContext<Board>(null);
|
||||||
|
|
||||||
export interface BoardContextValue {
|
export function BoardProvider({ boardId, children }: { boardId: string; children: ReactNode }) {
|
||||||
board: Partial<Board>;
|
const { data: board, isFetching, isLoading } = useBoardQuery(boardId);
|
||||||
updateBoard: (data: Partial<Board>) => void;
|
|
||||||
saveBoard: () => Promise<Board>;
|
|
||||||
isPending: boolean;
|
|
||||||
registerLayoutGetter: (getter: LayoutGetter) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BoardContext = createContext<BoardContextValue>(null);
|
if (isFetching && isLoading) {
|
||||||
|
|
||||||
const createDefaultBoard = (): Partial<Board> => ({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
parameters: {
|
|
||||||
rows: [{ id: uuid(), columns: [{ id: uuid(), component: null }] }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function BoardProvider({ boardId, children }: { boardId?: string; children: ReactNode }) {
|
|
||||||
const { data, isFetching, isLoading } = useBoardQuery(boardId);
|
|
||||||
const { post, useMutation } = useApi();
|
|
||||||
const { touch } = useModified();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const { router, renderUrl } = useNavigation();
|
|
||||||
|
|
||||||
const [board, setBoard] = useState<Partial<Board>>(data ?? createDefaultBoard());
|
|
||||||
const layoutGetterRef = useRef<LayoutGetter | null>(null);
|
|
||||||
|
|
||||||
const registerLayoutGetter = useCallback((getter: LayoutGetter) => {
|
|
||||||
layoutGetterRef.current = getter;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
setBoard(data);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const { mutateAsync, isPending } = useMutation({
|
|
||||||
mutationFn: (boardData: Partial<Board>) => {
|
|
||||||
if (boardData.id) {
|
|
||||||
return post(`/boards/${boardData.id}`, boardData);
|
|
||||||
}
|
|
||||||
return post('/boards', { ...boardData, type: 'dashboard', slug: '' });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateBoard = useCallback((data: Partial<Board>) => {
|
|
||||||
setBoard(current => ({ ...current, ...data }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveBoard = useCallback(async () => {
|
|
||||||
const defaultName = formatMessage(labels.untitled);
|
|
||||||
|
|
||||||
// Get current layout sizes from BoardBody if registered
|
|
||||||
const layoutData = layoutGetterRef.current?.();
|
|
||||||
console.log('layoutData from getter:', layoutData);
|
|
||||||
const parameters = layoutData ? { ...board.parameters, ...layoutData } : board.parameters;
|
|
||||||
console.log('parameters to save:', parameters);
|
|
||||||
|
|
||||||
const result = await mutateAsync({
|
|
||||||
...board,
|
|
||||||
name: board.name || defaultName,
|
|
||||||
parameters,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast(formatMessage(messages.saved));
|
|
||||||
touch('boards');
|
|
||||||
|
|
||||||
if (board.id) {
|
|
||||||
touch(`board:${board.id}`);
|
|
||||||
} else if (result?.id) {
|
|
||||||
router.push(renderUrl(`/boards/${result.id}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [
|
|
||||||
board,
|
|
||||||
mutateAsync,
|
|
||||||
toast,
|
|
||||||
formatMessage,
|
|
||||||
labels.untitled,
|
|
||||||
messages.saved,
|
|
||||||
touch,
|
|
||||||
router,
|
|
||||||
renderUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (boardId && isFetching && isLoading) {
|
|
||||||
return <Loading placement="absolute" />;
|
return <Loading placement="absolute" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (!board) {
|
||||||
<BoardContext.Provider
|
return null;
|
||||||
value={{ board, updateBoard, saveBoard, isPending, registerLayoutGetter }}
|
}
|
||||||
>
|
|
||||||
{children}
|
return <BoardContext.Provider value={board}>{children}</BoardContext.Provider>;
|
||||||
</BoardContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,322 +1,3 @@
|
||||||
import { Box, Button, Column, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
|
||||||
import { produce } from 'immer';
|
|
||||||
import { Fragment, type ReactElement, 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 { ChevronDown, Minus, Plus, X } from '@/components/icons';
|
|
||||||
import type { BoardColumn as BoardColumnType } from '@/lib/types';
|
|
||||||
|
|
||||||
const CATALOG = {
|
|
||||||
text: {
|
|
||||||
label: 'Text',
|
|
||||||
component: BoardColumn,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIN_ROW_HEIGHT = 300;
|
|
||||||
const MAX_ROW_HEIGHT = 600;
|
|
||||||
const MIN_COLUMN_WIDTH = 300;
|
|
||||||
const BUTTON_ROW_HEIGHT = 60;
|
|
||||||
const MAX_COLUMNS = 4;
|
|
||||||
|
|
||||||
export function BoardBody() {
|
export function BoardBody() {
|
||||||
const { board, updateBoard, saveBoard, isPending, registerLayoutGetter } = useBoard();
|
return <h1>i am bored.</h1>;
|
||||||
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;
|
|
||||||
console.log('Layout getter called, rows:', rows);
|
|
||||||
console.log('rowGroupRef.current:', rowGroupRef.current);
|
|
||||||
console.log('columnGroupRefs.current:', columnGroupRefs.current);
|
|
||||||
|
|
||||||
if (!rows?.length) return null;
|
|
||||||
|
|
||||||
const rowLayout = rowGroupRef.current?.getLayout();
|
|
||||||
console.log('rowLayout:', rowLayout);
|
|
||||||
|
|
||||||
const updatedRows = rows.map(row => {
|
|
||||||
const columnGroupRef = columnGroupRefs.current.get(row.id);
|
|
||||||
const columnLayout = columnGroupRef?.getLayout();
|
|
||||||
console.log(`Row ${row.id} columnLayout:`, columnLayout);
|
|
||||||
|
|
||||||
const updatedColumns = row.columns.map(col => ({
|
|
||||||
...col,
|
|
||||||
size: columnLayout?.[col.id],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
size: rowLayout?.[row.id],
|
|
||||||
columns: updatedColumns,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('updatedRows:', updatedRows);
|
|
||||||
return { rows: updatedRows };
|
|
||||||
});
|
|
||||||
}, [registerLayoutGetter, board?.parameters?.rows]);
|
|
||||||
|
|
||||||
const registerColumnGroupRef = (rowId: string, ref: GroupImperativeHandle | null) => {
|
|
||||||
if (ref) {
|
|
||||||
columnGroupRefs.current.set(rowId, ref);
|
|
||||||
} else {
|
|
||||||
columnGroupRefs.current.delete(rowId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log({ board });
|
|
||||||
|
|
||||||
const handleAddRow = () => {
|
|
||||||
updateBoard({
|
|
||||||
parameters: produce(board.parameters, draft => {
|
|
||||||
if (!draft.rows) {
|
|
||||||
draft.rows = [];
|
|
||||||
}
|
|
||||||
draft.rows.push({ id: uuid(), columns: [{ id: uuid(), component: null }] });
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveRow = (id: string) => {
|
|
||||||
updateBoard({
|
|
||||||
parameters: produce(board.parameters, draft => {
|
|
||||||
if (!draft.rows) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
draft.rows = draft.rows.filter(row => row?.id !== id);
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMoveRowUp = (id: string) => {
|
|
||||||
updateBoard({
|
|
||||||
parameters: produce(board.parameters, draft => {
|
|
||||||
if (!draft.rows) return;
|
|
||||||
|
|
||||||
const index = draft.rows.findIndex(row => row.id === id);
|
|
||||||
if (index > 0) {
|
|
||||||
const temp = draft.rows[index - 1];
|
|
||||||
draft.rows[index - 1] = draft.rows[index];
|
|
||||||
draft.rows[index] = temp;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMoveRowDown = (id: string) => {
|
|
||||||
updateBoard({
|
|
||||||
parameters: produce(board.parameters, draft => {
|
|
||||||
if (!draft.rows) return;
|
|
||||||
|
|
||||||
const index = draft.rows.findIndex(row => row.id === id);
|
|
||||||
if (index < draft.rows.length - 1) {
|
|
||||||
const temp = draft.rows[index + 1];
|
|
||||||
draft.rows[index + 1] = draft.rows[index];
|
|
||||||
draft.rows[index] = temp;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows = board?.parameters?.rows ?? [];
|
|
||||||
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}
|
|
||||||
onRemove={handleRemoveRow}
|
|
||||||
onMoveUp={handleMoveRowUp}
|
|
||||||
onMoveDown={handleMoveRowDown}
|
|
||||||
onRegisterRef={registerColumnGroupRef}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
{index < rows?.length - 1 && <Separator />}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BoardRow({
|
|
||||||
rowId,
|
|
||||||
rowIndex,
|
|
||||||
rowCount,
|
|
||||||
columns,
|
|
||||||
onRemove,
|
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
onRegisterRef,
|
|
||||||
}: {
|
|
||||||
rowId: string;
|
|
||||||
rowIndex: number;
|
|
||||||
rowCount: number;
|
|
||||||
columns: BoardColumnType[];
|
|
||||||
onRemove?: (id: string) => void;
|
|
||||||
onMoveUp?: (id: string) => void;
|
|
||||||
onMoveDown?: (id: string) => void;
|
|
||||||
onRegisterRef?: (rowId: string, ref: GroupImperativeHandle | null) => void;
|
|
||||||
}) {
|
|
||||||
const { board, updateBoard } = useBoard();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group groupRef={handleGroupRef} style={{ height: '100%' }}>
|
|
||||||
{columns?.map((column, index) => (
|
|
||||||
<Fragment key={column.id}>
|
|
||||||
<Panel id={column.id} minSize={MIN_COLUMN_WIDTH} defaultSize={column.size}>
|
|
||||||
<BoardColumn
|
|
||||||
{...column}
|
|
||||||
onRemove={handleRemoveColumn}
|
|
||||||
canRemove={columns?.length > 1}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
{index < columns?.length - 1 && <Separator />}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
<Column alignSelf="center" padding="3" gap="1">
|
|
||||||
<TooltipTrigger delay={0}>
|
|
||||||
<Button variant="outline" onPress={() => onMoveUp?.(rowId)} isDisabled={rowIndex === 0}>
|
|
||||||
<Icon rotate={180}>
|
|
||||||
<ChevronDown />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
<Tooltip placement="top">Move row up</Tooltip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipTrigger delay={0}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onPress={handleAddColumn}
|
|
||||||
isDisabled={columns?.length >= MAX_COLUMNS}
|
|
||||||
>
|
|
||||||
<Icon>
|
|
||||||
<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={rowIndex === rowCount - 1}
|
|
||||||
>
|
|
||||||
<Icon>
|
|
||||||
<ChevronDown />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
<Tooltip placement="bottom">Move row down</Tooltip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</Column>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BoardColumn({
|
|
||||||
id,
|
|
||||||
component,
|
|
||||||
onRemove,
|
|
||||||
canRemove = true,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
component?: ReactElement;
|
|
||||||
onRemove?: (id: string) => void;
|
|
||||||
canRemove?: boolean;
|
|
||||||
}) {
|
|
||||||
const handleAddComponent = () => {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column
|
|
||||||
marginTop="3"
|
|
||||||
marginLeft="3"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
backgroundColor="3"
|
|
||||||
position="relative"
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<Button variant="outline" onPress={handleAddComponent}>
|
|
||||||
<Icon>
|
|
||||||
<Plus />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
import { Column, Grid, Heading, LoadingButton, Row, TextField } from '@umami/react-zen';
|
import { Button, Column, Grid, Heading, Row, TextField } from '@umami/react-zen';
|
||||||
import { useBoard, useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
export function BoardHeader() {
|
export function BoardHeader() {
|
||||||
const { board, updateBoard, saveBoard, isPending } = useBoard();
|
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const defaultName = formatMessage(labels.untitled);
|
const defaultName = formatMessage(labels.untitled);
|
||||||
|
const name = '';
|
||||||
|
const description = '';
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
const handleNameChange = (name: string) => {
|
||||||
updateBoard({ name: value });
|
//updateReport({ name: name || defaultName });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDescriptionChange = (value: string) => {
|
const handleDescriptionChange = (description: string) => {
|
||||||
updateBoard({ description: value });
|
//updateReport({ description });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
return <h1>asdgfviybiyu8oaero8g9873qrgb875qh0g8</h1>;
|
||||||
saveBoard();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid
|
<Grid
|
||||||
|
|
@ -26,38 +25,39 @@ export function BoardHeader() {
|
||||||
border="bottom"
|
border="bottom"
|
||||||
gapX="6"
|
gapX="6"
|
||||||
>
|
>
|
||||||
|
asdfasdfds
|
||||||
<Column>
|
<Column>
|
||||||
<Row>
|
<Row>
|
||||||
<TextField
|
<TextField
|
||||||
variant="quiet"
|
variant="quiet"
|
||||||
name="name"
|
name="name"
|
||||||
value={board?.name ?? ''}
|
value={name}
|
||||||
|
defaultValue={name}
|
||||||
placeholder={defaultName}
|
placeholder={defaultName}
|
||||||
onChange={handleNameChange}
|
onChange={handleNameChange}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
style={{ fontSize: '2rem', fontWeight: 700, width: '100%' }}
|
style={{ fontSize: '2rem', fontWeight: 700, width: '100%' }}
|
||||||
>
|
>
|
||||||
<Heading size="4">{board?.name}</Heading>
|
<Heading size="4">{name}</Heading>
|
||||||
</TextField>
|
</TextField>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<TextField
|
<TextField
|
||||||
variant="quiet"
|
variant="quiet"
|
||||||
name="description"
|
name="description"
|
||||||
value={board?.description ?? ''}
|
value={description}
|
||||||
|
defaultValue={description}
|
||||||
placeholder={`+ ${formatMessage(labels.addDescription)}`}
|
placeholder={`+ ${formatMessage(labels.addDescription)}`}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={handleDescriptionChange}
|
onChange={handleDescriptionChange}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
{board?.description}
|
{description}
|
||||||
</TextField>
|
</TextField>
|
||||||
</Row>
|
</Row>
|
||||||
</Column>
|
</Column>
|
||||||
<Column justifyContent="center" alignItems="flex-end">
|
<Column justifyContent="center" alignItems="flex-end">
|
||||||
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
|
<Button variant="primary">{formatMessage(labels.save)}</Button>
|
||||||
{formatMessage(labels.save)}
|
|
||||||
</LoadingButton>
|
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Column } from '@umami/react-zen';
|
import { Column } from '@umami/react-zen';
|
||||||
import { BoardBody } from '@/app/(main)/boards/[boardId]/BoardBody';
|
|
||||||
import { BoardHeader } from '@/app/(main)/boards/[boardId]/BoardHeader';
|
import { BoardHeader } from '@/app/(main)/boards/[boardId]/BoardHeader';
|
||||||
import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
|
import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
|
||||||
import { PageBody } from '@/components/common/PageBody';
|
import { PageBody } from '@/components/common/PageBody';
|
||||||
|
|
@ -11,7 +10,6 @@ export function BoardPage({ boardId }: { boardId: string }) {
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<Column>
|
<Column>
|
||||||
<BoardHeader />
|
<BoardHeader />
|
||||||
<BoardBody />
|
|
||||||
</Column>
|
</Column>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
</BoardProvider>
|
</BoardProvider>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { BoardPage } from './BoardPage';
|
||||||
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
|
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
|
||||||
const { boardId } = await params;
|
const { boardId } = await params;
|
||||||
|
|
||||||
return <BoardPage boardId={boardId !== 'create' ? boardId : undefined} />;
|
return <BoardPage boardId={boardId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
||||||
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
|
import { useDeleteQuery, useMessages } from '@/components/hooks';
|
||||||
import { Trash } from '@/components/icons';
|
import { Trash } from '@/components/icons';
|
||||||
import { DialogButton } from '@/components/input/DialogButton';
|
import { DialogButton } from '@/components/input/DialogButton';
|
||||||
import { messages } from '@/components/messages';
|
import { messages } from '@/components/messages';
|
||||||
|
|
@ -15,8 +15,7 @@ export function LinkDeleteButton({
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
|
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
|
||||||
const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`);
|
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`);
|
||||||
const { touch } = useModified();
|
|
||||||
|
|
||||||
const handleConfirm = async (close: () => void) => {
|
const handleConfirm = async (close: () => void) => {
|
||||||
await mutateAsync(null, {
|
await mutateAsync(null, {
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ export function LinkEditForm({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast(formatMessage(messages.saved));
|
toast(formatMessage(messages.saved));
|
||||||
touch('links');
|
touch('links');
|
||||||
touch(`link:${linkId}`);
|
|
||||||
onSave?.();
|
onSave?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ export function PixelEditForm({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast(formatMessage(messages.saved));
|
toast(formatMessage(messages.saved));
|
||||||
touch('pixels');
|
touch('pixels');
|
||||||
touch(`pixel:${pixelId}`);
|
|
||||||
onSave?.();
|
onSave?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { SHARE_ID_REGEX } from '@/lib/constants';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
||||||
import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions';
|
import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions';
|
||||||
|
|
@ -25,8 +26,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ boar
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
|
export async function POST(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
description: z.string().optional(),
|
domain: z.string().optional(),
|
||||||
parameters: z.object({}).passthrough().optional(),
|
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
@ -36,14 +37,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
|
||||||
}
|
}
|
||||||
|
|
||||||
const { boardId } = await params;
|
const { boardId } = await params;
|
||||||
const { name, description, parameters } = body;
|
const { name, domain, shareId } = body;
|
||||||
|
|
||||||
if (!(await canUpdateBoard(auth, boardId))) {
|
if (!(await canUpdateBoard(auth, boardId))) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const board = await updateBoard(boardId, { name, description, parameters });
|
const board = await updateBoard(boardId, { name, domain, shareId });
|
||||||
|
|
||||||
return Response.json(board);
|
return Response.json(board);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||||
const filters = await getQueryFilters(body.filters, websiteId);
|
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getAttribution(websiteId, parameters as AttributionParameters, filters);
|
const data = await getAttribution(websiteId, parameters as AttributionParameters, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||||
const filters = await getQueryFilters(body.filters, websiteId);
|
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters);
|
const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||||
const filters = await getQueryFilters(body.filters, websiteId);
|
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getFunnel(websiteId, parameters as FunnelParameters, filters);
|
const data = await getFunnel(websiteId, parameters as FunnelParameters, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||||
const filters = await getQueryFilters(body.filters, websiteId);
|
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getGoal(websiteId, parameters as GoalParameters, filters);
|
const data = await getGoal(websiteId, parameters as GoalParameters, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(body.filters, websiteId);
|
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||||
|
|
||||||
const data = await getRetention(websiteId, parameters as RetentionParameters, filters);
|
const data = await getRetention(websiteId, parameters as RetentionParameters, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ export async function POST(request: Request) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||||
const filters = await getQueryFilters(body.filters, websiteId);
|
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getRevenue(websiteId, parameters as RevenuParameters, filters);
|
const data = await getRevenue(websiteId, parameters as RevenuParameters, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ export async function POST(request: Request) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(body.filters, websiteId);
|
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id);
|
||||||
const parameters = await setWebsiteDate(websiteId, body.parameters);
|
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters);
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
utm_source: [],
|
utm_source: [],
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { secret } from '@/lib/crypto';
|
import { secret } from '@/lib/crypto';
|
||||||
import { createToken } from '@/lib/jwt';
|
import { createToken } from '@/lib/jwt';
|
||||||
import { json, notFound } from '@/lib/response';
|
import { json, notFound } from '@/lib/response';
|
||||||
import { getShareByCode } from '@/queries/prisma';
|
import { getSharedWebsite } from '@/queries/prisma';
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) {
|
||||||
const { slug } = await params;
|
const { shareId } = await params;
|
||||||
|
|
||||||
const share = await getShareByCode(slug);
|
const website = await getSharedWebsite(shareId);
|
||||||
|
|
||||||
if (!share) {
|
if (!website) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = { shareId: share.id };
|
const data = { websiteId: website.id };
|
||||||
const token = createToken(data, secret());
|
const token = createToken(data, secret());
|
||||||
|
|
||||||
return json({ ...data, token });
|
return json({ ...data, token });
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import z from 'zod';
|
|
||||||
import { parseRequest } from '@/lib/request';
|
|
||||||
import { json, notFound, ok, unauthorized } from '@/lib/response';
|
|
||||||
import { anyObjectParam } from '@/lib/schema';
|
|
||||||
import { canDeleteEntity, canUpdateEntity, canViewEntity } from '@/permissions';
|
|
||||||
import { deleteShare, getShare, updateShare } from '@/queries/prisma';
|
|
||||||
|
|
||||||
export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
|
|
||||||
const { auth, error } = await parseRequest(request);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { shareId } = await params;
|
|
||||||
|
|
||||||
const share = await getShare(shareId);
|
|
||||||
|
|
||||||
if (!(await canViewEntity(auth, share.entityId))) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
return json(share);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
|
|
||||||
const schema = z.object({
|
|
||||||
slug: z.string().max(100),
|
|
||||||
parameters: anyObjectParam,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { shareId } = await params;
|
|
||||||
const { slug, parameters } = body;
|
|
||||||
|
|
||||||
const share = await getShare(shareId);
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await canUpdateEntity(auth, share.entityId))) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await updateShare(shareId, {
|
|
||||||
slug,
|
|
||||||
parameters,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ shareId: string }> },
|
|
||||||
) {
|
|
||||||
const { auth, error } = await parseRequest(request);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { shareId } = await params;
|
|
||||||
|
|
||||||
const share = await getShare(shareId);
|
|
||||||
|
|
||||||
if (!(await canDeleteEntity(auth, share.entityId))) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteShare(shareId);
|
|
||||||
|
|
||||||
return ok();
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import z from 'zod';
|
|
||||||
import { uuid } from '@/lib/crypto';
|
|
||||||
import { parseRequest } from '@/lib/request';
|
|
||||||
import { json, unauthorized } from '@/lib/response';
|
|
||||||
import { anyObjectParam } from '@/lib/schema';
|
|
||||||
import { canUpdateEntity } from '@/permissions';
|
|
||||||
import { createShare } from '@/queries/prisma';
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const schema = z.object({
|
|
||||||
entityId: z.uuid(),
|
|
||||||
shareType: z.coerce.number().int(),
|
|
||||||
slug: z.string().max(100),
|
|
||||||
parameters: anyObjectParam,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { entityId, shareType, slug, parameters } = body;
|
|
||||||
|
|
||||||
if (!(await canUpdateEntity(auth, entityId))) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
const share = await createShare({
|
|
||||||
id: uuid(),
|
|
||||||
entityId,
|
|
||||||
shareType,
|
|
||||||
slug,
|
|
||||||
parameters,
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(share);
|
|
||||||
}
|
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getEventDataEvents(websiteId, {
|
const data = await getEventDataEvents(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getEventDataFields(websiteId, filters);
|
const data = await getEventDataFields(websiteId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getEventDataProperties(websiteId, filters);
|
const data = await getEventDataProperties(websiteId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getEventDataStats(websiteId, filters);
|
const data = await getEventDataStats(websiteId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export async function GET(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { propertyName } = query;
|
const { propertyName } = query;
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getEventDataValues(websiteId, {
|
const data = await getEventDataValues(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getWebsiteEvents(websiteId, filters);
|
const data = await getWebsiteEvents(websiteId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getEventStats(websiteId, filters);
|
const data = await getEventStats(websiteId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
|
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
|
||||||
getEventMetrics(websiteId, { type: 'event' }, filters),
|
getEventMetrics(websiteId, { type: 'event' }, filters),
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export async function GET(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, limit, offset, search } = query;
|
const { type, limit, offset, search } = query;
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
filters[type] = `c.${search}`;
|
filters[type] = `c.${search}`;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export async function GET(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, limit, offset, search } = query;
|
const { type, limit, offset, search } = query;
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
filters[type] = `c.${search}`;
|
filters[type] = `c.${search}`;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const [pageviews, sessions] = await Promise.all([
|
const [pageviews, sessions] = await Promise.all([
|
||||||
getPageviewStats(websiteId, filters),
|
getPageviewStats(websiteId, filters),
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getSessionDataProperties(websiteId, filters);
|
const data = await getSessionDataProperties(websiteId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export async function GET(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { propertyName } = query;
|
const { propertyName } = query;
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getSessionDataValues(websiteId, {
|
const data = await getSessionDataValues(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getSessionActivity(websiteId, sessionId, filters);
|
const data = await getSessionActivity(websiteId, sessionId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getWebsiteSessions(websiteId, filters);
|
const data = await getWebsiteSessions(websiteId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const metrics = await getWebsiteSessionStats(websiteId, filters);
|
const metrics = await getWebsiteSessionStats(websiteId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getWeeklyTraffic(websiteId, filters);
|
const data = await getWeeklyTraffic(websiteId, filters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,11 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
|
|
||||||
const data = await getWebsiteStats(websiteId, filters);
|
const data = await getWebsiteStats(websiteId, filters);
|
||||||
|
|
||||||
const compare = filters.compare ?? 'prev';
|
const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate);
|
||||||
|
|
||||||
const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate);
|
|
||||||
|
|
||||||
const comparison = await getWebsiteStats(websiteId, {
|
const comparison = await getWebsiteStats(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export async function GET(
|
||||||
value: segment.name,
|
value: segment.name,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId, auth.user?.id);
|
||||||
values = await getValues(websiteId, FILTER_COLUMNS[type], filters);
|
values = await getValues(websiteId, FILTER_COLUMNS[type], filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { BoardContext, type BoardContextValue } from '@/app/(main)/boards/BoardProvider';
|
import { BoardContext } from '@/app/(main)/boards/BoardProvider';
|
||||||
|
|
||||||
export function useBoard(): BoardContextValue {
|
export function useBoard() {
|
||||||
return useContext(BoardContext);
|
return useContext(BoardContext);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useApi } from '../useApi';
|
||||||
|
|
||||||
const selector = (state: { shareToken: string }) => state.shareToken;
|
const selector = (state: { shareToken: string }) => state.shareToken;
|
||||||
|
|
||||||
export function useShareTokenQuery(slug: string): {
|
export function useShareTokenQuery(shareId: string): {
|
||||||
shareToken: any;
|
shareToken: any;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: Error;
|
error?: Error;
|
||||||
|
|
@ -11,9 +11,9 @@ export function useShareTokenQuery(slug: string): {
|
||||||
const shareToken = useApp(selector);
|
const shareToken = useApp(selector);
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { isLoading, error } = useQuery({
|
const { isLoading, error } = useQuery({
|
||||||
queryKey: ['share', slug],
|
queryKey: ['share', shareId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await get(`/share/${slug}`);
|
const data = await get(`/share/${shareId}`);
|
||||||
|
|
||||||
setShareToken(data);
|
setShareToken(data);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||||
import { useDateParameters } from '@/components/hooks/useDateParameters';
|
import { useDateParameters } from '@/components/hooks/useDateParameters';
|
||||||
import { useDateRange } from '@/components/hooks/useDateRange';
|
|
||||||
import { useApi } from '../useApi';
|
import { useApi } from '../useApi';
|
||||||
import { useFilterParameters } from '../useFilterParameters';
|
import { useFilterParameters } from '../useFilterParameters';
|
||||||
|
|
||||||
|
|
@ -25,16 +24,12 @@ export function useWebsiteStatsQuery(
|
||||||
) {
|
) {
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||||
const { compare } = useDateRange();
|
|
||||||
const filters = useFilterParameters();
|
const filters = useFilterParameters();
|
||||||
|
|
||||||
return useQuery<WebsiteStatsData>({
|
return useQuery<WebsiteStatsData>({
|
||||||
queryKey: [
|
queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
|
||||||
'websites:stats',
|
|
||||||
{ websiteId, startAt, endAt, unit, timezone, compare, ...filters },
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, compare, ...filters }),
|
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
|
||||||
enabled: !!websiteId,
|
enabled: !!websiteId,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -95,13 +95,6 @@ export const EVENT_TYPE = {
|
||||||
pixelEvent: 4,
|
pixelEvent: 4,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ENTITY_TYPE = {
|
|
||||||
website: 1,
|
|
||||||
link: 2,
|
|
||||||
pixel: 3,
|
|
||||||
board: 4,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const DATA_TYPE = {
|
export const DATA_TYPE = {
|
||||||
string: 1,
|
string: 1,
|
||||||
number: 2,
|
number: 2,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { getLink, getPixel, getWebsite } from '@/queries/prisma';
|
|
||||||
|
|
||||||
export async function getEntity(entityId: string) {
|
|
||||||
const website = await getWebsite(entityId);
|
|
||||||
const link = await getLink(entityId);
|
|
||||||
const pixel = await getPixel(entityId);
|
|
||||||
|
|
||||||
const entity = website || link || pixel;
|
|
||||||
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
@ -81,12 +81,12 @@ export function getRequestFilters(query: Record<string, any>) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setWebsiteDate(websiteId: string, data: Record<string, any>) {
|
export async function setWebsiteDate(websiteId: string, userId: string, data: Record<string, any>) {
|
||||||
const website = await fetchWebsite(websiteId);
|
const website = await fetchWebsite(websiteId);
|
||||||
const cloudMode = !!process.env.CLOUD_MODE;
|
const cloudMode = !!process.env.CLOUD_MODE;
|
||||||
|
|
||||||
if (cloudMode && website && !website.teamId) {
|
if (cloudMode && website && !website.teamId) {
|
||||||
const account = await fetchAccount(website.userId);
|
const account = await fetchAccount(userId);
|
||||||
|
|
||||||
if (!account?.hasSubscription) {
|
if (!account?.hasSubscription) {
|
||||||
data.startDate = maxDate(data.startDate, startOfMonth(subMonths(new Date(), 6)));
|
data.startDate = maxDate(data.startDate, startOfMonth(subMonths(new Date(), 6)));
|
||||||
|
|
@ -103,12 +103,13 @@ export async function setWebsiteDate(websiteId: string, data: Record<string, any
|
||||||
export async function getQueryFilters(
|
export async function getQueryFilters(
|
||||||
params: Record<string, any>,
|
params: Record<string, any>,
|
||||||
websiteId?: string,
|
websiteId?: string,
|
||||||
|
userId?: string,
|
||||||
): Promise<QueryFilters> {
|
): Promise<QueryFilters> {
|
||||||
const dateRange = getRequestDateRange(params);
|
const dateRange = getRequestDateRange(params);
|
||||||
const filters = getRequestFilters(params);
|
const filters = getRequestFilters(params);
|
||||||
|
|
||||||
if (websiteId) {
|
if (websiteId) {
|
||||||
await setWebsiteDate(websiteId, dateRange);
|
await setWebsiteDate(websiteId, userId, dateRange);
|
||||||
|
|
||||||
if (params.segment) {
|
if (params.segment) {
|
||||||
const segmentParams = (await getWebsiteSegment(websiteId, params.segment))
|
const segmentParams = (await getWebsiteSegment(websiteId, params.segment))
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export const dateRangeParams = {
|
||||||
endDate: z.coerce.date().optional(),
|
endDate: z.coerce.date().optional(),
|
||||||
timezone: timezoneParam.optional(),
|
timezone: timezoneParam.optional(),
|
||||||
unit: unitParam.optional(),
|
unit: unitParam.optional(),
|
||||||
compare: z.enum(['prev', 'yoy']).optional(),
|
compare: z.string().optional(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterParams = {
|
export const filterParams = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { Board as PrismaBoard } from '@/generated/prisma/client';
|
|
||||||
import type { DATA_TYPE, OPERATORS, ROLES } from './constants';
|
import type { DATA_TYPE, OPERATORS, ROLES } from './constants';
|
||||||
import type { TIME_UNIT } from './date';
|
import type { TIME_UNIT } from './date';
|
||||||
|
|
||||||
|
|
@ -143,29 +141,3 @@ export interface ApiError extends Error {
|
||||||
code?: string;
|
code?: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardComponent {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BoardColumn {
|
|
||||||
id: string;
|
|
||||||
component?: ReactElement;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BoardRow {
|
|
||||||
id: string;
|
|
||||||
columns: BoardColumn[];
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BoardParameters {
|
|
||||||
rows?: BoardRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Board extends Omit<PrismaBoard, 'parameters'> {
|
|
||||||
parameters: BoardParameters;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { hasPermission } from '@/lib/auth';
|
|
||||||
import { PERMISSIONS } from '@/lib/constants';
|
|
||||||
import { getEntity } from '@/lib/entity';
|
|
||||||
import type { Auth } from '@/lib/types';
|
|
||||||
import { getTeamUser } from '@/queries/prisma';
|
|
||||||
|
|
||||||
export async function canViewEntity({ user }: Auth, entityId: string) {
|
|
||||||
if (user?.isAdmin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = await getEntity(entityId);
|
|
||||||
|
|
||||||
if (entity.userId) {
|
|
||||||
return user.id === entity.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity.teamId) {
|
|
||||||
const teamUser = await getTeamUser(entity.teamId, user.id);
|
|
||||||
|
|
||||||
return !!teamUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function canUpdateEntity({ user }: Auth, entityId: string) {
|
|
||||||
if (user.isAdmin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = await getEntity(entityId);
|
|
||||||
|
|
||||||
if (entity.userId) {
|
|
||||||
return user.id === entity.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity.teamId) {
|
|
||||||
const teamUser = await getTeamUser(entity.teamId, user.id);
|
|
||||||
|
|
||||||
return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function canDeleteEntity({ user }: Auth, entityId: string) {
|
|
||||||
if (user.isAdmin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = await getEntity(entityId);
|
|
||||||
|
|
||||||
if (entity.userId) {
|
|
||||||
return user.id === entity.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity.teamId) {
|
|
||||||
const teamUser = await getTeamUser(entity.teamId, user.id);
|
|
||||||
|
|
||||||
return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
export * from './board';
|
export * from './board';
|
||||||
export * from './entity';
|
|
||||||
export * from './link';
|
export * from './link';
|
||||||
export * from './pixel';
|
export * from './pixel';
|
||||||
export * from './report';
|
export * from './report';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ export * from './link';
|
||||||
export * from './pixel';
|
export * from './pixel';
|
||||||
export * from './report';
|
export * from './report';
|
||||||
export * from './segment';
|
export * from './segment';
|
||||||
export * from './share';
|
|
||||||
export * from './team';
|
export * from './team';
|
||||||
export * from './teamUser';
|
export * from './teamUser';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import type { Prisma } from '@/generated/prisma/client';
|
|
||||||
import prisma from '@/lib/prisma';
|
|
||||||
|
|
||||||
export async function findShare(criteria: Prisma.ShareFindUniqueArgs) {
|
|
||||||
return prisma.client.share.findUnique(criteria);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getShare(entityId: string) {
|
|
||||||
return findShare({
|
|
||||||
where: {
|
|
||||||
id: entityId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getShareByCode(slug: string) {
|
|
||||||
return findShare({
|
|
||||||
where: {
|
|
||||||
slug,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createShare(
|
|
||||||
data: Prisma.ShareCreateInput | Prisma.ShareUncheckedCreateInput,
|
|
||||||
) {
|
|
||||||
return prisma.client.share.create({
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateShare(
|
|
||||||
shareId: string,
|
|
||||||
data: Prisma.ShareUpdateInput | Prisma.ShareUncheckedUpdateInput,
|
|
||||||
) {
|
|
||||||
return prisma.client.share.update({
|
|
||||||
where: {
|
|
||||||
id: shareId,
|
|
||||||
},
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteShare(shareId: string) {
|
|
||||||
return prisma.client.share.delete({ where: { id: shareId } });
|
|
||||||
}
|
|
||||||
|
|
@ -16,6 +16,15 @@ export async function getWebsite(websiteId: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSharedWebsite(shareId: string) {
|
||||||
|
return findWebsite({
|
||||||
|
where: {
|
||||||
|
shareId,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) {
|
export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) {
|
||||||
const { search } = filters;
|
const { search } = filters;
|
||||||
const { getSearchParameters, pagedQuery } = prisma;
|
const { getSearchParameters, pagedQuery } = prisma;
|
||||||
|
|
|
||||||
|
|
@ -41,18 +41,3 @@ a:hover {
|
||||||
border: 4px solid rgba(0, 0, 0, 0);
|
border: 4px solid rgba(0, 0, 0, 0);
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix autofill background color to match dark theme */
|
|
||||||
input:-webkit-autofill,
|
|
||||||
input:-webkit-autofill:hover,
|
|
||||||
input:-webkit-autofill:focus,
|
|
||||||
input:-webkit-autofill:active,
|
|
||||||
textarea:-webkit-autofill,
|
|
||||||
textarea:-webkit-autofill:hover,
|
|
||||||
textarea:-webkit-autofill:focus,
|
|
||||||
select:-webkit-autofill,
|
|
||||||
select:-webkit-autofill:hover,
|
|
||||||
select:-webkit-autofill:focus {
|
|
||||||
-webkit-box-shadow: 0 0 0 1000px var(--background-color) inset !important;
|
|
||||||
transition: color 5000s ease-in-out 0s;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue