@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.
- package/dist/__tests__/cli.test.d.ts +2 -0
- package/dist/__tests__/cli.test.d.ts.map +1 -0
- package/dist/__tests__/cli.test.js +173 -0
- package/dist/__tests__/cli.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +86 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/sail-installer.test.d.ts +2 -0
- package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
- package/dist/__tests__/sail-installer.test.js +158 -0
- package/dist/__tests__/sail-installer.test.js.map +1 -0
- package/dist/create-runner.d.ts +11 -0
- package/dist/create-runner.d.ts.map +1 -0
- package/dist/create-runner.js +63 -0
- package/dist/create-runner.js.map +1 -0
- package/dist/create.d.ts +10 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +15 -0
- package/dist/create.js.map +1 -0
- package/dist/manage.d.ts +24 -0
- package/dist/manage.d.ts.map +1 -0
- package/dist/manage.js +1461 -0
- package/dist/manage.js.map +1 -0
- package/dist/prompts.d.ts +36 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +208 -0
- package/dist/prompts.js.map +1 -0
- package/dist/sail-installer.d.ts +37 -0
- package/dist/sail-installer.d.ts.map +1 -0
- package/dist/sail-installer.js +935 -0
- package/dist/sail-installer.js.map +1 -0
- package/dist/scaffold.d.ts +10 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +297 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +57 -0
- package/sails/_template/addon.json +20 -0
- package/sails/_template/install.ts +402 -0
- package/sails/admin-dashboard/README.md +117 -0
- package/sails/admin-dashboard/addon.json +28 -0
- package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
- package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
- package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
- package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
- package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
- package/sails/admin-dashboard/install.ts +305 -0
- package/sails/analytics/README.md +178 -0
- package/sails/analytics/addon.json +27 -0
- package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
- package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
- package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
- package/sails/analytics/install.ts +297 -0
- package/sails/file-uploads/README.md +191 -0
- package/sails/file-uploads/addon.json +30 -0
- package/sails/file-uploads/files/backend/routes/files.ts +198 -0
- package/sails/file-uploads/files/backend/schema/files.ts +36 -0
- package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
- package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
- package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
- package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
- package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
- package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
- package/sails/file-uploads/install.ts +466 -0
- package/sails/gdpr/README.md +174 -0
- package/sails/gdpr/addon.json +27 -0
- package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
- package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
- package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
- package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
- package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
- package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
- package/sails/gdpr/install.ts +756 -0
- package/sails/google-oauth/README.md +121 -0
- package/sails/google-oauth/addon.json +22 -0
- package/sails/google-oauth/files/GoogleButton.tsx +50 -0
- package/sails/google-oauth/install.ts +252 -0
- package/sails/i18n/README.md +193 -0
- package/sails/i18n/addon.json +30 -0
- package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
- package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
- package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
- package/sails/i18n/files/frontend/locales/de/common.json +44 -0
- package/sails/i18n/files/frontend/locales/en/common.json +44 -0
- package/sails/i18n/install.ts +407 -0
- package/sails/push-notifications/README.md +163 -0
- package/sails/push-notifications/addon.json +31 -0
- package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
- package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
- package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
- package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
- package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
- package/sails/push-notifications/install.ts +384 -0
- package/sails/r2-storage/README.md +101 -0
- package/sails/r2-storage/addon.json +29 -0
- package/sails/r2-storage/files/backend/services/storage.ts +71 -0
- package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
- package/sails/r2-storage/install.ts +412 -0
- package/sails/rate-limiting/README.md +145 -0
- package/sails/rate-limiting/addon.json +20 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
- package/sails/rate-limiting/install.ts +300 -0
- package/sails/registry.json +107 -0
- package/sails/stripe/README.md +214 -0
- package/sails/stripe/addon.json +24 -0
- package/sails/stripe/files/backend/routes/stripe.ts +154 -0
- package/sails/stripe/files/backend/schema/stripe.ts +74 -0
- package/sails/stripe/files/backend/services/stripe.ts +224 -0
- package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
- package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
- package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
- package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
- 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
|
+
}
|