@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,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
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare R2 Storage Sail Installer
|
|
3
|
+
*
|
|
4
|
+
* Adds file uploads via Cloudflare R2 with presigned URLs.
|
|
5
|
+
* Includes profile picture upload component and avatar management endpoints.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* npx tsx sails/r2-storage/install.ts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
readFileSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
copyFileSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { resolve, dirname, join } from "node:path";
|
|
19
|
+
import { execSync } from "node:child_process";
|
|
20
|
+
import { input, confirm } from "@inquirer/prompts";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Paths
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const SAIL_DIR = dirname(new URL(import.meta.url).pathname);
|
|
27
|
+
const PROJECT_ROOT = resolve(SAIL_DIR, "../..");
|
|
28
|
+
const BACKEND_ROOT = join(PROJECT_ROOT, "packages/backend");
|
|
29
|
+
const FRONTEND_ROOT = join(PROJECT_ROOT, "packages/frontend");
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
interface SailManifest {
|
|
36
|
+
name: string;
|
|
37
|
+
displayName: string;
|
|
38
|
+
version: string;
|
|
39
|
+
requiredEnvVars: { key: string; description: string }[];
|
|
40
|
+
dependencies: { backend: Record<string, string>; frontend: Record<string, string> };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadManifest(): SailManifest {
|
|
44
|
+
return JSON.parse(readFileSync(join(SAIL_DIR, "addon.json"), "utf-8"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function insertAtMarker(filePath: string, marker: string, code: string): void {
|
|
48
|
+
if (!existsSync(filePath)) {
|
|
49
|
+
console.warn(` Warning: File not found: ${filePath}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let content = readFileSync(filePath, "utf-8");
|
|
53
|
+
if (!content.includes(marker)) {
|
|
54
|
+
console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (content.includes(code.trim())) {
|
|
58
|
+
console.log(` Skipped (already present) -> ${filePath}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
content = content.replace(marker, `${marker}\n${code}`);
|
|
62
|
+
writeFileSync(filePath, content, "utf-8");
|
|
63
|
+
console.log(` Modified -> ${filePath}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function copyFile(src: string, dest: string, label: string): void {
|
|
67
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
68
|
+
copyFileSync(src, dest);
|
|
69
|
+
console.log(` Copied -> ${label}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function appendToEnvFiles(entries: Record<string, string>, section: string): void {
|
|
73
|
+
for (const envFile of [".env.example", ".env"]) {
|
|
74
|
+
const envPath = join(PROJECT_ROOT, envFile);
|
|
75
|
+
if (!existsSync(envPath)) continue;
|
|
76
|
+
let content = readFileSync(envPath, "utf-8");
|
|
77
|
+
const lines: string[] = [];
|
|
78
|
+
for (const [key, val] of Object.entries(entries)) {
|
|
79
|
+
if (!content.includes(key)) lines.push(`${key}=${val}`);
|
|
80
|
+
}
|
|
81
|
+
if (lines.length > 0) {
|
|
82
|
+
content += `\n# ${section}\n${lines.join("\n")}\n`;
|
|
83
|
+
writeFileSync(envPath, content, "utf-8");
|
|
84
|
+
console.log(` Updated ${envFile}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function installDeps(deps: Record<string, string>, workspace: string): void {
|
|
90
|
+
const entries = Object.entries(deps);
|
|
91
|
+
if (entries.length === 0) return;
|
|
92
|
+
const packages = entries.map(([n, v]) => `${n}@${v}`).join(" ");
|
|
93
|
+
const cmd = `npm install ${packages} --workspace=${workspace}`;
|
|
94
|
+
console.log(` Running: ${cmd}`);
|
|
95
|
+
execSync(cmd, { cwd: PROJECT_ROOT, stdio: "inherit" });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Main
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
async function main(): Promise<void> {
|
|
103
|
+
const manifest = loadManifest();
|
|
104
|
+
|
|
105
|
+
// -- Step 1: Welcome message ------------------------------------------------
|
|
106
|
+
console.log("\n------------------------------------------------------------");
|
|
107
|
+
console.log(` Cloudflare R2 Storage Sail Installer (v${manifest.version})`);
|
|
108
|
+
console.log("------------------------------------------------------------");
|
|
109
|
+
console.log();
|
|
110
|
+
console.log(" This sail adds file upload support via Cloudflare R2:");
|
|
111
|
+
console.log(" - Presigned URL generation for direct browser uploads");
|
|
112
|
+
console.log(" - Profile picture upload component");
|
|
113
|
+
console.log(" - Avatar management API endpoints (upload URL + delete)");
|
|
114
|
+
console.log(" - S3-compatible storage via Cloudflare R2");
|
|
115
|
+
console.log();
|
|
116
|
+
|
|
117
|
+
const pkgPath = join(PROJECT_ROOT, "package.json");
|
|
118
|
+
if (existsSync(pkgPath)) {
|
|
119
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
120
|
+
console.log(` Template version: ${pkg.version ?? "unknown"}`);
|
|
121
|
+
console.log();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// -- Step 2: R2 bucket setup ------------------------------------------------
|
|
125
|
+
const hasBucket = await confirm({
|
|
126
|
+
message: "Do you already have a Cloudflare R2 bucket set up?",
|
|
127
|
+
default: false,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!hasBucket) {
|
|
131
|
+
console.log();
|
|
132
|
+
console.log(" Create a Cloudflare R2 bucket:");
|
|
133
|
+
console.log();
|
|
134
|
+
console.log(" 1. Go to the Cloudflare dashboard:");
|
|
135
|
+
console.log(" https://dash.cloudflare.com/?to=/:account/r2/new");
|
|
136
|
+
console.log(" 2. Click 'Create bucket'");
|
|
137
|
+
console.log(" 3. Choose a bucket name (e.g., 'avatars' or 'uploads')");
|
|
138
|
+
console.log(" 4. Select a location hint closest to your users");
|
|
139
|
+
console.log(" 5. Click 'Create bucket'");
|
|
140
|
+
console.log();
|
|
141
|
+
console.log(" Then create an API token:");
|
|
142
|
+
console.log(" 1. Go to R2 > Overview > Manage R2 API Tokens");
|
|
143
|
+
console.log(" 2. Click 'Create API token'");
|
|
144
|
+
console.log(" 3. Give it 'Object Read & Write' permissions");
|
|
145
|
+
console.log(" 4. Copy the Access Key ID and Secret Access Key");
|
|
146
|
+
console.log();
|
|
147
|
+
|
|
148
|
+
await confirm({
|
|
149
|
+
message: "I have created my R2 bucket and API token, ready to continue",
|
|
150
|
+
default: false,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -- Step 3: Collect env vars -----------------------------------------------
|
|
155
|
+
console.log();
|
|
156
|
+
console.log(" Enter your Cloudflare R2 credentials:");
|
|
157
|
+
console.log(" (Find your Account ID in the Cloudflare dashboard URL or R2 overview page)");
|
|
158
|
+
console.log();
|
|
159
|
+
|
|
160
|
+
const r2AccountId = await input({
|
|
161
|
+
message: "R2 Account ID:",
|
|
162
|
+
validate: (value) => {
|
|
163
|
+
if (!value || value.trim().length === 0) return "Account ID is required.";
|
|
164
|
+
return true;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const r2AccessKeyId = await input({
|
|
169
|
+
message: "R2 Access Key ID:",
|
|
170
|
+
validate: (value) => {
|
|
171
|
+
if (!value || value.trim().length === 0) return "Access Key ID is required.";
|
|
172
|
+
return true;
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const r2SecretAccessKey = await input({
|
|
177
|
+
message: "R2 Secret Access Key:",
|
|
178
|
+
validate: (value) => {
|
|
179
|
+
if (!value || value.trim().length === 0) return "Secret Access Key is required.";
|
|
180
|
+
return true;
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const r2BucketName = await input({
|
|
185
|
+
message: "R2 Bucket Name:",
|
|
186
|
+
default: "avatars",
|
|
187
|
+
validate: (value) => {
|
|
188
|
+
if (!value || value.trim().length === 0) return "Bucket name is required.";
|
|
189
|
+
return true;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const r2PublicUrl = await input({
|
|
194
|
+
message: "R2 Public URL (e.g., https://pub-xxx.r2.dev):",
|
|
195
|
+
validate: (value) => {
|
|
196
|
+
if (!value || value.trim().length === 0) return "Public URL is required.";
|
|
197
|
+
if (!value.startsWith("http")) return "Public URL should start with http:// or https://";
|
|
198
|
+
return true;
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// -- Step 4: CORS reminder --------------------------------------------------
|
|
203
|
+
console.log();
|
|
204
|
+
console.log(" Important: Configure CORS on your R2 bucket!");
|
|
205
|
+
console.log();
|
|
206
|
+
console.log(" Go to your bucket settings and add a CORS policy:");
|
|
207
|
+
console.log(" [");
|
|
208
|
+
console.log(' {');
|
|
209
|
+
console.log(' "AllowedOrigins": ["http://localhost:5173", "https://yourdomain.com"],');
|
|
210
|
+
console.log(' "AllowedMethods": ["GET", "PUT"],');
|
|
211
|
+
console.log(' "AllowedHeaders": ["Content-Type"],');
|
|
212
|
+
console.log(' "MaxAgeSeconds": 3600');
|
|
213
|
+
console.log(" }");
|
|
214
|
+
console.log(" ]");
|
|
215
|
+
console.log();
|
|
216
|
+
|
|
217
|
+
await confirm({
|
|
218
|
+
message: "I have configured CORS (or will do it later)",
|
|
219
|
+
default: true,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// -- Step 5: Summary --------------------------------------------------------
|
|
223
|
+
console.log();
|
|
224
|
+
console.log(" Summary of changes:");
|
|
225
|
+
console.log(" -------------------");
|
|
226
|
+
console.log(" Files to copy (backend):");
|
|
227
|
+
console.log(" + packages/backend/src/services/storage.ts");
|
|
228
|
+
console.log();
|
|
229
|
+
console.log(" Files to copy (frontend):");
|
|
230
|
+
console.log(" + packages/frontend/src/components/profile/ProfilePictureUpload.tsx");
|
|
231
|
+
console.log();
|
|
232
|
+
console.log(" Files to modify:");
|
|
233
|
+
console.log(" ~ packages/backend/src/routes/profile.ts (add avatar endpoints)");
|
|
234
|
+
console.log(" ~ packages/backend/src/env.ts (add R2 env vars)");
|
|
235
|
+
console.log(" ~ packages/frontend/src/components/profile/ProfilePage.tsx (add upload component)");
|
|
236
|
+
console.log(" ~ .env.example / .env");
|
|
237
|
+
console.log();
|
|
238
|
+
console.log(" Environment variables:");
|
|
239
|
+
console.log(` R2_ACCOUNT_ID=${r2AccountId.slice(0, 8)}...`);
|
|
240
|
+
console.log(` R2_ACCESS_KEY_ID=${r2AccessKeyId.slice(0, 8)}...`);
|
|
241
|
+
console.log(` R2_SECRET_ACCESS_KEY=${r2SecretAccessKey.slice(0, 8)}...`);
|
|
242
|
+
console.log(` R2_BUCKET_NAME=${r2BucketName}`);
|
|
243
|
+
console.log(` R2_PUBLIC_URL=${r2PublicUrl}`);
|
|
244
|
+
console.log();
|
|
245
|
+
|
|
246
|
+
const proceed = await confirm({ message: "Proceed with installation?", default: true });
|
|
247
|
+
if (!proceed) { console.log("\n Installation cancelled.\n"); process.exit(0); }
|
|
248
|
+
|
|
249
|
+
// -- Step 6: Execute --------------------------------------------------------
|
|
250
|
+
console.log();
|
|
251
|
+
console.log(" Installing...");
|
|
252
|
+
console.log();
|
|
253
|
+
|
|
254
|
+
console.log(" Copying backend files...");
|
|
255
|
+
copyFile(
|
|
256
|
+
join(SAIL_DIR, "files/backend/services/storage.ts"),
|
|
257
|
+
join(BACKEND_ROOT, "src/services/storage.ts"),
|
|
258
|
+
"src/services/storage.ts",
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
console.log();
|
|
262
|
+
console.log(" Copying frontend files...");
|
|
263
|
+
copyFile(
|
|
264
|
+
join(SAIL_DIR, "files/frontend/components/ProfilePictureUpload.tsx"),
|
|
265
|
+
join(FRONTEND_ROOT, "src/components/profile/ProfilePictureUpload.tsx"),
|
|
266
|
+
"src/components/profile/ProfilePictureUpload.tsx",
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
console.log();
|
|
270
|
+
console.log(" Modifying backend files...");
|
|
271
|
+
|
|
272
|
+
// Add R2 env vars to env.ts
|
|
273
|
+
insertAtMarker(
|
|
274
|
+
join(BACKEND_ROOT, "src/env.ts"),
|
|
275
|
+
"// [SAIL_ENV_VARS]",
|
|
276
|
+
` R2_ACCOUNT_ID: z.string().default(""),\n R2_ACCESS_KEY_ID: z.string().default(""),\n R2_SECRET_ACCESS_KEY: z.string().default(""),\n R2_BUCKET_NAME: z.string().default("avatars"),\n R2_PUBLIC_URL: z.string().default(""),`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Add avatar routes to profile.ts — insert import and route blocks
|
|
280
|
+
const profilePath = join(BACKEND_ROOT, "src/routes/profile.ts");
|
|
281
|
+
if (existsSync(profilePath)) {
|
|
282
|
+
let profileContent = readFileSync(profilePath, "utf-8");
|
|
283
|
+
|
|
284
|
+
// Add storage import if not present
|
|
285
|
+
if (!profileContent.includes("storage")) {
|
|
286
|
+
profileContent = profileContent.replace(
|
|
287
|
+
'import { db } from "../db/index.js";',
|
|
288
|
+
'import { db } from "../db/index.js";\nimport { generateUploadUrl, deleteObject } from "../services/storage.js";',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Add avatar routes if not present
|
|
293
|
+
if (!profileContent.includes("/avatar/upload-url")) {
|
|
294
|
+
const avatarRoutes = `
|
|
295
|
+
// POST /avatar/upload-url — generate presigned upload URL
|
|
296
|
+
router.post("/avatar/upload-url", async (req, res) => {
|
|
297
|
+
const { fileType } = req.body as { fileType?: string };
|
|
298
|
+
|
|
299
|
+
if (!fileType || typeof fileType !== "string") {
|
|
300
|
+
res.status(400).json({ error: "fileType is required" });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const result = await generateUploadUrl(req.user!.id, fileType);
|
|
305
|
+
res.json(result);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// DELETE /avatar — delete current avatar
|
|
309
|
+
router.delete("/avatar", async (req, res) => {
|
|
310
|
+
const user = req.user!;
|
|
311
|
+
|
|
312
|
+
if (user.image) {
|
|
313
|
+
try {
|
|
314
|
+
// Extract key from the image URL or stored key
|
|
315
|
+
await deleteObject(user.image);
|
|
316
|
+
} catch {
|
|
317
|
+
// Continue even if R2 deletion fails
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const [updated] = await db
|
|
322
|
+
.update(users)
|
|
323
|
+
.set({ image: null, updatedAt: new Date() })
|
|
324
|
+
.where(eq(users.id, user.id))
|
|
325
|
+
.returning();
|
|
326
|
+
|
|
327
|
+
res.json({ user: updated });
|
|
328
|
+
});`;
|
|
329
|
+
|
|
330
|
+
// Insert before "export default router"
|
|
331
|
+
profileContent = profileContent.replace(
|
|
332
|
+
"export default router;",
|
|
333
|
+
`${avatarRoutes}\n\nexport default router;`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
writeFileSync(profilePath, profileContent, "utf-8");
|
|
338
|
+
console.log(` Modified -> ${profilePath}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.log();
|
|
342
|
+
console.log(" Modifying frontend files...");
|
|
343
|
+
|
|
344
|
+
// Add ProfilePictureUpload to ProfilePage.tsx
|
|
345
|
+
const profilePagePath = join(FRONTEND_ROOT, "src/components/profile/ProfilePage.tsx");
|
|
346
|
+
if (existsSync(profilePagePath)) {
|
|
347
|
+
let pageContent = readFileSync(profilePagePath, "utf-8");
|
|
348
|
+
|
|
349
|
+
// Add import if not present
|
|
350
|
+
if (!pageContent.includes("ProfilePictureUpload")) {
|
|
351
|
+
pageContent = pageContent.replace(
|
|
352
|
+
'import { apiPatch } from "@/lib/api";',
|
|
353
|
+
'import { apiPatch } from "@/lib/api";\nimport ProfilePictureUpload from "./ProfilePictureUpload";',
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
// Add the component in the profile card layout
|
|
357
|
+
pageContent = pageContent.replace(
|
|
358
|
+
'<div className="flex flex-col items-start gap-6 sm:flex-row">',
|
|
359
|
+
'<div className="flex flex-col items-start gap-6 sm:flex-row">\n <ProfilePictureUpload />',
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
writeFileSync(profilePagePath, pageContent, "utf-8");
|
|
364
|
+
console.log(` Modified -> ${profilePagePath}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
console.log();
|
|
368
|
+
console.log(" Installing dependencies...");
|
|
369
|
+
installDeps(manifest.dependencies.backend, "packages/backend");
|
|
370
|
+
installDeps(manifest.dependencies.frontend, "packages/frontend");
|
|
371
|
+
|
|
372
|
+
console.log();
|
|
373
|
+
console.log(" Updating environment files...");
|
|
374
|
+
appendToEnvFiles(
|
|
375
|
+
{
|
|
376
|
+
R2_ACCOUNT_ID: r2AccountId,
|
|
377
|
+
R2_ACCESS_KEY_ID: r2AccessKeyId,
|
|
378
|
+
R2_SECRET_ACCESS_KEY: r2SecretAccessKey,
|
|
379
|
+
R2_BUCKET_NAME: r2BucketName,
|
|
380
|
+
R2_PUBLIC_URL: r2PublicUrl,
|
|
381
|
+
},
|
|
382
|
+
"Cloudflare R2 Storage",
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// -- Step 7: Next steps -----------------------------------------------------
|
|
386
|
+
console.log();
|
|
387
|
+
console.log("------------------------------------------------------------");
|
|
388
|
+
console.log(" Cloudflare R2 Storage installed successfully!");
|
|
389
|
+
console.log("------------------------------------------------------------");
|
|
390
|
+
console.log();
|
|
391
|
+
console.log(" Next steps:");
|
|
392
|
+
console.log();
|
|
393
|
+
console.log(" 1. Make sure CORS is configured on your R2 bucket");
|
|
394
|
+
console.log(" (allow PUT from your frontend origin)");
|
|
395
|
+
console.log();
|
|
396
|
+
console.log(" 2. If using a custom domain for R2 public access,");
|
|
397
|
+
console.log(" set R2_PUBLIC_URL to that domain in your .env");
|
|
398
|
+
console.log();
|
|
399
|
+
console.log(" 3. Start your dev server:");
|
|
400
|
+
console.log(" npm run dev");
|
|
401
|
+
console.log();
|
|
402
|
+
console.log(" 4. Navigate to your profile page and try uploading");
|
|
403
|
+
console.log(" a profile picture to verify the integration works.");
|
|
404
|
+
console.log();
|
|
405
|
+
console.log(" Troubleshooting:");
|
|
406
|
+
console.log(" - CORS errors: Check your R2 bucket CORS configuration");
|
|
407
|
+
console.log(" - 403 errors: Verify your API token has read/write permissions");
|
|
408
|
+
console.log(" - Upload fails: Check that R2_ACCOUNT_ID is correct");
|
|
409
|
+
console.log();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
main().catch((err) => { console.error("Installation failed:", err); process.exit(1); });
|