ProjectsPage.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. import { useState } from 'react';
  2. import { useNavigate } from 'react-router-dom';
  3. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import {
  5. FolderKanban,
  6. Loader2,
  7. Plus,
  8. Trash2,
  9. Edit3,
  10. Archive,
  11. ListTodo,
  12. Package,
  13. Clock,
  14. CheckCircle2,
  15. AlertTriangle,
  16. ChevronRight,
  17. MoreVertical,
  18. } from 'lucide-react';
  19. import { api } from '../api/client';
  20. import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
  21. import { Button } from '../components/Button';
  22. import { ConfirmModal } from '../components/ConfirmModal';
  23. import { useToast } from '../contexts/ToastContext';
  24. const PROJECT_COLORS = [
  25. '#ef4444', // red
  26. '#f97316', // orange
  27. '#eab308', // yellow
  28. '#22c55e', // green
  29. '#06b6d4', // cyan
  30. '#3b82f6', // blue
  31. '#8b5cf6', // violet
  32. '#ec4899', // pink
  33. '#6b7280', // gray
  34. ];
  35. interface ProjectModalProps {
  36. project?: ProjectListItem;
  37. onClose: () => void;
  38. onSave: (data: ProjectCreate | ProjectUpdate) => void;
  39. isLoading: boolean;
  40. }
  41. export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
  42. const [name, setName] = useState(project?.name || '');
  43. const [description, setDescription] = useState(project?.description || '');
  44. const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
  45. const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
  46. const [status, setStatus] = useState(project?.status || 'active');
  47. const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
  48. const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
  49. const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
  50. const handleSubmit = (e: React.FormEvent) => {
  51. e.preventDefault();
  52. onSave({
  53. name: name.trim(),
  54. description: description.trim() || undefined,
  55. color,
  56. target_count: targetCount ? parseInt(targetCount, 10) : undefined,
  57. tags: tags.trim() || undefined,
  58. due_date: dueDate || undefined,
  59. priority,
  60. ...(project && { status }),
  61. });
  62. };
  63. return (
  64. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  65. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
  66. <div className="p-4 border-b border-bambu-dark-tertiary">
  67. <h2 className="text-lg font-semibold text-white">
  68. {project ? 'Edit Project' : 'New Project'}
  69. </h2>
  70. </div>
  71. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  72. <div>
  73. <label className="block text-sm font-medium text-white mb-1">
  74. Name
  75. </label>
  76. <input
  77. type="text"
  78. value={name}
  79. onChange={(e) => setName(e.target.value)}
  80. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  81. placeholder="e.g., Voron 2.4 Build"
  82. required
  83. />
  84. </div>
  85. <div>
  86. <label className="block text-sm font-medium text-white mb-1">
  87. Description
  88. </label>
  89. <textarea
  90. value={description}
  91. onChange={(e) => setDescription(e.target.value)}
  92. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green resize-none"
  93. placeholder="Optional description..."
  94. rows={2}
  95. />
  96. </div>
  97. <div>
  98. <label className="block text-sm font-medium text-white mb-1">
  99. Color
  100. </label>
  101. <div className="flex gap-2 flex-wrap">
  102. {PROJECT_COLORS.map((c) => (
  103. <button
  104. key={c}
  105. type="button"
  106. onClick={() => setColor(c)}
  107. className={`w-8 h-8 rounded-full transition-transform ${
  108. color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : ''
  109. }`}
  110. style={{ backgroundColor: c }}
  111. />
  112. ))}
  113. </div>
  114. </div>
  115. <div>
  116. <label className="block text-sm font-medium text-white mb-1">
  117. Target Print Count (optional)
  118. </label>
  119. <input
  120. type="number"
  121. value={targetCount}
  122. onChange={(e) => setTargetCount(e.target.value)}
  123. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  124. placeholder="e.g., 50 parts to print"
  125. min="1"
  126. />
  127. </div>
  128. {/* Tags */}
  129. <div>
  130. <label className="block text-sm font-medium text-white mb-1">
  131. Tags (comma-separated)
  132. </label>
  133. <input
  134. type="text"
  135. value={tags}
  136. onChange={(e) => setTags(e.target.value)}
  137. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  138. placeholder="e.g., voron, functional, gift"
  139. />
  140. </div>
  141. {/* Due Date and Priority in a row */}
  142. <div className="grid grid-cols-2 gap-4">
  143. <div>
  144. <label className="block text-sm font-medium text-white mb-1">
  145. Due Date
  146. </label>
  147. <input
  148. type="date"
  149. value={dueDate}
  150. onChange={(e) => setDueDate(e.target.value)}
  151. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
  152. />
  153. </div>
  154. <div>
  155. <label className="block text-sm font-medium text-white mb-1">
  156. Priority
  157. </label>
  158. <select
  159. value={priority}
  160. onChange={(e) => setPriority(e.target.value)}
  161. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
  162. >
  163. <option value="low">Low</option>
  164. <option value="normal">Normal</option>
  165. <option value="high">High</option>
  166. <option value="urgent">Urgent</option>
  167. </select>
  168. </div>
  169. </div>
  170. {project && (
  171. <div>
  172. <label className="block text-sm font-medium text-white mb-1">
  173. Status
  174. </label>
  175. <select
  176. value={status}
  177. onChange={(e) => setStatus(e.target.value)}
  178. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
  179. >
  180. <option value="active">Active</option>
  181. <option value="completed">Completed</option>
  182. <option value="archived">Archived</option>
  183. </select>
  184. </div>
  185. )}
  186. <div className="flex justify-end gap-2 pt-2">
  187. <Button type="button" variant="secondary" onClick={onClose}>
  188. Cancel
  189. </Button>
  190. <Button type="submit" disabled={!name.trim() || isLoading}>
  191. {isLoading ? (
  192. <Loader2 className="w-4 h-4 animate-spin" />
  193. ) : project ? (
  194. 'Save'
  195. ) : (
  196. 'Create'
  197. )}
  198. </Button>
  199. </div>
  200. </form>
  201. </div>
  202. </div>
  203. );
  204. }
  205. interface ProjectCardProps {
  206. project: ProjectListItem;
  207. onClick: () => void;
  208. onEdit: () => void;
  209. onDelete: () => void;
  210. }
  211. function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
  212. const progressPercent = project.progress_percent ?? 0;
  213. const isCompleted = project.status === 'completed';
  214. const isArchived = project.status === 'archived';
  215. const [showActions, setShowActions] = useState(false);
  216. // Status icon and color
  217. const getStatusConfig = () => {
  218. if (isCompleted) return { icon: CheckCircle2, color: 'text-bambu-green', bg: 'bg-bambu-green/10' };
  219. if (isArchived) return { icon: Archive, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
  220. if (project.queue_count > 0) return { icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' };
  221. return { icon: FolderKanban, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
  222. };
  223. const statusConfig = getStatusConfig();
  224. return (
  225. <div
  226. className="group relative bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary hover:border-bambu-green/50 hover:shadow-lg hover:shadow-bambu-green/5 transition-all duration-300 cursor-pointer overflow-hidden"
  227. onClick={onClick}
  228. >
  229. {/* Color accent bar with glow */}
  230. <div
  231. className="absolute top-0 left-0 w-1.5 h-full"
  232. style={{
  233. backgroundColor: project.color || '#6b7280',
  234. boxShadow: `0 0 12px ${project.color || '#6b7280'}40`
  235. }}
  236. />
  237. <div className="p-5 pl-6">
  238. {/* Header */}
  239. <div className="flex items-start justify-between mb-4">
  240. <div className="flex items-center gap-3 min-w-0 flex-1">
  241. <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
  242. <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
  243. </div>
  244. <div className="min-w-0 flex-1">
  245. <div className="flex items-center gap-2 flex-wrap">
  246. <h3 className="font-semibold text-white truncate">{project.name}</h3>
  247. {project.target_count ? (
  248. <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
  249. progressPercent >= 100
  250. ? 'bg-bambu-green/20 text-bambu-green'
  251. : 'bg-bambu-dark text-bambu-gray'
  252. }`}>
  253. {project.total_items}/{project.target_count} items
  254. </span>
  255. ) : project.total_items > 0 ? (
  256. <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
  257. {project.total_items} item{project.total_items !== 1 ? 's' : ''}
  258. </span>
  259. ) : null}
  260. {isCompleted && (
  261. <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
  262. Done
  263. </span>
  264. )}
  265. {isArchived && (
  266. <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
  267. Archived
  268. </span>
  269. )}
  270. </div>
  271. {project.description && (
  272. <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
  273. {project.description}
  274. </p>
  275. )}
  276. {/* Filament materials/colors */}
  277. {project.archives && project.archives.length > 0 && (() => {
  278. // Flatten comma-separated materials and deduplicate
  279. const allMaterials = project.archives
  280. .map(a => a.filament_type)
  281. .filter(Boolean)
  282. .flatMap(m => (m as string).split(',').map(s => s.trim()))
  283. .filter(Boolean);
  284. const materials = [...new Set(allMaterials)];
  285. // Flatten comma-separated colors and deduplicate
  286. const allColors = project.archives
  287. .map(a => a.filament_color)
  288. .filter(Boolean)
  289. .flatMap(c => (c as string).split(',').map(s => s.trim()))
  290. .filter(c => c.startsWith('#') || /^[0-9A-Fa-f]{6}$/.test(c));
  291. const colors = [...new Set(allColors)];
  292. if (materials.length === 0 && colors.length === 0) return null;
  293. return (
  294. <div className="flex items-center gap-2 mt-1.5">
  295. {/* Material types as text badges */}
  296. {materials.slice(0, 3).map((mat) => (
  297. <span key={mat} className="text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded">
  298. {mat}
  299. </span>
  300. ))}
  301. {/* Colors as swatches */}
  302. {colors.length > 0 && (
  303. <div className="flex items-center gap-0.5">
  304. {colors.slice(0, 5).map((col) => (
  305. <div
  306. key={col}
  307. className="w-3 h-3 rounded-full border border-white/20"
  308. style={{ backgroundColor: col.startsWith('#') ? col : `#${col}` }}
  309. title={col}
  310. />
  311. ))}
  312. {colors.length > 5 && (
  313. <span className="text-[10px] text-bambu-gray ml-0.5">+{colors.length - 5}</span>
  314. )}
  315. </div>
  316. )}
  317. </div>
  318. );
  319. })()}
  320. </div>
  321. </div>
  322. {/* Actions menu */}
  323. <div className="relative" onClick={(e) => e.stopPropagation()}>
  324. <button
  325. className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100"
  326. onClick={() => setShowActions(!showActions)}
  327. >
  328. <MoreVertical className="w-4 h-4" />
  329. </button>
  330. {showActions && (
  331. <>
  332. <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
  333. <div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
  334. <button
  335. className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
  336. onClick={() => { onEdit(); setShowActions(false); }}
  337. >
  338. <Edit3 className="w-4 h-4" />
  339. Edit
  340. </button>
  341. <button
  342. className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
  343. onClick={() => { onDelete(); setShowActions(false); }}
  344. >
  345. <Trash2 className="w-4 h-4" />
  346. Delete
  347. </button>
  348. </div>
  349. </>
  350. )}
  351. </div>
  352. </div>
  353. {/* Progress section - show for all projects */}
  354. <div className="mb-4">
  355. {project.target_count ? (
  356. <>
  357. <div className="flex items-center justify-between text-xs mb-2">
  358. <span className="text-bambu-gray">Progress</span>
  359. <span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
  360. {project.total_items} / {project.target_count}
  361. </span>
  362. </div>
  363. <div className="h-2.5 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
  364. <div
  365. className="h-full transition-all duration-500 ease-out rounded-full relative"
  366. style={{
  367. width: `${Math.min(progressPercent, 100)}%`,
  368. background: progressPercent >= 100
  369. ? 'linear-gradient(90deg, #22c55e, #4ade80)'
  370. : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
  371. boxShadow: `0 0 8px ${progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
  372. }}
  373. />
  374. </div>
  375. <div className="text-right text-xs text-bambu-gray/60 mt-1">
  376. {progressPercent.toFixed(0)}% complete
  377. </div>
  378. </>
  379. ) : project.total_items > 0 ? (
  380. <div className="flex items-center gap-4 text-xs">
  381. <div className="flex items-center gap-1.5 text-bambu-gray">
  382. <Archive className="w-3.5 h-3.5" />
  383. <span>{project.total_items} item{project.total_items !== 1 ? 's' : ''} completed</span>
  384. </div>
  385. {project.queue_count > 0 && (
  386. <div className="flex items-center gap-1.5 text-blue-400">
  387. <Clock className="w-3.5 h-3.5" />
  388. <span>{project.queue_count} in queue</span>
  389. </div>
  390. )}
  391. </div>
  392. ) : (
  393. <div className="text-xs text-bambu-gray/60 italic">
  394. No prints yet
  395. </div>
  396. )}
  397. </div>
  398. {/* Archive thumbnails - compact 4-column grid */}
  399. {project.archives && project.archives.length > 0 && (
  400. <div className="mb-4">
  401. <div className="grid grid-cols-4 gap-1.5">
  402. {project.archives.slice(0, 4).map((archive) => (
  403. <div
  404. key={archive.id}
  405. className="relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary"
  406. title={archive.print_name || 'Unknown'}
  407. >
  408. {archive.thumbnail_path ? (
  409. <img
  410. src={api.getArchiveThumbnail(archive.id)}
  411. alt={archive.print_name || ''}
  412. className="w-full h-full object-cover"
  413. />
  414. ) : (
  415. <div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
  416. <Package className="w-6 h-6" />
  417. </div>
  418. )}
  419. {archive.status === 'failed' && (
  420. <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
  421. <AlertTriangle className="w-4 h-4 text-white" />
  422. </div>
  423. )}
  424. </div>
  425. ))}
  426. </div>
  427. {project.archive_count > 4 && (
  428. <p className="text-xs text-bambu-gray mt-1.5 text-center">
  429. +{project.archive_count - 4} more
  430. </p>
  431. )}
  432. </div>
  433. )}
  434. {/* Stats footer */}
  435. <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
  436. <div className="flex items-center gap-4 text-xs text-bambu-gray">
  437. <div className="flex items-center gap-1.5" title="Total items printed">
  438. <Archive className="w-3.5 h-3.5" />
  439. <span>{project.total_items}</span>
  440. </div>
  441. {project.queue_count > 0 && (
  442. <div className="flex items-center gap-1.5 text-blue-400" title="In queue">
  443. <ListTodo className="w-3.5 h-3.5" />
  444. <span>{project.queue_count}</span>
  445. </div>
  446. )}
  447. </div>
  448. <ChevronRight className="w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors" />
  449. </div>
  450. </div>
  451. </div>
  452. );
  453. }
  454. export function ProjectsPage() {
  455. const navigate = useNavigate();
  456. const queryClient = useQueryClient();
  457. const { showToast } = useToast();
  458. const [showModal, setShowModal] = useState(false);
  459. const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
  460. const [statusFilter, setStatusFilter] = useState<string>('active');
  461. const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
  462. const { data: projects, isLoading } = useQuery({
  463. queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
  464. queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
  465. });
  466. const createMutation = useMutation({
  467. mutationFn: (data: ProjectCreate) => api.createProject(data),
  468. onSuccess: () => {
  469. queryClient.invalidateQueries({ queryKey: ['projects'] });
  470. setShowModal(false);
  471. showToast('Project created', 'success');
  472. },
  473. onError: (error: Error) => {
  474. showToast(error.message, 'error');
  475. },
  476. });
  477. const updateMutation = useMutation({
  478. mutationFn: ({ id, data }: { id: number; data: ProjectUpdate }) =>
  479. api.updateProject(id, data),
  480. onSuccess: () => {
  481. queryClient.invalidateQueries({ queryKey: ['projects'] });
  482. setShowModal(false);
  483. setEditingProject(undefined);
  484. showToast('Project updated', 'success');
  485. },
  486. onError: (error: Error) => {
  487. showToast(error.message, 'error');
  488. },
  489. });
  490. const deleteMutation = useMutation({
  491. mutationFn: (id: number) => api.deleteProject(id),
  492. onSuccess: () => {
  493. setDeleteConfirm(null);
  494. showToast('Project deleted', 'success');
  495. // Reload to refresh the list (React Query cache invalidation not working reliably)
  496. setTimeout(() => window.location.reload(), 100);
  497. },
  498. onError: (error: Error) => {
  499. setDeleteConfirm(null);
  500. showToast(error.message, 'error');
  501. },
  502. });
  503. const handleSave = (data: ProjectCreate | ProjectUpdate) => {
  504. if (editingProject) {
  505. updateMutation.mutate({ id: editingProject.id, data });
  506. } else {
  507. createMutation.mutate(data as ProjectCreate);
  508. }
  509. };
  510. const handleEdit = (project: ProjectListItem) => {
  511. setEditingProject(project);
  512. setShowModal(true);
  513. };
  514. const handleClick = (project: ProjectListItem) => {
  515. // Navigate to project detail page
  516. navigate(`/projects/${project.id}`);
  517. };
  518. const handleDeleteClick = (id: number) => {
  519. setDeleteConfirm(id);
  520. };
  521. const handleDeleteConfirm = () => {
  522. if (deleteConfirm !== null) {
  523. deleteMutation.mutate(deleteConfirm);
  524. }
  525. };
  526. // Count projects by status for filter badges
  527. const projectCounts = projects?.reduce((acc, p) => {
  528. acc[p.status] = (acc[p.status] || 0) + 1;
  529. acc.all = (acc.all || 0) + 1;
  530. return acc;
  531. }, {} as Record<string, number>) || {};
  532. return (
  533. <div className="p-4 md:p-8 space-y-8">
  534. {/* Header */}
  535. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
  536. <div>
  537. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  538. <div className="p-2.5 bg-bambu-green/10 rounded-xl">
  539. <FolderKanban className="w-6 h-6 text-bambu-green" />
  540. </div>
  541. Projects
  542. </h1>
  543. <p className="text-sm text-bambu-gray mt-2 ml-14">
  544. Organize and track your 3D printing projects
  545. </p>
  546. </div>
  547. <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
  548. <Plus className="w-4 h-4 mr-2" />
  549. New Project
  550. </Button>
  551. </div>
  552. {/* Filter tabs */}
  553. <div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
  554. {[
  555. { key: 'active', label: 'Active', icon: Clock },
  556. { key: 'completed', label: 'Completed', icon: CheckCircle2 },
  557. { key: 'archived', label: 'Archived', icon: Archive },
  558. { key: 'all', label: 'All', icon: FolderKanban },
  559. ].map(({ key, label, icon: Icon }) => (
  560. <button
  561. key={key}
  562. onClick={() => setStatusFilter(key)}
  563. className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
  564. statusFilter === key
  565. ? 'bg-bambu-card text-white shadow-sm'
  566. : 'text-bambu-gray hover:text-white'
  567. }`}
  568. >
  569. <Icon className="w-4 h-4" />
  570. <span>{label}</span>
  571. {projectCounts[key] > 0 && (
  572. <span className={`text-xs px-1.5 py-0.5 rounded-full ${
  573. statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
  574. }`}>
  575. {projectCounts[key]}
  576. </span>
  577. )}
  578. </button>
  579. ))}
  580. </div>
  581. {/* Content */}
  582. {isLoading ? (
  583. <div className="flex items-center justify-center py-20">
  584. <div className="flex flex-col items-center gap-3">
  585. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  586. <p className="text-sm text-bambu-gray">Loading projects...</p>
  587. </div>
  588. </div>
  589. ) : projects?.length === 0 ? (
  590. <div className="flex flex-col items-center justify-center py-20 px-4">
  591. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  592. <FolderKanban className="w-12 h-12 text-bambu-gray/50" />
  593. </div>
  594. <h3 className="text-lg font-medium text-white mb-2">
  595. {statusFilter === 'all' ? 'No projects yet' : `No ${statusFilter} projects`}
  596. </h3>
  597. <p className="text-bambu-gray text-center max-w-md mb-6">
  598. {statusFilter === 'all'
  599. ? 'Create your first project to start organizing related prints, tracking progress, and managing your builds.'
  600. : `You don't have any ${statusFilter} projects. Projects will appear here when their status changes.`
  601. }
  602. </p>
  603. {statusFilter === 'all' && (
  604. <Button onClick={() => setShowModal(true)}>
  605. <Plus className="w-4 h-4 mr-2" />
  606. Create Your First Project
  607. </Button>
  608. )}
  609. </div>
  610. ) : (
  611. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
  612. {projects?.map((project) => (
  613. <ProjectCard
  614. key={project.id}
  615. project={project}
  616. onClick={() => handleClick(project)}
  617. onEdit={() => handleEdit(project)}
  618. onDelete={() => handleDeleteClick(project.id)}
  619. />
  620. ))}
  621. </div>
  622. )}
  623. {/* Delete Confirmation Modal */}
  624. {deleteConfirm !== null && (
  625. <ConfirmModal
  626. title="Delete Project"
  627. message="Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted."
  628. confirmText="Delete Project"
  629. variant="danger"
  630. onConfirm={handleDeleteConfirm}
  631. onCancel={() => setDeleteConfirm(null)}
  632. />
  633. )}
  634. {/* Modal */}
  635. {showModal && (
  636. <ProjectModal
  637. project={editingProject}
  638. onClose={() => {
  639. setShowModal(false);
  640. setEditingProject(undefined);
  641. }}
  642. onSave={handleSave}
  643. isLoading={createMutation.isPending || updateMutation.isPending}
  644. />
  645. )}
  646. </div>
  647. );
  648. }