this repo has no description
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}