Add personal dashboard flow and per-component website selection
Some checks are pending
Node.js CI / build (push) Waiting to run

This introduces a user-scoped dashboard with board-style view/edit pages while keeping it unavailable in team context, and moves website targeting to component config so dashboard components can each select their own website.
This commit is contained in:
Mike Cao 2026-02-13 11:48:15 -08:00
parent 2633697585
commit 631cc46f7f
73 changed files with 418 additions and 88 deletions

View file

@ -14,16 +14,33 @@ import { SettingsNav } from '@/app/(main)/settings/SettingsNav';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
import { IconLabel } from '@/components/common/IconLabel';
import { useGlobalState, useMessages, useNavigation } from '@/components/hooks';
import { Globe, Grid2x2, LayoutDashboard, LinkIcon, PanelLeft } from '@/components/icons';
import {
Globe,
Grid2x2,
LayoutDashboard,
LayoutGrid,
LinkIcon,
PanelLeft,
} from '@/components/icons';
import { UserButton } from '@/components/input/UserButton';
import { Logo } from '@/components/svg';
export function SideNav(props: any) {
const { t, labels } = useMessages();
const { pathname, renderUrl, websiteId } = useNavigation();
const { pathname, renderUrl, websiteId, teamId } = useNavigation();
const [isCollapsed] = useGlobalState('sidenav-collapsed', false);
const links = [
...(!teamId
? [
{
id: 'dashboard',
label: t(labels.dashboard),
path: '/dashboard',
icon: <LayoutGrid />,
},
]
: []),
{
id: 'boards',
label: t(labels.boards),

View file

@ -8,7 +8,7 @@ function BoardComponentRendererComponent({
websiteId,
}: {
config: BoardComponentConfig;
websiteId: string;
websiteId?: string;
}) {
const definition = getComponentDefinition(config.type);
@ -22,6 +22,14 @@ function BoardComponentRendererComponent({
const Component = definition.component;
if (!websiteId) {
return (
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
<Text color="muted">Select a website</Text>
</Column>
);
}
return <Component websiteId={websiteId} {...config.props} />;
}

View file

@ -11,6 +11,7 @@ import {
import { useEffect, useMemo, useState } from 'react';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
import type { BoardComponentConfig } from '@/lib/types';
import {
CATEGORIES,
@ -21,12 +22,16 @@ import {
import { BoardComponentRenderer } from './BoardComponentRenderer';
export function BoardComponentSelect({
teamId,
websiteId,
defaultWebsiteId,
initialConfig,
onSelect,
onClose,
}: {
websiteId: string;
teamId?: string;
websiteId?: string;
defaultWebsiteId?: string;
initialConfig?: BoardComponentConfig;
onSelect: (config: BoardComponentConfig) => void;
onClose: () => void;
@ -34,6 +39,9 @@ export function BoardComponentSelect({
const { t, labels, messages } = useMessages();
const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null);
const [configValues, setConfigValues] = useState<Record<string, any>>({});
const [selectedWebsiteId, setSelectedWebsiteId] = useState(
initialConfig?.websiteId || websiteId || defaultWebsiteId,
);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
@ -73,9 +81,10 @@ export function BoardComponentSelect({
setSelectedDef(definition);
setConfigValues(getDefaultConfigValues(definition, initialConfig));
setSelectedWebsiteId(initialConfig.websiteId || websiteId || defaultWebsiteId);
setTitle(initialConfig.title ?? '');
setDescription(initialConfig.description || '');
}, [initialConfig, allDefinitions]);
}, [initialConfig, allDefinitions, websiteId, defaultWebsiteId]);
const handleSelectComponent = (def: ComponentDefinition) => {
setSelectedDef(def);
@ -107,6 +116,7 @@ export function BoardComponentSelect({
const config: BoardComponentConfig = {
type: selectedDef.type,
websiteId: selectedWebsiteId,
title,
description,
};
@ -172,12 +182,14 @@ export function BoardComponentSelect({
<Column gap="3" flexGrow={1} style={{ minWidth: 0 }}>
<Panel maxHeight="100%">
{previewConfig && websiteId ? (
<BoardComponentRenderer config={previewConfig} websiteId={websiteId} />
{previewConfig && selectedWebsiteId ? (
<BoardComponentRenderer config={previewConfig} websiteId={selectedWebsiteId} />
) : (
<Column alignItems="center" justifyContent="center" height="100%">
<Text color="muted">
{websiteId ? t(messages.selectComponentPreview) : t(messages.selectWebsiteFirst)}
{selectedWebsiteId
? t(messages.selectComponentPreview)
: t(messages.selectWebsiteFirst)}
</Text>
</Column>
)}
@ -187,6 +199,18 @@ export function BoardComponentSelect({
<Column gap="3" style={{ width: 320, flexShrink: 0, overflowY: 'auto' }}>
<Text weight="bold">{t(labels.properties)}</Text>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.website)}
</Text>
<WebsiteSelect
websiteId={selectedWebsiteId}
teamId={teamId}
placeholder={t(labels.selectWebsite)}
onChange={setSelectedWebsiteId}
/>
</Column>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.title)}

View file

@ -8,7 +8,7 @@ import { GripHorizontal, Plus } from '@/components/icons';
import { BoardEditRow } from './BoardEditRow';
import { BUTTON_ROW_HEIGHT, MAX_ROW_HEIGHT, MIN_ROW_HEIGHT } from './boardConstants';
export function BoardEditBody() {
export function BoardEditBody({ requiresBoardWebsite = true }: { requiresBoardWebsite?: boolean }) {
const { board, updateBoard, registerLayoutGetter } = useBoard();
const rowGroupRef = useRef<GroupImperativeHandle>(null);
const columnGroupRefs = useRef<Map<string, GroupImperativeHandle>>(new Map());
@ -103,6 +103,7 @@ export function BoardEditBody() {
};
const websiteId = board?.parameters?.websiteId;
const canEdit = requiresBoardWebsite ? !!websiteId : true;
const rows = board?.parameters?.rows ?? [];
const minHeight = (rows.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT;
@ -122,7 +123,7 @@ export function BoardEditBody() {
rowId={row.id}
rowIndex={index}
rowCount={rows.length}
canEdit={!!websiteId}
canEdit={canEdit}
onRemove={handleRemoveRow}
onMoveUp={handleMoveRowUp}
onMoveDown={handleMoveRowDown}
@ -157,7 +158,7 @@ export function BoardEditBody() {
)}
</Fragment>
))}
{!!websiteId && (
{canEdit && (
<Panel minSize={BUTTON_ROW_HEIGHT}>
<Row padding="3">
<TooltipTrigger delay={0}>

View file

@ -11,7 +11,7 @@ import {
} from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { Panel } from '@/components/common/Panel';
import { useBoard, useMessages } from '@/components/hooks';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
import { Pencil, Plus, X } from '@/components/icons';
import type { BoardComponentConfig } from '@/lib/types';
import { BoardComponentRenderer } from './BoardComponentRenderer';
@ -36,7 +36,9 @@ export function BoardEditColumn({
const [showActions, setShowActions] = useState(false);
const { board } = useBoard();
const { t, labels } = useMessages();
const websiteId = board?.parameters?.websiteId;
const { teamId } = useNavigation();
const boardWebsiteId = board?.parameters?.websiteId;
const websiteId = component?.websiteId || boardWebsiteId;
const renderedComponent = useMemo(() => {
if (!component || !websiteId) {
return null;
@ -126,7 +128,9 @@ export function BoardEditColumn({
>
{() => (
<BoardComponentSelect
teamId={teamId}
websiteId={websiteId}
defaultWebsiteId={boardWebsiteId}
initialConfig={component}
onSelect={handleSelect}
onClose={() => setShowSelect(false)}

View file

@ -6,7 +6,7 @@ import { BoardComponentRenderer } from './BoardComponentRenderer';
export function BoardViewColumn({ component }: { component?: BoardComponentConfig }) {
const { board } = useBoard();
const websiteId = board?.parameters?.websiteId;
const websiteId = component?.websiteId || board?.parameters?.websiteId;
if (!component || !websiteId) {
return null;

View file

@ -0,0 +1,27 @@
import { Button, LoadingButton, Row } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
export function DashboardEditHeader() {
const { saveBoard, isPending } = useBoard();
const { t, labels } = useMessages();
const { router, renderUrl } = useNavigation();
const handleSave = async () => {
await saveBoard();
router.push(renderUrl('/dashboard', false));
};
return (
<PageHeader title={t(labels.dashboard)}>
<Row gap="3">
<Button variant="quiet" onPress={() => router.push(renderUrl('/dashboard', false))}>
{t(labels.cancel)}
</Button>
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
{t(labels.save)}
</LoadingButton>
</Row>
</PageHeader>
);
}

View file

@ -0,0 +1,33 @@
'use client';
import { Column } from '@umami/react-zen';
import { useEffect } from 'react';
import { BoardEditBody } from '@/app/(main)/boards/[boardId]/BoardEditBody';
import { PageBody } from '@/components/common/PageBody';
import { useNavigation } from '@/components/hooks';
import { DashboardEditHeader } from './DashboardEditHeader';
import { DashboardProvider } from './DashboardProvider';
export function DashboardEditPage() {
const { teamId, router } = useNavigation();
useEffect(() => {
if (teamId) {
router.replace('/dashboard/edit');
}
}, [teamId, router]);
if (teamId) {
return null;
}
return (
<DashboardProvider editing>
<PageBody>
<Column>
<DashboardEditHeader />
<BoardEditBody requiresBoardWebsite={false} />
</Column>
</PageBody>
</DashboardProvider>
);
}

View file

@ -1,17 +0,0 @@
'use client';
import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages } from '@/components/hooks';
export function DashboardPage() {
const { t, labels } = useMessages();
return (
<PageBody>
<Column margin="2">
<PageHeader title={t(labels.dashboard)}></PageHeader>
</Column>
</PageBody>
);
}

View file

@ -0,0 +1,111 @@
'use client';
import { Loading, useToast } from '@umami/react-zen';
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { v4 as uuid } from 'uuid';
import { BoardContext, type LayoutGetter } from '@/app/(main)/boards/BoardProvider';
import { getComponentDefinition } from '@/app/(main)/boards/boardComponentRegistry';
import { useApi, useDashboardQuery, useMessages, useModified } from '@/components/hooks';
import type { Board, BoardParameters } from '@/lib/types';
const createDefaultBoard = (): Partial<Board> => ({
name: '',
description: '',
parameters: {
rows: [{ id: uuid(), columns: [{ id: uuid(), component: null }] }],
},
});
function sanitizeBoardParameters(parameters?: BoardParameters): BoardParameters | undefined {
if (!parameters?.rows) {
return parameters;
}
return {
...parameters,
rows: parameters.rows.map(row => ({
...row,
columns: row.columns.map(column => {
if (column.component && !getComponentDefinition(column.component.type)) {
return {
...column,
component: null,
};
}
return column;
}),
})),
};
}
export function DashboardProvider({
editing = false,
children,
}: {
editing?: boolean;
children: ReactNode;
}) {
const { data, isFetching, isLoading } = useDashboardQuery();
const { post, useMutation } = useApi();
const { touch } = useModified();
const { toast } = useToast();
const { t, labels, messages } = useMessages();
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,
parameters: sanitizeBoardParameters(data.parameters),
});
}
}, [data]);
const { mutateAsync, isPending } = useMutation({
mutationFn: (boardData: Partial<Board>) => {
return post('/dashboard', boardData);
},
});
const updateBoard = useCallback((data: Partial<Board>) => {
setBoard(current => ({ ...current, ...data }));
}, []);
const saveBoard = useCallback(async () => {
const dashboardName = t(labels.dashboard);
const layoutData = layoutGetterRef.current?.();
const parameters = sanitizeBoardParameters(
layoutData ? { ...board.parameters, ...layoutData } : board.parameters,
);
const result = await mutateAsync({
...board,
name: dashboardName,
description: '',
parameters,
});
toast(t(messages.saved));
touch('dashboard');
touch('boards');
return result;
}, [board, labels.dashboard, messages.saved, mutateAsync, t, toast, touch]);
if (isFetching && isLoading) {
return <Loading placement="absolute" />;
}
return (
<BoardContext.Provider
value={{ board, editing, updateBoard, saveBoard, isPending, registerLayoutGetter }}
>
{children}
</BoardContext.Provider>
);
}

View file

@ -0,0 +1,18 @@
import { IconLabel } from '@/components/common/IconLabel';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, useNavigation } from '@/components/hooks';
import { Edit } from '@/components/icons';
export function DashboardViewHeader() {
const { t, labels } = useMessages();
const { renderUrl } = useNavigation();
return (
<PageHeader title={t(labels.dashboard)}>
<LinkButton href={renderUrl('/dashboard/edit', false)}>
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
</LinkButton>
</PageHeader>
);
}

View file

@ -0,0 +1,49 @@
'use client';
import { Column } from '@umami/react-zen';
import { useEffect } from 'react';
import { BoardViewBody } from '@/app/(main)/boards/[boardId]/BoardViewBody';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { PageBody } from '@/components/common/PageBody';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
import { DashboardProvider } from './DashboardProvider';
import { DashboardViewHeader } from './DashboardViewHeader';
function DashboardContent() {
const { board } = useBoard();
const { t, labels, messages } = useMessages();
const rows = board?.parameters?.rows ?? [];
const hasComponents = rows.some(row => row.columns?.some(column => !!column.component));
if (!hasComponents) {
return (
<EmptyPlaceholder title={t(labels.dashboard)} description={t(messages.emptyDashboard)} />
);
}
return <BoardViewBody />;
}
export function DashboardViewPage() {
const { teamId, router } = useNavigation();
useEffect(() => {
if (teamId) {
router.replace('/dashboard');
}
}, [teamId, router]);
if (teamId) {
return null;
}
return (
<DashboardProvider>
<PageBody>
<Column>
<DashboardViewHeader />
<DashboardContent />
</Column>
</PageBody>
</DashboardProvider>
);
}

View file

@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { DashboardEditPage } from '../DashboardEditPage';
export default function () {
return <DashboardEditPage />;
}
export const metadata: Metadata = {
title: 'Edit Dashboard',
};

View file

@ -1,8 +1,8 @@
import type { Metadata } from 'next';
import { DashboardPage } from './DashboardPage';
import { DashboardViewPage } from './DashboardViewPage';
export default async function () {
return <DashboardPage />;
return <DashboardViewPage />;
}
export const metadata: Metadata = {

View file

@ -0,0 +1,64 @@
import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { createBoard, getBoard, updateBoard } from '@/queries/prisma';
export async function GET(request: Request) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const board = await getBoard(auth.user.id);
if (board && board.userId !== auth.user.id) {
return unauthorized();
}
return json(board);
}
export async function POST(request: Request) {
const schema = z.object({
name: z.string().optional(),
description: z.string().optional(),
parameters: z.object({}).passthrough().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const userId = auth.user.id;
const existing = await getBoard(userId);
if (existing && existing.userId !== userId) {
return unauthorized();
}
const data = {
name: body.name,
description: body.description,
parameters: body.parameters ?? {},
};
if (existing) {
const result = await updateBoard(userId, data);
return json(result);
}
const result = await createBoard({
id: userId,
type: 'dashboard',
slug: uuid(),
userId,
...data,
});
return json(result);
}