@codaijs/keel 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/__tests__/sail-installer.test.js +25 -25
  2. package/dist/sail-installer.js +174 -174
  3. package/dist/scaffold.js +68 -68
  4. package/package.json +58 -58
  5. package/sails/_template/addon.json +20 -20
  6. package/sails/_template/install.ts +402 -402
  7. package/sails/admin-dashboard/README.md +117 -117
  8. package/sails/admin-dashboard/addon.json +28 -28
  9. package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -34
  10. package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -243
  11. package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -40
  12. package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -240
  13. package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -149
  14. package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -173
  15. package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -203
  16. package/sails/admin-dashboard/install.ts +305 -305
  17. package/sails/analytics/README.md +178 -178
  18. package/sails/analytics/addon.json +27 -27
  19. package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -58
  20. package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -64
  21. package/sails/analytics/files/frontend/lib/analytics.ts +103 -103
  22. package/sails/analytics/install.ts +297 -297
  23. package/sails/file-uploads/addon.json +30 -30
  24. package/sails/file-uploads/files/backend/routes/files.ts +198 -198
  25. package/sails/file-uploads/files/backend/schema/files.ts +36 -36
  26. package/sails/file-uploads/files/backend/services/file-storage.ts +128 -128
  27. package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -248
  28. package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -147
  29. package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -106
  30. package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -118
  31. package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -37
  32. package/sails/file-uploads/install.ts +466 -466
  33. package/sails/gdpr/README.md +174 -174
  34. package/sails/gdpr/addon.json +27 -27
  35. package/sails/gdpr/files/backend/routes/gdpr.ts +140 -140
  36. package/sails/gdpr/files/backend/services/gdpr.ts +293 -293
  37. package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -97
  38. package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -192
  39. package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -75
  40. package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -186
  41. package/sails/gdpr/install.ts +756 -756
  42. package/sails/google-oauth/README.md +121 -121
  43. package/sails/google-oauth/addon.json +22 -22
  44. package/sails/google-oauth/files/GoogleButton.tsx +50 -50
  45. package/sails/google-oauth/install.ts +252 -252
  46. package/sails/i18n/README.md +193 -193
  47. package/sails/i18n/addon.json +30 -30
  48. package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -108
  49. package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -31
  50. package/sails/i18n/files/frontend/lib/i18n.ts +32 -32
  51. package/sails/i18n/files/frontend/locales/de/common.json +44 -44
  52. package/sails/i18n/files/frontend/locales/en/common.json +44 -44
  53. package/sails/i18n/install.ts +407 -407
  54. package/sails/push-notifications/README.md +163 -163
  55. package/sails/push-notifications/addon.json +31 -31
  56. package/sails/push-notifications/files/backend/routes/notifications.ts +153 -153
  57. package/sails/push-notifications/files/backend/schema/notifications.ts +31 -31
  58. package/sails/push-notifications/files/backend/services/notifications.ts +117 -117
  59. package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -12
  60. package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -154
  61. package/sails/push-notifications/install.ts +384 -384
  62. package/sails/r2-storage/addon.json +29 -29
  63. package/sails/r2-storage/files/backend/services/storage.ts +71 -71
  64. package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -167
  65. package/sails/r2-storage/install.ts +412 -412
  66. package/sails/rate-limiting/addon.json +20 -20
  67. package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -104
  68. package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -137
  69. package/sails/rate-limiting/install.ts +300 -300
  70. package/sails/registry.json +107 -107
  71. package/sails/stripe/README.md +214 -214
  72. package/sails/stripe/addon.json +24 -24
  73. package/sails/stripe/files/backend/routes/stripe.ts +154 -154
  74. package/sails/stripe/files/backend/schema/stripe.ts +74 -74
  75. package/sails/stripe/files/backend/services/stripe.ts +224 -224
  76. package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -135
  77. package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -86
  78. package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -116
  79. package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -226
  80. package/sails/stripe/install.ts +378 -378
