add board detail and clean up login flow
This commit is contained in:
parent
94e157da4b
commit
1bcad71f23
22 changed files with 1756 additions and 78 deletions
101
frontend/package-lock.json
generated
101
frontend/package-lock.json
generated
|
|
@ -8,10 +8,16 @@
|
||||||
"name": "crafting-shop-frontend",
|
"name": "crafting-shop-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"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",
|
"axios": "^1.6.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
|
|
@ -469,6 +475,55 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"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": "^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": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||||
|
|
@ -1386,6 +1452,11 @@
|
||||||
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
|
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|
@ -5785,6 +5856,21 @@
|
||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
|
|
@ -6783,6 +6869,11 @@
|
||||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|
@ -7377,6 +7468,14 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,16 @@
|
||||||
"test:ui": "vitest --ui"
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"axios": "^1.6.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ import { Products } from './pages/Products';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import { Register } from './pages/Register';
|
import { Register } from './pages/Register';
|
||||||
import { Orders } from './pages/Orders';
|
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 = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -23,9 +28,45 @@ const App = () => {
|
||||||
<main className="flex-1 p-8 max-w-7xl mx-auto w-full">
|
<main className="flex-1 p-8 max-w-7xl mx-auto w-full">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/products" element={<Products />} />
|
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
|
||||||
|
{/* Protected Routes */}
|
||||||
|
<Route
|
||||||
|
path="/boards"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Boards />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/boards/new"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BoardCreate />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/boards/:id/edit"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BoardEdit />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/boards/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BoardDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Legacy Routes */}
|
||||||
|
<Route path="/products" element={<Products />} />
|
||||||
<Route path="/cart" element={<Cart />} />
|
<Route path="/cart" element={<Cart />} />
|
||||||
<Route path="/orders" element={<Orders />} />
|
<Route path="/orders" element={<Orders />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,20 @@ export function Navbar() {
|
||||||
Cart
|
Cart
|
||||||
</Link>
|
</Link>
|
||||||
{user && (
|
{user && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/boards"
|
||||||
|
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Boards
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/orders"
|
to="/orders"
|
||||||
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Orders
|
Orders
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
24
frontend/src/components/ProtectedRoute.tsx
Normal file
24
frontend/src/components/ProtectedRoute.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-white text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
99
frontend/src/components/kanban/BoardCard.tsx
Normal file
99
frontend/src/components/kanban/BoardCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div onClick={() => navigate(`/boards/${board.id}`)} className="block group cursor-pointer">
|
||||||
|
<div className="bg-gray-800 rounded-lg p-6 hover:bg-gray-750 transition-all hover:shadow-lg hover:-translate-y-1 border border-gray-700">
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<h3 className="text-xl font-bold text-white group-hover:text-blue-400 transition-colors">
|
||||||
|
{board.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Link
|
||||||
|
to={`/boards/${board.id}/edit`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-gray-400 hover:text-blue-400 transition-colors"
|
||||||
|
title="Edit board"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="text-gray-400 hover:text-red-400 transition-colors"
|
||||||
|
title="Delete board"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{board.description && (
|
||||||
|
<p className="text-gray-400 text-sm mb-4 line-clamp-2">{board.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Last activity: {formatDate(board.date_last_activity)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/kanban/KanbanCard.tsx
Normal file
46
frontend/src/components/kanban/KanbanCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
onClick={onClick}
|
||||||
|
className="bg-gray-700 rounded-lg p-4 mb-3 cursor-pointer hover:bg-gray-600 transition-colors border border-gray-600 shadow-sm"
|
||||||
|
>
|
||||||
|
<h3 className="text-white font-medium mb-2">{card.name}</h3>
|
||||||
|
|
||||||
|
{card.description && (
|
||||||
|
<p className="text-gray-400 text-sm mb-2 line-clamp-2">{card.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>#{card.id_short}</span>
|
||||||
|
{card.due && (
|
||||||
|
<span className={card.due_complete ? 'text-green-400' : 'text-yellow-400'}>
|
||||||
|
Due: {new Date(card.due).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/components/kanban/KanbanColumn.tsx
Normal file
42
frontend/src/components/kanban/KanbanColumn.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4 min-w-[300px] max-w-[300px] border border-gray-700">
|
||||||
|
<h2 className="text-white font-bold text-lg mb-4 flex items-center justify-between">
|
||||||
|
{list.name}
|
||||||
|
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full">
|
||||||
|
{cards.length}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<SortableContext
|
||||||
|
id={list.id.toString()}
|
||||||
|
items={cards.map((card) => card.id.toString())}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`min-h-[200px] transition-colors ${isOver ? 'bg-gray-750' : ''}`}
|
||||||
|
>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<KanbanCard key={card.id} card={card} onClick={() => onCardClick(card)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types';
|
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({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
|
|
@ -87,5 +98,163 @@ export function useApi() {
|
||||||
const response = await api.post<OrderData>('/orders', orderData);
|
const response = await api.post<OrderData>('/orders', orderData);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Boards
|
||||||
|
getBoards: async (): Promise<Board[]> => {
|
||||||
|
const response = await api.get<Board[]>('/boards');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
getBoard: async (id: number): Promise<BoardWithDetails> => {
|
||||||
|
const response = await api.get<BoardWithDetails>(`/boards/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
createBoard: async (boardData: BoardCreate): Promise<Board> => {
|
||||||
|
const response = await api.post<Board>('/boards', boardData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
updateBoard: async (id: number, boardData: Partial<BoardCreate>): Promise<Board> => {
|
||||||
|
const response = await api.put<Board>(`/boards/${id}`, boardData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
deleteBoard: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/boards/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
createList: async (boardId: number, listData: { name: string; pos: number }): Promise<List> => {
|
||||||
|
const response = await api.post<List>(`/boards/${boardId}/lists`, listData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
updateList: async (
|
||||||
|
id: number,
|
||||||
|
listData: { name: string; pos: number; closed?: boolean }
|
||||||
|
): Promise<List> => {
|
||||||
|
const response = await api.put<List>(`/lists/${id}`, listData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
deleteList: async (id: number): Promise<void> => {
|
||||||
|
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<string, any>;
|
||||||
|
cover?: Record<string, any>;
|
||||||
|
desc_data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
): Promise<Card> => {
|
||||||
|
const response = await api.post<Card>(`/lists/${listId}/cards`, cardData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
getCard: async (id: number): Promise<CardWithDetails> => {
|
||||||
|
const response = await api.get<CardWithDetails>(`/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<string, any>;
|
||||||
|
cover?: Record<string, any>;
|
||||||
|
desc_data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
): Promise<Card> => {
|
||||||
|
const response = await api.put<Card>(`/cards/${id}`, cardData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
deleteCard: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/cards/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
getLabels: async (boardId: number): Promise<Label[]> => {
|
||||||
|
const response = await api.get<Label[]>(`/boards/${boardId}/labels`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
createLabel: async (
|
||||||
|
boardId: number,
|
||||||
|
labelData: { name: string; color: string }
|
||||||
|
): Promise<Label> => {
|
||||||
|
const response = await api.post<Label>(`/boards/${boardId}/labels`, labelData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
addLabelToCard: async (cardId: number, labelId: number): Promise<any> => {
|
||||||
|
const response = await api.post(`/cards/${cardId}/labels`, { label_id: labelId });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
removeLabelFromCard: async (cardId: number, labelId: number): Promise<void> => {
|
||||||
|
await api.delete(`/cards/${cardId}/labels/${labelId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Checklists
|
||||||
|
createChecklist: async (
|
||||||
|
cardId: number,
|
||||||
|
checklistData: { name: string; pos: number }
|
||||||
|
): Promise<Checklist> => {
|
||||||
|
const response = await api.post<Checklist>(`/cards/${cardId}/checklists`, checklistData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
deleteChecklist: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/checklists/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check Items
|
||||||
|
createCheckItem: async (
|
||||||
|
checklistId: number,
|
||||||
|
itemData: {
|
||||||
|
name: string;
|
||||||
|
pos: number;
|
||||||
|
state: 'incomplete' | 'complete';
|
||||||
|
due?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<any> => {
|
||||||
|
const response = await api.post(`/checklists/${checklistId}/items`, itemData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
updateCheckItem: async (
|
||||||
|
id: number,
|
||||||
|
itemData: {
|
||||||
|
name: string;
|
||||||
|
pos: number;
|
||||||
|
state: 'incomplete' | 'complete';
|
||||||
|
due?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<any> => {
|
||||||
|
const response = await api.put(`/check-items/${id}`, itemData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
deleteCheckItem: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/check-items/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
getComments: async (cardId: number): Promise<CommentWithUser[]> => {
|
||||||
|
const response = await api.get<CommentWithUser[]>(`/cards/${cardId}/comments`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
createComment: async (cardId: number, commentData: { text: string }): Promise<any> => {
|
||||||
|
const response = await api.post(`/cards/${cardId}/comments`, commentData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
updateComment: async (id: number, commentData: { text: string }): Promise<any> => {
|
||||||
|
const response = await api.put(`/comments/${id}`, commentData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
deleteComment: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/comments/${id}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
frontend/src/hooks/useAuth.ts
Normal file
139
frontend/src/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
import { useLoader } from '../context/loaders/useLoader';
|
||||||
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
import { useApp } from '../context/AppContext';
|
||||||
|
import { User } from '../types';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useApp();
|
||||||
|
const { login: loginApi, register: registerApi } = useApi();
|
||||||
|
const { withLoader } = useLoader();
|
||||||
|
const { addNotification } = useToast();
|
||||||
|
|
||||||
|
const handleLogin = async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await withLoader(() => loginApi(email, password), 'Logging in...');
|
||||||
|
|
||||||
|
// Convert UserData to User type
|
||||||
|
const user: User = {
|
||||||
|
id: parseInt(response.user.id),
|
||||||
|
username: response.user.username,
|
||||||
|
email: response.user.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
// debugger
|
||||||
|
// Store in localStorage first
|
||||||
|
localStorage.setItem('token', response.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
|
||||||
|
// Then update context
|
||||||
|
login(user, response.access_token);
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Login Successful',
|
||||||
|
message: `Welcome back, ${user.username}!`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to boards
|
||||||
|
navigate('/boards');
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Login failed. Please try again.';
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Login Failed',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async (userData: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const response = await withLoader(() => registerApi(userData), 'Creating account...');
|
||||||
|
|
||||||
|
// Convert UserData to User type
|
||||||
|
const user: User = {
|
||||||
|
id: parseInt(response.user.id),
|
||||||
|
username: response.user.username,
|
||||||
|
email: response.user.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in localStorage first
|
||||||
|
// debugger
|
||||||
|
localStorage.setItem('token', response.access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
|
||||||
|
// Then update context
|
||||||
|
login(user, response.access_token);
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Account Created',
|
||||||
|
message: `Welcome, ${user.username}!`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to boards
|
||||||
|
navigate('/boards');
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'Registration failed. Please try again.';
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Registration Failed',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
// Show logout toast
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Logged Out',
|
||||||
|
message: 'You have been logged out successfully.',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear local storage
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
|
||||||
|
// Navigate to login
|
||||||
|
navigate('/login');
|
||||||
|
|
||||||
|
// Trigger page refresh to clear app context
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: handleLogin,
|
||||||
|
register: handleRegister,
|
||||||
|
logout: handleLogout,
|
||||||
|
};
|
||||||
|
}
|
||||||
49
frontend/src/hooks/useBoard.ts
Normal file
49
frontend/src/hooks/useBoard.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
import { useLoader } from '../context/loaders/useLoader';
|
||||||
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
import { BoardWithDetails } from '../types/kanban';
|
||||||
|
|
||||||
|
export function useBoard(boardId: number) {
|
||||||
|
const [board, setBoard] = useState<BoardWithDetails | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { getBoard } = useApi();
|
||||||
|
const { withLoader } = useLoader();
|
||||||
|
const { addNotification } = useToast();
|
||||||
|
|
||||||
|
const fetchBoard = async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await withLoader(() => getBoard(boardId), 'Loading board...');
|
||||||
|
setBoard(data);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load board';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Loading Board',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBoardData = (updatedBoard: BoardWithDetails) => {
|
||||||
|
setBoard(updatedBoard);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBoard();
|
||||||
|
}, [boardId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
board,
|
||||||
|
error, // For debugging, not for UI display
|
||||||
|
loading: false, // Loading is handled by global loader
|
||||||
|
fetchBoard,
|
||||||
|
updateBoardData,
|
||||||
|
};
|
||||||
|
}
|
||||||
124
frontend/src/hooks/useBoards.ts
Normal file
124
frontend/src/hooks/useBoards.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
import { useLoader } from '../context/loaders/useLoader';
|
||||||
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
import { Board } from '../types/kanban';
|
||||||
|
|
||||||
|
export function useBoards() {
|
||||||
|
const [boards, setBoards] = useState<Board[]>([]);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { getBoards, createBoard, updateBoard, deleteBoard } = useApi();
|
||||||
|
const { withLoader } = useLoader();
|
||||||
|
const { addNotification } = useToast();
|
||||||
|
|
||||||
|
const fetchBoards = async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await withLoader(() => getBoards(), 'Loading boards...');
|
||||||
|
setBoards(data);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load boards';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Loading Boards',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewBoard = async (boardData: { name: string; description?: string }) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await withLoader(() => createBoard(boardData), 'Creating board...');
|
||||||
|
setBoards((prev) => [...prev, data]);
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Board Created',
|
||||||
|
message: `Board "${data.name}" created successfully.`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create board';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Creating Board',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExistingBoard = async (
|
||||||
|
id: number,
|
||||||
|
boardData: { name: string; description?: string }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await withLoader(() => updateBoard(id, boardData), 'Updating board...');
|
||||||
|
setBoards((prev) => prev.map((board) => (board.id === id ? data : board)));
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Board Updated',
|
||||||
|
message: `Board "${data.name}" updated successfully.`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to update board';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Updating Board',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBoard = async (id: number) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await withLoader(() => deleteBoard(id), 'Deleting board...');
|
||||||
|
setBoards((prev) => prev.filter((board) => board.id !== id));
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Board Deleted',
|
||||||
|
message: 'Board deleted successfully.',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete board';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Deleting Board',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBoards();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
boards,
|
||||||
|
error, // For debugging, not for UI display
|
||||||
|
loading: false, // Loading is handled by global loader
|
||||||
|
fetchBoards,
|
||||||
|
createBoard: createNewBoard,
|
||||||
|
updateBoard: updateExistingBoard,
|
||||||
|
deleteBoard: removeBoard,
|
||||||
|
};
|
||||||
|
}
|
||||||
128
frontend/src/hooks/useCardMutations.ts
Normal file
128
frontend/src/hooks/useCardMutations.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
import { useLoader } from '../context/loaders/useLoader';
|
||||||
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
import { BoardWithDetails, Card, List } from '../types/kanban';
|
||||||
|
|
||||||
|
export function useCardMutations(boardId: number, onUpdate: (board: BoardWithDetails) => void) {
|
||||||
|
const { createCard, updateCard, deleteCard } = useApi();
|
||||||
|
const { withLoader } = useLoader();
|
||||||
|
const { addNotification } = useToast();
|
||||||
|
|
||||||
|
const createNewCard = async (
|
||||||
|
listId: number,
|
||||||
|
cardData: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
pos: number;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const data = await withLoader(() => createCard(listId, cardData), 'Creating card...');
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Card Created',
|
||||||
|
message: `Card "${data.name}" created successfully.`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create card';
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Creating Card',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExistingCard = async (
|
||||||
|
cardId: number,
|
||||||
|
cardData: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
pos: number;
|
||||||
|
list_id?: number;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const data = await withLoader(() => updateCard(cardId, cardData), 'Updating card...');
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Card Updated',
|
||||||
|
message: `Card "${data.name}" updated successfully.`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to update card';
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Updating Card',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveCard = async (
|
||||||
|
cardId: number,
|
||||||
|
fromListId: number,
|
||||||
|
toListId: number,
|
||||||
|
newPosition: number
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// Optimistic update - don't show loader for drag operations
|
||||||
|
await updateCard(cardId, {
|
||||||
|
name: '', // Placeholder, will be filled by server
|
||||||
|
pos: newPosition,
|
||||||
|
list_id: toListId,
|
||||||
|
});
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Card Moved',
|
||||||
|
message: 'Card moved successfully.',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to move card';
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Moving Card',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCard = async (cardId: number) => {
|
||||||
|
try {
|
||||||
|
await withLoader(() => deleteCard(cardId), 'Deleting card...');
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Card Deleted',
|
||||||
|
message: 'Card deleted successfully.',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete card';
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Deleting Card',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createCard: createNewCard,
|
||||||
|
updateCard: updateExistingCard,
|
||||||
|
moveCard,
|
||||||
|
deleteCard: removeCard,
|
||||||
|
};
|
||||||
|
}
|
||||||
84
frontend/src/hooks/useListMutations.ts
Normal file
84
frontend/src/hooks/useListMutations.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
import { useLoader } from '../context/loaders/useLoader';
|
||||||
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
import { BoardWithDetails } from '../types/kanban';
|
||||||
|
|
||||||
|
export function useListMutations(boardId: number, onUpdate: (board: BoardWithDetails) => void) {
|
||||||
|
const { createList, updateList, deleteList } = useApi();
|
||||||
|
const { withLoader } = useLoader();
|
||||||
|
const { addNotification } = useToast();
|
||||||
|
|
||||||
|
const createNewList = async (name: string, pos: number) => {
|
||||||
|
try {
|
||||||
|
const data = await withLoader(() => createList(boardId, { name, pos }), 'Creating list...');
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'List Created',
|
||||||
|
message: `List "${data.name}" created successfully.`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create list';
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Creating List',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExistingList = async (listId: number, data: { name: string; pos: number }) => {
|
||||||
|
try {
|
||||||
|
const updated = await withLoader(
|
||||||
|
() => updateList(listId, { ...data, closed: false }),
|
||||||
|
'Updating list...'
|
||||||
|
);
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'List Updated',
|
||||||
|
message: `List "${updated.name}" updated successfully.`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to update list';
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Updating List',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeList = async (listId: number) => {
|
||||||
|
try {
|
||||||
|
await withLoader(() => deleteList(listId), 'Deleting list...');
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'List Deleted',
|
||||||
|
message: 'List deleted successfully.',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete list';
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Deleting List',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createList: createNewList,
|
||||||
|
updateList: updateExistingList,
|
||||||
|
deleteList: removeList,
|
||||||
|
};
|
||||||
|
}
|
||||||
98
frontend/src/pages/BoardCreate.tsx
Normal file
98
frontend/src/pages/BoardCreate.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useBoards } from '../hooks/useBoards';
|
||||||
|
|
||||||
|
const boardSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Board name is required')
|
||||||
|
.max(100, 'Board name must be less than 100 characters'),
|
||||||
|
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type BoardFormData = z.infer<typeof boardSchema>;
|
||||||
|
|
||||||
|
export function BoardCreate() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { createBoard } = useBoards();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<BoardFormData>({
|
||||||
|
resolver: zodResolver(boardSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: BoardFormData) => {
|
||||||
|
try {
|
||||||
|
await createBoard(data);
|
||||||
|
navigate('/boards');
|
||||||
|
} catch (err) {
|
||||||
|
// Error is handled by the hook
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors">
|
||||||
|
← Back to Boards
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-6">Create New Board</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Board Name <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
{...register('name')}
|
||||||
|
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="e.g., Project Alpha, Personal Tasks"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
rows={4}
|
||||||
|
{...register('description')}
|
||||||
|
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||||
|
placeholder="Optional description for your board..."
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="mt-1 text-sm text-red-400">{errors.description.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Link
|
||||||
|
to="/boards"
|
||||||
|
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Create Board
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
frontend/src/pages/BoardDetail.tsx
Normal file
211
frontend/src/pages/BoardDetail.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useBoard } from '../hooks/useBoard';
|
||||||
|
import { useCardMutations } from '../hooks/useCardMutations';
|
||||||
|
import { useListMutations } from '../hooks/useListMutations';
|
||||||
|
import { KanbanColumn } from '../components/kanban/KanbanColumn';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { Card as CardType, BoardWithDetails } from '../types/kanban';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function BoardDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { board, fetchBoard } = useBoard(parseInt(id || '0'));
|
||||||
|
const { moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard);
|
||||||
|
const { createList } = useListMutations(parseInt(id || '0'), fetchBoard);
|
||||||
|
|
||||||
|
const [activeCard, setActiveCard] = useState<CardType | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
const { active } = event;
|
||||||
|
const cardId = parseInt(active.id as string);
|
||||||
|
|
||||||
|
if (board) {
|
||||||
|
const card = board.lists.flatMap((list) => list.cards).find((c) => c.id === cardId);
|
||||||
|
|
||||||
|
if (card) {
|
||||||
|
setActiveCard(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = parseInt(active.id as string);
|
||||||
|
const overId = parseInt(over.id as string);
|
||||||
|
|
||||||
|
if (activeId === overId) return;
|
||||||
|
|
||||||
|
// Find the active card and its current list
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
const activeList = board.lists.find((list) => list.cards.some((card) => card.id === activeId));
|
||||||
|
|
||||||
|
// If we're hovering over a card in the same list, do nothing
|
||||||
|
if (activeList) {
|
||||||
|
const overCard = activeList.cards.find((card) => card.id === overId);
|
||||||
|
if (overCard) return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveCard(null);
|
||||||
|
|
||||||
|
if (!over || !board) return;
|
||||||
|
|
||||||
|
const activeId = parseInt(active.id as string);
|
||||||
|
const overId = parseInt(over.id as string);
|
||||||
|
|
||||||
|
if (activeId === overId) return;
|
||||||
|
|
||||||
|
// Find the active card
|
||||||
|
let activeCard: CardType | undefined;
|
||||||
|
let activeList: (typeof board.lists)[0] | undefined;
|
||||||
|
|
||||||
|
for (const list of board.lists) {
|
||||||
|
const card = list.cards.find((c) => c.id === activeId);
|
||||||
|
if (card) {
|
||||||
|
activeCard = card;
|
||||||
|
activeList = list;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeCard || !activeList) return;
|
||||||
|
|
||||||
|
// Check if we're dropping on a list or a card
|
||||||
|
const overList = board.lists.find((list) => list.id === overId);
|
||||||
|
|
||||||
|
if (overList) {
|
||||||
|
// Dropping on a list - append to the end
|
||||||
|
if (overList.id === activeList.id) return; // Same list, do nothing
|
||||||
|
|
||||||
|
await moveCard(activeCard.id, activeList.id, overList.id, overList.cards.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropping on a card - find which list it belongs to
|
||||||
|
let overCard: CardType | undefined;
|
||||||
|
let overListContainingCard: (typeof board.lists)[0] | undefined;
|
||||||
|
|
||||||
|
for (const list of board.lists) {
|
||||||
|
const card = list.cards.find((c) => c.id === overId);
|
||||||
|
if (card) {
|
||||||
|
overCard = card;
|
||||||
|
overListContainingCard = list;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overCard || !overListContainingCard) return;
|
||||||
|
|
||||||
|
// Calculate new position
|
||||||
|
const overCardIndex = overListContainingCard.cards.findIndex((c) => c.id === overId);
|
||||||
|
|
||||||
|
// If dropping on the same list and after the same card, do nothing
|
||||||
|
if (
|
||||||
|
overListContainingCard.id === activeList.id &&
|
||||||
|
overCardIndex === activeList.cards.findIndex((c) => c.id === activeId) + 1
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveCard(activeCard.id, activeList.id, overListContainingCard.id, overCardIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardClick = (card: CardType) => {
|
||||||
|
navigate(`/boards/${id}/cards/${card.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddList = async () => {
|
||||||
|
const listName = prompt('Enter list name:');
|
||||||
|
if (listName && listName.trim()) {
|
||||||
|
try {
|
||||||
|
const newList = await createList(listName.trim(), board ? board.lists.length : 0);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handled by hook
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!board) {
|
||||||
|
return <div className="text-white">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm">
|
||||||
|
← Back to Boards
|
||||||
|
</Link>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{board.lists.map((list) => (
|
||||||
|
<KanbanColumn
|
||||||
|
key={list.id}
|
||||||
|
list={list}
|
||||||
|
cards={list.cards}
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
frontend/src/pages/BoardEdit.tsx
Normal file
116
frontend/src/pages/BoardEdit.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useNavigate, Link, useParams } from 'react-router-dom';
|
||||||
|
import { useBoard } from '../hooks/useBoard';
|
||||||
|
import { useBoards } from '../hooks/useBoards';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const boardSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Board name is required')
|
||||||
|
.max(100, 'Board name must be less than 100 characters'),
|
||||||
|
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type BoardFormData = z.infer<typeof boardSchema>;
|
||||||
|
|
||||||
|
export function BoardEdit() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { board } = useBoard(parseInt(id || '0'));
|
||||||
|
const { updateBoard } = useBoards();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
|
} = useForm<BoardFormData>({
|
||||||
|
resolver: zodResolver(boardSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form when board data is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (board) {
|
||||||
|
setValue('name', board.name);
|
||||||
|
setValue('description', board.description || '');
|
||||||
|
}
|
||||||
|
}, [board, setValue]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: BoardFormData) => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await updateBoard(parseInt(id), data);
|
||||||
|
navigate(`/boards/${id}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Error is handled by the hook
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!board) {
|
||||||
|
return <div className="text-white">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link to={`/boards/${id}`} className="text-gray-400 hover:text-white transition-colors">
|
||||||
|
← Back to Board
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-6">Edit Board</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Board Name <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
{...register('name')}
|
||||||
|
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="e.g., Project Alpha, Personal Tasks"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
rows={4}
|
||||||
|
{...register('description')}
|
||||||
|
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||||
|
placeholder="Optional description for your board..."
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="mt-1 text-sm text-red-400">{errors.description.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Link
|
||||||
|
to={`/boards/${id}`}
|
||||||
|
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/pages/Boards.tsx
Normal file
42
frontend/src/pages/Boards.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useBoards } from '../hooks/useBoards';
|
||||||
|
import { BoardCard } from '../components/kanban/BoardCard';
|
||||||
|
|
||||||
|
export function Boards() {
|
||||||
|
const { boards, deleteBoard } = useBoards();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">My Boards</h1>
|
||||||
|
<p className="text-gray-400">Manage your Kanban boards</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
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
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,38 +1,18 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { useApi } from '../hooks/useApi';
|
|
||||||
import { User } from '../types';
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const { login: handleLogin } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { login } = useApp();
|
|
||||||
const { login: loginApi } = useApi();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await loginApi(email, password);
|
await handleLogin(email, password);
|
||||||
// Convert UserData to User type
|
|
||||||
const user: User = {
|
|
||||||
id: parseInt(response.user.id),
|
|
||||||
username: response.user.username,
|
|
||||||
email: response.user.email,
|
|
||||||
};
|
|
||||||
login(user, response.token);
|
|
||||||
navigate('/');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Login failed. Please try again.');
|
// Error is handled by the hook (toast shown)
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -41,12 +21,6 @@ export default function Login() {
|
||||||
<h1 className="text-3xl font-bold text-white mb-8 text-center">Login</h1>
|
<h1 className="text-3xl font-bold text-white mb-8 text-center">Login</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{error && (
|
|
||||||
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Email
|
Email
|
||||||
|
|
@ -77,16 +51,15 @@ export default function Login() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
{loading ? 'Logging in...' : 'Login'}
|
Login
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-gray-400">
|
<p className="mt-6 text-center text-gray-400">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
<Link to="/register" className="ml-2 text-blue-400 hover:text-blue-300">
|
||||||
Register
|
Register
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, FormEvent, ChangeEvent } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useApi } from '../hooks/useApi';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -20,48 +20,37 @@ export function Register() {
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
});
|
});
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const { register: handleRegister } = useAuth();
|
||||||
const { register } = useApi();
|
|
||||||
|
|
||||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
[e.target.name]: e.target.value,
|
[e.target.name]: e.target.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
if (formData.password !== formData.confirmPassword) {
|
||||||
setError('Passwords do not match');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.password.length < 6) {
|
if (formData.password.length < 6) {
|
||||||
setError('Password must be at least 6 characters');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register({
|
await handleRegister({
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
username: formData.username,
|
username: formData.username,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
first_name: formData.first_name,
|
first_name: formData.first_name,
|
||||||
last_name: formData.last_name,
|
last_name: formData.last_name,
|
||||||
});
|
});
|
||||||
navigate('/login');
|
} catch (err) {
|
||||||
} catch (err: any) {
|
// Error is handled by the hook (toast shown)
|
||||||
setError(err.response?.data?.error || 'Registration failed. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -70,12 +59,6 @@ export function Register() {
|
||||||
<h1 className="text-3xl font-bold text-white mb-8 text-center">Register</h1>
|
<h1 className="text-3xl font-bold text-white mb-8 text-center">Register</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{error && (
|
|
||||||
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-300 mb-2">
|
<label htmlFor="first_name" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
|
@ -166,18 +149,30 @@ export function Register() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{formData.password !== formData.confirmPassword && formData.confirmPassword && (
|
||||||
|
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
|
||||||
|
Passwords do not match
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.password.length < 6 && formData.password && (
|
||||||
|
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
|
||||||
|
Password must be at least 6 characters
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={formData.password !== formData.confirmPassword || formData.password.length < 6}
|
||||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? 'Registering...' : 'Register'}
|
Register
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-gray-400">
|
<p className="mt-6 text-center text-gray-400">
|
||||||
Already have an account?{' '}
|
Already have an account?
|
||||||
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
<Link to="/login" className="ml-2 text-blue-400 hover:text-blue-300">
|
||||||
Login
|
Login
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
185
frontend/src/types/kanban.ts
Normal file
185
frontend/src/types/kanban.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
// Board types
|
||||||
|
export interface Board {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
closed: boolean;
|
||||||
|
url: string | null;
|
||||||
|
short_link: string | null;
|
||||||
|
short_url: string | null;
|
||||||
|
user_id: number;
|
||||||
|
date_last_activity: string;
|
||||||
|
date_last_view: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
prefs: Record<string, any>;
|
||||||
|
label_names: Record<string, any>;
|
||||||
|
limits: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardWithDetails extends Board {
|
||||||
|
lists: ListWithCards[];
|
||||||
|
labels: Label[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
url?: string;
|
||||||
|
short_link?: string;
|
||||||
|
short_url?: string;
|
||||||
|
prefs?: Record<string, any>;
|
||||||
|
label_names?: Record<string, any>;
|
||||||
|
limits?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardUpdate extends Partial<BoardCreate> {
|
||||||
|
closed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List types
|
||||||
|
export interface List {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
closed: boolean;
|
||||||
|
pos: number;
|
||||||
|
board_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListWithCards extends List {
|
||||||
|
cards: Card[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListCreate {
|
||||||
|
name: string;
|
||||||
|
pos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListUpdate extends Partial<ListCreate> {
|
||||||
|
closed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card types
|
||||||
|
export interface Card {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
closed: boolean;
|
||||||
|
due: string | null;
|
||||||
|
due_complete: boolean;
|
||||||
|
pos: number;
|
||||||
|
id_short: number;
|
||||||
|
board_id: number;
|
||||||
|
list_id: number;
|
||||||
|
date_last_activity: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
badges: Record<string, any>;
|
||||||
|
cover: Record<string, any>;
|
||||||
|
desc_data: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardWithDetails extends Card {
|
||||||
|
labels: Label[];
|
||||||
|
checklists: Checklist[];
|
||||||
|
comments: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
pos: number;
|
||||||
|
due?: string | null;
|
||||||
|
due_complete?: boolean;
|
||||||
|
badges?: Record<string, any>;
|
||||||
|
cover?: Record<string, any>;
|
||||||
|
desc_data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardUpdate extends Partial<CardCreate> {
|
||||||
|
closed?: boolean;
|
||||||
|
list_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label types
|
||||||
|
export interface Label {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
uses: number;
|
||||||
|
board_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabelCreate {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checklist types
|
||||||
|
export interface Checklist {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
pos: number;
|
||||||
|
board_id: number;
|
||||||
|
card_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
items: CheckItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChecklistCreate {
|
||||||
|
name: string;
|
||||||
|
pos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckItem types
|
||||||
|
export interface CheckItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
pos: number;
|
||||||
|
state: 'incomplete' | 'complete';
|
||||||
|
due: string | null;
|
||||||
|
checklist_id: number;
|
||||||
|
user_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckItemCreate {
|
||||||
|
name: string;
|
||||||
|
pos: number;
|
||||||
|
state: 'incomplete' | 'complete';
|
||||||
|
due?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment types
|
||||||
|
export interface Comment {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
card_id: number;
|
||||||
|
user_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentWithUser extends Comment {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentCreate {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User type for comments
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
}
|
||||||
|
|
@ -20,11 +20,11 @@ export interface RegisterData {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
username: string;
|
username: string;
|
||||||
first_name: string;
|
first_name?: string;
|
||||||
last_name: string;
|
last_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
token: string;
|
access_token: string;
|
||||||
user: UserData;
|
user: UserData;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue