Board setup.

This commit is contained in:
Mike Cao 2026-01-16 19:52:16 -08:00
parent f97c840825
commit e08907d998
9 changed files with 141 additions and 23 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*

92
CLAUDE.md Normal file
View file

@ -0,0 +1,92 @@
# 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)

View file

@ -6,7 +6,6 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL")
relationMode = "prisma" relationMode = "prisma"
} }

View file

@ -13,9 +13,5 @@ export function BoardProvider({ boardId, children }: { boardId: string; children
return <Loading placement="absolute" />; return <Loading placement="absolute" />;
} }
if (!board) {
return null;
}
return <BoardContext.Provider value={board}>{children}</BoardContext.Provider>; return <BoardContext.Provider value={board}>{children}</BoardContext.Provider>;
} }

View file

@ -1,21 +1,48 @@
import { Button, Column, Grid, Heading, Row, TextField } from '@umami/react-zen'; import { Column, Grid, Heading, LoadingButton, Row, TextField, useToast } from '@umami/react-zen';
import { useMessages } from '@/components/hooks'; import { useState } from 'react';
import { useApi, useBoard, useMessages, useModified, useNavigation } from '@/components/hooks';
export function BoardHeader() { export function BoardHeader() {
const { formatMessage, labels } = useMessages(); const board = useBoard();
const { formatMessage, labels, messages } = useMessages();
const { post, useMutation } = useApi();
const { touch } = useModified();
const { router, renderUrl } = useNavigation();
const { toast } = useToast();
const defaultName = formatMessage(labels.untitled); const defaultName = formatMessage(labels.untitled);
const name = '';
const description = '';
const handleNameChange = (name: string) => { const [name, setName] = useState(board?.name ?? '');
//updateReport({ name: name || defaultName }); const [description, setDescription] = useState(board?.description ?? '');
const { mutateAsync, isPending } = useMutation({
mutationFn: (data: { name: string; description: string }) => {
if (board) {
return post(`/boards/${board.id}`, data);
}
return post('/boards', { ...data, type: 'dashboard', slug: '' });
},
});
const handleNameChange = (value: string) => {
setName(value);
}; };
const handleDescriptionChange = (description: string) => { const handleDescriptionChange = (value: string) => {
//updateReport({ description }); setDescription(value);
}; };
return <h1>asdgfviybiyu8oaero8g9873qrgb875qh0g8</h1>; const handleSave = async () => {
const result = await mutateAsync({ name: name || defaultName, description });
toast(formatMessage(messages.saved));
touch('boards');
if (board) {
touch(`board:${board.id}`);
} else if (result?.id) {
router.push(renderUrl(`/boards/${result.id}`));
}
};
return ( return (
<Grid <Grid
@ -25,14 +52,12 @@ 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={name}
defaultValue={name}
placeholder={defaultName} placeholder={defaultName}
onChange={handleNameChange} onChange={handleNameChange}
autoComplete="off" autoComplete="off"
@ -46,7 +71,6 @@ export function BoardHeader() {
variant="quiet" variant="quiet"
name="description" name="description"
value={description} value={description}
defaultValue={description}
placeholder={`+ ${formatMessage(labels.addDescription)}`} placeholder={`+ ${formatMessage(labels.addDescription)}`}
autoComplete="off" autoComplete="off"
onChange={handleDescriptionChange} onChange={handleDescriptionChange}
@ -57,7 +81,9 @@ export function BoardHeader() {
</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

@ -26,7 +26,7 @@ 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(), shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
}); });
@ -37,14 +37,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, shareId } = 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, shareId });
return Response.json(board); return Response.json(board);
} catch (e: any) { } catch (e: any) {