filesTable.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. "use client";
  2. import { useState } 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. export function FilesTable() {
  23. const [sorting, setSorting] = useState<SortingState>([]);
  24. const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
  25. const { data, isLoading, isError, error, refetch } = useQuery({
  26. queryKey: ["files"],
  27. queryFn: async () => {
  28. const response = await fetch("/api/files");
  29. if (!response.ok) {
  30. throw new Error("Failed to fetch files");
  31. }
  32. const data = await response.json();
  33. return data.files as FileData[];
  34. },
  35. });
  36. const columns: ColumnDef<FileData>[] = [
  37. {
  38. id: "select",
  39. header: ({ table }) => (
  40. <div className="px-6 py-3">
  41. <input
  42. type="checkbox"
  43. checked={table.getIsAllPageRowsSelected()}
  44. onChange={(e) => table.toggleAllPageRowsSelected(!!e.target.checked)}
  45. className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
  46. />
  47. </div>
  48. ),
  49. cell: ({ row }) => (
  50. <div className="px-6 py-4">
  51. <input
  52. type="checkbox"
  53. checked={row.getIsSelected()}
  54. onChange={(e) => row.toggleSelected(!!e.target.checked)}
  55. className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
  56. />
  57. </div>
  58. ),
  59. enableSorting: false,
  60. enableHiding: false,
  61. },
  62. {
  63. accessorKey: "filename",
  64. header: "File Name",
  65. cell: ({ row }) => (
  66. <div className="font-medium text-gray-900">{row.getValue("filename")}</div>
  67. ),
  68. },
  69. {
  70. accessorKey: "mimetype",
  71. header: "Type",
  72. cell: ({ row }) => (
  73. <div className="text-sm text-gray-600">{row.getValue("mimetype")}</div>
  74. ),
  75. },
  76. {
  77. accessorKey: "size",
  78. header: "Size",
  79. cell: ({ row }) => {
  80. const bytes = row.getValue("size") as number;
  81. if (bytes === 0) return "0 Bytes";
  82. const k = 1024;
  83. const sizes = ["Bytes", "KB", "MB", "GB"];
  84. const i = Math.floor(Math.log(bytes) / Math.log(k));
  85. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
  86. },
  87. },
  88. {
  89. accessorKey: "createdAt",
  90. header: "Created",
  91. cell: ({ row }) => {
  92. const date = new Date(row.getValue("createdAt"));
  93. return date.toLocaleDateString("en-US", {
  94. year: "numeric",
  95. month: "short",
  96. day: "numeric",
  97. hour: "2-digit",
  98. minute: "2-digit",
  99. });
  100. },
  101. },
  102. {
  103. accessorKey: "updatedAt",
  104. header: "Updated",
  105. cell: ({ row }) => {
  106. const date = new Date(row.getValue("updatedAt"));
  107. return date.toLocaleDateString("en-US", {
  108. year: "numeric",
  109. month: "short",
  110. day: "numeric",
  111. hour: "2-digit",
  112. minute: "2-digit",
  113. });
  114. },
  115. },
  116. {
  117. id: "actions",
  118. header: "Actions",
  119. cell: ({ row }) => (
  120. <div className="flex gap-2">
  121. <button
  122. onClick={() => handleDownload(row.original.id, row.original.filename)}
  123. className="text-green-600 hover:text-green-800 transition-colors"
  124. title="Download file"
  125. >
  126. <svg
  127. className="w-5 h-5"
  128. fill="none"
  129. stroke="currentColor"
  130. viewBox="0 0 24 24"
  131. >
  132. <path
  133. strokeLinecap="round"
  134. strokeLinejoin="round"
  135. strokeWidth={2}
  136. 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"
  137. />
  138. </svg>
  139. </button>
  140. <button
  141. onClick={() => handleDeleteFile(row.original.id, row.original.filename)}
  142. className="text-red-600 hover:text-red-800 transition-colors"
  143. title="Delete file"
  144. >
  145. <svg
  146. className="w-5 h-5"
  147. fill="none"
  148. stroke="currentColor"
  149. viewBox="0 0 24 24"
  150. >
  151. <path
  152. strokeLinecap="round"
  153. strokeLinejoin="round"
  154. strokeWidth={2}
  155. 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"
  156. />
  157. </svg>
  158. </button>
  159. </div>
  160. ),
  161. enableSorting: false,
  162. },
  163. ];
  164. const table = useReactTable({
  165. data: data || [],
  166. columns,
  167. state: {
  168. sorting,
  169. rowSelection,
  170. },
  171. onSortingChange: setSorting,
  172. onRowSelectionChange: setRowSelection,
  173. getCoreRowModel: getCoreRowModel(),
  174. getPaginationRowModel: getPaginationRowModel(),
  175. getSortedRowModel: getSortedRowModel(),
  176. enableRowSelection: true,
  177. });
  178. const handleRefresh = () => {
  179. refetch();
  180. };
  181. const handleDownload = (fileId: string, filename: string) => {
  182. const downloadUrl = `/api/files/${fileId}`;
  183. const link = document.createElement('a');
  184. link.href = downloadUrl;
  185. link.download = filename;
  186. document.body.appendChild(link);
  187. link.click();
  188. document.body.removeChild(link);
  189. };
  190. const handleDownloadSelected = () => {
  191. const selectedRows = table.getSelectedRowModel().rows;
  192. if (selectedRows.length === 0) return;
  193. if (selectedRows.length === 1) {
  194. // Single file download
  195. const selected = selectedRows[0];
  196. handleDownload(selected.original.id, selected.original.filename);
  197. } else {
  198. // Multiple files - download each one
  199. selectedRows.forEach((row, index) => {
  200. setTimeout(() => {
  201. handleDownload(row.original.id, row.original.filename);
  202. }, index * 500); // Stagger downloads by 500ms to avoid browser blocking
  203. });
  204. }
  205. };
  206. const handleDeleteSelected = async () => {
  207. const selectedRows = table.getSelectedRowModel().rows;
  208. if (selectedRows.length === 0) return;
  209. const confirmDelete = window.confirm(
  210. `Are you sure you want to delete ${selectedRows.length} file${selectedRows.length > 1 ? 's' : ''}? This action cannot be undone.`
  211. );
  212. if (!confirmDelete) return;
  213. try {
  214. const deletePromises = selectedRows.map(async (row) => {
  215. const response = await fetch(`/api/files/${row.original.id}`, {
  216. method: 'DELETE',
  217. });
  218. if (!response.ok) {
  219. throw new Error(`Failed to delete file: ${row.original.filename}`);
  220. }
  221. return row.original.id;
  222. });
  223. await Promise.all(deletePromises);
  224. // Clear selection and refresh the table
  225. setRowSelection({});
  226. refetch();
  227. } catch (error) {
  228. console.error('Error deleting files:', error);
  229. alert('Failed to delete some files. Please try again.');
  230. }
  231. };
  232. const handleDeleteFile = async (fileId: string, filename: string) => {
  233. const confirmDelete = window.confirm(
  234. `Are you sure you want to delete "${filename}"? This action cannot be undone.`
  235. );
  236. if (!confirmDelete) return;
  237. try {
  238. const response = await fetch(`/api/files/${fileId}`, {
  239. method: 'DELETE',
  240. });
  241. if (!response.ok) {
  242. throw new Error('Failed to delete file');
  243. }
  244. refetch();
  245. } catch (error) {
  246. console.error('Error deleting file:', error);
  247. alert('Failed to delete file. Please try again.');
  248. }
  249. };
  250. if (isLoading) {
  251. return (
  252. <div className="bg-white rounded-lg shadow-sm border border-gray-200">
  253. <div className="p-6">
  254. <div className="flex justify-between items-center mb-4">
  255. <h2 className="text-xl font-semibold text-gray-900">Files in Database</h2>
  256. <div className="h-8 w-24 bg-gray-200 rounded animate-pulse"></div>
  257. </div>
  258. <div className="space-y-3">
  259. {[...Array(5)].map((_, i) => (
  260. <div key={i} className="h-12 bg-gray-100 rounded animate-pulse"></div>
  261. ))}
  262. </div>
  263. </div>
  264. </div>
  265. );
  266. }
  267. if (isError) {
  268. return (
  269. <div className="bg-red-50 border border-red-200 rounded-lg p-6">
  270. <div className="flex items-center">
  271. <svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
  272. <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" />
  273. </svg>
  274. <span className="text-red-700">Error: {error?.message || "Failed to load files"}</span>
  275. </div>
  276. <button
  277. onClick={handleRefresh}
  278. className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
  279. >
  280. Retry
  281. </button>
  282. </div>
  283. );
  284. }
  285. if (!data || data.length === 0) {
  286. return (
  287. <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
  288. <div className="text-center">
  289. <svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  290. <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" />
  291. </svg>
  292. <h3 className="mt-2 text-sm font-medium text-gray-900">No files found</h3>
  293. <p className="mt-1 text-sm text-gray-500">Upload some files to get started.</p>
  294. </div>
  295. </div>
  296. );
  297. }
  298. return (
  299. <div className="bg-white rounded-lg shadow-sm border border-gray-200">
  300. <div className="p-6">
  301. <div className="flex justify-between items-center mb-4">
  302. <h2 className="text-xl font-semibold text-gray-900">Files in Database</h2>
  303. <div className="flex gap-2">
  304. <button
  305. onClick={handleDownloadSelected}
  306. disabled={table.getSelectedRowModel().rows.length === 0 || isLoading}
  307. 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"
  308. >
  309. <svg
  310. className="w-4 h-4 mr-2"
  311. fill="none"
  312. stroke="currentColor"
  313. viewBox="0 0 24 24"
  314. >
  315. <path
  316. strokeLinecap="round"
  317. strokeLinejoin="round"
  318. strokeWidth={2}
  319. 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"
  320. />
  321. </svg>
  322. Download Selected
  323. </button>
  324. <button
  325. onClick={handleDeleteSelected}
  326. disabled={table.getSelectedRowModel().rows.length === 0 || isLoading}
  327. 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"
  328. >
  329. <svg
  330. className="w-4 h-4 mr-2"
  331. fill="none"
  332. stroke="currentColor"
  333. viewBox="0 0 24 24"
  334. >
  335. <path
  336. strokeLinecap="round"
  337. strokeLinejoin="round"
  338. strokeWidth={2}
  339. 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"
  340. />
  341. </svg>
  342. Delete Selected
  343. </button>
  344. <button
  345. onClick={handleRefresh}
  346. disabled={isLoading}
  347. 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"
  348. >
  349. <svg
  350. className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`}
  351. fill="none"
  352. stroke="currentColor"
  353. viewBox="0 0 24 24"
  354. >
  355. <path
  356. strokeLinecap="round"
  357. strokeLinejoin="round"
  358. strokeWidth={2}
  359. 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"
  360. />
  361. </svg>
  362. Refresh
  363. </button>
  364. </div>
  365. </div>
  366. <div className="overflow-x-auto">
  367. <table className="min-w-full divide-y divide-gray-200">
  368. <thead className="bg-gray-50">
  369. {table.getHeaderGroups().map((headerGroup) => (
  370. <tr key={headerGroup.id}>
  371. {headerGroup.headers.map((header) => (
  372. <th
  373. key={header.id}
  374. className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
  375. onClick={header.column.getToggleSortingHandler()}
  376. >
  377. <div className="flex items-center">
  378. {flexRender(header.column.columnDef.header, header.getContext())}
  379. {header.column.getIsSorted() && (
  380. <span className="ml-1">
  381. {header.column.getIsSorted() === "asc" ? "↑" : "↓"}
  382. </span>
  383. )}
  384. </div>
  385. </th>
  386. ))}
  387. </tr>
  388. ))}
  389. </thead>
  390. <tbody className="bg-white divide-y divide-gray-200">
  391. {table.getRowModel().rows.map((row) => (
  392. <tr
  393. key={row.id}
  394. className={`hover:bg-gray-50 ${row.getIsSelected() ? 'bg-blue-50' : ''}`}
  395. >
  396. {row.getVisibleCells().map((cell) => (
  397. <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
  398. {flexRender(cell.column.columnDef.cell, cell.getContext())}
  399. </td>
  400. ))}
  401. </tr>
  402. ))}
  403. </tbody>
  404. </table>
  405. </div>
  406. {/* Pagination */}
  407. <div className="flex items-center justify-between mt-4">
  408. <div className="text-sm text-gray-700">
  409. Showing {table.getRowModel().rows.length} of {data.length} results
  410. </div>
  411. <div className="flex gap-2">
  412. <button
  413. onClick={() => table.previousPage()}
  414. disabled={!table.getCanPreviousPage()}
  415. className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
  416. >
  417. Previous
  418. </button>
  419. <button
  420. onClick={() => table.nextPage()}
  421. disabled={!table.getCanNextPage()}
  422. className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
  423. >
  424. Next
  425. </button>
  426. </div>
  427. </div>
  428. </div>
  429. </div>
  430. );
  431. }