From 1bcad71f23702015e3fe3489e2f1b3427d932aad Mon Sep 17 00:00:00 2001 From: david Date: Thu, 26 Feb 2026 17:12:19 +0300 Subject: [PATCH] add board detail and clean up login flow --- frontend/package-lock.json | 101 ++++++++- frontend/package.json | 8 +- frontend/src/App.tsx | 43 +++- frontend/src/components/Navbar.tsx | 20 +- frontend/src/components/ProtectedRoute.tsx | 24 ++ frontend/src/components/kanban/BoardCard.tsx | 99 ++++++++ frontend/src/components/kanban/KanbanCard.tsx | 46 ++++ .../src/components/kanban/KanbanColumn.tsx | 42 ++++ frontend/src/hooks/useApi.ts | 169 ++++++++++++++ frontend/src/hooks/useAuth.ts | 139 ++++++++++++ frontend/src/hooks/useBoard.ts | 49 ++++ frontend/src/hooks/useBoards.ts | 124 ++++++++++ frontend/src/hooks/useCardMutations.ts | 128 +++++++++++ frontend/src/hooks/useListMutations.ts | 84 +++++++ frontend/src/pages/BoardCreate.tsx | 98 ++++++++ frontend/src/pages/BoardDetail.tsx | 211 ++++++++++++++++++ frontend/src/pages/BoardEdit.tsx | 116 ++++++++++ frontend/src/pages/Boards.tsx | 42 ++++ frontend/src/pages/Login.tsx | 45 +--- frontend/src/pages/Register.tsx | 55 +++-- frontend/src/types/kanban.ts | 185 +++++++++++++++ frontend/src/types/user.ts | 6 +- 22 files changed, 1756 insertions(+), 78 deletions(-) create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/components/kanban/BoardCard.tsx create mode 100644 frontend/src/components/kanban/KanbanCard.tsx create mode 100644 frontend/src/components/kanban/KanbanColumn.tsx create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/hooks/useBoard.ts create mode 100644 frontend/src/hooks/useBoards.ts create mode 100644 frontend/src/hooks/useCardMutations.ts create mode 100644 frontend/src/hooks/useListMutations.ts create mode 100644 frontend/src/pages/BoardCreate.tsx create mode 100644 frontend/src/pages/BoardDetail.tsx create mode 100644 frontend/src/pages/BoardEdit.tsx create mode 100644 frontend/src/pages/Boards.tsx create mode 100644 frontend/src/types/kanban.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8675e67..0bd44d1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,16 @@ "name": "crafting-shop-frontend", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", "axios": "^1.6.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.20.1" + "react-hook-form": "^7.71.2", + "react-router-dom": "^6.20.1", + "zod": "^4.3.6" }, "devDependencies": { "@testing-library/jest-dom": "^6.1.5", @@ -469,6 +475,55 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -896,6 +951,17 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1386,6 +1452,11 @@ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -5785,6 +5856,21 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -6783,6 +6869,11 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7377,6 +7468,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 043c425..c7df5ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,10 +14,16 @@ "test:ui": "vitest --ui" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", "axios": "^1.6.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.20.1" + "react-hook-form": "^7.71.2", + "react-router-dom": "^6.20.1", + "zod": "^4.3.6" }, "devDependencies": { "@testing-library/jest-dom": "^6.1.5", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57f0f6c..8b5d845 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,11 @@ import { Products } from './pages/Products'; import Login from './pages/Login'; import { Register } from './pages/Register'; import { Orders } from './pages/Orders'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { Boards } from './pages/Boards'; +import { BoardCreate } from './pages/BoardCreate'; +import { BoardEdit } from './pages/BoardEdit'; +import { BoardDetail } from './pages/BoardDetail'; const App = () => { return ( @@ -23,9 +28,45 @@ const App = () => {
} /> - } /> } /> } /> + + {/* Protected Routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Legacy Routes */} + } /> } /> } /> diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index ed353d7..e4991f3 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -35,12 +35,20 @@ export function Navbar() { Cart {user && ( - - Orders - + <> + + Boards + + + Orders + + )} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..c0868f1 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,24 @@ +import { Navigate } from 'react-router-dom'; +import { useApp } from '../context/AppContext'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { token, loading } = useApp(); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (!token) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/kanban/BoardCard.tsx b/frontend/src/components/kanban/BoardCard.tsx new file mode 100644 index 0000000..7445d9a --- /dev/null +++ b/frontend/src/components/kanban/BoardCard.tsx @@ -0,0 +1,99 @@ +import { Board } from '../../types/kanban'; +import { Link, useNavigate } from 'react-router-dom'; + +interface BoardCardProps { + board: Board; + onDelete: (id: number) => void; +} + +export function BoardCard({ board, onDelete }: BoardCardProps) { + const navigate = useNavigate(); + + const handleDelete = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (window.confirm(`Are you sure you want to delete "${board.name}"?`)) { + onDelete(board.id); + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + }; + + return ( +
navigate(`/boards/${board.id}`)} className="block group cursor-pointer"> +
+
+

+ {board.name} +

+
+ e.stopPropagation()} + className="text-gray-400 hover:text-blue-400 transition-colors" + title="Edit board" + > + + + + + + +
+
+ + {board.description && ( +

{board.description}

+ )} + +
+ + Last activity: {formatDate(board.date_last_activity)} + +
+
+
+ ); +} diff --git a/frontend/src/components/kanban/KanbanCard.tsx b/frontend/src/components/kanban/KanbanCard.tsx new file mode 100644 index 0000000..bed9801 --- /dev/null +++ b/frontend/src/components/kanban/KanbanCard.tsx @@ -0,0 +1,46 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Card as CardType } from '../../types/kanban'; + +interface KanbanCardProps { + card: CardType; + onClick: () => void; +} + +export function KanbanCard({ card, onClick }: KanbanCardProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: card.id.toString(), + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+

{card.name}

+ + {card.description && ( +

{card.description}

+ )} + +
+ #{card.id_short} + {card.due && ( + + Due: {new Date(card.due).toLocaleDateString()} + + )} +
+
+ ); +} diff --git a/frontend/src/components/kanban/KanbanColumn.tsx b/frontend/src/components/kanban/KanbanColumn.tsx new file mode 100644 index 0000000..aaaa0b1 --- /dev/null +++ b/frontend/src/components/kanban/KanbanColumn.tsx @@ -0,0 +1,42 @@ +import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { ListWithCards, Card as CardType } from '../../types/kanban'; +import { KanbanCard } from './KanbanCard'; + +interface KanbanColumnProps { + list: ListWithCards; + cards: CardType[]; + onCardClick: (card: CardType) => void; +} + +export function KanbanColumn({ list, cards, onCardClick }: KanbanColumnProps) { + const { setNodeRef, isOver } = useDroppable({ + id: list.id.toString(), + }); + + return ( +
+

+ {list.name} + + {cards.length} + +

+ + card.id.toString())} + strategy={verticalListSortingStrategy} + > +
+ {cards.map((card) => ( + onCardClick(card)} /> + ))} +
+
+
+ ); +} diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 0a32994..8cd6320 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1,5 +1,16 @@ import axios from 'axios'; import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types'; +import { + Board, + BoardWithDetails, + BoardCreate, + List, + Card, + CardWithDetails, + Label, + Checklist, + CommentWithUser, +} from '../types/kanban'; const api = axios.create({ baseURL: '/api', @@ -87,5 +98,163 @@ export function useApi() { const response = await api.post('/orders', orderData); return response.data; }, + + // Boards + getBoards: async (): Promise => { + const response = await api.get('/boards'); + return response.data; + }, + getBoard: async (id: number): Promise => { + const response = await api.get(`/boards/${id}`); + return response.data; + }, + createBoard: async (boardData: BoardCreate): Promise => { + const response = await api.post('/boards', boardData); + return response.data; + }, + updateBoard: async (id: number, boardData: Partial): Promise => { + const response = await api.put(`/boards/${id}`, boardData); + return response.data; + }, + deleteBoard: async (id: number): Promise => { + await api.delete(`/boards/${id}`); + }, + + // Lists + createList: async (boardId: number, listData: { name: string; pos: number }): Promise => { + const response = await api.post(`/boards/${boardId}/lists`, listData); + return response.data; + }, + updateList: async ( + id: number, + listData: { name: string; pos: number; closed?: boolean } + ): Promise => { + const response = await api.put(`/lists/${id}`, listData); + return response.data; + }, + deleteList: async (id: number): Promise => { + await api.delete(`/lists/${id}`); + }, + + // Cards + createCard: async ( + listId: number, + cardData: { + name: string; + description?: string; + pos: number; + due?: string | null; + due_complete?: boolean; + badges?: Record; + cover?: Record; + desc_data?: Record; + } + ): Promise => { + const response = await api.post(`/lists/${listId}/cards`, cardData); + return response.data; + }, + getCard: async (id: number): Promise => { + const response = await api.get(`/cards/${id}`); + return response.data; + }, + updateCard: async ( + id: number, + cardData: { + name: string; + description?: string; + pos: number; + due?: string | null; + due_complete?: boolean; + closed?: boolean; + list_id?: number; + badges?: Record; + cover?: Record; + desc_data?: Record; + } + ): Promise => { + const response = await api.put(`/cards/${id}`, cardData); + return response.data; + }, + deleteCard: async (id: number): Promise => { + await api.delete(`/cards/${id}`); + }, + + // Labels + getLabels: async (boardId: number): Promise => { + const response = await api.get(`/boards/${boardId}/labels`); + return response.data; + }, + createLabel: async ( + boardId: number, + labelData: { name: string; color: string } + ): Promise