Compare commits

...

23 commits

Author SHA1 Message Date
Mike Cao
6367d94552 Add default row/column for new boards and prevent removing last column.
Some checks are pending
Node.js CI / build (push) Waiting to run
- New boards now start with one row containing one column
- Hide remove column button when only one column remains in a row

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 03:02:51 -08:00
Mike Cao
385bdd6734 Add panel size persistence on board save.
- Add registerLayoutGetter to BoardContext for collecting sizes on save
- Use GroupImperativeHandle and groupRef prop for react-resizable-panels
- Add id props to Panels for layout mapping by panel id
- Collect row and column sizes via getLayout() only when saving
- Restore saved sizes via defaultSize prop on Panels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 01:25:31 -08:00
Mike Cao
30e48e3aaa Add panel size constraints and size properties to board types.
- Add MIN_ROW_HEIGHT, MAX_ROW_HEIGHT, MIN_COLUMN_WIDTH, BUTTON_ROW_HEIGHT constants
- Apply minSize/maxSize constraints to row and column panels
- Add size property to BoardColumn and BoardRow types for future persistence
- Add optional chaining for safer property access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 00:28:42 -08:00
Mike Cao
30c3ba77cc Add remove column button and fix container height calculation.
- Add X button in top right corner of each column to remove it
- Fix container height to use MIN_HEIGHT instead of MAX_HEIGHT per row

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 21:19:24 -08:00
Mike Cao
ff6575ff54 Add move row up/down functionality to board editor.
Rows can now be reordered using up/down buttons. Buttons are disabled
at boundaries (up disabled on first row, down disabled on last row).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 20:17:21 -08:00
Mike Cao
d9f698ca42 Board editing. 2026-01-18 04:20:36 -08:00
Mike Cao
68c56060b3 Add board state management with updateBoard and saveBoard methods.
BoardProvider now manages local board state and exposes updateBoard for
editing and saveBoard for persisting to the database. Supports both
create and edit modes with proper redirect after creation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 21:05:43 -08:00
Mike Cao
e08907d998 Board setup. 2026-01-16 19:52:16 -08:00
Mike Cao
f97c840825 Merge branch 'dev' into boards 2026-01-16 16:30:31 -08:00
Francis Cao
520c91c621 fix share api routes
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-16 16:29:08 -08:00
Mike Cao
e3e2ed0435 Merge branch 'dev' into boards
# Conflicts:
#	prisma/schema.prisma
#	src/permissions/index.ts
2026-01-16 16:25:21 -08:00
Francis Cao
29f2c7b7d4 share api, queries, permissions, migration, entity lib
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-15 16:25:56 -08:00
Mike Cao
a270b0afea
Merge pull request #3956 from Nayrode/master
Some checks are pending
Node.js CI / build (push) Waiting to run
Fix autofill styling inconsistency in dark theme
2026-01-14 21:34:52 -08:00
Francis Cao
889a404650 share table schema + migration
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-14 15:33:06 -08:00
Mike Cao
0fbd8a448d Merge branch 'dev' into boards 2026-01-14 12:53:25 -08:00
Mike Cao
1e0620c544 Updated packages. 2026-01-14 12:53:13 -08:00
Francis Cao
b6013c3ee8 Revert "refactor 6 month retention. use auth instead of cache:website". Fix share page retention bug.
This reverts commit 741c6039e6.
2026-01-14 10:28:48 -08:00
Dorian TETU
ebfbc282ee fix: autofill background color 2026-01-13 16:39:39 +01:00
Mike Cao
6d480d9c36
Merge pull request #3950 from AymanAlSuleihi/fix/compare-metrics
Some checks failed
Node.js CI / build (push) Has been cancelled
Fix metrics bar not updating on compare mode switch
2026-01-10 18:56:54 -08:00
AymanAlSuleihi
a049fbb5b0 Update compare parameter to use enum for valid values 2026-01-09 23:41:16 +00:00
AymanAlSuleihi
6420f2c813 Remove redundant compare param definition 2026-01-09 22:54:50 +00:00
AymanAlSuleihi
fbf03d6563 Fix metrics bar not updating on compare mode switch 2026-01-09 19:35:48 +00:00
Francis Cao
fbe031bfe9 update pixel/link edit form on save.
Some checks failed
Node.js CI / build (push) Has been cancelled
2026-01-07 09:30:59 -08:00
60 changed files with 2538 additions and 1934 deletions

