Files
TeamFlow--Trello-Like-/client/src/components/CardModal.jsx
admin 460f83aef8 🌊 TeamFlow — Modern Trello alternative with email integration
- Full-stack: React 18 + Express + SQLite
- Drag-and-drop kanban boards with @hello-pangea/dnd
- Google App Password email integration (SMTP + IMAP)
- Inbound email: create cards by sending emails
- Reply-to-card: email replies become comments
- Admin/user management with role-based access
- Setup wizard: email config → admin creation
- Checklists, time tracking, priorities, labels, due dates
- Real-time notifications with activity feed
- Beautiful HTML email templates
2026-04-03 15:11:27 +00:00

302 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { api } from '../api';
export default function CardModal({ cardId, boardId, onClose }) {
const [card, setCard] = useState(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null);
const [newComment, setNewComment] = useState('');
const [showLabelPicker, setShowLabelPicker] = useState(false);
const [newChecklist, setNewChecklist] = useState('');
const modalRef = useRef(null);
const commentRef = useRef(null);
useEffect(() => {
loadCard();
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [cardId]);
const loadCard = async () => {
try {
const data = await api.cards.get(cardId);
setCard(data);
} catch {}
setLoading(false);
};
const updateCard = async (updates) => {
try {
await api.cards.update(cardId, updates);
loadCard();
} catch (err) { alert(err.message); }
};
const addComment = async (e) => {
e.preventDefault();
if (!newComment.trim()) return;
try {
await api.cards.addComment(cardId, newComment);
setNewComment('');
loadCard();
} catch (err) { alert(err.message); }
};
const toggleLabel = async (labelId) => {
const current = card.labels.map(l => l.id);
const next = current.includes(labelId) ? current.filter(id => id !== labelId) : [...current, labelId];
try {
await api.cards.updateCardLabels(cardId, next);
loadCard();
} catch {}
};
const createChecklist = async (e) => {
e.preventDefault();
if (!newChecklist.trim()) return;
try {
await api.cards.createChecklist(cardId, newChecklist);
setNewChecklist('');
loadCard();
} catch {}
};
const addChecklistItem = async (checklistId, text) => {
if (!text.trim()) return;
try {
await api.cards.addChecklistItem(checklistId, text);
loadCard();
} catch {}
};
const toggleCheckItem = async (itemId, checked) => {
try {
await api.cards.updateChecklistItem(itemId, { is_checked: !checked });
loadCard();
} catch {}
};
const priorityOptions = ['none', 'low', 'medium', 'high', 'urgent'];
const priorityColors = { none: 'gray', low: 'green', medium: 'yellow', high: 'orange', urgent: 'red' };
if (loading) return null;
if (!card) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-start justify-center z-50 pt-16 px-4" onClick={onClose}>
<div ref={modalRef} onClick={e => e.stopPropagation()} className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className={`px-6 py-4 border-b flex items-center justify-between ${card.color ? '' : 'bg-gray-50'}`} style={card.color ? { backgroundColor: card.color + '15' } : {}}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<span className="text-xs bg-gray-200 text-gray-600 px-2 py-0.5 rounded-full">#{card.id}</span>
{card.priority !== 'none' && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium bg-${priorityColors[card.priority]}-100 text-${priorityColors[card.priority]}-700`}>
{card.priority.toUpperCase()}
</span>
)}
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1"></button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Title */}
<input
type="text"
value={editing === 'title' ? editing : card.title}
onChange={e => setCard({ ...card, title: e.target.value })}
onBlur={() => updateCard({ title: card.title })}
className="w-full text-xl font-bold text-gray-900 border-0 p-0 focus:ring-0 mb-3"
/>
{/* Meta row */}
<div className="flex flex-wrap gap-2 mb-4">
{/* Assignee */}
<div className="relative">
<select
value={card.assigned_to || ''}
onChange={e => updateCard({ assigned_to: e.target.value ? parseInt(e.target.value) : null })}
className="text-sm bg-gray-100 border-0 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-brand-500 cursor-pointer"
>
<option value="">👤 Unassigned</option>
{card._members?.map?.(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
{/* Due Date */}
<input
type="date"
value={card.due_date ? card.due_date.split('T')[0] : ''}
onChange={e => updateCard({ due_date: e.target.value || null })}
className="text-sm bg-gray-100 border-0 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-brand-500"
/>
{/* Priority */}
<select
value={card.priority}
onChange={e => updateCard({ priority: e.target.value })}
className="text-sm bg-gray-100 border-0 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-brand-500 cursor-pointer"
>
{priorityOptions.map(p => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
</select>
{/* Labels */}
<div className="relative">
<button onClick={() => setShowLabelPicker(!showLabelPicker)} className="text-sm bg-gray-100 rounded-lg px-3 py-1.5 hover:bg-gray-200 flex items-center gap-1">
🏷 Labels {card.labels?.length > 0 && `(${card.labels.length})`}
</button>
{showLabelPicker && (
<div className="absolute top-full mt-1 left-0 bg-white rounded-xl shadow-xl border border-gray-200 p-3 w-56 z-10">
{card._labels?.map?.(label => (
<button key={label.id} onClick={() => toggleLabel(label.id)} className="flex items-center gap-2 w-full text-left px-2 py-1.5 rounded hover:bg-gray-50 text-sm">
<span className={`w-4 h-4 rounded ${card.labels.find(l => l.id === label.id) ? 'ring-2 ring-brand-500 ring-offset-1' : ''}`} style={{ backgroundColor: label.color }} />
{label.name}
</button>
)) || <p className="text-sm text-gray-400">No labels on this board</p>}
</div>
)}
</div>
</div>
{/* Labels display */}
{card.labels?.length > 0 && (
<div className="flex gap-1.5 mb-4 flex-wrap">
{card.labels.map(l => (
<span key={l.id} className="text-xs px-2.5 py-1 rounded-full text-white font-medium" style={{ backgroundColor: l.color }}>{l.name}</span>
))}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Main content */}
<div className="md:col-span-2 space-y-5">
{/* Description */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">📝 Description</h4>
<textarea
value={card.description || ''}
onChange={e => setCard({ ...card, description: e.target.value })}
onBlur={() => updateCard({ description: card.description })}
placeholder="Add a description..."
rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-brand-500 focus:border-transparent resize-none"
/>
</div>
{/* Checklists */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1"> Checklists</h4>
{card.checklists?.map(cl => (
<div key={cl.id} className="mb-3 bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">{cl.title}</span>
<span className="text-xs text-gray-500">{cl.done_items}/{cl.total_items}</span>
</div>
<div className="space-y-1">
{(card.checklistItems?.[cl.id] || []).map(item => (
<label key={item.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-100 rounded px-1 py-0.5">
<input type="checkbox" checked={!!item.is_checked} onChange={() => toggleCheckItem(item.id, item.is_checked)} className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" />
<span className={item.is_checked ? 'line-through text-gray-400' : 'text-gray-700'}>{item.text}</span>
</label>
))}
</div>
<ChecklistItemAdd checklistId={cl.id} onAdd={addChecklistItem} />
</div>
))}
<form onSubmit={createChecklist} className="flex gap-2">
<input type="text" value={newChecklist} onChange={e => setNewChecklist(e.target.value)} placeholder="New checklist..." className="flex-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-brand-500" />
<button type="submit" className="text-sm bg-gray-200 hover:bg-gray-300 px-3 py-1 rounded-lg font-medium">Add</button>
</form>
</div>
{/* Comments */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">💬 Comments ({card.comments?.length || 0})</h4>
{card.comments?.map(comment => (
<div key={comment.id} className="flex gap-3 mb-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-medium shrink-0" style={{ backgroundColor: comment.avatar_color || '#6366f1' }}>
{comment.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-800">{comment.name}</span>
<span className="text-xs text-gray-400">{new Date(comment.created_at).toLocaleString()}</span>
{comment.is_email_reply && <span className="text-xs bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded">📧 email</span>}
</div>
<p className="text-sm text-gray-600 mt-0.5 whitespace-pre-wrap">{comment.content}</p>
</div>
</div>
))}
<form onSubmit={addComment} className="flex gap-2 mt-3">
<input ref={commentRef} type="text" value={newComment} onChange={e => setNewComment(e.target.value)} placeholder="Write a comment..." className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
<button type="submit" className="bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-700">Send</button>
</form>
</div>
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Time Tracking */}
<div className="bg-gray-50 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 mb-2"> Time</h4>
<div className="space-y-2">
<div>
<label className="text-xs text-gray-500">Estimated (hrs)</label>
<input type="number" step="0.5" min="0" value={card.estimated_hours || ''} onChange={e => updateCard({ estimated_hours: e.target.value ? parseFloat(e.target.value) : null })} className="w-full mt-0.5 px-2 py-1 text-sm border border-gray-200 rounded focus:ring-1 focus:ring-brand-500" />
</div>
<div>
<label className="text-xs text-gray-500">Spent (hrs)</label>
<input type="number" step="0.5" min="0" value={card.time_spent || ''} onChange={e => updateCard({ time_spent: e.target.value ? parseFloat(e.target.value) : null })} className="w-full mt-0.5 px-2 py-1 text-sm border border-gray-200 rounded focus:ring-1 focus:ring-brand-500" />
</div>
</div>
</div>
{/* Activity */}
<div className="bg-gray-50 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 mb-2">📜 Activity</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{card.activity?.map(a => (
<div key={a.id} className="flex items-start gap-2 text-xs">
<div className="w-5 h-5 rounded-full flex items-center justify-center text-white shrink-0" style={{ backgroundColor: a.avatar_color || '#94a3b8' }}>
{a.name?.charAt(0)?.toUpperCase()}
</div>
<div>
<span className="text-gray-700"><strong>{a.name}</strong> {a.action}</span>
<p className="text-gray-400 mt-0.5">{new Date(a.created_at).toLocaleString()}</p>
</div>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="space-y-2">
<button onClick={() => updateCard({ color: card.color ? '' : '#fef3c7' })} className="w-full text-left text-sm text-gray-600 hover:bg-gray-100 px-3 py-2 rounded-lg transition-colors">
🎨 {card.color ? 'Remove color' : 'Set color'}
</button>
<button onClick={async () => { if (confirm('Delete this card?')) { await api.cards.delete(cardId); onClose(); } }} className="w-full text-left text-sm text-red-500 hover:bg-red-50 px-3 py-2 rounded-lg transition-colors">
🗑 Delete card
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function ChecklistItemAdd({ checklistId, onAdd }) {
const [text, setText] = useState('');
const [show, setShow] = useState(false);
if (!show) return <button onClick={() => setShow(true)} className="text-xs text-gray-400 hover:text-gray-600 mt-1">+ Add item</button>;
return (
<form onSubmit={(e) => { e.preventDefault(); onAdd(checklistId, text); setText(''); setShow(false); }} className="flex gap-1 mt-1">
<input type="text" value={text} onChange={e => setText(e.target.value)} placeholder="Item..." autoFocus className="flex-1 px-2 py-0.5 text-xs border border-gray-200 rounded focus:ring-1 focus:ring-brand-500" />
<button type="submit" className="text-xs text-brand-600 font-medium">Add</button>
</form>
);
}