Split board view/edit rendering and isolate edit interactions

This commit is contained in:
Mike Cao 2026-02-12 22:36:23 -08:00
parent b09694ddb6
commit d8c41ac8a6
15 changed files with 249 additions and 111 deletions

View file

@ -99,7 +99,7 @@ export function BoardProvider({
const saveBoard = useCallback(async () => {
const defaultName = t(labels.untitled);
// Get current layout sizes from BoardBody if registered
// Get current layout sizes from BoardEditBody if registered
const layoutData = layoutGetterRef.current?.();
const parameters = sanitizeBoardParameters(
layoutData ? { ...board.parameters, ...layoutData } : board.parameters,

View file

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

View file

@ -4,16 +4,16 @@ import { Fragment, useEffect, useRef } from 'react';
import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resizable-panels';
import { v4 as uuid } from 'uuid';
import { useBoard } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { BoardRow } from './BoardRow';
import { GripHorizontal, Plus } from '@/components/icons';
import styles from './BoardEditLayout.module.css';
import { BoardEditRow } from './BoardEditRow';
import { BUTTON_ROW_HEIGHT, MAX_ROW_HEIGHT, MIN_ROW_HEIGHT } from './boardConstants';
export function BoardBody() {
const { board, editing, updateBoard, saveBoard, isPending, registerLayoutGetter } = useBoard();
export function BoardEditBody() {
const { board, updateBoard, registerLayoutGetter } = useBoard();
const rowGroupRef = useRef<GroupImperativeHandle>(null);
const columnGroupRefs = useRef<Map<string, GroupImperativeHandle>>(new Map());
// Register a function to get current layout sizes on save
useEffect(() => {
registerLayoutGetter(() => {
const rows = board?.parameters?.rows;
@ -104,9 +104,8 @@ export function BoardBody() {
};
const websiteId = board?.parameters?.websiteId;
const canEdit = editing && !!websiteId;
const rows = board?.parameters?.rows ?? [];
const minHeight = (rows?.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT;
const minHeight = (rows.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT;
return (
<Group groupRef={rowGroupRef} orientation="vertical" style={{ minHeight }}>
@ -118,22 +117,30 @@ export function BoardBody() {
maxSize={MAX_ROW_HEIGHT}
defaultSize={row.size}
>
<BoardRow
<BoardEditRow
{...row}
rowId={row.id}
rowIndex={index}
rowCount={rows?.length}
editing={canEdit}
rowCount={rows.length}
canEdit={!!websiteId}
onRemove={handleRemoveRow}
onMoveUp={handleMoveRowUp}
onMoveDown={handleMoveRowDown}
onRegisterRef={registerColumnGroupRef}
/>
</Panel>
{index < rows?.length - 1 && <Separator />}
{index < rows.length - 1 && (
<Separator className={styles.rowSeparator}>
<span className={styles.separatorHandle}>
<Icon size="sm">
<GripHorizontal />
</Icon>
</span>
</Separator>
)}
</Fragment>
))}
{canEdit && (
{!!websiteId && (
<Panel minSize={BUTTON_ROW_HEIGHT}>
<Row padding="3">
<TooltipTrigger delay={0}>

View file

@ -1,34 +1,27 @@
import {
Box,
Button,
Column,
Dialog,
Icon,
Modal,
Tooltip,
TooltipTrigger,
} from '@umami/react-zen';
import { Box, Button, Dialog, Icon, Modal, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { Panel } from '@/components/common/Panel';
import { useBoard, useMessages } from '@/components/hooks';
import { Pencil, Plus, X } from '@/components/icons';
import type { BoardComponentConfig } from '@/lib/types';
import { getComponentDefinition } from '../boardComponentRegistry';
import styles from './BoardColumn.module.css';
import { BoardComponentRenderer } from './BoardComponentRenderer';
import { BoardComponentSelect } from './BoardComponentSelect';
export function BoardColumn({
export function BoardEditColumn({
id,
component,
editing = false,
canEdit,
onRemove,
onSetComponent,
canRemove = true,
}: {
id: string;
component?: BoardComponentConfig;
editing?: boolean;
onRemove?: (id: string) => void;
onSetComponent?: (id: string, config: BoardComponentConfig | null) => void;
canEdit: boolean;
onRemove: (id: string) => void;
onSetComponent: (id: string, config: BoardComponentConfig | null) => void;
canRemove?: boolean;
}) {
const [showSelect, setShowSelect] = useState(false);
@ -44,32 +37,33 @@ export function BoardColumn({
}, [component, websiteId]);
const handleSelect = (config: BoardComponentConfig) => {
onSetComponent?.(id, config);
onSetComponent(id, config);
setShowSelect(false);
};
const hasComponent = !!component;
const canRemoveAction = hasComponent || canRemove;
const title = component ? getComponentDefinition(component.type)?.name : undefined;
const handleRemove = () => {
if (hasComponent) {
onSetComponent?.(id, null);
onSetComponent(id, null);
} else {
onRemove?.(id);
onRemove(id);
}
};
return (
<Column
<Panel
title={title}
width="100%"
height="100%"
alignItems="center"
justifyContent="center"
backgroundColor="surface-sunken"
position="relative"
className={styles.column}
>
{editing && canRemoveAction && (
{canEdit && canRemoveAction && (
<Box
className={styles.columnAction}
position="absolute"
@ -92,7 +86,7 @@ export function BoardColumn({
<Box width="100%" height="100%" overflow="auto">
{renderedComponent}
</Box>
{editing && (
{canEdit && (
<Box
className={styles.columnAction}
position="absolute"
@ -112,7 +106,7 @@ export function BoardColumn({
)}
</>
) : (
editing && (
canEdit && (
<Button variant="outline" onPress={() => setShowSelect(true)}>
<Icon>
<Plus />
@ -139,6 +133,6 @@ export function BoardColumn({
)}
</Dialog>
</Modal>
</Column>
</Panel>
);
}

View file

@ -0,0 +1,46 @@
.columnSeparator {
width: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
}
.rowSeparator {
height: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: row-resize;
}
.separatorHandle {
display: flex;
align-items: center;
justify-content: center;
color: var(--gray-9);
}
.rowGroup {
position: relative;
height: 100%;
}
.rowActions {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
z-index: 20;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 120ms ease;
}
.rowGroup:hover .rowActions,
.rowGroup:focus-within .rowActions {
opacity: 1;
visibility: visible;
pointer-events: auto;
}

View file

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

View file

@ -4,22 +4,23 @@ import { Fragment } from 'react';
import {
Group,
type GroupImperativeHandle,
Panel as ResizeablePanel,
Panel as ResizablePanel,
Separator,
} from 'react-resizable-panels';
import { v4 as uuid } from 'uuid';
import { useBoard } from '@/components/hooks';
import { ChevronDown, Minus, Plus } from '@/components/icons';
import { ChevronDown, GripVertical, Minus, Plus } from '@/components/icons';
import type { BoardColumn as BoardColumnType, BoardComponentConfig } from '@/lib/types';
import { BoardColumn } from './BoardColumn';
import { BoardEditColumn } from './BoardEditColumn';
import styles from './BoardEditLayout.module.css';
import { MAX_COLUMNS, MIN_COLUMN_WIDTH } from './boardConstants';
export function BoardRow({
export function BoardEditRow({
rowId,
rowIndex,
rowCount,
columns,
editing = false,
canEdit,
onRemove,
onMoveUp,
onMoveDown,
@ -29,16 +30,16 @@ export function BoardRow({
rowIndex: number;
rowCount: number;
columns: BoardColumnType[];
editing?: boolean;
onRemove?: (id: string) => void;
onMoveUp?: (id: string) => void;
onMoveDown?: (id: string) => void;
onRegisterRef?: (rowId: string, ref: GroupImperativeHandle | null) => void;
canEdit: boolean;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
onRegisterRef: (rowId: string, ref: GroupImperativeHandle | null) => void;
}) {
const { board, updateBoard } = useBoard();
const handleGroupRef = (ref: GroupImperativeHandle | null) => {
onRegisterRef?.(rowId, ref);
onRegisterRef(rowId, ref);
};
const handleAddColumn = () => {
@ -81,25 +82,33 @@ export function BoardRow({
};
return (
<Group groupRef={handleGroupRef} style={{ height: '100%' }}>
<Group groupRef={handleGroupRef} className={styles.rowGroup}>
{columns?.map((column, index) => (
<Fragment key={column.id}>
<ResizeablePanel id={column.id} minSize={MIN_COLUMN_WIDTH} defaultSize={column.size}>
<BoardColumn
<ResizablePanel id={column.id} minSize={MIN_COLUMN_WIDTH} defaultSize={column.size}>
<BoardEditColumn
{...column}
editing={editing}
canEdit={canEdit}
onRemove={handleRemoveColumn}
onSetComponent={handleSetComponent}
canRemove={columns?.length > 1}
canRemove={columns.length > 1}
/>
</ResizeablePanel>
{index < columns?.length - 1 && <Separator />}
</ResizablePanel>
{index < columns.length - 1 && (
<Separator className={styles.columnSeparator}>
<span className={styles.separatorHandle}>
<Icon size="sm">
<GripVertical />
</Icon>
</span>
</Separator>
)}
</Fragment>
))}
{editing && (
<Column alignSelf="center" padding="3" gap="1">
{canEdit && (
<Column className={styles.rowActions} padding="3" gap="1">
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={() => onMoveUp?.(rowId)} isDisabled={rowIndex === 0}>
<Button variant="outline" onPress={() => onMoveUp(rowId)} isDisabled={rowIndex === 0}>
<Icon rotate={180}>
<ChevronDown />
</Icon>
@ -110,7 +119,7 @@ export function BoardRow({
<Button
variant="outline"
onPress={handleAddColumn}
isDisabled={columns?.length >= MAX_COLUMNS}
isDisabled={columns.length >= MAX_COLUMNS}
>
<Icon>
<Plus />
@ -119,7 +128,7 @@ export function BoardRow({
<Tooltip placement="left">Add column</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={0}>
<Button variant="outline" onPress={() => onRemove?.(rowId)}>
<Button variant="outline" onPress={() => onRemove(rowId)}>
<Icon>
<Minus />
</Icon>
@ -129,7 +138,7 @@ export function BoardRow({
<TooltipTrigger delay={0}>
<Button
variant="outline"
onPress={() => onMoveDown?.(rowId)}
onPress={() => onMoveDown(rowId)}
isDisabled={rowIndex === rowCount - 1}
>
<Icon>

View file

@ -1,13 +0,0 @@
import { useBoard } from '@/components/hooks';
import { BoardEditHeader } from './BoardEditHeader';
import { BoardViewHeader } from './BoardViewHeader';
export function BoardHeader() {
const { board, editing } = useBoard();
if (editing) {
return <BoardEditHeader />;
}
return <BoardViewHeader />;
}

View file

@ -1,33 +0,0 @@
'use client';
import { Column } from '@umami/react-zen';
import { BoardBody } from '@/app/(main)/boards/[boardId]/BoardBody';
import { BoardHeader } from '@/app/(main)/boards/[boardId]/BoardHeader';
import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { PageBody } from '@/components/common/PageBody';
import { useBoard } from '@/components/hooks';
export function BoardPage({ boardId, editing = false }: { boardId?: string; editing?: boolean }) {
return (
<BoardProvider boardId={boardId} editing={editing}>
<PageBody>
<Column>
<BoardHeader />
<BoardControls />
<BoardBody />
</Column>
</PageBody>
</BoardProvider>
);
}
function BoardControls() {
const { board } = useBoard();
const websiteId = board?.parameters?.websiteId;
if (!websiteId) {
return null;
}
return <WebsiteControls websiteId={websiteId} allowCompare={true} />;
}

View file

@ -0,0 +1,15 @@
import { useBoard } from '@/components/hooks';
import { BoardViewRow } from './BoardViewRow';
export function BoardViewBody() {
const { board } = useBoard();
const rows = board?.parameters?.rows ?? [];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(row => (
<BoardViewRow key={row.id} columns={row.columns} />
))}
</div>
);
}

View file

@ -0,0 +1,27 @@
import { Box, Column } from '@umami/react-zen';
import { Panel } from '@/components/common/Panel';
import { useBoard } from '@/components/hooks';
import type { BoardComponentConfig } from '@/lib/types';
import { getComponentDefinition } from '../boardComponentRegistry';
import { BoardComponentRenderer } from './BoardComponentRenderer';
export function BoardViewColumn({ component }: { component?: BoardComponentConfig }) {
const { board } = useBoard();
const websiteId = board?.parameters?.websiteId;
if (!component || !websiteId) {
return null;
}
const title = getComponentDefinition(component.type)?.name;
return (
<Panel title={title} height="100%">
<Column width="100%" height="100%">
<Box width="100%" overflow="auto">
<BoardComponentRenderer config={component} websiteId={websiteId} />
</Box>
</Column>
</Panel>
);
}

View file

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

View file

@ -0,0 +1,21 @@
import type { BoardColumn } from '@/lib/types';
import { BoardViewColumn } from './BoardViewColumn';
import { MIN_COLUMN_WIDTH } from './boardConstants';
export function BoardViewRow({ columns }: { columns: BoardColumn[] }) {
return (
<div style={{ display: 'flex', gap: 12, width: '100%', overflowX: 'auto' }}>
{columns.map(column => (
<div
key={column.id}
style={{
flex: `${column.size ?? 1} 1 0%`,
minWidth: MIN_COLUMN_WIDTH,
}}
>
<BoardViewColumn component={column.component} />
</div>
))}
</div>
);
}

View file

@ -1,10 +1,10 @@
import type { Metadata } from 'next';
import { BoardPage } from '../BoardPage';
import { BoardEditPage } from '../BoardEditPage';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params;
return <BoardPage boardId={boardId} editing />;
return <BoardEditPage boardId={boardId} />;
}
export const metadata: Metadata = {

View file

@ -1,11 +1,16 @@
import type { Metadata } from 'next';
import { BoardPage } from './BoardPage';
import { BoardEditPage } from './BoardEditPage';
import { BoardViewPage } from './BoardViewPage';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params;
const isCreate = boardId === 'create';
return <BoardPage boardId={isCreate ? undefined : boardId} editing={isCreate} />;
if (isCreate) {
return <BoardEditPage />;
}
return <BoardViewPage boardId={boardId} />;
}
export const metadata: Metadata = {