PendingUploadsPanel.tsx 13 KB

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