this repo has no description
at main 248 lines 8.1 kB view raw
1import { Archive, Beaker, ChevronDown, Clock, Construction, EyeOff, Folder, Rocket, Ship, Zap } from 'lucide-react'; 2import React, { useState } from 'react'; 3 4import type { ProjectListItem, ProjectStatus } from '../../../types'; 5import { useProjects, useUpdateProjectStatus } from '../hooks/useProjects'; 6 7const STATUS_CONFIG: Record<ProjectStatus, { icon: React.ElementType; color: string; bgColor: string; label: string }> = 8 { 9 shipped: { icon: Ship, color: 'text-green-600', bgColor: 'bg-green-50', label: 'Shipped' }, 10 in_progress: { icon: Construction, color: 'text-blue-600', bgColor: 'bg-blue-50', label: 'In Progress' }, 11 ready_to_ship: { icon: Rocket, color: 'text-teal-600', bgColor: 'bg-teal-50', label: 'Ready to Ship' }, 12 abandoned: { icon: Archive, color: 'text-gray-500', bgColor: 'bg-gray-100', label: 'Abandoned' }, 13 ignore: { icon: EyeOff, color: 'text-slate-400', bgColor: 'bg-slate-100', label: 'Ignore' }, 14 one_off: { icon: Zap, color: 'text-amber-600', bgColor: 'bg-amber-50', label: 'One-off' }, 15 experiment: { icon: Beaker, color: 'text-purple-600', bgColor: 'bg-purple-50', label: 'Experiment' }, 16 }; 17 18const ALL_STATUSES: ProjectStatus[] = [ 19 'shipped', 20 'in_progress', 21 'ready_to_ship', 22 'abandoned', 23 'ignore', 24 'one_off', 25 'experiment', 26]; 27 28function StatusBadge({ 29 status, 30 onClick, 31 showDropdown, 32 onSelect, 33 onClose, 34}: { 35 status: ProjectStatus; 36 onClick: () => void; 37 showDropdown: boolean; 38 onSelect: (status: ProjectStatus) => void; 39 onClose: () => void; 40}) { 41 const config = STATUS_CONFIG[status]; 42 const Icon = config.icon; 43 44 return ( 45 <div className="relative"> 46 <button 47 onClick={onClick} 48 className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-all hover:ring-2 hover:ring-offset-1 hover:ring-gray-300 ${config.bgColor} ${config.color}`} 49 > 50 <Icon size={14} /> 51 {config.label} 52 <ChevronDown size={12} className="opacity-50" /> 53 </button> 54 55 {showDropdown && ( 56 <> 57 <div className="fixed inset-0 z-10" onClick={onClose} /> 58 <div className="absolute right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1 min-w-[140px]"> 59 {ALL_STATUSES.map((s) => { 60 const cfg = STATUS_CONFIG[s]; 61 const ItemIcon = cfg.icon; 62 return ( 63 <button 64 key={s} 65 onClick={() => { 66 onSelect(s); 67 }} 68 className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 hover:bg-gray-50 ${ 69 s === status ? 'bg-gray-50' : '' 70 }`} 71 > 72 <ItemIcon size={14} className={cfg.color} /> 73 <span>{cfg.label}</span> 74 </button> 75 ); 76 })} 77 </div> 78 </> 79 )} 80 </div> 81 ); 82} 83 84function ProjectRow({ project, onStatusChange }: { project: ProjectListItem; onStatusChange: () => void }) { 85 const [showDropdown, setShowDropdown] = useState(false); 86 const { updateStatus } = useUpdateProjectStatus(); 87 88 const handleStatusSelect = (newStatus: ProjectStatus) => { 89 setShowDropdown(false); 90 if (newStatus !== project.status) { 91 void (async () => { 92 await updateStatus(project.path, newStatus); 93 onStatusChange(); 94 })(); 95 } 96 }; 97 98 const isStale = project.status === 'in_progress' && project.daysSinceLastSession > 30; 99 100 return ( 101 <div className="bg-white rounded-lg p-3 border border-gray-200 shadow-sm flex items-center justify-between gap-3"> 102 <div className="flex items-center gap-3 min-w-0 flex-1"> 103 <div className="p-2 bg-slate-100 text-slate-600 rounded-md flex-shrink-0"> 104 <Folder size={18} /> 105 </div> 106 <div className="min-w-0"> 107 <h3 className="text-sm font-semibold text-slate-800 truncate">{project.name}</h3> 108 <div className="flex items-center gap-2 text-xs text-slate-500"> 109 <span>{project.totalSessions} sessions</span> 110 <span className="text-slate-300">|</span> 111 <span className={`flex items-center gap-1 ${isStale ? 'text-amber-600' : ''}`}> 112 {isStale && <Clock size={12} />} 113 {project.daysSinceLastSession === 0 ? 'Today' : `${String(project.daysSinceLastSession)}d ago`} 114 </span> 115 </div> 116 </div> 117 </div> 118 119 <StatusBadge 120 status={project.status} 121 onClick={() => { 122 setShowDropdown(!showDropdown); 123 }} 124 showDropdown={showDropdown} 125 onSelect={handleStatusSelect} 126 onClose={() => { 127 setShowDropdown(false); 128 }} 129 /> 130 </div> 131 ); 132} 133 134type FilterStatus = ProjectStatus | 'all'; 135 136export default function ProjectList() { 137 const [filter, setFilter] = useState<FilterStatus>('all'); 138 const { projects: allProjects, loading, error, refetch } = useProjects(); 139 140 // Filter locally instead of via API to keep counts in sync 141 const projects = filter === 'all' ? allProjects : allProjects.filter((p) => p.status === filter); 142 143 const counts = ALL_STATUSES.reduce( 144 (acc, s) => { 145 acc[s] = allProjects.filter((p) => p.status === s).length; 146 return acc; 147 }, 148 {} as Record<ProjectStatus, number>, 149 ); 150 151 const staleCount = allProjects.filter((p) => p.status === 'in_progress' && p.daysSinceLastSession > 30).length; 152 153 if (loading && projects.length === 0) { 154 return <div className="text-center py-20 text-slate-400">Loading projects...</div>; 155 } 156 157 if (error !== null) { 158 return <div className="text-center py-20 text-red-500">Error: {error}</div>; 159 } 160 161 return ( 162 <div> 163 <div className="flex items-center justify-between mb-4"> 164 <h2 className="text-xl font-bold text-slate-800">Projects</h2> 165 <span className="text-sm text-slate-500">{allProjects.length} total</span> 166 </div> 167 168 {/* Filter tabs */} 169 <div className="flex flex-wrap gap-1 mb-4 p-1 bg-gray-100 rounded-lg"> 170 <FilterTab 171 label="All" 172 count={allProjects.length} 173 isActive={filter === 'all'} 174 onClick={() => { 175 setFilter('all'); 176 }} 177 /> 178 {ALL_STATUSES.map((s) => ( 179 <FilterTab 180 key={s} 181 label={STATUS_CONFIG[s].label} 182 count={counts[s]} 183 isActive={filter === s} 184 onClick={() => { 185 setFilter(s); 186 }} 187 /> 188 ))} 189 </div> 190 191 {/* Stale projects callout */} 192 {staleCount > 0 && 193 filter !== 'shipped' && 194 filter !== 'ready_to_ship' && 195 filter !== 'abandoned' && 196 filter !== 'ignore' && 197 filter !== 'one_off' && 198 filter !== 'experiment' && ( 199 <div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800"> 200 <strong> 201 {String(staleCount)} project{staleCount > 1 ? 's' : ''} 202 </strong>{' '} 203 marked "In Progress" but untouched for 30+ days. 204 </div> 205 )} 206 207 {projects.length === 0 ? ( 208 <div className="text-center py-12 text-slate-400">No projects with this status</div> 209 ) : ( 210 <div className="space-y-2"> 211 {projects.map((project) => ( 212 <ProjectRow 213 key={project.path} 214 project={project} 215 onStatusChange={() => { 216 void refetch(); 217 }} 218 /> 219 ))} 220 </div> 221 )} 222 </div> 223 ); 224} 225 226function FilterTab({ 227 label, 228 count, 229 isActive, 230 onClick, 231}: { 232 label: string; 233 count: number; 234 isActive: boolean; 235 onClick: () => void; 236}) { 237 return ( 238 <button 239 onClick={onClick} 240 className={`px-3 py-1.5 text-sm rounded-md transition-all ${ 241 isActive ? 'bg-white shadow text-slate-800 font-medium' : 'text-slate-600 hover:bg-gray-200' 242 }`} 243 > 244 {label} 245 {count > 0 && <span className={`ml-1.5 ${isActive ? 'text-slate-500' : 'text-slate-400'}`}>{String(count)}</span>} 246 </button> 247 ); 248}