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.
- package/dist/lumina-slides.js +21984 -19455
- package/dist/lumina-slides.umd.cjs +223 -223
- package/dist/style.css +1 -1
- package/package.json +3 -1
- package/src/components/LandingPage.vue +1 -1
- package/src/components/LuminaDeck.vue +237 -232
- package/src/components/base/LuminaElement.vue +2 -0
- package/src/components/layouts/LayoutFeatures.vue +123 -123
- package/src/components/layouts/LayoutFlex.vue +212 -172
- package/src/components/layouts/LayoutStatement.vue +5 -2
- package/src/components/layouts/LayoutSteps.vue +108 -108
- package/src/components/parts/FlexHtml.vue +65 -0
- package/src/components/parts/FlexImage.vue +81 -54
- package/src/components/site/SiteDocs.vue +3313 -3182
- package/src/components/site/SiteExamples.vue +66 -66
- package/src/components/studio/EditorLayoutChart.vue +18 -0
- package/src/components/studio/EditorLayoutCustom.vue +18 -0
- package/src/components/studio/EditorLayoutVideo.vue +18 -0
- package/src/components/studio/LuminaStudioEmbed.vue +68 -0
- package/src/components/studio/StudioEmbedRoot.vue +19 -0
- package/src/components/studio/StudioInspector.vue +1113 -7
- package/src/components/studio/StudioSettings.vue +658 -7
- package/src/components/studio/StudioToolbar.vue +20 -2
- package/src/composables/useElementState.ts +12 -1
- package/src/composables/useFlexLayout.ts +128 -122
- package/src/core/Lumina.ts +174 -113
- package/src/core/animationConfig.ts +10 -0
- package/src/core/elementController.ts +18 -0
- package/src/core/elementResolver.ts +4 -2
- package/src/core/schema.ts +503 -478
- package/src/core/store.ts +465 -465
- package/src/core/types.ts +59 -14
- package/src/index.ts +2 -2
- package/src/utils/templateInterpolation.ts +52 -52
- package/src/views/DeckView.vue +313 -313
package/src/views/DeckView.vue
CHANGED
|
@@ -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>
|