PendingUploadsPanel.tsx 13 KB

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