Add right sidebar to board detail page

This commit is contained in:
david 2026-03-20 18:08:39 +03:00
parent 3711b0888d
commit 9958443c14
11 changed files with 226 additions and 111 deletions

View file

@ -23,6 +23,7 @@
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2", "@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",
@ -1614,6 +1615,15 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true "dev": true
}, },
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -7018,6 +7028,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",

View file

@ -29,6 +29,7 @@
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2", "@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",

View file

@ -16,6 +16,7 @@ import { Boards } from './pages/Boards';
import { BoardCreate } from './pages/BoardCreate'; import { BoardCreate } from './pages/BoardCreate';
import { BoardEdit } from './pages/BoardEdit'; import { BoardEdit } from './pages/BoardEdit';
import { BoardDetail } from './pages/BoardDetail'; import { BoardDetail } from './pages/BoardDetail';
import { BoardEpics } from './pages/BoardEpics';
import { CardDetail } from './pages/CardDetail'; import { CardDetail } from './pages/CardDetail';
const App = () => { const App = () => {
@ -35,7 +36,7 @@ const App = () => {
<ModalProvider> <ModalProvider>
<div className="min-h-screen bg-gray-900 text-gray-100"> <div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar /> <Navbar />
<main className="flex-1 p-8 mx-auto w-full max-w-7xl"> <main>
<Routes> <Routes>
<Route <Route
path="/" path="/"
@ -84,6 +85,14 @@ const App = () => {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/boards/:id/epics"
element={
<ProtectedRoute>
<BoardEpics />
</ProtectedRoute>
}
/>
<Route <Route
path="/boards/:id/cards/:cardId" path="/boards/:id/cards/:cardId"
element={ element={

View file

@ -0,0 +1,40 @@
import { Link, useLocation } from 'react-router-dom';
interface BoardSidebarProps {
boardId: string;
}
export function BoardSidebar({ boardId }: BoardSidebarProps) {
const location = useLocation();
const menuItems = [
{ id: 'epics', label: 'Epics', icon: '📋', path: `/boards/${boardId}/epics` },
{ id: 'history', label: 'History', icon: '📜', path: `/boards/${boardId}/history` },
{ id: 'documents', label: 'Documents', icon: '📄', path: `/boards/${boardId}/documents` },
];
return (
<div className="w-64 bg-gray-800 rounded-lg p-4 border border-gray-700 h-fit sticky top-4">
<h3 className="text-white font-bold text-lg mb-4">Board Menu</h3>
<nav className="space-y-2">
{menuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<Link
key={item.id}
to={item.path}
className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}
>
<span className="text-lg">{item.icon}</span>
<span className="font-medium">{item.label}</span>
</Link>
);
})}
</nav>
</div>
);
}

View file

@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export const NarrowPageLayout = ({ children }: { children: ReactNode }) => {
return <div className="flex-1 p-8 mx-auto w-full max-w-7xl">{children}</div>;
};

View file

@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export const WidePageLayout = ({ children }: { children: ReactNode }) => {
return <div className="flex-1 p-8 mx-auto w-full max-w-7xl">{children}</div>;
};

View file

@ -5,6 +5,7 @@ import { useListMutations } from '../hooks/useListMutations';
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn'; import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
import { CreateListModal } from '../components/kanban/CreateListModal'; import { CreateListModal } from '../components/kanban/CreateListModal';
import { CardPreviewModal } from '../components/CardPreviewModal'; import { CardPreviewModal } from '../components/CardPreviewModal';
import { BoardSidebar } from '../components/BoardSidebar';
import { useModal } from '../context/modals/useModal'; import { useModal } from '../context/modals/useModal';
import { import {
DndContext, DndContext,
@ -20,6 +21,7 @@ import {
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { Card as CardType, ListWithCards } from '../types/kanban'; import { Card as CardType, ListWithCards } from '../types/kanban';
import { useState } from 'react'; import { useState } from 'react';
import { WidePageLayout } from '@/components/WidePageLayout';
export function BoardDetail() { export function BoardDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -65,12 +67,8 @@ export function BoardDetail() {
const handleDragOver = (event: DragOverEvent) => { const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event; const { active, over } = event;
// console.log('---handleDragOver', event);
if (!over) return; if (!over) return;
// const activeId = parseInt(active.id as string);
// const overId = parseInt(over.id as string);
const overIdStr = (over.id as string).split('_')[1]; const overIdStr = (over.id as string).split('_')[1];
const overId = parseInt(overIdStr, 10); const overId = parseInt(overIdStr, 10);
const activeIdStr = (active.id as string).split('_')[1]; const activeIdStr = (active.id as string).split('_')[1];
@ -105,8 +103,6 @@ export function BoardDetail() {
// Handle column reordering // Handle column reordering
if (activeType === 'COLUMN') { if (activeType === 'COLUMN') {
// todo find over column id,
let overListIndex = -1; let overListIndex = -1;
const activeList = board.lists.find((l) => l.id === activeId); const activeList = board.lists.find((l) => l.id === activeId);
if (overType === 'CARD') { if (overType === 'CARD') {
@ -118,12 +114,7 @@ export function BoardDetail() {
overListIndex = board.lists.findIndex((l) => l.id === overId); overListIndex = board.lists.findIndex((l) => l.id === overId);
} }
// console.log('-------active.id', active.id)
// console.log('-------overType.id', overType)
// console.log('-------overListIndex', overListIndex)
const activeListIndex = board.lists.findIndex((l) => l.id === activeId); const activeListIndex = board.lists.findIndex((l) => l.id === activeId);
// overListIndex = board.lists.findIndex((l) => l.id === overId);
if (activeListIndex === -1 || overListIndex === -1 || !activeList) return; if (activeListIndex === -1 || overListIndex === -1 || !activeList) return;
@ -133,15 +124,6 @@ export function BoardDetail() {
reorderedLists.splice(overListIndex, 0, movedList); reorderedLists.splice(overListIndex, 0, movedList);
await updateList(activeList.id, { name: activeList.name, pos: overListIndex }); await updateList(activeList.id, { name: activeList.name, pos: overListIndex });
// // Update all list positions
// for (let i = 0; i < reorderedLists.length; i++) {
// const list = reorderedLists[i];
// if (list.pos !== i) {
// await updateList(list.id, { name: list.name, pos: i });
// }
// }
return; return;
} }
@ -236,71 +218,81 @@ export function BoardDetail() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-start"> <WidePageLayout>
<div> <div className="flex justify-between items-start">
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm"> <div>
Back to Boards <Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm">
</Link> Back to Boards
<h1 className="text-3xl font-bold text-white mt-2">{board.name}</h1> </Link>
{board.description && <p className="text-gray-400 mt-1">{board.description}</p>} <h1 className="text-3xl font-bold text-white mt-2">{board.name}</h1>
{board.description && <p className="text-gray-400 mt-1">{board.description}</p>}
</div>
<div className="flex gap-2">
<Link
to={`/boards/${id}/edit`}
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Edit Board
</Link>
<button
onClick={handleAddList}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
+ Add List
</button>
</div>
</div> </div>
<div className="flex gap-2"> </WidePageLayout>
<Link
to={`/boards/${id}/edit`} <div className="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors" <div className="flex justify-between items-start gap-6">
> <div className="flex-1">
Edit Board <DndContext
</Link> sensors={sensors}
<button collisionDetection={closestCenter}
onClick={handleAddList} onDragStart={handleDragStart}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" onDragOver={handleDragOver}
> onDragEnd={handleDragEnd}
+ Add List >
</button> <SortableContext
items={board.lists.map((list) => `COLUMN_${list.id}`)}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-4 overflow-x-auto pb-4">
{board.lists.map((list) => (
<SortableKanbanColumn
key={list.id}
list={list}
cards={list.cards}
onOpenCardModal={handleOpenCardModal}
onCardCreate={handleAddCard(list.id)}
onListEdit={(name) => handleEditList(list.id, name)}
onListDelete={() => handleDeleteList(list.id)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeCard ? (
<div className="bg-gray-700 rounded-lg p-4 cursor-grabbing border border-gray-600 shadow-lg opacity-80">
<h3 className="text-white font-medium">{activeCard.name}</h3>
</div>
) : activeList ? (
<div className="bg-gray-800 rounded-lg p-4 cursor-grabbing border border-gray-700 shadow-lg opacity-80 min-w-[300px] max-w-[300px]">
<h2 className="text-white font-bold text-lg">{activeList.name}</h2>
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block mt-2">
{activeList.cards.length} cards
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
<BoardSidebar boardId={id || ''} />
</div> </div>
</div> </div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext
items={board.lists.map((list) => `COLUMN_${list.id}`)}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-4 overflow-x-auto pb-4">
{board.lists.map((list) => (
<SortableKanbanColumn
key={list.id}
list={list}
cards={list.cards}
onOpenCardModal={handleOpenCardModal}
onCardCreate={handleAddCard(list.id)}
onListEdit={(name) => handleEditList(list.id, name)}
onListDelete={() => handleDeleteList(list.id)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeCard ? (
<div className="bg-gray-700 rounded-lg p-4 cursor-grabbing border border-gray-600 shadow-lg opacity-80">
<h3 className="text-white font-medium">{activeCard.name}</h3>
</div>
) : activeList ? (
<div className="bg-gray-800 rounded-lg p-4 cursor-grabbing border border-gray-700 shadow-lg opacity-80 min-w-[300px] max-w-[300px]">
<h2 className="text-white font-bold text-lg">{activeList.name}</h2>
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block mt-2">
{activeList.cards.length} cards
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div> </div>
); );
} }

View file

@ -0,0 +1,35 @@
import { useParams, Link } from 'react-router-dom';
import { WidePageLayout } from '../components/WidePageLayout';
export function BoardEpics() {
const { id } = useParams<{ id: string }>();
return (
<div className="space-y-6">
<WidePageLayout>
<div>
<Link
to={`/boards/${id}`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Board
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Epics</h1>
<p className="text-gray-400 mt-1">Manage and view epics for this board</p>
</div>
</WidePageLayout>
<WidePageLayout>
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
<div className="text-center py-12">
<div className="text-6xl mb-4">📋</div>
<h2 className="text-xl font-bold text-white mb-2">Epics Coming Soon</h2>
<p className="text-gray-400">
This page will allow you to create and manage epics for your board.
</p>
</div>
</div>
</WidePageLayout>
</div>
);
}

View file

@ -1,42 +1,45 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useBoards } from '../hooks/useBoards'; import { useBoards } from '../hooks/useBoards';
import { BoardCard } from '../components/kanban/BoardCard'; import { BoardCard } from '../components/kanban/BoardCard';
import { NarrowPageLayout } from '@/components/NarrowPageLayout';
export function Boards() { export function Boards() {
const { boards, deleteBoard } = useBoards(); const { boards, deleteBoard } = useBoards();
return ( return (
<div className="space-y-6"> <NarrowPageLayout>
<div className="flex justify-between items-center"> <div className="space-y-6">
<div> <div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-white mb-2">My Boards</h1> <div>
<p className="text-gray-400">Manage your Kanban boards</p> <h1 className="text-3xl font-bold text-white mb-2">My Boards</h1>
</div> <p className="text-gray-400">Manage your Kanban boards</p>
<Link </div>
to="/boards/new"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
+ Create Board
</Link>
</div>
{boards.length === 0 ? (
<div className="text-center py-16">
<p className="text-gray-400 text-lg mb-4">No boards yet</p>
<Link <Link
to="/boards/new" to="/boards/new"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors inline-block" className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
> >
Create your first board + Create Board
</Link> </Link>
</div> </div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {boards.length === 0 ? (
{boards.map((board) => ( <div className="text-center py-16">
<BoardCard key={board.id} board={board} onDelete={deleteBoard} /> <p className="text-gray-400 text-lg mb-4">No boards yet</p>
))} <Link
</div> to="/boards/new"
)} className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors inline-block"
</div> >
Create your first board
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{boards.map((board) => (
<BoardCard key={board.id} board={board} onDelete={deleteBoard} />
))}
</div>
)}
</div>
</NarrowPageLayout>
); );
} }

View file

@ -4,7 +4,10 @@
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true,
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View file

@ -1,9 +1,15 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {