lumina-slides 9.0.4 → 9.0.6

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 (35) hide show
  1. package/dist/lumina-slides.js +21984 -19455
  2. package/dist/lumina-slides.umd.cjs +223 -223
  3. package/dist/style.css +1 -1
  4. package/package.json +3 -1
  5. package/src/components/LandingPage.vue +1 -1
  6. package/src/components/LuminaDeck.vue +237 -232
  7. package/src/components/base/LuminaElement.vue +2 -0
  8. package/src/components/layouts/LayoutFeatures.vue +123 -123
  9. package/src/components/layouts/LayoutFlex.vue +212 -172
  10. package/src/components/layouts/LayoutStatement.vue +5 -2
  11. package/src/components/layouts/LayoutSteps.vue +108 -108
  12. package/src/components/parts/FlexHtml.vue +65 -0
  13. package/src/components/parts/FlexImage.vue +81 -54
  14. package/src/components/site/SiteDocs.vue +3313 -3182
  15. package/src/components/site/SiteExamples.vue +66 -66
  16. package/src/components/studio/EditorLayoutChart.vue +18 -0
  17. package/src/components/studio/EditorLayoutCustom.vue +18 -0
  18. package/src/components/studio/EditorLayoutVideo.vue +18 -0
  19. package/src/components/studio/LuminaStudioEmbed.vue +68 -0
  20. package/src/components/studio/StudioEmbedRoot.vue +19 -0
  21. package/src/components/studio/StudioInspector.vue +1113 -7
  22. package/src/components/studio/StudioSettings.vue +658 -7
  23. package/src/components/studio/StudioToolbar.vue +20 -2
  24. package/src/composables/useElementState.ts +12 -1
  25. package/src/composables/useFlexLayout.ts +128 -122
  26. package/src/core/Lumina.ts +174 -113
  27. package/src/core/animationConfig.ts +10 -0
  28. package/src/core/elementController.ts +18 -0
  29. package/src/core/elementResolver.ts +4 -2
  30. package/src/core/schema.ts +503 -478
  31. package/src/core/store.ts +465 -465
  32. package/src/core/types.ts +59 -14
  33. package/src/index.ts +2 -2
  34. package/src/utils/templateInterpolation.ts +52 -52
  35. package/src/views/DeckView.vue +313 -313
