FileList.tsx 27 KB

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