@codaijs/keel 0.1.0

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 (116) hide show
  1. package/dist/__tests__/cli.test.d.ts +2 -0
  2. package/dist/__tests__/cli.test.d.ts.map +1 -0
  3. package/dist/__tests__/cli.test.js +173 -0
  4. package/dist/__tests__/cli.test.js.map +1 -0
  5. package/dist/__tests__/registry.test.d.ts +2 -0
  6. package/dist/__tests__/registry.test.d.ts.map +1 -0
  7. package/dist/__tests__/registry.test.js +86 -0
  8. package/dist/__tests__/registry.test.js.map +1 -0
  9. package/dist/__tests__/sail-installer.test.d.ts +2 -0
  10. package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
  11. package/dist/__tests__/sail-installer.test.js +158 -0
  12. package/dist/__tests__/sail-installer.test.js.map +1 -0
  13. package/dist/create-runner.d.ts +11 -0
  14. package/dist/create-runner.d.ts.map +1 -0
  15. package/dist/create-runner.js +63 -0
  16. package/dist/create-runner.js.map +1 -0
  17. package/dist/create.d.ts +10 -0
  18. package/dist/create.d.ts.map +1 -0
  19. package/dist/create.js +15 -0
  20. package/dist/create.js.map +1 -0
  21. package/dist/manage.d.ts +24 -0
  22. package/dist/manage.d.ts.map +1 -0
  23. package/dist/manage.js +1461 -0
  24. package/dist/manage.js.map +1 -0
  25. package/dist/prompts.d.ts +36 -0
  26. package/dist/prompts.d.ts.map +1 -0
  27. package/dist/prompts.js +208 -0
  28. package/dist/prompts.js.map +1 -0
  29. package/dist/sail-installer.d.ts +37 -0
  30. package/dist/sail-installer.d.ts.map +1 -0
  31. package/dist/sail-installer.js +935 -0
  32. package/dist/sail-installer.js.map +1 -0
  33. package/dist/scaffold.d.ts +10 -0
  34. package/dist/scaffold.d.ts.map +1 -0
  35. package/dist/scaffold.js +297 -0
  36. package/dist/scaffold.js.map +1 -0
  37. package/package.json +57 -0
  38. package/sails/_template/addon.json +20 -0
  39. package/sails/_template/install.ts +402 -0
  40. package/sails/admin-dashboard/README.md +117 -0
  41. package/sails/admin-dashboard/addon.json +28 -0
  42. package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
  43. package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
  44. package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
  45. package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
  46. package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
  47. package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
  48. package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
  49. package/sails/admin-dashboard/install.ts +305 -0
  50. package/sails/analytics/README.md +178 -0
  51. package/sails/analytics/addon.json +27 -0
  52. package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
  53. package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
  54. package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
  55. package/sails/analytics/install.ts +297 -0
  56. package/sails/file-uploads/README.md +191 -0
  57. package/sails/file-uploads/addon.json +30 -0
  58. package/sails/file-uploads/files/backend/routes/files.ts +198 -0
  59. package/sails/file-uploads/files/backend/schema/files.ts +36 -0
  60. package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
  61. package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
  62. package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
  63. package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
  64. package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
  65. package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
  66. package/sails/file-uploads/install.ts +466 -0
  67. package/sails/gdpr/README.md +174 -0
  68. package/sails/gdpr/addon.json +27 -0
  69. package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
  70. package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
  71. package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
  72. package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
  73. package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
  74. package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
  75. package/sails/gdpr/install.ts +756 -0
  76. package/sails/google-oauth/README.md +121 -0
  77. package/sails/google-oauth/addon.json +22 -0
  78. package/sails/google-oauth/files/GoogleButton.tsx +50 -0
  79. package/sails/google-oauth/install.ts +252 -0
  80. package/sails/i18n/README.md +193 -0
  81. package/sails/i18n/addon.json +30 -0
  82. package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
  83. package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
  84. package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
  85. package/sails/i18n/files/frontend/locales/de/common.json +44 -0
  86. package/sails/i18n/files/frontend/locales/en/common.json +44 -0
  87. package/sails/i18n/install.ts +407 -0
  88. package/sails/push-notifications/README.md +163 -0
  89. package/sails/push-notifications/addon.json +31 -0
  90. package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
  91. package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
  92. package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
  93. package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
  94. package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
  95. package/sails/push-notifications/install.ts +384 -0
  96. package/sails/r2-storage/README.md +101 -0
  97. package/sails/r2-storage/addon.json +29 -0
  98. package/sails/r2-storage/files/backend/services/storage.ts +71 -0
  99. package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
  100. package/sails/r2-storage/install.ts +412 -0
  101. package/sails/rate-limiting/README.md +145 -0
  102. package/sails/rate-limiting/addon.json +20 -0
  103. package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
  104. package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
  105. package/sails/rate-limiting/install.ts +300 -0
  106. package/sails/registry.json +107 -0
  107. package/sails/stripe/README.md +214 -0
  108. package/sails/stripe/addon.json +24 -0
  109. package/sails/stripe/files/backend/routes/stripe.ts +154 -0
  110. package/sails/stripe/files/backend/schema/stripe.ts +74 -0
  111. package/sails/stripe/files/backend/services/stripe.ts +224 -0
  112. package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
  113. package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
  114. package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
  115. package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
  116. package/sails/stripe/install.ts +378 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * File management API routes.
