PendingUploadsPanel.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import { useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Loader2, Archive, Trash2, FileBox, Clock, Upload, ChevronDown, ChevronUp } from 'lucide-react';
  4. import { pendingUploadsApi } from '../api/client';
  5. import type { PendingUpload, ProjectListItem } from '../api/client';
  6. import { api } from '../api/client';
  7. import { Card, CardContent, CardHeader } from './Card';
  8. import { Button } from './Button';
  9. import { useToast } from '../contexts/ToastContext';
  10. import { ConfirmModal } from './ConfirmModal';
  11. import { formatFileSize } from '../utils/file';
  12. import { assignableProjects } from '../utils/projectTree';
  13. function formatTimeAgo(dateStr: string): string {
  14. const date = new Date(dateStr);
  15. const now = new Date();
  16. const diffMs = now.getTime() - date.getTime();
  17. const diffMins = Math.floor(diffMs / 60000);
  18. if (diffMins < 1) return 'Just now';
  19. if (diffMins < 60) return `${diffMins}m ago`;
  20. const diffHours = Math.floor(diffMins / 60);
  21. if (diffHours < 24) return `${diffHours}h ago`;
  22. const diffDays = Math.floor(diffHours / 24);
  23. return `${diffDays}d ago`;
  24. }
  25. interface PendingUploadItemProps {
  26. upload: PendingUpload;
  27. projects: ProjectListItem[];
  28. onArchive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) => void;
  29. onDiscard: (id: number) => void;
  30. isArchiving: boolean;
  31. isDiscarding: boolean;
  32. }
  33. function PendingUploadItem({
  34. upload,
  35. projects,
  36. onArchive,
  37. onDiscard,
  38. isArchiving,
  39. isDiscarding,
  40. }: PendingUploadItemProps) {
  41. const [expanded, setExpanded] = useState(false);
  42. const [tags, setTags] = useState(upload.tags || '');
  43. const [notes, setNotes] = useState(upload.notes || '');
  44. const [projectId, setProjectId] = useState<number | null>(upload.project_id);
  45. const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
  46. return (
  47. <Card>
  48. <CardContent className="py-3">
  49. <div className="flex items-center justify-between">
  50. <div className="flex items-center gap-3">
  51. <FileBox className="w-8 h-8 text-bambu-green flex-shrink-0" />
  52. <div>
  53. <p className="text-white font-medium" title={upload.filename}>{upload.display_name || upload.filename}</p>
  54. <div className="flex items-center gap-2 text-xs text-bambu-gray">
  55. <span>{formatFileSize(upload.file_size)}</span>
  56. <span>·</span>
  57. <span className="flex items-center gap-1">
  58. <Clock className="w-3 h-3" />
  59. {formatTimeAgo(upload.uploaded_at)}
  60. </span>
  61. {upload.source_ip && (
  62. <>
  63. <span>·</span>
  64. <span>from {upload.source_ip}</span>
  65. </>
  66. )}
  67. </div>
  68. </div>
  69. </div>
  70. <div className="flex items-center gap-2">
  71. <button
  72. onClick={() => setExpanded(!expanded)}
  73. className="p-1 text-bambu-gray hover:text-white transition-colors"
  74. >
  75. {expanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
  76. </button>
  77. <Button
  78. variant="primary"
  79. size="sm"
  80. onClick={() => onArchive(upload.id, { tags, notes, project_id: projectId || undefined })}
  81. disabled={isArchiving}
  82. >
  83. {isArchiving ? (
  84. <Loader2 className="w-4 h-4 animate-spin" />
  85. ) : (
  86. <>
  87. <Archive className="w-4 h-4" />
  88. Archive
  89. </>
  90. )}
  91. </Button>
  92. <Button
  93. variant="secondary"
  94. size="sm"
  95. onClick={() => setShowDiscardConfirm(true)}
  96. disabled={isDiscarding}
  97. >
  98. {isDiscarding ? (
  99. <Loader2 className="w-4 h-4 animate-spin" />
  100. ) : (
  101. <Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
  102. )}
  103. </Button>
  104. </div>
  105. </div>
  106. {/* Discard Confirmation Modal */}
  107. {showDiscardConfirm && (
  108. <ConfirmModal
  109. title="Discard Upload"
  110. message={`Are you sure you want to discard "${upload.filename}"? This cannot be undone.`}
  111. confirmText="Discard"
  112. variant="danger"
  113. onConfirm={() => {
  114. onDiscard(upload.id);
  115. setShowDiscardConfirm(false);
  116. }}
  117. onCancel={() => setShowDiscardConfirm(false)}
  118. />
  119. )}
  120. {/* Expanded details for adding tags/notes/project */}
  121. {expanded && (
  122. <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3">
  123. <div>
  124. <label className="block text-sm text-bambu-gray mb-1">Tags</label>
  125. <input
  126. type="text"
  127. value={tags}
  128. onChange={(e) => setTags(e.target.value)}
  129. placeholder="e.g., functional, prototype, gift"
  130. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm"
  131. />
  132. </div>
  133. <div>
  134. <label className="block text-sm text-bambu-gray mb-1">Notes</label>
  135. <textarea
  136. value={notes}
  137. onChange={(e) => setNotes(e.target.value)}
  138. placeholder="Add notes about this print..."
  139. rows={2}
  140. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm resize-none"
  141. />
  142. </div>
  143. <div>
  144. <label className="block text-sm text-bambu-gray mb-1">Project</label>
  145. <select
  146. value={projectId || ''}
  147. onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
  148. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm"
  149. >
  150. <option value="">No project</option>
  151. {projects.map((project) => (
  152. <option key={project.id} value={project.id}>
  153. {project.name}
  154. </option>
  155. ))}
  156. </select>
  157. </div>
  158. </div>
  159. )}
  160. </CardContent>
  161. </Card>
  162. );
  163. }
  164. export function PendingUploadsPanel() {
  165. const queryClient = useQueryClient();
  166. const { showToast } = useToast();
  167. const [showArchiveAllConfirm, setShowArchiveAllConfirm] = useState(false);
  168. const [showDiscardAllConfirm, setShowDiscardAllConfirm] = useState(false);
  169. const [archivingIds, setArchivingIds] = useState<Set<number>>(new Set());
  170. const [discardingIds, setDiscardingIds] = useState<Set<number>>(new Set());
  171. // Fetch pending uploads
  172. const { data: uploads, isLoading: uploadsLoading } = useQuery({
  173. queryKey: ['pending-uploads'],
  174. queryFn: pendingUploadsApi.list,
  175. refetchInterval: 10000, // Refresh every 10 seconds
  176. });
  177. // Fetch projects for dropdown. Nothing pending is filed anywhere yet, so
  178. // there is no current project to hold on to -- archived ones just go (#2888).
  179. const { data: projects } = useQuery({
  180. queryKey: ['projects'],
  181. queryFn: () => api.getProjects(),
  182. select: (rows) => assignableProjects([...rows].sort((a, b) => a.name.localeCompare(b.name))),
  183. });
  184. // Archive mutation
  185. const archiveMutation = useMutation({
  186. mutationFn: ({ id, data }: { id: number; data?: { tags?: string; notes?: string; project_id?: number } }) =>
  187. pendingUploadsApi.archive(id, data),
  188. onMutate: ({ id }) => {
  189. setArchivingIds((prev) => new Set(prev).add(id));
  190. },
  191. onSettled: (_, __, { id }) => {
  192. setArchivingIds((prev) => {
  193. const next = new Set(prev);
  194. next.delete(id);
  195. return next;
  196. });
  197. },
  198. onSuccess: (data) => {
  199. queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
  200. queryClient.invalidateQueries({ queryKey: ['archives'] });
  201. showToast(`Archived: ${data.print_name}`);
  202. },
  203. onError: (error: Error) => {
  204. showToast(error.message || 'Failed to archive', 'error');
  205. },
  206. });
  207. // Discard mutation
  208. const discardMutation = useMutation({
  209. mutationFn: (id: number) => pendingUploadsApi.discard(id),
  210. onMutate: (id) => {
  211. setDiscardingIds((prev) => new Set(prev).add(id));
  212. },
  213. onSettled: (_, __, id) => {
  214. setDiscardingIds((prev) => {
  215. const next = new Set(prev);
  216. next.delete(id);
  217. return next;
  218. });
  219. },
  220. onSuccess: () => {
  221. queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
  222. showToast('Upload discarded');
  223. },
  224. onError: (error: Error) => {
  225. showToast(error.message || 'Failed to discard', 'error');
  226. },
  227. });
  228. // Archive all mutation
  229. const archiveAllMutation = useMutation({
  230. mutationFn: pendingUploadsApi.archiveAll,
  231. onSuccess: (data) => {
  232. queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
  233. queryClient.invalidateQueries({ queryKey: ['archives'] });
  234. showToast(`Archived ${data.archived} files${data.failed > 0 ? `, ${data.failed} failed` : ''}`);
  235. },
  236. onError: (error: Error) => {
  237. showToast(error.message || 'Failed to archive all', 'error');
  238. },
  239. });
  240. // Discard all mutation
  241. const discardAllMutation = useMutation({
  242. mutationFn: pendingUploadsApi.discardAll,
  243. onSuccess: (data) => {
  244. queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
  245. showToast(`Discarded ${data.discarded} files`);
  246. },
  247. onError: (error: Error) => {
  248. showToast(error.message || 'Failed to discard all', 'error');
  249. },
  250. });
  251. if (uploadsLoading) {
  252. return (
  253. <Card>
  254. <CardContent className="py-8 flex justify-center">
  255. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  256. </CardContent>
  257. </Card>
  258. );
  259. }
  260. if (!uploads || uploads.length === 0) {
  261. return null; // Don't render if no pending uploads
  262. }
  263. return (
  264. <div className="mb-6">
  265. <Card className="border-l-4 border-l-yellow-500">
  266. <CardHeader>
  267. <div className="flex items-center justify-between">
  268. <div className="flex items-center gap-2">
  269. <Upload className="w-5 h-5 text-yellow-500" />
  270. <h2 className="text-lg font-semibold text-white">
  271. Pending Uploads ({uploads.length})
  272. </h2>
  273. </div>
  274. <div className="flex items-center gap-2">
  275. <Button
  276. variant="primary"
  277. size="sm"
  278. onClick={() => setShowArchiveAllConfirm(true)}
  279. disabled={archiveAllMutation.isPending}
  280. >
  281. {archiveAllMutation.isPending ? (
  282. <Loader2 className="w-4 h-4 animate-spin" />
  283. ) : (
  284. <>
  285. <Archive className="w-4 h-4" />
  286. Archive All
  287. </>
  288. )}
  289. </Button>
  290. <Button
  291. variant="secondary"
  292. size="sm"
  293. onClick={() => setShowDiscardAllConfirm(true)}
  294. disabled={discardAllMutation.isPending}
  295. >
  296. {discardAllMutation.isPending ? (
  297. <Loader2 className="w-4 h-4 animate-spin" />
  298. ) : (
  299. <>
  300. <Trash2 className="w-4 h-4" />
  301. Discard All
  302. </>
  303. )}
  304. </Button>
  305. </div>
  306. </div>
  307. </CardHeader>
  308. <CardContent>
  309. <p className="text-sm text-bambu-gray mb-4">
  310. These files were uploaded via the virtual printer. Review and archive them to add to your collection.
  311. </p>
  312. <div className="space-y-3">
  313. {uploads.map((upload) => (
  314. <PendingUploadItem
  315. key={upload.id}
  316. upload={upload}
  317. projects={projects || []}
  318. onArchive={(id, data) => archiveMutation.mutate({ id, data })}
  319. onDiscard={(id) => discardMutation.mutate(id)}
  320. isArchiving={archivingIds.has(upload.id)}
  321. isDiscarding={discardingIds.has(upload.id)}
  322. />
  323. ))}
  324. </div>
  325. </CardContent>
  326. </Card>
  327. {/* Archive All Confirmation */}
  328. {showArchiveAllConfirm && (
  329. <ConfirmModal
  330. title="Archive All Uploads"
  331. message={`Are you sure you want to archive all ${uploads.length} pending uploads?`}
  332. confirmText="Archive All"
  333. onConfirm={() => {
  334. archiveAllMutation.mutate();
  335. setShowArchiveAllConfirm(false);
  336. }}
  337. onCancel={() => setShowArchiveAllConfirm(false)}
  338. />
  339. )}
  340. {/* Discard All Confirmation */}
  341. {showDiscardAllConfirm && (
  342. <ConfirmModal
  343. title="Discard All Uploads"
  344. message={`Are you sure you want to discard all ${uploads.length} pending uploads? This cannot be undone.`}
  345. confirmText="Discard All"
  346. variant="danger"
  347. onConfirm={() => {
  348. discardAllMutation.mutate();
  349. setShowDiscardAllConfirm(false);
  350. }}
  351. onCancel={() => setShowDiscardAllConfirm(false)}
  352. />
  353. )}
  354. </div>
  355. );
  356. }