filesTable.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. "use client";
  2. import React, { useState, useEffect } from "react";
  3. import {
  4. ColumnDef,
  5. flexRender,
  6. getCoreRowModel,
  7. getPaginationRowModel,
  8. getSortedRowModel,
  9. useReactTable,
  10. SortingState,
  11. RowSelectionState,
  12. } from "@tanstack/react-table";
  13. import { useQuery } from "@tanstack/react-query";
  14. interface FileData {
  15. id: string;
  16. filename: string;
  17. mimetype: string;
  18. size: number;
  19. createdAt: string;
  20. updatedAt: string;
  21. }
  22. interface FilesTableProps {
  23. onFileAdded?: (file: FileData) => void;
  24. }
  25. export function FilesTable({ onFileAdded }: FilesTableProps) {
  26. const [sorting, setSorting] = useState<SortingState>([]);
  27. const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
  28. const [files, setFiles] = useState<FileData[]>([]);
  29. const { data, isLoading, isError, error, refetch } = useQuery({
  30. queryKey: ["files"],
  31. queryFn: async () => {
  32. const response = await fetch("/api/files");
  33. if (!response.ok) {
  34. throw new Error("Failed to fetch files");
  35. }
  36. const data = await response.json();
  37. return data.files as FileData[];
  38. },
  39. });
  40. // Update local files state when data changes
  41. useEffect(() => {
  42. if (data) {
  43. setFiles(data);
  44. }
  45. }, [data]);
  46. // Method to add a new file to the table
  47. const addFile = (file: FileData) => {
  48. setFiles(prevFiles => [file, ...prevFiles]);
  49. // Call the onFileAdded prop if provided
  50. if (onFileAdded) {
  51. onFileAdded(file);
  52. }
  53. };
  54. // Make addFile available globally for the upload form
  55. useEffect(() => {
  56. (window as any).addFileToTable = addFile;
  57. return () => {
  58. delete (window as any).addFileToTable;
  59. };
  60. }, [onFileAdded]);
  61. const columns: ColumnDef<FileData>[] = [
  62. {
  63. id: "select",
  64. header: ({ table }) => (
  65. <div className="px-6 py-3">
  66. <input
  67. type="checkbox"
  68. checked={table.getIsAllPageRowsSelected()}
  69. onChange={(e) => table.toggleAllPageRowsSelected(!!e.target.checked)}
  70. className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
  71. />
  72. </div>
  73. ),
  74. cell: ({ row }) => (
  75. <div className="px-6 py-4">
  76. <input
  77. type="checkbox"
  78. checked={row.getIsSelected()}
  79. onChange={(e) => row.toggleSelected(!!e.target.checked)}
  80. className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
  81. />
  82. </div>
  83. ),
  84. enableSorting: false,
  85. enableHiding: false,
  86. },
  87. {
  88. accessorKey: "filename",
  89. header: "File Name",
  90. cell: ({ row }) => (
  91. <div className="font-medium text-gray-900">{row.getValue("filename")}</div>
  92. ),
  93. },
  94. {
  95. accessorKey: "mimetype",
  96. header: "Type",
  97. cell: ({ row }) => (
  98. <div className="text-sm text-gray-600">{row.getValue("mimetype")}</div>
  99. ),
  100. },
  101. {
  102. accessorKey: "size",
  103. header: "Size",
  104. cell: ({ row }) => {
  105. const bytes = row.getValue("size") as number;
  106. if (bytes === 0) return "0 Bytes";
  107. const k = 1024;
  108. const sizes = ["Bytes", "KB", "MB", "GB"];
  109. const i = Math.floor(Math.log(bytes) / Math.log(k));
  110. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
  111. },
  112. },
  113. {
  114. accessorKey: "createdAt",
  115. header: "Created",
  116. cell: ({ row }) => {
  117. const date = new Date(row.getValue("createdAt"));
  118. return date.toLocaleDateString("en-US", {
  119. year: "numeric",
  120. month: "short",
  121. day: "numeric",
  122. hour: "2-digit",
  123. minute: "2-digit",
  124. });
  125. },
  126. },
  127. {
  128. accessorKey: "updatedAt",
  129. header: "Updated",
  130. cell: ({ row }) => {
  131. const date = new Date(row.getValue("updatedAt"));
  132. return date.toLocaleDateString("en-US", {
  133. year: "numeric",
  134. month: "short",
  135. day: "numeric",
  136. hour: "2-digit",
  137. minute: "2-digit",
  138. });
  139. },
  140. },
  141. {
  142. id: "actions",
  143. header: "Actions",
  144. cell: ({ row }) => (
  145. <div className="flex gap-2">
  146. <button
  147. onClick={() => handleDownload(row.original.id, row.original.filename)}
  148. className="text-green-600 hover:text-green-800 transition-colors"
  149. title="Download file"
  150. >
  151. <svg
  152. className="w-5 h-5"
  153. fill="none"
  154. stroke="currentColor"
  155. viewBox="0 0 24 24"
  156. >
  157. <path
  158. strokeLinecap="round"
  159. strokeLinejoin="round"
  160. strokeWidth={2}
  161. d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
  162. />
  163. </svg>
  164. </button>
  165. <button
  166. onClick={() => handleDeleteFile(row.original.id, row.original.filename)}
  167. className="text-red-600 hover:text-red-800 transition-colors"
  168. title="Delete file"
  169. >
  170. <svg
  171. className="w-5 h-5"
  172. fill="none"
  173. stroke="currentColor"
  174. viewBox="0 0 24 24"
  175. >
  176. <path
  177. strokeLinecap="round"
  178. strokeLinejoin="round"
  179. strokeWidth={2}
  180. d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
  181. />
  182. </svg>
  183. </button>
  184. </div>
  185. ),
  186. enableSorting: false,
  187. },
  188. ];
  189. const table = useReactTable({
  190. data: files,
  191. columns,
  192. state: {
  193. sorting,
  194. rowSelection,
  195. },
  196. onSortingChange: setSorting,
  197. onRowSelectionChange: setRowSelection,
  198. getCoreRowModel: getCoreRowModel(),
  199. getPaginationRowModel: getPaginationRowModel(),
  200. getSortedRowModel: getSortedRowModel(),
  201. enableRowSelection: true,
  202. });
  203. const handleRefresh = () => {
  204. refetch();
  205. };
  206. const handleDownload = (fileId: string, filename: string) => {
  207. const downloadUrl = `/api/files/${fileId}`;
  208. const link = document.createElement('a');
  209. link.href = downloadUrl;
  210. link.download = filename;
  211. document.body.appendChild(link);
  212. link.click();
  213. document.body.removeChild(link);
  214. };
  215. const handleDownloadSelected = () => {
  216. const selectedRows = table.getSelectedRowModel().rows;
  217. if (selectedRows.length === 0) return;
  218. if (selectedRows.length === 1) {
  219. // Single file download
  220. const selected = selectedRows[0];
  221. handleDownload(selected.original.id, selected.original.filename);
  222. } else {
  223. // Multiple files - download each one
  224. selectedRows.forEach((row, index) => {
  225. setTimeout(() => {
  226. handleDownload(row.original.id, row.original.filename);
  227. }, index * 500); // Stagger downloads by 500ms to avoid browser blocking
  228. });
  229. }
  230. };
  231. const handleDeleteSelected = async () => {
  232. const selectedRows = table.getSelectedRowModel().rows;
  233. if (selectedRows.length === 0) return;
  234. const confirmDelete = window.confirm(
  235. `Are you sure you want to delete ${selectedRows.length} file${selectedRows.length > 1 ? 's' : ''}? This action cannot be undone.`
  236. );
  237. if (!confirmDelete) return;
  238. try {
  239. const fileIds = selectedRows.map(row => row.original.id);
  240. const fileNames = selectedRows.map(row => row.original.filename);
  241. console.log(`Deleting ${fileIds.length} files:`, fileNames);
  242. // Process deletions sequentially to avoid race conditions
  243. const results = [];
  244. for (const row of selectedRows) {
  245. try {
  246. const response = await fetch(`/api/files/${row.original.id}`, {
  247. method: 'DELETE',
  248. });
  249. if (!response.ok) {
  250. const errorText = await response.text();
  251. console.error(`Failed to delete ${row.original.filename}:`, errorText);
  252. results.push({ id: row.original.id, success: false, error: errorText });
  253. } else {
  254. console.log(`Successfully deleted: ${row.original.filename}`);
  255. results.push({ id: row.original.id, success: true });
  256. }
  257. } catch (error) {
  258. console.error(`Error deleting ${row.original.filename}:`, error);
  259. results.push({ id: row.original.id, success: false, error: String(error) });
  260. }
  261. }
  262. const successful = results.filter(r => r.success).length;
  263. const failed = results.filter(r => !r.success).length;
  264. if (failed > 0) {
  265. alert(`${successful} file(s) deleted successfully, ${failed} failed. Check console for details.`);
  266. } else {
  267. alert(`${successful} file(s) deleted successfully.`);
  268. }
  269. // Clear selection and refresh the table
  270. setRowSelection({});
  271. refetch();
  272. } catch (error) {
  273. console.error('Error in delete process:', error);
  274. alert('Failed to delete files. Please try again.');
  275. }
  276. };
  277. const handleDeleteFile = async (fileId: string, filename: string) => {
  278. const confirmDelete = window.confirm(
  279. `Are you sure you want to delete "${filename}"? This action cannot be undone.`
  280. );
  281. if (!confirmDelete) return;
  282. try {
  283. const response = await fetch(`/api/files/${fileId}`, {
  284. method: 'DELETE',
  285. });
  286. if (!response.ok) {
  287. throw new Error('Failed to delete file');
  288. }
  289. refetch();
  290. } catch (error) {
  291. console.error('Error deleting file:', error);
  292. alert('Failed to delete file. Please try again.');
  293. }
  294. };
  295. if (isLoading) {
  296. return (
  297. <div className="bg-white rounded-lg shadow-sm border border-gray-200">
  298. <div className="p-6">
  299. <div className="flex justify-between items-center mb-4">
  300. <h2 className="text-xl font-semibold text-gray-900">Files in Database</h2>
  301. <div className="h-8 w-24 bg-gray-200 rounded animate-pulse"></div>
  302. </div>
  303. <div className="space-y-3">
  304. {[...Array(5)].map((_, i) => (
  305. <div key={i} className="h-12 bg-gray-100 rounded animate-pulse"></div>
  306. ))}
  307. </div>
  308. </div>
  309. </div>
  310. );
  311. }
  312. if (isError) {
  313. return (
  314. <div className="bg-red-50 border border-red-200 rounded-lg p-6">
  315. <div className="flex items-center">
  316. <svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
  317. <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
  318. </svg>
  319. <span className="text-red-700">Error: {error?.message || "Failed to load files"}</span>
  320. </div>
  321. <button
  322. onClick={handleRefresh}
  323. className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
  324. >
  325. Retry
  326. </button>
  327. </div>
  328. );
  329. }
  330. if (!data || data.length === 0) {
  331. return (
  332. <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
  333. <div className="text-center">
  334. <svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  335. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
  336. </svg>
  337. <h3 className="mt-2 text-sm font-medium text-gray-900">No files found</h3>
  338. <p className="mt-1 text-sm text-gray-500">Upload some files to get started.</p>
  339. </div>
  340. </div>
  341. );
  342. }
  343. return (
  344. <div className="bg-white rounded-lg shadow-sm border border-gray-200">
  345. <div className="p-6">
  346. <div className="flex justify-between items-center mb-4">
  347. <h2 className="text-xl font-semibold text-gray-900">Files in Database</h2>
  348. <div className="flex gap-2">
  349. <button
  350. onClick={handleDownloadSelected}
  351. disabled={table.getSelectedRowModel().rows.length === 0 || isLoading}
  352. className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
  353. >
  354. <svg
  355. className="w-4 h-4 mr-2"
  356. fill="none"
  357. stroke="currentColor"
  358. viewBox="0 0 24 24"
  359. >
  360. <path
  361. strokeLinecap="round"
  362. strokeLinejoin="round"
  363. strokeWidth={2}
  364. d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
  365. />
  366. </svg>
  367. Download Selected
  368. </button>
  369. <button
  370. onClick={handleDeleteSelected}
  371. disabled={table.getSelectedRowModel().rows.length === 0 || isLoading}
  372. className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
  373. >
  374. <svg
  375. className="w-4 h-4 mr-2"
  376. fill="none"
  377. stroke="currentColor"
  378. viewBox="0 0 24 24"
  379. >
  380. <path
  381. strokeLinecap="round"
  382. strokeLinejoin="round"
  383. strokeWidth={2}
  384. d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
  385. />
  386. </svg>
  387. Delete Selected
  388. </button>
  389. <button
  390. onClick={handleRefresh}
  391. disabled={isLoading}
  392. className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
  393. >
  394. <svg
  395. className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`}
  396. fill="none"
  397. stroke="currentColor"
  398. viewBox="0 0 24 24"
  399. >
  400. <path
  401. strokeLinecap="round"
  402. strokeLinejoin="round"
  403. strokeWidth={2}
  404. d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
  405. />
  406. </svg>
  407. Refresh
  408. </button>
  409. </div>
  410. </div>
  411. <div className="overflow-x-auto">
  412. <table className="min-w-full divide-y divide-gray-200">
  413. <thead className="bg-gray-50">
  414. {table.getHeaderGroups().map((headerGroup) => (
  415. <tr key={headerGroup.id}>
  416. {headerGroup.headers.map((header) => (
  417. <th
  418. key={header.id}
  419. className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
  420. onClick={header.column.getToggleSortingHandler()}
  421. >
  422. <div className="flex items-center">
  423. {flexRender(header.column.columnDef.header, header.getContext())}
  424. {header.column.getIsSorted() && (
  425. <span className="ml-1">
  426. {header.column.getIsSorted() === "asc" ? "↑" : "↓"}
  427. </span>
  428. )}
  429. </div>
  430. </th>
  431. ))}
  432. </tr>
  433. ))}
  434. </thead>
  435. <tbody className="bg-white divide-y divide-gray-200">
  436. {table.getRowModel().rows.map((row) => (
  437. <tr
  438. key={row.id}
  439. className={`hover:bg-gray-50 ${row.getIsSelected() ? 'bg-blue-50' : ''}`}
  440. >
  441. {row.getVisibleCells().map((cell) => (
  442. <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
  443. {flexRender(cell.column.columnDef.cell, cell.getContext())}
  444. </td>
  445. ))}
  446. </tr>
  447. ))}
  448. </tbody>
  449. </table>
  450. </div>
  451. {/* Pagination */}
  452. <div className="flex items-center justify-between mt-4">
  453. <div className="text-sm text-gray-700">
  454. Showing {table.getRowModel().rows.length} of {files.length} results
  455. </div>
  456. <div className="flex gap-2">
  457. <button
  458. onClick={() => table.previousPage()}
  459. disabled={!table.getCanPreviousPage()}
  460. className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
  461. >
  462. Previous
  463. </button>
  464. <button
  465. onClick={() => table.nextPage()}
  466. disabled={!table.getCanNextPage()}
  467. className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
  468. >
  469. Next
  470. </button>
  471. </div>
  472. </div>
  473. </div>
  474. </div>
  475. );
  476. }