@codaijs/keel 0.2.3 → 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,29 +1,29 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "r2-storage",
|
|
3
|
-
"displayName": "Cloudflare R2 Storage",
|
|
4
|
-
"description": "File uploads via Cloudflare R2 with presigned URLs. Adds profile picture upload.",
|
|
5
|
-
"version": "1.0.0",
|
|
6
|
-
"compatibility": ">=1.0.0",
|
|
7
|
-
"requiredEnvVars": [
|
|
8
|
-
{ "key": "R2_ACCOUNT_ID", "description": "Cloudflare R2 account ID" },
|
|
9
|
-
{ "key": "R2_ACCESS_KEY_ID", "description": "R2 access key ID" },
|
|
10
|
-
{ "key": "R2_SECRET_ACCESS_KEY", "description": "R2 secret access key" },
|
|
11
|
-
{ "key": "R2_BUCKET_NAME", "description": "R2 bucket name (e.g., avatars)" },
|
|
12
|
-
{ "key": "R2_PUBLIC_URL", "description": "R2 public URL for serving files" }
|
|
13
|
-
],
|
|
14
|
-
"dependencies": {
|
|
15
|
-
"backend": {
|
|
16
|
-
"@aws-sdk/client-s3": "^3.700.0",
|
|
17
|
-
"@aws-sdk/s3-request-presigner": "^3.700.0"
|
|
18
|
-
},
|
|
19
|
-
"frontend": {}
|
|
20
|
-
},
|
|
21
|
-
"modifies": {
|
|
22
|
-
"backend": ["src/routes/profile.ts", "src/env.ts"],
|
|
23
|
-
"frontend": ["src/components/profile/ProfilePage.tsx"]
|
|
24
|
-
},
|
|
25
|
-
"adds": {
|
|
26
|
-
"backend": ["src/services/storage.ts"],
|
|
27
|
-
"frontend": ["src/components/profile/ProfilePictureUpload.tsx"]
|
|
28
|
-
}
|
|
29
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "r2-storage",
|
|
3
|
+
"displayName": "Cloudflare R2 Storage",
|
|
4
|
+
"description": "File uploads via Cloudflare R2 with presigned URLs. Adds profile picture upload.",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"compatibility": ">=1.0.0",
|
|
7
|
+
"requiredEnvVars": [
|
|
8
|
+
{ "key": "R2_ACCOUNT_ID", "description": "Cloudflare R2 account ID" },
|
|
9
|
+
{ "key": "R2_ACCESS_KEY_ID", "description": "R2 access key ID" },
|
|
10
|
+
{ "key": "R2_SECRET_ACCESS_KEY", "description": "R2 secret access key" },
|
|
11
|
+
{ "key": "R2_BUCKET_NAME", "description": "R2 bucket name (e.g., avatars)" },
|
|
12
|
+
{ "key": "R2_PUBLIC_URL", "description": "R2 public URL for serving files" }
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"backend": {
|
|
16
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
17
|
+
"@aws-sdk/s3-request-presigner": "^3.700.0"
|
|
18
|
+
},
|
|
19
|
+
"frontend": {}
|
|
20
|
+
},
|
|
21
|
+
"modifies": {
|
|
22
|
+
"backend": ["src/routes/profile.ts", "src/env.ts"],
|
|
23
|
+
"frontend": ["src/components/profile/ProfilePage.tsx"]
|
|
24
|
+
},
|
|
25
|
+
"adds": {
|
|
26
|
+
"backend": ["src/services/storage.ts"],
|
|
27
|
+
"frontend": ["src/components/profile/ProfilePictureUpload.tsx"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -1,71 +1,71 @@
|
|
|
1
|
-
import {
|
|
2
|
-
S3Client,
|
|
3
|
-
PutObjectCommand,
|
|
4
|
-
GetObjectCommand,
|
|
5
|
-
DeleteObjectCommand,
|
|
6
|
-
} from "@aws-sdk/client-s3";
|
|
7
|
-
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
8
|
-
import { env } from "../env.js";
|
|
9
|
-
|
|
10
|
-
const s3Client = new S3Client({
|
|
11
|
-
region: "auto",
|
|
12
|
-
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
13
|
-
credentials: {
|
|
14
|
-
accessKeyId: env.R2_ACCESS_KEY_ID,
|
|
15
|
-
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
|
16
|
-
},
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const UPLOAD_URL_EXPIRY = 60 * 10; // 10 minutes
|
|
20
|
-
const DOWNLOAD_URL_EXPIRY = 60 * 60; // 1 hour
|
|
21
|
-
|
|
22
|
-
function getExtensionFromMimeType(fileType: string): string {
|
|
23
|
-
const mimeMap: Record<string, string> = {
|
|
24
|
-
"image/jpeg": "jpg",
|
|
25
|
-
"image/png": "png",
|
|
26
|
-
"image/webp": "webp",
|
|
27
|
-
"image/gif": "gif",
|
|
28
|
-
"image/svg+xml": "svg",
|
|
29
|
-
};
|
|
30
|
-
return mimeMap[fileType] ?? "bin";
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function generateUploadUrl(
|
|
34
|
-
userId: string,
|
|
35
|
-
fileType: string,
|
|
36
|
-
): Promise<{ uploadUrl: string; key: string }> {
|
|
37
|
-
const ext = getExtensionFromMimeType(fileType);
|
|
38
|
-
const key = `avatars/${userId}/${Date.now()}.${ext}`;
|
|
39
|
-
|
|
40
|
-
const command = new PutObjectCommand({
|
|
41
|
-
Bucket: env.R2_BUCKET_NAME,
|
|
42
|
-
Key: key,
|
|
43
|
-
ContentType: fileType,
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
const uploadUrl = await getSignedUrl(s3Client, command, {
|
|
47
|
-
expiresIn: UPLOAD_URL_EXPIRY,
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
return { uploadUrl, key };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function generateDownloadUrl(key: string): Promise<string> {
|
|
54
|
-
const command = new GetObjectCommand({
|
|
55
|
-
Bucket: env.R2_BUCKET_NAME,
|
|
56
|
-
Key: key,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
return getSignedUrl(s3Client, command, {
|
|
60
|
-
expiresIn: DOWNLOAD_URL_EXPIRY,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function deleteObject(key: string): Promise<void> {
|
|
65
|
-
const command = new DeleteObjectCommand({
|
|
66
|
-
Bucket: env.R2_BUCKET_NAME,
|
|
67
|
-
Key: key,
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
await s3Client.send(command);
|
|
71
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
S3Client,
|
|
3
|
+
PutObjectCommand,
|
|
4
|
+
GetObjectCommand,
|
|
5
|
+
DeleteObjectCommand,
|
|
6
|
+
} from "@aws-sdk/client-s3";
|
|
7
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
8
|
+
import { env } from "../env.js";
|
|
9
|
+
|
|
10
|
+
const s3Client = new S3Client({
|
|
11
|
+
region: "auto",
|
|
12
|
+
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
13
|
+
credentials: {
|
|
14
|
+
accessKeyId: env.R2_ACCESS_KEY_ID,
|
|
15
|
+
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const UPLOAD_URL_EXPIRY = 60 * 10; // 10 minutes
|
|
20
|
+
const DOWNLOAD_URL_EXPIRY = 60 * 60; // 1 hour
|
|
21
|
+
|
|
22
|
+
function getExtensionFromMimeType(fileType: string): string {
|
|
23
|
+
const mimeMap: Record<string, string> = {
|
|
24
|
+
"image/jpeg": "jpg",
|
|
25
|
+
"image/png": "png",
|
|
26
|
+
"image/webp": "webp",
|
|
27
|
+
"image/gif": "gif",
|
|
28
|
+
"image/svg+xml": "svg",
|
|
29
|
+
};
|
|
30
|
+
return mimeMap[fileType] ?? "bin";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function generateUploadUrl(
|
|
34
|
+
userId: string,
|
|
35
|
+
fileType: string,
|
|
36
|
+
): Promise<{ uploadUrl: string; key: string }> {
|
|
37
|
+
const ext = getExtensionFromMimeType(fileType);
|
|
38
|
+
const key = `avatars/${userId}/${Date.now()}.${ext}`;
|
|
39
|
+
|
|
40
|
+
const command = new PutObjectCommand({
|
|
41
|
+
Bucket: env.R2_BUCKET_NAME,
|
|
42
|
+
Key: key,
|
|
43
|
+
ContentType: fileType,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const uploadUrl = await getSignedUrl(s3Client, command, {
|
|
47
|
+
expiresIn: UPLOAD_URL_EXPIRY,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return { uploadUrl, key };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function generateDownloadUrl(key: string): Promise<string> {
|
|
54
|
+
const command = new GetObjectCommand({
|
|
55
|
+
Bucket: env.R2_BUCKET_NAME,
|
|
56
|
+
Key: key,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return getSignedUrl(s3Client, command, {
|
|
60
|
+
expiresIn: DOWNLOAD_URL_EXPIRY,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function deleteObject(key: string): Promise<void> {
|
|
65
|
+
const command = new DeleteObjectCommand({
|
|
66
|
+
Bucket: env.R2_BUCKET_NAME,
|
|
67
|
+
Key: key,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await s3Client.send(command);
|
|
71
|
+
}
|
|
@@ -1,167 +1,167 @@
|
|
|
1
|
-
import { useState, useRef } from "react";
|
|
2
|
-
import { useAuth } from "@/hooks/useAuth";
|
|
3
|
-
import { apiPost, apiPatch } from "@/lib/api";
|
|
4
|
-
import { isNative } from "@/lib/capacitor";
|
|
5
|
-
|
|
6
|
-
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
7
|
-
|
|
8
|
-
interface PresignedUrlResponse {
|
|
9
|
-
uploadUrl: string;
|
|
10
|
-
publicUrl: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export default function ProfilePictureUpload() {
|
|
14
|
-
const { user } = useAuth();
|
|
15
|
-
const [isUploading, setIsUploading] = useState(false);
|
|
16
|
-
const [error, setError] = useState("");
|
|
17
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
18
|
-
|
|
19
|
-
const handleFileSelect = async (file: File) => {
|
|
20
|
-
setError("");
|
|
21
|
-
|
|
22
|
-
if (file.size > MAX_FILE_SIZE) {
|
|
23
|
-
setError("File size must be less than 5MB.");
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (!file.type.startsWith("image/")) {
|
|
28
|
-
setError("Please select an image file.");
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
setIsUploading(true);
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
// Get presigned upload URL
|
|
36
|
-
const { uploadUrl, publicUrl } = await apiPost<PresignedUrlResponse>(
|
|
37
|
-
"/api/user/profile/avatar/upload-url",
|
|
38
|
-
{
|
|
39
|
-
contentType: file.type,
|
|
40
|
-
fileName: file.name,
|
|
41
|
-
},
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
// Upload file to R2
|
|
45
|
-
await fetch(uploadUrl, {
|
|
46
|
-
method: "PUT",
|
|
47
|
-
headers: { "Content-Type": file.type },
|
|
48
|
-
body: file,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// Update profile with new image URL
|
|
52
|
-
await apiPatch("/api/user/profile", { image: publicUrl });
|
|
53
|
-
|
|
54
|
-
// Force page reload to show new avatar
|
|
55
|
-
window.location.reload();
|
|
56
|
-
} catch (err) {
|
|
57
|
-
setError(
|
|
58
|
-
err instanceof Error ? err.message : "Failed to upload image.",
|
|
59
|
-
);
|
|
60
|
-
} finally {
|
|
61
|
-
setIsUploading(false);
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const handleClick = async () => {
|
|
66
|
-
if (isNative) {
|
|
67
|
-
try {
|
|
68
|
-
const { Camera, CameraResultType, CameraSource } = await import(
|
|
69
|
-
"@capacitor/camera"
|
|
70
|
-
);
|
|
71
|
-
const photo = await Camera.getPhoto({
|
|
72
|
-
quality: 80,
|
|
73
|
-
allowEditing: true,
|
|
74
|
-
resultType: CameraResultType.Uri,
|
|
75
|
-
source: CameraSource.Prompt,
|
|
76
|
-
width: 512,
|
|
77
|
-
height: 512,
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
if (photo.webPath) {
|
|
81
|
-
const response = await fetch(photo.webPath);
|
|
82
|
-
const blob = await response.blob();
|
|
83
|
-
const file = new File([blob], "avatar.jpg", {
|
|
84
|
-
type: `image/${photo.format}`,
|
|
85
|
-
});
|
|
86
|
-
await handleFileSelect(file);
|
|
87
|
-
}
|
|
88
|
-
} catch {
|
|
89
|
-
// User cancelled
|
|
90
|
-
}
|
|
91
|
-
} else {
|
|
92
|
-
fileInputRef.current?.click();
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
97
|
-
const file = e.target.files?.[0];
|
|
98
|
-
if (file) {
|
|
99
|
-
handleFileSelect(file);
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const avatarUrl = user?.image;
|
|
104
|
-
const initials = user?.name?.charAt(0)?.toUpperCase() || "U";
|
|
105
|
-
|
|
106
|
-
return (
|
|
107
|
-
<div className="flex flex-col items-center gap-3">
|
|
108
|
-
<button
|
|
109
|
-
onClick={handleClick}
|
|
110
|
-
disabled={isUploading}
|
|
111
|
-
className="group relative h-20 w-20 overflow-hidden rounded-full border-2 border-keel-gray-800 transition-colors hover:border-keel-blue disabled:opacity-50"
|
|
112
|
-
>
|
|
113
|
-
{avatarUrl ? (
|
|
114
|
-
<img
|
|
115
|
-
src={avatarUrl}
|
|
116
|
-
alt={user?.name || "Avatar"}
|
|
117
|
-
className="h-full w-full object-cover"
|
|
118
|
-
/>
|
|
119
|
-
) : (
|
|
120
|
-
<div className="flex h-full w-full items-center justify-center bg-keel-blue/20 text-2xl font-semibold text-keel-blue">
|
|
121
|
-
{initials}
|
|
122
|
-
</div>
|
|
123
|
-
)}
|
|
124
|
-
|
|
125
|
-
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
|
126
|
-
{isUploading ? (
|
|
127
|
-
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
128
|
-
) : (
|
|
129
|
-
<svg
|
|
130
|
-
className="h-6 w-6 text-white"
|
|
131
|
-
fill="none"
|
|
132
|
-
viewBox="0 0 24 24"
|
|
133
|
-
stroke="currentColor"
|
|
134
|
-
>
|
|
135
|
-
<path
|
|
136
|
-
strokeLinecap="round"
|
|
137
|
-
strokeLinejoin="round"
|
|
138
|
-
strokeWidth={2}
|
|
139
|
-
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
|
140
|
-
/>
|
|
141
|
-
<path
|
|
142
|
-
strokeLinecap="round"
|
|
143
|
-
strokeLinejoin="round"
|
|
144
|
-
strokeWidth={2}
|
|
145
|
-
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
|
146
|
-
/>
|
|
147
|
-
</svg>
|
|
148
|
-
)}
|
|
149
|
-
</div>
|
|
150
|
-
</button>
|
|
151
|
-
|
|
152
|
-
<input
|
|
153
|
-
ref={fileInputRef}
|
|
154
|
-
type="file"
|
|
155
|
-
accept="image/*"
|
|
156
|
-
onChange={handleInputChange}
|
|
157
|
-
className="hidden"
|
|
158
|
-
/>
|
|
159
|
-
|
|
160
|
-
<p className="text-xs text-keel-gray-400">
|
|
161
|
-
{isUploading ? "Uploading..." : "Click to change"}
|
|
162
|
-
</p>
|
|
163
|
-
|
|
164
|
-
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
165
|
-
</div>
|
|
166
|
-
);
|
|
167
|
-
}
|
|
1
|
+
import { useState, useRef } from "react";
|
|
2
|
+
import { useAuth } from "@/hooks/useAuth";
|
|
3
|
+
import { apiPost, apiPatch } from "@/lib/api";
|
|
4
|
+
import { isNative } from "@/lib/capacitor";
|
|
5
|
+
|
|
6
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
7
|
+
|
|
8
|
+
interface PresignedUrlResponse {
|
|
9
|
+
uploadUrl: string;
|
|
10
|
+
publicUrl: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function ProfilePictureUpload() {
|
|
14
|
+
const { user } = useAuth();
|
|
15
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
16
|
+
const [error, setError] = useState("");
|
|
17
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
18
|
+
|
|
19
|
+
const handleFileSelect = async (file: File) => {
|
|
20
|
+
setError("");
|
|
21
|
+
|
|
22
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
23
|
+
setError("File size must be less than 5MB.");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!file.type.startsWith("image/")) {
|
|
28
|
+
setError("Please select an image file.");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setIsUploading(true);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Get presigned upload URL
|
|
36
|
+
const { uploadUrl, publicUrl } = await apiPost<PresignedUrlResponse>(
|
|
37
|
+
"/api/user/profile/avatar/upload-url",
|
|
38
|
+
{
|
|
39
|
+
contentType: file.type,
|
|
40
|
+
fileName: file.name,
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Upload file to R2
|
|
45
|
+
await fetch(uploadUrl, {
|
|
46
|
+
method: "PUT",
|
|
47
|
+
headers: { "Content-Type": file.type },
|
|
48
|
+
body: file,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Update profile with new image URL
|
|
52
|
+
await apiPatch("/api/user/profile", { image: publicUrl });
|
|
53
|
+
|
|
54
|
+
// Force page reload to show new avatar
|
|
55
|
+
window.location.reload();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setError(
|
|
58
|
+
err instanceof Error ? err.message : "Failed to upload image.",
|
|
59
|
+
);
|
|
60
|
+
} finally {
|
|
61
|
+
setIsUploading(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleClick = async () => {
|
|
66
|
+
if (isNative) {
|
|
67
|
+
try {
|
|
68
|
+
const { Camera, CameraResultType, CameraSource } = await import(
|
|
69
|
+
"@capacitor/camera"
|
|
70
|
+
);
|
|
71
|
+
const photo = await Camera.getPhoto({
|
|
72
|
+
quality: 80,
|
|
73
|
+
allowEditing: true,
|
|
74
|
+
resultType: CameraResultType.Uri,
|
|
75
|
+
source: CameraSource.Prompt,
|
|
76
|
+
width: 512,
|
|
77
|
+
height: 512,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (photo.webPath) {
|
|
81
|
+
const response = await fetch(photo.webPath);
|
|
82
|
+
const blob = await response.blob();
|
|
83
|
+
const file = new File([blob], "avatar.jpg", {
|
|
84
|
+
type: `image/${photo.format}`,
|
|
85
|
+
});
|
|
86
|
+
await handleFileSelect(file);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// User cancelled
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
fileInputRef.current?.click();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
97
|
+
const file = e.target.files?.[0];
|
|
98
|
+
if (file) {
|
|
99
|
+
handleFileSelect(file);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const avatarUrl = user?.image;
|
|
104
|
+
const initials = user?.name?.charAt(0)?.toUpperCase() || "U";
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="flex flex-col items-center gap-3">
|
|
108
|
+
<button
|
|
109
|
+
onClick={handleClick}
|
|
110
|
+
disabled={isUploading}
|
|
111
|
+
className="group relative h-20 w-20 overflow-hidden rounded-full border-2 border-keel-gray-800 transition-colors hover:border-keel-blue disabled:opacity-50"
|
|
112
|
+
>
|
|
113
|
+
{avatarUrl ? (
|
|
114
|
+
<img
|
|
115
|
+
src={avatarUrl}
|
|
116
|
+
alt={user?.name || "Avatar"}
|
|
117
|
+
className="h-full w-full object-cover"
|
|
118
|
+
/>
|
|
119
|
+
) : (
|
|
120
|
+
<div className="flex h-full w-full items-center justify-center bg-keel-blue/20 text-2xl font-semibold text-keel-blue">
|
|
121
|
+
{initials}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
|
126
|
+
{isUploading ? (
|
|
127
|
+
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
128
|
+
) : (
|
|
129
|
+
<svg
|
|
130
|
+
className="h-6 w-6 text-white"
|
|
131
|
+
fill="none"
|
|
132
|
+
viewBox="0 0 24 24"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
>
|
|
135
|
+
<path
|
|
136
|
+
strokeLinecap="round"
|
|
137
|
+
strokeLinejoin="round"
|
|
138
|
+
strokeWidth={2}
|
|
139
|
+
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
|
140
|
+
/>
|
|
141
|
+
<path
|
|
142
|
+
strokeLinecap="round"
|
|
143
|
+
strokeLinejoin="round"
|
|
144
|
+
strokeWidth={2}
|
|
145
|
+
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
|
146
|
+
/>
|
|
147
|
+
</svg>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</button>
|
|
151
|
+
|
|
152
|
+
<input
|
|
153
|
+
ref={fileInputRef}
|
|
154
|
+
type="file"
|
|
155
|
+
accept="image/*"
|
|
156
|
+
onChange={handleInputChange}
|
|
157
|
+
className="hidden"
|
|
158
|
+
/>
|
|
159
|
+
|
|
160
|
+
<p className="text-xs text-keel-gray-400">
|
|
161
|
+
{isUploading ? "Uploading..." : "Click to change"}
|
|
162
|
+
</p>
|
|
163
|
+
|
|
164
|
+
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|