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.
Files changed (67) hide show
  1. package/app/app.config.ts +9 -0
  2. package/app/assets/css/main.css +90 -0
  3. package/app/components/AppHeader.vue +429 -0
  4. package/app/components/CreatePresentationDialog.vue +236 -0
  5. package/app/components/EmptyState.vue +37 -0
  6. package/app/components/ImportPresentationDialog.vue +865 -0
  7. package/app/components/PresentationCard.vue +343 -0
  8. package/app/components/PresentationListItem.vue +242 -0
  9. package/app/composables/useServers.ts +148 -0
  10. package/app/layouts/default.vue +49 -0
  11. package/app/pages/index.vue +542 -0
  12. package/dist/cli/index.js +183751 -137
  13. package/dist/config.d.ts +8 -0
  14. package/dist/config.js +16 -0
  15. package/dist/index.d.ts +21 -0
  16. package/dist/index.js +3 -0
  17. package/dist/module.d.ts +6 -0
  18. package/dist/module.js +9168 -0
  19. package/dist/prompt.js +847 -0
  20. package/nuxt.config.ts +53 -0
  21. package/package.json +26 -19
  22. package/server/api/export/[id].post.ts +67 -0
  23. package/server/api/open-editor/[id].post.ts +28 -0
  24. package/server/api/presentations/import.post.ts +139 -0
  25. package/server/api/presentations/index.get.ts +18 -0
  26. package/server/api/presentations/index.post.ts +175 -0
  27. package/server/api/presentations/upload.post.ts +174 -0
  28. package/server/api/presentations/validate.post.ts +14 -0
  29. package/server/api/servers/[id].delete.ts +15 -0
  30. package/server/api/servers/[id].post.ts +17 -0
  31. package/server/api/servers/index.delete.ts +5 -0
  32. package/server/api/servers/index.get.ts +5 -0
  33. package/server/api/servers/stop-all.post.ts +5 -0
  34. package/server/plugins/generate.ts +12 -0
  35. package/server/plugins/shutdown.ts +16 -0
  36. package/server/routes/exports/[...path].get.ts +25 -0
  37. package/server/utils/config.ts +13 -0
  38. package/server/utils/process-manager.ts +119 -0
  39. package/src/cli/commands/create.ts +125 -0
  40. package/src/cli/commands/deploy.ts +90 -0
  41. package/src/cli/commands/dev.ts +116 -0
  42. package/src/cli/commands/export.ts +63 -0
  43. package/src/cli/commands/import.ts +178 -0
  44. package/src/cli/commands/present.ts +111 -0
  45. package/src/cli/index.ts +87 -0
  46. package/src/cli/utils.ts +94 -0
  47. package/src/config.ts +21 -0
  48. package/src/index.ts +2 -0
  49. package/src/module.ts +12 -0
  50. package/src/shared/catalog.ts +94 -0
  51. package/src/shared/copy.ts +28 -0
  52. package/src/shared/index.ts +29 -0
  53. package/{scripts/generate-presentations.mjs → src/shared/presentations.ts} +23 -46
  54. package/src/shared/types.ts +29 -0
  55. package/src/shared/validation.ts +111 -0
  56. package/dist/assets/index-BerY9FcI.js +0 -49
  57. package/dist/assets/index-CVzsY-on.css +0 -1
  58. package/dist/index.html +0 -24
  59. package/server/api.js +0 -1225
  60. /package/{dist → public}/apple-touch-icon.png +0 -0
  61. /package/{dist → public}/favicon-96x96.png +0 -0
  62. /package/{dist → public}/favicon.ico +0 -0
  63. /package/{dist → public}/favicon.svg +0 -0
  64. /package/{dist → public}/site.webmanifest +0 -0
  65. /package/{dist → public}/ssl-logo.png +0 -0
  66. /package/{dist → public}/web-app-manifest-192x192.png +0 -0
  67. /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>