3
.gitignore vendored
View file

@ -31,6 +31,9 @@ pm2.yml
*.log *.log
.vscode .vscode
.tool-versions .tool-versions
.claude
tmpclaude*
nul
# debug # debug
npm-debug.log* npm-debug.log*

96
CLAUDE.md Normal file
View file

@ -0,0 +1,96 @@
# 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`.

View file

@ -61,18 +61,18 @@
".next/cache" ".next/cache"
], ],
"dependencies": { "dependencies": {
"@clickhouse/client": "^1.12.0", "@clickhouse/client": "^1.16.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.1.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.1.0", "@prisma/client": "^7.2.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.12", "@tanstack/react-query": "^5.90.17",
"@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.2", "fs-extra": "^11.3.3",
"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,18 +101,19 @@
"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.0", "maxmind": "^5.0.3",
"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.16.3", "pg": "^8.17.0",
"prisma": "^7.1.0", "prisma": "^7.2.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",
@ -120,15 +121,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.6", "ua-parser-js": "^2.0.8",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zod": "^4.1.13", "zod": "^4.3.5",
"zustand": "^5.0.9" "zustand": "^5.0.10"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "^2.3.11",
"@formatjs/cli": "^4.2.29", "@formatjs/cli": "^4.2.29",
"@netlify/plugin-nextjs": "^5.15.1", "@netlify/plugin-nextjs": "^5.15.5",
"@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",
@ -137,8 +138,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.9.2", "@types/node": "^24.10.8",
"@types/react": "^19.2.7", "@types/react": "^19.2.8",
"@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",
@ -153,9 +154,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.52.5", "rollup": "^4.55.1",
"rollup-plugin-copy": "^3.4.0", "rollup-plugin-copy": "^3.4.0",
"rollup-plugin-delete": "^3.0.1", "rollup-plugin-delete": "^3.0.2",
"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",
@ -164,7 +165,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": "^6.1.2", "tar": "^7.5.3",
"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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
-- 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";

View file

@ -67,7 +67,6 @@ 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
@ -88,7 +87,6 @@ model Website {
@@index([userId]) @@index([userId])
@@index([teamId]) @@index([teamId])
@@index([createdAt]) @@index([createdAt])
@@index([shareId])
@@index([createdBy]) @@index([createdBy])
@@map("website") @@map("website")
} }
@ -339,3 +337,16 @@ 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")
}

View file

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

View file

@ -1,21 +1,111 @@
'use client'; 'use client';
import { Loading } from '@umami/react-zen'; import { Loading, useToast } from '@umami/react-zen';
import { createContext, type ReactNode } from 'react'; import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } 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 } from '@/generated/prisma/client'; import type { Board, BoardParameters } from '@/lib/types';
export const BoardContext = createContext<Board>(null); export type LayoutGetter = () => Partial<BoardParameters> | null;
export function BoardProvider({ boardId, children }: { boardId: string; children: ReactNode }) { export interface BoardContextValue {
const { data: board, isFetching, isLoading } = useBoardQuery(boardId); board: Partial<Board>;
updateBoard: (data: Partial<Board>) => void;
saveBoard: () => Promise<Board>;
isPending: boolean;
registerLayoutGetter: (getter: LayoutGetter) => void;
}
if (isFetching && isLoading) { export const BoardContext = createContext<BoardContextValue>(null);
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" />;
} }
if (!board) { return (
return null; <BoardContext.Provider
} value={{ board, updateBoard, saveBoard, isPending, registerLayoutGetter }}
>
return <BoardContext.Provider value={board}>{children}</BoardContext.Provider>; {children}
</BoardContext.Provider>
);
} }

