filesTable.tsx 17 KB

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