lumina-slides 8.9.4 → 9.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.
- package/LUMINA_LLM_EXAMPLES.json +234 -0
- package/README.md +18 -18
- package/dist/lumina-slides.js +13207 -12659
- package/dist/lumina-slides.umd.cjs +215 -215
- package/dist/style.css +1 -1
- package/package.json +5 -4
- package/src/App.vue +16 -0
- package/src/animation/index.ts +11 -0
- package/src/animation/registry.ts +126 -0
- package/src/animation/stagger.ts +95 -0
- package/src/animation/types.ts +53 -0
- package/src/components/LandingPage.vue +229 -0
- package/src/components/LuminaDeck.vue +224 -0
- package/src/components/LuminaSpeakerNotes.vue +701 -0
- package/src/components/base/BaseSlide.vue +122 -0
- package/src/components/base/LuminaElement.vue +67 -0
- package/src/components/base/VideoPlayer.vue +204 -0
- package/src/components/layouts/LayoutAuto.vue +71 -0
- package/src/components/layouts/LayoutChart.vue +287 -0
- package/src/components/layouts/LayoutCustom.vue +92 -0
- package/src/components/layouts/LayoutDiagram.vue +253 -0
- package/src/components/layouts/LayoutFeatures.vue +121 -0
- package/src/components/layouts/LayoutFlex.vue +172 -0
- package/src/components/layouts/LayoutFree.vue +62 -0
- package/src/components/layouts/LayoutHalf.vue +127 -0
- package/src/components/layouts/LayoutStatement.vue +74 -0
- package/src/components/layouts/LayoutSteps.vue +106 -0
- package/src/components/layouts/LayoutTimeline.vue +104 -0
- package/src/components/layouts/LayoutVideo.vue +41 -0
- package/src/components/parts/FlexBullets.vue +45 -0
- package/src/components/parts/FlexButton.vue +132 -0
- package/src/components/parts/FlexImage.vue +54 -0
- package/src/components/parts/FlexOrdered.vue +44 -0
- package/src/components/parts/FlexSpacer.vue +13 -0
- package/src/components/parts/FlexStepper.vue +59 -0
- package/src/components/parts/FlexText.vue +29 -0
- package/src/components/parts/FlexTimeline.vue +67 -0
- package/src/components/parts/FlexTitle.vue +39 -0
- package/src/components/parts/LuminaBackground.vue +100 -0
- package/src/components/site/LivePreview.vue +101 -0
- package/src/components/site/SiteApi.vue +301 -0
- package/src/components/site/SiteDashboard.vue +604 -0
- package/src/components/site/SiteDocs.vue +3267 -0
- package/src/components/site/SiteExamples.vue +65 -0
- package/src/components/site/SiteFooter.vue +6 -0
- package/src/components/site/SiteHome.vue +362 -0
- package/src/components/site/SiteNavBar.vue +122 -0
- package/src/components/site/SitePlayground.vue +389 -0
- package/src/components/site/SitePromptBuilder.vue +266 -0
- package/src/components/site/SiteUserMenu.vue +90 -0
- package/src/components/studio/ActionEditor.vue +108 -0
- package/src/components/studio/ArrayEditor.vue +124 -0
- package/src/components/studio/CollapsibleSection.vue +33 -0
- package/src/components/studio/ColorField.vue +22 -0
- package/src/components/studio/EditorCanvas.vue +326 -0
- package/src/components/studio/EditorLayoutFeatures.vue +18 -0
- package/src/components/studio/EditorLayoutFixed.vue +46 -0
- package/src/components/studio/EditorLayoutFlex.vue +133 -0
- package/src/components/studio/EditorLayoutHalf.vue +18 -0
- package/src/components/studio/EditorLayoutStatement.vue +18 -0
- package/src/components/studio/EditorLayoutSteps.vue +18 -0
- package/src/components/studio/EditorLayoutTimeline.vue +18 -0
- package/src/components/studio/EditorNode.vue +89 -0
- package/src/components/studio/FieldEditor.vue +133 -0
- package/src/components/studio/IconPicker.vue +109 -0
- package/src/components/studio/LayerItem.vue +117 -0
- package/src/components/studio/LuminaStudio.vue +30 -0
- package/src/components/studio/SaveSuccessModal.vue +138 -0
- package/src/components/studio/SlideNavigator.vue +373 -0
- package/src/components/studio/SliderField.vue +44 -0
- package/src/components/studio/StudioInspector.vue +595 -0
- package/src/components/studio/StudioJsonEditor.vue +191 -0
- package/src/components/studio/StudioLayers.vue +145 -0
- package/src/components/studio/StudioSettings.vue +514 -0
- package/src/components/studio/StudioSidebar.vue +29 -0
- package/src/components/studio/StudioToolbar.vue +222 -0
- package/src/components/studio/fieldLabels.ts +224 -0
- package/src/components/studio/inspectors/DiagramEdgeEditor.vue +77 -0
- package/src/components/studio/inspectors/DiagramNodeEditor.vue +117 -0
- package/src/components/studio/nodes/StudioDiagramNode.vue +138 -0
- package/src/composables/useAuth.ts +87 -0
- package/src/composables/useEditor.ts +224 -0
- package/src/composables/useElementState.ts +81 -0
- package/src/composables/useFlexLayout.ts +122 -0
- package/src/composables/useKeyboard.ts +45 -0
- package/src/composables/useLumina.ts +32 -0
- package/src/composables/useStudio.ts +87 -0
- package/src/composables/useSwipeNav.ts +53 -0
- package/src/composables/useTransition.ts +373 -0
- package/src/core/Lumina.ts +819 -0
- package/src/core/animationConfig.ts +251 -0
- package/src/core/compression.ts +34 -0
- package/src/core/elementController.ts +170 -0
- package/src/core/elementId.ts +27 -0
- package/src/core/elementResolver.ts +207 -0
- package/src/core/events.ts +53 -0
- package/src/core/fonts.ts +100 -0
- package/src/core/presets.ts +231 -0
- package/src/core/prompts.ts +272 -0
- package/src/core/schema.ts +478 -0
- package/src/core/speaker-channel.ts +250 -0
- package/src/core/store.ts +461 -0
- package/src/core/theme.ts +666 -0
- package/src/core/types.ts +1611 -0
- package/src/directives/vStudio.ts +45 -0
- package/src/index.ts +175 -0
- package/src/main.ts +17 -0
- package/src/router/index.ts +92 -0
- package/src/style/main.css +462 -0
- package/src/utils/deep.ts +127 -0
- package/src/utils/firebase.ts +184 -0
- package/src/utils/streaming.ts +134 -0
- package/src/views/DashboardView.vue +32 -0
- package/src/views/DeckView.vue +289 -0
- package/src/views/HomeView.vue +17 -0
- package/src/views/SiteLayout.vue +21 -0
- package/src/views/StudioView.vue +61 -0
- package/src/vite-env.d.ts +6 -0
- package/IMPLEMENTATION.md +0 -418
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="editor-node relative group outline-none transition-all duration-150" :class="nodeClasses"
|
|
3
|
+
@click.stop="select" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
|
|
4
|
+
|
|
5
|
+
<!-- Hover Label (shows on hover before selection) -->
|
|
6
|
+
<div v-if="isHovered && !isSelected"
|
|
7
|
+
class="absolute -top-5 left-0 bg-blue-500/80 text-white text-[9px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wider z-20 pointer-events-none whitespace-nowrap">
|
|
8
|
+
{{ label || 'Element' }}
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<!-- Selection Handle / Label (Top Left) -->
|
|
12
|
+
<div v-if="isSelected"
|
|
13
|
+
class="absolute -top-5 left-0 bg-blue-500 text-white text-[10px] px-1.5 py-0.5 rounded-t font-bold uppercase tracking-wider flex items-center gap-1 z-20">
|
|
14
|
+
<span>{{ label || 'Element' }}</span>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<!-- Slot for the actual content -->
|
|
18
|
+
<slot></slot>
|
|
19
|
+
|
|
20
|
+
<!-- Resize Handle (Right Edge for width adjustment) -->
|
|
21
|
+
<div v-if="isSelected && resizable" @mousedown.stop.prevent="startResize"
|
|
22
|
+
class="absolute top-0 right-0 w-2 h-full cursor-ew-resize bg-blue-500/50 hover:bg-blue-500 transition opacity-0 group-hover:opacity-100"
|
|
23
|
+
:class="{ 'opacity-100': isResizing }">
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
import { ref, computed } from 'vue';
|
|
30
|
+
import { useEditor } from '../../composables/useEditor';
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<{
|
|
33
|
+
path: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
resizable?: boolean;
|
|
36
|
+
}>(), {
|
|
37
|
+
resizable: false
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const editor = useEditor();
|
|
41
|
+
|
|
42
|
+
const isSelected = computed(() => editor.selection.value === props.path);
|
|
43
|
+
const isHovered = ref(false);
|
|
44
|
+
const isResizing = ref(false);
|
|
45
|
+
|
|
46
|
+
// Dynamic classes for different states
|
|
47
|
+
const nodeClasses = computed(() => {
|
|
48
|
+
if (isSelected.value) {
|
|
49
|
+
return 'ring-2 ring-blue-500 ring-offset-1 ring-offset-transparent z-20 bg-blue-500/5';
|
|
50
|
+
}
|
|
51
|
+
if (isHovered.value) {
|
|
52
|
+
return 'ring-2 ring-dashed ring-blue-400/60 z-10 cursor-pointer';
|
|
53
|
+
}
|
|
54
|
+
return 'hover:ring-2 hover:ring-dashed hover:ring-blue-400/40';
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const select = () => {
|
|
58
|
+
editor.select(props.path);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const startResize = (event: MouseEvent) => {
|
|
62
|
+
isResizing.value = true;
|
|
63
|
+
const startX = event.clientX;
|
|
64
|
+
|
|
65
|
+
const onMouseMove = (e: MouseEvent) => {
|
|
66
|
+
const deltaX = e.clientX - startX;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const onMouseUp = () => {
|
|
70
|
+
isResizing.value = false;
|
|
71
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
72
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
76
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
77
|
+
};
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<style scoped>
|
|
81
|
+
.editor-node {
|
|
82
|
+
min-height: 20px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Dashed ring for hover state */
|
|
86
|
+
.ring-dashed {
|
|
87
|
+
--tw-ring-offset-shadow: none;
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="space-y-1">
|
|
3
|
+
<label class="text-white/50 uppercase tracking-wider text-[10px] block">{{ friendlyLabel }}</label>
|
|
4
|
+
|
|
5
|
+
<!-- Icon Field - use IconPicker -->
|
|
6
|
+
<IconPicker v-if="isIconField" :model-value="String(value || '')"
|
|
7
|
+
@update:model-value="emit('update', fieldKey, $event)" />
|
|
8
|
+
|
|
9
|
+
<!-- Enum Select with friendly labels -->
|
|
10
|
+
<select v-else-if="enumOptions" :value="value"
|
|
11
|
+
@change="emit('update', fieldKey, ($event.target as HTMLSelectElement).value)"
|
|
12
|
+
class="w-full bg-[#1a1a1a] border border-[#333] rounded px-2 py-1.5 text-white focus:border-blue-500 focus:outline-none">
|
|
13
|
+
<option v-for="opt in enumOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
|
14
|
+
</select>
|
|
15
|
+
|
|
16
|
+
<!-- Boolean Toggle -->
|
|
17
|
+
<div v-else-if="typeof value === 'boolean'" class="flex items-center justify-between py-1">
|
|
18
|
+
<button @click="emit('update', fieldKey, !value)" class="w-10 h-5 rounded-full transition-colors relative"
|
|
19
|
+
:class="value ? 'bg-blue-600' : 'bg-[#333]'">
|
|
20
|
+
<span class="absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform"
|
|
21
|
+
:class="value ? 'left-5' : 'left-0.5'"></span>
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- Number Input -->
|
|
26
|
+
<input v-else-if="typeof value === 'number'" type="number" :value="value"
|
|
27
|
+
@input="emit('update', fieldKey, parseFloat(($event.target as HTMLInputElement).value))"
|
|
28
|
+
class="w-full bg-[#1a1a1a] border border-[#333] rounded px-2 py-1.5 text-white focus:border-blue-500 focus:outline-none" />
|
|
29
|
+
|
|
30
|
+
<!-- Color Input -->
|
|
31
|
+
<div v-else-if="isColorField" class="flex gap-2">
|
|
32
|
+
<input type="color" :value="String(value || '#ffffff')"
|
|
33
|
+
@input="emit('update', fieldKey, ($event.target as HTMLInputElement).value)"
|
|
34
|
+
class="w-10 h-8 rounded border border-[#333] cursor-pointer bg-transparent" />
|
|
35
|
+
<input type="text" :value="value"
|
|
36
|
+
@input="emit('update', fieldKey, ($event.target as HTMLInputElement).value)"
|
|
37
|
+
class="flex-1 bg-[#1a1a1a] border border-[#333] rounded px-2 py-1.5 text-white font-mono text-xs focus:border-blue-500 focus:outline-none" />
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- Image URL with Preview -->
|
|
41
|
+
<div v-else-if="isImageField" class="space-y-2">
|
|
42
|
+
<input type="url" :value="value"
|
|
43
|
+
@input="emit('update', fieldKey, ($event.target as HTMLInputElement).value)"
|
|
44
|
+
placeholder="https://example.com/image.jpg"
|
|
45
|
+
class="w-full bg-[#1a1a1a] border border-[#333] rounded px-2 py-1.5 text-white focus:border-blue-500 focus:outline-none" />
|
|
46
|
+
<div v-if="value" class="w-full h-16 rounded bg-[#1a1a1a] border border-[#333] overflow-hidden">
|
|
47
|
+
<img :src="String(value)" alt="Preview" class="w-full h-full object-cover" @error="onImageError" />
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<!-- Long Text (textarea) -->
|
|
52
|
+
<textarea v-else-if="isLongText" :value="String(value || '')"
|
|
53
|
+
@input="emit('update', fieldKey, ($event.target as HTMLTextAreaElement).value)"
|
|
54
|
+
class="w-full bg-[#1a1a1a] border border-[#333] rounded px-2 py-1.5 text-white focus:border-blue-500 focus:outline-none resize-none"
|
|
55
|
+
rows="3"></textarea>
|
|
56
|
+
|
|
57
|
+
<!-- Default String Input -->
|
|
58
|
+
<input v-else type="text" :value="value"
|
|
59
|
+
@input="emit('update', fieldKey, ($event.target as HTMLInputElement).value)"
|
|
60
|
+
:placeholder="getPlaceholder(fieldKey)"
|
|
61
|
+
class="w-full bg-[#1a1a1a] border border-[#333] rounded px-2 py-1.5 text-white focus:border-blue-500 focus:outline-none" />
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<script setup lang="ts">
|
|
66
|
+
import { computed } from 'vue';
|
|
67
|
+
import IconPicker from './IconPicker.vue';
|
|
68
|
+
import { getFieldLabel, getValueOptions, VALUE_LABELS } from './fieldLabels';
|
|
69
|
+
|
|
70
|
+
const props = defineProps<{
|
|
71
|
+
fieldKey: string;
|
|
72
|
+
value: any;
|
|
73
|
+
}>();
|
|
74
|
+
|
|
75
|
+
const emit = defineEmits<{
|
|
76
|
+
(e: 'update', key: string, value: any): void;
|
|
77
|
+
}>();
|
|
78
|
+
|
|
79
|
+
// Friendly label from mapping
|
|
80
|
+
const friendlyLabel = computed(() => getFieldLabel(props.fieldKey));
|
|
81
|
+
|
|
82
|
+
// Field type detection
|
|
83
|
+
const isIconField = computed(() => props.fieldKey === 'icon');
|
|
84
|
+
|
|
85
|
+
const isColorField = computed(() => {
|
|
86
|
+
const colorFields = ['color', 'primaryColor', 'secondaryColor', 'backgroundColor', 'orbColor'];
|
|
87
|
+
return colorFields.includes(props.fieldKey);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const isImageField = computed(() => {
|
|
91
|
+
const imageFields = ['src', 'image', 'poster', 'background'];
|
|
92
|
+
return imageFields.includes(props.fieldKey) && typeof props.value === 'string';
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const isLongText = computed(() => {
|
|
96
|
+
if (typeof props.value !== 'string') return false;
|
|
97
|
+
const longFields = ['text', 'description', 'desc', 'notes', 'subtitle', 'html', 'css'];
|
|
98
|
+
return longFields.includes(props.fieldKey) || props.value.length > 60;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Enum options with friendly labels
|
|
102
|
+
const enumOptions = computed(() => {
|
|
103
|
+
const options = getValueOptions(props.fieldKey);
|
|
104
|
+
if (options) return options;
|
|
105
|
+
|
|
106
|
+
// Fallback for legacy unmapped enums
|
|
107
|
+
const legacyEnums: Record<string, string[]> = {
|
|
108
|
+
'titleSize': ['lg', 'xl', '2xl', '3xl'],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (legacyEnums[props.fieldKey]) {
|
|
112
|
+
return legacyEnums[props.fieldKey].map(v => ({ value: v, label: v.toUpperCase() }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const getPlaceholder = (key: string): string => {
|
|
119
|
+
const placeholders: Record<string, string> = {
|
|
120
|
+
'action': 'button-click-action',
|
|
121
|
+
'href': 'https://example.com',
|
|
122
|
+
'alt': 'Image description',
|
|
123
|
+
'tag': 'Category or label',
|
|
124
|
+
'cta': 'Call to action text',
|
|
125
|
+
};
|
|
126
|
+
return placeholders[key] || '';
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const onImageError = (e: Event) => {
|
|
130
|
+
const img = e.target as HTMLImageElement;
|
|
131
|
+
img.style.display = 'none';
|
|
132
|
+
};
|
|
133
|
+
</script>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="space-y-2">
|
|
3
|
+
<label class="text-white/50 uppercase tracking-wider text-[10px] block">{{ label }}</label>
|
|
4
|
+
|
|
5
|
+
<!-- Current Selection Display -->
|
|
6
|
+
<div class="flex items-center gap-2">
|
|
7
|
+
<div class="w-8 h-8 rounded bg-[#1a1a1a] border border-[#333] flex items-center justify-center text-lg">
|
|
8
|
+
<i :class="currentIconClass" class="text-white"></i>
|
|
9
|
+
</div>
|
|
10
|
+
<button @click="showPicker = !showPicker"
|
|
11
|
+
class="flex-1 bg-[#1a1a1a] border border-[#333] rounded px-2 py-1.5 text-white text-left hover:border-blue-500 transition text-xs">
|
|
12
|
+
{{ modelValue || 'Select icon...' }}
|
|
13
|
+
</button>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<!-- Icon Picker Modal -->
|
|
17
|
+
<div v-if="showPicker" class="bg-[#111] border border-[#333] rounded-lg p-3 space-y-2">
|
|
18
|
+
<!-- Search -->
|
|
19
|
+
<input v-model="search" type="text" placeholder="Search icons..."
|
|
20
|
+
class="w-full bg-[#1a1a1a] border border-[#333] rounded px-2 py-1.5 text-white text-xs focus:border-blue-500 focus:outline-none" />
|
|
21
|
+
|
|
22
|
+
<!-- Icon Grid -->
|
|
23
|
+
<div class="grid grid-cols-6 gap-1 max-h-48 overflow-y-auto">
|
|
24
|
+
<button v-for="icon in filteredIcons" :key="icon" @click="selectIcon(icon)"
|
|
25
|
+
class="w-8 h-8 rounded flex items-center justify-center hover:bg-blue-600 transition"
|
|
26
|
+
:class="modelValue === icon ? 'bg-blue-600' : 'bg-[#1a1a1a]'" :title="icon">
|
|
27
|
+
<i :class="`ph-thin ph-${icon}`" class="text-white text-sm"></i>
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<!-- Common categories -->
|
|
32
|
+
<div class="flex flex-wrap gap-1 pt-2 border-t border-[#333]">
|
|
33
|
+
<button v-for="cat in categories" :key="cat.name" @click="search = cat.prefix"
|
|
34
|
+
class="px-2 py-0.5 rounded text-[10px] bg-[#1a1a1a] text-white/70 hover:bg-[#333] hover:text-white">
|
|
35
|
+
{{ cat.name }}
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<script setup lang="ts">
|
|
43
|
+
import { ref, computed } from 'vue';
|
|
44
|
+
|
|
45
|
+
const props = defineProps<{
|
|
46
|
+
modelValue: string;
|
|
47
|
+
label?: string;
|
|
48
|
+
}>();
|
|
49
|
+
|
|
50
|
+
const emit = defineEmits<{
|
|
51
|
+
(e: 'update:modelValue', value: string): void;
|
|
52
|
+
}>();
|
|
53
|
+
|
|
54
|
+
const showPicker = ref(false);
|
|
55
|
+
const search = ref('');
|
|
56
|
+
|
|
57
|
+
// Common FontAwesome icons (50+)
|
|
58
|
+
const iconList = [
|
|
59
|
+
// General
|
|
60
|
+
'star', 'heart', 'check', 'x', 'plus', 'minus', 'magnifying-glass', 'gear', 'house', 'user',
|
|
61
|
+
// Arrows
|
|
62
|
+
'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'caret-right', 'caret-left',
|
|
63
|
+
// Business
|
|
64
|
+
'briefcase', 'chart-line', 'chart-bar', 'currency-dollar', 'coins', 'wallet', 'buildings',
|
|
65
|
+
// Tech
|
|
66
|
+
'laptop', 'device-mobile', 'desktop', 'code', 'database', 'hard-drive', 'cloud', 'wifi-high',
|
|
67
|
+
// Communication
|
|
68
|
+
'envelope', 'phone', 'chat-centered', 'chat-dots', 'bell', 'paper-plane',
|
|
69
|
+
// Media
|
|
70
|
+
'image', 'video-camera', 'camera', 'play', 'pause', 'speaker-high', 'music-note',
|
|
71
|
+
// Objects
|
|
72
|
+
'lightbulb', 'lightning', 'fire', 'rocket', 'trophy', 'medal', 'flag', 'gift',
|
|
73
|
+
// Nature
|
|
74
|
+
'leaf', 'tree', 'sun', 'moon', 'globe', 'mountains',
|
|
75
|
+
// Security
|
|
76
|
+
'lock', 'lock-open', 'shield-check', 'key', 'eye', 'eye-slash',
|
|
77
|
+
// Actions
|
|
78
|
+
'download-simple', 'upload-simple', 'share', 'link', 'floppy-disk', 'pencil-simple', 'trash', 'copy',
|
|
79
|
+
// People
|
|
80
|
+
'users', 'user-plus', 'handshake', 'users-four',
|
|
81
|
+
// Misc
|
|
82
|
+
'magic-wand', 'sparkle', 'diamond', 'crown', 'infinity'
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const categories = [
|
|
86
|
+
{ name: 'All', prefix: '' },
|
|
87
|
+
{ name: 'Arrows', prefix: 'arrow' },
|
|
88
|
+
{ name: 'Business', prefix: 'chart' },
|
|
89
|
+
{ name: 'Tech', prefix: 'code' },
|
|
90
|
+
{ name: 'People', prefix: 'user' }
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const filteredIcons = computed(() => {
|
|
94
|
+
if (!search.value) return iconList;
|
|
95
|
+
return iconList.filter(icon => icon.includes(search.value.toLowerCase()));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const currentIconClass = computed(() => {
|
|
99
|
+
if (!props.modelValue) return 'ph-thin ph-question';
|
|
100
|
+
if (props.modelValue.startsWith('ph-')) return `ph-thin ${props.modelValue}`;
|
|
101
|
+
return `ph-thin ph-${props.modelValue}`;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const selectIcon = (icon: string) => {
|
|
105
|
+
emit('update:modelValue', icon);
|
|
106
|
+
showPicker.value = false;
|
|
107
|
+
search.value = '';
|
|
108
|
+
};
|
|
109
|
+
</script>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="layer-item" :class="{ 'ml-3': depth > 0 }">
|
|
3
|
+
<!-- Node Header -->
|
|
4
|
+
<div class="flex items-center gap-1 py-1 px-2 rounded cursor-pointer transition-colors text-xs" :class="[
|
|
5
|
+
isSelected ? 'bg-blue-600 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'
|
|
6
|
+
]" @click.stop="select">
|
|
7
|
+
|
|
8
|
+
<!-- Expand/Collapse (if has children) -->
|
|
9
|
+
<button v-if="hasChildren" @click.stop="toggleExpand"
|
|
10
|
+
class="w-4 h-4 flex items-center justify-center text-white/50 hover:text-white">
|
|
11
|
+
<i :class="expanded ? 'ph-thin ph-caret-down' : 'ph-thin ph-caret-right'" class="text-[8px]"></i>
|
|
12
|
+
</button>
|
|
13
|
+
<div v-else class="w-4"></div>
|
|
14
|
+
|
|
15
|
+
<!-- Type Icon -->
|
|
16
|
+
<i :class="typeIcon" class="text-[10px] opacity-70"></i>
|
|
17
|
+
|
|
18
|
+
<!-- Label -->
|
|
19
|
+
<span class="truncate flex-1">{{ label }}</span>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<!-- Children (Recursive) -->
|
|
23
|
+
<div v-if="expanded && hasChildren" class="border-l border-white/10 ml-2">
|
|
24
|
+
<draggable :list="childrenList" item-key="dragKey" :group="{ name: 'layers' }" @change="handleDragChange"
|
|
25
|
+
class="min-h-[8px]">
|
|
26
|
+
<template #item="{ element, index }">
|
|
27
|
+
<LayerItem :data="element" :path="`${path}.${childrenKey}.${index}`" :depth="depth + 1" />
|
|
28
|
+
</template>
|
|
29
|
+
</draggable>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<script setup lang="ts">
|
|
35
|
+
import { ref, computed } from 'vue';
|
|
36
|
+
import draggable from 'vuedraggable';
|
|
37
|
+
import { useEditor } from '../../composables/useEditor';
|
|
38
|
+
|
|
39
|
+
const props = defineProps<{
|
|
40
|
+
data: any;
|
|
41
|
+
path: string;
|
|
42
|
+
depth?: number;
|
|
43
|
+
}>();
|
|
44
|
+
|
|
45
|
+
const depth = props.depth ?? 0;
|
|
46
|
+
const editor = useEditor();
|
|
47
|
+
|
|
48
|
+
const expanded = ref(true);
|
|
49
|
+
const isSelected = computed(() => editor.selection.value === props.path);
|
|
50
|
+
|
|
51
|
+
// Determine the type and label
|
|
52
|
+
const label = computed(() => {
|
|
53
|
+
if (props.data?.type) return props.data.type;
|
|
54
|
+
if (props.data?.title) return props.data.title;
|
|
55
|
+
if (props.data?.text) return props.data.text.substring(0, 20) + '...';
|
|
56
|
+
return 'Element';
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const typeIcon = computed(() => {
|
|
60
|
+
const iconMap: Record<string, string> = {
|
|
61
|
+
'title': 'ph-thin ph-text-h',
|
|
62
|
+
'text': 'ph-thin ph-text-align-left',
|
|
63
|
+
'image': 'ph-thin ph-image',
|
|
64
|
+
'video': 'ph-thin ph-video-camera',
|
|
65
|
+
'content': 'ph-thin ph-square',
|
|
66
|
+
'bullets': 'ph-thin ph-list-bullets',
|
|
67
|
+
'ordered': 'ph-thin ph-list-numbers',
|
|
68
|
+
'button': 'ph-thin ph-rectangle',
|
|
69
|
+
'flex': 'ph-thin ph-columns',
|
|
70
|
+
'half': 'ph-thin ph-layout',
|
|
71
|
+
'statement': 'ph-thin ph-quotes',
|
|
72
|
+
};
|
|
73
|
+
return iconMap[props.data?.type] || 'ph-thin ph-cube';
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Children logic
|
|
77
|
+
const childrenKey = computed(() => {
|
|
78
|
+
if (props.data?.elements) return 'elements';
|
|
79
|
+
if (props.data?.items) return 'items';
|
|
80
|
+
if (props.data?.features) return 'features';
|
|
81
|
+
return null;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const hasChildren = computed(() => {
|
|
85
|
+
if (!childrenKey.value) return false;
|
|
86
|
+
const children = props.data[childrenKey.value];
|
|
87
|
+
return Array.isArray(children) && children.length > 0;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const childrenList = computed(() => {
|
|
91
|
+
if (!childrenKey.value) return [];
|
|
92
|
+
return props.data[childrenKey.value] || [];
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const toggleExpand = () => {
|
|
96
|
+
expanded.value = !expanded.value;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const select = () => {
|
|
100
|
+
editor.select(props.path);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleDragChange = (event: any) => {
|
|
104
|
+
if (event.moved) {
|
|
105
|
+
const { oldIndex, newIndex } = event.moved;
|
|
106
|
+
editor.store.moveNode(`${props.path}.${childrenKey.value}`, oldIndex, newIndex);
|
|
107
|
+
editor.commit();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<script lang="ts">
|
|
113
|
+
import { defineComponent } from 'vue';
|
|
114
|
+
export default defineComponent({
|
|
115
|
+
name: 'LayerItem'
|
|
116
|
+
});
|
|
117
|
+
</script>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex flex-col w-full h-screen bg-black text-white font-sans overflow-hidden">
|
|
3
|
+
<StudioToolbar />
|
|
4
|
+
<div class="flex flex-1 overflow-hidden relative">
|
|
5
|
+
<StudioSidebar />
|
|
6
|
+
<EditorCanvas />
|
|
7
|
+
<div class="w-72 bg-[#111] border-l border-[#333] flex flex-col">
|
|
8
|
+
<StudioInspector />
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<!-- Floating Slide Navigator -->
|
|
12
|
+
<SlideNavigator />
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script setup lang="ts">
|
|
18
|
+
import { provide } from 'vue';
|
|
19
|
+
import { createEditor, EditorKey } from '../../composables/useEditor';
|
|
20
|
+
import StudioToolbar from './StudioToolbar.vue';
|
|
21
|
+
import StudioSidebar from './StudioSidebar.vue';
|
|
22
|
+
import StudioInspector from './StudioInspector.vue';
|
|
23
|
+
import EditorCanvas from './EditorCanvas.vue';
|
|
24
|
+
import SlideNavigator from './SlideNavigator.vue';
|
|
25
|
+
|
|
26
|
+
// Initialize Editor State
|
|
27
|
+
const editor = createEditor();
|
|
28
|
+
provide(EditorKey, editor);
|
|
29
|
+
|
|
30
|
+
</script>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
3
|
+
<!-- Backdrop -->
|
|
4
|
+
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity" @click="$emit('close')"></div>
|
|
5
|
+
|
|
6
|
+
<!-- Modal -->
|
|
7
|
+
<div
|
|
8
|
+
class="relative w-full max-w-md bg-[#1a1a1a] border border-[#333] rounded-xl shadow-2xl p-6 transform transition-all animate-in fade-in zoom-in-95 duration-200">
|
|
9
|
+
|
|
10
|
+
<!-- Close Button -->
|
|
11
|
+
<button @click="$emit('close')"
|
|
12
|
+
class="absolute top-4 right-4 text-white/40 hover:text-white transition-colors">
|
|
13
|
+
<i class="ph-thin ph-x text-lg"></i>
|
|
14
|
+
</button>
|
|
15
|
+
|
|
16
|
+
<!-- Content -->
|
|
17
|
+
<div class="text-center mb-6">
|
|
18
|
+
<div
|
|
19
|
+
class="w-16 h-16 bg-green-500/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-green-500/20">
|
|
20
|
+
<i class="ph-thin ph-check text-green-500 text-2xl"></i>
|
|
21
|
+
</div>
|
|
22
|
+
<h2 class="text-xl font-bold text-white mb-2">
|
|
23
|
+
{{ isUpdate ? 'Presentation Updated!' : 'Presentation Saved!' }}
|
|
24
|
+
</h2>
|
|
25
|
+
<p class="text-white/60 text-sm">
|
|
26
|
+
{{ isUpdate
|
|
27
|
+
? 'Your changes have been successfully synced to the cloud.'
|
|
28
|
+
: 'Your presentation has been securely saved to the cloud.'
|
|
29
|
+
}}
|
|
30
|
+
</p>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<!-- IDs & Links -->
|
|
34
|
+
<div class="space-y-4">
|
|
35
|
+
<!-- Deck ID -->
|
|
36
|
+
<div class="space-y-1.5">
|
|
37
|
+
<label class="text-xs text-white/40 font-semibold uppercase tracking-wider">Presentation ID</label>
|
|
38
|
+
<div class="flex gap-2">
|
|
39
|
+
<code
|
|
40
|
+
class="flex-1 bg-[#222] border border-[#333] rounded-lg px-3 py-2 text-sm text-white/80 font-mono truncate select-all">
|
|
41
|
+
{{ deckId }}
|
|
42
|
+
</code>
|
|
43
|
+
<button @click="copyToClipboard(deckId, 'id')"
|
|
44
|
+
class="px-3 py-2 bg-[#333] hover:bg-[#444] border border-[#444] rounded-lg text-white/80 transition-all active:scale-95 flex items-center justify-center min-w-[40px]"
|
|
45
|
+
:title="copiedId ? 'Copied!' : 'Copy ID'">
|
|
46
|
+
<i :class="copiedId ? 'ph-thin ph-check text-green-500' : 'ph-thin ph-copy'"></i>
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<!-- View Link -->
|
|
52
|
+
<div class="space-y-1.5">
|
|
53
|
+
<label class="text-xs text-white/40 font-semibold uppercase tracking-wider">Share Link</label>
|
|
54
|
+
<div class="flex gap-2">
|
|
55
|
+
<code
|
|
56
|
+
class="flex-1 bg-[#222] border border-[#333] rounded-lg px-3 py-2 text-sm text-blue-400/80 font-mono truncate select-all">
|
|
57
|
+
{{ shareUrl }}
|
|
58
|
+
</code>
|
|
59
|
+
<button @click="copyToClipboard(shareUrl, 'link')"
|
|
60
|
+
class="px-3 py-2 bg-[#333] hover:bg-[#444] border border-[#444] rounded-lg text-white/80 transition-all active:scale-95 flex items-center justify-center min-w-[40px]"
|
|
61
|
+
:title="copiedLink ? 'Copied!' : 'Copy Link'">
|
|
62
|
+
<i :class="copiedLink ? 'ph-thin ph-check text-green-500' : 'ph-thin ph-copy'"></i>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<!-- Actions -->
|
|
69
|
+
<div class="mt-8 flex items-center gap-3">
|
|
70
|
+
<a :href="shareUrl" target="_blank"
|
|
71
|
+
class="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-medium py-2 px-4 rounded-lg text-sm text-center transition-colors flex items-center justify-center gap-2">
|
|
72
|
+
<i class="ph-thin ph-arrow-square-out"></i> Open Presentation
|
|
73
|
+
</a>
|
|
74
|
+
<button @click="$emit('close')"
|
|
75
|
+
class="px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] text-white/80 rounded-lg text-sm font-medium transition-colors">
|
|
76
|
+
Done
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<script setup lang="ts">
|
|
85
|
+
import { ref, computed } from 'vue';
|
|
86
|
+
|
|
87
|
+
const props = defineProps<{
|
|
88
|
+
isOpen: boolean;
|
|
89
|
+
deckId: string;
|
|
90
|
+
isUpdate?: boolean;
|
|
91
|
+
}>();
|
|
92
|
+
|
|
93
|
+
defineEmits(['close']);
|
|
94
|
+
|
|
95
|
+
const copiedId = ref(false);
|
|
96
|
+
const copiedLink = ref(false);
|
|
97
|
+
|
|
98
|
+
const shareUrl = computed(() => {
|
|
99
|
+
const url = new URL(window.location.href);
|
|
100
|
+
url.searchParams.set('deckId', props.deckId);
|
|
101
|
+
url.searchParams.delete('mode');
|
|
102
|
+
return url.toString();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const copyToClipboard = async (text: string, type: 'id' | 'link') => {
|
|
106
|
+
try {
|
|
107
|
+
await navigator.clipboard.writeText(text);
|
|
108
|
+
if (type === 'id') {
|
|
109
|
+
copiedId.value = true;
|
|
110
|
+
setTimeout(() => copiedId.value = false, 2000);
|
|
111
|
+
} else {
|
|
112
|
+
copiedLink.value = true;
|
|
113
|
+
setTimeout(() => copiedLink.value = false, 2000);
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error('Failed to copy:', err);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<style scoped>
|
|
122
|
+
/* Simple enter animation */
|
|
123
|
+
.animate-in {
|
|
124
|
+
animation: enter 0.2s ease-out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@keyframes enter {
|
|
128
|
+
from {
|
|
129
|
+
opacity: 0;
|
|
130
|
+
transform: scale(0.95);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
to {
|
|
134
|
+
opacity: 1;
|
|
135
|
+
transform: scale(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
</style>
|