View file

@ -1,3 +1,322 @@
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() {
return <h1>i am bored.</h1>; const { board, updateBoard, saveBoard, isPending, 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;
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>
);
} }

View file

@ -1,21 +1,22 @@
import { Button, Column, Grid, Heading, Row, TextField } from '@umami/react-zen'; import { Column, Grid, Heading, LoadingButton, Row, TextField } from '@umami/react-zen';
import { useMessages } from '@/components/hooks'; import { useBoard, 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 = (name: string) => { const handleNameChange = (value: string) => {
//updateReport({ name: name || defaultName }); updateBoard({ name: value });
}; };
const handleDescriptionChange = (description: string) => { const handleDescriptionChange = (value: string) => {
//updateReport({ description }); updateBoard({ description: value });
}; };
return <h1>asdgfviybiyu8oaero8g9873qrgb875qh0g8</h1>; const handleSave = () => {
saveBoard();
};
return ( return (
<Grid <Grid
@ -25,39 +26,38 @@ 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={name} value={board?.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">{name}</Heading> <Heading size="4">{board?.name}</Heading>
</TextField> </TextField>
</Row> </Row>
<Row> <Row>
<TextField <TextField
variant="quiet" variant="quiet"
name="description" name="description"
value={description} value={board?.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%' }}
> >
{description} {board?.description}
</TextField> </TextField>
</Row> </Row>
</Column> </Column>
<Column justifyContent="center" alignItems="flex-end"> <Column justifyContent="center" alignItems="flex-end">
<Button variant="primary">{formatMessage(labels.save)}</Button> <LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
{formatMessage(labels.save)}
</LoadingButton>
</Column> </Column>
</Grid> </Grid>
); );

View file

@ -1,5 +1,6 @@
'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';
@ -10,6 +11,7 @@ export function BoardPage({ boardId }: { boardId: string }) {
<PageBody> <PageBody>
<Column> <Column>
<BoardHeader /> <BoardHeader />
<BoardBody />
</Column> </Column>
</PageBody> </PageBody>
</BoardProvider> </BoardProvider>

View file

@ -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} />; return <BoardPage boardId={boardId !== 'create' ? boardId : undefined} />;
} }
export const metadata: Metadata = { export const metadata: Metadata = {

View file

@ -1,5 +1,5 @@
import { ConfirmationForm } from '@/components/common/ConfirmationForm'; import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages } from '@/components/hooks'; import { useDeleteQuery, useMessages, useModified } 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,7 +15,8 @@ export function LinkDeleteButton({
onSave?: () => void; onSave?: () => void;
}) { }) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages(); const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`); const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`);
const { touch } = useModified();
const handleConfirm = async (close: () => void) => { const handleConfirm = async (close: () => void) => {
await mutateAsync(null, { await mutateAsync(null, {

View file

@ -50,6 +50,7 @@ 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?.();
}, },

View file

@ -48,6 +48,7 @@ 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?.();
}, },

View file

@ -1,5 +1,4 @@
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';
@ -26,8 +25,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(),
domain: z.string().optional(), description: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(), parameters: z.object({}).passthrough().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -37,14 +36,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
} }
const { boardId } = await params; const { boardId } = await params;
const { name, domain, shareId } = body; const { name, description, parameters } = body;
if (!(await canUpdateBoard(auth, boardId))) { if (!(await canUpdateBoard(auth, boardId))) {
return unauthorized(); return unauthorized();
} }
try { try {
const board = await updateBoard(boardId, { name, domain, shareId }); const board = await updateBoard(boardId, { name, description, parameters });
return Response.json(board); return Response.json(board);
} catch (e: any) { } catch (e: any) {

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id); const filters = await getQueryFilters(body.filters, websiteId);
const data = await getAttribution(websiteId, parameters as AttributionParameters, filters); const data = await getAttribution(websiteId, parameters as AttributionParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id); const filters = await getQueryFilters(body.filters, websiteId);
const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters); const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id); const filters = await getQueryFilters(body.filters, websiteId);
const data = await getFunnel(websiteId, parameters as FunnelParameters, filters); const data = await getFunnel(websiteId, parameters as FunnelParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id); const filters = await getQueryFilters(body.filters, websiteId);
const data = await getGoal(websiteId, parameters as GoalParameters, filters); const data = await getGoal(websiteId, parameters as GoalParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id); const filters = await getQueryFilters(body.filters, websiteId);
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const data = await getRetention(websiteId, parameters as RetentionParameters, filters); const data = await getRetention(websiteId, parameters as RetentionParameters, filters);

