supaslidev 0.1.4 → 0.2.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/app/app.config.ts +9 -0
- package/app/assets/css/main.css +90 -0
- package/app/components/AppHeader.vue +429 -0
- package/app/components/CreatePresentationDialog.vue +236 -0
- package/app/components/EmptyState.vue +37 -0
- package/app/components/ImportPresentationDialog.vue +865 -0
- package/app/components/PresentationCard.vue +343 -0
- package/app/components/PresentationListItem.vue +242 -0
- package/app/composables/useServers.ts +148 -0
- package/app/layouts/default.vue +49 -0
- package/app/pages/index.vue +542 -0
- package/dist/cli/index.js +183751 -137
- package/dist/config.d.ts +8 -0
- package/dist/config.js +16 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +3 -0
- package/dist/module.d.ts +6 -0
- package/dist/module.js +9168 -0
- package/dist/prompt.js +847 -0
- package/nuxt.config.ts +53 -0
- package/package.json +26 -19
- package/server/api/export/[id].post.ts +67 -0
- package/server/api/open-editor/[id].post.ts +28 -0
- package/server/api/presentations/import.post.ts +139 -0
- package/server/api/presentations/index.get.ts +18 -0
- package/server/api/presentations/index.post.ts +175 -0
- package/server/api/presentations/upload.post.ts +174 -0
- package/server/api/presentations/validate.post.ts +14 -0
- package/server/api/servers/[id].delete.ts +15 -0
- package/server/api/servers/[id].post.ts +17 -0
- package/server/api/servers/index.delete.ts +5 -0
- package/server/api/servers/index.get.ts +5 -0
- package/server/api/servers/stop-all.post.ts +5 -0
- package/server/plugins/generate.ts +12 -0
- package/server/plugins/shutdown.ts +16 -0
- package/server/routes/exports/[...path].get.ts +25 -0
- package/server/utils/config.ts +13 -0
- package/server/utils/process-manager.ts +119 -0
- package/src/cli/commands/create.ts +125 -0
- package/src/cli/commands/deploy.ts +90 -0
- package/src/cli/commands/dev.ts +116 -0
- package/src/cli/commands/export.ts +63 -0
- package/src/cli/commands/import.ts +178 -0
- package/src/cli/commands/present.ts +111 -0
- package/src/cli/index.ts +87 -0
- package/src/cli/utils.ts +94 -0
- package/src/config.ts +21 -0
- package/src/index.ts +2 -0
- package/src/module.ts +12 -0
- package/src/shared/catalog.ts +94 -0
- package/src/shared/copy.ts +28 -0
- package/src/shared/index.ts +29 -0
- package/{scripts/generate-presentations.mjs → src/shared/presentations.ts} +23 -46
- package/src/shared/types.ts +29 -0
- package/src/shared/validation.ts +111 -0
- package/dist/assets/index-BerY9FcI.js +0 -49
- package/dist/assets/index-CVzsY-on.css +0 -1
- package/dist/index.html +0 -24
- package/server/api.js +0 -1225
- /package/{dist → public}/apple-touch-icon.png +0 -0
- /package/{dist → public}/favicon-96x96.png +0 -0
- /package/{dist → public}/favicon.ico +0 -0
- /package/{dist → public}/favicon.svg +0 -0
- /package/{dist → public}/site.webmanifest +0 -0
- /package/{dist → public}/ssl-logo.png +0 -0
- /package/{dist → public}/web-app-manifest-192x192.png +0 -0
- /package/{dist → public}/web-app-manifest-512x512.png +0 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Presentation } from '../composables/useServers';
|
|
3
|
+
|
|
4
|
+
type ImportStatus = 'idle' | 'validating' | 'importing' | 'success' | 'error';
|
|
5
|
+
|
|
6
|
+
interface ImportProject {
|
|
7
|
+
path: string;
|
|
8
|
+
name: string;
|
|
9
|
+
isValid: boolean;
|
|
10
|
+
error: string;
|
|
11
|
+
status: ImportStatus;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ValidationResult {
|
|
15
|
+
path: string;
|
|
16
|
+
isValid: boolean;
|
|
17
|
+
suggestedName: string | null;
|
|
18
|
+
error: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ImportProgress {
|
|
22
|
+
currentIndex: number;
|
|
23
|
+
total: number;
|
|
24
|
+
currentProjectName: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ImportSummary {
|
|
28
|
+
successCount: number;
|
|
29
|
+
failureCount: number;
|
|
30
|
+
errors: Array<{ path: string; error: string }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface UploadedFile {
|
|
34
|
+
path: string;
|
|
35
|
+
content: string;
|
|
36
|
+
encoding: 'utf8' | 'base64';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface UploadedProject {
|
|
40
|
+
folderName: string;
|
|
41
|
+
files: UploadedFile[];
|
|
42
|
+
isValid: boolean;
|
|
43
|
+
error: string | null;
|
|
44
|
+
suggestedName: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createEmptyImportProject(): ImportProject {
|
|
48
|
+
return {
|
|
49
|
+
path: '',
|
|
50
|
+
name: '',
|
|
51
|
+
isValid: false,
|
|
52
|
+
error: '',
|
|
53
|
+
status: 'idle',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const props = defineProps<{
|
|
58
|
+
open: boolean;
|
|
59
|
+
}>();
|
|
60
|
+
|
|
61
|
+
const emit = defineEmits<{
|
|
62
|
+
close: [];
|
|
63
|
+
imported: [presentations: Presentation[]];
|
|
64
|
+
}>();
|
|
65
|
+
|
|
66
|
+
const importProject = ref<ImportProject>(createEmptyImportProject());
|
|
67
|
+
const isSubmitting = ref(false);
|
|
68
|
+
const nameError = ref('');
|
|
69
|
+
const touched = ref({ path: false, name: false });
|
|
70
|
+
const folderInputRef = ref<HTMLInputElement | null>(null);
|
|
71
|
+
const dragCounter = ref(0);
|
|
72
|
+
const validationResults = ref<ValidationResult[]>([]);
|
|
73
|
+
const isValidating = ref(false);
|
|
74
|
+
const importProgress = ref<ImportProgress | null>(null);
|
|
75
|
+
const importSummary = ref<ImportSummary | null>(null);
|
|
76
|
+
const uploadedProjects = ref<Map<string, UploadedProject>>(new Map());
|
|
77
|
+
const isReadingFiles = ref(false);
|
|
78
|
+
let validationDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
79
|
+
|
|
80
|
+
const TEXT_EXTENSIONS = new Set([
|
|
81
|
+
'.md',
|
|
82
|
+
'.json',
|
|
83
|
+
'.vue',
|
|
84
|
+
'.ts',
|
|
85
|
+
'.js',
|
|
86
|
+
'.css',
|
|
87
|
+
'.scss',
|
|
88
|
+
'.less',
|
|
89
|
+
'.html',
|
|
90
|
+
'.yaml',
|
|
91
|
+
'.yml',
|
|
92
|
+
'.txt',
|
|
93
|
+
'.svg',
|
|
94
|
+
'.gitignore',
|
|
95
|
+
'.npmrc',
|
|
96
|
+
'.env',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const IGNORE_PATTERNS = [
|
|
100
|
+
'node_modules',
|
|
101
|
+
'.git',
|
|
102
|
+
'dist',
|
|
103
|
+
'.nuxt',
|
|
104
|
+
'.output',
|
|
105
|
+
'pnpm-lock.yaml',
|
|
106
|
+
'package-lock.json',
|
|
107
|
+
'yarn.lock',
|
|
108
|
+
'.DS_Store',
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
function shouldIgnoreFile(path: string): boolean {
|
|
112
|
+
const parts = path.split('/');
|
|
113
|
+
return parts.some((part) => IGNORE_PATTERNS.includes(part));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isTextFile(filename: string): boolean {
|
|
117
|
+
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
|
118
|
+
return TEXT_EXTENSIONS.has(ext) || !filename.includes('.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readFileContent(file: File): Promise<UploadedFile> {
|
|
122
|
+
const isText = isTextFile(file.name);
|
|
123
|
+
|
|
124
|
+
if (isText) {
|
|
125
|
+
const content = await file.text();
|
|
126
|
+
return {
|
|
127
|
+
path: file.webkitRelativePath.split('/').slice(1).join('/'),
|
|
128
|
+
content,
|
|
129
|
+
encoding: 'utf8',
|
|
130
|
+
};
|
|
131
|
+
} else {
|
|
132
|
+
const buffer = await file.arrayBuffer();
|
|
133
|
+
const base64 = btoa(
|
|
134
|
+
new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), ''),
|
|
135
|
+
);
|
|
136
|
+
return {
|
|
137
|
+
path: file.webkitRelativePath.split('/').slice(1).join('/'),
|
|
138
|
+
content: base64,
|
|
139
|
+
encoding: 'base64',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function readDirectoryEntry(entry: FileSystemDirectoryEntry): Promise<UploadedFile[]> {
|
|
145
|
+
const files: UploadedFile[] = [];
|
|
146
|
+
|
|
147
|
+
async function readEntry(dirEntry: FileSystemDirectoryEntry, basePath: string): Promise<void> {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const reader = dirEntry.createReader();
|
|
150
|
+
const readEntries = () => {
|
|
151
|
+
reader.readEntries(async (entries) => {
|
|
152
|
+
if (entries.length === 0) {
|
|
153
|
+
resolve();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
const entryPath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
159
|
+
|
|
160
|
+
if (shouldIgnoreFile(entryPath)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (entry.isFile) {
|
|
165
|
+
const fileEntry = entry as FileSystemFileEntry;
|
|
166
|
+
const file = await new Promise<File>((res, rej) => {
|
|
167
|
+
fileEntry.file(res, rej);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const isText = isTextFile(file.name);
|
|
171
|
+
if (isText) {
|
|
172
|
+
const content = await file.text();
|
|
173
|
+
files.push({ path: entryPath, content, encoding: 'utf8' });
|
|
174
|
+
} else {
|
|
175
|
+
const buffer = await file.arrayBuffer();
|
|
176
|
+
const base64 = btoa(
|
|
177
|
+
new Uint8Array(buffer).reduce(
|
|
178
|
+
(data, byte) => data + String.fromCharCode(byte),
|
|
179
|
+
'',
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
files.push({ path: entryPath, content: base64, encoding: 'base64' });
|
|
183
|
+
}
|
|
184
|
+
} else if (entry.isDirectory) {
|
|
185
|
+
await readEntry(entry as FileSystemDirectoryEntry, entryPath);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
readEntries();
|
|
190
|
+
}, reject);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
readEntries();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await readEntry(entry, '');
|
|
198
|
+
return files;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validateUploadedFiles(files: UploadedFile[]): { isValid: boolean; error: string | null } {
|
|
202
|
+
const hasSlides = files.some((f) => f.path === 'slides.md');
|
|
203
|
+
const hasPackageJson = files.some((f) => f.path === 'package.json');
|
|
204
|
+
|
|
205
|
+
if (!hasSlides) {
|
|
206
|
+
return { isValid: false, error: 'No slides.md found' };
|
|
207
|
+
}
|
|
208
|
+
if (!hasPackageJson) {
|
|
209
|
+
return { isValid: false, error: 'No package.json found' };
|
|
210
|
+
}
|
|
211
|
+
return { isValid: true, error: null };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function toSlug(name: string): string {
|
|
215
|
+
return name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const isDraggingOver = computed(() => dragCounter.value > 0);
|
|
219
|
+
|
|
220
|
+
const validProjects = computed(() => {
|
|
221
|
+
return validationResults.value.filter((r) => r.isValid);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const validUploadedProjects = computed(() => {
|
|
225
|
+
return Array.from(uploadedProjects.value.values()).filter((p) => p.isValid);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const hasValidProjects = computed(() => {
|
|
229
|
+
return validProjects.value.length > 0 || validUploadedProjects.value.length > 0;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const isValid = computed(() => {
|
|
233
|
+
return hasValidProjects.value && !nameError.value && !isValidating.value && !isReadingFiles.value;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const hasMultiplePaths = computed(() => {
|
|
237
|
+
const pathCount = parsePaths(importProject.value.path).length;
|
|
238
|
+
const uploadCount = uploadedProjects.value.size;
|
|
239
|
+
return pathCount + uploadCount > 1;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const showPreviewList = computed(() => {
|
|
243
|
+
return (
|
|
244
|
+
validationResults.value.length > 0 ||
|
|
245
|
+
uploadedProjects.value.size > 0 ||
|
|
246
|
+
isValidating.value ||
|
|
247
|
+
isReadingFiles.value
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const hasUploadedProjects = computed(() => {
|
|
252
|
+
return uploadedProjects.value.size > 0;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
function parsePaths(input: string): string[] {
|
|
256
|
+
return input
|
|
257
|
+
.split(',')
|
|
258
|
+
.map((path) => path.trim())
|
|
259
|
+
.filter((path) => path.length > 0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function validatePathsOnServer(paths: string[]): Promise<ValidationResult[]> {
|
|
263
|
+
if (paths.length === 0) return [];
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const response = await fetch('/api/presentations/validate', {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { 'Content-Type': 'application/json' },
|
|
269
|
+
body: JSON.stringify({ paths }),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
return paths.map((path) => ({
|
|
274
|
+
path,
|
|
275
|
+
isValid: false,
|
|
276
|
+
suggestedName: null,
|
|
277
|
+
error: 'Validation request failed',
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return await response.json();
|
|
282
|
+
} catch {
|
|
283
|
+
return paths.map((path) => ({
|
|
284
|
+
path,
|
|
285
|
+
isValid: false,
|
|
286
|
+
suggestedName: null,
|
|
287
|
+
error: 'Failed to validate path',
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function triggerValidation(paths: string[]) {
|
|
293
|
+
if (validationDebounceTimer) {
|
|
294
|
+
clearTimeout(validationDebounceTimer);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (paths.length === 0) {
|
|
298
|
+
validationResults.value = [];
|
|
299
|
+
isValidating.value = false;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
isValidating.value = true;
|
|
304
|
+
validationDebounceTimer = setTimeout(async () => {
|
|
305
|
+
const results = await validatePathsOnServer(paths);
|
|
306
|
+
validationResults.value = results;
|
|
307
|
+
isValidating.value = false;
|
|
308
|
+
|
|
309
|
+
const hasValid = results.some((r) => r.isValid);
|
|
310
|
+
importProject.value.isValid = hasValid;
|
|
311
|
+
importProject.value.error = hasValid ? '' : 'No valid projects found';
|
|
312
|
+
}, 300);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function validateName(value: string) {
|
|
316
|
+
if (!touched.value.name) return;
|
|
317
|
+
if (!value.trim()) {
|
|
318
|
+
nameError.value = '';
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
323
|
+
if (!slugRegex.test(value)) {
|
|
324
|
+
nameError.value = 'Use lowercase letters, numbers, and hyphens only';
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
nameError.value = '';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
watch(
|
|
332
|
+
() => importProject.value.path,
|
|
333
|
+
(value) => {
|
|
334
|
+
if (touched.value.path) {
|
|
335
|
+
const paths = parsePaths(value);
|
|
336
|
+
triggerValidation(paths);
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
watch(
|
|
342
|
+
() => importProject.value.name,
|
|
343
|
+
(value) => {
|
|
344
|
+
if (touched.value.name) {
|
|
345
|
+
validateName(value);
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
function handlePathBlur() {
|
|
351
|
+
touched.value.path = true;
|
|
352
|
+
const paths = parsePaths(importProject.value.path);
|
|
353
|
+
triggerValidation(paths);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function handleNameBlur() {
|
|
357
|
+
touched.value.name = true;
|
|
358
|
+
validateName(importProject.value.name);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function openFolderPicker() {
|
|
362
|
+
folderInputRef.value?.click();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function handleFolderSelect(event: Event) {
|
|
366
|
+
const input = event.target as HTMLInputElement;
|
|
367
|
+
const files = input.files;
|
|
368
|
+
if (!files || files.length === 0) return;
|
|
369
|
+
|
|
370
|
+
isReadingFiles.value = true;
|
|
371
|
+
|
|
372
|
+
const filesByFolder = new Map<string, File[]>();
|
|
373
|
+
for (const file of files) {
|
|
374
|
+
const pathParts = file.webkitRelativePath.split('/');
|
|
375
|
+
if (pathParts.length > 1) {
|
|
376
|
+
const folderName = pathParts[0];
|
|
377
|
+
if (!filesByFolder.has(folderName)) {
|
|
378
|
+
filesByFolder.set(folderName, []);
|
|
379
|
+
}
|
|
380
|
+
filesByFolder.get(folderName)!.push(file);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const [folderName, folderFiles] of filesByFolder) {
|
|
385
|
+
if (uploadedProjects.value.has(folderName)) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const uploadedFiles: UploadedFile[] = [];
|
|
390
|
+
for (const file of folderFiles) {
|
|
391
|
+
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
|
|
392
|
+
if (shouldIgnoreFile(relativePath)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const uploadedFile = await readFileContent(file);
|
|
397
|
+
uploadedFiles.push(uploadedFile);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const validation = validateUploadedFiles(uploadedFiles);
|
|
401
|
+
const project: UploadedProject = {
|
|
402
|
+
folderName,
|
|
403
|
+
files: uploadedFiles,
|
|
404
|
+
isValid: validation.isValid,
|
|
405
|
+
error: validation.error,
|
|
406
|
+
suggestedName: toSlug(folderName),
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
uploadedProjects.value.set(folderName, project);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
uploadedProjects.value = new Map(uploadedProjects.value);
|
|
413
|
+
isReadingFiles.value = false;
|
|
414
|
+
input.value = '';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function handleDragEnter(event: DragEvent) {
|
|
418
|
+
event.preventDefault();
|
|
419
|
+
if (event.dataTransfer?.types.includes('Files')) {
|
|
420
|
+
dragCounter.value++;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function handleDragOver(event: DragEvent) {
|
|
425
|
+
event.preventDefault();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function handleDragLeave(event: DragEvent) {
|
|
429
|
+
event.preventDefault();
|
|
430
|
+
dragCounter.value--;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function handleDrop(event: DragEvent) {
|
|
434
|
+
event.preventDefault();
|
|
435
|
+
dragCounter.value = 0;
|
|
436
|
+
|
|
437
|
+
const items = event.dataTransfer?.items;
|
|
438
|
+
if (!items) return;
|
|
439
|
+
|
|
440
|
+
const directoryEntries: FileSystemDirectoryEntry[] = [];
|
|
441
|
+
|
|
442
|
+
for (const item of items) {
|
|
443
|
+
if (item.kind === 'file') {
|
|
444
|
+
const entry = item.webkitGetAsEntry?.();
|
|
445
|
+
if (entry?.isDirectory) {
|
|
446
|
+
directoryEntries.push(entry as FileSystemDirectoryEntry);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (directoryEntries.length === 0) return;
|
|
452
|
+
|
|
453
|
+
isReadingFiles.value = true;
|
|
454
|
+
|
|
455
|
+
for (const entry of directoryEntries) {
|
|
456
|
+
const folderName = entry.name;
|
|
457
|
+
if (uploadedProjects.value.has(folderName)) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const files = await readDirectoryEntry(entry);
|
|
462
|
+
const validation = validateUploadedFiles(files);
|
|
463
|
+
|
|
464
|
+
const project: UploadedProject = {
|
|
465
|
+
folderName,
|
|
466
|
+
files,
|
|
467
|
+
isValid: validation.isValid,
|
|
468
|
+
error: validation.error,
|
|
469
|
+
suggestedName: toSlug(folderName),
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
uploadedProjects.value.set(folderName, project);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
uploadedProjects.value = new Map(uploadedProjects.value);
|
|
476
|
+
isReadingFiles.value = false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function resetForm() {
|
|
480
|
+
importProject.value = createEmptyImportProject();
|
|
481
|
+
nameError.value = '';
|
|
482
|
+
isSubmitting.value = false;
|
|
483
|
+
touched.value = { path: false, name: false };
|
|
484
|
+
validationResults.value = [];
|
|
485
|
+
isValidating.value = false;
|
|
486
|
+
importProgress.value = null;
|
|
487
|
+
importSummary.value = null;
|
|
488
|
+
uploadedProjects.value = new Map();
|
|
489
|
+
isReadingFiles.value = false;
|
|
490
|
+
dragCounter.value = 0;
|
|
491
|
+
if (validationDebounceTimer) {
|
|
492
|
+
clearTimeout(validationDebounceTimer);
|
|
493
|
+
validationDebounceTimer = null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function handleClose() {
|
|
498
|
+
resetForm();
|
|
499
|
+
emit('close');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function handleSubmit() {
|
|
503
|
+
if (!isValid.value || isSubmitting.value) return;
|
|
504
|
+
|
|
505
|
+
const pathProjects = validProjects.value;
|
|
506
|
+
const uploadProjects = validUploadedProjects.value;
|
|
507
|
+
const totalProjects = pathProjects.length + uploadProjects.length;
|
|
508
|
+
|
|
509
|
+
if (totalProjects === 0) return;
|
|
510
|
+
|
|
511
|
+
const isSingleProject = totalProjects === 1;
|
|
512
|
+
|
|
513
|
+
isSubmitting.value = true;
|
|
514
|
+
importProject.value.status = 'importing';
|
|
515
|
+
importSummary.value = null;
|
|
516
|
+
|
|
517
|
+
const importedPresentations: Presentation[] = [];
|
|
518
|
+
const errors: Array<{ path: string; error: string }> = [];
|
|
519
|
+
let currentIndex = 0;
|
|
520
|
+
|
|
521
|
+
for (const project of pathProjects) {
|
|
522
|
+
currentIndex++;
|
|
523
|
+
importProgress.value = {
|
|
524
|
+
currentIndex,
|
|
525
|
+
total: totalProjects,
|
|
526
|
+
currentProjectName: project.suggestedName || project.path,
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
const response = await fetch('/api/presentations/import', {
|
|
531
|
+
method: 'POST',
|
|
532
|
+
headers: { 'Content-Type': 'application/json' },
|
|
533
|
+
body: JSON.stringify({
|
|
534
|
+
source: project.path,
|
|
535
|
+
name: isSingleProject ? importProject.value.name || undefined : undefined,
|
|
536
|
+
}),
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (!response.ok) {
|
|
540
|
+
const error = await response.json();
|
|
541
|
+
errors.push({ path: project.path, error: error.message });
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const presentation = await response.json();
|
|
546
|
+
importedPresentations.push(presentation);
|
|
547
|
+
} catch {
|
|
548
|
+
errors.push({ path: project.path, error: 'Failed to import' });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for (const project of uploadProjects) {
|
|
553
|
+
currentIndex++;
|
|
554
|
+
importProgress.value = {
|
|
555
|
+
currentIndex,
|
|
556
|
+
total: totalProjects,
|
|
557
|
+
currentProjectName: project.suggestedName || project.folderName,
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
try {
|
|
561
|
+
const response = await fetch('/api/presentations/upload', {
|
|
562
|
+
method: 'POST',
|
|
563
|
+
headers: { 'Content-Type': 'application/json' },
|
|
564
|
+
body: JSON.stringify({
|
|
565
|
+
files: project.files,
|
|
566
|
+
folderName: project.folderName,
|
|
567
|
+
name: isSingleProject ? importProject.value.name || undefined : undefined,
|
|
568
|
+
}),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (!response.ok) {
|
|
572
|
+
const error = await response.json();
|
|
573
|
+
errors.push({ path: project.folderName, error: error.message });
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const presentation = await response.json();
|
|
578
|
+
importedPresentations.push(presentation);
|
|
579
|
+
} catch {
|
|
580
|
+
errors.push({ path: project.folderName, error: 'Failed to upload' });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
importProgress.value = null;
|
|
585
|
+
|
|
586
|
+
if (importedPresentations.length > 0) {
|
|
587
|
+
emit('imported', importedPresentations);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
importSummary.value = {
|
|
591
|
+
successCount: importedPresentations.length,
|
|
592
|
+
failureCount: errors.length,
|
|
593
|
+
errors,
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
if (errors.length === 0) {
|
|
597
|
+
importProject.value.status = 'success';
|
|
598
|
+
handleClose();
|
|
599
|
+
} else {
|
|
600
|
+
importProject.value.status = 'error';
|
|
601
|
+
isSubmitting.value = false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
</script>
|
|
605
|
+
|
|
606
|
+
<template>
|
|
607
|
+
<UModal :open="props.open" :ui="{ content: 'sm:max-w-xl' }" @close="handleClose">
|
|
608
|
+
<template #header>
|
|
609
|
+
<div class="flex items-center gap-3">
|
|
610
|
+
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
611
|
+
<UIcon name="i-lucide-import" class="w-5 h-5 text-primary" />
|
|
612
|
+
</div>
|
|
613
|
+
<div>
|
|
614
|
+
<h2 class="text-lg font-semibold">Import Presentations</h2>
|
|
615
|
+
<p class="text-sm text-muted">Import existing Slidev presentations</p>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
</template>
|
|
619
|
+
|
|
620
|
+
<template #body>
|
|
621
|
+
<form
|
|
622
|
+
class="flex flex-col gap-6 relative"
|
|
623
|
+
@submit.prevent="handleSubmit"
|
|
624
|
+
@dragenter="handleDragEnter"
|
|
625
|
+
@dragover="handleDragOver"
|
|
626
|
+
@dragleave="handleDragLeave"
|
|
627
|
+
@drop="handleDrop"
|
|
628
|
+
>
|
|
629
|
+
<div
|
|
630
|
+
v-show="isDraggingOver"
|
|
631
|
+
class="absolute inset-0 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-primary bg-(--ui-bg)/95 backdrop-blur-sm pointer-events-none"
|
|
632
|
+
>
|
|
633
|
+
<div class="flex flex-col items-center gap-2 text-primary">
|
|
634
|
+
<UIcon name="i-lucide-folder-down" class="w-8 h-8" />
|
|
635
|
+
<span class="font-medium">Drop folders here</span>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
<UFormField
|
|
640
|
+
label="Source Path(s)"
|
|
641
|
+
:required="!hasUploadedProjects"
|
|
642
|
+
:error="importProject.error || undefined"
|
|
643
|
+
hint="Enter paths, browse multiple times, or drag-drop folders"
|
|
644
|
+
>
|
|
645
|
+
<div class="flex gap-2">
|
|
646
|
+
<UInput
|
|
647
|
+
v-model="importProject.path"
|
|
648
|
+
placeholder="/path/to/presentation"
|
|
649
|
+
:color="importProject.error ? 'error' : undefined"
|
|
650
|
+
:ui="{ base: 'font-mono' }"
|
|
651
|
+
class="flex-1"
|
|
652
|
+
autocomplete="off"
|
|
653
|
+
@blur="handlePathBlur"
|
|
654
|
+
/>
|
|
655
|
+
<UButton
|
|
656
|
+
color="neutral"
|
|
657
|
+
variant="outline"
|
|
658
|
+
icon="i-lucide-folder-open"
|
|
659
|
+
@click="openFolderPicker"
|
|
660
|
+
>
|
|
661
|
+
Browse
|
|
662
|
+
</UButton>
|
|
663
|
+
<input
|
|
664
|
+
ref="folderInputRef"
|
|
665
|
+
type="file"
|
|
666
|
+
webkitdirectory
|
|
667
|
+
multiple
|
|
668
|
+
class="hidden"
|
|
669
|
+
@change="handleFolderSelect"
|
|
670
|
+
/>
|
|
671
|
+
</div>
|
|
672
|
+
</UFormField>
|
|
673
|
+
|
|
674
|
+
<div v-if="showPreviewList" class="flex flex-col gap-2">
|
|
675
|
+
<div class="flex items-center justify-between">
|
|
676
|
+
<label class="text-sm font-medium text-default">Projects to Import</label>
|
|
677
|
+
<span
|
|
678
|
+
v-if="isValidating || isReadingFiles"
|
|
679
|
+
class="text-xs text-muted flex items-center gap-1"
|
|
680
|
+
>
|
|
681
|
+
<UIcon name="i-lucide-loader-2" class="w-3 h-3 animate-spin" />
|
|
682
|
+
{{ isReadingFiles ? 'Reading files...' : 'Validating...' }}
|
|
683
|
+
</span>
|
|
684
|
+
<span v-else class="text-xs text-muted">
|
|
685
|
+
{{ validProjects.length + validUploadedProjects.length }} of
|
|
686
|
+
{{ validationResults.length + uploadedProjects.size }} valid
|
|
687
|
+
</span>
|
|
688
|
+
</div>
|
|
689
|
+
<div
|
|
690
|
+
class="border border-default rounded-lg divide-y divide-default max-h-48 overflow-y-auto"
|
|
691
|
+
>
|
|
692
|
+
<div
|
|
693
|
+
v-for="result in validationResults"
|
|
694
|
+
:key="result.path"
|
|
695
|
+
class="flex items-center gap-3 px-3 py-2"
|
|
696
|
+
:class="result.isValid ? 'bg-success/5' : 'bg-error/5'"
|
|
697
|
+
>
|
|
698
|
+
<UIcon
|
|
699
|
+
:name="result.isValid ? 'i-lucide-check-circle' : 'i-lucide-x-circle'"
|
|
700
|
+
class="w-4 h-4 shrink-0"
|
|
701
|
+
:class="result.isValid ? 'text-success' : 'text-error'"
|
|
702
|
+
/>
|
|
703
|
+
<div class="flex-1 min-w-0">
|
|
704
|
+
<div class="flex items-center gap-2">
|
|
705
|
+
<span class="font-mono text-xs truncate">{{ result.path }}</span>
|
|
706
|
+
<UIcon name="i-lucide-arrow-right" class="w-3 h-3 text-muted shrink-0" />
|
|
707
|
+
<span class="font-mono text-xs text-primary font-medium">
|
|
708
|
+
{{ result.suggestedName || '—' }}
|
|
709
|
+
</span>
|
|
710
|
+
</div>
|
|
711
|
+
<p v-if="result.error" class="text-xs text-error mt-0.5">{{ result.error }}</p>
|
|
712
|
+
</div>
|
|
713
|
+
</div>
|
|
714
|
+
<div
|
|
715
|
+
v-for="[folderName, project] in uploadedProjects"
|
|
716
|
+
:key="folderName"
|
|
717
|
+
class="flex items-center gap-3 px-3 py-2"
|
|
718
|
+
:class="project.isValid ? 'bg-success/5' : 'bg-error/5'"
|
|
719
|
+
>
|
|
720
|
+
<UIcon
|
|
721
|
+
:name="project.isValid ? 'i-lucide-check-circle' : 'i-lucide-x-circle'"
|
|
722
|
+
class="w-4 h-4 shrink-0"
|
|
723
|
+
:class="project.isValid ? 'text-success' : 'text-error'"
|
|
724
|
+
/>
|
|
725
|
+
<div class="flex-1 min-w-0">
|
|
726
|
+
<div class="flex items-center gap-2">
|
|
727
|
+
<UIcon name="i-lucide-folder" class="w-3 h-3 text-muted shrink-0" />
|
|
728
|
+
<span class="font-mono text-xs truncate">{{ folderName }}</span>
|
|
729
|
+
<UIcon name="i-lucide-arrow-right" class="w-3 h-3 text-muted shrink-0" />
|
|
730
|
+
<span class="font-mono text-xs text-primary font-medium">
|
|
731
|
+
{{ project.suggestedName }}
|
|
732
|
+
</span>
|
|
733
|
+
<span class="text-xs text-muted">({{ project.files.length }} files)</span>
|
|
734
|
+
</div>
|
|
735
|
+
<p v-if="project.error" class="text-xs text-error mt-0.5">{{ project.error }}</p>
|
|
736
|
+
</div>
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
|
|
741
|
+
<UFormField
|
|
742
|
+
v-if="!hasMultiplePaths"
|
|
743
|
+
label="Name"
|
|
744
|
+
:error="nameError || undefined"
|
|
745
|
+
hint="Optional: Custom name for the imported presentation"
|
|
746
|
+
>
|
|
747
|
+
<UInput
|
|
748
|
+
v-model="importProject.name"
|
|
749
|
+
placeholder="my-presentation (optional)"
|
|
750
|
+
:color="nameError ? 'error' : undefined"
|
|
751
|
+
:ui="{ base: 'font-mono' }"
|
|
752
|
+
class="w-full"
|
|
753
|
+
autocomplete="off"
|
|
754
|
+
@blur="handleNameBlur"
|
|
755
|
+
/>
|
|
756
|
+
</UFormField>
|
|
757
|
+
|
|
758
|
+
<div
|
|
759
|
+
v-if="importProgress"
|
|
760
|
+
class="flex flex-col gap-3 p-4 rounded-lg bg-primary/5 border border-primary/20"
|
|
761
|
+
>
|
|
762
|
+
<div class="flex items-center gap-3">
|
|
763
|
+
<UIcon name="i-lucide-loader-2" class="w-5 h-5 text-primary animate-spin" />
|
|
764
|
+
<div class="flex-1">
|
|
765
|
+
<p class="text-sm font-medium text-default">
|
|
766
|
+
Importing {{ importProgress.currentIndex }}/{{ importProgress.total }}...
|
|
767
|
+
</p>
|
|
768
|
+
<p class="text-xs text-muted font-mono truncate">
|
|
769
|
+
{{ importProgress.currentProjectName }}
|
|
770
|
+
</p>
|
|
771
|
+
</div>
|
|
772
|
+
</div>
|
|
773
|
+
<div class="w-full h-1.5 bg-muted/30 rounded-full overflow-hidden">
|
|
774
|
+
<div
|
|
775
|
+
class="h-full bg-primary rounded-full transition-all duration-300"
|
|
776
|
+
:style="{ width: `${(importProgress.currentIndex / importProgress.total) * 100}%` }"
|
|
777
|
+
/>
|
|
778
|
+
</div>
|
|
779
|
+
</div>
|
|
780
|
+
|
|
781
|
+
<div
|
|
782
|
+
v-else-if="importSummary && importSummary.failureCount > 0"
|
|
783
|
+
class="flex flex-col gap-3"
|
|
784
|
+
>
|
|
785
|
+
<div
|
|
786
|
+
class="flex items-center gap-3 p-3 rounded-lg bg-warning/10 border border-warning/20"
|
|
787
|
+
>
|
|
788
|
+
<UIcon name="i-lucide-alert-triangle" class="w-5 h-5 text-warning shrink-0" />
|
|
789
|
+
<div class="flex-1">
|
|
790
|
+
<p class="text-sm font-medium text-default">Import Partially Completed</p>
|
|
791
|
+
<p class="text-xs text-muted">
|
|
792
|
+
{{ importSummary.successCount }} succeeded, {{ importSummary.failureCount }} failed
|
|
793
|
+
</p>
|
|
794
|
+
</div>
|
|
795
|
+
</div>
|
|
796
|
+
<div
|
|
797
|
+
v-if="importSummary.errors.length > 0"
|
|
798
|
+
class="border border-error/20 rounded-lg divide-y divide-error/10 max-h-32 overflow-y-auto"
|
|
799
|
+
>
|
|
800
|
+
<div
|
|
801
|
+
v-for="error in importSummary.errors"
|
|
802
|
+
:key="error.path"
|
|
803
|
+
class="flex items-start gap-2 px-3 py-2 bg-error/5"
|
|
804
|
+
>
|
|
805
|
+
<UIcon name="i-lucide-x-circle" class="w-4 h-4 text-error shrink-0 mt-0.5" />
|
|
806
|
+
<div class="flex-1 min-w-0">
|
|
807
|
+
<span class="font-mono text-xs text-default truncate block">{{ error.path }}</span>
|
|
808
|
+
<span class="text-xs text-error">{{ error.error }}</span>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
812
|
+
</div>
|
|
813
|
+
|
|
814
|
+
<div
|
|
815
|
+
v-else-if="!importProgress && !importSummary"
|
|
816
|
+
class="flex items-start gap-3 p-3 rounded-lg bg-muted/50"
|
|
817
|
+
>
|
|
818
|
+
<UIcon name="i-lucide-info" class="w-4 h-4 text-muted mt-0.5 shrink-0" />
|
|
819
|
+
<p class="text-sm text-muted">
|
|
820
|
+
{{ hasMultiplePaths ? 'Presentations' : 'The presentation' }} will be copied to your
|
|
821
|
+
workspace. Files like
|
|
822
|
+
<code class="font-mono text-xs px-1.5 py-0.5 rounded bg-muted">node_modules</code>
|
|
823
|
+
and lock files will be ignored.
|
|
824
|
+
</p>
|
|
825
|
+
</div>
|
|
826
|
+
</form>
|
|
827
|
+
</template>
|
|
828
|
+
|
|
829
|
+
<template #footer>
|
|
830
|
+
<div class="flex gap-3 justify-end">
|
|
831
|
+
<UButton
|
|
832
|
+
v-if="importSummary && importSummary.failureCount > 0"
|
|
833
|
+
color="neutral"
|
|
834
|
+
variant="solid"
|
|
835
|
+
@click="handleClose"
|
|
836
|
+
>
|
|
837
|
+
Close
|
|
838
|
+
</UButton>
|
|
839
|
+
<template v-else>
|
|
840
|
+
<UButton color="neutral" variant="ghost" :disabled="isSubmitting" @click="handleClose">
|
|
841
|
+
Cancel
|
|
842
|
+
</UButton>
|
|
843
|
+
<UButton
|
|
844
|
+
:disabled="!isValid || isSubmitting || isValidating"
|
|
845
|
+
:loading="isSubmitting || isValidating"
|
|
846
|
+
icon="i-lucide-import"
|
|
847
|
+
@click="handleSubmit"
|
|
848
|
+
>
|
|
849
|
+
{{
|
|
850
|
+
isSubmitting
|
|
851
|
+
? 'Importing...'
|
|
852
|
+
: isValidating
|
|
853
|
+
? 'Validating...'
|
|
854
|
+
: validProjects.length > 1
|
|
855
|
+
? `Import ${validProjects.length} Presentations`
|
|
856
|
+
: validProjects.length === 1
|
|
857
|
+
? 'Import Presentation'
|
|
858
|
+
: 'Import'
|
|
859
|
+
}}
|
|
860
|
+
</UButton>
|
|
861
|
+
</template>
|
|
862
|
+
</div>
|
|
863
|
+
</template>
|
|
864
|
+
</UModal>
|
|
865
|
+
</template>
|