@@ -1,248 +1,248 @@
1
- import { useState, useCallback } from "react";
2
- import { useFiles } from "@/hooks/useFiles";
3
-
4
- /**
5
- * Format a file size in bytes to a human-readable string.
6
- */
7
- function formatFileSize(bytes: number | null): string {
8
- if (bytes === null || bytes === 0) return "--";
9
- if (bytes < 1024) return `${bytes} B`;
10
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
11
- if (bytes < 1024 * 1024 * 1024)
12
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
14
- }
15
-
16
- /**
17
- * Format a date string to a short locale representation.
18
- */
19
- function formatDate(dateString: string): string {
20
- return new Date(dateString).toLocaleDateString(undefined, {
21
- year: "numeric",
22
- month: "short",
23
- day: "numeric",
24
- });
25
- }
26
-
27
- /**
28
- * Return a simple icon/label for a content type.
29
- */
30
- function fileTypeIcon(contentType: string | null): string {
31
- if (!contentType) return "FILE";
32
- if (contentType.startsWith("image/")) return "IMG";
33
- if (contentType.startsWith("video/")) return "VID";
34
- if (contentType.startsWith("audio/")) return "AUD";
35
- if (contentType === "application/pdf") return "PDF";
36
- if (contentType.includes("spreadsheet") || contentType.includes("excel"))
37
- return "XLS";
38
- if (contentType.includes("document") || contentType.includes("word"))
39
- return "DOC";
40
- if (contentType.includes("zip") || contentType.includes("compressed"))
41
- return "ZIP";
42
- return "FILE";
43
- }
44
-
45
- /**
46
- * File browser component.
47
- *
48
- * Displays the current user's files in a list view with download and delete
49
- * actions per file. Includes loading, empty, and error states.
50
- */
51
- export function FileList() {
52
- const { files, isLoading, error, deleteFile, getDownloadUrl, refresh } =
53
- useFiles();
54
- const [deletingId, setDeletingId] = useState<string | null>(null);
55
- const [downloadingId, setDownloadingId] = useState<string | null>(null);
56
-
57
- const handleDownload = useCallback(
58
- async (id: string, fileName: string) => {
59
- setDownloadingId(id);
60
- try {
61
- const url = await getDownloadUrl(id);
62
- if (url) {
63
- const a = document.createElement("a");
64
- a.href = url;
65
- a.download = fileName;
66
- a.target = "_blank";
67
- a.rel = "noopener noreferrer";
68
- document.body.appendChild(a);
69
- a.click();
70
- document.body.removeChild(a);
71
- }
72
- } finally {
73
- setDownloadingId(null);
74
- }
75
- },
76
- [getDownloadUrl],
77
- );
78
-
79
- const handleDelete = useCallback(
80
- async (id: string) => {
81
- if (!window.confirm("Are you sure you want to delete this file?")) {
82
- return;
83
- }
84
- setDeletingId(id);
85
- try {
86
- await deleteFile(id);
87
- } finally {
88
- setDeletingId(null);
89
- }
90
- },
91
- [deleteFile],
92
- );
93
-
94
- // -- Loading state --------------------------------------------------------
95
- if (isLoading) {
96
- return (
97
- <div className="flex items-center justify-center py-16">
98
- <div className="h-8 w-8 animate-spin rounded-full border-4 border-keel-gray-600 border-t-keel-blue" />
99
- </div>
100
- );
101
- }
102
-
103
- // -- Error state ----------------------------------------------------------
104
- if (error) {
105
- return (
106
- <div className="rounded-xl border border-red-500/30 bg-red-500/10 px-6 py-8 text-center">
107
- <p className="text-sm text-red-400">{error}</p>
108
- <button
109
- onClick={refresh}
110
- className="mt-3 text-sm font-medium text-keel-blue hover:underline"
111
- >
112
- Try again
113
- </button>
114
- </div>
115
- );
116
- }
117
-
118
- // -- Empty state ----------------------------------------------------------
119
- if (files.length === 0) {
120
- return (
121
- <div className="rounded-xl border border-keel-gray-800 px-6 py-16 text-center">
122
- <svg
123
- className="mx-auto mb-4 h-12 w-12 text-keel-gray-600"
124
- fill="none"
125
- viewBox="0 0 24 24"
126
- stroke="currentColor"
127
- strokeWidth={1}
128
- >
129
- <path
130
- strokeLinecap="round"
131
- strokeLinejoin="round"
132
- d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
133
- />
134
- </svg>
135
- <p className="text-sm text-keel-gray-400">No files uploaded yet.</p>
136
- <p className="mt-1 text-xs text-keel-gray-600">
137
- Upload a file to get started.
138
- </p>
139
- </div>
140
- );
141
- }
142
-
143
- // -- File list ------------------------------------------------------------
144
- return (
145
- <div className="overflow-hidden rounded-xl border border-keel-gray-800">
146
- <table className="w-full text-left text-sm">
147
- <thead>
148
- <tr className="border-b border-keel-gray-800 bg-keel-navy/50">
149
- <th className="px-4 py-3 font-medium text-keel-gray-400">Type</th>
150
- <th className="px-4 py-3 font-medium text-keel-gray-400">Name</th>
151
- <th className="hidden px-4 py-3 font-medium text-keel-gray-400 sm:table-cell">
152
- Size
153
- </th>
154
- <th className="hidden px-4 py-3 font-medium text-keel-gray-400 md:table-cell">
155
- Date
156
- </th>
157
- <th className="px-4 py-3 text-right font-medium text-keel-gray-400">
158
- Actions
159
- </th>
160
- </tr>
161
- </thead>
162
- <tbody>
163
- {files.map((file) => (
164
- <tr
165
- key={file.id}
166
- className="border-b border-keel-gray-800/50 last:border-b-0 hover:bg-keel-navy/30"
167
- >
168
- {/* Type badge */}
169
- <td className="px-4 py-3">
170
- <span className="inline-flex items-center rounded bg-keel-gray-800 px-2 py-0.5 text-xs font-medium text-keel-gray-300">
171
- {fileTypeIcon(file.contentType)}
172
- </span>
173
- </td>
174
-
175
- {/* Name */}
176
- <td className="max-w-[200px] truncate px-4 py-3 font-medium text-keel-gray-200">
177
- {file.fileName}
178
- </td>
179
-
180
- {/* Size */}
181
- <td className="hidden px-4 py-3 text-keel-gray-400 sm:table-cell">
182
- {formatFileSize(file.sizeBytes)}
183
- </td>
184
-
185
- {/* Date */}
186
- <td className="hidden px-4 py-3 text-keel-gray-400 md:table-cell">
187
- {formatDate(file.createdAt)}
188
- </td>
189
-
190
- {/* Actions */}
191
- <td className="px-4 py-3 text-right">
192
- <div className="flex items-center justify-end gap-2">
193
- {/* Download */}
194
- <button
195
- onClick={() => handleDownload(file.id, file.fileName)}
196
- disabled={downloadingId === file.id}
197
- className="rounded-lg p-1.5 text-keel-gray-400 transition-colors hover:bg-keel-gray-800 hover:text-keel-blue disabled:opacity-50"
198
- title="Download"
199
- >
200
- <svg
201
- className="h-4 w-4"
202
- fill="none"
203
- viewBox="0 0 24 24"
204
- stroke="currentColor"
205
- strokeWidth={2}
206
- >
207
- <path
208
- strokeLinecap="round"
209
- strokeLinejoin="round"
210
- d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
211
- />
212
- </svg>
213
- </button>
214
-
215
- {/* Delete */}
216
- <button
217
- onClick={() => handleDelete(file.id)}
218
- disabled={deletingId === file.id}
219
- className="rounded-lg p-1.5 text-keel-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400 disabled:opacity-50"
220
- title="Delete"
221
- >
222
- {deletingId === file.id ? (
223
- <div className="h-4 w-4 animate-spin rounded-full border-2 border-keel-gray-600 border-t-red-400" />
224
- ) : (
225
- <svg
226
- className="h-4 w-4"
227
- fill="none"
228
- viewBox="0 0 24 24"
229
- stroke="currentColor"
230
- strokeWidth={2}
231
- >
232
- <path
233
- strokeLinecap="round"
234
- strokeLinejoin="round"
235
- d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
236
- />
237
- </svg>
238
- )}
239
- </button>
240
- </div>
241
- </td>
242
- </tr>
243
- ))}
244
- </tbody>
245
- </table>
246
- </div>
247
- );
248
- }
1
+ import { useState, useCallback } from "react";
2
+ import { useFiles } from "@/hooks/useFiles";
3
+
4
+ /**
5
+ * Format a file size in bytes to a human-readable string.
6
+ */
7
+ function formatFileSize(bytes: number | null): string {
8
+ if (bytes === null || bytes === 0) return "--";
9
+ if (bytes < 1024) return `${bytes} B`;
10
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
11
+ if (bytes < 1024 * 1024 * 1024)
12
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
14
+ }
15
+
16
+ /**
17
+ * Format a date string to a short locale representation.
18
+ */
19
+ function formatDate(dateString: string): string {
20
+ return new Date(dateString).toLocaleDateString(undefined, {
21
+ year: "numeric",
22
+ month: "short",
23
+ day: "numeric",
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Return a simple icon/label for a content type.
29
+ */
30
+ function fileTypeIcon(contentType: string | null): string {
31
+ if (!contentType) return "FILE";
32
+ if (contentType.startsWith("image/")) return "IMG";
33
+ if (contentType.startsWith("video/")) return "VID";
34
+ if (contentType.startsWith("audio/")) return "AUD";
35
+ if (contentType === "application/pdf") return "PDF";
36
+ if (contentType.includes("spreadsheet") || contentType.includes("excel"))
37
+ return "XLS";
38
+ if (contentType.includes("document") || contentType.includes("word"))
39
+ return "DOC";
40
+ if (contentType.includes("zip") || contentType.includes("compressed"))
41
+ return "ZIP";
42
+ return "FILE";
43
+ }
44
+
45
+ /**
46
+ * File browser component.
47
+ *
48
+ * Displays the current user's files in a list view with download and delete
49
+ * actions per file. Includes loading, empty, and error states.
50
+ */
51
+ export function FileList() {
52
+ const { files, isLoading, error, deleteFile, getDownloadUrl, refresh } =
53
+ useFiles();
54
+ const [deletingId, setDeletingId] = useState<string | null>(null);
55
+ const [downloadingId, setDownloadingId] = useState<string | null>(null);
56
+
57
+ const handleDownload = useCallback(
58
+ async (id: string, fileName: string) => {
59
+ setDownloadingId(id);
60
+ try {
61
+ const url = await getDownloadUrl(id);
62
+ if (url) {
63
+ const a = document.createElement("a");
64
+ a.href = url;
65
+ a.download = fileName;
66
+ a.target = "_blank";
67
+ a.rel = "noopener noreferrer";
68
+ document.body.appendChild(a);
69
+ a.click();
70
+ document.body.removeChild(a);
71
+ }
72
+ } finally {
73
+ setDownloadingId(null);
74
+ }
75
+ },
76
+ [getDownloadUrl],
77
+ );
78
+
79
+ const handleDelete = useCallback(
80
+ async (id: string) => {
81
+ if (!window.confirm("Are you sure you want to delete this file?")) {
82
+ return;
83
+ }
84
+ setDeletingId(id);
85
+ try {
86
+ await deleteFile(id);
87
+ } finally {
88
+ setDeletingId(null);
89
+ }
90
+ },
91
+ [deleteFile],
92
+ );
93
+
94
+ // -- Loading state --------------------------------------------------------
95
+ if (isLoading) {
96
+ return (
97
+ <div className="flex items-center justify-center py-16">
98
+ <div className="h-8 w-8 animate-spin rounded-full border-4 border-keel-gray-600 border-t-keel-blue" />
99
+ </div>
100
+ );
101
+ }
102
+
103
+ // -- Error state ----------------------------------------------------------
104
+ if (error) {
105
+ return (
106
+ <div className="rounded-xl border border-red-500/30 bg-red-500/10 px-6 py-8 text-center">
107
+ <p className="text-sm text-red-400">{error}</p>
108
+ <button
109
+ onClick={refresh}
110
+ className="mt-3 text-sm font-medium text-keel-blue hover:underline"
111
+ >
112
+ Try again
113
+ </button>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ // -- Empty state ----------------------------------------------------------
119
+ if (files.length === 0) {
120
+ return (
121
+ <div className="rounded-xl border border-keel-gray-800 px-6 py-16 text-center">
122
+ <svg
123
+ className="mx-auto mb-4 h-12 w-12 text-keel-gray-600"
124
+ fill="none"
125
+ viewBox="0 0 24 24"
126
+ stroke="currentColor"
127
+ strokeWidth={1}
128
+ >
129
+ <path
130
+ strokeLinecap="round"
131
+ strokeLinejoin="round"
132
+ d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
133
+ />
134
+ </svg>
135
+ <p className="text-sm text-keel-gray-400">No files uploaded yet.</p>
136
+ <p className="mt-1 text-xs text-keel-gray-600">
137
+ Upload a file to get started.
138
+ </p>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ // -- File list ------------------------------------------------------------
144
+ return (
145
+ <div className="overflow-hidden rounded-xl border border-keel-gray-800">
146
+ <table className="w-full text-left text-sm">
147
+ <thead>
148
+ <tr className="border-b border-keel-gray-800 bg-keel-navy/50">
149
+ <th className="px-4 py-3 font-medium text-keel-gray-400">Type</th>
150
+ <th className="px-4 py-3 font-medium text-keel-gray-400">Name</th>
151
+ <th className="hidden px-4 py-3 font-medium text-keel-gray-400 sm:table-cell">
152
+ Size
153
+ </th>
154
+ <th className="hidden px-4 py-3 font-medium text-keel-gray-400 md:table-cell">
155
+ Date
156
+ </th>
157
+ <th className="px-4 py-3 text-right font-medium text-keel-gray-400">
158
+ Actions
159
+ </th>
160
+ </tr>
161
+ </thead>
162
+ <tbody>
163
+ {files.map((file) => (
164
+ <tr
165
+ key={file.id}
166
+ className="border-b border-keel-gray-800/50 last:border-b-0 hover:bg-keel-navy/30"
167
+ >
168
+ {/* Type badge */}
169
+ <td className="px-4 py-3">
170
+ <span className="inline-flex items-center rounded bg-keel-gray-800 px-2 py-0.5 text-xs font-medium text-keel-gray-300">
171
+ {fileTypeIcon(file.contentType)}
172
+ </span>
173
+ </td>
174
+
175
+ {/* Name */}
176
+ <td className="max-w-[200px] truncate px-4 py-3 font-medium text-keel-gray-200">
177
+ {file.fileName}
178
+ </td>
179
+
180
+ {/* Size */}
181
+ <td className="hidden px-4 py-3 text-keel-gray-400 sm:table-cell">
182
+ {formatFileSize(file.sizeBytes)}
183
+ </td>
184
+
185
+ {/* Date */}
186
+ <td className="hidden px-4 py-3 text-keel-gray-400 md:table-cell">
187
+ {formatDate(file.createdAt)}
188
+ </td>
189
+
190
+ {/* Actions */}
191
+ <td className="px-4 py-3 text-right">
192
+ <div className="flex items-center justify-end gap-2">
193
+ {/* Download */}
194
+ <button
195
+ onClick={() => handleDownload(file.id, file.fileName)}
196
+ disabled={downloadingId === file.id}
197
+ className="rounded-lg p-1.5 text-keel-gray-400 transition-colors hover:bg-keel-gray-800 hover:text-keel-blue disabled:opacity-50"
198
+ title="Download"
199
+ >
200
+ <svg
201
+ className="h-4 w-4"
202
+ fill="none"
203
+ viewBox="0 0 24 24"
204
+ stroke="currentColor"
205
+ strokeWidth={2}
206
+ >
207
+ <path
208
+ strokeLinecap="round"
209
+ strokeLinejoin="round"
210
+ d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
211
+ />
212
+ </svg>
213
+ </button>
214
+
215
+ {/* Delete */}
216
+ <button
217
+ onClick={() => handleDelete(file.id)}
218
+ disabled={deletingId === file.id}
219
+ className="rounded-lg p-1.5 text-keel-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400 disabled:opacity-50"
220
+ title="Delete"
221
+ >
222
+ {deletingId === file.id ? (
223
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-keel-gray-600 border-t-red-400" />
224
+ ) : (
225
+ <svg
226
+ className="h-4 w-4"
227
+ fill="none"
228
+ viewBox="0 0 24 24"
229
+ stroke="currentColor"
230
+ strokeWidth={2}
231
+ >
232
+ <path
233
+ strokeLinecap="round"
234
+ strokeLinejoin="round"
235
+ d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
236
+ />
237
+ </svg>
238
+ )}
239
+ </button>
240
+ </div>
241
+ </td>
242
+ </tr>
243
+ ))}
244
+ </tbody>
245
+ </table>
246
+ </div>
247
+ );
248
+ }