Files
kandev/apps/web/components/kanban-with-preview.tsx
T
2026-02-16 13:48:58 +00:00

269 lines
8.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef } 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 { useKanbanLayout } from "@/hooks/use-kanban-layout";
import { useTaskSession } from "@/hooks/use-task-session";
import { useAppStore } from "@/components/state-provider";
import { Task } from "./kanban-card";
import type { KanbanState } from "@/lib/state/slices";
import { PREVIEW_PANEL } from "@/lib/settings/constants";
import { linkToSession } from "@/lib/links";
type KanbanWithPreviewProps = {
initialTaskId?: string;
initialSessionId?: string;
};
export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) {
const router = useRouter();
// Get tasks from the kanban store
const kanbanTasks = useAppStore((state) => state.kanban.tasks);
const {
selectedTaskId,
isOpen,
previewWidthPx,
open,
close,
updatePreviewWidth,
} = useKanbanPreview({
initialTaskId,
onClose: () => {
// Cleanup handled by close
},
});
// Use custom hooks for layout and session management
const { containerRef, shouldFloat, kanbanWidth } = useKanbanLayout(isOpen, previewWidthPx);
const { sessionId: selectedTaskSessionId } = useTaskSession(selectedTaskId ?? null);
// Track resize state
const isResizingRef = useRef(false);
// Compute selected task from kanbanTasks and selectedTaskId
const selectedTask = useMemo(() => {
if (!selectedTaskId || kanbanTasks.length === 0) return null;
const task = kanbanTasks.find((t: KanbanState['tasks'][number]) => t.id === selectedTaskId);
if (!task) return null;
return {
id: task.id,
title: task.title,
workflowStepId: task.workflowStepId,
state: task.state,
description: task.description,
position: task.position,
repositoryId: task.repositoryId,
};
}, [selectedTaskId, kanbanTasks]);
// Close panel if selected task no longer exists
useEffect(() => {
if (isOpen && selectedTaskId && !selectedTask) {
close();
}
}, [isOpen, selectedTaskId, selectedTask, close]);
const handleNavigateToTask = useCallback(
(task: Task, sessionId: string) => {
router.push(linkToSession(sessionId));
},
[router]
);
// Update URL query params when task selection changes
useEffect(() => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
if (selectedTaskId) {
url.searchParams.set('taskId', selectedTaskId);
} else {
url.searchParams.delete('taskId');
url.searchParams.delete('sessionId');
}
// Update sessionId param when it's available
if (selectedTaskId && selectedTaskSessionId) {
url.searchParams.set('sessionId', selectedTaskSessionId);
}
// Use replaceState to avoid adding to browser history on every click
window.history.replaceState({}, '', url.toString());
}, [selectedTaskId, selectedTaskSessionId]);
// Preview handler - opens/toggles the preview panel
// Toggle behavior: clicking the same task closes the panel
const handlePreviewTaskWithData = useCallback(
(task: Task) => {
// Toggle preview: if already open for this task, close it; otherwise open
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]);
const activeSessionId = selectedTaskId ? selectedTaskSessionId : null;
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}
sessionId={activeSessionId}
onClose={close}
onMaximize={
activeSessionId
? (task) => handleNavigateToTask(task, activeSessionId)
: undefined
}
/>
</div>
</div>
</>
) : (
// Inline mode: side by side with custom resize
<div className="flex-1 flex overflow-hidden">
{/* Kanban */}
<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}
sessionId={activeSessionId}
onClose={close}
onMaximize={
activeSessionId
? (task) => handleNavigateToTask(task, activeSessionId)
: undefined
}
/>
</div>
</div>
)}
</div>
)}
</div>
);
}