@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.
- package/dist/__tests__/sail-installer.test.js +25 -25
- package/dist/sail-installer.js +174 -174
- package/dist/scaffold.js +68 -68
- package/package.json +58 -58
- package/sails/_template/addon.json +20 -20
- package/sails/_template/install.ts +402 -402
- package/sails/admin-dashboard/README.md +117 -117
- package/sails/admin-dashboard/addon.json +28 -28
- package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -34
- package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -243
- package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -40
- package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -240
- package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -149
- package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -173
- package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -203
- package/sails/admin-dashboard/install.ts +305 -305
- package/sails/analytics/README.md +178 -178
- package/sails/analytics/addon.json +27 -27
- package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -58
- package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -64
- package/sails/analytics/files/frontend/lib/analytics.ts +103 -103
- package/sails/analytics/install.ts +297 -297
- package/sails/file-uploads/addon.json +30 -30
- package/sails/file-uploads/files/backend/routes/files.ts +198 -198
- package/sails/file-uploads/files/backend/schema/files.ts +36 -36
- package/sails/file-uploads/files/backend/services/file-storage.ts +128 -128
- package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -248
- package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -147
- package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -106
- package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -118
- package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -37
- package/sails/file-uploads/install.ts +466 -466
- package/sails/gdpr/README.md +174 -174
- package/sails/gdpr/addon.json +27 -27
- package/sails/gdpr/files/backend/routes/gdpr.ts +140 -140
- package/sails/gdpr/files/backend/services/gdpr.ts +293 -293
- package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -97
- package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -192
- package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -75
- package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -186
- package/sails/gdpr/install.ts +756 -756
- package/sails/google-oauth/README.md +121 -121
- package/sails/google-oauth/addon.json +22 -22
- package/sails/google-oauth/files/GoogleButton.tsx +50 -50
- package/sails/google-oauth/install.ts +252 -252
- package/sails/i18n/README.md +193 -193
- package/sails/i18n/addon.json +30 -30
- package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -108
- package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -31
- package/sails/i18n/files/frontend/lib/i18n.ts +32 -32
- package/sails/i18n/files/frontend/locales/de/common.json +44 -44
- package/sails/i18n/files/frontend/locales/en/common.json +44 -44
- package/sails/i18n/install.ts +407 -407
- package/sails/push-notifications/README.md +163 -163
- package/sails/push-notifications/addon.json +31 -31
- package/sails/push-notifications/files/backend/routes/notifications.ts +153 -153
- package/sails/push-notifications/files/backend/schema/notifications.ts +31 -31
- package/sails/push-notifications/files/backend/services/notifications.ts +117 -117
- package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -12
- package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -154
- package/sails/push-notifications/install.ts +384 -384
- package/sails/r2-storage/addon.json +29 -29
- package/sails/r2-storage/files/backend/services/storage.ts +71 -71
- package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -167
- package/sails/r2-storage/install.ts +412 -412
- package/sails/rate-limiting/addon.json +20 -20
- package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -104
- package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -137
- package/sails/rate-limiting/install.ts +300 -300
- package/sails/registry.json +107 -107
- package/sails/stripe/README.md +214 -214
- package/sails/stripe/addon.json +24 -24
- package/sails/stripe/files/backend/routes/stripe.ts +154 -154
- package/sails/stripe/files/backend/schema/stripe.ts +74 -74
- package/sails/stripe/files/backend/services/stripe.ts +224 -224
- package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -135
- package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -86
- package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -116
- package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -226
- package/sails/stripe/install.ts +378 -378
|
@@ -1,147 +1,147 @@
|
|
|
1
|
-
import { useState, useRef, useCallback } from "react";
|
|
2
|
-
import { useFileUpload } from "@/hooks/useFileUpload";
|
|
3
|
-
|
|
4
|
-
interface FileUploadButtonProps {
|
|
5
|
-
/** Accepted MIME types (e.g., "image/*,.pdf"). Defaults to all files. */
|
|
6
|
-
accept?: string;
|
|
7
|
-
/** Maximum file size in bytes. Defaults to 50 MB. */
|
|
8
|
-
maxSize?: number;
|
|
9
|
-
/** Callback invoked when an upload completes successfully. */
|
|
10
|
-
onUploadComplete?: (file: { id: string; fileName: string }) => void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* File upload button with drag-and-drop support.
|
|
17
|
-
*
|
|
18
|
-
* Displays an upload zone that accepts clicks and drag-and-drop. Shows upload
|
|
19
|
-
* progress while a file is being transferred.
|
|
20
|
-
*/
|
|
21
|
-
export function FileUploadButton({
|
|
22
|
-
accept,
|
|
23
|
-
maxSize = DEFAULT_MAX_SIZE,
|
|
24
|
-
onUploadComplete,
|
|
25
|
-
}: FileUploadButtonProps) {
|
|
26
|
-
const { upload, isUploading, progress, error } = useFileUpload();
|
|
27
|
-
const [isDragOver, setIsDragOver] = useState(false);
|
|
28
|
-
const [validationError, setValidationError] = useState<string | null>(null);
|
|
29
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
30
|
-
|
|
31
|
-
const handleFile = useCallback(
|
|
32
|
-
async (file: File) => {
|
|
33
|
-
setValidationError(null);
|
|
34
|
-
|
|
35
|
-
if (file.size > maxSize) {
|
|
36
|
-
const mb = Math.round(maxSize / 1024 / 1024);
|
|
37
|
-
setValidationError(`File size must be less than ${mb} MB.`);
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const result = await upload(file);
|
|
42
|
-
if (result) {
|
|
43
|
-
onUploadComplete?.(result);
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
[upload, maxSize, onUploadComplete],
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
50
|
-
const file = e.target.files?.[0];
|
|
51
|
-
if (file) {
|
|
52
|
-
handleFile(file);
|
|
53
|
-
}
|
|
54
|
-
// Reset the input so the same file can be selected again.
|
|
55
|
-
e.target.value = "";
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const handleDrop = (e: React.DragEvent) => {
|
|
59
|
-
e.preventDefault();
|
|
60
|
-
setIsDragOver(false);
|
|
61
|
-
const file = e.dataTransfer.files?.[0];
|
|
62
|
-
if (file) {
|
|
63
|
-
handleFile(file);
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const handleDragOver = (e: React.DragEvent) => {
|
|
68
|
-
e.preventDefault();
|
|
69
|
-
setIsDragOver(true);
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const handleDragLeave = (e: React.DragEvent) => {
|
|
73
|
-
e.preventDefault();
|
|
74
|
-
setIsDragOver(false);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const displayError = validationError ?? error;
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<div className="w-full">
|
|
81
|
-
<button
|
|
82
|
-
type="button"
|
|
83
|
-
onClick={() => fileInputRef.current?.click()}
|
|
84
|
-
onDrop={handleDrop}
|
|
85
|
-
onDragOver={handleDragOver}
|
|
86
|
-
onDragLeave={handleDragLeave}
|
|
87
|
-
disabled={isUploading}
|
|
88
|
-
className={`flex w-full flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-10 transition-colors ${
|
|
89
|
-
isDragOver
|
|
90
|
-
? "border-keel-blue bg-keel-blue/10"
|
|
91
|
-
: "border-keel-gray-700 hover:border-keel-gray-500"
|
|
92
|
-
} ${isUploading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
|
93
|
-
>
|
|
94
|
-
{isUploading ? (
|
|
95
|
-
<>
|
|
96
|
-
{/* Progress indicator */}
|
|
97
|
-
<div className="mb-3 h-10 w-10 animate-spin rounded-full border-4 border-keel-gray-600 border-t-keel-blue" />
|
|
98
|
-
<p className="text-sm font-medium text-keel-gray-300">
|
|
99
|
-
Uploading... {progress}%
|
|
100
|
-
</p>
|
|
101
|
-
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-keel-gray-800">
|
|
102
|
-
<div
|
|
103
|
-
className="h-full rounded-full bg-keel-blue transition-all duration-300"
|
|
104
|
-
style={{ width: `${progress}%` }}
|
|
105
|
-
/>
|
|
106
|
-
</div>
|
|
107
|
-
</>
|
|
108
|
-
) : (
|
|
109
|
-
<>
|
|
110
|
-
{/* Upload icon */}
|
|
111
|
-
<svg
|
|
112
|
-
className="mb-3 h-10 w-10 text-keel-gray-500"
|
|
113
|
-
fill="none"
|
|
114
|
-
viewBox="0 0 24 24"
|
|
115
|
-
stroke="currentColor"
|
|
116
|
-
strokeWidth={1.5}
|
|
117
|
-
>
|
|
118
|
-
<path
|
|
119
|
-
strokeLinecap="round"
|
|
120
|
-
strokeLinejoin="round"
|
|
121
|
-
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
|
122
|
-
/>
|
|
123
|
-
</svg>
|
|
124
|
-
<p className="text-sm font-medium text-keel-gray-300">
|
|
125
|
-
Click to upload or drag and drop
|
|
126
|
-
</p>
|
|
127
|
-
<p className="mt-1 text-xs text-keel-gray-500">
|
|
128
|
-
Max file size: {Math.round(maxSize / 1024 / 1024)} MB
|
|
129
|
-
</p>
|
|
130
|
-
</>
|
|
131
|
-
)}
|
|
132
|
-
</button>
|
|
133
|
-
|
|
134
|
-
<input
|
|
135
|
-
ref={fileInputRef}
|
|
136
|
-
type="file"
|
|
137
|
-
accept={accept}
|
|
138
|
-
onChange={handleInputChange}
|
|
139
|
-
className="hidden"
|
|
140
|
-
/>
|
|
141
|
-
|
|
142
|
-
{displayError && (
|
|
143
|
-
<p className="mt-2 text-sm text-red-400">{displayError}</p>
|
|
144
|
-
)}
|
|
145
|
-
</div>
|
|
146
|
-
);
|
|
147
|
-
}
|
|
1
|
+
import { useState, useRef, useCallback } from "react";
|
|
2
|
+
import { useFileUpload } from "@/hooks/useFileUpload";
|
|
3
|
+
|
|
4
|
+
interface FileUploadButtonProps {
|
|
5
|
+
/** Accepted MIME types (e.g., "image/*,.pdf"). Defaults to all files. */
|
|
6
|
+
accept?: string;
|
|
7
|
+
/** Maximum file size in bytes. Defaults to 50 MB. */
|
|
8
|
+
maxSize?: number;
|
|
9
|
+
/** Callback invoked when an upload completes successfully. */
|
|
10
|
+
onUploadComplete?: (file: { id: string; fileName: string }) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* File upload button with drag-and-drop support.
|
|
17
|
+
*
|
|
18
|
+
* Displays an upload zone that accepts clicks and drag-and-drop. Shows upload
|
|
19
|
+
* progress while a file is being transferred.
|
|
20
|
+
*/
|
|
21
|
+
export function FileUploadButton({
|
|
22
|
+
accept,
|
|
23
|
+
maxSize = DEFAULT_MAX_SIZE,
|
|
24
|
+
onUploadComplete,
|
|
25
|
+
}: FileUploadButtonProps) {
|
|
26
|
+
const { upload, isUploading, progress, error } = useFileUpload();
|
|
27
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
28
|
+
const [validationError, setValidationError] = useState<string | null>(null);
|
|
29
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
|
|
31
|
+
const handleFile = useCallback(
|
|
32
|
+
async (file: File) => {
|
|
33
|
+
setValidationError(null);
|
|
34
|
+
|
|
35
|
+
if (file.size > maxSize) {
|
|
36
|
+
const mb = Math.round(maxSize / 1024 / 1024);
|
|
37
|
+
setValidationError(`File size must be less than ${mb} MB.`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = await upload(file);
|
|
42
|
+
if (result) {
|
|
43
|
+
onUploadComplete?.(result);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[upload, maxSize, onUploadComplete],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
50
|
+
const file = e.target.files?.[0];
|
|
51
|
+
if (file) {
|
|
52
|
+
handleFile(file);
|
|
53
|
+
}
|
|
54
|
+
// Reset the input so the same file can be selected again.
|
|
55
|
+
e.target.value = "";
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
setIsDragOver(false);
|
|
61
|
+
const file = e.dataTransfer.files?.[0];
|
|
62
|
+
if (file) {
|
|
63
|
+
handleFile(file);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
setIsDragOver(true);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleDragLeave = (e: React.DragEvent) => {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
setIsDragOver(false);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const displayError = validationError ?? error;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="w-full">
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={() => fileInputRef.current?.click()}
|
|
84
|
+
onDrop={handleDrop}
|
|
85
|
+
onDragOver={handleDragOver}
|
|
86
|
+
onDragLeave={handleDragLeave}
|
|
87
|
+
disabled={isUploading}
|
|
88
|
+
className={`flex w-full flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-10 transition-colors ${
|
|
89
|
+
isDragOver
|
|
90
|
+
? "border-keel-blue bg-keel-blue/10"
|
|
91
|
+
: "border-keel-gray-700 hover:border-keel-gray-500"
|
|
92
|
+
} ${isUploading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
|
93
|
+
>
|
|
94
|
+
{isUploading ? (
|
|
95
|
+
<>
|
|
96
|
+
{/* Progress indicator */}
|
|
97
|
+
<div className="mb-3 h-10 w-10 animate-spin rounded-full border-4 border-keel-gray-600 border-t-keel-blue" />
|
|
98
|
+
<p className="text-sm font-medium text-keel-gray-300">
|
|
99
|
+
Uploading... {progress}%
|
|
100
|
+
</p>
|
|
101
|
+
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-keel-gray-800">
|
|
102
|
+
<div
|
|
103
|
+
className="h-full rounded-full bg-keel-blue transition-all duration-300"
|
|
104
|
+
style={{ width: `${progress}%` }}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
</>
|
|
108
|
+
) : (
|
|
109
|
+
<>
|
|
110
|
+
{/* Upload icon */}
|
|
111
|
+
<svg
|
|
112
|
+
className="mb-3 h-10 w-10 text-keel-gray-500"
|
|
113
|
+
fill="none"
|
|
114
|
+
viewBox="0 0 24 24"
|
|
115
|
+
stroke="currentColor"
|
|
116
|
+
strokeWidth={1.5}
|
|
117
|
+
>
|
|
118
|
+
<path
|
|
119
|
+
strokeLinecap="round"
|
|
120
|
+
strokeLinejoin="round"
|
|
121
|
+
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
|
122
|
+
/>
|
|
123
|
+
</svg>
|
|
124
|
+
<p className="text-sm font-medium text-keel-gray-300">
|
|
125
|
+
Click to upload or drag and drop
|
|
126
|
+
</p>
|
|
127
|
+
<p className="mt-1 text-xs text-keel-gray-500">
|
|
128
|
+
Max file size: {Math.round(maxSize / 1024 / 1024)} MB
|
|
129
|
+
</p>
|
|
130
|
+
</>
|
|
131
|
+
)}
|
|
132
|
+
</button>
|
|
133
|
+
|
|
134
|
+
<input
|
|
135
|
+
ref={fileInputRef}
|
|
136
|
+
type="file"
|
|
137
|
+
accept={accept}
|
|
138
|
+
onChange={handleInputChange}
|
|
139
|
+
className="hidden"
|
|
140
|
+
/>
|
|
141
|
+
|
|
142
|
+
{displayError && (
|
|
143
|
+
<p className="mt-2 text-sm text-red-400">{displayError}</p>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -1,106 +1,106 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
2
|
-
|
|
3
|
-
interface UploadUrlResponse {
|
|
4
|
-
uploadUrl: string;
|
|
5
|
-
file: {
|
|
6
|
-
id: string;
|
|
7
|
-
key: string;
|
|
8
|
-
fileName: string;
|
|
9
|
-
};
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface UseFileUploadResult {
|
|
13
|
-
/** Upload a file. Returns the file record on success. */
|
|
14
|
-
upload: (file: File) => Promise<{ id: string; fileName: string } | null>;
|
|
15
|
-
/** Whether an upload is currently in progress. */
|
|
16
|
-
isUploading: boolean;
|
|
17
|
-
/** Upload progress as a percentage (0-100). */
|
|
18
|
-
progress: number;
|
|
19
|
-
/** Last error message, if any. */
|
|
20
|
-
error: string | null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Hook for uploading files via presigned URLs.
|
|
25
|
-
*
|
|
26
|
-
* 1. Requests a presigned upload URL from the backend
|
|
27
|
-
* 2. PUTs the file directly to S3-compatible storage
|
|
28
|
-
* 3. Returns the file metadata
|
|
29
|
-
*
|
|
30
|
-
* Usage:
|
|
31
|
-
* const { upload, isUploading, progress, error } = useFileUpload();
|
|
32
|
-
* const result = await upload(file);
|
|
33
|
-
*/
|
|
34
|
-
export function useFileUpload(): UseFileUploadResult {
|
|
35
|
-
const [isUploading, setIsUploading] = useState(false);
|
|
36
|
-
const [progress, setProgress] = useState(0);
|
|
37
|
-
const [error, setError] = useState<string | null>(null);
|
|
38
|
-
|
|
39
|
-
const upload = useCallback(async (file: File) => {
|
|
40
|
-
setIsUploading(true);
|
|
41
|
-
setProgress(0);
|
|
42
|
-
setError(null);
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
// Step 1: Get presigned upload URL from the backend.
|
|
46
|
-
const response = await fetch("/api/files/upload-url", {
|
|
47
|
-
method: "POST",
|
|
48
|
-
headers: { "Content-Type": "application/json" },
|
|
49
|
-
credentials: "include",
|
|
50
|
-
body: JSON.stringify({
|
|
51
|
-
fileName: file.name,
|
|
52
|
-
contentType: file.type,
|
|
53
|
-
maxSize: file.size,
|
|
54
|
-
}),
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
if (!response.ok) {
|
|
58
|
-
const data = await response.json().catch(() => ({}));
|
|
59
|
-
throw new Error(data.error ?? `Upload failed (${response.status})`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const { uploadUrl, file: fileRecord } =
|
|
63
|
-
(await response.json()) as UploadUrlResponse;
|
|
64
|
-
|
|
65
|
-
// Step 2: Upload the file directly to storage via presigned URL.
|
|
66
|
-
// Use XMLHttpRequest for progress tracking.
|
|
67
|
-
await new Promise<void>((resolve, reject) => {
|
|
68
|
-
const xhr = new XMLHttpRequest();
|
|
69
|
-
|
|
70
|
-
xhr.upload.addEventListener("progress", (e) => {
|
|
71
|
-
if (e.lengthComputable) {
|
|
72
|
-
setProgress(Math.round((e.loaded / e.total) * 100));
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
xhr.addEventListener("load", () => {
|
|
77
|
-
if (xhr.status >= 200 && xhr.status < 300) {
|
|
78
|
-
resolve();
|
|
79
|
-
} else {
|
|
80
|
-
reject(new Error(`Storage upload failed (${xhr.status})`));
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
xhr.addEventListener("error", () => {
|
|
85
|
-
reject(new Error("Network error during upload"));
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
xhr.open("PUT", uploadUrl);
|
|
89
|
-
xhr.setRequestHeader("Content-Type", file.type);
|
|
90
|
-
xhr.send(file);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
setProgress(100);
|
|
94
|
-
return { id: fileRecord.id, fileName: fileRecord.fileName };
|
|
95
|
-
} catch (err) {
|
|
96
|
-
const message =
|
|
97
|
-
err instanceof Error ? err.message : "Upload failed";
|
|
98
|
-
setError(message);
|
|
99
|
-
return null;
|
|
100
|
-
} finally {
|
|
101
|
-
setIsUploading(false);
|
|
102
|
-
}
|
|
103
|
-
}, []);
|
|
104
|
-
|
|
105
|
-
return { upload, isUploading, progress, error };
|
|
106
|
-
}
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
interface UploadUrlResponse {
|
|
4
|
+
uploadUrl: string;
|
|
5
|
+
file: {
|
|
6
|
+
id: string;
|
|
7
|
+
key: string;
|
|
8
|
+
fileName: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface UseFileUploadResult {
|
|
13
|
+
/** Upload a file. Returns the file record on success. */
|
|
14
|
+
upload: (file: File) => Promise<{ id: string; fileName: string } | null>;
|
|
15
|
+
/** Whether an upload is currently in progress. */
|
|
16
|
+
isUploading: boolean;
|
|
17
|
+
/** Upload progress as a percentage (0-100). */
|
|
18
|
+
progress: number;
|
|
19
|
+
/** Last error message, if any. */
|
|
20
|
+
error: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Hook for uploading files via presigned URLs.
|
|
25
|
+
*
|
|
26
|
+
* 1. Requests a presigned upload URL from the backend
|
|
27
|
+
* 2. PUTs the file directly to S3-compatible storage
|
|
28
|
+
* 3. Returns the file metadata
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* const { upload, isUploading, progress, error } = useFileUpload();
|
|
32
|
+
* const result = await upload(file);
|
|
33
|
+
*/
|
|
34
|
+
export function useFileUpload(): UseFileUploadResult {
|
|
35
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
36
|
+
const [progress, setProgress] = useState(0);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
const upload = useCallback(async (file: File) => {
|
|
40
|
+
setIsUploading(true);
|
|
41
|
+
setProgress(0);
|
|
42
|
+
setError(null);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Step 1: Get presigned upload URL from the backend.
|
|
46
|
+
const response = await fetch("/api/files/upload-url", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
credentials: "include",
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
fileName: file.name,
|
|
52
|
+
contentType: file.type,
|
|
53
|
+
maxSize: file.size,
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const data = await response.json().catch(() => ({}));
|
|
59
|
+
throw new Error(data.error ?? `Upload failed (${response.status})`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { uploadUrl, file: fileRecord } =
|
|
63
|
+
(await response.json()) as UploadUrlResponse;
|
|
64
|
+
|
|
65
|
+
// Step 2: Upload the file directly to storage via presigned URL.
|
|
66
|
+
// Use XMLHttpRequest for progress tracking.
|
|
67
|
+
await new Promise<void>((resolve, reject) => {
|
|
68
|
+
const xhr = new XMLHttpRequest();
|
|
69
|
+
|
|
70
|
+
xhr.upload.addEventListener("progress", (e) => {
|
|
71
|
+
if (e.lengthComputable) {
|
|
72
|
+
setProgress(Math.round((e.loaded / e.total) * 100));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
xhr.addEventListener("load", () => {
|
|
77
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
78
|
+
resolve();
|
|
79
|
+
} else {
|
|
80
|
+
reject(new Error(`Storage upload failed (${xhr.status})`));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
xhr.addEventListener("error", () => {
|
|
85
|
+
reject(new Error("Network error during upload"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
xhr.open("PUT", uploadUrl);
|
|
89
|
+
xhr.setRequestHeader("Content-Type", file.type);
|
|
90
|
+
xhr.send(file);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
setProgress(100);
|
|
94
|
+
return { id: fileRecord.id, fileName: fileRecord.fileName };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const message =
|
|
97
|
+
err instanceof Error ? err.message : "Upload failed";
|
|
98
|
+
setError(message);
|
|
99
|
+
return null;
|
|
100
|
+
} finally {
|
|
101
|
+
setIsUploading(false);
|
|
102
|
+
}
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
return { upload, isUploading, progress, error };
|
|
106
|
+
}
|