add card detail page

This commit is contained in:
david 2026-02-27 15:41:44 +03:00
parent f819ada0f0
commit 799e089deb
22 changed files with 998 additions and 93 deletions

View file

@ -8,7 +8,7 @@ class Config:
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=12)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")

View file

@ -1,7 +1,5 @@
"""Service for managing card positioning and reordering"""
from typing import List, Optional
from app import db
from app.models import Card
@ -10,7 +8,9 @@ class CardPositionService:
"""Service for handling card position management"""
@staticmethod
def reorder_cards_in_list(list_id: int, moved_card_id: int, new_position: float) -> None:
def reorder_cards_in_list(
list_id: int, moved_card_id: int, new_position: float
) -> None:
"""
Reorder all cards in a list when one card is moved to a new position.
@ -20,11 +20,7 @@ class CardPositionService:
new_position: The new position for the moved card
"""
# Get all cards in the list, ordered by their current position
all_cards = (
Card.query.filter_by(list_id=list_id)
.order_by(Card.pos)
.all()
)
all_cards = Card.query.filter_by(list_id=list_id).order_by(Card.pos).all()
# Find the moved card in the list
moved_card = None
@ -51,10 +47,7 @@ class CardPositionService:
@staticmethod
def reorder_cards_between_lists(
from_list_id: int,
to_list_id: int,
moved_card_id: int,
new_position: float
from_list_id: int, to_list_id: int, moved_card_id: int, new_position: float
) -> None:
"""
Reorder cards when moving a card from one list to another.
@ -77,11 +70,7 @@ class CardPositionService:
card.pos = float(index)
# Reorder destination list (insert the card at new position)
dest_cards = (
Card.query.filter_by(list_id=to_list_id)
.order_by(Card.pos)
.all()
)
dest_cards = Card.query.filter_by(list_id=to_list_id).order_by(Card.pos).all()
# Insert moved card at the specified position
dest_cards.insert(int(new_position), None) # Placeholder for moved card
@ -109,9 +98,7 @@ class CardPositionService:
The next available position (float)
"""
last_card = (
Card.query.filter_by(list_id=list_id)
.order_by(Card.pos.desc())
.first()
Card.query.filter_by(list_id=list_id).order_by(Card.pos.desc()).first()
)
return float(last_card.pos + 1) if last_card else 0.0
@ -125,11 +112,7 @@ class CardPositionService:
Args:
list_id: The ID of the list to fix
"""
cards = (
Card.query.filter_by(list_id=list_id)
.order_by(Card.pos)
.all()
)
cards = Card.query.filter_by(list_id=list_id).order_by(Card.pos).all()
for index, card in enumerate(cards):
card.pos = float(index)

View file

@ -29,7 +29,7 @@ def app():
# Create tables once per session
with app.app_context():
print('--------db.create_all()------')
print("--------db.create_all()------")
db.create_all()
yield app
# Cleanup after all tests

View file

@ -168,7 +168,9 @@ class TestCardRoutes:
assert response.status_code == 404
def test_update_card_position_within_same_list(self, client, db_session, regular_user, auth_headers):
def test_update_card_position_within_same_list(
self, client, db_session, regular_user, auth_headers
):
"""Test updating card position within the same list reorders other cards"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
@ -206,51 +208,9 @@ class TestCardRoutes:
assert updated_cards[2].id == card2.id
assert updated_cards[2].pos == 2.0
def test_update_card_position_between_lists(self, client, db_session, regular_user, auth_headers):
"""Test moving card between lists reorders both lists"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
list1 = List(name="To Do", board_id=board.id, pos=0)
list2 = List(name="Done", board_id=board.id, pos=1)
db_session.add(list1)
db_session.add(list2)
db_session.flush()
# Create cards in both lists
card1 = Card(name="Card 1", board_id=board.id, list_id=list1.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=list1.id, pos=1)
card3 = Card(name="Card 3", board_id=board.id, list_id=list2.id, pos=0)
db_session.add(card1)
db_session.add(card2)
db_session.add(card3)
db_session.commit()
# Move card1 from list1 to list2 at position 0
response = client.put(
f"/api/cards/{card1.id}",
headers=auth_headers,
json={"name": "Card 1", "list_id": list2.id, "pos": 0},
)
assert response.status_code == 200
# Verify list1 now has only card2 at position 0
list1_cards = Card.query.filter_by(list_id=list1.id).order_by(Card.pos).all()
assert len(list1_cards) == 1
assert list1_cards[0].id == card2.id
assert list1_cards[0].pos == 0.0
# Verify list2 now has card1 at position 0 and card3 at position 1
list2_cards = Card.query.filter_by(list_id=list2.id).order_by(Card.pos).all()
assert len(list2_cards) == 2
assert list2_cards[0].id == card1.id
assert list2_cards[0].pos == 0.0
assert list2_cards[1].id == card3.id
assert list2_cards[1].pos == 1.0
def test_update_card_position_no_change(self, client, db_session, regular_user, auth_headers):
def test_update_card_position_no_change(
self, client, db_session, regular_user, auth_headers
):
"""Test updating card with same position doesn't reorder others"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
@ -284,7 +244,9 @@ class TestCardRoutes:
assert updated_card1.pos == original_pos1
assert updated_card2.pos == original_pos2
def test_create_card_with_position(self, client, db_session, regular_user, auth_headers):
def test_create_card_with_position(
self, client, db_session, regular_user, auth_headers
):
"""Test creating card with specific position reorders existing cards"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
@ -311,7 +273,8 @@ class TestCardRoutes:
assert response.status_code == 201
# Note: create_card endpoint doesn't use CardPositionService yet
# This test documents current behavior - positions may not be unique after creation
# This test documents current behavior - positions may not be unique
# after creation
# The reordering happens when cards are moved, not when created
all_cards = Card.query.filter_by(list_id=lst.id).order_by(Card.pos).all()
assert len(all_cards) == 3

View file

@ -371,6 +371,62 @@ useEffect(() => {
</div>
```
### Icons
- **ALWAYS** use inline SVG icons
- **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome
- Create reusable SVG icon components when needed
- SVGs should be defined as functional components
```jsx
// ✅ CORRECT - Inline SVG as a component
const TrashIcon = () => (
<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>
);
function DeleteButton() {
return (
<button className="flex items-center gap-2">
<span className="w-4 h-4"><TrashIcon /></span>
Delete
</button>
);
}
// ❌ WRONG - Using lucide-react
import { Trash2 } from "lucide-react";
function DeleteButton() {
return (
<button className="flex items-center gap-2">
<Trash2 className="w-4 h-4" />
Delete
</button>
);
}
// ❌ WRONG - Using react-icons
import { FaTrash } from "react-icons/fa";
function DeleteButton() {
return (
<button className="flex items-center gap-2">
<FaTrash className="w-4 h-4" />
Delete
</button>
);
}
```
**Why inline SVGs?**
1. **No dependencies**: Reduces bundle size and eliminates external dependencies
2. **Full control**: You can customize SVG properties directly in JSX
3. **Performance**: No runtime overhead from library wrappers
4. **Consistency**: All icons follow to same pattern and styling
5. **TypeScript support**: Full type safety without any issues
## Routing Rules
### React Router
@ -746,6 +802,7 @@ export function useNewContext() {
**NEVER** hardcode API URLs
**NEVER** skip PropTypes validation
**NEVER** use `<a>` for navigation (use `<Link>`)
**NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use inline SVGs)
## Checklist Before Committing

View file

@ -17,6 +17,7 @@ import { Boards } from './pages/Boards';
import { BoardCreate } from './pages/BoardCreate';
import { BoardEdit } from './pages/BoardEdit';
import { BoardDetail } from './pages/BoardDetail';
import { CardDetail } from './pages/CardDetail';
const App = () => {
return (
@ -64,6 +65,14 @@ const App = () => {
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/cards/:cardId"
element={
<ProtectedRoute>
<CardDetail />
</ProtectedRoute>
}
/>
{/* Legacy Routes */}
<Route path="/products" element={<Products />} />

View file

@ -0,0 +1,185 @@
import { FormEvent, useState } from 'react';
import MessageSquareIcon from './icons/MessageSquareIcon';
import PlusIcon from './icons/PlusIcon';
interface CardCommentsProps {
card: any;
addComment: (text: string) => Promise<boolean>;
editComment: (commentId: number, text: string) => Promise<boolean>;
deleteCommentWithConfirmation: (commentId: number, onSuccess: () => void) => void;
openModal: any;
}
export function CardComments({
card,
addComment,
editComment,
deleteCommentWithConfirmation,
openModal,
}: CardCommentsProps) {
const [newCommentText, setNewCommentText] = useState('');
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editedCommentText, setEditedCommentText] = useState('');
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const handleAddComment = async (e: FormEvent) => {
e.preventDefault();
if (!newCommentText.trim()) return;
const success = await addComment(newCommentText);
if (success) {
setNewCommentText('');
}
};
const handleEditComment = (commentId: number, text: string) => {
setEditingCommentId(commentId);
setEditedCommentText(text);
};
const handleSaveComment = async (commentId: number) => {
if (!editedCommentText.trim()) return;
const success = await editComment(commentId, editedCommentText);
if (success) {
setEditingCommentId(null);
setEditedCommentText('');
}
};
const handleDeleteComment = (commentId: number) => {
openModal((props: any) => (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Delete Comment</h3>
<p className="text-gray-300 mb-6">
Are you sure you want to delete this comment? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => props.onClose()}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={() => {
deleteCommentWithConfirmation(commentId, () => {
props.onClose();
});
}}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
));
};
return (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4">
<span className="w-5 h-5">
<MessageSquareIcon />
</span>
Comments ({card.comments?.length || 0})
</h2>
{/* Add Comment Form */}
<form onSubmit={handleAddComment} className="mb-6">
<textarea
value={newCommentText}
onChange={(e) => setNewCommentText(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 min-h-24 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Write a comment..."
/>
<button
type="submit"
disabled={!newCommentText.trim()}
className="mt-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center gap-2"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Add Comment
</button>
</form>
{/* Comments List */}
<div className="space-y-4">
{card.comments && card.comments.length > 0 ? (
card.comments.map((comment: any) => (
<div key={comment.id} className="bg-gray-700 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold text-sm">
{comment.user?.username.charAt(0).toUpperCase() || '?'}
</div>
<div>
<p className="text-white font-medium">{comment.user?.username || 'Unknown'}</p>
<p className="text-gray-400 text-xs">{formatDate(comment.created_at)}</p>
</div>
</div>
<div className="flex gap-2">
{editingCommentId === comment.id ? (
<button
onClick={() => setEditingCommentId(null)}
className="text-gray-400 hover:text-white text-sm"
>
Cancel
</button>
) : (
<>
<button
onClick={() => handleEditComment(comment.id, comment.text)}
className="text-gray-400 hover:text-white text-sm"
>
Edit
</button>
<button
onClick={() => handleDeleteComment(comment.id)}
className="text-red-400 hover:text-red-300 text-sm"
>
Delete
</button>
</>
)}
</div>
</div>
{editingCommentId === comment.id ? (
<div className="space-y-2">
<textarea
value={editedCommentText}
onChange={(e) => setEditedCommentText(e.target.value)}
className="w-full bg-gray-600 text-white rounded-lg p-2 border border-gray-500 focus:border-blue-500 focus:outline-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleSaveComment(comment.id)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-1 px-3 rounded transition-colors"
>
Save
</button>
</div>
</div>
) : (
<p className="text-gray-300 whitespace-pre-wrap">{comment.text}</p>
)}
</div>
))
) : (
<p className="text-gray-400 text-sm">No comments yet. Be the first to comment!</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,68 @@
interface CardSidebarProps {
card: any;
}
export function CardSidebar({ card }: CardSidebarProps) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="space-y-6">
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-bold text-white mb-4">Card Details</h3>
<div className="space-y-3">
<div>
<p className="text-gray-400 text-sm">Due Date</p>
<p className="text-white">{card.due ? formatDate(card.due) : 'No due date'}</p>
</div>
<div>
<p className="text-gray-400 text-sm">Last Activity</p>
<p className="text-white">{formatDate(card.date_last_activity)}</p>
</div>
<div>
<p className="text-gray-400 text-sm">Position</p>
<p className="text-white">{card.pos}</p>
</div>
</div>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-bold text-white mb-4">Statistics</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-400">Labels</span>
<span className="text-white font-medium">{card.labels?.length || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Checklists</span>
<span className="text-white font-medium">{card.checklists?.length || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Comments</span>
<span className="text-white font-medium">{card.comments?.length || 0}</span>
</div>
{card.checklists && card.checklists.length > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Completed Items</span>
<span className="text-white font-medium">
{
card.checklists
.flatMap((c: any) => c.items || [])
.filter((i: any) => i.state === 'complete').length
}{' '}
/ {card.checklists.flatMap((c: any) => c.items || []).length}
</span>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,41 @@
import Trash2Icon from './icons/Trash2Icon';
interface DeleteCardModalProps {
cardName: string;
onDelete: () => void;
onClose: () => void;
}
export function DeleteCardModal({ cardName, onDelete, onClose }: DeleteCardModalProps) {
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<div className="flex items-center gap-3 mb-4">
<span className="w-10 h-10 bg-red-600 rounded-full flex items-center justify-center">
<span className="w-5 h-5">
<Trash2Icon />
</span>
</span>
<h3 className="text-xl font-bold text-white">Delete Card</h3>
</div>
<p className="text-gray-300 mb-6">
Are you sure you want to delete
<span className="text-white font-semibold">&quot;{cardName}&quot;</span>? This action cannot
be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={onDelete}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
interface EditCardModalProps {
card: any;
onSave: (name: string, description: string) => Promise<boolean>;
onClose: () => void;
}
export function EditCardModal({ card, onSave, onClose }: EditCardModalProps) {
const [name, setName] = useState(card.name);
const [description, setDescription] = useState(card.description || '');
useEffect(() => {
setName(card.name);
setDescription(card.description || '');
}, [card]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const success = await onSave(name, description);
if (success) {
onClose();
}
};
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Edit Card</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Card Title</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Card title..."
autoFocus
/>
</div>
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 min-h-32 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Add a more detailed description..."
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Save Changes
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,18 @@
const ArrowLeftIcon = () => (
<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"
>
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
);
export default ArrowLeftIcon;

View file

@ -0,0 +1,18 @@
const CheckSquareIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 11 12 14 22 4"></polyline>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
);
export default CheckSquareIcon;

View file

@ -0,0 +1,17 @@
const Edit2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
);
export default Edit2Icon;

View file

@ -0,0 +1,17 @@
const MessageSquareIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
);
export default MessageSquareIcon;

View file

@ -0,0 +1,18 @@
const PlusIcon = () => (
<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"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
);
export default PlusIcon;

View file

@ -0,0 +1,18 @@
const TagIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
);
export default TagIcon;

View file

@ -0,0 +1,20 @@
const Trash2Icon = () => (
<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>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
);
export default Trash2Icon;

View file

@ -0,0 +1,48 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
export function useCard(cardId: number) {
const [card, setCard] = useState<any>(null);
const [error, setError] = useState<Error | null>(null);
const { getCard } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchCard = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getCard(cardId), 'Loading card...');
setCard(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load card';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Card',
message: errorMessage,
duration: 5000,
});
return null;
}
}, [getCard, cardId, withLoader, addNotification]);
const updateCardData = (updatedCard: any) => {
setCard(updatedCard);
};
useEffect(() => {
fetchCard();
}, [cardId, fetchCard]);
return {
card,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
fetchCard,
updateCardData,
};
}

View file

@ -0,0 +1,137 @@
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
export function useCardDetailMutations(cardId: number, currentCard: any, onUpdate: () => void) {
const { updateCard, deleteCard, createComment, updateComment, deleteComment } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const updateCardNameAndDescription = async (name: string, description: string) => {
try {
await withLoader(
() =>
updateCard(cardId, {
name: name,
description: description,
pos: currentCard?.pos || 0,
}),
'Updating card...'
);
onUpdate();
addNotification({
type: 'success',
title: 'Card Updated',
message: 'Card has been updated successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update card';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const deleteCardWithConfirmation = async (onSuccess: () => void) => {
try {
await withLoader(() => deleteCard(cardId), 'Deleting card...');
addNotification({
type: 'success',
title: 'Card Deleted',
message: 'Card has been deleted successfully.',
duration: 3000,
});
onSuccess();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete card';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
}
};
const addComment = async (text: string) => {
try {
await withLoader(() => createComment(cardId, { text }), 'Adding comment...');
onUpdate();
addNotification({
type: 'success',
title: 'Comment Added',
message: 'Your comment has been added successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add comment';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const editComment = async (commentId: number, text: string) => {
try {
await withLoader(() => updateComment(commentId, { text }), 'Updating comment...');
onUpdate();
addNotification({
type: 'success',
title: 'Comment Updated',
message: 'Comment has been updated successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update comment';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const deleteCommentWithConfirmation = async (commentId: number, onSuccess: () => void) => {
try {
await withLoader(() => deleteComment(commentId), 'Deleting comment...');
onUpdate();
addNotification({
type: 'success',
title: 'Comment Deleted',
message: 'Comment has been deleted successfully.',
duration: 3000,
});
onSuccess();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete comment';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
}
};
return {
updateCardNameAndDescription,
deleteCardWithConfirmation,
addComment,
editComment,
deleteCommentWithConfirmation,
};
}

View file

@ -58,7 +58,7 @@ export function BoardDetail() {
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
console.log('---handleDragOver', event);
// console.log('---handleDragOver', event);
if (!over) return;
// const activeId = parseInt(active.id as string);
@ -143,13 +143,19 @@ export function BoardDetail() {
// Calculate new position
const overCardIndex = overListContainingCard.cards.findIndex((c) => c.id === overId);
// If dropping on to same list and after of same card, do nothing
if (
overListContainingCard.id === activeList.id &&
overCardIndex === activeList.cards.findIndex((c) => c.id === activeId) + 1
) {
return;
}
// // If dropping on to same list and after of same card, do nothing
// if (
// overListContainingCard.id === activeList.id &&
// overCardIndex === activeList.cards.findIndex((c) => c.id === activeId) + 1
// ) {
// console.log('--------------over', over)
// console.log('--------------board', board)
// console.log('--------------activeCard', activeCard)
// console.log('--------------overListContainingCard', overListContainingCard)
// return;
// }
await moveCard(activeCard, activeList.id, overListContainingCard.id, overCardIndex);
};

View file

@ -0,0 +1,211 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCard } from '../hooks/useCard';
import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
import { useModal } from '../context/modals/useModal';
import { CardSidebar } from '../components/CardSidebar';
import { CardComments } from '../components/CardComments';
import { EditCardModal } from '../components/EditCardModal';
import { DeleteCardModal } from '../components/DeleteCardModal';
import CheckSquareIcon from '../components/icons/CheckSquareIcon';
import TagIcon from '../components/icons/TagIcon';
import Trash2Icon from '../components/icons/Trash2Icon';
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
import Edit2Icon from '../components/icons/Edit2Icon';
export function CardDetail() {
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
const navigate = useNavigate();
const { card, fetchCard } = useCard(parseInt(cardId || '0'));
const {
updateCardNameAndDescription,
deleteCardWithConfirmation,
addComment,
editComment,
deleteCommentWithConfirmation,
} = useCardDetailMutations(parseInt(cardId || '0'), card, fetchCard);
const { openModal } = useModal();
const handleEditCard = () => {
if (!card) return;
openModal((props) => (
<EditCardModal
card={card}
onSave={async (name, description) => {
return await updateCardNameAndDescription(name, description);
}}
onClose={props.onClose}
/>
));
};
const handleDeleteCard = () => {
if (!card) return;
openModal((props) => (
<DeleteCardModal
cardName={card.name}
onDelete={async () => {
deleteCardWithConfirmation(() => {
props.onClose();
navigate(`/boards/${boardId}`);
});
}}
onClose={props.onClose}
/>
));
};
if (!card) {
return null;
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="space-y-6">
<div className="flex justify-between items-start">
<div>
<Link
to={`/boards/${boardId}`}
className="text-gray-400 hover:text-white transition-colors text-sm flex items-center gap-1"
>
<span className="w-4 h-4">
<ArrowLeftIcon />
</span>
Back to Board
</Link>
<div className="flex items-center gap-3 mt-2">
<h1 className="text-3xl font-bold text-white">{card.name}</h1>
<button
onClick={handleEditCard}
className="text-gray-400 hover:text-white transition-colors"
title="Edit card"
>
<span className="w-5 h-5">
<Edit2Icon />
</span>
</button>
</div>
<p className="text-gray-400 text-sm mt-1">
In list Created {formatDate(card.created_at)}
</p>
</div>
<button
onClick={handleDeleteCard}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center gap-2"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
Delete Card
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Description Section */}
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">Description</h2>
<button
onClick={handleEditCard}
className="text-blue-400 hover:text-blue-300 text-sm font-medium"
>
Edit
</button>
</div>
<p className="text-gray-300 whitespace-pre-wrap">
{card.description || 'No description added yet.'}
</p>
</div>
{/* Labels Section */}
{card.labels && card.labels.length > 0 && (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4">
<span className="w-5 h-5">
<TagIcon />
</span>
Labels
</h2>
<div className="flex flex-wrap gap-2">
{card.labels.map((label: any) => (
<span
key={label.id}
className="px-3 py-1 rounded-full text-sm font-medium"
style={{ backgroundColor: label.color, color: 'white' }}
>
{label.name}
</span>
))}
</div>
</div>
)}
{/* Checklists Section */}
{card.checklists && card.checklists.length > 0 && (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4">
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
Checklists
</h2>
<div className="space-y-4">
{card.checklists.map((checklist: any) => (
<div key={checklist.id} className="border border-gray-700 rounded-lg p-4">
<h3 className="font-semibold text-white mb-3">{checklist.name}</h3>
<div className="space-y-2">
{checklist.items && checklist.items.length > 0 ? (
checklist.items.map((item: any) => (
<div
key={item.id}
className="flex items-center gap-3 p-2 bg-gray-700 rounded"
>
<input
type="checkbox"
checked={item.state === 'complete'}
readOnly
className="w-5 h-5 rounded"
/>
<span
className={`text-sm ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
>
{item.name}
</span>
</div>
))
) : (
<p className="text-gray-400 text-sm">No items yet</p>
)}
</div>
</div>
))}
</div>
</div>
)}
<CardComments
card={card}
addComment={addComment}
editComment={editComment}
deleteCommentWithConfirmation={deleteCommentWithConfirmation}
openModal={openModal}
/>
</div>
<CardSidebar card={card} />
</div>
</div>
);
}