From 2317c948ff79a8b227d4709fbf9b1bbc23ff11f6 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 17:18:24 +0200 Subject: [PATCH] feat: enhance Kanban board with grid layout and persistence improvements - Introduced a new KanbanGridLayout component for improved task organization and layout management. - Updated KanbanBoard to utilize the new grid layout, enhancing the visual structure of tasks. - Added CSS styles for grid layout and resizing handles to improve user interaction. - Refactored KanbanColumn to support additional customization options for headers and body styles. - Enhanced persistence flows to rely on repository abstractions, promoting better separation of concerns in storage management. - Updated package dependencies to include react-grid-layout and react-resizable for enhanced layout capabilities. --- CLAUDE.md | 6 + package.json | 2 + pnpm-lock.yaml | 59 ++++++ .../components/team/kanban/KanbanBoard.tsx | 46 ++--- .../components/team/kanban/KanbanColumn.tsx | 27 ++- .../team/kanban/KanbanGridLayout.tsx | 185 ++++++++++++++++++ src/renderer/hooks/useMentionDetection.ts | 17 +- src/renderer/hooks/usePersistedGridLayout.ts | 107 ++++++++++ src/renderer/index.css | 112 +++++++++++ src/renderer/main.tsx | 2 + .../BrowserGridLayoutRepository.ts | 96 +++++++++ .../layout-system/GridLayoutRepository.ts | 7 + .../layout-system/gridLayoutSchema.ts | 160 +++++++++++++++ .../services/layout-system/gridLayoutTypes.ts | 17 ++ 14 files changed, 812 insertions(+), 31 deletions(-) create mode 100644 src/renderer/components/team/kanban/KanbanGridLayout.tsx create mode 100644 src/renderer/hooks/usePersistedGridLayout.ts create mode 100644 src/renderer/services/layout-system/BrowserGridLayoutRepository.ts create mode 100644 src/renderer/services/layout-system/GridLayoutRepository.ts create mode 100644 src/renderer/services/layout-system/gridLayoutSchema.ts create mode 100644 src/renderer/services/layout-system/gridLayoutTypes.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7be051f9..99f14a97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,3 +187,9 @@ Note: renderer utils/hooks/types do NOT have barrel exports — import directly 1. External packages 2. Path aliases (@main, @renderer, @shared) 3. Relative imports + +### Storage And Persistence +- New persistence flows should depend on small repository/storage abstractions, not directly on `localStorage`, `IndexedDB`, Electron APIs, or JSON files from UI components/hooks. +- Keep persistence concerns split by responsibility: schema/normalization, repository interface, concrete storage implementation, and UI adapter logic should live in separate modules. +- Prefer designs where the high-level feature code can swap local browser/Electron storage for a server-backed implementation without rewriting the rendering layer. +- Reuse generic persistence/layout infrastructure when adding new draggable/resizable surfaces instead of copying feature-specific storage code. diff --git a/package.json b/package.json index d29ac95a..d1601c0e 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,9 @@ "node-pty": "^1.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-grid-layout": "^2.2.2", "react-markdown": "^10.1.0", + "react-resizable": "^3.1.3", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bd4c861..3dd843b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,9 +212,15 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-grid-layout: + specifier: ^2.2.2 + version: 2.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.27)(react@18.3.1) + react-resizable: + specifier: ^3.1.3 + version: 3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -3770,6 +3776,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@4.0.3: + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -5560,6 +5569,18 @@ packages: peerDependencies: react: ^18.3.1 + react-draggable@4.5.0: + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-grid-layout@2.2.2: + resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5593,6 +5614,12 @@ packages: '@types/react': optional: true + react-resizable@3.1.3: + resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==} + peerDependencies: + react: '>= 16.3' + react-dom: '>= 16.3' + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -5690,6 +5717,9 @@ packages: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -10785,6 +10815,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@4.0.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -12938,6 +12970,24 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-draggable@4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react-grid-layout@2.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + fast-equals: 4.0.3 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-draggable: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-resizable: 3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + resize-observer-polyfill: 1.5.1 + react-is@16.13.1: {} react-markdown@10.1.0(@types/react@18.3.27)(react@18.3.1): @@ -12979,6 +13029,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 + react-resizable@3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-draggable: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -13125,6 +13182,8 @@ snapshots: dependencies: pe-library: 0.4.1 + resize-observer-polyfill@1.5.1: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index f6b747ef..811d1f20 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -8,7 +8,6 @@ import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useResizableColumns } from '@renderer/hooks/useResizableColumns'; import { cn } from '@renderer/lib/utils'; - import { CheckCircle2, ClipboardList, @@ -23,6 +22,7 @@ import { import { KanbanColumn } from './KanbanColumn'; import { KanbanFilterPopover } from './KanbanFilterPopover'; +import { KanbanGridLayout } from './KanbanGridLayout'; import { KanbanSortPopover } from './KanbanSortPopover'; import { KanbanTaskCard } from './KanbanTaskCard'; @@ -303,6 +303,8 @@ export const KanbanBoard = ({ onOpenTrash, }: KanbanBoardProps): React.JSX.Element => { const [viewMode, setViewMode] = useState('grid'); + const enableTaskSorting = + viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]); const grouped = useMemo(() => { @@ -390,7 +392,7 @@ export const KanbanBoard = ({ ) ); } - if (onColumnOrderChange && sort.field === 'manual') { + if (enableTaskSorting) { const itemIds = columnTasks.map((t) => t.id); return ( <> @@ -541,33 +543,31 @@ export const KanbanBoard = ({ {viewMode === 'grid' ? ( -
- {visibleColumns.map((column) => { + column.id)} + columns={visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; - return ( - - {renderCards(column.id, columnTasks)} - - ); + + return { + id: column.id, + title: column.title, + count: columnTasks.length, + icon: accent.icon, + headerBg: accent.headerBg, + bodyBg: accent.bodyBg, + content: renderCards(column.id, columnTasks), + }; })} -
+ /> ) : (
{visibleColumns.map((column, index) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; const width = columnWidths.get(column.id) ?? 256; + const handleProps = getHandleProps(column.id); return (
@@ -584,7 +584,9 @@ export const KanbanBoard = ({ {index < visibleColumns.length - 1 ? (
@@ -597,7 +599,7 @@ export const KanbanBoard = ({ ); - if (onColumnOrderChange && sort.field === 'manual') { + if (enableTaskSorting) { return ( {boardContent} diff --git a/src/renderer/components/team/kanban/KanbanColumn.tsx b/src/renderer/components/team/kanban/KanbanColumn.tsx index 99627d58..a45f1e88 100644 --- a/src/renderer/components/team/kanban/KanbanColumn.tsx +++ b/src/renderer/components/team/kanban/KanbanColumn.tsx @@ -7,6 +7,10 @@ interface KanbanColumnProps { icon?: React.ReactNode; headerBg?: string; bodyBg?: string; + className?: string; + headerClassName?: string; + bodyClassName?: string; + headerAccessory?: React.ReactNode; children: React.ReactNode; } @@ -16,29 +20,42 @@ export const KanbanColumn = ({ icon, headerBg, bodyBg, + className, + headerClassName, + bodyClassName, + headerAccessory, children, }: KanbanColumnProps): React.JSX.Element => { return (

{icon} {title}

- - {count} - +
+ {headerAccessory} + + {count} + +
-
{children}
+
+ {children} +
); }; diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx new file mode 100644 index 00000000..b8e54ca8 --- /dev/null +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -0,0 +1,185 @@ +/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; + +import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; +import { browserGridLayoutRepository } from '@renderer/services/layout-system/BrowserGridLayoutRepository'; +import { GripVertical } from 'lucide-react'; + +import { KanbanColumn } from './KanbanColumn'; + +import type { PersistedGridLayoutItem } from '@renderer/services/layout-system/gridLayoutTypes'; +import type { KanbanColumnId } from '@shared/types'; +import type { ReactElement, Ref } from 'react'; +import type { Layout, LayoutItem, ResizeHandleAxis } from 'react-grid-layout/legacy'; + +const GRID_COLS = 12; +const GRID_ROW_HEIGHT = 18; +const GRID_MARGIN: [number, number] = [12, 12]; +const DEFAULT_ITEM_WIDTH = 4; +const DEFAULT_ITEM_HEIGHT_PX = 400; +const DEFAULT_ITEM_HEIGHT = Math.max( + 1, + Math.round((DEFAULT_ITEM_HEIGHT_PX + GRID_MARGIN[1]) / (GRID_ROW_HEIGHT + GRID_MARGIN[1])) +); +const DEFAULT_MIN_HEIGHT = 10; +const DEFAULT_MIN_WIDTH = 3; +const GRID_SCOPE_PREFIX = 'kanban-grid-layout:v2'; +const RESIZE_HANDLES: ResizeHandleAxis[] = ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne']; +const WidthAwareGridLayout = WidthProvider(ReactGridLayout); + +export interface KanbanGridColumn { + id: KanbanColumnId; + title: string; + count: number; + icon?: React.ReactNode; + headerBg?: string; + bodyBg?: string; + content: React.ReactNode; +} + +interface KanbanGridLayoutProps { + teamName: string; + columns: KanbanGridColumn[]; + allColumnIds: KanbanColumnId[]; +} + +function buildDefaultItems(itemIds: string[]): PersistedGridLayoutItem[] { + return itemIds.map((id, index) => ({ + id, + x: (index % 3) * DEFAULT_ITEM_WIDTH, + y: Math.floor(index / 3) * DEFAULT_ITEM_HEIGHT, + w: DEFAULT_ITEM_WIDTH, + h: DEFAULT_ITEM_HEIGHT, + minW: DEFAULT_MIN_WIDTH, + minH: DEFAULT_MIN_HEIGHT, + })); +} + +function toReactGridLayoutItem(item: PersistedGridLayoutItem): LayoutItem { + return { + i: item.id, + x: item.x, + y: item.y, + w: item.w, + h: item.h, + minW: item.minW, + minH: item.minH, + maxW: item.maxW, + maxH: item.maxH, + }; +} + +function fromReactGridLayout(layout: Layout): PersistedGridLayoutItem[] { + return layout.map((item) => ({ + id: item.i, + x: item.x, + y: item.y, + w: item.w, + h: item.h, + minW: item.minW, + minH: item.minH, + maxW: item.maxW, + maxH: item.maxH, + })); +} + +function renderResizeHandle(axis: ResizeHandleAxis, ref: Ref): ReactElement { + return ( +