@mars-stack/cli 3.0.2 → 4.0.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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { FileUploader } from './FileUploader';
2
+ export { FileList } from './FileList';
@@ -0,0 +1,2 @@
1
+ export { FileUploader, FileList } from './components';
2
+ export type { FileRecord, CreateFileData, FileListParams } from './types';
@@ -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