@@ -1,313 +1,313 @@
1
- <template>
2
- <div class="lumina-deck-root w-full relative bg-black">
3
- <!-- Edit (owner) -->
4
- <button v-if="canEdit" @click="editDeck"
5
- class="fixed top-6 right-6 max-sm:top-auto max-sm:bottom-24 max-sm:right-20 z-[100] px-4 py-2 max-sm:px-2.5 max-sm:py-2 rounded-full bg-blue-600 border border-blue-500 text-xs font-bold uppercase hover:bg-blue-500 transition backdrop-blur-md flex items-center gap-2">
6
- <i class="ph-thin ph-pencil-simple"></i>
7
- <span class="max-sm:hidden">Edit</span>
8
- </button>
9
-
10
- <!-- Duplicate & Edit (GPT / non-owner): copies the deck for the user and prompts login if needed -->
11
- <button v-else-if="showDuplicateEdit" @click="duplicateAndEdit" :disabled="duplicating"
12
- class="fixed top-6 right-6 max-sm:top-auto max-sm:bottom-24 max-sm:right-20 z-[100] px-4 py-2 max-sm:px-2.5 max-sm:py-2 rounded-full bg-blue-600/90 border border-blue-500 text-xs font-bold uppercase hover:bg-blue-500 transition backdrop-blur-md flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
13
- :title="duplicating ? 'Copying…' : 'Duplicate & Edit'">
14
- <i v-if="duplicating" class="ph-thin ph-spinner ph-spin"></i>
15
- <i v-else class="ph-thin ph-copy"></i>
16
- <span class="max-sm:hidden">{{ duplicating ? 'Copying…' : 'Duplicate & Edit' }}</span>
17
- </button>
18
-
19
- <!-- Modal: sign in to duplicate and edit -->
20
- <Teleport to="body">
21
- <div v-if="showLoginModal" class="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
22
- @click.self="showLoginModal = false">
23
- <div class="bg-[#0f0f0f] border border-white/10 rounded-2xl p-6 max-w-sm w-full shadow-2xl"
24
- @click.stop>
25
- <h3 class="text-lg font-bold text-white mb-2">Sign in to duplicate</h3>
26
- <p class="text-white/60 text-sm mb-6">Create a copy in your account to edit this presentation.</p>
27
- <div class="flex flex-col gap-3">
28
- <button @click="onLoginAndDuplicate" :disabled="loginLoading"
29
- class="w-full px-4 py-3 rounded-xl bg-white text-black font-bold flex items-center justify-center gap-2 hover:bg-white/90 transition disabled:opacity-60">
30
- <i v-if="loginLoading" class="ph-thin ph-spinner ph-spin"></i>
31
- <i v-else class="ph-thin ph-google-logo"></i>
32
- {{ loginLoading ? 'Signing in…' : 'Sign in with Google' }}
33
- </button>
34
- <button @click="showLoginModal = false"
35
- class="w-full px-4 py-2 rounded-xl border border-white/20 text-white/80 font-medium hover:bg-white/5 transition">
36
- Cancel
37
- </button>
38
- </div>
39
- <p v-if="loginError" class="mt-4 text-sm text-red-400">{{ loginError }}</p>
40
- </div>
41
- </div>
42
- </Teleport>
43
-
44
- <!-- Container -->
45
- <div id="deck-container" class="w-full h-full min-h-0 overflow-hidden"></div>
46
-
47
- <!-- Timeline (Remotion-style) for animation-timeline example -->
48
- <div v-if="showTimelineUI" class="fixed bottom-0 left-0 right-0 z-[90] flex items-center gap-3 px-4 py-3 bg-black/90 border-t border-white/10 backdrop-blur">
49
- <span class="text-white/70 text-sm tabular-nums w-10">{{ Math.round(timelineProgress) }}%</span>
50
- <input type="range" min="0" max="100" step="0.5" :value="timelineProgress"
51
- class="flex-1 h-2 rounded accent-blue-500"
52
- @input="onTimelineInput" />
53
- <button type="button" :disabled="timelinePlaying"
54
- class="px-3 py-1.5 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
55
- @click="onTimelinePlay">Play</button>
56
- </div>
57
-
58
- <!-- Template Tags: edit engine.data and see slides update (layout-template-tags example) -->
59
- <div v-if="showTemplateTagsUI" class="fixed bottom-0 left-0 right-0 z-[90] flex items-center gap-4 px-4 py-3 bg-black/90 border-t border-white/10 backdrop-blur flex-wrap">
60
- <span class="text-white/50 text-xs uppercase tracking-wider">engine.data</span>
61
- <label class="flex items-center gap-2 text-sm text-white/80">
62
- Product
63
- <input v-model="templateProduct" type="text" placeholder="product"
64
- class="w-28 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
65
- @input="onTemplateDataInput('product', ($event.target as HTMLInputElement).value)" />
66
- </label>
67
- <label class="flex items-center gap-2 text-sm text-white/80">
68
- Version
69
- <input v-model="templateVersion" type="text" placeholder="version"
70
- class="w-24 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
71
- @input="onTemplateDataInput('version', ($event.target as HTMLInputElement).value)" />
72
- </label>
73
- <label class="flex items-center gap-2 text-sm text-white/80">
74
- User
75
- <input v-model="templateUser" type="text" placeholder="user"
76
- class="w-28 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
77
- @input="onTemplateDataInput('user', ($event.target as HTMLInputElement).value)" />
78
- </label>
79
- </div>
80
- </div>
81
- </template>
82
-
83
- <script setup lang="ts">
84
- import { onMounted, onUnmounted, computed, ref } from 'vue';
85
- import { useRoute, useRouter } from 'vue-router';
86
- import { Lumina } from '../core/Lumina';
87
- import { getDeck, saveDeck, initFirebase } from '../utils/firebase';
88
- import { useAuth } from '../composables/useAuth';
89
- import type { Deck } from '../core/types';
90
-
91
- const route = useRoute();
92
- const router = useRouter();
93
- const { user, loginWithGoogle } = useAuth();
94
- let engine: Lumina | null = null;
95
- const engineRef = ref<Lumina | null>(null);
96
- const deckAuthorId = ref<string | null>(null);
97
- const loadedDeck = ref<Deck | null>(null);
98
- const duplicating = ref(false);
99
- const showLoginModal = ref(false);
100
- const loginError = ref('');
101
- const loginLoading = ref(false);
102
-
103
- // Timeline (Remotion-style) for animation-timeline
104
- const timelineProgress = ref(0);
105
- const currentSlideIndex = ref(0);
106
- const timelinePlaying = ref(false);
107
- const timelineCancelRef = ref<(() => void) | null>(null);
108
- const timelineIntervalRef = ref<ReturnType<typeof setInterval> | null>(null);
109
-
110
- const showTimelineUI = computed(() => {
111
- const p = route.params.id;
112
- const id = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
113
- if (id !== 'animation-timeline') return false;
114
- const slide = loadedDeck.value?.slides[currentSlideIndex.value];
115
- return !!slide?.timelineTracks;
116
- });
117
-
118
- // Template Tags example: edit engine.data and see slides update
119
- const templateProduct = ref('Lumina');
120
- const templateVersion = ref('2.0');
121
- const templateUser = ref('World');
122
- const showTemplateTagsUI = computed(() => {
123
- const p = route.params.id;
124
- const id = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
125
- return id === 'layout-template-tags';
126
- });
127
- function onTemplateDataInput(key: string, value: string) {
128
- engineRef.value?.data.set(key, value);
129
- }
130
-
131
- function onTimelineInput(e: Event) {
132
- const t = e.target;
133
- if (t instanceof HTMLInputElement) {
134
- const v = parseFloat(t.value);
135
- timelineProgress.value = v;
136
- engineRef.value?.seekTo(v / 100);
137
- }
138
- }
139
-
140
- function onTimelinePlay() {
141
- if (timelinePlaying.value || !engineRef.value) return;
142
- timelinePlaying.value = true;
143
- const [promise, cancel] = engineRef.value.playTimeline(5);
144
- timelineCancelRef.value = cancel;
145
- timelineIntervalRef.value = setInterval(() => {
146
- if (!engineRef.value) return;
147
- const p = engineRef.value.timelineProgress;
148
- timelineProgress.value = p * 100;
149
- if (p >= 1) {
150
- if (timelineIntervalRef.value) clearInterval(timelineIntervalRef.value);
151
- timelineIntervalRef.value = null;
152
- timelinePlaying.value = false;
153
- timelineCancelRef.value = null;
154
- }
155
- }, 50);
156
- promise.then(() => {
157
- if (timelineIntervalRef.value) clearInterval(timelineIntervalRef.value);
158
- timelineIntervalRef.value = null;
159
- timelinePlaying.value = false;
160
- timelineCancelRef.value = null;
161
- });
162
- }
163
-
164
- const canEdit = computed(() => {
165
- return user.value && deckAuthorId.value && user.value.uid === deckAuthorId.value;
166
- });
167
-
168
- const showDuplicateEdit = computed(() => {
169
- const hasDuplicateParam = 'duplicate' in route.query;
170
- return hasDuplicateParam && !canEdit.value && loadedDeck.value != null;
171
- });
172
-
173
- const editDeck = () => {
174
- router.push({ name: 'studio', params: { id: route.params.id } });
175
- };
176
-
177
- async function duplicateAndEdit() {
178
- if (!loadedDeck.value) return;
179
- if (!user.value) {
180
- showLoginModal.value = true;
181
- loginError.value = '';
182
- return;
183
- }
184
- duplicating.value = true;
185
- try {
186
- const { id: _id, authorId: _aid, authorName: _an, ...restMeta } = loadedDeck.value.meta || {};
187
- const copyMeta = { ...restMeta, title: (loadedDeck.value.meta?.title || 'Presentation') + ' (Copy)' };
188
- const copy: Deck = { meta: copyMeta, slides: loadedDeck.value.slides || [] };
189
- const newId = await saveDeck(copy, user.value.uid, user.value.displayName || undefined);
190
- router.push({ name: 'studio', params: { id: newId } });
191
- } catch (e) {
192
- console.error('Failed to duplicate deck:', e);
193
- alert('Could not create a copy. Please try again.');
194
- } finally {
195
- duplicating.value = false;
196
- }
197
- }
198
-
199
- async function onLoginAndDuplicate() {
200
- loginError.value = '';
201
- loginLoading.value = true;
202
- try {
203
- await loginWithGoogle();
204
- showLoginModal.value = false;
205
- await duplicateAndEdit();
206
- } catch (e) {
207
- const msg = e instanceof Error ? e.message : 'Sign-in failed. Please try again.';
208
- loginError.value = msg;
209
- } finally {
210
- loginLoading.value = false;
211
- }
212
- }
213
-
214
- onMounted(async () => {
215
- try {
216
- initFirebase();
217
- } catch (e) { }
218
-
219
- const p = route.params.id;
220
- const deckId = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
221
- if (!deckId) return;
222
-
223
- try {
224
- let deckData: Deck | null = null;
225
- const isExample = deckId.startsWith('deck') || deckId.startsWith('layout') || deckId.startsWith('theme') || deckId.startsWith('animation');
226
-
227
- if (isExample) {
228
- const response = await fetch(`${import.meta.env.BASE_URL}${deckId}.json`);
229
- deckData = (await response.json()) as Deck;
230
- } else {
231
- deckData = await getDeck(deckId);
232
- }
233
-
234
- if (deckData?.meta?.authorId) {
235
- deckAuthorId.value = deckData.meta.authorId;
236
- }
237
- if (!deckData) throw new Error('Deck not found');
238
-
239
- loadedDeck.value = deckData;
240
-
241
- engine = new Lumina('#deck-container', {
242
- loop: true,
243
- studio: false,
244
- debug: isExample,
245
- navigation: true,
246
- ui: {
247
- visible: true,
248
- showSlideCount: true,
249
- showControls: true,
250
- showProgressBar: true
251
- },
252
- theme: deckData.theme || 'default'
253
- });
254
- engineRef.value = engine;
255
-
256
- if (deckId === 'layout-element-control') {
257
- engine.on('ready', () => {
258
- ['s0-tag', 's0-title', 's0-subtitle'].forEach((id, i) => {
259
- setTimeout(() => engine?.element(id).show(), (i + 1) * 700);
260
- });
261
- });
262
- engine.on('slideChange', ({ index }) => {
263
- if (index === 1) {
264
- ['s1-tag', 's1-title', 's1-subtitle'].forEach((id, i) => {
265
- setTimeout(() => engine?.element(id).show(), (i + 1) * 700);
266
- });
267
- }
268
- });
269
- }
270
-
271
- if (deckId === 'animation-timeline') {
272
- engine.on('ready', () => {
273
- engine?.seekTo(0);
274
- timelineProgress.value = 0;
275
- currentSlideIndex.value = 0;
276
- });
277
- engine.on('slideChange', ({ index }) => {
278
- if (timelineIntervalRef.value) {
279
- clearInterval(timelineIntervalRef.value);
280
- timelineIntervalRef.value = null;
281
- }
282
- timelineCancelRef.value?.();
283
- timelineCancelRef.value = null;
284
- timelinePlaying.value = false;
285
- currentSlideIndex.value = index;
286
- engine?.seekTo(0, index);
287
- timelineProgress.value = 0;
288
- });
289
- }
290
-
291
- if (deckId === 'layout-template-tags') {
292
- engine.data.set('product', templateProduct.value);
293
- engine.data.set('version', templateVersion.value);
294
- engine.data.set('user', templateUser.value);
295
- }
296
-
297
- engine.load(deckData);
298
- } catch (error) {
299
- console.error("Failed to load deck:", error);
300
- alert("Failed to load presentation.");
301
- router.push({ name: 'dashboard' });
302
- }
303
- });
304
-
305
- onUnmounted(() => {
306
- if (timelineIntervalRef.value) clearInterval(timelineIntervalRef.value);
307
- timelineCancelRef.value?.();
308
- if (engine) {
309
- engine.destroy();
310
- }
311
- engineRef.value = null;
312
- });
313
- </script>
1
+ <template>
2
+ <div class="lumina-deck-root w-full relative bg-black">
3
+ <!-- Edit (owner) -->
4
+ <button v-if="canEdit" @click="editDeck"
5
+ class="fixed top-6 right-6 max-sm:top-auto max-sm:bottom-24 max-sm:right-20 z-[100] px-4 py-2 max-sm:px-2.5 max-sm:py-2 rounded-full bg-blue-600 border border-blue-500 text-xs font-bold uppercase hover:bg-blue-500 transition backdrop-blur-md flex items-center gap-2">
6
+ <i class="ph-thin ph-pencil-simple"></i>
7
+ <span class="max-sm:hidden">Edit</span>
8
+ </button>
9
+
10
+ <!-- Duplicate & Edit (GPT / non-owner): copies the deck for the user and prompts login if needed -->
11
+ <button v-else-if="showDuplicateEdit" @click="duplicateAndEdit" :disabled="duplicating"
12
+ class="fixed top-6 right-6 max-sm:top-auto max-sm:bottom-24 max-sm:right-20 z-[100] px-4 py-2 max-sm:px-2.5 max-sm:py-2 rounded-full bg-blue-600/90 border border-blue-500 text-xs font-bold uppercase hover:bg-blue-500 transition backdrop-blur-md flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
13
+ :title="duplicating ? 'Copying…' : 'Duplicate & Edit'">
14
+ <i v-if="duplicating" class="ph-thin ph-spinner ph-spin"></i>
15
+ <i v-else class="ph-thin ph-copy"></i>
16
+ <span class="max-sm:hidden">{{ duplicating ? 'Copying…' : 'Duplicate & Edit' }}</span>
17
+ </button>
18
+
19
+ <!-- Modal: sign in to duplicate and edit -->
20
+ <Teleport to="body">
21
+ <div v-if="showLoginModal" class="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
22
+ @click.self="showLoginModal = false">
23
+ <div class="bg-[#0f0f0f] border border-white/10 rounded-2xl p-6 max-w-sm w-full shadow-2xl"
24
+ @click.stop>
25
+ <h3 class="text-lg font-bold text-white mb-2">Sign in to duplicate</h3>
26
+ <p class="text-white/60 text-sm mb-6">Create a copy in your account to edit this presentation.</p>
27
+ <div class="flex flex-col gap-3">
28
+ <button @click="onLoginAndDuplicate" :disabled="loginLoading"
29
+ class="w-full px-4 py-3 rounded-xl bg-white text-black font-bold flex items-center justify-center gap-2 hover:bg-white/90 transition disabled:opacity-60">
30
+ <i v-if="loginLoading" class="ph-thin ph-spinner ph-spin"></i>
31
+ <i v-else class="ph-thin ph-google-logo"></i>
32
+ {{ loginLoading ? 'Signing in…' : 'Sign in with Google' }}
33
+ </button>
34
+ <button @click="showLoginModal = false"
35
+ class="w-full px-4 py-2 rounded-xl border border-white/20 text-white/80 font-medium hover:bg-white/5 transition">
36
+ Cancel
37
+ </button>
38
+ </div>
39
+ <p v-if="loginError" class="mt-4 text-sm text-red-400">{{ loginError }}</p>
40
+ </div>
41
+ </div>
42
+ </Teleport>
43
+
44
+ <!-- Container -->
45
+ <div id="deck-container" class="w-full h-full min-h-0 overflow-hidden"></div>
46
+
47
+ <!-- Timeline (Remotion-style) for animation-timeline example -->
48
+ <div v-if="showTimelineUI" class="fixed bottom-0 left-0 right-0 z-[90] flex items-center gap-3 px-4 py-3 bg-black/90 border-t border-white/10 backdrop-blur">
49
+ <span class="text-white/70 text-sm tabular-nums w-10">{{ Math.round(timelineProgress) }}%</span>
50
+ <input type="range" min="0" max="100" step="0.5" :value="timelineProgress"
51
+ class="flex-1 h-2 rounded accent-blue-500"
52
+ @input="onTimelineInput" />
53
+ <button type="button" :disabled="timelinePlaying"
54
+ class="px-3 py-1.5 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
55
+ @click="onTimelinePlay">Play</button>
56
+ </div>
57
+
58
+ <!-- Template Tags: edit engine.data and see slides update (layout-template-tags example) -->
59
+ <div v-if="showTemplateTagsUI" class="fixed bottom-0 left-0 right-0 z-[90] flex items-center gap-4 px-4 py-3 bg-black/90 border-t border-white/10 backdrop-blur flex-wrap">
60
+ <span class="text-white/50 text-xs uppercase tracking-wider">engine.data</span>
61
+ <label class="flex items-center gap-2 text-sm text-white/80">
62
+ Product
63
+ <input v-model="templateProduct" type="text" placeholder="product"
64
+ class="w-28 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
65
+ @input="onTemplateDataInput('product', ($event.target as HTMLInputElement).value)" />
66
+ </label>
67
+ <label class="flex items-center gap-2 text-sm text-white/80">
68
+ Version
69
+ <input v-model="templateVersion" type="text" placeholder="version"
70
+ class="w-24 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
71
+ @input="onTemplateDataInput('version', ($event.target as HTMLInputElement).value)" />
72
+ </label>
73
+ <label class="flex items-center gap-2 text-sm text-white/80">
74
+ User
75
+ <input v-model="templateUser" type="text" placeholder="user"
76
+ class="w-28 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
77
+ @input="onTemplateDataInput('user', ($event.target as HTMLInputElement).value)" />
78
+ </label>
79
+ </div>
80
+ </div>
81
+ </template>
82
+
83
+ <script setup lang="ts">
84
+ import { onMounted, onUnmounted, computed, ref } from 'vue';
85
+ import { useRoute, useRouter } from 'vue-router';
86
+ import { Lumina } from '../core/Lumina';
87
+ import { getDeck, saveDeck, initFirebase } from '../utils/firebase';
88
+ import { useAuth } from '../composables/useAuth';
89
+ import type { Deck } from '../core/types';
90
+
91
+ const route = useRoute();
92
+ const router = useRouter();
93
+ const { user, loginWithGoogle } = useAuth();
94
+ let engine: Lumina | null = null;
95
+ const engineRef = ref<Lumina | null>(null);
96
+ const deckAuthorId = ref<string | null>(null);
97
+ const loadedDeck = ref<Deck | null>(null);
98
+ const duplicating = ref(false);
99
+ const showLoginModal = ref(false);
100
+ const loginError = ref('');
101
+ const loginLoading = ref(false);
102
+
103
+ // Timeline (Remotion-style) for animation-timeline
104
+ const timelineProgress = ref(0);
105
+ const currentSlideIndex = ref(0);
106
+ const timelinePlaying = ref(false);
107
+ const timelineCancelRef = ref<(() => void) | null>(null);
108
+ const timelineIntervalRef = ref<ReturnType<typeof setInterval> | null>(null);
109
+
110
+ const showTimelineUI = computed(() => {
111
+ const p = route.params.id;
112
+ const id = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
113
+ if (id !== 'animation-timeline') return false;
114
+ const slide = loadedDeck.value?.slides[currentSlideIndex.value];
115
+ return !!slide?.timelineTracks;
116
+ });
117
+
118
+ // Template Tags example: edit engine.data and see slides update
119
+ const templateProduct = ref('Lumina');
120
+ const templateVersion = ref('2.0');
121
+ const templateUser = ref('World');
122
+ const showTemplateTagsUI = computed(() => {
123
+ const p = route.params.id;
124
+ const id = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
125
+ return id === 'layout-template-tags';
126
+ });
127
+ function onTemplateDataInput(key: string, value: string) {
128
+ engineRef.value?.data.set(key, value);
129
+ }
130
+
131
+ function onTimelineInput(e: Event) {
132
+ const t = e.target;
133
+ if (t instanceof HTMLInputElement) {
134
+ const v = parseFloat(t.value);
135
+ timelineProgress.value = v;
136
+ engineRef.value?.seekTo(v / 100);
137
+ }
138
+ }
139
+
140
+ function onTimelinePlay() {
141
+ if (timelinePlaying.value || !engineRef.value) return;
142
+ timelinePlaying.value = true;
143
+ const [promise, cancel] = engineRef.value.playTimeline(5);
144
+ timelineCancelRef.value = cancel;
145
+ timelineIntervalRef.value = setInterval(() => {
146
+ if (!engineRef.value) return;
147
+ const p = engineRef.value.timelineProgress;
148
+ timelineProgress.value = p * 100;
149
+ if (p >= 1) {
150
+ if (timelineIntervalRef.value) clearInterval(timelineIntervalRef.value);
151
+ timelineIntervalRef.value = null;
152
+ timelinePlaying.value = false;
153
+ timelineCancelRef.value = null;
154
+ }
155
+ }, 50);
156
+ promise.then(() => {
157
+ if (timelineIntervalRef.value) clearInterval(timelineIntervalRef.value);
158
+ timelineIntervalRef.value = null;
159
+ timelinePlaying.value = false;
160
+ timelineCancelRef.value = null;
161
+ });
162
+ }
163
+
164
+ const canEdit = computed(() => {
165
+ return user.value && deckAuthorId.value && user.value.uid === deckAuthorId.value;
166
+ });
167
+
168
+ const showDuplicateEdit = computed(() => {
169
+ const hasDuplicateParam = 'duplicate' in route.query;
170
+ return hasDuplicateParam && !canEdit.value && loadedDeck.value != null;
171
+ });
172
+
173
+ const editDeck = () => {
174
+ router.push({ name: 'studio', params: { id: route.params.id } });
175
+ };
176
+
177
+ async function duplicateAndEdit() {
178
+ if (!loadedDeck.value) return;
179
+ if (!user.value) {
180
+ showLoginModal.value = true;
181
+ loginError.value = '';
182
+ return;
183
+ }
184
+ duplicating.value = true;
185
+ try {
186
+ const { id: _id, authorId: _aid, authorName: _an, ...restMeta } = loadedDeck.value.meta || {};
187
+ const copyMeta = { ...restMeta, title: (loadedDeck.value.meta?.title || 'Presentation') + ' (Copy)' };
188
+ const copy: Deck = { meta: copyMeta, slides: loadedDeck.value.slides || [] };
189
+ const newId = await saveDeck(copy, user.value.uid, user.value.displayName || undefined);
190
+ router.push({ name: 'studio', params: { id: newId } });
191
+ } catch (e) {
192
+ console.error('Failed to duplicate deck:', e);
193
+ alert('Could not create a copy. Please try again.');
194
+ } finally {
195
+ duplicating.value = false;
196
+ }
197
+ }
198
+
199
+ async function onLoginAndDuplicate() {
200
+ loginError.value = '';
201
+ loginLoading.value = true;
202
+ try {
203
+ await loginWithGoogle();
204
+ showLoginModal.value = false;
205
+ await duplicateAndEdit();
206
+ } catch (e) {
207
+ const msg = e instanceof Error ? e.message : 'Sign-in failed. Please try again.';
208
+ loginError.value = msg;
209
+ } finally {
210
+ loginLoading.value = false;
211
+ }
212
+ }
213
+
214
+ onMounted(async () => {
215
+ try {
216
+ initFirebase();
217
+ } catch (e) { }
218
+
219
+ const p = route.params.id;
220
+ const deckId = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
221
+ if (!deckId) return;
222
+
223
+ try {
224
+ let deckData: Deck | null = null;
225
+ const isExample = deckId.startsWith('deck') || deckId.startsWith('layout') || deckId.startsWith('theme') || deckId.startsWith('animation');
226
+
227
+ if (isExample) {
228
+ const response = await fetch(`${import.meta.env.BASE_URL}${deckId}.json`);
229
+ deckData = (await response.json()) as Deck;
230
+ } else {
231
+ deckData = await getDeck(deckId);
232
+ }
233
+
234
+ if (deckData?.meta?.authorId) {
235
+ deckAuthorId.value = deckData.meta.authorId;
236
+ }
237
+ if (!deckData) throw new Error('Deck not found');
238
+
239
+ loadedDeck.value = deckData;
240
+
241
+ engine = new Lumina('#deck-container', {
242
+ loop: true,
243
+ studio: false,
244
+ debug: isExample,
245
+ navigation: true,
246
+ ui: {
247
+ visible: true,
248
+ showSlideCount: true,
249
+ showControls: true,
250
+ showProgressBar: true
251
+ },
252
+ theme: deckData.theme || 'default'
253
+ });
254
+ engineRef.value = engine;
255
+
256
+ if (deckId === 'layout-element-control') {
257
+ engine.on('ready', () => {
258
+ ['s0-tag', 's0-title', 's0-subtitle'].forEach((id, i) => {
259
+ setTimeout(() => engine?.element(id).show(), (i + 1) * 700);
260
+ });
261
+ });
262
+ engine.on('slideChange', ({ index }) => {
263
+ if (index === 1) {
264
+ ['s1-tag', 's1-title', 's1-subtitle'].forEach((id, i) => {
265
+ setTimeout(() => engine?.element(id).show(), (i + 1) * 700);
266
+ });
267
+ }
268
+ });
269
+ }
270
+
271
+ if (deckId === 'animation-timeline') {
272
+ engine.on('ready', () => {
273
+ engine?.seekTo(0);
274
+ timelineProgress.value = 0;
275
+ currentSlideIndex.value = 0;
276
+ });
277
+ engine.on('slideChange', ({ index }) => {
278
+ if (timelineIntervalRef.value) {
279
+ clearInterval(timelineIntervalRef.value);
280
+ timelineIntervalRef.value = null;
281
+ }
282
+ timelineCancelRef.value?.();
283
+ timelineCancelRef.value = null;
284
+ timelinePlaying.value = false;
285
+ currentSlideIndex.value = index;
286
+ engine?.seekTo(0, index);
287
+ timelineProgress.value = 0;
288
+ });
289
+ }
290
+
291
+ if (deckId === 'layout-template-tags') {
292
+ engine.data.set('product', templateProduct.value);
293
+ engine.data.set('version', templateVersion.value);
294
+ engine.data.set('user', templateUser.value);
295
+ }
296
+
297
+ engine.load(deckData);
298
+ } catch (error) {
299
+ console.error("Failed to load deck:", error);
300
+ alert("Failed to load presentation.");
301
+ router.push({ name: 'dashboard' });
302
+ }
303
+ });
304
+
305
+ onUnmounted(() => {
306
+ if (timelineIntervalRef.value) clearInterval(timelineIntervalRef.value);
307
+ timelineCancelRef.value?.();
308
+ if (engine) {
309
+ engine.destroy();
310
+ }
311
+ engineRef.value = null;
312
+ });
313
+ </script>