View file

@ -17,8 +17,8 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id); const filters = await getQueryFilters(body.filters, websiteId);
const data = await getRevenue(websiteId, parameters as RevenuParameters, filters); const data = await getRevenue(websiteId, parameters as RevenuParameters, filters);

View file

@ -18,8 +18,8 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(body.filters, websiteId, auth.user?.id); const filters = await getQueryFilters(body.filters, websiteId);
const parameters = await setWebsiteDate(websiteId, auth.user.id, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const data = { const data = {
utm_source: [], utm_source: [],

View file

@ -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 { getSharedWebsite } from '@/queries/prisma'; import { getShareByCode } from '@/queries/prisma';
export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { shareId } = await params; const { slug } = await params;
const website = await getSharedWebsite(shareId); const share = await getShareByCode(slug);
if (!website) { if (!share) {
return notFound(); return notFound();
} }
const data = { websiteId: website.id }; const data = { shareId: share.id };
const token = createToken(data, secret()); const token = createToken(data, secret());
return json({ ...data, token }); return json({ ...data, token });

View file

@ -0,0 +1,80 @@
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();
}

View file

@ -0,0 +1,38 @@
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);
}

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getEventDataEvents(websiteId, { const data = await getEventDataEvents(websiteId, {
...filters, ...filters,

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getEventDataFields(websiteId, filters); const data = await getEventDataFields(websiteId, filters);

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getEventDataProperties(websiteId, filters); const data = await getEventDataProperties(websiteId, filters);

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getEventDataStats(websiteId, filters); const data = await getEventDataStats(websiteId, filters);

View file

@ -30,7 +30,7 @@ export async function GET(
} }
const { propertyName } = query; const { propertyName } = query;
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getEventDataValues(websiteId, { const data = await getEventDataValues(websiteId, {
...filters, ...filters,

View file

@ -29,7 +29,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getWebsiteEvents(websiteId, filters); const data = await getWebsiteEvents(websiteId, filters);

View file

@ -29,7 +29,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getEventStats(websiteId, filters); const data = await getEventStats(websiteId, filters);

View file

@ -28,7 +28,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
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),

View file

@ -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, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
if (search) { if (search) {
filters[type] = `c.${search}`; filters[type] = `c.${search}`;

View file

@ -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, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
if (search) { if (search) {
filters[type] = `c.${search}`; filters[type] = `c.${search}`;

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const [pageviews, sessions] = await Promise.all([ const [pageviews, sessions] = await Promise.all([
getPageviewStats(websiteId, filters), getPageviewStats(websiteId, filters),

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getSessionDataProperties(websiteId, filters); const data = await getSessionDataProperties(websiteId, filters);

View file

@ -29,7 +29,7 @@ export async function GET(
} }
const { propertyName } = query; const { propertyName } = query;
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getSessionDataValues(websiteId, { const data = await getSessionDataValues(websiteId, {
...filters, ...filters,

View file

@ -25,7 +25,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getSessionActivity(websiteId, sessionId, filters); const data = await getSessionActivity(websiteId, sessionId, filters);

View file

@ -28,7 +28,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getWebsiteSessions(websiteId, filters); const data = await getWebsiteSessions(websiteId, filters);

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const metrics = await getWebsiteSessionStats(websiteId, filters); const metrics = await getWebsiteSessionStats(websiteId, filters);

View file

@ -28,7 +28,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getWeeklyTraffic(websiteId, filters); const data = await getWeeklyTraffic(websiteId, filters);

View file

@ -27,11 +27,13 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
const data = await getWebsiteStats(websiteId, filters); const data = await getWebsiteStats(websiteId, filters);
const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate); const compare = filters.compare ?? 'prev';
const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate);
const comparison = await getWebsiteStats(websiteId, { const comparison = await getWebsiteStats(websiteId, {
...filters, ...filters,

View file

@ -42,7 +42,7 @@ export async function GET(
value: segment.name, value: segment.name,
})); }));
} else { } else {
const filters = await getQueryFilters(query, websiteId, auth.user?.id); const filters = await getQueryFilters(query, websiteId);
values = await getValues(websiteId, FILTER_COLUMNS[type], filters); values = await getValues(websiteId, FILTER_COLUMNS[type], filters);
} }

View file

@ -1,6 +1,6 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { BoardContext } from '@/app/(main)/boards/BoardProvider'; import { BoardContext, type BoardContextValue } from '@/app/(main)/boards/BoardProvider';
export function useBoard() { export function useBoard(): BoardContextValue {
return useContext(BoardContext); return useContext(BoardContext);
} }

View file

@ -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(shareId: string): { export function useShareTokenQuery(slug: string): {
shareToken: any; shareToken: any;
isLoading?: boolean; isLoading?: boolean;
error?: Error; error?: Error;
@ -11,9 +11,9 @@ export function useShareTokenQuery(shareId: 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', shareId], queryKey: ['share', slug],
queryFn: async () => { queryFn: async () => {
const data = await get(`/share/${shareId}`); const data = await get(`/share/${slug}`);
setShareToken(data); setShareToken(data);

View file

@ -1,5 +1,6 @@
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';
@ -24,12 +25,16 @@ 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: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }], queryKey: [
'websites:stats',
{ websiteId, startAt, endAt, unit, timezone, compare, ...filters },
],
queryFn: () => queryFn: () =>
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }), get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, compare, ...filters }),
enabled: !!websiteId, enabled: !!websiteId,
...options, ...options,
}); });

View file

@ -95,6 +95,13 @@ 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,

11
src/lib/entity.ts Normal file
View file

@ -0,0 +1,11 @@
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;
}

View file

@ -81,12 +81,12 @@ export function getRequestFilters(query: Record<string, any>) {
return result; return result;
} }
export async function setWebsiteDate(websiteId: string, userId: string, data: Record<string, any>) { export async function setWebsiteDate(websiteId: 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(userId); const account = await fetchAccount(website.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,13 +103,12 @@ export async function setWebsiteDate(websiteId: string, userId: string, data: Re
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, userId, dateRange); await setWebsiteDate(websiteId, dateRange);
if (params.segment) { if (params.segment) {
const segmentParams = (await getWebsiteSegment(websiteId, params.segment)) const segmentParams = (await getWebsiteSegment(websiteId, params.segment))

View file

@ -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.string().optional(), compare: z.enum(['prev', 'yoy']).optional(),
}; };
export const filterParams = { export const filterParams = {

View file

@ -1,4 +1,6 @@
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';
@ -141,3 +143,29 @@ 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;
}

65
src/permissions/entity.ts Normal file
View file

@ -0,0 +1,65 @@
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;
}

View file

@ -1,4 +1,5 @@
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';

View file

@ -3,6 +3,7 @@ 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';

View file

@ -0,0 +1,46 @@
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 } });
}

View file

@ -16,15 +16,6 @@ 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;

View file

@ -41,3 +41,18 @@ 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;
}