- 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
302 lines
15 KiB
JavaScript
302 lines
15 KiB
JavaScript
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>
|
||
);
|
||
}
|