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,184 @@
|
|
|
1
|
+
import { initializeApp, FirebaseApp, FirebaseOptions } from "firebase/app";
|
|
2
|
+
import { getFirestore, Firestore, collection, addDoc, doc, getDoc, setDoc, DocumentReference, query, where, getDocs } from "firebase/firestore";
|
|
3
|
+
import { getAuth, Auth, GoogleAuthProvider } from "firebase/auth";
|
|
4
|
+
import type { Deck } from "../core/types";
|
|
5
|
+
|
|
6
|
+
interface FirebaseClient {
|
|
7
|
+
app: FirebaseApp;
|
|
8
|
+
db: Firestore;
|
|
9
|
+
auth: Auth;
|
|
10
|
+
provider: GoogleAuthProvider;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Singleton instance
|
|
14
|
+
let firebaseInstance: FirebaseClient | undefined;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Initialize Firebase with environment variables.
|
|
18
|
+
* This should be called once in main.ts.
|
|
19
|
+
*/
|
|
20
|
+
export const initFirebase = (): FirebaseClient => {
|
|
21
|
+
if (firebaseInstance) return firebaseInstance;
|
|
22
|
+
|
|
23
|
+
const config: FirebaseOptions = {
|
|
24
|
+
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
|
25
|
+
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
|
26
|
+
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
|
27
|
+
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
|
28
|
+
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
|
29
|
+
appId: import.meta.env.VITE_FIREBASE_APP_ID
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Validation (Professional Practice)
|
|
33
|
+
if (!config.apiKey) {
|
|
34
|
+
throw new Error("Missing Firebase Configuration: VITE_FIREBASE_API_KEY is not defined.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const app = initializeApp(config);
|
|
38
|
+
const db = getFirestore(app);
|
|
39
|
+
const auth = getAuth(app);
|
|
40
|
+
const provider = new GoogleAuthProvider();
|
|
41
|
+
|
|
42
|
+
firebaseInstance = { app, db, auth, provider };
|
|
43
|
+
return firebaseInstance;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the initialized Auth instance. Throws if not initialized.
|
|
48
|
+
*/
|
|
49
|
+
export const getFirebaseAuth = (): Auth => {
|
|
50
|
+
if (!firebaseInstance) throw new Error("Firebase not initialized. Call initFirebase() in main.ts.");
|
|
51
|
+
return firebaseInstance.auth;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the initialized Auth Provider.
|
|
56
|
+
*/
|
|
57
|
+
export const getAuthProvider = (): GoogleAuthProvider => {
|
|
58
|
+
if (!firebaseInstance) throw new Error("Firebase not initialized.");
|
|
59
|
+
return firebaseInstance.provider;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the initialized Firestore instance.
|
|
64
|
+
*/
|
|
65
|
+
export const getFirestoreDb = (): Firestore => {
|
|
66
|
+
if (!firebaseInstance) throw new Error("Firebase not initialized.");
|
|
67
|
+
return firebaseInstance.db;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Save a deck (presentation) to Firestore.
|
|
72
|
+
*/
|
|
73
|
+
export const saveDeck = async (deck: Deck, userId?: string, userName?: string): Promise<string> => {
|
|
74
|
+
const db = getFirestoreDb();
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const metaUpdates: Deck['meta'] = { ...deck.meta };
|
|
78
|
+
|
|
79
|
+
if (userId) metaUpdates.authorId = userId;
|
|
80
|
+
if (userName) metaUpdates.authorName = userName;
|
|
81
|
+
|
|
82
|
+
const dataToSave = {
|
|
83
|
+
meta: metaUpdates,
|
|
84
|
+
slides: deck.slides || [],
|
|
85
|
+
savedAt: new Date().toISOString(),
|
|
86
|
+
version: '1.0'
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const docRef: DocumentReference = await addDoc(collection(db, "decks"), dataToSave);
|
|
90
|
+
return docRef.id;
|
|
91
|
+
} catch (e: unknown) {
|
|
92
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
93
|
+
console.error("Error adding document: ", error);
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Retrieve all decks belonging to a specific user (excluding deleted).
|
|
100
|
+
*/
|
|
101
|
+
export const getUserDecks = async (userId: string): Promise<Deck[]> => {
|
|
102
|
+
const db = getFirestoreDb();
|
|
103
|
+
try {
|
|
104
|
+
const q = query(
|
|
105
|
+
collection(db, "decks"),
|
|
106
|
+
where("meta.authorId", "==", userId)
|
|
107
|
+
);
|
|
108
|
+
const querySnapshot = await getDocs(q);
|
|
109
|
+
|
|
110
|
+
return querySnapshot.docs
|
|
111
|
+
.map((doc) => {
|
|
112
|
+
const data = doc.data();
|
|
113
|
+
return {
|
|
114
|
+
meta: { ...data.meta, id: doc.id },
|
|
115
|
+
slides: data.slides
|
|
116
|
+
} as Deck;
|
|
117
|
+
})
|
|
118
|
+
.filter(deck => !deck.meta.deletedAt); // Safe client-side filter
|
|
119
|
+
} catch (e: unknown) {
|
|
120
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
121
|
+
console.error("Error fetching user decks: ", error);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Soft delete a deck by setting deletedAt timestamp.
|
|
128
|
+
*/
|
|
129
|
+
export const softDeleteDeck = async (id: string): Promise<void> => {
|
|
130
|
+
const db = getFirestoreDb();
|
|
131
|
+
try {
|
|
132
|
+
const docRef = doc(db, "decks", id);
|
|
133
|
+
await setDoc(docRef, {
|
|
134
|
+
meta: { deletedAt: new Date().toISOString() }
|
|
135
|
+
}, { merge: true });
|
|
136
|
+
} catch (e: unknown) {
|
|
137
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
138
|
+
console.error("Error soft deleting document: ", error);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const updateDeck = async (id: string, deck: Deck): Promise<void> => {
|
|
144
|
+
const db = getFirestoreDb();
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const dataToSave = {
|
|
148
|
+
meta: deck.meta || {},
|
|
149
|
+
slides: deck.slides || [],
|
|
150
|
+
updatedAt: new Date().toISOString(),
|
|
151
|
+
version: '1.0'
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const docRef = doc(db, "decks", id);
|
|
155
|
+
await setDoc(docRef, dataToSave, { merge: true });
|
|
156
|
+
} catch (e: unknown) {
|
|
157
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
158
|
+
console.error("Error updating document: ", error);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const getDeck = async (id: string): Promise<Deck | null> => {
|
|
164
|
+
const db = getFirestoreDb();
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const docRef = doc(db, "decks", id);
|
|
168
|
+
const docSnap = await getDoc(docRef);
|
|
169
|
+
|
|
170
|
+
if (docSnap.exists()) {
|
|
171
|
+
const data = docSnap.data();
|
|
172
|
+
return {
|
|
173
|
+
meta: data.meta,
|
|
174
|
+
slides: data.slides
|
|
175
|
+
} as Deck;
|
|
176
|
+
} else {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
} catch (e: unknown) {
|
|
180
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
181
|
+
console.error("Error getting document: ", error);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Streaming utilities for piping LLM token streams into Lumina.
|
|
4
|
+
* Use when the model streams JSON incrementally; these helpers tolerate incomplete output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parses possibly incomplete JSON by auto-closing unclosed brackets and strings.
|
|
9
|
+
* Use in your LLM stream handler: append chunks to a buffer, call this on each chunk;
|
|
10
|
+
* when it returns non-null, pass the result to `engine.load()` for progressive rendering.
|
|
11
|
+
*
|
|
12
|
+
* @param input - Raw or partial JSON string (e.g. from SSE or WebSocket chunks).
|
|
13
|
+
* @returns Parsed object, or `null` if the string cannot be repaired to valid JSON.
|
|
14
|
+
* @example
|
|
15
|
+
* let buf = "";
|
|
16
|
+
* for await (const chunk of stream) {
|
|
17
|
+
* buf += chunk;
|
|
18
|
+
* const json = parsePartialJson(buf);
|
|
19
|
+
* if (json) engine.load(json);
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
export function parsePartialJson(input: string): any {
|
|
23
|
+
let fixed = input.trim();
|
|
24
|
+
|
|
25
|
+
// 1. Remove trailing commas (common in streams)
|
|
26
|
+
fixed = fixed.replace(/,(\s*[}\]])/g, '$1');
|
|
27
|
+
|
|
28
|
+
// 2. Count open brackets/braces
|
|
29
|
+
const stack: string[] = [];
|
|
30
|
+
let inString = false;
|
|
31
|
+
let isEscaped = false;
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < fixed.length; i++) {
|
|
34
|
+
const char = fixed[i];
|
|
35
|
+
|
|
36
|
+
if (inString) {
|
|
37
|
+
if (char === '\\') {
|
|
38
|
+
isEscaped = !isEscaped;
|
|
39
|
+
} else if (char === '"' && !isEscaped) {
|
|
40
|
+
inString = false;
|
|
41
|
+
} else {
|
|
42
|
+
isEscaped = false;
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (char === '"') {
|
|
48
|
+
inString = true;
|
|
49
|
+
} else if (char === '{') {
|
|
50
|
+
stack.push('}');
|
|
51
|
+
} else if (char === '[') {
|
|
52
|
+
stack.push(']');
|
|
53
|
+
} else if (char === '}') {
|
|
54
|
+
if (stack[stack.length - 1] === '}') stack.pop();
|
|
55
|
+
} else if (char === ']') {
|
|
56
|
+
if (stack[stack.length - 1] === ']') stack.pop();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 3. Auto-close strings
|
|
61
|
+
if (inString) {
|
|
62
|
+
fixed += '"';
|
|
63
|
+
// If we closed a property key "key": "val, we might need value closing
|
|
64
|
+
// But usually streams cut off at value. "title": "my tit
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 4. Auto-close structural stack in reverse
|
|
68
|
+
while (stack.length > 0) {
|
|
69
|
+
fixed += stack.pop();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(fixed);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// If it still fails, return null or a safe fallback
|
|
76
|
+
// console.warn("Stream parse failed, waiting for more chunks...");
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns a function that parses partial JSON and invokes `callback` only after the stream
|
|
84
|
+
* has been idle for `delay` ms. Reduces UI jitter when tokens arrive very fast.
|
|
85
|
+
*
|
|
86
|
+
* @param callback - Called with the parsed object when a valid parse occurs after the debounce.
|
|
87
|
+
* @param delay - Idle time in ms before firing. Default 50.
|
|
88
|
+
* @returns A function `(raw: string) => void` to call with each concatenated chunk.
|
|
89
|
+
* @example
|
|
90
|
+
* const onChunk = createDebouncedLoader((deck) => engine.load(deck), 80);
|
|
91
|
+
* for await (const c of stream) { buffer += c; onChunk(buffer); }
|
|
92
|
+
*/
|
|
93
|
+
export function createDebouncedLoader<T>(
|
|
94
|
+
callback: (data: T) => void,
|
|
95
|
+
delay = 50
|
|
96
|
+
) {
|
|
97
|
+
let timeout: any = null;
|
|
98
|
+
|
|
99
|
+
return (rawData: string) => {
|
|
100
|
+
const parsed = parsePartialJson(rawData);
|
|
101
|
+
|
|
102
|
+
if (!parsed) return; // Skip invalid frames
|
|
103
|
+
|
|
104
|
+
if (timeout) clearTimeout(timeout);
|
|
105
|
+
|
|
106
|
+
timeout = setTimeout(() => {
|
|
107
|
+
callback(parsed);
|
|
108
|
+
}, delay);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns whether a slide object has the minimum fields needed to render.
|
|
115
|
+
* Use when streaming to decide between showing the slide or a skeleton/placeholder.
|
|
116
|
+
*
|
|
117
|
+
* @param slide - A possible slide object (e.g. from `parsePartialJson` mid-stream).
|
|
118
|
+
* @returns `true` if `slide.type` exists and type-specific required fields (e.g. `title` for statement, `features` for features) are present.
|
|
119
|
+
* @example
|
|
120
|
+
* const json = parsePartialJson(buffer);
|
|
121
|
+
* if (json?.slides) json.slides.forEach((s, i) => { if (isSlideReady(s)) showSlide(i, s); else showSkeleton(i); });
|
|
122
|
+
*/
|
|
123
|
+
export function isSlideReady(slide: any): boolean {
|
|
124
|
+
if (!slide) return false;
|
|
125
|
+
if (!slide.type) return false;
|
|
126
|
+
|
|
127
|
+
// For statement, we need at least a title partial
|
|
128
|
+
if (slide.type === 'statement' && typeof slide.title !== 'string') return false;
|
|
129
|
+
|
|
130
|
+
// For features, we need the array to exist
|
|
131
|
+
if (slide.type === 'features' && !Array.isArray(slide.features)) return false;
|
|
132
|
+
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="min-h-screen bg-[#030303] text-white">
|
|
3
|
+
<SiteNavBar active-page="dashboard" @navigate="handleNavigate" />
|
|
4
|
+
|
|
5
|
+
<SiteDashboard @select-deck="openDeck" />
|
|
6
|
+
|
|
7
|
+
<SiteFooter />
|
|
8
|
+
</div>
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<script setup lang="ts">
|
|
12
|
+
import { useRouter } from 'vue-router';
|
|
13
|
+
import SiteNavBar from '../components/site/SiteNavBar.vue';
|
|
14
|
+
import SiteDashboard from '../components/site/SiteDashboard.vue';
|
|
15
|
+
import SiteFooter from '../components/site/SiteFooter.vue';
|
|
16
|
+
|
|
17
|
+
const router = useRouter();
|
|
18
|
+
|
|
19
|
+
const handleNavigate = (page: string) => {
|
|
20
|
+
// Map internal page names to route names
|
|
21
|
+
// 'home', 'examples', etc usually match
|
|
22
|
+
const routeName = page === 'prompt-builder' ? 'prompt-builder' : page;
|
|
23
|
+
router.push({ name: routeName });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const openDeck = (deckId: string) => {
|
|
27
|
+
// Navigate to studio to edit
|
|
28
|
+
// Or maybe we want to view it first?
|
|
29
|
+
// Let's assume dashboard opens Editor for now as requested in previous flow
|
|
30
|
+
router.push({ name: 'studio', params: { id: deckId } });
|
|
31
|
+
};
|
|
32
|
+
</script>
|
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<script setup lang="ts">
|
|
61
|
+
import { onMounted, onUnmounted, computed, ref } from 'vue';
|
|
62
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
63
|
+
import { Lumina } from '../core/Lumina';
|
|
64
|
+
import { getDeck, saveDeck, initFirebase } from '../utils/firebase';
|
|
65
|
+
import { useAuth } from '../composables/useAuth';
|
|
66
|
+
import type { Deck } from '../core/types';
|
|
67
|
+
|
|
68
|
+
const route = useRoute();
|
|
69
|
+
const router = useRouter();
|
|
70
|
+
const { user, loginWithGoogle } = useAuth();
|
|
71
|
+
let engine: Lumina | null = null;
|
|
72
|
+
const engineRef = ref<Lumina | null>(null);
|
|
73
|
+
const deckAuthorId = ref<string | null>(null);
|
|
74
|
+
const loadedDeck = ref<Deck | null>(null);
|
|
75
|
+
const duplicating = ref(false);
|
|
76
|
+
const showLoginModal = ref(false);
|
|
77
|
+
const loginError = ref('');
|
|
78
|
+
const loginLoading = ref(false);
|
|
79
|
+
|
|
80
|
+
// Timeline (Remotion-style) for animation-timeline
|
|
81
|
+
const timelineProgress = ref(0);
|
|
82
|
+
const currentSlideIndex = ref(0);
|
|
83
|
+
const timelinePlaying = ref(false);
|
|
84
|
+
const timelineCancelRef = ref<(() => void) | null>(null);
|
|
85
|
+
const timelineIntervalRef = ref<ReturnType<typeof setInterval> | null>(null);
|
|
86
|
+
|
|
87
|
+
const showTimelineUI = computed(() => {
|
|
88
|
+
const id = route.params.id as string;
|
|
89
|
+
if (id !== 'animation-timeline') return false;
|
|
90
|
+
const slide = loadedDeck.value?.slides[currentSlideIndex.value] as { timelineTracks?: unknown } | undefined;
|
|
91
|
+
return !!slide?.timelineTracks;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function onTimelineInput(e: Event) {
|
|
95
|
+
const v = parseFloat((e.target as HTMLInputElement).value);
|
|
96
|
+
timelineProgress.value = v;
|
|
97
|
+
engineRef.value?.seekTo(v / 100);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onTimelinePlay() {
|
|
101
|
+
if (timelinePlaying.value || !engineRef.value) return;
|
|
102
|
+
timelinePlaying.value = true;
|
|
103
|
+
const [promise, cancel] = engineRef.value.playTimeline(5);
|
|
104
|
+
timelineCancelRef.value = cancel;
|
|
105
|
+
timelineIntervalRef.value = setInterval(() => {
|
|
106
|
+
if (!engineRef.value) return;
|
|
107
|
+
const p = engineRef.value.timelineProgress;
|
|
108
|
+
timelineProgress.value = p * 100;
|
|
109
|
+
if (p >= 1) {
|
|
110
|
+
if (timelineIntervalRef.value) clearInterval(timelineIntervalRef.value);
|
|
111
|
+
timelineIntervalRef.value = null;
|
|
112
|
+
timelinePlaying.value = false;
|
|
113
|
+
timelineCancelRef.value = null;
|
|
114
|
+
}
|
|
115
|
+
}, 50);
|
|
116
|
+
promise.then(() => {
|
|
117
|
+
if (timelineIntervalRef.value) clearInterval(timelineIntervalRef.value);
|
|
118
|
+
timelineIntervalRef.value = null;
|
|
119
|
+
timelinePlaying.value = false;
|
|
120
|
+
timelineCancelRef.value = null;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const canEdit = computed(() => {
|
|
125
|
+
return user.value && deckAuthorId.value && user.value.uid === deckAuthorId.value;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const showDuplicateEdit = computed(() => {
|
|
129
|
+
const hasDuplicateParam = 'duplicate' in route.query;
|
|
130
|
+
return hasDuplicateParam && !canEdit.value && loadedDeck.value != null;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const editDeck = () => {
|
|
134
|
+
router.push({ name: 'studio', params: { id: route.params.id } });
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
async function duplicateAndEdit() {
|
|
138
|
+
if (!loadedDeck.value) return;
|
|
139
|
+
if (!user.value) {
|
|
140
|
+
showLoginModal.value = true;
|
|
141
|
+
loginError.value = '';
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
duplicating.value = true;
|
|
145
|
+
try {
|
|
146
|
+
const { id: _id, authorId: _aid, authorName: _an, ...restMeta } = loadedDeck.value.meta || {};
|
|
147
|
+
const copyMeta = { ...restMeta, title: (loadedDeck.value.meta?.title || 'Presentation') + ' (Copy)' };
|
|
148
|
+
const copy: Deck = { meta: copyMeta, slides: loadedDeck.value.slides || [] };
|
|
149
|
+
const newId = await saveDeck(copy, user.value.uid, user.value.displayName || undefined);
|
|
150
|
+
router.push({ name: 'studio', params: { id: newId } });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error('Failed to duplicate deck:', e);
|
|
153
|
+
alert('Could not create a copy. Please try again.');
|
|
154
|
+
} finally {
|
|
155
|
+
duplicating.value = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function onLoginAndDuplicate() {
|
|
160
|
+
loginError.value = '';
|
|
161
|
+
loginLoading.value = true;
|
|
162
|
+
try {
|
|
163
|
+
await loginWithGoogle();
|
|
164
|
+
showLoginModal.value = false;
|
|
165
|
+
await duplicateAndEdit();
|
|
166
|
+
} catch (e: unknown) {
|
|
167
|
+
const msg = e instanceof Error ? e.message : 'Sign-in failed. Please try again.';
|
|
168
|
+
loginError.value = msg;
|
|
169
|
+
} finally {
|
|
170
|
+
loginLoading.value = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onMounted(async () => {
|
|
175
|
+
try {
|
|
176
|
+
initFirebase();
|
|
177
|
+
} catch (e) { }
|
|
178
|
+
|
|
179
|
+
const deckId = route.params.id as string;
|
|
180
|
+
if (!deckId) return;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
let deckData: Deck | null = null;
|
|
184
|
+
const isExample = deckId.startsWith('deck') || deckId.startsWith('layout') || deckId.startsWith('theme') || deckId.startsWith('animation');
|
|
185
|
+
|
|
186
|
+
if (isExample) {
|
|
187
|
+
const response = await fetch(`${import.meta.env.BASE_URL}${deckId}.json`);
|
|
188
|
+
deckData = (await response.json()) as Deck;
|
|
189
|
+
} else {
|
|
190
|
+
deckData = await getDeck(deckId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (deckData?.meta?.authorId) {
|
|
194
|
+
deckAuthorId.value = deckData.meta.authorId;
|
|
195
|
+
}
|
|
196
|
+
if (!deckData) throw new Error('Deck not found');
|
|
197
|
+
|
|
198
|
+
loadedDeck.value = deckData;
|
|
199
|
+
|
|
200
|
+
engine = new Lumina('#deck-container', {
|
|
201
|
+
loop: true,
|
|
202
|
+
studio: false,
|
|
203
|
+
debug: isExample,
|
|
204
|
+
navigation: true,
|
|
205
|
+
ui: {
|
|
206
|
+
visible: true,
|
|
207
|
+
showSlideCount: true,
|
|
208
|
+
showControls: true,
|
|
209
|
+
showProgressBar: true
|
|
210
|
+
},
|
|
211
|
+
theme: (deckData as Deck & { theme?: string }).theme || 'default'
|
|
212
|
+
});
|
|
213
|
+
engineRef.value = engine;
|
|
214
|
+
|
|
215
|
+
if (deckId === 'layout-element-control') {
|
|
216
|
+
engine.on('ready', () => {
|
|
217
|
+
['s0-tag', 's0-title', 's0-subtitle'].forEach((id, i) => {
|
|
218
|
+
setTimeout(() => engine?.element(id).show(), (i + 1) * 700);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
engine.on('slideChange', ({ index }) => {
|
|
222
|
+
if (index === 1) {
|
|
223
|
+
['s1-tag', 's1-title', 's1-subtitle'].forEach((id, i) => {
|
|
224
|
+
setTimeout(() => engine?.element(id).show(), (i + 1) * 700);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (deckId === 'animation-presets') {
|
|
231
|
+
const presets = ['fadeUp', 'scaleIn', 'spring'] as const;
|
|
232
|
+
engine.on('ready', () => engine?.revealInSequence(0, { preset: presets[0], delayMs: 450 }));
|
|
233
|
+
engine.on('slideChange', ({ index }) => {
|
|
234
|
+
engine?.revealInSequence(index, { preset: presets[index] ?? 'fadeUp', delayMs: 450 });
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (deckId === 'animation-stagger') {
|
|
239
|
+
const modes = ['center-out', 'wave', 'random'] as const;
|
|
240
|
+
engine.on('ready', () =>
|
|
241
|
+
engine?.revealInSequence(0, { preset: 'scaleIn', staggerMode: modes[0], delayMs: 380 })
|
|
242
|
+
);
|
|
243
|
+
engine.on('slideChange', ({ index }) => {
|
|
244
|
+
engine?.revealInSequence(index, {
|
|
245
|
+
preset: 'scaleIn',
|
|
246
|
+
staggerMode: modes[index] ?? 'sequential',
|
|
247
|
+
randomSeed: index,
|
|
248
|
+
delayMs: 380
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (deckId === 'animation-timeline') {
|
|
254
|
+
engine.on('ready', () => {
|
|
255
|
+
engine?.seekTo(0);
|
|
256
|
+
timelineProgress.value = 0;
|
|
257
|
+
currentSlideIndex.value = 0;
|
|
258
|
+
});
|
|
259
|
+
engine.on('slideChange', ({ index }) => {
|
|
260
|
+
if (timelineIntervalRef.value) {
|
|
261
|
+
clearInterval(timelineIntervalRef.value);
|
|
262
|
+
timelineIntervalRef.value = null;
|
|
263
|
+
}
|
|
264
|
+
timelineCancelRef.value?.();
|
|
265
|
+
timelineCancelRef.value = null;
|
|
266
|
+
timelinePlaying.value = false;
|
|
267
|
+
currentSlideIndex.value = index;
|
|
268
|
+
engine?.seekTo(0, index);
|
|
269
|
+
timelineProgress.value = 0;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
engine.load(deckData);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.error("Failed to load deck:", error);
|
|
276
|
+
alert("Failed to load presentation.");
|
|
277
|
+
router.push({ name: 'dashboard' });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
onUnmounted(() => {
|
|
282
|
+
if (timelineIntervalRef.value) clearInterval(timelineIntervalRef.value);
|
|
283
|
+
timelineCancelRef.value?.();
|
|
284
|
+
if (engine) {
|
|
285
|
+
engine.destroy();
|
|
286
|
+
}
|
|
287
|
+
engineRef.value = null;
|
|
288
|
+
});
|
|
289
|
+
</script>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="min-h-screen">
|
|
3
|
+
<SiteNavBar active-page="home" @navigate="(p) => router.push({ name: p })" />
|
|
4
|
+
<SiteHome @navigate="(p) => router.push({ name: p })" />
|
|
5
|
+
<SiteFooter />
|
|
6
|
+
</div>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
import { useRouter } from 'vue-router';
|
|
11
|
+
import SiteNavBar from '../components/site/SiteNavBar.vue';
|
|
12
|
+
import SiteHome from '../components/site/SiteHome.vue';
|
|
13
|
+
// Assuming we extract footer or just include it here
|
|
14
|
+
import SiteFooter from '../components/site/SiteFooter.vue';
|
|
15
|
+
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
</script>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="min-h-screen bg-[#030303] text-white flex flex-col">
|
|
3
|
+
<SiteNavBar :active-page="currentPage" />
|
|
4
|
+
|
|
5
|
+
<main class="flex-1 w-full">
|
|
6
|
+
<router-view />
|
|
7
|
+
</main>
|
|
8
|
+
|
|
9
|
+
<SiteFooter />
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
import { computed } from 'vue';
|
|
15
|
+
import { useRoute } from 'vue-router';
|
|
16
|
+
import SiteNavBar from '../components/site/SiteNavBar.vue';
|
|
17
|
+
import SiteFooter from '../components/site/SiteFooter.vue';
|
|
18
|
+
|
|
19
|
+
const route = useRoute();
|
|
20
|
+
const currentPage = computed(() => route.name?.toString() || '');
|
|
21
|
+
</script>
|