filesTable.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  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 deletePromises = selectedRows.map(async (row) => {
  240. const response = await fetch(`/api/files/${row.original.id}`, {
  241. method: 'DELETE',
  242. });
  243. if (!response.ok) {
  244. throw new Error(`Failed to delete file: ${row.original.filename}`);
  245. }
  246. return row.original.id;
  247. });
  248. await Promise.all(deletePromises);
  249. // Clear selection and refresh the table
  250. setRowSelection({});
  251. refetch();
  252. } catch (error) {
  253. console.error('Error deleting files:', error);
  254. alert('Failed to delete some files. Please try again.');
  255. }
  256. };
  257. const handleDeleteFile = async (fileId: string, filename: string) => {
  258. const confirmDelete = window.confirm(
  259. `Are you sure you want to delete "${filename}"? This action cannot be undone.`
  260. );
  261. if (!confirmDelete) return;
  262. try {
  263. const response = await fetch(`/api/files/${fileId}`, {
  264. method: 'DELETE',
  265. });
  266. if (!response.ok) {
  267. throw new Error('Failed to delete file');
  268. }
  269. refetch();
  270. } catch (error) {
  271. console.error('Error deleting file:', error);
  272. alert('Failed to delete file. Please try again.');
  273. }
  274. };
  275. if (isLoading) {
  276. return (
  277. <div className="bg-white rounded-lg shadow-sm border border-gray-200">
  278. <div className="p-6">
  279. <div className="flex justify-between items-center mb-4">
  280. <h2 className="text-xl font-semibold text-gray-900">Files in Database</h2>
  281. <div className="h-8 w-24 bg-gray-200 rounded animate-pulse"></div>
  282. </div>
  283. <div className="space-y-3">
  284. {[...Array(5)].map((_, i) => (
  285. <div key={i} className="h-12 bg-gray-100 rounded animate-pulse"></div>
  286. ))}
  287. </div>
  288. </div>
  289. </div>
  290. );
  291. }
  292. if (isError) {
  293. return (
  294. <div className="bg-red-50 border border-red-200 rounded-lg p-6">
  295. <div className="flex items-center">
  296. <svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
  297. <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" />
  298. </svg>
  299. <span className="text-red-700">Error: {error?.message || "Failed to load files"}</span>
  300. </div>
  301. <button
  302. onClick={handleRefresh}
  303. className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
  304. >
  305. Retry
  306. </button>
  307. </div>
  308. );
  309. }
  310. if (!data || data.length === 0) {
  311. return (
  312. <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
  313. <div className="text-center">
  314. <svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  315. <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" />
  316. </svg>
  317. <h3 className="mt-2 text-sm font-medium text-gray-900">No files found</h3>
  318. <p className="mt-1 text-sm text-gray-500">Upload some files to get started.</p>
  319. </div>
  320. </div>
  321. );
  322. }
  323. return (
  324. <div className="bg-white rounded-lg shadow-sm border border-gray-200">
  325. <div className="p-6">
  326. <div className="flex justify-between items-center mb-4">
  327. <h2 className="text-xl font-semibold text-gray-900">Files in Database</h2>
  328. <div className="flex gap-2">
  329. <button
  330. onClick={handleDownloadSelected}
  331. disabled={table.getSelectedRowModel().rows.length === 0 || isLoading}
  332. 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"
  333. >
  334. <svg
  335. className="w-4 h-4 mr-2"
  336. fill="none"
  337. stroke="currentColor"
  338. viewBox="0 0 24 24"
  339. >
  340. <path
  341. strokeLinecap="round"
  342. strokeLinejoin="round"
  343. strokeWidth={2}
  344. 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"
  345. />
  346. </svg>
  347. Download Selected
  348. </button>
  349. <button
  350. onClick={handleDeleteSelected}
  351. disabled={table.getSelectedRowModel().rows.length === 0 || isLoading}
  352. 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"
  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="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"
  365. />
  366. </svg>
  367. Delete Selected
  368. </button>
  369. <button
  370. onClick={handleRefresh}
  371. disabled={isLoading}
  372. 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"
  373. >
  374. <svg
  375. className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`}
  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="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"
  385. />
  386. </svg>
  387. Refresh
  388. </button>
  389. </div>
  390. </div>
  391. <div className="overflow-x-auto">
  392. <table className="min-w-full divide-y divide-gray-200">
  393. <thead className="bg-gray-50">
  394. {table.getHeaderGroups().map((headerGroup) => (
  395. <tr key={headerGroup.id}>
  396. {headerGroup.headers.map((header) => (
  397. <th
  398. key={header.id}
  399. className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
  400. onClick={header.column.getToggleSortingHandler()}
  401. >
  402. <div className="flex items-center">
  403. {flexRender(header.column.columnDef.header, header.getContext())}
  404. {header.column.getIsSorted() && (
  405. <span className="ml-1">
  406. {header.column.getIsSorted() === "asc" ? "↑" : "↓"}
  407. </span>
  408. )}
  409. </div>
  410. </th>
  411. ))}
  412. </tr>
  413. ))}
  414. </thead>
  415. <tbody className="bg-white divide-y divide-gray-200">
  416. {table.getRowModel().rows.map((row) => (
  417. <tr
  418. key={row.id}
  419. className={`hover:bg-gray-50 ${row.getIsSelected() ? 'bg-blue-50' : ''}`}
  420. >
  421. {row.getVisibleCells().map((cell) => (
  422. <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
  423. {flexRender(cell.column.columnDef.cell, cell.getContext())}
  424. </td>
  425. ))}
  426. </tr>
  427. ))}
  428. </tbody>
  429. </table>
  430. </div>
  431. {/* Pagination */}
  432. <div className="flex items-center justify-between mt-4">
  433. <div className="text-sm text-gray-700">
  434. Showing {table.getRowModel().rows.length} of {files.length} results
  435. </div>
  436. <div className="flex gap-2">
  437. <button
  438. onClick={() => table.previousPage()}
  439. disabled={!table.getCanPreviousPage()}
  440. className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
  441. >
  442. Previous
  443. </button>
  444. <button
  445. onClick={() => table.nextPage()}
  446. disabled={!table.getCanNextPage()}
  447. className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
  448. >
  449. Next
  450. </button>
  451. </div>
  452. </div>
  453. </div>
  454. </div>
  455. );
  456. }