filesTable.tsx 17 KB

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