ArchiveMediaDownloadModal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import { useEffect, useMemo, useRef, useState } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { CheckSquare, Download, Film, Loader2, Square, Video, X } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import { useToast } from '../contexts/ToastContext';
  7. import { formatFileSize } from '../utils/file';
  8. import { Button } from './Button';
  9. interface ArchiveMediaDownloadModalProps {
  10. archiveId: number;
  11. archiveName: string;
  12. printerName: string;
  13. onClose: () => void;
  14. }
  15. export function ArchiveMediaDownloadModal({
  16. archiveId,
  17. archiveName,
  18. printerName,
  19. onClose,
  20. }: ArchiveMediaDownloadModalProps) {
  21. const { t } = useTranslation();
  22. const { showToast } = useToast();
  23. const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
  24. const [downloadStarting, setDownloadStarting] = useState(false);
  25. const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
  26. const initializedSelectionArchiveRef = useRef<number | null>(null);
  27. const downloadAbortRef = useRef<AbortController | null>(null);
  28. const mediaQuery = useQuery({
  29. queryKey: ['archive-printer-media', archiveId],
  30. queryFn: () => api.getArchivePrinterMedia(archiveId),
  31. staleTime: 60_000,
  32. });
  33. const remoteFiles = useMemo(() => mediaQuery.data?.remote_files ?? [], [mediaQuery.data]);
  34. useEffect(() => {
  35. if (!mediaQuery.data || initializedSelectionArchiveRef.current === archiveId) return;
  36. initializedSelectionArchiveRef.current = archiveId;
  37. if (remoteFiles.length === 1) {
  38. setSelectedPaths(new Set([remoteFiles[0].path]));
  39. }
  40. }, [archiveId, mediaQuery.data, remoteFiles]);
  41. useEffect(() => {
  42. const availablePaths = new Set(remoteFiles.map(file => file.path));
  43. setSelectedPaths(current => new Set([...current].filter(path => availablePaths.has(path))));
  44. }, [remoteFiles]);
  45. useEffect(() => () => downloadAbortRef.current?.abort(), []);
  46. useEffect(() => {
  47. const handleKeyDown = (event: KeyboardEvent) => {
  48. if (event.key === 'Escape') onClose();
  49. };
  50. window.addEventListener('keydown', handleKeyDown);
  51. return () => window.removeEventListener('keydown', handleKeyDown);
  52. }, [onClose]);
  53. const togglePath = (path: string) => {
  54. setSelectedPaths((current) => {
  55. const next = new Set(current);
  56. if (next.has(path)) next.delete(path);
  57. else next.add(path);
  58. return next;
  59. });
  60. };
  61. const downloadLocalTimelapse = async () => {
  62. const sourceName = mediaQuery.data?.local_timelapse?.name ?? '';
  63. const extension = sourceName.includes('.') ? `.${sourceName.split('.').pop()}` : '';
  64. try {
  65. await api.downloadArchiveTimelapse(archiveId, `${archiveName}_timelapse${extension}`);
  66. } catch (error) {
  67. showToast(t('printerFiles.downloadFailed', {
  68. error: error instanceof Error ? error.message : t('printerFiles.unknownError'),
  69. }), 'error');
  70. }
  71. };
  72. const downloadSelected = async () => {
  73. if (!mediaQuery.data?.printer_id || selectedPaths.size === 0) return;
  74. const selectedFiles = remoteFiles.filter(file => selectedPaths.has(file.path));
  75. if (selectedFiles.length === 0) return;
  76. const controller = new AbortController();
  77. downloadAbortRef.current = controller;
  78. setDownloadStarting(true);
  79. try {
  80. const result = await api.downloadPrinterFilesAsZip(
  81. mediaQuery.data.printer_id,
  82. selectedFiles.map(file => file.path),
  83. Object.fromEntries(selectedFiles.map(file => [file.path, file.size])),
  84. `${archiveName.replace(/[^a-zA-Z0-9]/g, '_')}-printer-videos.zip`,
  85. true,
  86. controller.signal,
  87. (completed, total) => setDownloadProgress({ current: completed, total }),
  88. );
  89. if (result.failed > 0) {
  90. showToast(t('printerFiles.zipPartial', {
  91. successful: result.successful,
  92. total: result.requested,
  93. }), 'warning');
  94. } else {
  95. showToast(t('printerFiles.zipStarted', { count: result.successful }));
  96. }
  97. } catch (error) {
  98. showToast(t('printerFiles.downloadFailed', {
  99. error: error instanceof Error ? error.message : t('printerFiles.unknownError'),
  100. }), 'error');
  101. } finally {
  102. if (downloadAbortRef.current === controller) downloadAbortRef.current = null;
  103. setDownloadProgress(null);
  104. setDownloadStarting(false);
  105. }
  106. };
  107. const hasMedia = !!mediaQuery.data?.local_timelapse || remoteFiles.length > 0;
  108. const warningText = (warning: string) => {
  109. if (warning === 'printer_files_forbidden') return t('printers.permission.noFiles');
  110. if (warning === 'printer_missing') return t('archives.media.printerMissing');
  111. if (warning === 'timelapse_unavailable') return t('archives.media.timelapseUnavailable');
  112. return t('archives.media.ipcamUnavailable');
  113. };
  114. const warnings = (mediaQuery.data?.warnings ?? []).map((warning) => (
  115. <p key={warning} className="text-xs text-amber-600 dark:text-amber-400">
  116. {warningText(warning)}
  117. </p>
  118. ));
  119. return (
  120. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
  121. <div
  122. className="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-bambu-dark-tertiary bg-bambu-dark-secondary"
  123. onClick={(event) => event.stopPropagation()}
  124. >
  125. <div className="flex items-center justify-between border-b border-bambu-dark-tertiary p-4">
  126. <div className="min-w-0">
  127. <h3 className="flex items-center gap-2 text-lg font-semibold text-white">
  128. <Video className="h-5 w-5 text-bambu-green" />
  129. {t('archives.media.title')}
  130. </h3>
  131. <p className="truncate text-sm text-bambu-gray">{archiveName} · {printerName}</p>
  132. </div>
  133. <button onClick={onClose} className="rounded p-1 text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white">
  134. <X className="h-5 w-5" />
  135. </button>
  136. </div>
  137. <div className="flex-1 overflow-y-auto p-4">
  138. {mediaQuery.isLoading ? (
  139. <div className="flex items-center justify-center gap-2 py-12 text-bambu-gray">
  140. <Loader2 className="h-5 w-5 animate-spin" />
  141. {t('archives.media.searching')}
  142. </div>
  143. ) : mediaQuery.isError ? (
  144. <p className="py-8 text-center text-red-500">
  145. {t('archives.media.searchFailed')}
  146. </p>
  147. ) : !hasMedia ? (
  148. <div className="space-y-3 py-8 text-center">
  149. <p className="text-bambu-gray">{t('archives.media.none')}</p>
  150. {warnings}
  151. </div>
  152. ) : (
  153. <div className="space-y-4">
  154. {mediaQuery.data?.local_timelapse && (
  155. <div className="flex items-center gap-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
  156. <Film className="h-5 w-5 shrink-0 text-bambu-green" />
  157. <div className="min-w-0 flex-1">
  158. <p className="truncate text-sm font-medium text-white">
  159. {mediaQuery.data.local_timelapse.name}
  160. </p>
  161. <p className="text-xs text-bambu-gray">
  162. {t('archives.media.attachedTimelapse')} · {formatFileSize(mediaQuery.data.local_timelapse.size)}
  163. </p>
  164. </div>
  165. <Button variant="secondary" size="sm" onClick={downloadLocalTimelapse}>
  166. <Download className="h-4 w-4" />
  167. {t('common.download')}
  168. </Button>
  169. </div>
  170. )}
  171. {remoteFiles.length > 0 && (
  172. <div>
  173. <div className="mb-2 flex items-center justify-between gap-2">
  174. <p className="text-sm text-bambu-gray">
  175. {t('archives.media.printerFiles')} ({remoteFiles.length})
  176. </p>
  177. <button
  178. className="text-xs text-bambu-green hover:text-bambu-green-light"
  179. onClick={() => setSelectedPaths(
  180. selectedPaths.size === remoteFiles.length
  181. ? new Set()
  182. : new Set(remoteFiles.map((file) => file.path)),
  183. )}
  184. >
  185. {selectedPaths.size === remoteFiles.length
  186. ? t('common.deselectAll')
  187. : t('common.selectAll')}
  188. </button>
  189. </div>
  190. <div className="space-y-1">
  191. {remoteFiles.map((file) => {
  192. const selected = selectedPaths.has(file.path);
  193. return (
  194. <button
  195. key={file.path}
  196. className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
  197. selected
  198. ? 'border-bambu-green/60 bg-bambu-green/10'
  199. : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
  200. }`}
  201. onClick={() => togglePath(file.path)}
  202. >
  203. {selected
  204. ? <CheckSquare className="h-5 w-5 shrink-0 text-bambu-green" />
  205. : <Square className="h-5 w-5 shrink-0 text-bambu-gray" />}
  206. {file.kind === 'timelapse'
  207. ? <Film className="h-5 w-5 shrink-0 text-bambu-green" />
  208. : <Video className="h-5 w-5 shrink-0 text-blue-400" />}
  209. <span className="min-w-0 flex-1 truncate text-sm text-white">{file.name}</span>
  210. <span className="shrink-0 text-xs text-bambu-gray">{formatFileSize(file.size)}</span>
  211. </button>
  212. );
  213. })}
  214. </div>
  215. </div>
  216. )}
  217. {warnings}
  218. </div>
  219. )}
  220. </div>
  221. {remoteFiles.length > 0 && (
  222. <div className="flex items-center justify-end border-t border-bambu-dark-tertiary p-4">
  223. <Button
  224. variant="primary"
  225. onClick={downloadSelected}
  226. disabled={selectedPaths.size === 0 || downloadStarting}
  227. >
  228. {downloadStarting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
  229. {downloadProgress
  230. ? `${downloadProgress.current}/${downloadProgress.total}`
  231. : `${t('archives.media.downloadSelected')} (${selectedPaths.size})`}
  232. </Button>
  233. </div>
  234. )}
  235. </div>
  236. </div>
  237. );
  238. }