🌊 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
This commit is contained in:
admin
2026-04-03 15:11:27 +00:00
Unverified
commit 460f83aef8
40 changed files with 8512 additions and 0 deletions

View File

@@ -0,0 +1,301 @@
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>
);
}