3
+ *
4
+ * All routes require authentication. Files are scoped per user.
5
+ *
6
+ * Endpoints:
7
+ * POST /upload-url -- generate a presigned upload URL
8
+ * GET / -- list the current user's files
9
+ * GET /:fileId -- get file metadata + download URL
10
+ * DELETE /:fileId -- delete a file
11
+ */
12
+
13
+ import { Router, type Request, type Response } from "express";
14
+ import { eq, and } from "drizzle-orm";
15
+ import { db } from "../db/index.js";
16
+ import { files } from "../db/schema/files.js";
17
+ import { requireAuth } from "../middleware/auth.js";
18
+ import {
19
+ generateUploadUrl,
20
+ generateDownloadUrl,
21
+ deleteFile as deleteFromStorage,
22
+ } from "../services/file-storage.js";
23
+
24
+ export const filesRouter = Router();
25
+
26
+ // All file routes require authentication.
27
+ filesRouter.use(requireAuth);
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // POST /upload-url -- generate presigned upload URL
31
+ // ---------------------------------------------------------------------------
32
+
33
+ filesRouter.post("/upload-url", async (req: Request, res: Response) => {
34
+ try {
35
+ const { fileName, contentType, maxSize } = req.body as {
36
+ fileName?: string;
37
+ contentType?: string;
38
+ maxSize?: number;
39
+ };
40
+
41
+ if (!fileName || typeof fileName !== "string") {
42
+ res.status(400).json({ error: "fileName is required" });
43
+ return;
44
+ }
45
+
46
+ if (!contentType || typeof contentType !== "string") {
47
+ res.status(400).json({ error: "contentType is required" });
48
+ return;
49
+ }
50
+
51
+ const userId = req.user!.id;
52
+ const { uploadUrl, key } = await generateUploadUrl(
53
+ userId,
54
+ fileName,
55
+ contentType,
56
+ maxSize,
57
+ );
58
+
59
+ // Create a file record in the database so we can track it.
60
+ const [fileRecord] = await db
61
+ .insert(files)
62
+ .values({
63
+ id: crypto.randomUUID(),
64
+ userId,
65
+ key,
66
+ fileName,
67
+ contentType,
68
+ sizeBytes: maxSize ?? null,
69
+ })
70
+ .returning();
71
+
72
+ res.json({
73
+ uploadUrl,
74
+ file: {
75
+ id: fileRecord.id,
76
+ key: fileRecord.key,
77
+ fileName: fileRecord.fileName,
78
+ },
79
+ });
80
+ } catch (error) {
81
+ console.error("Error generating upload URL:", error);
82
+ res.status(500).json({ error: "Failed to generate upload URL" });
83
+ }
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // GET / -- list user's files
88
+ // ---------------------------------------------------------------------------
89
+
90
+ filesRouter.get("/", async (req: Request, res: Response) => {
91
+ try {
92
+ const userId = req.user!.id;
93
+ const prefix = req.query.prefix as string | undefined;
94
+
95
+ let query = db
96
+ .select()
97
+ .from(files)
98
+ .where(eq(files.userId, userId))
99
+ .$dynamic();
100
+
101
+ if (prefix) {
102
+ query = query.where(
103
+ and(eq(files.userId, userId)),
104
+ );
105
+ }
106
+
107
+ const userFiles = await db
108
+ .select()
109
+ .from(files)
110
+ .where(eq(files.userId, userId))
111
+ .orderBy(files.createdAt);
112
+
113
+ res.json({
114
+ files: userFiles.map((f) => ({
115
+ id: f.id,
116
+ fileName: f.fileName,
117
+ contentType: f.contentType,
118
+ sizeBytes: f.sizeBytes,
119
+ createdAt: f.createdAt,
120
+ })),
121
+ });
122
+ } catch (error) {
123
+ console.error("Error listing files:", error);
124
+ res.status(500).json({ error: "Failed to list files" });
125
+ }
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // GET /:fileId -- get file metadata + download URL
130
+ // ---------------------------------------------------------------------------
131
+
132
+ filesRouter.get("/:fileId", async (req: Request, res: Response) => {
133
+ try {
134
+ const userId = req.user!.id;
135
+ const { fileId } = req.params;
136
+
137
+ const file = await db.query.files.findFirst({
138
+ where: and(eq(files.id, fileId), eq(files.userId, userId)),
139
+ });
140
+
141
+ if (!file) {
142
+ res.status(404).json({ error: "File not found" });
143
+ return;
144
+ }
145
+
146
+ const downloadUrl = await generateDownloadUrl(file.key);
147
+
148
+ res.json({
149
+ file: {
150
+ id: file.id,
151
+ fileName: file.fileName,
152
+ contentType: file.contentType,
153
+ sizeBytes: file.sizeBytes,
154
+ createdAt: file.createdAt,
155
+ downloadUrl,
156
+ },
157
+ });
158
+ } catch (error) {
159
+ console.error("Error getting file:", error);
160
+ res.status(500).json({ error: "Failed to get file" });
161
+ }
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // DELETE /:fileId -- delete a file
166
+ // ---------------------------------------------------------------------------
167
+
168
+ filesRouter.delete("/:fileId", async (req: Request, res: Response) => {
169
+ try {
170
+ const userId = req.user!.id;
171
+ const { fileId } = req.params;
172
+
173
+ const file = await db.query.files.findFirst({
174
+ where: and(eq(files.id, fileId), eq(files.userId, userId)),
175
+ });
176
+
177
+ if (!file) {
178
+ res.status(404).json({ error: "File not found" });
179
+ return;
180
+ }
181
+
182
+ // Delete from S3-compatible storage.
183
+ try {
184
+ await deleteFromStorage(file.key);
185
+ } catch (err) {
186
+ console.error("Warning: failed to delete file from storage:", err);
187
+ // Continue with DB deletion even if storage deletion fails.
188
+ }
189
+
190
+ // Delete from database.
191
+ await db.delete(files).where(eq(files.id, fileId));
192
+
193
+ res.json({ success: true });
194
+ } catch (error) {
195
+ console.error("Error deleting file:", error);
196
+ res.status(500).json({ error: "Failed to delete file" });
197
+ }
198
+ });
@@ -0,0 +1,36 @@
1
+ import {
2
+ pgTable,
3
+ text,
4
+ varchar,
5
+ integer,
6
+ timestamp,
7
+ } from "drizzle-orm/pg-core";
8
+ import { relations } from "drizzle-orm";
9
+ import { users } from "./users.js";
10
+
11
+ /**
12
+ * Files table.
13
+ *
14
+ * Tracks uploaded files and their S3-compatible storage keys. Each file
15
+ * belongs to a user and is deleted when the user is deleted (cascade).
16
+ */
17
+ export const files = pgTable("files", {
18
+ id: text("id").primaryKey(),
19
+ userId: text("user_id")
20
+ .notNull()
21
+ .references(() => users.id, { onDelete: "cascade" }),
22
+ key: text("key").notNull(),
23
+ fileName: text("file_name").notNull(),
24
+ contentType: varchar("content_type", { length: 100 }),
25
+ sizeBytes: integer("size_bytes"),
26
+ createdAt: timestamp("created_at", { withTimezone: true })
27
+ .notNull()
28
+ .defaultNow(),
29
+ });
30
+
31
+ export const filesRelations = relations(files, ({ one }) => ({
32
+ user: one(users, {
33
+ fields: [files.userId],
34
+ references: [users.id],
35
+ }),
36
+ }));
@@ -0,0 +1,128 @@
1
+ /**
2
+ * S3-compatible file storage service.
3
+ *
4
+ * Works with Cloudflare R2, AWS S3, MinIO, and any other S3-compatible
5
+ * provider. Configured entirely via environment variables.
6
+ */
7
+
8
+ import {
9
+ S3Client,
10
+ PutObjectCommand,
11
+ GetObjectCommand,
12
+ DeleteObjectCommand,
13
+ ListObjectsV2Command,
14
+ } from "@aws-sdk/client-s3";
15
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
16
+ import { env } from "../env.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Client
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const s3Client = new S3Client({
23
+ region: env.S3_REGION,
24
+ endpoint: env.S3_ENDPOINT,
25
+ credentials: {
26
+ accessKeyId: env.S3_ACCESS_KEY_ID,
27
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
28
+ },
29
+ // Required for some S3-compatible providers (R2, MinIO)
30
+ forcePathStyle: true,
31
+ });
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Constants
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const UPLOAD_URL_EXPIRY = 60 * 10; // 10 minutes
38
+ const DOWNLOAD_URL_EXPIRY = 60 * 60; // 1 hour
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Sanitise a file name for use as an S3 key segment.
46
+ * Removes path separators and other problematic characters.
47
+ */
48
+ function sanitizeFileName(name: string): string {
49
+ return name.replace(/[^a-zA-Z0-9._-]/g, "_");
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Public API
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Generate a presigned PUT URL for uploading a file.
58
+ *
59
+ * @returns The presigned upload URL and the object key that will be stored.
60
+ */
61
+ export async function generateUploadUrl(
62
+ userId: string,
63
+ fileName: string,
64
+ contentType: string,
65
+ maxSizeBytes?: number,
66
+ ): Promise<{ uploadUrl: string; key: string }> {
67
+ const sanitized = sanitizeFileName(fileName);
68
+ const key = `${userId}/${Date.now()}-${sanitized}`;
69
+
70
+ const command = new PutObjectCommand({
71
+ Bucket: env.S3_BUCKET_NAME,
72
+ Key: key,
73
+ ContentType: contentType,
74
+ ...(maxSizeBytes ? { ContentLength: maxSizeBytes } : {}),
75
+ });
76
+
77
+ const uploadUrl = await getSignedUrl(s3Client, command, {
78
+ expiresIn: UPLOAD_URL_EXPIRY,
79
+ });
80
+
81
+ return { uploadUrl, key };
82
+ }
83
+
84
+ /**
85
+ * Generate a presigned GET URL for downloading / viewing a file.
86
+ */
87
+ export async function generateDownloadUrl(key: string): Promise<string> {
88
+ const command = new GetObjectCommand({
89
+ Bucket: env.S3_BUCKET_NAME,
90
+ Key: key,
91
+ });
92
+
93
+ return getSignedUrl(s3Client, command, {
94
+ expiresIn: DOWNLOAD_URL_EXPIRY,
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Delete an object from the bucket.
100
+ */
101
+ export async function deleteFile(key: string): Promise<void> {
102
+ const command = new DeleteObjectCommand({
103
+ Bucket: env.S3_BUCKET_NAME,
104
+ Key: key,
105
+ });
106
+
107
+ await s3Client.send(command);
108
+ }
109
+
110
+ /**
111
+ * List objects under a prefix (e.g., a user's directory).
112
+ */
113
+ export async function listFiles(
114
+ prefix: string,
115
+ ): Promise<{ key: string; size: number; lastModified: Date | undefined }[]> {
116
+ const command = new ListObjectsV2Command({
117
+ Bucket: env.S3_BUCKET_NAME,
118
+ Prefix: prefix,
119
+ });
120
+
121
+ const response = await s3Client.send(command);
122
+
123
+ return (response.Contents ?? []).map((obj) => ({
124
+ key: obj.Key!,
125
+ size: obj.Size ?? 0,
126
+ lastModified: obj.LastModified,
127
+ }));
128
+ }
@@ -0,0 +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
+ }