FileList.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780
  1. "use client";
  2. import {
  3. ChevronDownIcon,
  4. ChevronRightIcon,
  5. TrashIcon
  6. } from "@heroicons/react/24/outline";
  7. import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
  8. import { useEffect, useMemo, useState } from "react";
  9. import toast from "react-hot-toast";
  10. import { del, get, post } from "../../lib/api";
  11. import ConfirmationDialog from "../components/ConfirmationDialog";
  12. import FileCrud from "../components/FileCrud";
  13. import LoadingCard from "../components/Loading";
  14. import { useNotifications } from "../components/NotificationContext";
  15. import Pagination from "../components/Pagination";
  16. export default function FileList() {
  17. const queryClient = useQueryClient();
  18. const { addNotification } = useNotifications();
  19. // Get available datasets
  20. const { data: datasets } = useQuery({
  21. queryKey: ["datasets"],
  22. queryFn: () => get(`/files/all-datasets`)
  23. });
  24. // Get files from all datasets
  25. const {
  26. data: allFiles,
  27. isLoading,
  28. error
  29. } = useQuery({
  30. queryKey: ["all-files"],
  31. queryFn: async () => {
  32. if (!datasets) return [];
  33. const allFilesPromises = datasets.map((datasetName: string) =>
  34. get(`/files/${datasetName}`).catch(() => [])
  35. );
  36. const results = await Promise.all(allFilesPromises);
  37. return results.flat().map((file: any) => ({
  38. ...file
  39. }));
  40. },
  41. enabled: !!datasets
  42. });
  43. // State for filters and search - initialize from localStorage
  44. const [enabledDatasets, setEnabledDatasets] = useState<Set<string>>(() => {
  45. if (typeof window !== "undefined") {
  46. const saved = localStorage.getItem("fileList:enabledDatasets");
  47. return saved ? new Set(JSON.parse(saved)) : new Set();
  48. }
  49. return new Set();
  50. });
  51. const [searchTerm, setSearchTerm] = useState(() => {
  52. if (typeof window !== "undefined") {
  53. return localStorage.getItem("fileList:searchTerm") || "";
  54. }
  55. return "";
  56. });
  57. const [sortField, setSortField] = useState<"input" | "date">(() => {
  58. if (typeof window !== "undefined") {
  59. const saved = localStorage.getItem("fileList:sortField");
  60. return (saved as "input" | "date") || "date";
  61. }
  62. return "date";
  63. });
  64. const [sortDirection, setSortDirection] = useState<"asc" | "desc">(() => {
  65. if (typeof window !== "undefined") {
  66. const saved = localStorage.getItem("fileList:sortDirection");
  67. return (saved as "asc" | "desc") || "desc";
  68. }
  69. return "desc";
  70. });
  71. // Pagination state
  72. const [currentPage, setCurrentPage] = useState(1);
  73. const [pageSize, setPageSize] = useState(() => {
  74. if (typeof window !== "undefined") {
  75. const saved = localStorage.getItem("fileList:pageSize");
  76. return saved ? parseInt(saved) : 25;
  77. }
  78. return 25;
  79. });
  80. // Save filters to localStorage
  81. useEffect(() => {
  82. if (typeof window !== "undefined") {
  83. localStorage.setItem(
  84. "fileList:enabledDatasets",
  85. JSON.stringify(Array.from(enabledDatasets))
  86. );
  87. }
  88. }, [enabledDatasets]);
  89. useEffect(() => {
  90. if (typeof window !== "undefined") {
  91. localStorage.setItem("fileList:searchTerm", searchTerm);
  92. }
  93. }, [searchTerm]);
  94. useEffect(() => {
  95. if (typeof window !== "undefined") {
  96. localStorage.setItem("fileList:sortField", sortField);
  97. }
  98. }, [sortField]);
  99. useEffect(() => {
  100. if (typeof window !== "undefined") {
  101. localStorage.setItem("fileList:sortDirection", sortDirection);
  102. }
  103. }, [sortDirection]);
  104. useEffect(() => {
  105. if (typeof window !== "undefined") {
  106. localStorage.setItem("fileList:pageSize", pageSize.toString());
  107. }
  108. }, [pageSize]);
  109. // Initialize enabled datasets when datasets are loaded
  110. useEffect(() => {
  111. if (datasets && enabledDatasets.size === 0) {
  112. const datasetNames = datasets
  113. .map((path: string) => path.split("/").pop())
  114. .filter(Boolean);
  115. setEnabledDatasets(new Set(datasetNames));
  116. }
  117. }, [datasets, enabledDatasets.size]);
  118. // Listen for WebSocket events
  119. useEffect(() => {
  120. const handleFileUpdate = (event: CustomEvent) => {
  121. const fileData = event.detail;
  122. // Refetch files when file updates occur
  123. queryClient.refetchQueries({ queryKey: ["all-files"] });
  124. };
  125. const handleTaskUpdate = (event: CustomEvent) => {
  126. const taskData = event.detail;
  127. // Refetch files when task updates occur (e.g., requeue processing)
  128. if (taskData.task === "handbrake") {
  129. queryClient.refetchQueries({ queryKey: ["all-files"] });
  130. }
  131. };
  132. window.addEventListener("fileUpdate", handleFileUpdate as EventListener);
  133. window.addEventListener("taskUpdate", handleTaskUpdate as EventListener);
  134. return () => {
  135. window.removeEventListener(
  136. "fileUpdate",
  137. handleFileUpdate as EventListener
  138. );
  139. window.removeEventListener(
  140. "taskUpdate",
  141. handleTaskUpdate as EventListener
  142. );
  143. };
  144. }, [queryClient]);
  145. const [editFile, setEditFile] = useState<any>(null);
  146. // Confirmation dialog state
  147. const [deleteConfirm, setDeleteConfirm] = useState<{
  148. isOpen: boolean;
  149. file?: any;
  150. isBatch?: boolean;
  151. selectedFiles?: any[];
  152. }>({
  153. isOpen: false
  154. });
  155. // Batch selection state
  156. const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
  157. // Expanded rows state
  158. const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
  159. // Dropdown state
  160. const [openDropdown, setOpenDropdown] = useState<string | null>(null);
  161. const deleteMutation = useMutation({
  162. mutationFn: async (params: { file?: string; files?: any[] }) => {
  163. if (params.files && params.files.length > 0) {
  164. // Batch delete
  165. const deletePromises = params.files.map((file) =>
  166. del(`/files/${file.dataset}/${encodeURIComponent(file.input)}`)
  167. );
  168. await Promise.all(deletePromises);
  169. return params.files;
  170. } else if (params.file) {
  171. // Single delete
  172. const fileData = deleteConfirm.file;
  173. return del(
  174. `/files/${fileData?.dataset || "pr0n"}/${encodeURIComponent(params.file)}`
  175. );
  176. }
  177. },
  178. onSuccess: (result, params) => {
  179. queryClient.refetchQueries({ queryKey: ["all-files"] });
  180. setSelectedFiles(new Set()); // Clear selections after delete
  181. if (params.files && params.files.length > 0) {
  182. toast.success(`${params.files.length} files deleted successfully`);
  183. addNotification({
  184. type: "success",
  185. title: "Files Deleted",
  186. message: `${params.files.length} files have been deleted successfully.`
  187. });
  188. } else {
  189. toast.success("File deleted successfully");
  190. addNotification({
  191. type: "success",
  192. title: "File Deleted",
  193. message: `File "${params.file}" has been deleted successfully.`
  194. });
  195. }
  196. },
  197. onError: (error, params) => {
  198. console.error("Failed to delete file(s):", error);
  199. toast.error("Failed to delete file(s)");
  200. addNotification({
  201. type: "error",
  202. title: "Delete Failed",
  203. message: `Failed to delete file(s). Please try again.`
  204. });
  205. }
  206. });
  207. const requeueMutation = useMutation({
  208. mutationFn: ({ dataset, file }: { dataset: string; file: string }) =>
  209. post(`/files/${dataset}/${encodeURIComponent(file)}/requeue`),
  210. onSuccess: (_, { file }) => {
  211. queryClient.refetchQueries({ queryKey: ["all-files"] });
  212. queryClient.refetchQueries({ queryKey: ["tasks"] });
  213. toast.success("File requeued for processing");
  214. addNotification({
  215. type: "success",
  216. title: "File Requeued",
  217. message: `File "${file}" has been requeued for processing.`
  218. });
  219. },
  220. onError: (error, { file }) => {
  221. console.error("Failed to requeue file:", error);
  222. toast.error("Failed to requeue file");
  223. addNotification({
  224. type: "error",
  225. title: "Requeue Failed",
  226. message: `Failed to requeue file "${file}". Please try again.`
  227. });
  228. }
  229. });
  230. const handleEdit = (file: any) => {
  231. setEditFile(file);
  232. };
  233. const handleEditClose = () => {
  234. setEditFile(null);
  235. };
  236. // Confirmation dialog handlers
  237. const handleDeleteClick = (file: any) => {
  238. setDeleteConfirm({
  239. isOpen: true,
  240. file,
  241. isBatch: false
  242. });
  243. };
  244. const handleBatchDeleteClick = () => {
  245. const filesToDelete = displayFiles.filter((file) =>
  246. selectedFiles.has(`${file.dataset}-${file.input}`)
  247. );
  248. setDeleteConfirm({
  249. isOpen: true,
  250. isBatch: true,
  251. selectedFiles: filesToDelete
  252. });
  253. };
  254. const handleBatchRequeueClick = () => {
  255. const filesToRequeue = displayFiles.filter((file) =>
  256. selectedFiles.has(`${file.dataset}-${file.input}`)
  257. );
  258. filesToRequeue.forEach((file) => {
  259. requeueMutation.mutate({
  260. dataset: file.dataset,
  261. file: file.input
  262. });
  263. });
  264. setSelectedFiles(new Set()); // Clear selection after requeue
  265. };
  266. const handleDeleteConfirm = () => {
  267. if (deleteConfirm.isBatch && deleteConfirm.selectedFiles) {
  268. deleteMutation.mutate({ files: deleteConfirm.selectedFiles });
  269. } else if (deleteConfirm.file) {
  270. deleteMutation.mutate({ file: deleteConfirm.file.input });
  271. }
  272. setDeleteConfirm({ isOpen: false });
  273. };
  274. const handleDeleteCancel = () => {
  275. setDeleteConfirm({ isOpen: false });
  276. };
  277. // Batch selection handlers
  278. const handleSelectAll = () => {
  279. if (selectedFiles.size === displayFiles.length) {
  280. setSelectedFiles(new Set());
  281. } else {
  282. const allIds = new Set(
  283. displayFiles.map((file) => `${file.dataset}-${file.input}`)
  284. );
  285. setSelectedFiles(allIds);
  286. }
  287. };
  288. const handleSelectFile = (file: any) => {
  289. const fileId = `${file.dataset}-${file.input}`;
  290. const newSelected = new Set(selectedFiles);
  291. if (newSelected.has(fileId)) {
  292. newSelected.delete(fileId);
  293. } else {
  294. newSelected.add(fileId);
  295. }
  296. setSelectedFiles(newSelected);
  297. };
  298. // Expanded row handlers
  299. const handleToggleExpanded = (fileId: string) => {
  300. const newExpanded = new Set(expandedRows);
  301. if (newExpanded.has(fileId)) {
  302. newExpanded.delete(fileId);
  303. } else {
  304. newExpanded.add(fileId);
  305. }
  306. setExpandedRows(newExpanded);
  307. };
  308. // Dropdown handlers
  309. const handleToggleDropdown = (fileId: string) => {
  310. setOpenDropdown(openDropdown === fileId ? null : fileId);
  311. };
  312. const handleDropdownAction = (action: string, file: any) => {
  313. setOpenDropdown(null);
  314. switch (action) {
  315. case "edit":
  316. handleEdit(file);
  317. break;
  318. case "delete":
  319. handleDeleteClick(file);
  320. break;
  321. }
  322. };
  323. // Close dropdown when clicking outside
  324. useEffect(() => {
  325. const handleClickOutside = (event: MouseEvent) => {
  326. if (
  327. openDropdown &&
  328. !(event.target as Element).closest(".dropdown-container")
  329. ) {
  330. setOpenDropdown(null);
  331. }
  332. };
  333. document.addEventListener("mousedown", handleClickOutside);
  334. return () => document.removeEventListener("mousedown", handleClickOutside);
  335. }, [openDropdown]);
  336. const formatDate = (dateString: string) => {
  337. if (!dateString) return "-";
  338. try {
  339. const date = new Date(dateString);
  340. return date.toLocaleString();
  341. } catch {
  342. return dateString;
  343. }
  344. };
  345. const toggleDataset = (datasetName: string) => {
  346. const newEnabled = new Set(enabledDatasets);
  347. if (newEnabled.has(datasetName)) {
  348. newEnabled.delete(datasetName);
  349. } else {
  350. newEnabled.add(datasetName);
  351. }
  352. setEnabledDatasets(newEnabled);
  353. };
  354. const handleSort = (field: "input" | "date") => {
  355. if (sortField === field) {
  356. setSortDirection(sortDirection === "asc" ? "desc" : "asc");
  357. } else {
  358. setSortField(field);
  359. setSortDirection("asc");
  360. }
  361. };
  362. // Filter and sort data
  363. const filteredAndSortedData = useMemo(() => {
  364. if (!allFiles) return [];
  365. let filtered = allFiles.filter(
  366. (file: any) =>
  367. enabledDatasets.has(file.dataset) &&
  368. (file.input.toLowerCase().includes(searchTerm.toLowerCase()) ||
  369. (file.output &&
  370. file.output.toLowerCase().includes(searchTerm.toLowerCase())))
  371. );
  372. filtered.sort((a: any, b: any) => {
  373. let aValue = a[sortField];
  374. let bValue = b[sortField];
  375. if (sortField === "date") {
  376. aValue = new Date(aValue).getTime();
  377. bValue = new Date(bValue).getTime();
  378. } else {
  379. aValue = aValue.toLowerCase();
  380. bValue = bValue.toLowerCase();
  381. }
  382. if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
  383. if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
  384. return 0;
  385. });
  386. return filtered;
  387. }, [allFiles, enabledDatasets, searchTerm, sortField, sortDirection]);
  388. // Handle pagination
  389. const paginatedData = useMemo(() => {
  390. const startIndex = (currentPage - 1) * pageSize;
  391. const endIndex = startIndex + pageSize;
  392. return filteredAndSortedData.slice(startIndex, endIndex);
  393. }, [filteredAndSortedData, currentPage, pageSize]);
  394. // Reset to page 1 when filters change
  395. useEffect(() => {
  396. setCurrentPage(1);
  397. }, [enabledDatasets, searchTerm, sortField, sortDirection]);
  398. const displayFiles = paginatedData;
  399. if (isLoading) return <LoadingCard message="Loading files..." />;
  400. if (error) {
  401. return (
  402. <div className="text-center p-8 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
  403. <div className="mb-6">
  404. <svg
  405. className="mx-auto h-16 w-16 text-red-400"
  406. fill="none"
  407. viewBox="0 0 24 24"
  408. stroke="currentColor"
  409. >
  410. <path
  411. strokeLinecap="round"
  412. strokeLinejoin="round"
  413. strokeWidth={2}
  414. d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
  415. />
  416. </svg>
  417. </div>
  418. <h2 className="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
  419. Failed to load files
  420. </h2>
  421. <p className="text-gray-600 dark:text-gray-400 mb-6">
  422. There was an error loading the files data.
  423. </p>
  424. <button
  425. onClick={() =>
  426. queryClient.refetchQueries({ queryKey: ["all-files"] })
  427. }
  428. className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
  429. >
  430. Try Again
  431. </button>
  432. </div>
  433. );
  434. }
  435. return (
  436. <>
  437. <div className="mb-6 p-4 border rounded bg-gray-50 dark:bg-gray-800">
  438. <div className="flex items-center justify-between mb-4">
  439. <h3 className="font-semibold">Files</h3>
  440. <div className="flex items-center gap-2">
  441. <span className="text-sm text-gray-600 dark:text-gray-400">
  442. Total: {filteredAndSortedData.length} files
  443. </span>
  444. </div>
  445. </div>
  446. {/* Filter Controls */}
  447. <div className="mb-4 space-y-4">
  448. {/* Search */}
  449. <div className="flex items-center gap-4">
  450. <div className="flex-1">
  451. <input
  452. type="text"
  453. placeholder="Search files..."
  454. value={searchTerm}
  455. onChange={(e) => setSearchTerm(e.target.value)}
  456. className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
  457. />
  458. </div>
  459. </div>
  460. {/* Dataset Toggles */}
  461. <div className="flex flex-wrap gap-2">
  462. <span className="text-sm text-gray-600 dark:text-gray-400 mr-2">
  463. Datasets:
  464. </span>
  465. {datasets?.map((datasetPath: string) => {
  466. const datasetName = datasetPath.split("/").pop();
  467. if (!datasetName) return null;
  468. return (
  469. <label key={datasetName} className="flex items-center gap-2">
  470. <input
  471. type="checkbox"
  472. checked={enabledDatasets.has(datasetName)}
  473. onChange={() => toggleDataset(datasetName)}
  474. className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
  475. />
  476. <span className="text-sm text-gray-700 dark:text-gray-300">
  477. {datasetName}
  478. </span>
  479. </label>
  480. );
  481. })}
  482. </div>
  483. </div>
  484. {/* Batch Actions */}
  485. {selectedFiles.size > 0 && (
  486. <div className="mb-4 flex items-center gap-2">
  487. <span className="text-sm text-gray-700 dark:text-gray-300">
  488. {selectedFiles.size} file{selectedFiles.size !== 1 ? "s" : ""}{" "}
  489. selected
  490. </span>
  491. <button
  492. onClick={handleBatchRequeueClick}
  493. className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
  494. >
  495. Requeue Selected
  496. </button>
  497. <button
  498. onClick={handleBatchDeleteClick}
  499. className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
  500. >
  501. <TrashIcon className="h-4 w-4 mr-2" />
  502. Delete Selected
  503. </button>
  504. <button
  505. onClick={() => setSelectedFiles(new Set())}
  506. className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
  507. >
  508. Clear Selection
  509. </button>
  510. </div>
  511. )}
  512. <div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm">
  513. <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
  514. <thead className="bg-gray-50 dark:bg-gray-800">
  515. <tr>
  516. <th
  517. scope="col"
  518. className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider w-8"
  519. >
  520. {/* Expand toggle column */}
  521. </th>
  522. <th
  523. scope="col"
  524. className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider"
  525. >
  526. <input
  527. type="checkbox"
  528. checked={
  529. selectedFiles.size === displayFiles.length &&
  530. displayFiles.length > 0
  531. }
  532. onChange={handleSelectAll}
  533. className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
  534. />
  535. </th>
  536. <th
  537. scope="col"
  538. className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider"
  539. >
  540. Dataset
  541. </th>
  542. <th
  543. scope="col"
  544. className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
  545. onClick={() => handleSort("input")}
  546. >
  547. Input{" "}
  548. {sortField === "input" &&
  549. (sortDirection === "asc" ? "↑" : "↓")}
  550. </th>
  551. <th
  552. scope="col"
  553. className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
  554. onClick={() => handleSort("date")}
  555. >
  556. Date{" "}
  557. {sortField === "date" &&
  558. (sortDirection === "asc" ? "↑" : "↓")}
  559. </th>
  560. <th
  561. scope="col"
  562. className="px-4 py-3 text-right text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider"
  563. >
  564. Actions
  565. </th>
  566. </tr>
  567. </thead>
  568. <tbody className="divide-y divide-gray-200 dark:divide-gray-800">
  569. {displayFiles.length > 0 ? (
  570. displayFiles.map((file: any) => {
  571. const fileId = `${file.dataset}-${file.input}`;
  572. const isExpanded = expandedRows.has(fileId);
  573. return (
  574. <>
  575. <tr
  576. key={fileId}
  577. className="hover:bg-gray-50 dark:hover:bg-gray-700"
  578. >
  579. <td className="px-4 py-2 whitespace-nowrap">
  580. <button
  581. onClick={() => handleToggleExpanded(fileId)}
  582. className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
  583. >
  584. {isExpanded ? (
  585. <ChevronDownIcon className="h-4 w-4" />
  586. ) : (
  587. <ChevronRightIcon className="h-4 w-4" />
  588. )}
  589. </button>
  590. </td>
  591. <td className="px-4 py-2 whitespace-nowrap">
  592. <input
  593. type="checkbox"
  594. checked={selectedFiles.has(fileId)}
  595. onChange={() => handleSelectFile(file)}
  596. className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
  597. />
  598. </td>
  599. <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
  600. <span className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded text-xs font-medium">
  601. {file.dataset}
  602. </span>
  603. </td>
  604. <td className="px-4 py-2 text-sm text-gray-900 dark:text-gray-100 max-w-xs">
  605. <div className="truncate" title={file.input}>
  606. {file.input}
  607. </div>
  608. </td>
  609. <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
  610. {formatDate(file.date)}
  611. </td>
  612. <td className="px-4 py-2 whitespace-nowrap text-sm text-right">
  613. <div className="flex items-center justify-end space-x-1">
  614. <button
  615. onClick={() =>
  616. requeueMutation.mutate({
  617. dataset: file.dataset,
  618. file: file.input
  619. })
  620. }
  621. className="inline-flex items-center px-3 py-1 text-xs font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-l-md border border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
  622. >
  623. Requeue
  624. </button>
  625. <div className="relative">
  626. <button
  627. onClick={() => handleToggleDropdown(fileId)}
  628. className="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-l-0 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-r-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
  629. >
  630. <ChevronDownIcon className="h-4 w-4" />
  631. </button>
  632. {openDropdown === fileId && (
  633. <div className="dropdown-container absolute right-0 mt-1 w-32 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-10">
  634. <div className="py-1">
  635. <button
  636. onClick={() =>
  637. handleDropdownAction("edit", file)
  638. }
  639. className="block w-full text-left px-3 py-2 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
  640. >
  641. Edit
  642. </button>
  643. <button
  644. onClick={() =>
  645. handleDropdownAction("delete", file)
  646. }
  647. className="block w-full text-left px-3 py-2 text-xs text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700"
  648. >
  649. Delete
  650. </button>
  651. </div>
  652. </div>
  653. )}
  654. </div>
  655. </div>
  656. </td>
  657. </tr>
  658. {isExpanded && (
  659. <tr
  660. key={`${fileId}-expanded`}
  661. className="bg-gray-50 dark:bg-gray-800"
  662. >
  663. <td colSpan={5} className="px-4 py-3">
  664. <div className="text-sm">
  665. <div className="font-medium text-gray-900 dark:text-gray-100 mb-2">
  666. Output
  667. </div>
  668. <div className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border">
  669. {file.output || "No output available"}
  670. </div>
  671. </div>
  672. </td>
  673. </tr>
  674. )}
  675. </>
  676. );
  677. })
  678. ) : (
  679. <tr>
  680. <td
  681. colSpan={5}
  682. className="px-4 py-4 text-center text-gray-500 dark:text-gray-400"
  683. >
  684. No files found matching your filters.
  685. </td>
  686. </tr>
  687. )}
  688. </tbody>
  689. </table>
  690. </div>
  691. </div>
  692. <Pagination
  693. currentPage={currentPage}
  694. totalItems={filteredAndSortedData.length}
  695. pageSize={pageSize}
  696. onPageChange={setCurrentPage}
  697. onPageSizeChange={(newPageSize) => {
  698. setPageSize(newPageSize);
  699. setCurrentPage(1);
  700. }}
  701. />
  702. {editFile && (
  703. <FileCrud editFile={editFile} onEditClose={handleEditClose} />
  704. )}
  705. <ConfirmationDialog
  706. isOpen={deleteConfirm.isOpen}
  707. title={deleteConfirm.isBatch ? "Delete Selected Files" : "Delete File"}
  708. message={
  709. deleteConfirm.isBatch
  710. ? `Are you sure you want to delete ${deleteConfirm.selectedFiles?.length} selected file(s)? This action cannot be undone.`
  711. : `Are you sure you want to delete "${deleteConfirm.file?.input}"? This action cannot be undone.`
  712. }
  713. confirmText="Delete"
  714. cancelText="Cancel"
  715. type="danger"
  716. onConfirm={handleDeleteConfirm}
  717. onClose={handleDeleteCancel}
  718. isLoading={deleteMutation.isPending}
  719. />
  720. </>
  721. );
  722. }