feat: right panel overlay
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user