improve card component
This commit is contained in:
parent
e69dd6a56d
commit
8a33e1c644
6 changed files with 279 additions and 17 deletions
|
|
@ -27,12 +27,15 @@ def get_boards():
|
|||
@load_board_owned
|
||||
def get_board(board_id, board):
|
||||
"""Get a single board with all its details"""
|
||||
from app.models import User
|
||||
|
||||
# Get all lists for this board
|
||||
lists_data = []
|
||||
for lst in board.lists.filter_by(closed=False).order_by(List.pos).all():
|
||||
cards_data = []
|
||||
for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all():
|
||||
card_dict = card.to_dict()
|
||||
|
||||
# Add labels for this card
|
||||
card_dict["labels"] = [
|
||||
label.to_dict()
|
||||
|
|
@ -43,6 +46,24 @@ def get_board(board_id, board):
|
|||
.all()
|
||||
)
|
||||
]
|
||||
|
||||
# Add comments for this card
|
||||
card_dict["comments"] = []
|
||||
for comment in card.comments.all():
|
||||
comment_dict = comment.to_dict()
|
||||
user = db.session.get(User, comment.user_id)
|
||||
comment_dict["user"] = user.to_dict() if user else None
|
||||
card_dict["comments"].append(comment_dict)
|
||||
|
||||
# Add checklists with items for this card
|
||||
card_dict["checklists"] = [
|
||||
{
|
||||
**checklist.to_dict(),
|
||||
"items": [item.to_dict() for item in checklist.check_items.all()],
|
||||
}
|
||||
for checklist in card.checklists.all()
|
||||
]
|
||||
|
||||
cards_data.append(card_dict)
|
||||
|
||||
lists_data.append({**lst.to_dict(), "cards": cards_data})
|
||||
|
|
|
|||
|
|
@ -105,3 +105,5 @@ class BoardWithDetailsResponse(BaseModel):
|
|||
date_last_activity: Optional[datetime] = None
|
||||
lists: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
labels: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
checklists: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
comments: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
|
|
|||
155
frontend/src/components/CardPreviewModal.tsx
Normal file
155
frontend/src/components/CardPreviewModal.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { CardWithDetails } from '../types/kanban';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface CardPreviewModalProps {
|
||||
card: CardWithDetails;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<Link
|
||||
to={`/boards/${card.board_id}/cards/${card.id}`}
|
||||
className="text-2xl font-bold text-blue-400 hover:text-blue-300 hover:underline transition-colors"
|
||||
>
|
||||
{card.name}
|
||||
</Link>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{card.labels && card.labels.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{card.labels.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="px-3 py-1 rounded-full text-sm font-medium text-white"
|
||||
style={{ backgroundColor: label.color }}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{card.description && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Description</h3>
|
||||
<p className="text-gray-300 whitespace-pre-wrap">{card.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checklists */}
|
||||
{card.checklists && card.checklists.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Checklists</h3>
|
||||
<div className="space-y-3">
|
||||
{card.checklists.map((checklist) => {
|
||||
const completedItems = checklist.items.filter(
|
||||
(item) => item.state === 'complete'
|
||||
).length;
|
||||
const totalItems = checklist.items.length;
|
||||
const progress = totalItems > 0 ? (completedItems / totalItems) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={checklist.id} className="bg-gray-700 rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h4 className="font-semibold text-white">{checklist.name}</h4>
|
||||
<span className="text-sm text-gray-400">
|
||||
{completedItems}/{totalItems}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-600 rounded-full overflow-hidden mb-3">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Checklist Items */}
|
||||
<div className="space-y-2">
|
||||
{checklist.items.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-2 text-sm">
|
||||
<div
|
||||
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
|
||||
item.state === 'complete'
|
||||
? 'bg-green-500 border-green-500'
|
||||
: 'border-gray-500'
|
||||
}`}
|
||||
>
|
||||
{item.state === 'complete' && (
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-gray-300 ${
|
||||
item.state === 'complete' ? 'line-through text-gray-500' : ''
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments Preview */}
|
||||
{card.comments && card.comments.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Comments</h3>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{card.comments.slice(0, 3).map((comment) => (
|
||||
<div key={comment.id} className="bg-gray-700 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold text-xs">
|
||||
{comment.user?.username.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">
|
||||
{comment.user?.username || 'Unknown'}
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs">
|
||||
{new Date(comment.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm line-clamp-3">{comment.text}</p>
|
||||
</div>
|
||||
))}
|
||||
{card.comments.length > 3 && (
|
||||
<p className="text-gray-400 text-sm text-center">
|
||||
+{card.comments.length - 3} more comments
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Card as CardType } from '../../types/kanban';
|
||||
import MessageSquareIcon from '../icons/MessageSquareIcon';
|
||||
import CheckSquareIcon from '../icons/CheckSquareIcon';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface KanbanCardProps {
|
||||
card: CardType;
|
||||
onClick: () => void;
|
||||
onOpenModal: () => void;
|
||||
}
|
||||
|
||||
export function KanbanCard({ card, onClick }: KanbanCardProps) {
|
||||
export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: `CARD_${card.id}`,
|
||||
});
|
||||
|
|
@ -18,28 +21,109 @@ export function KanbanCard({ card, onClick }: KanbanCardProps) {
|
|||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
// Calculate checklist progress
|
||||
const checklists = (card as any).checklists || [];
|
||||
const totalItems = checklists.reduce(
|
||||
(sum: number, checklist: any) => sum + checklist.items.length,
|
||||
0
|
||||
);
|
||||
const completedItems = checklists.reduce(
|
||||
(sum: number, checklist: any) =>
|
||||
sum + checklist.items.filter((item: any) => item.state === 'complete').length,
|
||||
0
|
||||
);
|
||||
const hasChecklists = totalItems > 0;
|
||||
|
||||
// Calculate comment count
|
||||
const comments = (card as any).comments || [];
|
||||
const commentCount = comments.length;
|
||||
const hasComments = commentCount > 0;
|
||||
|
||||
// Get labels
|
||||
const labels = (card as any).labels || [];
|
||||
const hasLabels = labels.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={onClick}
|
||||
onClick={(e) => {
|
||||
// Only open modal if not clicking title link
|
||||
if (!(e.target as HTMLElement).closest('a')) {
|
||||
onOpenModal();
|
||||
}
|
||||
}}
|
||||
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>
|
||||
{/* Labels */}
|
||||
{hasLabels && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{labels.slice(0, 3).map((label: any) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="px-2 py-0.5 rounded text-xs font-medium text-white"
|
||||
style={{ backgroundColor: label.color }}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{labels.length > 3 && (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium text-gray-400">
|
||||
+{labels.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>#{card.id_short}</span>
|
||||
<h3 className="text-white font-medium mb-2 hover:text-blue-400 transition-colors">
|
||||
<Link
|
||||
to={`/boards/${card.board_id}/cards/${card.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:underline"
|
||||
>
|
||||
{card.name}
|
||||
</Link>
|
||||
</h3>
|
||||
|
||||
{card.description && (
|
||||
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{card.description}</p>
|
||||
)}
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400 mt-3 pt-2 border-t border-gray-600">
|
||||
{/* Comments Badge */}
|
||||
{hasComments && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-3.5 h-3.5">
|
||||
<MessageSquareIcon />
|
||||
</span>
|
||||
<span>{commentCount}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checklist Badge */}
|
||||
{hasChecklists && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-3.5 h-3.5">
|
||||
<CheckSquareIcon />
|
||||
</span>
|
||||
<span>
|
||||
{completedItems}/{totalItems}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Due Date Badge */}
|
||||
{card.due && (
|
||||
<span className={card.due_complete ? 'text-green-400' : 'text-yellow-400'}>
|
||||
Due: {new Date(card.due).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Card ID */}
|
||||
<span className="text-gray-500">#{card.id_short}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import { useModal } from '../../context/modals/useModal';
|
|||
interface KanbanColumnProps {
|
||||
list: ListWithCards;
|
||||
cards: CardType[];
|
||||
onCardClick: (card: CardType) => void;
|
||||
onOpenCardModal: (card: CardType) => void;
|
||||
onCardCreate: (data: { name: string; description?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export function KanbanColumn({ list, cards, onCardClick, onCardCreate }: KanbanColumnProps) {
|
||||
export function KanbanColumn({ list, cards, onOpenCardModal, onCardCreate }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `LIST_${list.id}`,
|
||||
});
|
||||
|
|
@ -42,7 +42,7 @@ export function KanbanColumn({ list, cards, onCardClick, onCardCreate }: KanbanC
|
|||
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''}`}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<KanbanCard key={card.id} card={card} onClick={() => onCardClick(card)} />
|
||||
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useParams, Link } 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 { CreateListModal } from '../components/kanban/CreateListModal';
|
||||
import { CardPreviewModal } from '../components/CardPreviewModal';
|
||||
import { useModal } from '../context/modals/useModal';
|
||||
import {
|
||||
DndContext,
|
||||
|
|
@ -21,7 +22,6 @@ import { useState } from 'react';
|
|||
|
||||
export function BoardDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { board, fetchBoard } = useBoard(parseInt(id || '0'));
|
||||
const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard);
|
||||
const { createList } = useListMutations(parseInt(id || '0'), fetchBoard);
|
||||
|
|
@ -160,8 +160,8 @@ export function BoardDetail() {
|
|||
await moveCard(activeCard, activeList.id, overListContainingCard.id, overCardIndex);
|
||||
};
|
||||
|
||||
const handleCardClick = (card: CardType) => {
|
||||
navigate(`/boards/${id}/cards/${card.id}`);
|
||||
const handleOpenCardModal = (card: CardType) => {
|
||||
openModal((props) => <CardPreviewModal card={card as any} onClose={props.onClose} />);
|
||||
};
|
||||
|
||||
const handleAddList = () => {
|
||||
|
|
@ -229,7 +229,7 @@ export function BoardDetail() {
|
|||
key={list.id}
|
||||
list={list}
|
||||
cards={list.cards}
|
||||
onCardClick={handleCardClick}
|
||||
onOpenCardModal={handleOpenCardModal}
|
||||
onCardCreate={handleAddCard(list.id)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in a new issue