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.
This commit is contained in:
iliya 2026-03-11 17:18:24 +02:00
parent 6bcb81d337
commit 2317c948ff
14 changed files with 812 additions and 31 deletions

View file

@ -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.

View file

@ -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",

View file

@ -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: {}

View file

@ -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<KanbanViewMode>('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 = ({
</div>
{viewMode === 'grid' ? (
<div
className="grid gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}
>
{visibleColumns.map((column) => {
<KanbanGridLayout
teamName={teamName}
allColumnIds={COLUMNS.map((column) => column.id)}
columns={visibleColumns.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return (
<KanbanColumn
key={column.id}
title={column.title}
count={columnTasks.length}
icon={accent.icon}
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
>
{renderCards(column.id, columnTasks)}
</KanbanColumn>
);
return {
id: column.id,
title: column.title,
count: columnTasks.length,
icon: accent.icon,
headerBg: accent.headerBg,
bodyBg: accent.bodyBg,
content: renderCards(column.id, columnTasks),
};
})}
</div>
/>
) : (
<div className="flex overflow-x-auto pb-2">
{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 (
<div key={column.id} className="flex shrink-0">
<div style={{ width }}>
@ -584,7 +584,9 @@ export const KanbanBoard = ({
{index < visibleColumns.length - 1 ? (
<div
className="group relative mx-0.5 flex items-center"
{...getHandleProps(column.id)}
onPointerDown={handleProps.onPointerDown}
style={handleProps.style}
aria-label={handleProps['aria-label']}
>
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
</div>
@ -597,7 +599,7 @@ export const KanbanBoard = ({
</>
);
if (onColumnOrderChange && sort.field === 'manual') {
if (enableTaskSorting) {
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{boardContent}

View file

@ -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 (
<section
className={cn(
'rounded-md border border-[var(--color-border)]',
className,
!bodyBg && 'bg-[var(--color-surface)]'
)}
style={bodyBg ? { backgroundColor: bodyBg } : undefined}
>
<header
className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2"
className={cn(
'flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2',
headerClassName
)}
style={headerBg ? { backgroundColor: headerBg } : undefined}
>
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
{icon}
{title}
</h4>
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
{count}
</Badge>
<div className="flex items-center gap-2">
{headerAccessory}
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
{count}
</Badge>
</div>
</header>
<div className="flex max-h-[480px] flex-col overflow-auto p-2">{children}</div>
<div className={cn('flex max-h-[480px] flex-col overflow-auto p-2', bodyClassName)}>
{children}
</div>
</section>
);
};

View file

@ -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<HTMLElement>): ReactElement {
return (
<span
ref={ref}
className={`kanban-grid-resize-handle kanban-grid-resize-handle-${axis}`}
aria-hidden="true"
/>
);
}
export const KanbanGridLayout = ({
teamName,
columns,
allColumnIds,
}: KanbanGridLayoutProps): React.JSX.Element => {
const columnMap = useMemo(() => new Map(columns.map((column) => [column.id, column])), [columns]);
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
const { visibleItems, applyVisibleItems } = usePersistedGridLayout({
scopeKey: `${GRID_SCOPE_PREFIX}:${teamName}`,
allItemIds: allColumnIds,
visibleItemIds: visibleColumnIds,
cols: GRID_COLS,
repository: browserGridLayoutRepository,
buildDefaultItems,
});
const [renderLayout, setRenderLayout] = useState<Layout>(() =>
visibleItems.map(toReactGridLayoutItem)
);
useEffect(() => {
setRenderLayout(visibleItems.map(toReactGridLayoutItem));
}, [visibleItems]);
const applyReactGridLayout = useCallback(
(layout: Layout, options?: { persist?: boolean }) => {
setRenderLayout(layout);
if (options?.persist) {
applyVisibleItems(fromReactGridLayout(layout), options);
}
},
[applyVisibleItems]
);
return (
<div className="p-1.5">
<WidthAwareGridLayout
className="kanban-grid-layout"
layout={renderLayout}
cols={GRID_COLS}
rowHeight={GRID_ROW_HEIGHT}
margin={GRID_MARGIN}
containerPadding={[0, 0]}
isDraggable
isResizable
draggableHandle=".kanban-grid-drag-handle"
resizeHandles={RESIZE_HANDLES}
resizeHandle={renderResizeHandle}
onLayoutChange={(layout) => applyReactGridLayout(layout)}
onDragStop={(layout) => applyReactGridLayout(layout, { persist: true })}
onResizeStop={(layout) => applyReactGridLayout(layout, { persist: true })}
>
{visibleItems.map((layoutItem) => {
const column = columnMap.get(layoutItem.id as KanbanColumnId);
if (!column) {
return <div key={layoutItem.id} />;
}
return (
<div key={layoutItem.id} className="kanban-grid-item-wrapper min-h-0">
<KanbanColumn
title={column.title}
count={column.count}
icon={column.icon}
headerBg={column.headerBg}
bodyBg={column.bodyBg}
className="flex h-full min-h-0 flex-col"
headerClassName="shrink-0"
bodyClassName="kanban-grid-no-drag min-h-0 max-h-none flex-1"
headerAccessory={
<button
type="button"
className="kanban-grid-drag-handle inline-flex cursor-grab items-center justify-center rounded-sm p-1 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text)] active:cursor-grabbing"
aria-label={`Drag ${column.title} column`}
>
<GripVertical size={14} />
</button>
}
>
{column.content}
</KanbanColumn>
</div>
);
})}
</WidthAwareGridLayout>
</div>
);
};
/* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */

View file

@ -1,6 +1,9 @@
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions';
import {
getSuggestionInsertionText,
getSuggestionTriggerChar,
} from '@renderer/utils/mentionSuggestions';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -217,7 +220,13 @@ export function useMentionDetection({
const before = value.slice(0, triggerIndexRef.current);
const after = value.slice(triggerIndexRef.current + 1 + queryRef.current.length);
const insertion = `${triggerChar}${getSuggestionInsertionText(s)} `;
const suggestionText = getSuggestionInsertionText(s);
const expectedTriggerChar = getSuggestionTriggerChar(s);
const insertionBody =
triggerChar === expectedTriggerChar && suggestionText.startsWith(triggerChar)
? suggestionText
: `${triggerChar}${suggestionText}`;
const insertion = `${insertionBody} `;
const newValue = before + insertion + after;
const newCursorPos = before.length + insertion.length;
@ -226,8 +235,8 @@ export function useMentionDetection({
// Set cursor position after React re-render
requestAnimationFrame(() => {
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
textarea.focus();
textarea.setSelectionRange(newCursorPos, newCursorPos);
});
},
[value, onValueChange, textareaRef, dismiss]

View file

@ -0,0 +1,107 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
createPersistedGridLayoutState,
mergeGridLayoutItems,
normalizePersistedGridLayoutState,
projectVisibleGridLayoutItems,
} from '@renderer/services/layout-system/gridLayoutSchema';
import type { GridLayoutRepository } from '@renderer/services/layout-system/GridLayoutRepository';
import type {
PersistedGridLayoutItem,
PersistedGridLayoutState,
} from '@renderer/services/layout-system/gridLayoutTypes';
interface UsePersistedGridLayoutOptions {
scopeKey: string;
allItemIds: string[];
visibleItemIds: string[];
cols: number;
repository: GridLayoutRepository<PersistedGridLayoutState>;
buildDefaultItems: (itemIds: string[]) => PersistedGridLayoutItem[];
}
interface UsePersistedGridLayoutResult {
allItems: PersistedGridLayoutItem[];
visibleItems: PersistedGridLayoutItem[];
isLoaded: boolean;
applyVisibleItems: (items: PersistedGridLayoutItem[], options?: { persist?: boolean }) => void;
}
export function usePersistedGridLayout({
scopeKey,
allItemIds,
visibleItemIds,
cols,
repository,
buildDefaultItems,
}: UsePersistedGridLayoutOptions): UsePersistedGridLayoutResult {
const defaultItems = useMemo(
() => buildDefaultItems(allItemIds),
[allItemIds, buildDefaultItems]
);
const [layoutState, setLayoutState] = useState<PersistedGridLayoutState>(() =>
normalizePersistedGridLayoutState(null, defaultItems)
);
const [loadedScopeKey, setLoadedScopeKey] = useState<string | null>(null);
const resolvedLayoutState = useMemo(
() => normalizePersistedGridLayoutState(layoutState, defaultItems),
[defaultItems, layoutState]
);
useEffect(() => {
let cancelled = false;
void repository
.load(scopeKey)
.then((stored) => {
if (cancelled) return;
setLayoutState(normalizePersistedGridLayoutState(stored, defaultItems));
setLoadedScopeKey(scopeKey);
})
.catch(() => {
if (cancelled) return;
setLayoutState(normalizePersistedGridLayoutState(null, defaultItems));
setLoadedScopeKey(scopeKey);
});
return () => {
cancelled = true;
};
}, [defaultItems, repository, scopeKey]);
const visibleItems = useMemo(
() => projectVisibleGridLayoutItems(resolvedLayoutState.items, visibleItemIds, cols),
[cols, resolvedLayoutState.items, visibleItemIds]
);
const applyVisibleItems = useCallback(
(items: PersistedGridLayoutItem[], options?: { persist?: boolean }) => {
setLayoutState((current) => {
const mergedItems = mergeGridLayoutItems(
normalizePersistedGridLayoutState(current, defaultItems).items,
items
);
const nextState = normalizePersistedGridLayoutState(
createPersistedGridLayoutState(mergedItems),
defaultItems
);
if (options?.persist) {
void repository.save(scopeKey, nextState);
}
return nextState;
});
},
[defaultItems, repository, scopeKey]
);
return {
allItems: resolvedLayoutState.items,
visibleItems,
isLoaded: loadedScopeKey === scopeKey,
applyVisibleItems,
};
}

View file

@ -260,6 +260,118 @@
--compact-phase-text: #818cf8;
}
.kanban-grid-layout {
min-height: 640px;
overflow: visible;
}
.kanban-grid-layout .react-grid-item {
transition-property: transform, width, height;
overflow: visible;
}
.kanban-grid-item-wrapper {
height: 100%;
}
.kanban-grid-resize-handle {
position: absolute;
z-index: 20;
}
.kanban-grid-resize-handle::after {
content: '';
position: absolute;
inset: 0;
border-radius: 9999px;
background: rgba(129, 140, 248, 0.55);
opacity: 0;
}
.kanban-grid-layout .react-grid-item:hover .kanban-grid-resize-handle::after,
.kanban-grid-layout
.react-grid-item.react-grid-placeholder
+ .react-grid-item
.kanban-grid-resize-handle::after {
animation: kanban-grid-handle-fade-in 120ms ease forwards;
animation-delay: 1s;
}
.kanban-grid-layout .react-grid-item.resizing .kanban-grid-resize-handle::after {
animation: none;
opacity: 1;
}
@keyframes kanban-grid-handle-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.kanban-grid-resize-handle-n,
.kanban-grid-resize-handle-s {
left: 50%;
width: 36px;
height: 6px;
transform: translateX(-50%);
}
.kanban-grid-resize-handle-n {
top: -3px;
}
.kanban-grid-resize-handle-s {
bottom: -3px;
}
.kanban-grid-resize-handle-e,
.kanban-grid-resize-handle-w {
top: 50%;
width: 6px;
height: 36px;
transform: translateY(-50%);
}
.kanban-grid-resize-handle-e {
right: -3px;
}
.kanban-grid-resize-handle-w {
left: -3px;
}
.kanban-grid-resize-handle-ne,
.kanban-grid-resize-handle-nw,
.kanban-grid-resize-handle-se,
.kanban-grid-resize-handle-sw {
width: 9px;
height: 9px;
}
.kanban-grid-resize-handle-ne {
top: -4px;
right: -4px;
}
.kanban-grid-resize-handle-nw {
top: -4px;
left: -4px;
}
.kanban-grid-resize-handle-se {
right: -4px;
bottom: -4px;
}
.kanban-grid-resize-handle-sw {
left: -4px;
bottom: -4px;
}
/* File icon glow — halo so dark icons stay visible on dark backgrounds */
.file-icon-glow {
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));

View file

@ -1,4 +1,6 @@
import './index.css';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import React from 'react';
import ReactDOM from 'react-dom/client';

View file

@ -0,0 +1,96 @@
import { del, get, set } from 'idb-keyval';
import { sanitizePersistedGridLayoutState } from './gridLayoutSchema';
import type { GridLayoutRepository } from './GridLayoutRepository';
import type { PersistedGridLayoutState } from './gridLayoutTypes';
const STORAGE_KEY_PREFIX = 'grid-layout:';
function storageKey(scopeKey: string): string {
return `${STORAGE_KEY_PREFIX}${scopeKey}`;
}
function readLocalStorage(key: string): PersistedGridLayoutState | null {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
return sanitizePersistedGridLayoutState(JSON.parse(raw));
} catch {
return null;
}
}
function writeLocalStorage(key: string, state: PersistedGridLayoutState): void {
try {
localStorage.setItem(key, JSON.stringify(state));
} catch {
// Ignore quota/storage errors and fall back to memory.
}
}
function removeLocalStorage(key: string): void {
try {
localStorage.removeItem(key);
} catch {
// Ignore storage errors.
}
}
export class BrowserGridLayoutRepository implements GridLayoutRepository<PersistedGridLayoutState> {
private idbUnavailable = false;
private readonly fallbackStore = new Map<string, PersistedGridLayoutState>();
async load(scopeKey: string): Promise<PersistedGridLayoutState | null> {
const key = storageKey(scopeKey);
if (!this.idbUnavailable) {
try {
const stored = await get<unknown>(key);
const sanitized = sanitizePersistedGridLayoutState(stored);
if (sanitized) {
return sanitized;
}
} catch {
this.idbUnavailable = true;
}
}
return this.fallbackStore.get(key) ?? readLocalStorage(key);
}
async save(scopeKey: string, state: PersistedGridLayoutState): Promise<void> {
const key = storageKey(scopeKey);
const sanitized = sanitizePersistedGridLayoutState(state);
if (!sanitized) {
return;
}
this.fallbackStore.set(key, sanitized);
writeLocalStorage(key, sanitized);
if (!this.idbUnavailable) {
try {
await set(key, sanitized);
} catch {
this.idbUnavailable = true;
}
}
}
async clear(scopeKey: string): Promise<void> {
const key = storageKey(scopeKey);
this.fallbackStore.delete(key);
removeLocalStorage(key);
if (!this.idbUnavailable) {
try {
await del(key);
} catch {
this.idbUnavailable = true;
}
}
}
}
export const browserGridLayoutRepository = new BrowserGridLayoutRepository();

View file

@ -0,0 +1,7 @@
import type { PersistedGridLayoutState } from './gridLayoutTypes';
export interface GridLayoutRepository<TState = PersistedGridLayoutState> {
load(scopeKey: string): Promise<TState | null>;
save(scopeKey: string, state: TState): Promise<void>;
clear(scopeKey: string): Promise<void>;
}

View file

@ -0,0 +1,160 @@
import type { PersistedGridLayoutItem, PersistedGridLayoutState } from './gridLayoutTypes';
const GRID_LAYOUT_SCHEMA_VERSION = 1;
function toPositiveInt(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.max(1, Math.round(value))
: fallback;
}
function toNonNegativeInt(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? Math.round(value)
: fallback;
}
function sanitizeConstraint(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
return undefined;
}
return Math.round(value);
}
function sanitizeGridLayoutItem(
raw: unknown,
fallback?: PersistedGridLayoutItem
): PersistedGridLayoutItem | null {
if (typeof raw !== 'object' || raw === null) {
return fallback ?? null;
}
const candidate = raw as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id : fallback?.id;
if (!id) {
return null;
}
return {
id,
x: toNonNegativeInt(candidate.x, fallback?.x ?? 0),
y: toNonNegativeInt(candidate.y, fallback?.y ?? 0),
w: toPositiveInt(candidate.w, fallback?.w ?? 1),
h: toPositiveInt(candidate.h, fallback?.h ?? 1),
minW: sanitizeConstraint(candidate.minW ?? fallback?.minW),
minH: sanitizeConstraint(candidate.minH ?? fallback?.minH),
maxW: sanitizeConstraint(candidate.maxW ?? fallback?.maxW),
maxH: sanitizeConstraint(candidate.maxH ?? fallback?.maxH),
};
}
export function createPersistedGridLayoutState(
items: PersistedGridLayoutItem[]
): PersistedGridLayoutState {
return {
version: GRID_LAYOUT_SCHEMA_VERSION,
updatedAt: Date.now(),
items,
};
}
export function sanitizePersistedGridLayoutState(raw: unknown): PersistedGridLayoutState | null {
if (typeof raw !== 'object' || raw === null) {
return null;
}
const candidate = raw as Record<string, unknown>;
if (!Array.isArray(candidate.items)) {
return null;
}
const items = candidate.items
.map((item) => sanitizeGridLayoutItem(item))
.filter((item): item is PersistedGridLayoutItem => item !== null);
return {
version: GRID_LAYOUT_SCHEMA_VERSION,
updatedAt:
typeof candidate.updatedAt === 'number' && Number.isFinite(candidate.updatedAt)
? candidate.updatedAt
: Date.now(),
items,
};
}
export function normalizePersistedGridLayoutState(
rawState: unknown,
defaultItems: PersistedGridLayoutItem[]
): PersistedGridLayoutState {
const sanitized = sanitizePersistedGridLayoutState(rawState);
const persistedById = new Map(sanitized?.items.map((item) => [item.id, item]));
const items = defaultItems.map((defaultItem) => {
const persisted = persistedById.get(defaultItem.id);
return sanitizeGridLayoutItem(persisted ?? defaultItem, defaultItem) ?? defaultItem;
});
return {
version: GRID_LAYOUT_SCHEMA_VERSION,
updatedAt: sanitized?.updatedAt ?? Date.now(),
items,
};
}
export function mergeGridLayoutItems(
currentItems: PersistedGridLayoutItem[],
updatedItems: PersistedGridLayoutItem[]
): PersistedGridLayoutItem[] {
const updatedById = new Map(updatedItems.map((item) => [item.id, item]));
return currentItems.map((item) => {
const updated = updatedById.get(item.id);
return updated ? { ...item, ...updated } : item;
});
}
export function projectVisibleGridLayoutItems(
allItems: PersistedGridLayoutItem[],
visibleIds: string[],
cols: number
): PersistedGridLayoutItem[] {
const visibleIdSet = new Set(visibleIds);
const visibleItems = allItems
.filter((item) => visibleIdSet.has(item.id))
.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
if (visibleItems.length === 0) {
return [];
}
const fillWidth =
visibleItems.length <= 3 ? Math.max(1, Math.floor(cols / visibleItems.length)) : 0;
const projected: PersistedGridLayoutItem[] = [];
let currentX = 0;
let currentY = 0;
let rowHeight = 0;
for (const item of visibleItems) {
const nextWidth =
visibleItems.length === 1 ? cols : Math.min(cols, Math.max(item.w, fillWidth || item.w));
if (currentX + nextWidth > cols) {
currentX = 0;
currentY += rowHeight;
rowHeight = 0;
}
projected.push({
...item,
x: currentX,
y: currentY,
w: nextWidth,
});
currentX += nextWidth;
rowHeight = Math.max(rowHeight, item.h);
}
return projected;
}

View file

@ -0,0 +1,17 @@
export interface PersistedGridLayoutItem {
id: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
}
export interface PersistedGridLayoutState {
version: number;
updatedAt: number;
items: PersistedGridLayoutItem[];
}