this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add ProjectList component with status filtering

Features:
- Filter tabs by status (shipped, in_progress, abandoned, etc.)
- Inline status dropdown to change project status
- Stale project detection (in_progress 30+ days)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

alice e6a36adb fe94dcab

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