feat: right panel overlay

This commit is contained in:
Carlos Florêncio
2026-01-15 18:27:05 +00:00
parent 7d0905b8cb
commit 84252719b4
10 changed files with 593 additions and 9 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { KanbanBoard } from '@/components/kanban-board';
import { KanbanWithPreview } from '@/components/kanban-with-preview';
import { OnboardingDialog } from '@/components/onboarding-dialog';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { STORAGE_KEYS } from '@/lib/settings/constants';
@@ -24,7 +24,7 @@ export function PageClient() {
return (
<>
<OnboardingDialog open={showOnboarding} onComplete={handleOnboardingComplete} />
<KanbanBoard key={boardKey} />
<KanbanWithPreview key={boardKey} />
</>
);
}
@@ -15,6 +15,7 @@ import { KanbanCardPreview, Task } from './kanban-card';
export type KanbanBoardGridProps = {
columns: Column[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
@@ -27,6 +28,7 @@ export type KanbanBoardGridProps = {
export function KanbanBoardGrid({
columns,
tasks,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
@@ -72,6 +74,7 @@ export function KanbanBoardGrid({
key={column.id}
column={column}
tasks={getTasksForColumn(column.id)}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
+21 -2
View File
@@ -16,7 +16,12 @@ import { useTaskActions } from '@/hooks/use-task-actions';
import { KanbanBoardHeader } from './kanban-board-header';
import { KanbanBoardGrid } from './kanban-board-grid';
export function KanbanBoard() {
interface KanbanBoardProps {
onPreviewTask?: (task: Task) => void;
onOpenTask?: (task: Task) => void;
}
export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [isMovingTask, setIsMovingTask] = useState(false);
@@ -192,7 +197,20 @@ export function KanbanBoard() {
};
const handleOpenTask = (task: Task) => {
router.push(`/task/${task.id}`);
if (onOpenTask) {
onOpenTask(task);
} else {
router.push(`/task/${task.id}`);
}
};
const handlePreviewTask = (task: Task) => {
if (onPreviewTask) {
onPreviewTask(task);
} else {
// Fallback to opening full page if no preview handler
handleOpenTask(task);
}
};
const handleEditTask = (task: Task) => {
@@ -358,6 +376,7 @@ export function KanbanBoard() {
<KanbanBoardGrid
columns={activeColumns}
tasks={visibleTasks}
onPreviewTask={handlePreviewTask}
onOpenTask={handleOpenTask}
onEditTask={handleEditTask}
onDeleteTask={handleDeleteTask}
+20 -3
View File
@@ -2,7 +2,7 @@
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { IconAlertTriangle, IconCircleCheck, IconCircleX, IconDots, IconLoader2 } from '@tabler/icons-react';
import { IconAlertTriangle, IconCircleCheck, IconCircleX, IconDots, IconLoader2, IconArrowsMaximize } from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import {
DropdownMenu,
@@ -29,6 +29,7 @@ interface KanbanCardProps {
onClick?: (task: Task) => void;
onEdit?: (task: Task) => void;
onDelete?: (task: Task) => void;
onOpenFullPage?: (task: Task) => void;
}
function KanbanCardBody({
@@ -77,7 +78,7 @@ function KanbanCardLayout({ task, className }: KanbanCardProps & { className?: s
);
}
export function KanbanCard({ task, onClick, onEdit, onDelete }: KanbanCardProps) {
export function KanbanCard({ task, onClick, onEdit, onDelete, onOpenFullPage }: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
});
@@ -137,13 +138,29 @@ export function KanbanCard({ task, onClick, onEdit, onDelete }: KanbanCardProps)
actions={
<div className="flex items-center gap-2">
{statusIcon}
{onOpenFullPage && (
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer"
onClick={(event) => {
event.stopPropagation();
onOpenFullPage(task);
}}
onPointerDown={(event) => event.stopPropagation()}
aria-label="Open full page"
title="Open full page"
>
<IconArrowsMaximize className="h-4 w-4" />
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
aria-label="More options"
>
<IconDots className="h-4 w-4" />
</button>
+4 -2
View File
@@ -14,12 +14,13 @@ export interface Column {
interface KanbanColumnProps {
column: Column;
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
}
export function KanbanColumn({ column, tasks, onOpenTask, onEditTask, onDeleteTask }: KanbanColumnProps) {
export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditTask, onDeleteTask }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.id,
});
@@ -52,7 +53,8 @@ export function KanbanColumn({ column, tasks, onOpenTask, onEditTask, onDeleteTa
<KanbanCard
key={task.id}
task={task}
onClick={onOpenTask}
onClick={onPreviewTask}
onOpenFullPage={onOpenTask}
onEdit={onEditTask}
onDelete={onDeleteTask}
/>
+242
View File
@@ -0,0 +1,242 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { KanbanBoard } from "./kanban-board";
import { TaskPreviewPanel } from "./task-preview-panel";
import { useKanbanPreview } from "@/hooks/use-kanban-preview";
import { useAppStore } from "@/components/state-provider";
import { Task } from "./kanban-card";
import { PREVIEW_PANEL } from "@/lib/settings/constants";
export function KanbanWithPreview() {
const router = useRouter();
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number>(0);
const isResizingRef = useRef(false);
// Get tasks from the kanban store
const kanbanTasks = useAppStore((state) => state.kanban.tasks);
const {
selectedTaskId,
isOpen,
previewWidthPx,
open,
close,
updatePreviewWidth,
} = useKanbanPreview({
onClose: () => {
// Cleanup handled by close
},
});
// Compute selected task from kanbanTasks and selectedTaskId
const selectedTask = useMemo(() => {
if (!selectedTaskId || kanbanTasks.length === 0) return null;
const task = kanbanTasks.find((t) => t.id === selectedTaskId);
if (!task) return null;
return {
id: task.id,
title: task.title,
columnId: task.columnId,
state: task.state,
description: task.description,
position: task.position,
repositoryUrl: task.repositoryUrl,
};
}, [selectedTaskId, kanbanTasks]);
// Measure container width on mount and resize
useEffect(() => {
if (!containerRef.current) return;
const updateWidth = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);
}
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
// Calculate if we should be in floating mode
// Float when preview would make kanban less than minimum percentage
const minKanbanWidthPx = (PREVIEW_PANEL.MIN_KANBAN_WIDTH_PERCENT / 100) * containerWidth;
const availableForKanban = containerWidth - previewWidthPx;
const shouldFloat = isOpen && containerWidth > 0 && availableForKanban < minKanbanWidthPx;
// Close panel if selected task no longer exists
useEffect(() => {
if (isOpen && selectedTaskId && !selectedTask) {
close();
}
}, [isOpen, selectedTaskId, selectedTask, close]);
const handleNavigateToTask = useCallback(
(task: Task) => {
close();
router.push(`/task/${task.id}`);
},
[close, router]
);
// Preview handler - task data is computed from selectedTaskId
// Toggle behavior: clicking the same task closes the panel
const handlePreviewTaskWithData = useCallback(
(task: Task) => {
if (isOpen && selectedTaskId === task.id) {
close();
} else {
open(task.id);
}
},
[isOpen, selectedTaskId, open, close]
);
// Handle Escape key to close preview
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
close();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, close]);
// Unified resize handler for both inline and floating modes
const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isResizingRef.current = true;
const startX = e.clientX;
const startWidth = previewWidthPx;
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizingRef.current) return;
// Calculate new width (moving left increases width)
const deltaX = startX - moveEvent.clientX;
const newWidth = startWidth + deltaX;
// No maximum limit - can resize all the way to the left
// updatePreviewWidth enforces minimum width
updatePreviewWidth(newWidth);
};
const handleMouseUp = () => {
isResizingRef.current = false;
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}, [previewWidthPx, updatePreviewWidth]);
// Calculate kanban width - keep it at min width when floating to avoid repainting
const kanbanWidth = shouldFloat
? minKanbanWidthPx
: (isOpen ? containerWidth - previewWidthPx : containerWidth);
return (
<div ref={containerRef} className="h-screen w-full flex flex-col bg-background relative">
{shouldFloat ? (
// Floating mode: kanban at min width, preview overlays
<>
<div
className="flex-1 overflow-hidden"
style={{
width: `${kanbanWidth}px`,
}}
>
<KanbanBoard
onPreviewTask={handlePreviewTaskWithData}
onOpenTask={handleNavigateToTask}
/>
</div>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 z-30"
onClick={close}
aria-label="Close preview"
/>
{/* Floating Panel */}
<div
className="fixed right-0 top-0 bottom-0 z-40 shadow-2xl bg-background flex"
style={{
width: `${previewWidthPx}px`,
maxWidth: `${PREVIEW_PANEL.MAX_WIDTH_VW}vw`,
}}
>
{/* Resize handle on the left edge */}
<div
className="w-1 bg-border hover:bg-primary cursor-col-resize flex-shrink-0 relative group"
onMouseDown={handleResizeMouseDown}
>
<div className="absolute inset-y-0 -left-2 -right-2" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-8 bg-border group-hover:bg-primary rounded-full transition-colors" />
</div>
{/* Panel content */}
<div className="flex-1 min-w-0 overflow-hidden">
<TaskPreviewPanel task={selectedTask} onClose={close} onMaximize={handleNavigateToTask} />
</div>
</div>
</>
) : (
// Inline mode: side by side with custom resize
<div className="flex-1 flex overflow-hidden">
{/* Kanban Board */}
<div
className="overflow-hidden"
style={{
width: `${kanbanWidth}px`,
}}
>
<KanbanBoard
onPreviewTask={handlePreviewTaskWithData}
onOpenTask={handleNavigateToTask}
/>
</div>
{/* Preview Panel */}
{isOpen && (
<div
className="flex-shrink-0 border-l bg-background flex"
style={{
width: `${previewWidthPx}px`,
}}
>
{/* Resize handle */}
<div
className="w-1 bg-border hover:bg-primary cursor-col-resize flex-shrink-0 relative group"
onMouseDown={handleResizeMouseDown}
>
<div className="absolute inset-y-0 -left-2 -right-2" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-8 bg-border group-hover:bg-primary rounded-full transition-colors" />
</div>
{/* Panel content */}
<div className="flex-1 min-w-0 overflow-hidden">
<TaskPreviewPanel task={selectedTask} onClose={close} onMaximize={handleNavigateToTask} />
</div>
</div>
)}
</div>
)}
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { IconX, IconCircleCheck, IconCircleX, IconLoader2, IconAlertTriangle, IconArrowsMaximize } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { getRepositoryDisplayName } from "@/lib/utils";
import type { Task } from "./kanban-card";
interface TaskPreviewPanelProps {
task: Task | null;
onClose: () => void;
onMaximize?: (task: Task) => void;
}
function getStateDisplay(state?: string) {
switch (state) {
case "IN_PROGRESS":
case "SCHEDULING":
return {
label: state === "SCHEDULING" ? "Scheduling" : "In Progress",
icon: <IconLoader2 className="h-3 w-3 animate-spin" />,
variant: "default" as const,
};
case "COMPLETED":
return {
label: "Completed",
icon: <IconCircleCheck className="h-3 w-3" />,
variant: "default" as const,
className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
};
case "FAILED":
case "CANCELLED":
return {
label: state === "FAILED" ? "Failed" : "Cancelled",
icon: <IconCircleX className="h-3 w-3" />,
variant: "destructive" as const,
};
case "BLOCKED":
case "WAITING_FOR_INPUT":
return {
label: state === "BLOCKED" ? "Blocked" : "Waiting for Input",
icon: <IconAlertTriangle className="h-3 w-3" />,
variant: "default" as const,
className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
};
case "TODO":
return {
label: "To Do",
icon: null,
variant: "secondary" as const,
};
case "REVIEW":
return {
label: "Review",
icon: null,
variant: "default" as const,
};
case "CREATED":
default:
return {
label: "Created",
icon: null,
variant: "secondary" as const,
};
}
}
export function TaskPreviewPanel({ task, onClose, onMaximize }: TaskPreviewPanelProps) {
if (!task) {
return (
<div className="flex h-full w-full flex-col border-l bg-background">
<div className="flex items-center justify-between border-b px-4 py-3">
<h2 className="text-sm font-semibold">Task Preview</h2>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={onClose}>
<IconX className="h-4 w-4" />
<span className="sr-only">Close preview</span>
</Button>
</div>
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-sm text-muted-foreground">Select a task to preview</p>
</div>
</div>
);
}
const repoName = getRepositoryDisplayName(task.repositoryUrl);
const stateDisplay = getStateDisplay(task.state);
return (
<div className="flex h-full w-full flex-col border-l bg-background">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<h2 className="text-sm font-semibold">Task Preview</h2>
<div className="flex items-center gap-1">
{onMaximize && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => onMaximize(task)}
title="Open full page"
>
<IconArrowsMaximize className="h-4 w-4" />
<span className="sr-only">Open full page</span>
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={onClose}>
<IconX className="h-4 w-4" />
<span className="sr-only">Close preview</span>
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{/* Repository */}
{repoName && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Repository</p>
<p className="text-sm">{repoName}</p>
</div>
)}
{/* Title */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Title</p>
<h3 className="text-base font-semibold">{task.title}</h3>
</div>
{/* State */}
{task.state && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Status</p>
<Badge variant={stateDisplay.variant} className={stateDisplay.className}>
{stateDisplay.icon && <span className="mr-1">{stateDisplay.icon}</span>}
{stateDisplay.label}
</Badge>
</div>
)}
{/* Description */}
{task.description && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Description</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{task.description}
</p>
</div>
)}
</div>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { getKanbanPreviewState, setKanbanPreviewState } from "@/lib/local-storage";
import { PREVIEW_PANEL } from "@/lib/settings/constants";
interface UseKanbanPreviewOptions {
onClose?: () => void;
}
export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
// Always start with default values to avoid hydration mismatch
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [previewWidthPx, setPreviewWidthPx] = useState(PREVIEW_PANEL.DEFAULT_WIDTH_PX);
const containerRef = useRef<HTMLDivElement | null>(null);
// Load persisted state from localStorage AFTER hydration
// This is necessary to avoid hydration mismatch between server and client
useEffect(() => {
const savedState = getKanbanPreviewState({
isOpen: false,
previewWidthPx: PREVIEW_PANEL.DEFAULT_WIDTH_PX,
selectedTaskId: null,
});
if (savedState.isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOpen(true);
}
if (savedState.previewWidthPx) {
setPreviewWidthPx(Math.max(PREVIEW_PANEL.MIN_WIDTH_PX, savedState.previewWidthPx));
}
if (savedState.selectedTaskId) {
setSelectedTaskId(savedState.selectedTaskId);
}
}, []);
// Persist state to localStorage
useEffect(() => {
setKanbanPreviewState({ isOpen });
}, [isOpen]);
useEffect(() => {
if (isOpen && previewWidthPx > 0) {
setKanbanPreviewState({ previewWidthPx });
}
}, [isOpen, previewWidthPx]);
useEffect(() => {
setKanbanPreviewState({ selectedTaskId });
}, [selectedTaskId]);
const open = useCallback((taskId: string) => {
setSelectedTaskId(taskId);
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
setSelectedTaskId(null);
options.onClose?.();
}, [options]);
const toggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
const updatePreviewWidth = useCallback((width: number) => {
setPreviewWidthPx(Math.max(PREVIEW_PANEL.MIN_WIDTH_PX, width));
}, []);
return {
selectedTaskId,
isOpen,
previewWidthPx,
open,
close,
toggle,
setSelectedTaskId,
updatePreviewWidth,
containerRef,
};
}
+47
View File
@@ -28,3 +28,50 @@ export function removeLocalStorage(key: string): void {
// Ignore removal failures.
}
}
// Internal storage keys for kanban preview (not exported - encapsulated)
const KANBAN_PREVIEW_KEYS = {
OPEN: 'kandev.kanban.preview.open',
WIDTH: 'kandev.kanban.preview.width',
SELECTED_TASK: 'kandev.kanban.preview.selectedTask',
} as const;
// Kanban preview state type
export interface KanbanPreviewState {
isOpen: boolean;
previewWidthPx: number;
selectedTaskId: string | null;
}
/**
* Get the kanban preview state from localStorage
* @param defaults - Default values to use if not found in localStorage
* @returns The kanban preview state
*/
export function getKanbanPreviewState(defaults: KanbanPreviewState): KanbanPreviewState {
return {
isOpen: getLocalStorage(KANBAN_PREVIEW_KEYS.OPEN, defaults.isOpen),
previewWidthPx: getLocalStorage(KANBAN_PREVIEW_KEYS.WIDTH, defaults.previewWidthPx),
selectedTaskId: getLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK, defaults.selectedTaskId),
};
}
/**
* Set the kanban preview state in localStorage
* @param state - Partial state to update (only provided fields are updated)
*/
export function setKanbanPreviewState(state: Partial<KanbanPreviewState>): void {
if (state.isOpen !== undefined) {
setLocalStorage(KANBAN_PREVIEW_KEYS.OPEN, state.isOpen);
}
if (state.previewWidthPx !== undefined) {
setLocalStorage(KANBAN_PREVIEW_KEYS.WIDTH, state.previewWidthPx);
}
if (state.selectedTaskId !== undefined) {
if (state.selectedTaskId === null) {
removeLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK);
} else {
setLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK, state.selectedTaskId);
}
}
}
+15
View File
@@ -4,3 +4,18 @@ export const STORAGE_KEYS = {
} as const;
export const DEFAULT_BACKEND_URL = 'http://localhost:8080';
// Kanban Preview Panel Settings
export const PREVIEW_PANEL = {
// Minimum width of the preview panel in pixels (prevents panel from being too narrow)
MIN_WIDTH_PX: 300,
// Default width of the preview panel in pixels when first opened
DEFAULT_WIDTH_PX: 500,
// Maximum width of the preview panel in viewport width percentage (prevents covering entire screen)
MAX_WIDTH_VW: 95,
// Minimum width of the kanban board as a percentage of viewport before the panel switches to floating mode
MIN_KANBAN_WIDTH_PERCENT: 50,
} as const;