@mars-stack/cli 3.0.2 → 4.0.1
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/index.js +350 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/template/.cursor/skills/mars-upgrade-scaffold/SKILL.md +70 -0
- package/template/AGENTS.md +5 -1
- package/template/scripts/ensure-db.mjs +15 -9
- package/template/src/app/(auth)/verify/page.tsx +9 -8
- package/template/src/app/(protected)/dashboard/page.tsx +228 -11
- package/template/src/app/(protected)/files/page.tsx +30 -0
- package/template/src/app/(protected)/layout.tsx +14 -1
- package/template/src/app/(protected)/settings/billing/page.tsx +262 -0
- package/template/src/app/api/auth/signup/route.test.ts +118 -0
- package/template/src/app/api/auth/signup/route.ts +29 -5
- package/template/src/app/api/protected/billing/checkout/route.ts +2 -2
- package/template/src/app/api/protected/billing/portal/route.ts +1 -1
- package/template/src/app/api/protected/billing/subscription/route.ts +13 -0
- package/template/src/app/api/protected/files/list/route.ts +22 -0
- package/template/src/app/pricing/page.tsx +276 -0
- package/template/src/config/routes.ts +3 -0
- package/template/src/features/uploads/components/FileList.tsx +202 -0
- package/template/src/features/uploads/components/FileUploader.tsx +225 -0
- package/template/src/features/uploads/components/index.ts +2 -0
- package/template/src/features/uploads/index.ts +2 -0
- package/template/src/proxy.ts +1 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from 'react';
|
|
4
|
+
import { Badge } from '@mars-stack/ui';
|
|
5
|
+
|
|
6
|
+
interface UploadingFile {
|
|
7
|
+
id: string;
|
|
8
|
+
file: File;
|
|
9
|
+
progress: number;
|
|
10
|
+
status: 'uploading' | 'success' | 'error';
|
|
11
|
+
errorMessage?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FileUploaderProps {
|
|
15
|
+
acceptedTypes?: string;
|
|
16
|
+
maxSizeBytes?: number;
|
|
17
|
+
onUploadComplete?: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatFileSize(bytes: number): string {
|
|
21
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
22
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
23
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function UploadIcon() {
|
|
27
|
+
return (
|
|
28
|
+
<svg className="mx-auto h-10 w-10 text-text-muted" fill="none" viewBox="0 0 24 24" strokeWidth={1} stroke="currentColor">
|
|
29
|
+
<path strokeLinecap="round" strokeLinejoin="round" 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" />
|
|
30
|
+
</svg>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function FileUploader({
|
|
35
|
+
acceptedTypes,
|
|
36
|
+
maxSizeBytes = 10 * 1024 * 1024,
|
|
37
|
+
onUploadComplete,
|
|
38
|
+
}: FileUploaderProps) {
|
|
39
|
+
const [uploads, setUploads] = useState<UploadingFile[]>([]);
|
|
40
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
41
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
42
|
+
|
|
43
|
+
const uploadFile = useCallback(async (file: File) => {
|
|
44
|
+
const uploadId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
45
|
+
|
|
46
|
+
if (file.size > maxSizeBytes) {
|
|
47
|
+
setUploads((prev) => [
|
|
48
|
+
...prev,
|
|
49
|
+
{
|
|
50
|
+
id: uploadId,
|
|
51
|
+
file,
|
|
52
|
+
progress: 0,
|
|
53
|
+
status: 'error',
|
|
54
|
+
errorMessage: `File exceeds ${formatFileSize(maxSizeBytes)} limit`,
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setUploads((prev) => [
|
|
61
|
+
...prev,
|
|
62
|
+
{ id: uploadId, file, progress: 0, status: 'uploading' },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const formData = new FormData();
|
|
67
|
+
formData.append('file', file);
|
|
68
|
+
|
|
69
|
+
const xhr = new XMLHttpRequest();
|
|
70
|
+
|
|
71
|
+
await new Promise<void>((resolve, reject) => {
|
|
72
|
+
xhr.upload.addEventListener('progress', (event) => {
|
|
73
|
+
if (event.lengthComputable) {
|
|
74
|
+
const pct = Math.round((event.loaded / event.total) * 100);
|
|
75
|
+
setUploads((prev) =>
|
|
76
|
+
prev.map((u) => (u.id === uploadId ? { ...u, progress: pct } : u)),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
xhr.addEventListener('load', () => {
|
|
82
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
83
|
+
setUploads((prev) =>
|
|
84
|
+
prev.map((u) =>
|
|
85
|
+
u.id === uploadId ? { ...u, progress: 100, status: 'success' } : u,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
resolve();
|
|
89
|
+
} else {
|
|
90
|
+
let errorMsg = 'Upload failed';
|
|
91
|
+
try {
|
|
92
|
+
const resp = JSON.parse(xhr.responseText) as { error?: string };
|
|
93
|
+
if (resp.error) errorMsg = resp.error;
|
|
94
|
+
} catch { /* use default message */ }
|
|
95
|
+
reject(new Error(errorMsg));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
xhr.addEventListener('error', () => reject(new Error('Network error')));
|
|
100
|
+
xhr.open('POST', '/api/protected/files/upload');
|
|
101
|
+
xhr.withCredentials = true;
|
|
102
|
+
xhr.send(formData);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
onUploadComplete?.();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : 'Upload failed';
|
|
108
|
+
setUploads((prev) =>
|
|
109
|
+
prev.map((u) =>
|
|
110
|
+
u.id === uploadId ? { ...u, status: 'error', errorMessage: message } : u,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}, [maxSizeBytes, onUploadComplete]);
|
|
115
|
+
|
|
116
|
+
const handleFiles = useCallback(
|
|
117
|
+
(files: FileList | null) => {
|
|
118
|
+
if (!files) return;
|
|
119
|
+
Array.from(files).forEach((file) => uploadFile(file));
|
|
120
|
+
},
|
|
121
|
+
[uploadFile],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const handleDrop = useCallback(
|
|
125
|
+
(event: React.DragEvent) => {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
setIsDragOver(false);
|
|
128
|
+
handleFiles(event.dataTransfer.files);
|
|
129
|
+
},
|
|
130
|
+
[handleFiles],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const clearCompleted = useCallback(() => {
|
|
134
|
+
setUploads((prev) => prev.filter((u) => u.status === 'uploading'));
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const hasCompleted = uploads.some((u) => u.status !== 'uploading');
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="space-y-4">
|
|
141
|
+
<div
|
|
142
|
+
role="button"
|
|
143
|
+
tabIndex={0}
|
|
144
|
+
onDrop={handleDrop}
|
|
145
|
+
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
|
146
|
+
onDragLeave={() => setIsDragOver(false)}
|
|
147
|
+
onClick={() => inputRef.current?.click()}
|
|
148
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click(); }}
|
|
149
|
+
className={`flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 transition-colors ${
|
|
150
|
+
isDragOver
|
|
151
|
+
? 'border-brand-primary bg-brand-primary-muted'
|
|
152
|
+
: 'border-border-default bg-surface-card hover:border-brand-primary hover:bg-surface-hover'
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
<UploadIcon />
|
|
156
|
+
<p className="mt-3 text-sm font-medium text-text-primary">
|
|
157
|
+
Drop files here or click to browse
|
|
158
|
+
</p>
|
|
159
|
+
<p className="mt-1 text-xs text-text-muted">
|
|
160
|
+
Max {formatFileSize(maxSizeBytes)} per file
|
|
161
|
+
{acceptedTypes ? ` — ${acceptedTypes}` : ''}
|
|
162
|
+
</p>
|
|
163
|
+
<input
|
|
164
|
+
ref={inputRef}
|
|
165
|
+
type="file"
|
|
166
|
+
multiple
|
|
167
|
+
accept={acceptedTypes}
|
|
168
|
+
className="hidden"
|
|
169
|
+
onChange={(e) => handleFiles(e.target.files)}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{uploads.length > 0 && (
|
|
174
|
+
<div className="space-y-2">
|
|
175
|
+
<div className="flex items-center justify-between">
|
|
176
|
+
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
|
177
|
+
Uploads
|
|
178
|
+
</span>
|
|
179
|
+
{hasCompleted && (
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={clearCompleted}
|
|
183
|
+
className="text-xs text-text-link hover:text-text-link-hover"
|
|
184
|
+
>
|
|
185
|
+
Clear completed
|
|
186
|
+
</button>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{uploads.map((upload) => (
|
|
191
|
+
<div
|
|
192
|
+
key={upload.id}
|
|
193
|
+
className="flex items-center gap-3 rounded-lg border border-border-default bg-surface-card p-3"
|
|
194
|
+
>
|
|
195
|
+
<div className="min-w-0 flex-1">
|
|
196
|
+
<p className="truncate text-sm font-medium text-text-primary">
|
|
197
|
+
{upload.file.name}
|
|
198
|
+
</p>
|
|
199
|
+
<p className="text-xs text-text-muted">{formatFileSize(upload.file.size)}</p>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{upload.status === 'uploading' && (
|
|
203
|
+
<div className="flex items-center gap-2">
|
|
204
|
+
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-surface-hover">
|
|
205
|
+
<div
|
|
206
|
+
className="h-full rounded-full bg-brand-primary transition-all duration-300"
|
|
207
|
+
style={{ width: `${upload.progress}%` }}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
<span className="text-xs text-text-muted">{upload.progress}%</span>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{upload.status === 'success' && <Badge variant="success">Done</Badge>}
|
|
215
|
+
|
|
216
|
+
{upload.status === 'error' && (
|
|
217
|
+
<Badge variant="error">{upload.errorMessage ?? 'Failed'}</Badge>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
package/template/src/proxy.ts
CHANGED
|
@@ -20,7 +20,7 @@ const csrf = createCSRFProtection({
|
|
|
20
20
|
|
|
21
21
|
const authRoutes = [routes.signIn, routes.signUp, routes.forgotPassword, routes.resetPassword];
|
|
22
22
|
|
|
23
|
-
const protectedRoutes = [routes.dashboard, routes.settings];
|
|
23
|
+
const protectedRoutes = [routes.dashboard, routes.settings, routes.files];
|
|
24
24
|
|
|
25
25
|
const adminRoutes = [routes.admin];
|
|
26
26
|
|