Reubencf commited on
Commit
fbd2ca3
·
1 Parent(s): 1eb0838

working on production

Browse files
app/api/auth/callback/route.ts DELETED
@@ -1,96 +0,0 @@
1
- /**
2
- * HuggingFace OAuth 2.0 Callback Handler
3
- *
4
- * This route handles the redirect from HuggingFace after the user authorises
5
- * the application via the OAuth flow. It exchanges the one-time `code`
6
- * parameter for a long-lived access token and then redirects the user back to
7
- * the home page.
8
- *
9
- * Flow:
10
- * 1. User clicks "Connect with HuggingFace" → browser redirected to HF.
11
- * 2. User grants permission → HF redirects back here with `?code=…`.
12
- * 3. This handler exchanges the code for an access token.
13
- * 4. The token is appended to the home page URL so the client can store it.
14
- *
15
- * Environment variables required:
16
- * - HUGGINGFACE_CLIENT_ID — OAuth application client ID
17
- * - HUGGINGFACE_CLIENT_SECRET — OAuth application client secret
18
- *
19
- * Security note:
20
- * For this demo the token is passed in a query parameter which exposes it in
21
- * browser history and server logs. A production implementation should use
22
- * httpOnly cookies or a server-side session instead.
23
- *
24
- * Route: GET /api/auth/callback
25
- */
26
-
27
- import { NextRequest, NextResponse } from 'next/server';
28
-
29
- /**
30
- * GET /api/auth/callback
31
- *
32
- * Handles the OAuth redirect from HuggingFace. On success redirects to
33
- * `/?token=<access_token>&auth=success`. On any failure redirects to
34
- * `/?error=<reason>`.
35
- *
36
- * @param request - NextRequest; the `code` and `error` query params are read.
37
- * @returns A redirect response — never a JSON body.
38
- */
39
- export async function GET(request: NextRequest) {
40
- const searchParams = request.nextUrl.searchParams;
41
-
42
- // Read the OAuth code sent by HuggingFace on successful authorisation
43
- const code = searchParams.get('code');
44
- // HuggingFace may also send an `error` parameter when the user denies access
45
- const error = searchParams.get('error');
46
-
47
- // If HuggingFace signalled an error, redirect back with a friendly flag
48
- if (error) {
49
- // Redirect to home with error
50
- return NextResponse.redirect(new URL('/?error=auth_failed', request.url));
51
- }
52
-
53
- // Guard: code must be present to proceed with token exchange
54
- if (!code) {
55
- return NextResponse.redirect(new URL('/?error=no_code', request.url));
56
- }
57
-
58
- try {
59
- // Exchange code for access token — standard OAuth 2.0 code exchange
60
- const tokenResponse = await fetch('https://huggingface.co/oauth/token', {
61
- method: 'POST',
62
- headers: {
63
- // HuggingFace token endpoint expects URL-encoded form data
64
- 'Content-Type': 'application/x-www-form-urlencoded',
65
- },
66
- body: new URLSearchParams({
67
- client_id: process.env.HUGGINGFACE_CLIENT_ID!, // OAuth app ID
68
- client_secret: process.env.HUGGINGFACE_CLIENT_SECRET!, // OAuth app secret
69
- code, // One-time authorisation code
70
- grant_type: 'authorization_code', // OAuth grant type
71
- // The redirect_uri must match exactly what was registered with HF
72
- redirect_uri: `${new URL(request.url).origin}/api/auth/callback`,
73
- }),
74
- });
75
-
76
- // Non-OK response means the code was invalid, expired, or the credentials wrong
77
- if (!tokenResponse.ok) {
78
- throw new Error('Failed to exchange code for token');
79
- }
80
-
81
- const tokenData = await tokenResponse.json();
82
- const accessToken = tokenData.access_token; // The bearer token to store
83
-
84
- // In a real app, you'd store this token securely (session, JWT, etc.)
85
- // For this demo, we'll redirect with the token in a query parameter
86
- // Note: This is not secure for production use
87
- return NextResponse.redirect(
88
- new URL(`/?token=${accessToken}&auth=success`, request.url)
89
- );
90
-
91
- } catch (error) {
92
- console.error('OAuth callback error:', error);
93
- // Redirect home with a descriptive error flag the frontend can handle
94
- return NextResponse.redirect(new URL('/?error=token_exchange_failed', request.url));
95
- }
96
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/auth/hf/route.ts DELETED
@@ -1,144 +0,0 @@
1
- /**
2
- * Hugging Face API Key Authentication Endpoint
3
- *
4
- * Manages server-side validation and storage of the user's Hugging Face API
5
- * key in an httpOnly cookie so it can be forwarded to AI generation routes
6
- * without exposing the raw key to client-side JavaScript.
7
- *
8
- * Routes:
9
- * POST /api/auth/hf — Validate and store an HF API key
10
- * GET /api/auth/hf — Check whether the user is currently authenticated
11
- * DELETE /api/auth/hf — Log out by deleting the stored cookie
12
- *
13
- * Cookie: `hf_api_key`
14
- * - httpOnly: true (not accessible from JS — prevents XSS theft)
15
- * - secure: true (HTTPS only in production)
16
- * - sameSite: strict (prevents CSRF)
17
- * - maxAge: 24h
18
- */
19
-
20
- import { NextRequest, NextResponse } from 'next/server';
21
-
22
- /**
23
- * POST /api/auth/hf
24
- *
25
- * Validates the provided Hugging Face API key by making a lightweight test
26
- * request to the HF router. On success the key is stored in an httpOnly
27
- * cookie and a success response is returned.
28
- *
29
- * @param request - NextRequest containing `{ apiKey: string }` in its JSON body.
30
- * @returns 200 with `{ success: true }` on valid key, 400 on missing key,
31
- * 401 on invalid key, 500 on unexpected errors.
32
- */
33
- export async function POST(request: NextRequest) {
34
- try {
35
- const body = await request.json();
36
- const { apiKey }: { apiKey: string } = body;
37
-
38
- // Require the API key to be present in the request body
39
- if (!apiKey) {
40
- return NextResponse.json(
41
- { error: 'API key is required' },
42
- { status: 400 }
43
- );
44
- }
45
-
46
- // Validate HF API key by making a test request to the HF router
47
- try {
48
- const testResponse = await fetch('https://router.huggingface.co/v1/models', {
49
- headers: {
50
- // HF API keys are passed as Bearer tokens
51
- 'Authorization': `Bearer ${apiKey}`,
52
- 'Content-Type': 'application/json'
53
- }
54
- });
55
-
56
- // A non-OK response means the key is invalid or expired
57
- if (!testResponse.ok) {
58
- return NextResponse.json(
59
- { error: 'Invalid HF Pro API key' },
60
- { status: 401 }
61
- );
62
- }
63
-
64
- // Build the success response and attach the secure API key cookie
65
- // Store API key in session/cookie (in a real app, use proper session management)
66
- const response = NextResponse.json({
67
- success: true,
68
- message: 'Successfully authenticated with HF Pro'
69
- });
70
-
71
- // Set httpOnly cookie for API key (secure in production)
72
- response.cookies.set('hf_api_key', apiKey, {
73
- httpOnly: true, // Hidden from client JS
74
- secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
75
- sameSite: 'strict', // No cross-site sending
76
- maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
77
- });
78
-
79
- return response;
80
-
81
- } catch {
82
- // Network error or unexpected failure during the validation call
83
- return NextResponse.json(
84
- { error: 'Failed to validate API key' },
85
- { status: 401 }
86
- );
87
- }
88
-
89
- } catch (error) {
90
- console.error('HF authentication error:', error);
91
- return NextResponse.json(
92
- { error: 'Authentication failed' },
93
- { status: 500 }
94
- );
95
- }
96
- }
97
-
98
- /**
99
- * GET /api/auth/hf
100
- *
101
- * Checks whether the current request carries a valid `hf_api_key` cookie.
102
- * Used by the frontend on mount to restore the user's authenticated state.
103
- *
104
- * @param request - NextRequest; the `hf_api_key` cookie is read from it.
105
- * @returns 200 with `{ authenticated: boolean }`.
106
- */
107
- export async function GET(request: NextRequest) {
108
- // Read the API key from the httpOnly cookie set during POST
109
- const apiKey = request.cookies.get('hf_api_key')?.value;
110
-
111
- // No cookie present → user is not authenticated
112
- if (!apiKey) {
113
- return NextResponse.json(
114
- { authenticated: false },
115
- { status: 200 }
116
- );
117
- }
118
-
119
- // Cookie is present → user previously authenticated successfully
120
- return NextResponse.json({
121
- authenticated: true,
122
- message: 'User is authenticated with HF Pro'
123
- });
124
- }
125
-
126
- /**
127
- * DELETE /api/auth/hf
128
- *
129
- * Logs the user out by deleting the `hf_api_key` cookie.
130
- * The cookie deletion is sent as a Set-Cookie header with an expired date.
131
- *
132
- * @returns 200 with `{ success: true }` always.
133
- */
134
- export async function DELETE() {
135
- // Build the response first, then remove the cookie from it
136
- const response = NextResponse.json({
137
- success: true,
138
- message: 'Successfully logged out'
139
- });
140
-
141
- // Instruct the browser to delete the API key cookie immediately
142
- response.cookies.delete('hf_api_key');
143
- return response;
144
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/export-pptx/route.ts DELETED
@@ -1,179 +0,0 @@
1
- /*
2
- * File: app/api/export-pptx/route.ts
3
- * Description: API endpoint for exporting presentations to PowerPoint format
4
- * This endpoint receives slide data from the frontend and generates a .pptx file
5
- * using the pptxgenjs library with modern styling and gradient effects.
6
- *
7
- * Features:
8
- * - Creates PowerPoint presentations with gradient backgrounds
9
- * - Supports multiple slides with titles and bullet points
10
- * - Adds speaker notes and modern styling
11
- * - Returns base64-encoded PowerPoint file
12
- *
13
- * Method: POST
14
- * Input: JSON array of slides with title, content, and optional notes
15
- * Output: Base64-encoded PowerPoint file
16
- */
17
-
18
- import { NextRequest, NextResponse } from 'next/server';
19
- import pptxgen from 'pptxgenjs';
20
-
21
- /**
22
- * Slide interface defining the structure of each presentation slide
23
- */
24
- interface Slide {
25
- id: string;
26
- title: string;
27
- content: string[];
28
- notes?: string;
29
- }
30
-
31
- // Theme color mappings (solid colors for PPTX compatibility)
32
- const themeColors: Record<string, { background: string; title: string; text: string }> = {
33
- white: { background: 'FFFFFF', title: '202124', text: '5f6368' },
34
- workshop: { background: 'EFE8DF', title: '0E0E0E', text: '151515' },
35
- light: { background: 'FFFFFF', title: '202124', text: '5f6368' },
36
- dark: { background: '1a1a1a', title: 'FFFFFF', text: 'e0e0e0' },
37
- darkModern: { background: '1a1a2e', title: '64b5f6', text: 'e1f5fe' },
38
- professionalBlue: { background: '1e3a8a', title: 'FFFFFF', text: 'dbeafe' },
39
- elegantGreen: { background: '065f46', title: 'FFFFFF', text: 'd1fae5' },
40
- sophisticatedPurple: { background: '581c87', title: 'FFFFFF', text: 'e9d5ff' },
41
- corporateCity: { background: '1a1a1a', title: 'FFFFFF', text: 'e0e0e0' },
42
- techInnovation: { background: '0d1b2a', title: '00d9ff', text: 'e0e0e0' },
43
- natureSerene: { background: '4caf50', title: 'FFFFFF', text: 'FFFFFF' },
44
- minimalistConcrete: { background: 'f5f5f5', title: '212121', text: '424242' },
45
- blueGrad: { background: '1e293b', title: '60a5fa', text: 'e2e8f0' },
46
- cyanGrad: { background: '0e7490', title: 'FFFFFF', text: 'e0f2fe' },
47
- purpleBlue: { background: '1e40af', title: 'FFFFFF', text: 'e0e7ff' },
48
- green: { background: '065f46', title: 'FFFFFF', text: 'd1fae5' },
49
- amberGlow: { background: '0e0e0f', title: 'f97316', text: 'FFFFFF' }
50
- };
51
-
52
- /**
53
- * POST handler for generating PowerPoint presentations
54
- * Processes slide data and creates a styled .pptx file
55
- */
56
- export async function POST(request: NextRequest) {
57
- try {
58
- const { slides, theme = 'light' }: { slides: Slide[]; theme?: string } = await request.json();
59
-
60
- if (!slides || !Array.isArray(slides) || slides.length === 0) {
61
- return NextResponse.json(
62
- { error: 'Slides array is required' },
63
- { status: 400 }
64
- );
65
- }
66
-
67
- // Get theme colors or default to light
68
- const colors = themeColors[theme] || themeColors.light;
69
-
70
- // Create new presentation
71
- const pres = new pptxgen();
72
-
73
- // Set presentation properties
74
- pres.author = 'AI PowerPoint Generator';
75
- pres.company = 'AI Generated';
76
- pres.subject = 'AI Generated Presentation';
77
- pres.title = slides[0]?.title || 'Presentation';
78
-
79
- // Define slide layout and styling
80
- pres.defineLayout({ name: 'LAYOUT_16x9', width: 10, height: 5.625 });
81
- pres.layout = 'LAYOUT_16x9';
82
-
83
- // Process each slide
84
- slides.forEach((slideData, index) => {
85
- const slide = pres.addSlide();
86
-
87
- // Add theme background with proper color format
88
- slide.background = { fill: colors.background };
89
-
90
- // Add title with theme colors and better positioning
91
- slide.addText(slideData.title, {
92
- x: 0.5,
93
- y: 0.5,
94
- w: 9,
95
- h: 1.2,
96
- fontSize: 40,
97
- bold: true,
98
- color: colors.title,
99
- align: 'left',
100
- fontFace: 'Calibri',
101
- valign: 'top',
102
- });
103
-
104
- // Add content points with theme colors and better spacing
105
- if (slideData.content && slideData.content.length > 0) {
106
- const contentArray: any[] = [];
107
-
108
- slideData.content.forEach((point) => {
109
- contentArray.push({
110
- text: point,
111
- options: {
112
- bullet: true,
113
- fontSize: 20,
114
- color: colors.text,
115
- fontFace: 'Calibri',
116
- paraSpaceBefore: 6,
117
- paraSpaceAfter: 6,
118
- indentLevel: 0,
119
- }
120
- });
121
- });
122
-
123
- // Add all content as a single text block with bullets
124
- slide.addText(contentArray, {
125
- x: 0.5,
126
- y: 1.8,
127
- w: 9,
128
- h: 3.2,
129
- fontSize: 20,
130
- color: colors.text,
131
- fontFace: 'Calibri',
132
- valign: 'top',
133
- align: 'left',
134
- });
135
- }
136
-
137
- // Add slide number
138
- slide.addText(`${index + 1}`, {
139
- x: 9.3,
140
- y: 5.1,
141
- w: 0.5,
142
- h: 0.3,
143
- fontSize: 12,
144
- color: colors.text,
145
- align: 'center',
146
- valign: 'middle',
147
- fontFace: 'Arial'
148
- });
149
-
150
- // Add speaker notes if available
151
- if (slideData.notes) {
152
- slide.addNotes(slideData.notes);
153
- }
154
- });
155
-
156
- // Generate the presentation
157
- const pptxBuffer = await pres.write({ outputType: 'nodebuffer' });
158
-
159
- // Convert to Buffer and then to ArrayBuffer for NextResponse
160
- const buffer = Buffer.from(pptxBuffer as Uint8Array);
161
- const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
162
-
163
- // Return the file as a response
164
- return new NextResponse(arrayBuffer, {
165
- status: 200,
166
- headers: {
167
- 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
168
- 'Content-Disposition': 'attachment; filename="presentation.pptx"',
169
- },
170
- });
171
-
172
- } catch (error) {
173
- console.error('Export error:', error);
174
- return NextResponse.json(
175
- { error: 'Failed to export presentation' },
176
- { status: 500 }
177
- );
178
- }
179
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/generate-slides/route.ts DELETED
@@ -1,340 +0,0 @@
1
- /**
2
- * API endpoint for generating presentation slides using AI models.
3
- * Integrates with Hugging Face Inference API via HF Inference.
4
- *
5
- * Method: POST
6
- * Input: { prompt: string, model: string }
7
- * Output: { presentationName: string, slides: GeneratedSlide[] }
8
- */
9
-
10
- import { InferenceClient } from '@huggingface/inference';
11
- import { NextRequest, NextResponse } from 'next/server';
12
- import { fetchImagesForSlides } from '@/lib/imageService';
13
- import { buildSlidePrompt, normalizeLayout } from '@/lib/slide-prompt';
14
- import type { GeneratedSlide } from '@/lib/slide-prompt';
15
- import { LLAMA_PRESENTATION_MODEL, isAllowedPresentationModel } from '@/lib/ai-models';
16
-
17
- interface Slide {
18
- id: string;
19
- title: string;
20
- subtitle?: string;
21
- content: string[];
22
- notes?: string;
23
- layout: string;
24
- imageKeyword?: string;
25
- imageUrl?: string;
26
- }
27
-
28
- export async function POST(request: NextRequest) {
29
- try {
30
- const { prompt, model } = await request.json();
31
-
32
- if (!prompt) {
33
- return NextResponse.json(
34
- { error: 'Prompt is required' },
35
- { status: 400 }
36
- );
37
- }
38
-
39
- if (!isAllowedPresentationModel(model)) {
40
- console.warn(`Ignoring unsupported presentation model "${model}" and forcing ${LLAMA_PRESENTATION_MODEL}.`);
41
- }
42
-
43
- const actualModel = LLAMA_PRESENTATION_MODEL;
44
-
45
- // Resolve HF token: header → cookie → Bearer
46
- let apiToken: string | undefined;
47
-
48
- const hfTokenHeader = request.headers.get('x-hf-token');
49
- if (hfTokenHeader) apiToken = hfTokenHeader;
50
-
51
- if (!apiToken) {
52
- const hfApiKeyCookie = request.cookies.get('hf_api_key')?.value;
53
- if (hfApiKeyCookie) apiToken = hfApiKeyCookie;
54
- }
55
-
56
- if (!apiToken) {
57
- const authHeader = request.headers.get('authorization');
58
- if (authHeader?.startsWith('Bearer ')) apiToken = authHeader.slice(7);
59
- }
60
-
61
- if (!apiToken) {
62
- return NextResponse.json(
63
- { error: 'HuggingFace authentication required. Please connect your Hugging Face account to use AI generation.' },
64
- { status: 401 }
65
- );
66
- }
67
-
68
- const hf = new InferenceClient(apiToken);
69
- const slideGenerationPrompt = buildSlidePrompt(prompt);
70
-
71
- try {
72
- console.log(`Attempting to use model: ${actualModel}`);
73
-
74
- const modelId = actualModel.replace(/:hf-inference$/, '');
75
-
76
- const response = await hf.textGeneration({
77
- model: modelId,
78
- inputs: slideGenerationPrompt,
79
- parameters: {
80
- max_new_tokens: 4000,
81
- temperature: 0.5,
82
- do_sample: true,
83
- top_p: 0.92,
84
- top_k: 50,
85
- repetition_penalty: 1.15,
86
- return_full_text: false,
87
- },
88
- });
89
-
90
- const generatedText = response.generated_text;
91
- console.log('Model response received, length:', generatedText.length);
92
-
93
- let slides: Slide[] = [];
94
- let presentationName = prompt.length > 50 ? prompt.substring(0, 47) + '...' : prompt;
95
-
96
- try {
97
- let cleanText = generatedText.trim();
98
-
99
- // Remove prompt echo if present
100
- const promptIndex = cleanText.indexOf(slideGenerationPrompt);
101
- if (promptIndex !== -1) {
102
- cleanText = cleanText.substring(promptIndex + slideGenerationPrompt.length).trim();
103
- }
104
-
105
- // Remove markdown code blocks
106
- cleanText = cleanText.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '');
107
-
108
- // Try to parse as object with presentationName (new format)
109
- let parsed: any = null;
110
-
111
- // Strategy 1: Try full object parse { presentationName, slides }
112
- const objMatch = cleanText.match(/\{[\s\S]*"presentationName"[\s\S]*"slides"[\s\S]*\}/);
113
- if (objMatch) {
114
- try {
115
- parsed = JSON.parse(objMatch[0]);
116
- } catch { /* fall through */ }
117
- }
118
-
119
- // Strategy 2: Find from first { to last }
120
- if (!parsed) {
121
- const firstBrace = cleanText.indexOf('{');
122
- const lastBrace = cleanText.lastIndexOf('}');
123
- if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
124
- const potentialJson = cleanText.substring(firstBrace, lastBrace + 1);
125
- if (potentialJson.includes('"presentationName"') || potentialJson.includes('"slides"')) {
126
- try {
127
- parsed = JSON.parse(potentialJson);
128
- } catch { /* fall through */ }
129
- }
130
- }
131
- }
132
-
133
- // Strategy 3: Find a JSON array (old format fallback)
134
- if (!parsed) {
135
- const arrayMatch = cleanText.match(/\[\s*\{[\s\S]*?"title"[\s\S]*?\}\s*\]/);
136
- if (arrayMatch) {
137
- const arr = JSON.parse(arrayMatch[0]);
138
- parsed = { slides: Array.isArray(arr) ? arr : [arr] };
139
- } else {
140
- const firstBracket = cleanText.indexOf('[');
141
- const lastBracket = cleanText.lastIndexOf(']');
142
- if (firstBracket !== -1 && lastBracket !== -1 && lastBracket > firstBracket) {
143
- const potentialJson = cleanText.substring(firstBracket, lastBracket + 1);
144
- if (potentialJson.includes('"title"')) {
145
- const arr = JSON.parse(potentialJson);
146
- parsed = { slides: Array.isArray(arr) ? arr : [arr] };
147
- }
148
- }
149
- }
150
- }
151
-
152
- if (!parsed) {
153
- throw new Error('No valid JSON structure found in response');
154
- }
155
-
156
- // Extract presentationName
157
- if (parsed.presentationName) {
158
- presentationName = String(parsed.presentationName).trim();
159
- }
160
-
161
- // Extract slides array
162
- const slidesArray = Array.isArray(parsed.slides) ? parsed.slides : (Array.isArray(parsed) ? parsed : []);
163
- const totalSlides = slidesArray.length;
164
-
165
- slides = slidesArray
166
- .filter((slide: any) => slide && typeof slide === 'object' && slide.title)
167
- .map((slide: any, index: number) => ({
168
- id: `slide-${index + 1}`,
169
- title: String(slide.title || `Slide ${index + 1}`).trim(),
170
- subtitle: slide.subtitle ? String(slide.subtitle).trim() : undefined,
171
- content: Array.isArray(slide.content)
172
- ? slide.content.filter((c: any) => c && typeof c === 'string').map((c: string) => c.trim())
173
- : slide.content ? [String(slide.content).trim()] : [],
174
- notes: slide.notes ? String(slide.notes).trim() : '',
175
- layout: normalizeLayout(slide.layout || '', index, totalSlides),
176
- imageKeyword: slide.imageKeyword ? String(slide.imageKeyword).trim()
177
- : slide.imageKeywords ? String(slide.imageKeywords).trim() : '',
178
- }));
179
-
180
- console.log(`Successfully parsed ${slides.length} slides from JSON`);
181
- } catch (jsonError) {
182
- console.error('JSON parsing failed:', jsonError);
183
- console.log('Falling back to text parsing');
184
- slides = parseTextToSlides(generatedText, prompt);
185
- }
186
-
187
- if (slides.length === 0) {
188
- slides = createFallbackSlides(prompt);
189
- }
190
-
191
- // Fetch images for image_and_text slides
192
- const slidesToFetchImages = slides
193
- .map((slide, index) => ({
194
- index,
195
- keywords: slide.imageKeyword || '',
196
- layout: slide.layout,
197
- }))
198
- .filter(item => item.layout === 'image_and_text' && item.keywords.trim() !== '');
199
-
200
- if (slidesToFetchImages.length > 0) {
201
- try {
202
- const imageResults = await fetchImagesForSlides(slidesToFetchImages);
203
- imageResults.forEach(({ index, result }) => {
204
- if (slides[index]) {
205
- slides[index].imageUrl = result.imageUrl;
206
- }
207
- });
208
- console.log(`Successfully fetched ${imageResults.length} images`);
209
- } catch (imageError) {
210
- console.error('Error fetching images:', imageError);
211
- }
212
- }
213
-
214
- return NextResponse.json({ presentationName, slides });
215
-
216
- } catch (modelError: unknown) {
217
- console.error('Model error:', modelError);
218
-
219
- let errorMessage = 'Unknown model error';
220
- let statusCode = 500;
221
-
222
- if (modelError instanceof Error && modelError.message) {
223
- if (modelError.message.includes('not supported for task') || modelError.message.includes('conversational')) {
224
- errorMessage = `Model ${actualModel} does not support text generation with HF Inference.`;
225
- statusCode = 400;
226
- } else if (modelError.message.includes('Rate limit') || modelError.message.includes('quota')) {
227
- errorMessage = 'HF Inference rate limit reached. Please try again in a moment.';
228
- statusCode = 429;
229
- } else if (modelError.message.includes('Authentication') || modelError.message.includes('Unauthorized')) {
230
- errorMessage = 'Invalid Hugging Face API token. Please check your API key.';
231
- statusCode = 401;
232
- } else if (modelError.message.includes('Model') && (modelError.message.includes('not found') || modelError.message.includes('not available'))) {
233
- errorMessage = `Model ${actualModel} not found on HF Inference.`;
234
- statusCode = 404;
235
- } else {
236
- errorMessage = modelError.message;
237
- }
238
- }
239
-
240
- if (statusCode >= 400 && statusCode < 500) {
241
- return NextResponse.json({ error: errorMessage }, { status: statusCode });
242
- }
243
-
244
- const slides = createFallbackSlides(prompt);
245
- return NextResponse.json({
246
- presentationName: prompt.length > 50 ? prompt.substring(0, 47) + '...' : prompt,
247
- slides,
248
- warning: `Model generation failed: ${errorMessage}. Using fallback content.`
249
- });
250
- }
251
-
252
- } catch (error) {
253
- console.error('API error:', error);
254
- return NextResponse.json({ error: 'Failed to generate slides' }, { status: 500 });
255
- }
256
- }
257
-
258
- function parseTextToSlides(text: string, prompt: string): Slide[] {
259
- const lines = text.split('\n').filter(line => line.trim());
260
- const slides: Slide[] = [];
261
-
262
- if (text.includes('Internal Server Error') || text.includes('error') || text.includes('Error')) {
263
- return createFallbackSlides(prompt);
264
- }
265
-
266
- let currentSlide: Partial<Slide> = {};
267
- let slideIndex = 1;
268
-
269
- for (let i = 0; i < lines.length; i++) {
270
- const trimmedLine = lines[i].trim();
271
- if (trimmedLine.length < 2) continue;
272
-
273
- const isTitleLine =
274
- trimmedLine.startsWith('#') ||
275
- trimmedLine.includes('Slide') ||
276
- trimmedLine.includes('SLIDE') ||
277
- trimmedLine.match(/^\d+\.?\s/) ||
278
- (trimmedLine.length > 5 && trimmedLine.length < 60 && !trimmedLine.includes('.') && i < lines.length / 2);
279
-
280
- if (isTitleLine) {
281
- if (currentSlide.title) {
282
- slides.push({
283
- id: `slide-${slideIndex}`,
284
- title: currentSlide.title,
285
- content: currentSlide.content || ['Content for this slide'],
286
- notes: currentSlide.notes || '',
287
- layout: normalizeLayout('', slideIndex - 1, 0),
288
- });
289
- slideIndex++;
290
- }
291
-
292
- const title = trimmedLine
293
- .replace(/^#+\s*/, '')
294
- .replace(/slide\s*\d*:?\s*/i, '')
295
- .replace(/^\d+\.?\s*/, '')
296
- .trim();
297
-
298
- currentSlide = { title: title || `Slide ${slideIndex}`, content: [] };
299
- } else if (trimmedLine.match(/^[-•*○]/)) {
300
- if (!currentSlide.content) currentSlide.content = [];
301
- const bulletContent = trimmedLine.replace(/^[-•*○]\s*/, '').trim();
302
- if (bulletContent) currentSlide.content.push(bulletContent);
303
- } else if (currentSlide.title && trimmedLine.length > 10) {
304
- if (!currentSlide.content) currentSlide.content = [];
305
- currentSlide.content.push(trimmedLine);
306
- }
307
- }
308
-
309
- if (currentSlide.title) {
310
- slides.push({
311
- id: `slide-${slideIndex}`,
312
- title: currentSlide.title,
313
- content: currentSlide.content || ['Content for this slide'],
314
- notes: currentSlide.notes || '',
315
- layout: normalizeLayout('', slideIndex - 1, 0),
316
- });
317
- }
318
-
319
- if (slides.length === 0 || slides.every(slide => slide.content.length === 0)) {
320
- return createFallbackSlides(prompt);
321
- }
322
-
323
- return slides;
324
- }
325
-
326
- function createFallbackSlides(prompt: string): Slide[] {
327
- const title = prompt.length > 50 ? prompt.substring(0, 47) + '...' : prompt;
328
-
329
- return [
330
- { id: 'slide-1', title, subtitle: `A comprehensive overview of ${prompt.toLowerCase()}`, content: [], layout: 'title_subtitle', imageKeyword: '' },
331
- { id: 'slide-2', title: 'Agenda', content: ['Background & Context', 'Key Concepts', 'Analysis & Insights', 'Practical Applications', 'Benefits & Impact'], layout: 'agenda', imageKeyword: '' },
332
- { id: 'slide-3', title: 'Background & Context', content: [`Understanding the fundamentals of ${prompt.toLowerCase()}`, 'Historical context and current relevance', 'Why this topic matters today'], layout: 'title_and_text', imageKeyword: '' },
333
- { id: 'slide-4', title: 'Key Concepts', content: ['Primary concepts and definitions', 'Core principles and frameworks', 'Essential components to understand'], layout: 'image_and_text', imageKeyword: `${prompt.toLowerCase()} concept diagram` },
334
- { id: 'slide-5', title: 'Analysis & Insights', content: ['Current trends and patterns', 'Data-driven insights and findings', 'Expert opinions and research'], layout: 'title_and_text', imageKeyword: '' },
335
- { id: 'slide-6', title: 'Practical Applications', content: ['Real-world use cases and examples', 'Best practices and recommendations', 'Common challenges and solutions'], layout: 'image_and_text', imageKeyword: `${prompt.toLowerCase()} practical application` },
336
- { id: 'slide-7', title: 'Benefits & Impact', content: ['Expected benefits and positive outcomes', 'Measurable impact and results', 'Long-term advantages and value'], layout: 'title_and_text', imageKeyword: '' },
337
- { id: 'slide-8', title: 'References', content: ['Industry reports and publications', 'Research papers and case studies'], layout: 'references', imageKeyword: '' },
338
- { id: 'slide-9', title: 'Thank You', subtitle: 'Questions and discussion welcome', content: [], layout: 'thank_you', imageKeyword: '' },
339
- ];
340
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/presentations/route.ts DELETED
@@ -1,79 +0,0 @@
1
- /*
2
- * File: app/api/presentations/route.ts
3
- * Description: Main API endpoint for generating presentations using multiple AI providers
4
- * This endpoint orchestrates the presentation generation process by routing requests
5
- * to appropriate AI providers (Gemini, HuggingFace, etc.) based on the selected model.
6
- *
7
- * Features:
8
- * - Multi-provider support (Gemini, HuggingFace)
9
- * - Intelligent provider selection based on model name
10
- * - Integration with orchestrator library for generation logic
11
- * - Returns structured presentation data with metadata
12
- *
13
- * Method: POST
14
- * Input: { prompt: string, model?: string, provider?: Provider }
15
- * Output: { success: boolean, presentation: object, metadata: object }
16
- */
17
-
18
- import { NextRequest, NextResponse } from 'next/server';
19
- import { generatePresentation, type GenerationRequest, type Provider } from '@/lib/orchestrator';
20
- import { LLAMA_PRESENTATION_MODEL, LLAMA_PRESENTATION_PROVIDER, isAllowedPresentationModel } from '@/lib/ai-models';
21
-
22
- /**
23
- * POST handler for presentation generation
24
- * Routes requests to appropriate AI providers and returns generated content
25
- */
26
- export async function POST(request: NextRequest) {
27
- try {
28
- const body = await request.json();
29
- const { prompt, model, provider }: { prompt: string; model?: string; provider?: Provider } = body;
30
-
31
- if (!prompt) {
32
- return NextResponse.json(
33
- { error: 'Prompt is required' },
34
- { status: 400 }
35
- );
36
- }
37
-
38
- if (provider && provider !== LLAMA_PRESENTATION_PROVIDER) {
39
- console.warn(`Ignoring unsupported provider "${provider}" and forcing ${LLAMA_PRESENTATION_PROVIDER}.`);
40
- }
41
-
42
- if (!isAllowedPresentationModel(model)) {
43
- console.warn(`Ignoring unsupported presentation model "${model}" and forcing ${LLAMA_PRESENTATION_MODEL}.`);
44
- }
45
-
46
- const finalProvider: Provider = LLAMA_PRESENTATION_PROVIDER;
47
-
48
- const generationRequest: GenerationRequest = {
49
- prompt,
50
- provider: finalProvider,
51
- model: LLAMA_PRESENTATION_MODEL,
52
- };
53
-
54
- const presentation = await generatePresentation(generationRequest);
55
-
56
- return NextResponse.json({
57
- success: true,
58
- presentation,
59
- metadata: {
60
- provider: finalProvider,
61
- model: generationRequest.model,
62
- timestamp: new Date().toISOString()
63
- }
64
- });
65
-
66
- } catch (error) {
67
- console.error('Error generating presentation:', error);
68
-
69
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
70
-
71
- return NextResponse.json(
72
- {
73
- error: 'Failed to generate presentation',
74
- details: errorMessage
75
- },
76
- { status: 500 }
77
- );
78
- }
79
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/upload-template/route.ts DELETED
@@ -1,141 +0,0 @@
1
- /**
2
- * PowerPoint Template Upload and Modification Endpoint
3
- *
4
- * Accepts a `.pptx` or `.ppt` file along with a user prompt and generates a
5
- * new AI-modified presentation that incorporates the requested changes while
6
- * preserving the structure of the original template.
7
- *
8
- * The uploaded file is converted to a Base64 string and attached to the
9
- * generation request as `templateContext`. In the current implementation the
10
- * context is forwarded to the orchestrator for prompt engineering but the raw
11
- * binary is not parsed — full template-aware generation is a future enhancement.
12
- *
13
- * Accepted file types:
14
- * - `application/vnd.openxmlformats-officedocument.presentationml.presentation` (.pptx)
15
- * - `application/vnd.ms-powerpoint` (.ppt)
16
- *
17
- * Method: POST (multipart/form-data)
18
- * Form fields:
19
- * - `file` — The PowerPoint template file (required)
20
- * - `prompt` — Instructions for how to modify the template (required)
21
- * - `model` — AI model slug to use (optional, defaults to provider default)
22
- */
23
-
24
- import { NextRequest, NextResponse } from 'next/server';
25
- import { generatePresentation, type GenerationRequest, type Provider } from '@/lib/orchestrator';
26
- import { LLAMA_PRESENTATION_MODEL, LLAMA_PRESENTATION_PROVIDER, isAllowedPresentationModel } from '@/lib/ai-models';
27
-
28
- /**
29
- * POST /api/upload-template
30
- *
31
- * Validates the uploaded file, determines the AI provider from the model slug,
32
- * builds an enhanced prompt that includes template-context instructions, and
33
- * delegates to the shared {@link generatePresentation} orchestrator.
34
- *
35
- * @param request - NextRequest containing `multipart/form-data` with `file`,
36
- * `prompt`, and optional `model` fields.
37
- * @returns 200 `{ success, presentation, metadata }` on success,
38
- * 400 on validation failures,
39
- * 500 on generation errors.
40
- */
41
- export async function POST(request: NextRequest) {
42
- try {
43
- // Parse the multipart form data from the request
44
- const formData = await request.formData();
45
- const file = formData.get('file') as File;
46
- const prompt = formData.get('prompt') as string;
47
- const model = formData.get('model') as string;
48
-
49
- // Both the template file and a user prompt are required to proceed
50
- if (!file || !prompt) {
51
- return NextResponse.json(
52
- { error: 'File and prompt are required' },
53
- { status: 400 }
54
- );
55
- }
56
-
57
- // Validate file type — only PowerPoint formats are accepted
58
- const allowedTypes = [
59
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx
60
- 'application/vnd.ms-powerpoint' // .ppt (legacy format)
61
- ];
62
-
63
- // Check both the MIME type and file extension for robust validation
64
- if (!allowedTypes.includes(file.type) && !file.name.endsWith('.pptx') && !file.name.endsWith('.ppt')) {
65
- return NextResponse.json(
66
- { error: 'Invalid file type. Please upload a PowerPoint file (.pptx or .ppt)' },
67
- { status: 400 }
68
- );
69
- }
70
-
71
- // Convert file to base64 for processing
72
- // The Base64 string is attached to the generation request for future
73
- // template-parsing implementations
74
- const arrayBuffer = await file.arrayBuffer();
75
- const base64File = Buffer.from(arrayBuffer).toString('base64');
76
-
77
- if (!isAllowedPresentationModel(model)) {
78
- console.warn(`Ignoring unsupported presentation model "${model}" and forcing ${LLAMA_PRESENTATION_MODEL}.`);
79
- }
80
-
81
- const finalProvider: Provider = LLAMA_PRESENTATION_PROVIDER;
82
-
83
- // Build an enhanced prompt that instructs the AI to act as a template editor
84
- // rather than generating a presentation from scratch
85
- const enhancedPrompt = `
86
- You are an expert presentation editor. I have uploaded a PowerPoint template and want you to modify it based on the following instructions:
87
-
88
- User Instructions: ${prompt}
89
-
90
- Please analyze the uploaded PowerPoint template and create a new presentation that incorporates the requested changes. The template contains the original structure and content that should be preserved where appropriate, but modified according to the user's specifications.
91
-
92
- Focus on:
93
- 1. Maintaining the overall structure and layout of the original template
94
- 2. Applying the requested changes (color schemes, content updates, new slides, etc.)
95
- 3. Ensuring professional quality and consistency
96
- 4. Preserving any important branding or design elements unless specifically asked to change them
97
-
98
- Generate a complete presentation that reflects both the original template's strengths and the requested modifications.
99
- `;
100
-
101
- const generationRequest: GenerationRequest = {
102
- prompt: enhancedPrompt,
103
- provider: finalProvider,
104
- model: LLAMA_PRESENTATION_MODEL,
105
- // Add template context - in a real implementation, you'd parse the PowerPoint file
106
- templateContext: {
107
- fileName: file.name,
108
- fileSize: file.size,
109
- fileType: file.type,
110
- base64Content: base64File // Raw file bytes encoded as Base64
111
- }
112
- };
113
-
114
- // Delegate to the shared generation orchestrator
115
- const presentation = await generatePresentation(generationRequest);
116
-
117
- return NextResponse.json({
118
- success: true,
119
- presentation,
120
- metadata: {
121
- provider: finalProvider,
122
- model: generationRequest.model,
123
- originalFile: file.name, // Let the client display the source filename
124
- timestamp: new Date().toISOString()
125
- }
126
- });
127
-
128
- } catch (error) {
129
- console.error('Error processing template:', error);
130
-
131
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
132
-
133
- return NextResponse.json(
134
- {
135
- error: 'Failed to process template',
136
- details: errorMessage
137
- },
138
- { status: 500 }
139
- );
140
- }
141
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/editor/page.tsx CHANGED
@@ -16,7 +16,8 @@
16
 
17
  'use client';
18
 
19
- import React from 'react';
 
20
  import GoogleSlidesEditor from '@/components/editor/GoogleSlidesEditor';
21
 
22
  /**
@@ -25,9 +26,24 @@ import GoogleSlidesEditor from '@/components/editor/GoogleSlidesEditor';
25
  * All the complex logic is handled within GoogleSlidesEditor
26
  */
27
  export default function EditorPage() {
28
- return (
29
- <GoogleSlidesEditor />
30
- );
31
- }
 
 
 
 
 
 
32
 
 
 
 
 
 
 
 
 
 
33
 
 
16
 
17
  'use client';
18
 
19
+ import React, { useEffect, useState } from 'react';
20
+ import { useRouter } from 'next/navigation';
21
  import GoogleSlidesEditor from '@/components/editor/GoogleSlidesEditor';
22
 
23
  /**
 
26
  * All the complex logic is handled within GoogleSlidesEditor
27
  */
28
  export default function EditorPage() {
29
+ const router = useRouter();
30
+ const [hasEditorAccess, setHasEditorAccess] = useState(false);
31
+
32
+ useEffect(() => {
33
+ const editorAccess = sessionStorage.getItem('editorAccess') === 'true';
34
+
35
+ if (!editorAccess) {
36
+ router.replace('/');
37
+ return;
38
+ }
39
 
40
+ setHasEditorAccess(true);
41
+ }, [router]);
42
+
43
+ if (!hasEditorAccess) {
44
+ return null;
45
+ }
46
+
47
+ return <GoogleSlidesEditor />;
48
+ }
49
 
app/layout.tsx CHANGED
@@ -92,8 +92,8 @@ const merriweather = Merriweather({
92
 
93
  // SEO and browser metadata configuration
94
  export const metadata: Metadata = {
95
- title: "PowerPoint Generator",
96
- description: "Generate PowerPoint presentations",
97
  };
98
 
99
  import { ThemeProvider } from "@/components/ThemeProvider";
 
92
 
93
  // SEO and browser metadata configuration
94
  export const metadata: Metadata = {
95
+ title: "Powerpoint.ai",
96
+ description: "Generate presentations with Powerpoint.ai",
97
  };
98
 
99
  import { ThemeProvider } from "@/components/ThemeProvider";
components/HFAuth.tsx DELETED
@@ -1,145 +0,0 @@
1
- 'use client';
2
-
3
- import React, { useState, useEffect } from 'react';
4
- import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
5
-
6
- interface OAuthResult {
7
- accessToken: string;
8
- userInfo: {
9
- id: string;
10
- name: string;
11
- fullname?: string;
12
- email: string;
13
- avatarUrl?: string;
14
- };
15
- }
16
-
17
- interface HFAuthProps {
18
- onAuthSuccess: (result: OAuthResult) => void;
19
- onAuthError: (error: string) => void;
20
- }
21
-
22
- export default function HFAuth({ onAuthSuccess, onAuthError }: HFAuthProps) {
23
- const [isLoading, setIsLoading] = useState(true);
24
- const [oauthResult, setOauthResult] = useState<OAuthResult | null>(null);
25
-
26
- useEffect(() => {
27
- const initializeAuth = async () => {
28
- try {
29
- // Check for existing OAuth result in localStorage
30
- let stored = localStorage.getItem('hf_oauth');
31
- if (stored) {
32
- try {
33
- const parsedResult = JSON.parse(stored);
34
- setOauthResult(parsedResult);
35
- onAuthSuccess(parsedResult);
36
- setIsLoading(false);
37
- return;
38
- } catch {
39
- localStorage.removeItem('hf_oauth');
40
- }
41
- }
42
-
43
- // Handle OAuth redirect if present
44
- const result = await oauthHandleRedirectIfPresent();
45
- if (result) {
46
- const oauthData: OAuthResult = {
47
- accessToken: result.accessToken,
48
- userInfo: result.userInfo as unknown as OAuthResult['userInfo']
49
- };
50
-
51
- setOauthResult(oauthData);
52
- localStorage.setItem('hf_oauth', JSON.stringify(oauthData));
53
- onAuthSuccess(oauthData);
54
- }
55
- } catch (error) {
56
- console.error('OAuth initialization error:', error);
57
- onAuthError('Failed to initialize authentication');
58
- } finally {
59
- setIsLoading(false);
60
- }
61
- };
62
-
63
- initializeAuth();
64
- }, [onAuthSuccess, onAuthError]);
65
-
66
- const handleSignIn = async () => {
67
- try {
68
- setIsLoading(true);
69
- // Default scopes for reading user info
70
- const scopes = 'read-repos read-billing';
71
- const loginUrl = await oauthLoginUrl({ scopes });
72
- window.location.href = loginUrl + '&prompt=consent';
73
- } catch (error) {
74
- console.error('Sign in error:', error);
75
- onAuthError('Failed to initiate sign in');
76
- setIsLoading(false);
77
- }
78
- };
79
-
80
- const handleSignOut = () => {
81
- localStorage.removeItem('hf_oauth');
82
- setOauthResult(null);
83
- // Redirect to clean URL without OAuth parameters
84
- const cleanUrl = window.location.href.replace(/[?&]code=[^&]*/, '').replace(/[?&]state=[^&]*/, '');
85
- window.location.href = cleanUrl;
86
- };
87
-
88
- if (isLoading) {
89
- return (
90
- <div className="flex items-center justify-center p-4">
91
- <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-orange-600"></div>
92
- <span className="ml-2 text-sm text-gray-600">Checking authentication...</span>
93
- </div>
94
- );
95
- }
96
-
97
- if (oauthResult) {
98
- return (
99
- <div className="flex items-center gap-3 p-3 bg-green-50 border border-green-200 rounded-lg">
100
- <img
101
- src={oauthResult.userInfo.avatarUrl || 'https://www.gravatar.com/avatar/0000?d=mp'}
102
- alt={oauthResult.userInfo.name}
103
- className="w-8 h-8 rounded-full"
104
- />
105
- <div className="flex-1">
106
- <div className="text-sm font-medium text-green-800">
107
- Welcome, {oauthResult.userInfo.fullname || oauthResult.userInfo.name}!
108
- </div>
109
- <div className="text-xs text-green-600">Signed in with Hugging Face</div>
110
- </div>
111
- <button
112
- onClick={handleSignOut}
113
- className="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
114
- >
115
- Sign Out
116
- </button>
117
- </div>
118
- );
119
- }
120
-
121
- return (
122
- <div className="text-center p-6">
123
- <div className="mb-4">
124
- <h3 className="text-lg font-semibold text-gray-900 mb-2">Sign in with Hugging Face</h3>
125
- <p className="text-sm text-gray-600 mb-4">
126
- Connect your Hugging Face account to access AI-powered presentation generation
127
- </p>
128
- </div>
129
-
130
- <button
131
- onClick={handleSignIn}
132
- className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-yellow-400 to-orange-500 text-white font-medium rounded-lg hover:from-yellow-500 hover:to-orange-600 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
133
- >
134
- <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
135
- <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
136
- </svg>
137
- Sign in with Hugging Face
138
- </button>
139
-
140
- <div className="mt-4 text-xs text-gray-500">
141
- Secure OAuth authentication • Your data stays private
142
- </div>
143
- </div>
144
- );
145
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/InteractivePresenter.tsx DELETED
@@ -1,36 +0,0 @@
1
- "use client";
2
- import React, { useMemo, useState } from 'react';
3
- import { Swiper, SwiperSlide } from 'swiper/react';
4
- import 'swiper/css';
5
- import { renderSlide, SlideSpec } from './slides/SlideFactory';
6
-
7
- export default function InteractivePresenter({ slides }: { slides: SlideSpec[] }) {
8
- const [theme, setTheme] = useState<'dark'|'light'>('dark');
9
- const content = useMemo(() => slides || [], [slides]);
10
-
11
- return (
12
- <div className="w-full grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-4">
13
- <div className="rounded-2xl overflow-hidden border border-gray-200 bg-white">
14
- <Swiper className="w-full h-[70vh]">
15
- {content.map((s) => (
16
- <SwiperSlide key={s.id}>
17
- <div className="w-full h-[70vh]">{renderSlide(s, theme)}</div>
18
- </SwiperSlide>
19
- ))}
20
- </Swiper>
21
- </div>
22
- <aside className="bg-white border border-gray-200 rounded-2xl p-4 h-[70vh] overflow-auto">
23
- <div className="flex items-center justify-between mb-3">
24
- <div className="font-semibold">Editor</div>
25
- <select className="border rounded px-2 py-1" value={theme} onChange={(e)=>setTheme(e.target.value as 'dark' | 'light')}>
26
- <option value="dark">Dark</option>
27
- <option value="light">Light</option>
28
- </select>
29
- </div>
30
- <p className="text-sm text-gray-600">Live preview. Future: per-slide editing, regenerate, layout swap.</p>
31
- </aside>
32
- </div>
33
- );
34
- }
35
-
36
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/auth/HuggingFaceLogin.tsx DELETED
@@ -1,263 +0,0 @@
1
- 'use client';
2
-
3
- import React, { useState, useEffect, useCallback } from 'react';
4
- import { X, LogIn, LogOut, User } from 'lucide-react';
5
- import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
6
-
7
- interface OAuthResult {
8
- accessToken: string;
9
- accessTokenExpiresAt: Date;
10
- userInfo: {
11
- id: string;
12
- name: string;
13
- fullname: string;
14
- email?: string;
15
- avatarUrl: string;
16
- isPro?: boolean;
17
- };
18
- }
19
-
20
- interface HuggingFaceLoginProps {
21
- onAuthChange?: (isAuthenticated: boolean) => void;
22
- className?: string;
23
- variant?: 'light' | 'dark';
24
- }
25
-
26
- export default function HuggingFaceLogin({ onAuthChange, className, variant = 'light' }: HuggingFaceLoginProps) {
27
- const [isLoading, setIsLoading] = useState(true);
28
- const [isAuthenticated, setIsAuthenticated] = useState(false);
29
- const [oauthResult, setOauthResult] = useState<OAuthResult | null>(null);
30
- const [showUserMenu, setShowUserMenu] = useState(false);
31
-
32
- // Handle OAuth callback and check for existing session
33
- useEffect(() => {
34
- const initializeAuth = async () => {
35
- try {
36
- // First, check for existing OAuth result in localStorage
37
- const stored = localStorage.getItem('hf_oauth');
38
- if (stored) {
39
- try {
40
- const parsedResult = JSON.parse(stored) as OAuthResult;
41
- // Check if token is still valid (not expired)
42
- const expiresAt = new Date(parsedResult.accessTokenExpiresAt);
43
- if (expiresAt > new Date()) {
44
- setOauthResult(parsedResult);
45
- setIsAuthenticated(true);
46
- // Also store the access token for API usage
47
- localStorage.setItem('hf_api_key', parsedResult.accessToken);
48
- onAuthChange?.(true);
49
- setIsLoading(false);
50
- return;
51
- } else {
52
- // Token expired, clear it
53
- localStorage.removeItem('hf_oauth');
54
- localStorage.removeItem('hf_api_key');
55
- }
56
- } catch {
57
- localStorage.removeItem('hf_oauth');
58
- localStorage.removeItem('hf_api_key');
59
- }
60
- }
61
-
62
- // Handle OAuth redirect if present (user just came back from HuggingFace)
63
- const result = await oauthHandleRedirectIfPresent();
64
- if (result) {
65
- // Cast userInfo to access all properties (HF types may be incomplete)
66
- const userInfo = result.userInfo as any;
67
- const oauthData: OAuthResult = {
68
- accessToken: result.accessToken,
69
- accessTokenExpiresAt: result.accessTokenExpiresAt,
70
- userInfo: {
71
- id: userInfo.sub || userInfo.id || userInfo.name,
72
- name: userInfo.preferred_username || userInfo.name,
73
- fullname: userInfo.name || userInfo.fullname || '',
74
- email: userInfo.email,
75
- avatarUrl: userInfo.picture || userInfo.avatarUrl || '',
76
- isPro: userInfo.isPro || false,
77
- }
78
- };
79
-
80
- setOauthResult(oauthData);
81
- setIsAuthenticated(true);
82
-
83
- // Store in localStorage for persistence
84
- localStorage.setItem('hf_oauth', JSON.stringify(oauthData));
85
- localStorage.setItem('hf_api_key', oauthData.accessToken);
86
-
87
- onAuthChange?.(true);
88
-
89
- // Clean up URL by removing OAuth parameters
90
- const cleanUrl = window.location.origin + window.location.pathname;
91
- window.history.replaceState({}, document.title, cleanUrl);
92
- }
93
- } catch (error) {
94
- console.error('OAuth initialization error:', error);
95
- } finally {
96
- setIsLoading(false);
97
- }
98
- };
99
-
100
- initializeAuth();
101
- }, [onAuthChange]);
102
-
103
- const handleSignIn = useCallback(async () => {
104
- try {
105
- setIsLoading(true);
106
-
107
- const res = await fetch('/api/auth/client-id');
108
- const { clientId } = await res.json();
109
- if (!clientId) {
110
- throw new Error('HuggingFace Client ID not configured');
111
- }
112
-
113
- // Request scopes needed for inference API access
114
- // 'inference-api' scope allows using the user's credits
115
- const scopes = 'openid profile inference-api';
116
- const loginUrl = await oauthLoginUrl({
117
- clientId,
118
- scopes,
119
- redirectUrl: `${window.location.origin}/`,
120
- });
121
- // Redirect to HuggingFace OAuth page
122
- window.location.href = loginUrl;
123
- } catch (error) {
124
- console.error('Sign in error:', error);
125
- alert('Failed to initiate sign in. Please try again.');
126
- setIsLoading(false);
127
- }
128
- }, []);
129
-
130
- const handleSignOut = useCallback(() => {
131
- localStorage.removeItem('hf_oauth');
132
- localStorage.removeItem('hf_api_key');
133
- setOauthResult(null);
134
- setIsAuthenticated(false);
135
- setShowUserMenu(false);
136
- onAuthChange?.(false);
137
- }, [onAuthChange]);
138
-
139
- // Loading state
140
- if (isLoading) {
141
- return (
142
- <button
143
- disabled
144
- className={`flex items-center gap-2 px-3 py-1.5 text-sm border rounded-lg opacity-50 cursor-not-allowed
145
- ${variant === 'dark' ? 'border-white/10 text-white/50' : 'border-gray-300 text-gray-500'} ${className || ''}`}
146
- >
147
- <div className={`animate-spin h-4 w-4 border-2 border-t-transparent rounded-full ${variant === 'dark' ? 'border-white/50' : 'border-gray-400'}`} />
148
- <span className="hidden sm:inline">Loading...</span>
149
- </button>
150
- );
151
- }
152
-
153
- // Authenticated state - show user info
154
- if (isAuthenticated && oauthResult) {
155
- return (
156
- <div className={`relative ${className || ''}`}>
157
- <button
158
- onClick={() => setShowUserMenu(!showUserMenu)}
159
- className={`flex items-center gap-2 px-3 py-1.5 text-sm border rounded-lg transition-colors
160
- ${variant === 'dark'
161
- ? 'border-green-500/30 bg-green-500/10 hover:bg-green-500/20 text-green-300'
162
- : 'border-green-300 bg-green-50 hover:bg-green-100 text-green-700'
163
- }`}
164
- title="Signed in with Hugging Face"
165
- >
166
- {oauthResult.userInfo.avatarUrl ? (
167
- <img
168
- src={oauthResult.userInfo.avatarUrl}
169
- alt={oauthResult.userInfo.name}
170
- className="w-5 h-5 rounded-full"
171
- />
172
- ) : (
173
- <User className={`w-4 h-4 ${variant === 'dark' ? 'text-green-400' : 'text-green-600'}`} />
174
- )}
175
- <span className="hidden sm:inline font-medium">
176
- {oauthResult.userInfo.name}
177
- </span>
178
- {oauthResult.userInfo.isPro && (
179
- <span className="text-xs bg-gradient-to-r from-yellow-400 to-orange-500 text-white px-1.5 py-0.5 rounded-full font-medium">
180
- PRO
181
- </span>
182
- )}
183
- </button>
184
-
185
- {/* Dropdown menu */}
186
- {showUserMenu && (
187
- <>
188
- {/* Backdrop */}
189
- <div
190
- className="fixed inset-0 z-[1999]"
191
- onClick={() => setShowUserMenu(false)}
192
- />
193
-
194
- {/* Menu */}
195
- <div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-lg shadow-xl border border-gray-200 z-[2000] text-gray-900 text-left">
196
- <div className="p-3 border-b">
197
- <div className="flex items-center gap-3">
198
- {oauthResult.userInfo.avatarUrl ? (
199
- <img
200
- src={oauthResult.userInfo.avatarUrl}
201
- alt={oauthResult.userInfo.name}
202
- className="w-10 h-10 rounded-full"
203
- />
204
- ) : (
205
- <div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
206
- <User className="w-6 h-6 text-gray-500" />
207
- </div>
208
- )}
209
- <div>
210
- <div className="font-medium text-gray-900">
211
- {oauthResult.userInfo.fullname || oauthResult.userInfo.name}
212
- </div>
213
- <div className="text-sm text-gray-500">
214
- @{oauthResult.userInfo.name}
215
- </div>
216
- </div>
217
- </div>
218
- </div>
219
-
220
- <div className="p-2">
221
- <div className="px-3 py-2 text-xs text-gray-500">
222
- <p>✓ Connected to HuggingFace</p>
223
- <p className="mt-1">Your inference credits will be used when generating with HF models.</p>
224
- </div>
225
-
226
- <button
227
- onClick={handleSignOut}
228
- className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded transition-colors"
229
- >
230
- <LogOut className="w-4 h-4" />
231
- Sign Out
232
- </button>
233
- </div>
234
- </div>
235
- </>
236
- )}
237
- </div>
238
- );
239
- }
240
-
241
- // Not authenticated - show sign in button
242
- return (
243
- <button
244
- onClick={handleSignIn}
245
- className={`flex items-center gap-2 px-3 py-1.5 text-sm border rounded-lg transition-colors
246
- ${variant === 'dark'
247
- ? 'border-white/20 hover:bg-white/10 text-white hover:border-white/40'
248
- : 'border-gray-300 hover:bg-gray-50 hover:border-orange-400 hover:bg-orange-50 text-gray-700'
249
- } ${className || ''}`}
250
- title="Sign in with Hugging Face"
251
- >
252
- <svg
253
- className="w-4 h-4"
254
- viewBox="0 0 32 32"
255
- fill="currentColor"
256
- >
257
- <path d="M16.878 1.377c-1.097-.01-2.033.84-2.177 1.94-.144 1.1-1.816 14.35-1.816 14.35s-.28-1.311-.576-2.602c-.297-1.29-.561-2.27-.768-2.653-.207-.384-1.234-1.503-2.115-1.405-.882.098-1.65.956-1.555 1.9.095.943 2.633 10.51 2.633 10.51s-.827-1.23-1.604-2.444c-.516-.806-1.001-1.525-1.192-1.745-.382-.44-1.064-.615-1.665-.41-.752.257-1.28 1.014-1.16 1.823.12.81 4.919 8.378 9.568 9.64 4.649 1.263 9.13-1.8 10.24-6.282 1.11-4.482 3.05-13.01 3.16-13.65.111-.64-.185-1.29-.735-1.62-.55-.329-5.188-3.301-7.238-3.302z" />
258
- </svg>
259
- <span className="hidden sm:inline">Sign in with HF</span>
260
- <LogIn className="w-3 h-3" />
261
- </button>
262
- );
263
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/editor/GoogleSlidesEditor.tsx CHANGED
@@ -24,7 +24,7 @@ import { getTemplateById, LayoutType } from '@/data/templates';
24
  import type { ThemeName } from '@/lib/theme-system';
25
 
26
  // Shared types, themes, and layout helpers
27
- import type { TextElement, ImageElement, ShapeElement, ShapeType, EditorElement, SlideModel, AlignmentGuide } from '@/lib/editor-types';
28
  import { themes, TEMPLATE_THEMES } from '@/lib/editor-themes';
29
  import { createId, withinBounds, getWorkshopLayoutBackground, createLayoutElements } from '@/lib/layout-templates';
30
  import { useSlideHistory } from '@/hooks/useSlideHistory';
@@ -54,8 +54,6 @@ export default function GoogleSlidesEditor() {
54
  const [zoom, setZoom] = useState(1); // Canvas zoom level (0.5 to 2)
55
 
56
  // MENU/MODAL STATE
57
- const [showExportMenu, setShowExportMenu] = useState(false); // Export dropdown visible
58
- const [showAIMenu, setShowAIMenu] = useState(false); // AI menu visible
59
  const [isAIEditing, setIsAIEditing] = useState(false); // AI is processing text
60
  const [showAIDialog, setShowAIDialog] = useState(false); // AI dialog visible
61
  const [showUnsplashSearch, setShowUnsplashSearch] = useState(false);
@@ -521,69 +519,11 @@ export default function GoogleSlidesEditor() {
521
  setSelectedId,
522
  });
523
 
524
- // PDF/PPTX export via useExport hook
525
- const { exportToPDF, exportToPPTX } = useExport({
526
- slideRef, slides, slideSpecs, currentTheme, currentSlideIndex, zoom,
527
- selectedId, isEditingTextId, presentationTitle,
528
- setCurrentSlideIndex, setZoom, setSelectedId, setIsEditingTextId,
529
  });
530
 
531
- // Apply layout to current slide
532
- const applyLayout = (layout: string) => {
533
- setSlides(prev => {
534
- const copy = [...prev];
535
- const newElements = createLayoutElements(layout, currentTheme);
536
-
537
- copy[currentSlideIndex] = {
538
- ...copy[currentSlideIndex],
539
- elements: newElements,
540
- layout: layout, // Store the layout for background styling
541
- };
542
- return copy;
543
- });
544
- saveToHistory(slides);
545
- };
546
-
547
- // Apply theme to all slides
548
- const applyTheme = (theme: keyof typeof themes) => {
549
- setCurrentTheme(theme);
550
- setSlides(prev =>
551
- prev.map(slide => ({
552
- ...slide,
553
- elements: slide.elements.map(el => {
554
- if (el.type === 'text') {
555
- const isHeading = (el as TextElement).fontSize >= 32;
556
- const fontFamily = isHeading ? (themes[theme].headingFont || 'Arial') : (themes[theme].bodyFont || 'Arial');
557
- const color = isHeading ? themes[theme].titleColor : themes[theme].textColor;
558
- return { ...el, color, fontFamily };
559
- }
560
- return el;
561
- }),
562
- }))
563
- );
564
-
565
- // When switching to a template theme and slideSpecs is empty, create specs from existing slides
566
- if (TEMPLATE_THEMES.has(theme) && slideSpecs.length === 0 && slides.length > 0) {
567
- const specs: SlideSpec[] = slides.map((slide, index) => {
568
- const textElements = slide.elements.filter(e => e.type === 'text') as TextElement[];
569
- const titleEl = textElements.find(e => e.fontSize >= 32);
570
- const bodyEls = textElements.filter(e => e.fontSize < 32);
571
- const imageEls = slide.elements.filter(e => e.type === 'image');
572
- const hasImage = imageEls.length > 0;
573
- return {
574
- id: `slide-${index + 1}`,
575
- templateId: theme,
576
- layout: (index === 0 ? 'title_subtitle' : (hasImage ? 'image_and_text' : 'title_and_text')) as LayoutType,
577
- title: titleEl?.text || `Slide ${index + 1}`,
578
- subtitle: index === 0 && bodyEls.length > 0 ? bodyEls[0].text : undefined,
579
- body: index === 0 ? undefined : bodyEls.map(e => ({ text: e.text })),
580
- imageUrl: hasImage ? (imageEls[0] as any).src : undefined,
581
- };
582
- });
583
- setSlideSpecs(specs);
584
- }
585
- };
586
-
587
  // AI Text Editing - returns the edited text for the dialog
588
  const handleAIEdit = async (action: 'refine' | 'change' | 'expand' | 'regenerate' | 'shorten'): Promise<string> => {
589
  const selectedElement = currentSlide.elements.find(e => e.id === selectedId);
@@ -602,10 +542,10 @@ export default function GoogleSlidesEditor() {
602
  'Content-Type': 'application/json',
603
  };
604
 
605
- // Get the HF API key from localStorage (set by HuggingFaceLogin component)
606
  const hfApiKey = localStorage.getItem('hf_api_key');
607
  if (hfApiKey) {
608
- // Use x-hf-token header - this is the user's HF API key which will deduct their credits
609
  headers['x-hf-token'] = hfApiKey;
610
  }
611
 
@@ -708,31 +648,6 @@ export default function GoogleSlidesEditor() {
708
  img.src = url;
709
  };
710
 
711
- // Add shape element
712
- const addShape = (shapeType: ShapeType = 'rectangle') => {
713
- const newId = createId();
714
- setSlides(prev => {
715
- const copy = [...prev];
716
- const el: ShapeElement = {
717
- id: newId,
718
- type: 'shape',
719
- shapeType: shapeType,
720
- x: 300,
721
- y: 150,
722
- width: shapeType === 'line' ? 200 : 150,
723
- height: shapeType === 'line' ? 4 : 150,
724
- backgroundColor: '#3b82f6',
725
- borderColor: '#2563eb',
726
- borderWidth: 2,
727
- opacity: 1,
728
- };
729
- copy[currentSlideIndex] = { ...copy[currentSlideIndex], elements: [...copy[currentSlideIndex].elements, el] };
730
- return copy;
731
- });
732
- setSelectedId(newId);
733
- saveToHistory(slides);
734
- };
735
-
736
  // Delete element (text, image, etc.)
737
  const deleteElement = (id: string) => {
738
  setSlides(prev => {
@@ -1435,42 +1350,13 @@ export default function GoogleSlidesEditor() {
1435
  placeholder="Enter presentation title"
1436
  />
1437
  <div className="ml-auto flex items-center gap-3">
1438
- {/* Export Button */}
1439
- <div className="relative">
1440
- <button
1441
- onClick={() => setShowExportMenu(!showExportMenu)}
1442
- className="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
1443
- >
1444
- <FiDownload className="w-4 h-4" />
1445
- <span>Export</span>
1446
- </button>
1447
- {showExportMenu && (
1448
- <>
1449
- {/* Backdrop */}
1450
- <div
1451
- className="fixed inset-0 z-[1999]"
1452
- onClick={() => setShowExportMenu(false)}
1453
- />
1454
- {/* Dropdown */}
1455
- <div className="absolute right-0 top-full mt-2 w-48 bg-white rounded-lg shadow-xl border border-gray-200 z-[2000] py-1">
1456
- <button
1457
- onClick={() => { setShowExportMenu(false); exportToPDF(); }}
1458
- className="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
1459
- >
1460
- <span className="text-red-500">📄</span>
1461
- Export as PDF
1462
- </button>
1463
- <button
1464
- onClick={() => { setShowExportMenu(false); exportToPPTX(); }}
1465
- className="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
1466
- >
1467
- <span className="text-orange-500">📊</span>
1468
- Export as PPTX
1469
- </button>
1470
- </div>
1471
- </>
1472
- )}
1473
- </div>
1474
  {/* Profile Picture */}
1475
  {avatarUrl ? (
1476
  <img
@@ -1768,3 +1654,4 @@ export default function GoogleSlidesEditor() {
1768
  </div >
1769
  );
1770
  }
 
 
24
  import type { ThemeName } from '@/lib/theme-system';
25
 
26
  // Shared types, themes, and layout helpers
27
+ import type { TextElement, ImageElement, ShapeElement, EditorElement, SlideModel, AlignmentGuide } from '@/lib/editor-types';
28
  import { themes, TEMPLATE_THEMES } from '@/lib/editor-themes';
29
  import { createId, withinBounds, getWorkshopLayoutBackground, createLayoutElements } from '@/lib/layout-templates';
30
  import { useSlideHistory } from '@/hooks/useSlideHistory';
 
54
  const [zoom, setZoom] = useState(1); // Canvas zoom level (0.5 to 2)
55
 
56
  // MENU/MODAL STATE
 
 
57
  const [isAIEditing, setIsAIEditing] = useState(false); // AI is processing text
58
  const [showAIDialog, setShowAIDialog] = useState(false); // AI dialog visible
59
  const [showUnsplashSearch, setShowUnsplashSearch] = useState(false);
 
519
  setSelectedId,
520
  });
521
 
522
+ // PPTX export via useExport hook
523
+ const { exportToPPTX } = useExport({
524
+ slideRef, slides, slideSpecs, currentTheme, presentationTitle,
 
 
525
  });
526
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  // AI Text Editing - returns the edited text for the dialog
528
  const handleAIEdit = async (action: 'refine' | 'change' | 'expand' | 'regenerate' | 'shorten'): Promise<string> => {
529
  const selectedElement = currentSlide.elements.find(e => e.id === selectedId);
 
542
  'Content-Type': 'application/json',
543
  };
544
 
545
+ // Get the signed-in user's HF token if available
546
  const hfApiKey = localStorage.getItem('hf_api_key');
547
  if (hfApiKey) {
548
+ // Use x-hf-token so the request is billed against the user's HF credits
549
  headers['x-hf-token'] = hfApiKey;
550
  }
551
 
 
648
  img.src = url;
649
  };
650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  // Delete element (text, image, etc.)
652
  const deleteElement = (id: string) => {
653
  setSlides(prev => {
 
1350
  placeholder="Enter presentation title"
1351
  />
1352
  <div className="ml-auto flex items-center gap-3">
1353
+ <button
1354
+ onClick={exportToPPTX}
1355
+ className="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
1356
+ >
1357
+ <FiDownload className="w-4 h-4" />
1358
+ <span>Export PPTX</span>
1359
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1360
  {/* Profile Picture */}
1361
  {avatarUrl ? (
1362
  <img
 
1654
  </div >
1655
  );
1656
  }
1657
+
components/editor/GoogleSlidesMenubar.tsx DELETED
@@ -1,21 +0,0 @@
1
- import React from 'react';
2
-
3
- export default function GoogleSlidesMenubar() {
4
- const menus = ['File', 'Edit', 'View', 'Insert', 'Format', 'Arrange', 'Tools', 'Extensions', 'Help'];
5
-
6
- return (
7
- <div className="flex items-center gap-1 px-2 text-[13px] text-[#202124] select-none">
8
- {menus.map((menu) => (
9
- <button
10
- key={menu}
11
- className="px-2 py-0.5 rounded-[4px] hover:bg-[#f1f3f4] transition-colors"
12
- >
13
- {menu}
14
- </button>
15
- ))}
16
- <div className="ml-4 text-[12px] text-[#5f6368] hover:underline cursor-pointer">
17
- Last edit was seconds ago
18
- </div>
19
- </div>
20
- );
21
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/editor/GoogleSlidesToolbar.tsx DELETED
@@ -1,373 +0,0 @@
1
- import React, { useState } from 'react';
2
- import {
3
- Undo2, Redo2, Printer, PaintRoller, MousePointer2, Type, Image as ImageIcon,
4
- Minus, Plus, Bold, Italic, Underline, Link, MessageSquarePlus,
5
- AlignLeft, AlignCenter, AlignRight, AlignJustify, List, ListOrdered,
6
- Indent, Outdent, Baseline, Highlighter, MoreHorizontal, CheckSquare
7
- } from 'lucide-react';
8
-
9
- interface GoogleSlidesToolbarProps {
10
- handleUndo: () => void;
11
- handleRedo: () => void;
12
- historyIndex: number;
13
- historyLength: number;
14
- zoom: number;
15
- setZoom: (fn: (z: number) => number) => void;
16
- addText: () => void;
17
- addImage: () => void;
18
- fileInputRef: React.RefObject<HTMLInputElement | null>;
19
- onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
20
- selectedId: string | null;
21
- selectedElement: any;
22
- updateElement: (id: string, props: any) => void;
23
- fonts: string[];
24
- applyLayout: (layout: string) => void;
25
- currentTheme: string;
26
- applyTheme: (theme: string) => void;
27
- themes: any;
28
- deleteElement: (id: string) => void;
29
- showAIMenu: boolean;
30
- setShowAIMenu: (show: boolean) => void;
31
- isAIEditing: boolean;
32
- handleAIEdit: (action: string | 'dialog') => void;
33
- showExportMenu: boolean;
34
- setShowExportMenu: (show: boolean) => void;
35
- exportToPDF: () => void;
36
- exportToPPTX: () => void;
37
- }
38
-
39
- export default function GoogleSlidesToolbar(props: GoogleSlidesToolbarProps) {
40
- const {
41
- handleUndo, handleRedo, historyIndex, historyLength,
42
- zoom, setZoom, addText, addImage, fileInputRef, onFileChange,
43
- selectedId, selectedElement, updateElement, fonts,
44
- applyLayout, currentTheme, applyTheme, themes,
45
- showAIMenu, setShowAIMenu, isAIEditing, handleAIEdit,
46
- showExportMenu, setShowExportMenu, exportToPDF, exportToPPTX
47
- } = props;
48
-
49
- const [showThemeMenu, setShowThemeMenu] = useState(false);
50
-
51
- const textEl = selectedElement?.type === 'text' ? selectedElement : null;
52
- const isTextSelected = !!textEl;
53
-
54
- const Separator = () => <div className="w-px h-5 bg-gray-300 mx-1" />;
55
-
56
- const IconButton = ({
57
- children, title, onClick, disabled, active
58
- }: {
59
- children: React.ReactNode, title: string, onClick?: () => void, disabled?: boolean, active?: boolean
60
- }) => (
61
- <button
62
- title={title}
63
- onClick={onClick}
64
- onMouseDown={(e) => e.preventDefault()}
65
- disabled={disabled}
66
- className={`p-1 rounded-[4px] hover:bg-[#f1f3f4] disabled:opacity-30 disabled:hover:bg-transparent transition-colors ${active ? 'bg-[#e8f0fe] text-[#1a73e8]' : 'text-[#444746]'}`}
67
- >
68
- {children}
69
- </button>
70
- );
71
-
72
- return (
73
- <div className="w-full bg-[#f9fbfd] rounded-full mx-2 my-1 px-4 py-1.5 flex items-center gap-1 overflow-x-auto scrollbar-hide shadow-sm border border-[#e1e3e1]">
74
- {/* History */}
75
- <IconButton title="Undo (Ctrl+Z)" onClick={handleUndo} disabled={historyIndex <= 0}>
76
- <Undo2 size={16} />
77
- </IconButton>
78
- <IconButton title="Redo (Ctrl+Y)" onClick={handleRedo} disabled={historyIndex >= historyLength - 1}>
79
- <Redo2 size={16} />
80
- </IconButton>
81
- <IconButton title="Print">
82
- <Printer size={16} />
83
- </IconButton>
84
- <IconButton title="Paint format">
85
- <PaintRoller size={16} />
86
- </IconButton>
87
-
88
- <Separator />
89
-
90
- {/* Zoom */}
91
- <div className="flex items-center gap-1 px-1 hover:bg-[#f1f3f4] rounded-[4px] cursor-pointer" title="Zoom">
92
- <span className="text-[13px] font-medium text-[#444746]">{Math.round(zoom * 100)}%</span>
93
- <button onClick={() => setZoom(z => Math.max(0.5, z - 0.1))} className="p-0.5"><Minus size={10} /></button>
94
- <button onClick={() => setZoom(z => Math.min(2, z + 0.1))} className="p-0.5"><Plus size={10} /></button>
95
- </div>
96
-
97
- <Separator />
98
-
99
- {/* Tools */}
100
- <IconButton title="Select" active>
101
- <MousePointer2 size={16} />
102
- </IconButton>
103
- <IconButton title="Text box" onClick={addText}>
104
- <div className="border border-[#444746] rounded-[2px] px-0.5">
105
- <Type size={14} />
106
- </div>
107
- </IconButton>
108
- <IconButton title="Insert image" onClick={addImage}>
109
- <ImageIcon size={16} />
110
- </IconButton>
111
- <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onFileChange} />
112
-
113
- <IconButton title="Line">
114
- <Minus size={16} className="rotate-45" />
115
- </IconButton>
116
-
117
- <Separator />
118
-
119
- {/* Font Controls */}
120
- <select
121
- value={textEl?.fontFamily || 'Arial'}
122
- onChange={(e) => selectedId && updateElement(selectedId, { fontFamily: e.target.value })}
123
- disabled={!isTextSelected}
124
- className="h-7 px-2 text-[13px] bg-transparent hover:bg-[#f1f3f4] rounded-[4px] border-none focus:ring-0 cursor-pointer disabled:opacity-50 max-w-[100px]"
125
- >
126
- {fonts.map(font => (
127
- <option key={font} value={font}>{font}</option>
128
- ))}
129
- </select>
130
-
131
- <div className="w-px h-4 bg-gray-300 mx-0.5" />
132
-
133
- <div className="flex items-center">
134
- <button
135
- className="p-1 hover:bg-[#f1f3f4] rounded-[4px] disabled:opacity-30"
136
- disabled={!isTextSelected}
137
- onClick={() => selectedId && updateElement(selectedId, { fontSize: (textEl?.fontSize || 24) - 1 })}
138
- >
139
- <Minus size={12} />
140
- </button>
141
- <input
142
- type="text"
143
- value={textEl?.fontSize || 24}
144
- onChange={(e) => selectedId && updateElement(selectedId, { fontSize: parseInt(e.target.value) || 24 })}
145
- disabled={!isTextSelected}
146
- className="w-8 text-center text-[13px] bg-transparent border border-transparent hover:border-gray-300 rounded-[2px] focus:border-blue-500 outline-none"
147
- />
148
- <button
149
- className="p-1 hover:bg-[#f1f3f4] rounded-[4px] disabled:opacity-30"
150
- disabled={!isTextSelected}
151
- onClick={() => selectedId && updateElement(selectedId, { fontSize: (textEl?.fontSize || 24) + 1 })}
152
- >
153
- <Plus size={12} />
154
- </button>
155
- </div>
156
-
157
- <Separator />
158
-
159
- <IconButton
160
- title="Bold (Ctrl+B)"
161
- onClick={() => selectedId && updateElement(selectedId, { fontWeight: textEl?.fontWeight === 'bold' ? 'normal' : 'bold' })}
162
- disabled={!isTextSelected}
163
- active={textEl?.fontWeight === 'bold'}
164
- >
165
- <Bold size={16} />
166
- </IconButton>
167
- <IconButton
168
- title="Italic (Ctrl+I)"
169
- onClick={() => selectedId && updateElement(selectedId, { fontStyle: textEl?.fontStyle === 'italic' ? 'normal' : 'italic' })}
170
- disabled={!isTextSelected}
171
- active={textEl?.fontStyle === 'italic'}
172
- >
173
- <Italic size={16} />
174
- </IconButton>
175
- <IconButton
176
- title="Underline (Ctrl+U)"
177
- onClick={() => selectedId && updateElement(selectedId, { textDecoration: textEl?.textDecoration === 'underline' ? 'none' : 'underline' })}
178
- disabled={!isTextSelected}
179
- active={textEl?.textDecoration === 'underline'}
180
- >
181
- <Underline size={16} />
182
- </IconButton>
183
-
184
- <div className="flex items-center gap-1">
185
- <IconButton title="Text color" disabled={!isTextSelected}>
186
- <div className="flex flex-col items-center">
187
- <Baseline size={14} />
188
- <div className="w-3 h-1 mt-0.5" style={{ backgroundColor: textEl?.color || '#000000' }} />
189
- </div>
190
- <input
191
- type="color"
192
- value={textEl?.color || '#000000'}
193
- onChange={(e) => selectedId && updateElement(selectedId, { color: e.target.value })}
194
- className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
195
- disabled={!isTextSelected}
196
- />
197
- </IconButton>
198
- <IconButton title="Highlight color" disabled={!isTextSelected}>
199
- <div className="flex flex-col items-center">
200
- <Highlighter size={14} />
201
- <div className="w-3 h-1 mt-0.5 bg-transparent border border-gray-300" />
202
- </div>
203
- </IconButton>
204
- </div>
205
-
206
- <Separator />
207
-
208
- <IconButton title="Insert link" disabled={!isTextSelected}>
209
- <Link size={16} />
210
- </IconButton>
211
- <IconButton title="Add comment">
212
- <MessageSquarePlus size={16} />
213
- </IconButton>
214
-
215
- {/* AI Edit */}
216
- <div className="relative">
217
- <IconButton
218
- title="AI Edit"
219
- disabled={!isTextSelected && !selectedId}
220
- onClick={() => setShowAIMenu(!showAIMenu)}
221
- active={showAIMenu}
222
- >
223
- <div className="flex items-center gap-1">
224
- {isAIEditing ? (
225
- <div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full" />
226
- ) : (
227
- <span className="text-[10px]">✨</span>
228
- )}
229
- <span className="text-[11px] font-medium">AI</span>
230
- </div>
231
- </IconButton>
232
- {showAIMenu && (
233
- <div className="absolute top-full left-0 mt-1 bg-white border rounded shadow-lg z-50 min-w-[160px]">
234
- <button onClick={() => handleAIEdit('refine')} className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100">✨ Refine text</button>
235
- <button onClick={() => handleAIEdit('change')} className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100">🔄 Change content</button>
236
- <button onClick={() => handleAIEdit('expand')} className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100">📝 Make it longer</button>
237
- <button onClick={() => handleAIEdit('dialog')} className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 border-t">💬 Open AI Chat</button>
238
- </div>
239
- )}
240
- </div>
241
-
242
- {/* Export */}
243
- <div className="relative">
244
- <IconButton
245
- title="Export"
246
- onClick={() => setShowExportMenu(!showExportMenu)}
247
- active={showExportMenu}
248
- >
249
- <div className="flex items-center gap-1">
250
- <Printer size={14} />
251
- <span className="text-[11px] font-medium">Export</span>
252
- </div>
253
- </IconButton>
254
- {showExportMenu && (
255
- <div className="absolute top-full left-0 mt-1 bg-white border rounded shadow-lg z-50 min-w-[120px]">
256
- <button onClick={() => { exportToPDF(); setShowExportMenu(false); }} className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100">PDF Document</button>
257
- <button onClick={() => { exportToPPTX(); setShowExportMenu(false); }} className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100">PowerPoint (.pptx)</button>
258
- </div>
259
- )}
260
- </div>
261
-
262
- <Separator />
263
-
264
- <IconButton
265
- title="Align"
266
- disabled={!isTextSelected}
267
- onClick={() => {
268
- const nextAlign = textEl?.align === 'left' ? 'center' : textEl?.align === 'center' ? 'right' : 'left';
269
- selectedId && updateElement(selectedId, { align: nextAlign });
270
- }}
271
- >
272
- {textEl?.align === 'center' ? <AlignCenter size={16} /> : textEl?.align === 'right' ? <AlignRight size={16} /> : <AlignLeft size={16} />}
273
- </IconButton>
274
-
275
- <IconButton title="Line & paragraph spacing" disabled={!isTextSelected}>
276
- <MoreHorizontal size={16} />
277
- </IconButton>
278
-
279
- <IconButton title="Checklist" disabled={!isTextSelected}>
280
- <CheckSquare size={16} />
281
- </IconButton>
282
-
283
- <IconButton title="Bulleted list" disabled={!isTextSelected}>
284
- <List size={16} />
285
- </IconButton>
286
-
287
- <IconButton title="Numbered list" disabled={!isTextSelected}>
288
- <ListOrdered size={16} />
289
- </IconButton>
290
-
291
- <IconButton title="Decrease indent" disabled={!isTextSelected}>
292
- <Outdent size={16} />
293
- </IconButton>
294
-
295
- <IconButton title="Increase indent" disabled={!isTextSelected}>
296
- <Indent size={16} />
297
- </IconButton>
298
-
299
- <IconButton title="Clear formatting" disabled={!isTextSelected}>
300
- <div className="relative">
301
- <Type size={14} />
302
- <div className="absolute -bottom-1 -right-1 text-[10px]">\</div>
303
- </div>
304
- </IconButton>
305
-
306
- <Separator />
307
-
308
- <button className="px-2 py-1 text-[13px] hover:bg-[#f1f3f4] rounded-[4px] text-[#444746]">Background</button>
309
- <button className="px-2 py-1 text-[13px] hover:bg-[#f1f3f4] rounded-[4px] text-[#444746]">Layout</button>
310
-
311
- <div className="relative">
312
- <button
313
- onClick={() => setShowThemeMenu(!showThemeMenu)}
314
- className={`px-2 py-1 text-[13px] hover:bg-[#f1f3f4] rounded-[4px] text-[#444746] ${showThemeMenu ? 'bg-[#e8f0fe] text-[#1a73e8]' : ''}`}
315
- >
316
- Theme
317
- </button>
318
- {showThemeMenu && (
319
- <div className="absolute top-full left-0 mt-1 bg-white border rounded shadow-lg z-50 min-w-[200px] max-h-[400px] overflow-y-auto p-2">
320
- <div className="text-xs font-semibold text-gray-500 mb-1 px-2">Basic</div>
321
- <button onClick={() => { applyTheme('white'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
322
- <div className="w-4 h-4 border rounded bg-white"></div> White
323
- </button>
324
- <button onClick={() => { applyTheme('workshop'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
325
- <div className="w-4 h-4 border rounded bg-[#EFE8DF]"></div> Workshop
326
- </button>
327
-
328
- <div className="text-xs font-semibold text-gray-500 mt-2 mb-1 px-2">Gradients</div>
329
- <button onClick={() => { applyTheme('oceanBreeze'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
330
- <div className="w-4 h-4 border rounded" style={{ background: 'linear-gradient(135deg, #00B4DB 0%, #0083B0 100%)' }}></div> Ocean Breeze
331
- </button>
332
- <button onClick={() => { applyTheme('sunsetGlow'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
333
- <div className="w-4 h-4 border rounded" style={{ background: 'linear-gradient(135deg, #FF512F 0%, #DD2476 100%)' }}></div> Sunset Glow
334
- </button>
335
- <button onClick={() => { applyTheme('forestMist'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
336
- <div className="w-4 h-4 border rounded" style={{ background: 'linear-gradient(135deg, #134E5E 0%, #71B280 100%)' }}></div> Forest Mist
337
- </button>
338
- <button onClick={() => { applyTheme('midnightBlue'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
339
- <div className="w-4 h-4 border rounded" style={{ background: 'linear-gradient(to bottom, #0f2027, #203a43, #2c5364)' }}></div> Midnight Blue
340
- </button>
341
-
342
- <div className="text-xs font-semibold text-gray-500 mt-2 mb-1 px-2">Patterns</div>
343
- <button onClick={() => { applyTheme('geometricDark'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
344
- <div className="w-4 h-4 border rounded bg-[#232526]"></div> Geometric Dark
345
- </button>
346
- <button onClick={() => { applyTheme('stripedProfessional'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
347
- <div className="w-4 h-4 border rounded bg-white"></div> Striped Professional
348
- </button>
349
- <button onClick={() => { applyTheme('dotGrid'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
350
- <div className="w-4 h-4 border rounded bg-[#f0f2f5]"></div> Dot Grid
351
- </button>
352
-
353
- <div className="text-xs font-semibold text-gray-500 mt-2 mb-1 px-2">Images</div>
354
- <button onClick={() => { applyTheme('corporateCity'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
355
- <div className="w-4 h-4 border rounded bg-gray-800"></div> Corporate City
356
- </button>
357
- <button onClick={() => { applyTheme('techInnovation'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
358
- <div className="w-4 h-4 border rounded bg-blue-900"></div> Tech Innovation
359
- </button>
360
- <button onClick={() => { applyTheme('natureSerene'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
361
- <div className="w-4 h-4 border rounded bg-green-700"></div> Nature Serene
362
- </button>
363
- <button onClick={() => { applyTheme('minimalistConcrete'); setShowThemeMenu(false); }} className="w-full px-2 py-1.5 text-left text-sm hover:bg-gray-100 rounded flex items-center gap-2">
364
- <div className="w-4 h-4 border rounded bg-gray-200"></div> Minimalist Concrete
365
- </button>
366
- </div>
367
- )}
368
- </div>
369
- <button className="px-2 py-1 text-[13px] hover:bg-[#f1f3f4] rounded-[4px] text-[#444746]">Transition</button>
370
-
371
- </div>
372
- );
373
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/editor/SlideThumbnailPanel.tsx DELETED
@@ -1,77 +0,0 @@
1
- import { Trash2 } from 'lucide-react';
2
- import { renderSlide } from '@/components/slides/SlideFactory';
3
-
4
- type SlideThumbnailPanelProps = {
5
- slides: any[];
6
- currentSlideIndex: number;
7
- setCurrentSlideIndex: (index: number) => void;
8
- handleDeleteSlide: (index: number) => void;
9
- theme: any;
10
- }
11
-
12
- export function SlideThumbnailPanel(props: SlideThumbnailPanelProps) {
13
- const { slides, currentSlideIndex, setCurrentSlideIndex, handleDeleteSlide, theme } = props;
14
-
15
- return (
16
- <div className="w-[260px] bg-slate-900/70 text-white border-r border-white/10 flex flex-col shrink-0 overflow-y-auto scrollbar-hide">
17
- <div className="p-3 border-b border-white/10 bg-white/[0.04]">
18
- <h3 className="font-medium text-sm flex items-center justify-between">
19
- <span>Slides</span>
20
- <span className="text-xs text-white/60">{slides.length} total</span>
21
- </h3>
22
- </div>
23
- <div className="flex-1 overflow-y-auto p-1.5">
24
- <div className="space-y-2">
25
- {slides.map((slide, index) => (
26
- <div
27
- key={slide.id}
28
- className={`relative group cursor-pointer rounded-lg overflow-hidden transition-all ${index === currentSlideIndex
29
- ? 'ring-2 ring-emerald-400 bg-emerald-400/10 border-emerald-400/50 scale-105'
30
- : 'hover:ring-1 hover:ring-white/30 bg-white/[0.02] hover:bg-white/[0.05]'
31
- } shadow-lg border border-white/10`}
32
- onClick={() => setCurrentSlideIndex(index)}
33
- >
34
- <div className="aspect-video p-1.5">
35
- <div className="w-full h-full overflow-hidden">
36
- <div
37
- key={`${slide.id}-${theme}`}
38
- className="origin-top-left transform"
39
- style={{ width: 800, height: 450, transform: 'scale(0.25)' }}
40
- >
41
- {renderSlide(slide, theme)}
42
- </div>
43
- </div>
44
- </div>
45
-
46
- {/* Slide Number */}
47
- <div className="absolute top-1 left-1 bg-slate-900/80 text-white text-xs px-1.5 py-0.5 rounded">
48
- {index + 1}
49
- </div>
50
-
51
- {/* Delete Button */}
52
- {slides.length > 1 && (
53
- <button
54
- onClick={(e) => {
55
- e.stopPropagation();
56
- handleDeleteSlide(index);
57
- }}
58
- className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity p-1 bg-red-500 text-white rounded hover:bg-red-600"
59
- title="Delete slide"
60
- >
61
- <Trash2 size={12} />
62
- </button>
63
- )}
64
-
65
- {/* Slide Title */}
66
- <div className="px-2 py-1 bg-white/[0.04] border-t border-white/10">
67
- <p className="text-xs truncate">
68
- {slide.title || `Slide ${index + 1}`}
69
- </p>
70
- </div>
71
- </div>
72
- ))}
73
- </div>
74
- </div>
75
- </div>
76
- )
77
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/home/HomePage.tsx CHANGED
@@ -14,6 +14,9 @@ export default function HomePage() {
14
  const [isGenerating, setIsGenerating] = useState(false);
15
  const [isSelectOpen, setIsSelectOpen] = useState(false);
16
  const [user, setUser] = useState<{ name: string; avatarUrl?: string } | null>(null);
 
 
 
17
  const textareaRef = useRef<HTMLTextAreaElement>(null);
18
  const selectRef = useRef<HTMLDivElement>(null);
19
  const { theme, setTheme } = useTheme();
@@ -21,6 +24,10 @@ export default function HomePage() {
21
 
22
  useEffect(() => {
23
  setMounted(true);
 
 
 
 
24
 
25
  const savedTemplate = normalizeTemplateId(localStorage.getItem('ppt_theme'));
26
  if (savedTemplate) {
@@ -28,31 +35,34 @@ export default function HomePage() {
28
  }
29
 
30
  const initializeAuth = async () => {
31
- // Check stored auth
32
- const stored = localStorage.getItem('hf_oauth');
33
- if (stored) {
34
- try {
35
- const parsed = JSON.parse(stored);
36
- const expiresAt = parsed.accessTokenExpiresAt ? new Date(parsed.accessTokenExpiresAt) : null;
 
37
 
38
- if (expiresAt && expiresAt > new Date() && parsed.accessToken) {
39
- setUser({
40
- name: parsed.userInfo.name || parsed.userInfo.preferred_username,
41
- avatarUrl: parsed.userInfo.avatarUrl || parsed.userInfo.picture || '',
42
- });
43
- localStorage.setItem('hf_api_key', parsed.accessToken);
44
- } else {
 
 
 
 
 
 
45
  localStorage.removeItem('hf_oauth');
46
  localStorage.removeItem('hf_api_key');
 
47
  }
48
- } catch {
49
- localStorage.removeItem('hf_oauth');
50
- localStorage.removeItem('hf_api_key');
51
  }
52
- }
53
 
54
- // Handle redirect
55
- try {
56
  const result = await oauthHandleRedirectIfPresent();
57
  if (result) {
58
  const ui = result.userInfo as any;
@@ -69,11 +79,14 @@ export default function HomePage() {
69
  localStorage.setItem('hf_oauth', JSON.stringify(userData));
70
  localStorage.setItem('hf_api_key', result.accessToken);
71
  setUser({ name: userData.userInfo.name, avatarUrl: userData.userInfo.avatarUrl });
 
72
  // Clean URL
73
  window.history.replaceState({}, document.title, window.location.pathname);
74
  }
75
  } catch (err) {
76
  console.error('OAuth error:', err);
 
 
77
  }
78
  };
79
 
@@ -93,7 +106,17 @@ export default function HomePage() {
93
  scopes: 'openid profile inference-api',
94
  redirectUrl: `${window.location.origin}/`,
95
  });
96
- window.location.href = loginUrl + '&prompt=consent';
 
 
 
 
 
 
 
 
 
 
97
  } catch (err) {
98
  console.error('Sign in error:', err);
99
  }
@@ -102,18 +125,29 @@ export default function HomePage() {
102
  const handleSignOut = () => {
103
  localStorage.removeItem('hf_oauth');
104
  localStorage.removeItem('hf_api_key');
 
105
  setUser(null);
106
  };
107
 
108
  const router = useRouter();
109
 
110
  const handleSubmit = () => {
111
- if (!prompt.trim()) return;
 
 
 
 
 
 
 
 
 
112
  setIsGenerating(true);
113
 
114
- sessionStorage.setItem('generationPrompt', prompt);
115
  sessionStorage.setItem('generationModel', LLAMA_PRESENTATION_MODEL);
116
  sessionStorage.setItem('isGenerating', 'true');
 
117
  localStorage.setItem('ppt_theme', template);
118
 
119
  router.push('/editor');
@@ -150,9 +184,9 @@ export default function HomePage() {
150
  return (
151
  <div className="min-h-screen flex flex-col bg-white dark:bg-[#09090b] selection:bg-zinc-200 dark:selection:bg-zinc-800 transition-colors duration-300 font-sans">
152
  {/* Navbar */}
153
- <nav className="flex items-center justify-between px-6 py-6">
154
  <div className="flex items-center gap-2 text-zinc-950 dark:text-zinc-50">
155
- <span className="text-xl font-semibold tracking-tight">Slide.ai</span>
156
  </div>
157
 
158
  <div className="flex items-center gap-6">
@@ -191,10 +225,10 @@ export default function HomePage() {
191
  ) : (
192
  <button
193
  onClick={handleSignIn}
194
- className="flex items-center gap-2 text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-50 transition-all text-sm font-medium"
195
  >
196
  <LogIn className="w-4 h-4" />
197
- <span>Log in with Huggingface</span>
198
  </button>
199
  )}
200
  </div>
@@ -216,7 +250,12 @@ export default function HomePage() {
216
  <textarea
217
  ref={textareaRef}
218
  value={prompt}
219
- onChange={(e) => setPrompt(e.target.value)}
 
 
 
 
 
220
  onKeyDown={handleKeyDown}
221
  placeholder="How can I help you today?"
222
  className="w-full min-h-[100px] bg-transparent text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-500 dark:placeholder:text-zinc-500 text-lg outline-none resize-none px-4 py-2"
@@ -257,22 +296,20 @@ export default function HomePage() {
257
  </div>
258
  <button
259
  onClick={handleSubmit}
260
- disabled={!prompt.trim() || isGenerating}
261
  className="flex h-9 w-9 items-center justify-center rounded-xl bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700 hover:text-zinc-900 dark:hover:text-zinc-50 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
262
  >
263
  <ArrowUp className="h-5 w-5" />
264
  </button>
265
  </div>
266
  </div>
 
 
 
 
 
267
  </div>
268
  </main>
269
-
270
- {/* Brand Icon (Fixed bottom left) */}
271
- <div className="fixed bottom-6 left-6">
272
- <div className="w-8 h-8 rounded-full bg-zinc-950 dark:bg-white flex items-center justify-center shadow-lg border border-white/5">
273
- <span className="text-white dark:text-black font-bold text-xs italic">N</span>
274
- </div>
275
- </div>
276
  </div>
277
  );
278
  }
 
14
  const [isGenerating, setIsGenerating] = useState(false);
15
  const [isSelectOpen, setIsSelectOpen] = useState(false);
16
  const [user, setUser] = useState<{ name: string; avatarUrl?: string } | null>(null);
17
+ const [submitError, setSubmitError] = useState<string | null>(null);
18
+ const [isAuthReady, setIsAuthReady] = useState(false);
19
+ const [isHuggingFaceSpace, setIsHuggingFaceSpace] = useState(false);
20
  const textareaRef = useRef<HTMLTextAreaElement>(null);
21
  const selectRef = useRef<HTMLDivElement>(null);
22
  const { theme, setTheme } = useTheme();
 
24
 
25
  useEffect(() => {
26
  setMounted(true);
27
+ const hostname = window.location.hostname;
28
+ const isEmbedded = window.self !== window.top;
29
+ const isSpaceHost = hostname.endsWith('.hf.space') || hostname === 'huggingface.co';
30
+ setIsHuggingFaceSpace(isSpaceHost || isEmbedded);
31
 
32
  const savedTemplate = normalizeTemplateId(localStorage.getItem('ppt_theme'));
33
  if (savedTemplate) {
 
35
  }
36
 
37
  const initializeAuth = async () => {
38
+ try {
39
+ // Check stored auth
40
+ const stored = localStorage.getItem('hf_oauth');
41
+ if (stored) {
42
+ try {
43
+ const parsed = JSON.parse(stored);
44
+ const expiresAt = parsed.accessTokenExpiresAt ? new Date(parsed.accessTokenExpiresAt) : null;
45
 
46
+ if (expiresAt && expiresAt > new Date() && parsed.accessToken) {
47
+ setUser({
48
+ name: parsed.userInfo.name || parsed.userInfo.preferred_username,
49
+ avatarUrl: parsed.userInfo.avatarUrl || parsed.userInfo.picture || '',
50
+ });
51
+ localStorage.setItem('hf_api_key', parsed.accessToken);
52
+ setSubmitError(null);
53
+ } else {
54
+ localStorage.removeItem('hf_oauth');
55
+ localStorage.removeItem('hf_api_key');
56
+ sessionStorage.removeItem('editorAccess');
57
+ }
58
+ } catch {
59
  localStorage.removeItem('hf_oauth');
60
  localStorage.removeItem('hf_api_key');
61
+ sessionStorage.removeItem('editorAccess');
62
  }
 
 
 
63
  }
 
64
 
65
+ // Handle redirect
 
66
  const result = await oauthHandleRedirectIfPresent();
67
  if (result) {
68
  const ui = result.userInfo as any;
 
79
  localStorage.setItem('hf_oauth', JSON.stringify(userData));
80
  localStorage.setItem('hf_api_key', result.accessToken);
81
  setUser({ name: userData.userInfo.name, avatarUrl: userData.userInfo.avatarUrl });
82
+ setSubmitError(null);
83
  // Clean URL
84
  window.history.replaceState({}, document.title, window.location.pathname);
85
  }
86
  } catch (err) {
87
  console.error('OAuth error:', err);
88
+ } finally {
89
+ setIsAuthReady(true);
90
  }
91
  };
92
 
 
106
  scopes: 'openid profile inference-api',
107
  redirectUrl: `${window.location.origin}/`,
108
  });
109
+ const destination = `${loginUrl}&prompt=consent`;
110
+ if (isHuggingFaceSpace) {
111
+ const topNavigation = window.open(destination, '_top');
112
+
113
+ if (!topNavigation) {
114
+ window.open(destination, '_blank', 'noopener,noreferrer');
115
+ }
116
+ return;
117
+ }
118
+
119
+ window.location.assign(destination);
120
  } catch (err) {
121
  console.error('Sign in error:', err);
122
  }
 
125
  const handleSignOut = () => {
126
  localStorage.removeItem('hf_oauth');
127
  localStorage.removeItem('hf_api_key');
128
+ sessionStorage.removeItem('editorAccess');
129
  setUser(null);
130
  };
131
 
132
  const router = useRouter();
133
 
134
  const handleSubmit = () => {
135
+ const trimmedPrompt = prompt.trim();
136
+ if (!trimmedPrompt) return;
137
+ if (!isAuthReady) return;
138
+
139
+ if (!user) {
140
+ setSubmitError('Please log in with Hugging Face before generating a presentation.');
141
+ return;
142
+ }
143
+
144
+ setSubmitError(null);
145
  setIsGenerating(true);
146
 
147
+ sessionStorage.setItem('generationPrompt', trimmedPrompt);
148
  sessionStorage.setItem('generationModel', LLAMA_PRESENTATION_MODEL);
149
  sessionStorage.setItem('isGenerating', 'true');
150
+ sessionStorage.setItem('editorAccess', 'true');
151
  localStorage.setItem('ppt_theme', template);
152
 
153
  router.push('/editor');
 
184
  return (
185
  <div className="min-h-screen flex flex-col bg-white dark:bg-[#09090b] selection:bg-zinc-200 dark:selection:bg-zinc-800 transition-colors duration-300 font-sans">
186
  {/* Navbar */}
187
+ <nav className={`relative z-20 flex items-center justify-between px-6 ${isHuggingFaceSpace ? 'pb-6 pt-16' : 'py-6'}`}>
188
  <div className="flex items-center gap-2 text-zinc-950 dark:text-zinc-50">
189
+ <span className="text-xl font-semibold tracking-tight">Powerpoint.ai</span>
190
  </div>
191
 
192
  <div className="flex items-center gap-6">
 
225
  ) : (
226
  <button
227
  onClick={handleSignIn}
228
+ className="pointer-events-auto flex items-center gap-2 text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-50 transition-all text-sm font-medium"
229
  >
230
  <LogIn className="w-4 h-4" />
231
+ <span>Log in with Hugging Face</span>
232
  </button>
233
  )}
234
  </div>
 
250
  <textarea
251
  ref={textareaRef}
252
  value={prompt}
253
+ onChange={(e) => {
254
+ setPrompt(e.target.value);
255
+ if (submitError) {
256
+ setSubmitError(null);
257
+ }
258
+ }}
259
  onKeyDown={handleKeyDown}
260
  placeholder="How can I help you today?"
261
  className="w-full min-h-[100px] bg-transparent text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-500 dark:placeholder:text-zinc-500 text-lg outline-none resize-none px-4 py-2"
 
296
  </div>
297
  <button
298
  onClick={handleSubmit}
299
+ disabled={!prompt.trim() || isGenerating || !isAuthReady}
300
  className="flex h-9 w-9 items-center justify-center rounded-xl bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700 hover:text-zinc-900 dark:hover:text-zinc-50 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
301
  >
302
  <ArrowUp className="h-5 w-5" />
303
  </button>
304
  </div>
305
  </div>
306
+ {submitError && (
307
+ <p className="mt-3 px-2 text-sm font-medium text-red-600 dark:text-red-400">
308
+ {submitError}
309
+ </p>
310
+ )}
311
  </div>
312
  </main>
 
 
 
 
 
 
 
313
  </div>
314
  );
315
  }
components/ui/button.tsx DELETED
@@ -1,59 +0,0 @@
1
- import * as React from "react"
2
- import { Slot } from "@radix-ui/react-slot"
3
- import { cva, type VariantProps } from "class-variance-authority"
4
-
5
- import { cn } from "@/lib/utils"
6
-
7
- const buttonVariants = cva(
8
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9
- {
10
- variants: {
11
- variant: {
12
- default:
13
- "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14
- destructive:
15
- "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16
- outline:
17
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18
- secondary:
19
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20
- ghost:
21
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22
- link: "text-primary underline-offset-4 hover:underline",
23
- },
24
- size: {
25
- default: "h-9 px-4 py-2 has-[>svg]:px-3",
26
- sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
- icon: "size-9",
29
- },
30
- },
31
- defaultVariants: {
32
- variant: "default",
33
- size: "default",
34
- },
35
- }
36
- )
37
-
38
- function Button({
39
- className,
40
- variant,
41
- size,
42
- asChild = false,
43
- ...props
44
- }: React.ComponentProps<"button"> &
45
- VariantProps<typeof buttonVariants> & {
46
- asChild?: boolean
47
- }) {
48
- const Comp = asChild ? Slot : "button"
49
-
50
- return (
51
- <Comp
52
- data-slot="button"
53
- className={cn(buttonVariants({ variant, size, className }))}
54
- {...props}
55
- />
56
- )
57
- }
58
-
59
- export { Button, buttonVariants }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/ui/dialog.tsx DELETED
@@ -1,143 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import * as DialogPrimitive from "@radix-ui/react-dialog"
5
- import { XIcon } from "lucide-react"
6
-
7
- import { cn } from "@/lib/utils"
8
-
9
- function Dialog({
10
- ...props
11
- }: React.ComponentProps<typeof DialogPrimitive.Root>) {
12
- return <DialogPrimitive.Root data-slot="dialog" {...props} />
13
- }
14
-
15
- function DialogTrigger({
16
- ...props
17
- }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
18
- return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
19
- }
20
-
21
- function DialogPortal({
22
- ...props
23
- }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
24
- return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
25
- }
26
-
27
- function DialogClose({
28
- ...props
29
- }: React.ComponentProps<typeof DialogPrimitive.Close>) {
30
- return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
31
- }
32
-
33
- function DialogOverlay({
34
- className,
35
- ...props
36
- }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
37
- return (
38
- <DialogPrimitive.Overlay
39
- data-slot="dialog-overlay"
40
- className={cn(
41
- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
42
- className
43
- )}
44
- {...props}
45
- />
46
- )
47
- }
48
-
49
- function DialogContent({
50
- className,
51
- children,
52
- showCloseButton = true,
53
- ...props
54
- }: React.ComponentProps<typeof DialogPrimitive.Content> & {
55
- showCloseButton?: boolean
56
- }) {
57
- return (
58
- <DialogPortal data-slot="dialog-portal">
59
- <DialogOverlay />
60
- <DialogPrimitive.Content
61
- data-slot="dialog-content"
62
- className={cn(
63
- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
64
- className
65
- )}
66
- {...props}
67
- >
68
- {children}
69
- {showCloseButton && (
70
- <DialogPrimitive.Close
71
- data-slot="dialog-close"
72
- className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
73
- >
74
- <XIcon />
75
- <span className="sr-only">Close</span>
76
- </DialogPrimitive.Close>
77
- )}
78
- </DialogPrimitive.Content>
79
- </DialogPortal>
80
- )
81
- }
82
-
83
- function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
84
- return (
85
- <div
86
- data-slot="dialog-header"
87
- className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
88
- {...props}
89
- />
90
- )
91
- }
92
-
93
- function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
94
- return (
95
- <div
96
- data-slot="dialog-footer"
97
- className={cn(
98
- "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
99
- className
100
- )}
101
- {...props}
102
- />
103
- )
104
- }
105
-
106
- function DialogTitle({
107
- className,
108
- ...props
109
- }: React.ComponentProps<typeof DialogPrimitive.Title>) {
110
- return (
111
- <DialogPrimitive.Title
112
- data-slot="dialog-title"
113
- className={cn("text-lg leading-none font-semibold", className)}
114
- {...props}
115
- />
116
- )
117
- }
118
-
119
- function DialogDescription({
120
- className,
121
- ...props
122
- }: React.ComponentProps<typeof DialogPrimitive.Description>) {
123
- return (
124
- <DialogPrimitive.Description
125
- data-slot="dialog-description"
126
- className={cn("text-muted-foreground text-sm", className)}
127
- {...props}
128
- />
129
- )
130
- }
131
-
132
- export {
133
- Dialog,
134
- DialogClose,
135
- DialogContent,
136
- DialogDescription,
137
- DialogFooter,
138
- DialogHeader,
139
- DialogOverlay,
140
- DialogPortal,
141
- DialogTitle,
142
- DialogTrigger,
143
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/ui/dropdown-menu.tsx DELETED
@@ -1,257 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5
- import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6
-
7
- import { cn } from "@/lib/utils"
8
-
9
- function DropdownMenu({
10
- ...props
11
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
12
- return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
13
- }
14
-
15
- function DropdownMenuPortal({
16
- ...props
17
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
18
- return (
19
- <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
20
- )
21
- }
22
-
23
- function DropdownMenuTrigger({
24
- ...props
25
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
26
- return (
27
- <DropdownMenuPrimitive.Trigger
28
- data-slot="dropdown-menu-trigger"
29
- {...props}
30
- />
31
- )
32
- }
33
-
34
- function DropdownMenuContent({
35
- className,
36
- sideOffset = 4,
37
- ...props
38
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
39
- return (
40
- <DropdownMenuPrimitive.Portal>
41
- <DropdownMenuPrimitive.Content
42
- data-slot="dropdown-menu-content"
43
- sideOffset={sideOffset}
44
- className={cn(
45
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
46
- className
47
- )}
48
- {...props}
49
- />
50
- </DropdownMenuPrimitive.Portal>
51
- )
52
- }
53
-
54
- function DropdownMenuGroup({
55
- ...props
56
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
57
- return (
58
- <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
59
- )
60
- }
61
-
62
- function DropdownMenuItem({
63
- className,
64
- inset,
65
- variant = "default",
66
- ...props
67
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
68
- inset?: boolean
69
- variant?: "default" | "destructive"
70
- }) {
71
- return (
72
- <DropdownMenuPrimitive.Item
73
- data-slot="dropdown-menu-item"
74
- data-inset={inset}
75
- data-variant={variant}
76
- className={cn(
77
- "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
78
- className
79
- )}
80
- {...props}
81
- />
82
- )
83
- }
84
-
85
- function DropdownMenuCheckboxItem({
86
- className,
87
- children,
88
- checked,
89
- ...props
90
- }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
91
- return (
92
- <DropdownMenuPrimitive.CheckboxItem
93
- data-slot="dropdown-menu-checkbox-item"
94
- className={cn(
95
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
96
- className
97
- )}
98
- checked={checked}
99
- {...props}
100
- >
101
- <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
102
- <DropdownMenuPrimitive.ItemIndicator>
103
- <CheckIcon className="size-4" />
104
- </DropdownMenuPrimitive.ItemIndicator>
105
- </span>
106
- {children}
107
- </DropdownMenuPrimitive.CheckboxItem>
108
- )
109
- }
110
-
111
- function DropdownMenuRadioGroup({
112
- ...props
113
- }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
114
- return (
115
- <DropdownMenuPrimitive.RadioGroup
116
- data-slot="dropdown-menu-radio-group"
117
- {...props}
118
- />
119
- )
120
- }
121
-
122
- function DropdownMenuRadioItem({
123
- className,
124
- children,
125
- ...props
126
- }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
127
- return (
128
- <DropdownMenuPrimitive.RadioItem
129
- data-slot="dropdown-menu-radio-item"
130
- className={cn(
131
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
132
- className
133
- )}
134
- {...props}
135
- >
136
- <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
137
- <DropdownMenuPrimitive.ItemIndicator>
138
- <CircleIcon className="size-2 fill-current" />
139
- </DropdownMenuPrimitive.ItemIndicator>
140
- </span>
141
- {children}
142
- </DropdownMenuPrimitive.RadioItem>
143
- )
144
- }
145
-
146
- function DropdownMenuLabel({
147
- className,
148
- inset,
149
- ...props
150
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
151
- inset?: boolean
152
- }) {
153
- return (
154
- <DropdownMenuPrimitive.Label
155
- data-slot="dropdown-menu-label"
156
- data-inset={inset}
157
- className={cn(
158
- "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
159
- className
160
- )}
161
- {...props}
162
- />
163
- )
164
- }
165
-
166
- function DropdownMenuSeparator({
167
- className,
168
- ...props
169
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
170
- return (
171
- <DropdownMenuPrimitive.Separator
172
- data-slot="dropdown-menu-separator"
173
- className={cn("bg-border -mx-1 my-1 h-px", className)}
174
- {...props}
175
- />
176
- )
177
- }
178
-
179
- function DropdownMenuShortcut({
180
- className,
181
- ...props
182
- }: React.ComponentProps<"span">) {
183
- return (
184
- <span
185
- data-slot="dropdown-menu-shortcut"
186
- className={cn(
187
- "text-muted-foreground ml-auto text-xs tracking-widest",
188
- className
189
- )}
190
- {...props}
191
- />
192
- )
193
- }
194
-
195
- function DropdownMenuSub({
196
- ...props
197
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
198
- return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
199
- }
200
-
201
- function DropdownMenuSubTrigger({
202
- className,
203
- inset,
204
- children,
205
- ...props
206
- }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
207
- inset?: boolean
208
- }) {
209
- return (
210
- <DropdownMenuPrimitive.SubTrigger
211
- data-slot="dropdown-menu-sub-trigger"
212
- data-inset={inset}
213
- className={cn(
214
- "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
215
- className
216
- )}
217
- {...props}
218
- >
219
- {children}
220
- <ChevronRightIcon className="ml-auto size-4" />
221
- </DropdownMenuPrimitive.SubTrigger>
222
- )
223
- }
224
-
225
- function DropdownMenuSubContent({
226
- className,
227
- ...props
228
- }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
229
- return (
230
- <DropdownMenuPrimitive.SubContent
231
- data-slot="dropdown-menu-sub-content"
232
- className={cn(
233
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
234
- className
235
- )}
236
- {...props}
237
- />
238
- )
239
- }
240
-
241
- export {
242
- DropdownMenu,
243
- DropdownMenuPortal,
244
- DropdownMenuTrigger,
245
- DropdownMenuContent,
246
- DropdownMenuGroup,
247
- DropdownMenuLabel,
248
- DropdownMenuItem,
249
- DropdownMenuCheckboxItem,
250
- DropdownMenuRadioGroup,
251
- DropdownMenuRadioItem,
252
- DropdownMenuSeparator,
253
- DropdownMenuShortcut,
254
- DropdownMenuSub,
255
- DropdownMenuSubTrigger,
256
- DropdownMenuSubContent,
257
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/ui/popover.tsx DELETED
@@ -1,48 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import * as PopoverPrimitive from "@radix-ui/react-popover"
5
-
6
- import { cn } from "@/lib/utils"
7
-
8
- function Popover({
9
- ...props
10
- }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
11
- return <PopoverPrimitive.Root data-slot="popover" {...props} />
12
- }
13
-
14
- function PopoverTrigger({
15
- ...props
16
- }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
17
- return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
18
- }
19
-
20
- function PopoverContent({
21
- className,
22
- align = "center",
23
- sideOffset = 4,
24
- ...props
25
- }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
26
- return (
27
- <PopoverPrimitive.Portal>
28
- <PopoverPrimitive.Content
29
- data-slot="popover-content"
30
- align={align}
31
- sideOffset={sideOffset}
32
- className={cn(
33
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
34
- className
35
- )}
36
- {...props}
37
- />
38
- </PopoverPrimitive.Portal>
39
- )
40
- }
41
-
42
- function PopoverAnchor({
43
- ...props
44
- }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
45
- return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
46
- }
47
-
48
- export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/ui/select.tsx DELETED
@@ -1,185 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import * as SelectPrimitive from "@radix-ui/react-select"
5
- import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6
-
7
- import { cn } from "@/lib/utils"
8
-
9
- function Select({
10
- ...props
11
- }: React.ComponentProps<typeof SelectPrimitive.Root>) {
12
- return <SelectPrimitive.Root data-slot="select" {...props} />
13
- }
14
-
15
- function SelectGroup({
16
- ...props
17
- }: React.ComponentProps<typeof SelectPrimitive.Group>) {
18
- return <SelectPrimitive.Group data-slot="select-group" {...props} />
19
- }
20
-
21
- function SelectValue({
22
- ...props
23
- }: React.ComponentProps<typeof SelectPrimitive.Value>) {
24
- return <SelectPrimitive.Value data-slot="select-value" {...props} />
25
- }
26
-
27
- function SelectTrigger({
28
- className,
29
- size = "default",
30
- children,
31
- ...props
32
- }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
33
- size?: "sm" | "default"
34
- }) {
35
- return (
36
- <SelectPrimitive.Trigger
37
- data-slot="select-trigger"
38
- data-size={size}
39
- className={cn(
40
- "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
41
- className
42
- )}
43
- {...props}
44
- >
45
- {children}
46
- <SelectPrimitive.Icon asChild>
47
- <ChevronDownIcon className="size-4 opacity-50" />
48
- </SelectPrimitive.Icon>
49
- </SelectPrimitive.Trigger>
50
- )
51
- }
52
-
53
- function SelectContent({
54
- className,
55
- children,
56
- position = "popper",
57
- ...props
58
- }: React.ComponentProps<typeof SelectPrimitive.Content>) {
59
- return (
60
- <SelectPrimitive.Portal>
61
- <SelectPrimitive.Content
62
- data-slot="select-content"
63
- className={cn(
64
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
65
- position === "popper" &&
66
- "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
67
- className
68
- )}
69
- position={position}
70
- {...props}
71
- >
72
- <SelectScrollUpButton />
73
- <SelectPrimitive.Viewport
74
- className={cn(
75
- "p-1",
76
- position === "popper" &&
77
- "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
78
- )}
79
- >
80
- {children}
81
- </SelectPrimitive.Viewport>
82
- <SelectScrollDownButton />
83
- </SelectPrimitive.Content>
84
- </SelectPrimitive.Portal>
85
- )
86
- }
87
-
88
- function SelectLabel({
89
- className,
90
- ...props
91
- }: React.ComponentProps<typeof SelectPrimitive.Label>) {
92
- return (
93
- <SelectPrimitive.Label
94
- data-slot="select-label"
95
- className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
96
- {...props}
97
- />
98
- )
99
- }
100
-
101
- function SelectItem({
102
- className,
103
- children,
104
- ...props
105
- }: React.ComponentProps<typeof SelectPrimitive.Item>) {
106
- return (
107
- <SelectPrimitive.Item
108
- data-slot="select-item"
109
- className={cn(
110
- "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
111
- className
112
- )}
113
- {...props}
114
- >
115
- <span className="absolute right-2 flex size-3.5 items-center justify-center">
116
- <SelectPrimitive.ItemIndicator>
117
- <CheckIcon className="size-4" />
118
- </SelectPrimitive.ItemIndicator>
119
- </span>
120
- <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
121
- </SelectPrimitive.Item>
122
- )
123
- }
124
-
125
- function SelectSeparator({
126
- className,
127
- ...props
128
- }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
129
- return (
130
- <SelectPrimitive.Separator
131
- data-slot="select-separator"
132
- className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
133
- {...props}
134
- />
135
- )
136
- }
137
-
138
- function SelectScrollUpButton({
139
- className,
140
- ...props
141
- }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
142
- return (
143
- <SelectPrimitive.ScrollUpButton
144
- data-slot="select-scroll-up-button"
145
- className={cn(
146
- "flex cursor-default items-center justify-center py-1",
147
- className
148
- )}
149
- {...props}
150
- >
151
- <ChevronUpIcon className="size-4" />
152
- </SelectPrimitive.ScrollUpButton>
153
- )
154
- }
155
-
156
- function SelectScrollDownButton({
157
- className,
158
- ...props
159
- }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
160
- return (
161
- <SelectPrimitive.ScrollDownButton
162
- data-slot="select-scroll-down-button"
163
- className={cn(
164
- "flex cursor-default items-center justify-center py-1",
165
- className
166
- )}
167
- {...props}
168
- >
169
- <ChevronDownIcon className="size-4" />
170
- </SelectPrimitive.ScrollDownButton>
171
- )
172
- }
173
-
174
- export {
175
- Select,
176
- SelectContent,
177
- SelectGroup,
178
- SelectItem,
179
- SelectLabel,
180
- SelectScrollDownButton,
181
- SelectScrollUpButton,
182
- SelectSeparator,
183
- SelectTrigger,
184
- SelectValue,
185
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/ui/textarea.tsx DELETED
@@ -1,18 +0,0 @@
1
- import * as React from "react"
2
-
3
- import { cn } from "@/lib/utils"
4
-
5
- function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
- return (
7
- <textarea
8
- data-slot="textarea"
9
- className={cn(
10
- "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
- className
12
- )}
13
- {...props}
14
- />
15
- )
16
- }
17
-
18
- export { Textarea }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
hooks/useEditableField.ts DELETED
@@ -1,56 +0,0 @@
1
- 'use client';
2
-
3
- import { useState, useCallback } from 'react';
4
-
5
- interface UseEditableFieldParams {
6
- slideId?: string;
7
- isEditable?: boolean;
8
- onFieldUpdate?: (slideId: string, field: string, value: string, index?: number) => void;
9
- }
10
-
11
- export function useEditableField({ slideId, isEditable, onFieldUpdate }: UseEditableFieldParams) {
12
- const [editingField, setEditingField] = useState<string | null>(null);
13
- const [tempValues, setTempValues] = useState<Record<string, string>>({});
14
-
15
- const startEditing = useCallback((field: string, currentValue: string) => {
16
- if (!isEditable) return;
17
- setEditingField(field);
18
- setTempValues(prev => ({ ...prev, [field]: currentValue }));
19
- }, [isEditable]);
20
-
21
- const updateTemp = useCallback((field: string, value: string) => {
22
- setTempValues(prev => ({ ...prev, [field]: value }));
23
- }, []);
24
-
25
- const commitEdit = useCallback((field: string, index?: number) => {
26
- const value = tempValues[field];
27
- if (value !== undefined && slideId && onFieldUpdate) {
28
- onFieldUpdate(slideId, field, value, index);
29
- }
30
- setEditingField(null);
31
- }, [tempValues, slideId, onFieldUpdate]);
32
-
33
- const cancelEdit = useCallback(() => {
34
- setEditingField(null);
35
- }, []);
36
-
37
- const handleKeyDown = useCallback((e: React.KeyboardEvent, field: string, index?: number) => {
38
- if (e.key === 'Enter') {
39
- e.preventDefault();
40
- commitEdit(field, index);
41
- }
42
- if (e.key === 'Escape') {
43
- cancelEdit();
44
- }
45
- }, [commitEdit, cancelEdit]);
46
-
47
- return {
48
- editingField,
49
- tempValues,
50
- startEditing,
51
- updateTemp,
52
- commitEdit,
53
- cancelEdit,
54
- handleKeyDown,
55
- };
56
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
hooks/useExport.ts CHANGED
@@ -1,123 +1,20 @@
1
- import jsPDF from 'jspdf';
2
  import type { SlideModel } from '@/lib/editor-types';
3
  import { themes } from '@/lib/editor-themes';
4
  import type { SlideSpec } from '@/data/templates';
5
  import { exportEditablePptx } from '@/lib/editable-pptx-export';
6
- import { captureElementAsPng } from '@/lib/capture-element';
7
 
8
  interface UseExportParams {
9
  slideRef: React.RefObject<HTMLDivElement | null>;
10
  slides: SlideModel[];
11
  slideSpecs: SlideSpec[];
12
  currentTheme: keyof typeof themes;
13
- currentSlideIndex: number;
14
- zoom: number;
15
- selectedId: string | null;
16
- isEditingTextId: string | null;
17
  presentationTitle: string;
18
- setCurrentSlideIndex: (i: number) => void;
19
- setZoom: (z: number) => void;
20
- setSelectedId: (id: string | null) => void;
21
- setIsEditingTextId: (id: string | null) => void;
22
- }
23
-
24
- function waitForPaint() {
25
- return new Promise<void>((resolve) => {
26
- requestAnimationFrame(() => {
27
- requestAnimationFrame(() => resolve());
28
- });
29
- });
30
- }
31
-
32
- async function waitForFonts() {
33
- if ('fonts' in document) {
34
- await (document as Document & { fonts: FontFaceSet }).fonts.ready;
35
- }
36
- }
37
-
38
- async function waitForImages(container: HTMLElement) {
39
- const images = Array.from(container.querySelectorAll('img'));
40
-
41
- await Promise.all(
42
- images.map(
43
- (image) =>
44
- new Promise<void>((resolve) => {
45
- if (image.complete) {
46
- resolve();
47
- return;
48
- }
49
-
50
- image.onload = () => resolve();
51
- image.onerror = () => resolve();
52
- })
53
- )
54
- );
55
  }
56
 
57
  export function useExport({
58
  slideRef, slides, slideSpecs, currentTheme,
59
- currentSlideIndex, zoom, selectedId, isEditingTextId,
60
- presentationTitle, setCurrentSlideIndex, setZoom,
61
- setSelectedId, setIsEditingTextId,
62
  }: UseExportParams) {
63
- const captureNode = async (node: HTMLElement) => captureElementAsPng(node, { scale: 2 });
64
-
65
- const captureEditorSlides = async (): Promise<string[]> => {
66
- if (!slideRef.current || slides.length === 0) return [];
67
-
68
- const originalSlideIndex = currentSlideIndex;
69
- const originalZoom = zoom;
70
- const originalSelectedId = selectedId;
71
- const originalEditingTextId = isEditingTextId;
72
-
73
- setSelectedId(null);
74
- setIsEditingTextId(null);
75
- setZoom(1);
76
- await waitForFonts();
77
- await new Promise((resolve) => setTimeout(resolve, 250));
78
-
79
- const images: string[] = [];
80
-
81
- for (let index = 0; index < slides.length; index++) {
82
- setCurrentSlideIndex(index);
83
- await new Promise((resolve) => setTimeout(resolve, 450));
84
-
85
- if (!slideRef.current) continue;
86
-
87
- await waitForFonts();
88
- await waitForImages(slideRef.current);
89
- await waitForPaint();
90
- images.push(await captureNode(slideRef.current));
91
- }
92
-
93
- setCurrentSlideIndex(originalSlideIndex);
94
- setZoom(originalZoom);
95
- setSelectedId(originalSelectedId);
96
- setIsEditingTextId(originalEditingTextId);
97
-
98
- return images;
99
- };
100
-
101
- const exportToPDF = async () => {
102
- if (!slideRef.current || slides.length === 0) return;
103
-
104
- try {
105
- const images = await captureEditorSlides();
106
- const pdf = new jsPDF({ orientation: 'landscape', unit: 'px', format: [800, 450] });
107
-
108
- images.forEach((imageData, index) => {
109
- if (index > 0) pdf.addPage();
110
- pdf.addImage(imageData, 'PNG', 0, 0, 800, 450);
111
- });
112
-
113
- const sanitizedTitle = presentationTitle.replace(/[^a-zA-Z0-9 -]/g, '').trim() || 'presentation';
114
- pdf.save(`${sanitizedTitle}.pdf`);
115
- } catch (error) {
116
- console.error('Error exporting to PDF:', error);
117
- alert('Failed to export to PDF. Please try again.');
118
- }
119
- };
120
-
121
  const exportToPPTX = async () => {
122
  if (!slideRef.current || slides.length === 0) return;
123
 
@@ -134,5 +31,5 @@ export function useExport({
134
  }
135
  };
136
 
137
- return { exportToPDF, exportToPPTX };
138
  }
 
 
1
  import type { SlideModel } from '@/lib/editor-types';
2
  import { themes } from '@/lib/editor-themes';
3
  import type { SlideSpec } from '@/data/templates';
4
  import { exportEditablePptx } from '@/lib/editable-pptx-export';
 
5
 
6
  interface UseExportParams {
7
  slideRef: React.RefObject<HTMLDivElement | null>;
8
  slides: SlideModel[];
9
  slideSpecs: SlideSpec[];
10
  currentTheme: keyof typeof themes;
 
 
 
 
11
  presentationTitle: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
13
 
14
  export function useExport({
15
  slideRef, slides, slideSpecs, currentTheme,
16
+ presentationTitle,
 
 
17
  }: UseExportParams) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  const exportToPPTX = async () => {
19
  if (!slideRef.current || slides.length === 0) return;
20
 
 
31
  }
32
  };
33
 
34
+ return { exportToPPTX };
35
  }
lib/imageService.ts DELETED
@@ -1,127 +0,0 @@
1
- /**
2
- * Image Service
3
- * Handles fetching images from Unsplash API for slide generation
4
- */
5
-
6
- import { searchUnsplash } from './unsplash';
7
-
8
- interface UnsplashImage {
9
- id: string;
10
- urls: {
11
- regular: string;
12
- small: string;
13
- full: string;
14
- };
15
- alt_description: string | null;
16
- user: {
17
- name: string;
18
- username: string;
19
- };
20
- }
21
-
22
- interface ImageSearchResult {
23
- imageUrl: string;
24
- attribution: {
25
- photographer: string;
26
- photographerUrl: string;
27
- } | null;
28
- }
29
-
30
- /**
31
- * Fetch a relevant image from Unsplash based on keywords
32
- * @param keywords - Search keywords (e.g., "technology innovation futuristic")
33
- * @returns Image URL and attribution info
34
- */
35
- export async function fetchImageForSlide(keywords: string): Promise<ImageSearchResult> {
36
- // If no keywords provided, return default placeholder
37
- if (!keywords || keywords.trim() === '') {
38
- return {
39
- imageUrl: 'https://images.pexels.com/photos/18803582/pexels-photo-18803582.jpeg?auto=compress&cs=tinysrgb&w=800',
40
- attribution: null
41
- };
42
- }
43
-
44
- try {
45
- const results = await searchUnsplash(keywords, 1);
46
-
47
- if (results.length > 0) {
48
- const image: UnsplashImage = results[0];
49
- return {
50
- imageUrl: image.urls.regular,
51
- attribution: {
52
- photographer: image.user.name,
53
- photographerUrl: `https://unsplash.com/@${image.user.username}?utm_source=presentation-generator&utm_medium=referral`
54
- }
55
- };
56
- }
57
-
58
- // No results found, use fallback
59
- return getFallbackImage();
60
-
61
- } catch (error) {
62
- console.error('Error fetching image:', error);
63
- return getFallbackImage();
64
- }
65
- }
66
-
67
- /**
68
- * Fetch multiple images for multiple slides
69
- * @param slidesWithKeywords - Array of objects with slide info and keywords
70
- * @returns Array of image URLs in same order
71
- */
72
- export async function fetchImagesForSlides(
73
- slidesWithKeywords: Array<{ index: number; keywords: string }>
74
- ): Promise<Array<{ index: number; result: ImageSearchResult }>> {
75
- // Fetch all images in parallel for better performance
76
- const promises = slidesWithKeywords.map(async (slide) => {
77
- const result = await fetchImageForSlide(slide.keywords);
78
- return {
79
- index: slide.index,
80
- result
81
- };
82
- });
83
-
84
- return Promise.all(promises);
85
- }
86
-
87
- /**
88
- * Get a high-quality fallback image
89
- */
90
- function getFallbackImage(): ImageSearchResult {
91
- // Rotate through different fallback images for variety
92
- const fallbackImages = [
93
- 'https://images.pexels.com/photos/18803582/pexels-photo-18803582.jpeg?auto=compress&cs=tinysrgb&w=800',
94
- 'https://images.unsplash.com/photo-1557683316-973673baf926?w=800&q=80',
95
- 'https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=800&q=80',
96
- 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=800&q=80',
97
- ];
98
-
99
- const randomImage = fallbackImages[Math.floor(Math.random() * fallbackImages.length)];
100
-
101
- return {
102
- imageUrl: randomImage,
103
- attribution: null
104
- };
105
- }
106
-
107
- /**
108
- * Extract relevant keywords from slide title and content for image search
109
- * @param title - Slide title
110
- * @param content - Slide content array
111
- * @returns Optimized search keywords
112
- */
113
- export function generateImageKeywords(title: string, content: string[]): string {
114
- // Common words to filter out
115
- const stopWords = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by'];
116
-
117
- // Combine title and first content item
118
- const text = `${title} ${content[0] || ''}`.toLowerCase();
119
-
120
- // Extract meaningful words
121
- const words = text
122
- .split(/\s+/)
123
- .filter(word => word.length > 3 && !stopWords.includes(word))
124
- .slice(0, 3); // Take first 3 meaningful words
125
-
126
- return words.join(' ');
127
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/orchestrator.ts DELETED
@@ -1,123 +0,0 @@
1
- /**
2
- * Presentation Generation Orchestrator
3
- *
4
- * This module is the single entry-point for AI-powered presentation generation.
5
- * It uses the approved Hugging Face Llama 3.3 70B model for generation.
6
- *
7
- * After content is generated the orchestrator enriches slides that carry image
8
- * keyword hints by fetching real photos from Unsplash.
9
- *
10
- * The function always returns a {@link PresentationJSON}. If the model
11
- * produces unparseable output the result defaults to `{ theme: "dark", slides: [] }`.
12
- */
13
-
14
- import { HFClient } from './hf-client';
15
- import { searchUnsplash } from './unsplash';
16
- import { buildSlidePrompt, normalizeLayout } from './slide-prompt';
17
- import { LLAMA_PRESENTATION_MODEL } from './ai-models';
18
-
19
- export type Provider = 'hf';
20
-
21
- export interface GenerationRequest {
22
- prompt: string;
23
- provider: Provider;
24
- model?: string;
25
- templateContext?: {
26
- fileName: string;
27
- fileSize: number;
28
- fileType: string;
29
- base64Content: string;
30
- };
31
- }
32
-
33
- export interface SlideJSON {
34
- id: string;
35
- layout: string;
36
- title?: string;
37
- subtitle?: string;
38
- body?: Array<{ heading?: string; text: string }>;
39
- columns?: Array<{ heading: string; text: string }>;
40
- chart?: {
41
- type: 'bar' | 'line' | 'pie';
42
- data: Record<string, unknown>;
43
- options?: Record<string, unknown>;
44
- };
45
- images?: string[];
46
- imageKeyword?: string;
47
- }
48
-
49
- export interface PresentationJSON {
50
- theme: string;
51
- presentationName?: string;
52
- slides: SlideJSON[];
53
- }
54
-
55
- export async function generatePresentation(req: GenerationRequest): Promise<PresentationJSON> {
56
- const slidePrompt = buildSlidePrompt(req.prompt);
57
- const hfApiKey = process.env.HF_API_KEY || process.env.NEXT_PUBLIC_HF_API_KEY;
58
-
59
- if (!hfApiKey) {
60
- throw new Error('HF Pro API key required for Llama 3.3 70B generation');
61
- }
62
-
63
- if (req.provider && req.provider !== 'hf') {
64
- console.warn(`Ignoring unsupported provider "${req.provider}" and forcing hf.`);
65
- }
66
-
67
- if (req.model && req.model !== LLAMA_PRESENTATION_MODEL) {
68
- console.warn(`Ignoring unsupported presentation model "${req.model}" and forcing ${LLAMA_PRESENTATION_MODEL}.`);
69
- }
70
-
71
- const raw = await new HFClient({
72
- apiKey: hfApiKey,
73
- model: LLAMA_PRESENTATION_MODEL,
74
- }).generateSlideContent(slidePrompt, LLAMA_PRESENTATION_MODEL);
75
-
76
- let parsed: PresentationJSON;
77
-
78
- try {
79
- const rawParsed = JSON.parse(raw);
80
- parsed = {
81
- theme: rawParsed.theme || 'professional',
82
- presentationName: rawParsed.presentationName,
83
- slides: [],
84
- };
85
-
86
- const slidesArray = rawParsed.slides || (Array.isArray(rawParsed) ? rawParsed : []);
87
- const total = slidesArray.length;
88
-
89
- parsed.slides = slidesArray.map((slide: any, index: number) => ({
90
- id: slide.id || `slide-${index + 1}`,
91
- layout: normalizeLayout(slide.layout || '', index, total),
92
- title: slide.title,
93
- subtitle: slide.subtitle,
94
- body: slide.body,
95
- columns: Array.isArray(slide.columns)
96
- ? slide.columns
97
- .filter((column: any) => column && typeof column === 'object')
98
- .map((column: any, columnIndex: number) => ({
99
- heading: String(column.heading || `Column ${columnIndex + 1}`),
100
- text: String(column.text || ''),
101
- }))
102
- : undefined,
103
- chart: slide.chart,
104
- images: slide.images,
105
- imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
106
- }));
107
- } catch {
108
- parsed = { theme: 'dark', slides: [] };
109
- }
110
-
111
- for (const slide of parsed.slides) {
112
- const keyword = slide.imageKeyword || (slide.images && slide.images.length ? slide.images[0] : '');
113
- if (keyword && slide.layout === 'image_and_text') {
114
- const results = await searchUnsplash(keyword, 3);
115
- slide.images = results.map((result) => result.urls.regular);
116
- } else if (slide.images && slide.images.length) {
117
- const results = await searchUnsplash(slide.images[0], 3);
118
- slide.images = results.map((result) => result.urls.regular);
119
- }
120
- }
121
-
122
- return parsed;
123
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/ppt-generator.ts DELETED
@@ -1,567 +0,0 @@
1
- /**
2
- * PowerPoint File Generator
3
- *
4
- * Uses the `pptxgenjs` library to produce native `.pptx` files from the
5
- * application's internal slide data model.
6
- *
7
- * Three slide types are supported:
8
- * - `"title"` — Opening title card with author info and date
9
- * - `"content"` — Bullet-point content slide with optional stats sidebar
10
- * - `"conclusion"` — Closing slide with key takeaways and a call-to-action
11
- *
12
- * The generated file uses Inter / Playfair Display as the primary typefaces
13
- * and a 16:9 aspect ratio (industry standard).
14
- *
15
- * Usage:
16
- * ```ts
17
- * import { generatePowerPoint } from '@/lib/ppt-generator';
18
- * await generatePowerPoint(slidesArray);
19
- * // A file named "Generated-Presentation.pptx" will be downloaded
20
- * ```
21
- */
22
-
23
- import PptxGenJS from 'pptxgenjs';
24
-
25
- // ---------------------------------------------------------------------------
26
- // Data-model types
27
- // ---------------------------------------------------------------------------
28
-
29
- /**
30
- * The content payload for a single slide. Different slide types use different
31
- * subsets of these fields — unused fields are simply ignored.
32
- */
33
- interface SlideContent {
34
- /** Optional override for the slide's main title text. */
35
- title?: string;
36
- /** Tagline or sub-heading shown beneath the title. */
37
- subtitle?: string;
38
- /** Presenter's full name (title slides only). */
39
- authorName?: string;
40
- /** Presenter's job title or role (title slides only). */
41
- authorTitle?: string;
42
- /** Formatted date string displayed in the footer (title slides only). */
43
- date?: string;
44
- /**
45
- * Main content items rendered as styled bullet-point cards.
46
- * Each item has a short `title` label and a longer `description`.
47
- */
48
- points?: Array<{ title: string; description: string }>;
49
- /**
50
- * Key metrics displayed in a sidebar panel on content slides.
51
- * Each stat shows a large `number` with a descriptive `label`.
52
- */
53
- stats?: Array<{ number: string; label: string }>;
54
- /** Optional pull-quote or highlighted text. */
55
- highlightText?: string;
56
- /**
57
- * Short takeaway strings rendered as numbered boxes in the conclusion slide.
58
- * Recommended 3-4 items.
59
- */
60
- takeaways?: string[];
61
- /** Heading text for the call-to-action box (conclusion slide). */
62
- ctaTitle?: string;
63
- /** Body copy inside the call-to-action box. */
64
- ctaText?: string;
65
- /** Button label inside the call-to-action box (currently decorative in PPTX). */
66
- ctaButtonText?: string;
67
- /** Contact details shown at the very bottom of the conclusion slide. */
68
- contactInfo?: string;
69
- }
70
-
71
- /**
72
- * A single slide in the presentation data model.
73
- *
74
- * @property id - Unique identifier used for React list keys.
75
- * @property type - Determines which slide template is rendered.
76
- * @property title - Slide heading (may be overridden by `content.title`).
77
- * @property content - Rich content payload; fields depend on `type`.
78
- */
79
- export interface SlideData {
80
- id: string;
81
- type: 'title' | 'content' | 'conclusion';
82
- title: string;
83
- content: SlideContent;
84
- }
85
-
86
- // ---------------------------------------------------------------------------
87
- // PPTGenerator class
88
- // ---------------------------------------------------------------------------
89
-
90
- /**
91
- * Orchestrates the creation of a `.pptx` file from an array of {@link SlideData}
92
- * objects.
93
- *
94
- * Instantiate once per export operation, then call {@link generatePresentation}.
95
- */
96
- export class PPTGenerator {
97
- /** The `pptxgenjs` presentation instance being constructed. */
98
- private pptx: PptxGenJS;
99
-
100
- /** Creates a new generator instance and initialises the base presentation. */
101
- constructor() {
102
- this.pptx = new PptxGenJS();
103
- this.setupPresentation();
104
- }
105
-
106
- /**
107
- * Configures global presentation properties applied to every slide:
108
- * - 16:9 `LAYOUT_16x9` aspect ratio
109
- * - Inter as the default body/heading typeface
110
- */
111
- private setupPresentation() {
112
- this.pptx.layout = 'LAYOUT_16x9';
113
- this.pptx.theme = {
114
- headFontFace: 'Inter',
115
- bodyFontFace: 'Inter',
116
- };
117
- }
118
-
119
- /**
120
- * Iterates over the provided slides, delegates to the appropriate builder
121
- * method based on `slide.type`, then triggers the file download.
122
- *
123
- * `writeFile` initiates a browser-side download, so this method does not
124
- * return meaningful file data — the returned Blob is a placeholder for API
125
- * consistency.
126
- *
127
- * @param slides - Ordered array of slide data to render.
128
- * @returns A Promise that resolves to a placeholder Blob when download starts.
129
- */
130
- generatePresentation(slides: SlideData[]): Promise<Blob> {
131
- return new Promise((resolve, reject) => {
132
- try {
133
- // Build each slide using the type-specific builder methods
134
- slides.forEach((slide) => {
135
- switch (slide.type) {
136
- case 'title':
137
- this.createTitleSlide(slide);
138
- break;
139
- case 'content':
140
- this.createContentSlide(slide);
141
- break;
142
- case 'conclusion':
143
- this.createConclusionSlide(slide);
144
- break;
145
- }
146
- });
147
-
148
- // Trigger browser download; pptxgenjs handles the file serialisation
149
- this.pptx.writeFile({ fileName: 'Generated-Presentation.pptx' })
150
- .then(() => {
151
- // Since writeFile downloads directly, we'll create a blob for consistency
152
- resolve(new Blob(['Presentation generated successfully'], { type: 'text/plain' }));
153
- })
154
- .catch(reject);
155
- } catch (error) {
156
- reject(error);
157
- }
158
- });
159
- }
160
-
161
- /**
162
- * Creates an opening title slide with a purple gradient background.
163
- *
164
- * Layout (all measurements in inches on a 10×5.625 canvas):
165
- * - Title text: centered, y=2, h=2, 48pt Playfair Display Bold, white
166
- * - Subtitle: centered, y=4, h=1, 24pt Inter, light grey
167
- * - Author info: centered, y=6.5, h=1, 16pt Inter, near-white
168
- * - Date stamp: right-aligned, y=7.5, 12pt Inter, muted grey
169
- *
170
- * @param slide - Slide data object; `content.authorName`, `subtitle`, `date` are used.
171
- */
172
- private createTitleSlide(slide: SlideData) {
173
- const titleSlide = this.pptx.addSlide();
174
-
175
- // Background gradient — deep violet matching the app's brand colour
176
- titleSlide.background = { fill: '667eea' };
177
-
178
- // Main title — large, bold, centred
179
- titleSlide.addText(slide.content.title || slide.title, {
180
- x: 1,
181
- y: 2,
182
- w: 8,
183
- h: 2,
184
- fontSize: 48,
185
- fontFace: 'Playfair Display',
186
- color: 'FFFFFF',
187
- bold: true,
188
- align: 'center',
189
- valign: 'middle',
190
- });
191
-
192
- // Subtitle — softer styling below the main title
193
- titleSlide.addText(slide.content.subtitle || '', {
194
- x: 1,
195
- y: 4,
196
- w: 8,
197
- h: 1,
198
- fontSize: 24,
199
- fontFace: 'Inter',
200
- color: 'E2E8F0', // Light slate
201
- align: 'center',
202
- valign: 'middle',
203
- });
204
-
205
- // Author block — name and job title stacked on two lines
206
- titleSlide.addText(`${slide.content.authorName || 'Author'}\n${slide.content.authorTitle || ''}`, {
207
- x: 1,
208
- y: 6.5,
209
- w: 8,
210
- h: 1,
211
- fontSize: 16,
212
- fontFace: 'Inter',
213
- color: 'F1F5F9', // Near white
214
- align: 'center',
215
- valign: 'middle',
216
- });
217
-
218
- // Date stamp — small, right-aligned, muted
219
- titleSlide.addText(slide.content.date || new Date().toLocaleDateString(), {
220
- x: 8,
221
- y: 7.5,
222
- w: 2,
223
- h: 0.5,
224
- fontSize: 12,
225
- fontFace: 'Inter',
226
- color: '94A3B8', // Slate-400
227
- align: 'right',
228
- });
229
- }
230
-
231
- /**
232
- * Creates a content slide with white background, bullet-point cards, and an
233
- * optional stats sidebar.
234
- *
235
- * Layout:
236
- * - Blue accent rule beneath the title
237
- * - Title at y=0.5, subtitle at y=1.2
238
- * - Up to ~4 content point cards (auto-positioned, y starts at 2.2 with 1.2in spacing)
239
- * - Optional right-side stats panel (x=7, w=2.5, h=4)
240
- *
241
- * @param slide - Slide data object; `content.points` and `content.stats` are used.
242
- */
243
- private createContentSlide(slide: SlideData) {
244
- const contentSlide = this.pptx.addSlide();
245
-
246
- // White background for maximum readability
247
- contentSlide.background = { fill: 'FFFFFF' };
248
-
249
- // Thin blue accent rule beneath the title for visual separation
250
- contentSlide.addShape('rect', {
251
- x: 0.5,
252
- y: 0.8, // Just below the title text block
253
- w: 9,
254
- h: 0.1, // Very thin horizontal line
255
- fill: { color: '2563EB' }, // Tailwind blue-600
256
- });
257
-
258
- // Main slide title
259
- contentSlide.addText(slide.title, {
260
- x: 0.5,
261
- y: 0.5,
262
- w: 9,
263
- h: 1,
264
- fontSize: 36,
265
- fontFace: 'Inter',
266
- color: '0F172A', // Slate-900 — near black
267
- bold: true,
268
- });
269
-
270
- // Optional subtitle / descriptive sub-heading
271
- contentSlide.addText(slide.content.subtitle || '', {
272
- x: 0.5,
273
- y: 1.2,
274
- w: 9,
275
- h: 0.5,
276
- fontSize: 16,
277
- fontFace: 'Inter',
278
- color: '64748B', // Slate-500 — muted grey
279
- });
280
-
281
- // Render each bullet-point card as a rounded rectangle with a green dot
282
- if (slide.content.points && Array.isArray(slide.content.points)) {
283
- slide.content.points.forEach((point, index: number) => {
284
- // Stack cards vertically with 1.2-inch spacing between each
285
- const yPos = 2.2 + (index * 1.2);
286
-
287
- // Background card — light grey with a subtle border
288
- contentSlide.addShape('rect', {
289
- x: 0.5,
290
- y: yPos,
291
- w: 6,
292
- h: 1,
293
- fill: { color: 'F8FAFC' }, // Slate-50
294
- line: { color: 'E2E8F0', width: 1 }, // Slate-200 border
295
- rectRadius: 0.1, // Rounded corners
296
- });
297
-
298
- // Green circular bullet indicator on the left edge
299
- contentSlide.addShape('ellipse', {
300
- x: 0.2,
301
- y: yPos + 0.3, // Vertically centred within the card
302
- w: 0.4,
303
- h: 0.4,
304
- fill: { color: '10B981' }, // Emerald-500
305
- });
306
-
307
- // Point title — bold, dark text at the top of the card
308
- contentSlide.addText(point.title || `Point ${index + 1}`, {
309
- x: 0.8,
310
- y: yPos + 0.1,
311
- w: 5.5,
312
- h: 0.4,
313
- fontSize: 14,
314
- fontFace: 'Inter',
315
- color: '0F172A',
316
- bold: true,
317
- });
318
-
319
- // Point description — muted grey, smaller font
320
- contentSlide.addText(point.description || '', {
321
- x: 0.8,
322
- y: yPos + 0.5,
323
- w: 5.5,
324
- h: 0.4,
325
- fontSize: 12,
326
- fontFace: 'Inter',
327
- color: '64748B',
328
- });
329
- });
330
- }
331
-
332
- // Optional stats sidebar — only rendered when stats are provided
333
- if (slide.content.stats && Array.isArray(slide.content.stats)) {
334
- // Sidebar container panel
335
- contentSlide.addShape('rect', {
336
- x: 7,
337
- y: 2,
338
- w: 2.5,
339
- h: 4,
340
- fill: { color: 'F8FAFC' },
341
- line: { color: 'E2E8F0', width: 1 },
342
- rectRadius: 0.1,
343
- });
344
-
345
- // "Key Metrics" heading at the top of the sidebar
346
- contentSlide.addText('Key Metrics', {
347
- x: 7.2,
348
- y: 2.2,
349
- w: 2.1,
350
- h: 0.4,
351
- fontSize: 14,
352
- fontFace: 'Inter',
353
- color: '0F172A',
354
- bold: true,
355
- });
356
-
357
- // Render each stat as a stacked number + label pair
358
- slide.content.stats.forEach((stat, index: number) => {
359
- // Each stat occupies ~0.8 inches of vertical space
360
- const yPos = 2.8 + (index * 0.8);
361
-
362
- // Large blue number for visual impact
363
- contentSlide.addText(stat.number || '0', {
364
- x: 7.2,
365
- y: yPos,
366
- w: 2.1,
367
- h: 0.4,
368
- fontSize: 18,
369
- fontFace: 'Inter',
370
- color: '2563EB', // Blue-600
371
- bold: true,
372
- align: 'center',
373
- });
374
-
375
- // Small grey label beneath the number
376
- contentSlide.addText(stat.label || '', {
377
- x: 7.2,
378
- y: yPos + 0.3,
379
- w: 2.1,
380
- h: 0.3,
381
- fontSize: 10,
382
- fontFace: 'Inter',
383
- color: '64748B',
384
- align: 'center',
385
- });
386
- });
387
- }
388
- }
389
-
390
- /**
391
- * Creates a closing conclusion slide with a green background, key takeaway
392
- * boxes, and a call-to-action panel.
393
- *
394
- * Layout:
395
- * - Green background (Emerald-500 `#10B981`)
396
- * - Large white title at the top
397
- * - "Key Takeaways" sub-heading
398
- * - Numbered takeaway boxes arranged horizontally (x advances by 2.5in)
399
- * - Semi-transparent CTA panel at y=5.5
400
- * - "Thank You" sign-off and contact info at the bottom
401
- *
402
- * @param slide - Slide data; `content.takeaways`, `ctaTitle`, `ctaText`,
403
- * and `contactInfo` are used.
404
- */
405
- private createConclusionSlide(slide: SlideData) {
406
- const conclusionSlide = this.pptx.addSlide();
407
-
408
- // Vibrant green background to signal the end of the presentation
409
- conclusionSlide.background = { fill: '10B981' };
410
-
411
- // Main slide title — large Playfair Display for elegance
412
- conclusionSlide.addText(slide.title, {
413
- x: 1,
414
- y: 0.5,
415
- w: 8,
416
- h: 1.5,
417
- fontSize: 48,
418
- fontFace: 'Playfair Display',
419
- color: 'FFFFFF',
420
- bold: true,
421
- align: 'center',
422
- });
423
-
424
- // "Key Takeaways" section heading
425
- conclusionSlide.addText('Key Takeaways', {
426
- x: 1,
427
- y: 2.5,
428
- w: 8,
429
- h: 0.5,
430
- fontSize: 24,
431
- fontFace: 'Inter',
432
- color: 'FFFFFF',
433
- bold: true,
434
- align: 'center',
435
- });
436
-
437
- // Render each takeaway in a semi-transparent numbered box
438
- if (slide.content.takeaways && Array.isArray(slide.content.takeaways)) {
439
- slide.content.takeaways.forEach((takeaway: string, index: number) => {
440
- // Boxes are laid out horizontally; each takes 2.5 inches of width
441
- const xPos = 1 + (index * 2.5);
442
-
443
- // Frosted-glass style box background
444
- conclusionSlide.addShape('rect', {
445
- x: xPos,
446
- y: 3.5,
447
- w: 2.2,
448
- h: 1.5,
449
- fill: { color: 'FFFFFF', transparency: 85 }, // Very translucent white
450
- line: { color: 'FFFFFF', width: 1, transparency: 50 },
451
- rectRadius: 0.2,
452
- });
453
-
454
- // Small circle behind the ordinal number
455
- conclusionSlide.addShape('ellipse', {
456
- x: xPos + 0.1,
457
- y: 3.6,
458
- w: 0.3,
459
- h: 0.3,
460
- fill: { color: 'FFFFFF', transparency: 70 },
461
- });
462
-
463
- // Ordinal number (1, 2, 3…) centred in the circle
464
- conclusionSlide.addText((index + 1).toString(), {
465
- x: xPos + 0.1,
466
- y: 3.6,
467
- w: 0.3,
468
- h: 0.3,
469
- fontSize: 12,
470
- fontFace: 'Inter',
471
- color: 'FFFFFF',
472
- bold: true,
473
- align: 'center',
474
- valign: 'middle',
475
- });
476
-
477
- // Takeaway body text within the box
478
- conclusionSlide.addText(takeaway, {
479
- x: xPos + 0.1,
480
- y: 4,
481
- w: 2,
482
- h: 1,
483
- fontSize: 12,
484
- fontFace: 'Inter',
485
- color: 'FFFFFF',
486
- valign: 'top',
487
- });
488
- });
489
- }
490
-
491
- // Call-to-action panel — semi-transparent white box near the bottom
492
- conclusionSlide.addShape('rect', {
493
- x: 2,
494
- y: 5.5,
495
- w: 6,
496
- h: 1.5,
497
- fill: { color: 'FFFFFF', transparency: 80 },
498
- line: { color: 'FFFFFF', width: 2 },
499
- rectRadius: 0.2,
500
- });
501
-
502
- // CTA heading — prominently centred in the panel
503
- conclusionSlide.addText(slide.content.ctaTitle || 'Next Steps', {
504
- x: 2.2,
505
- y: 5.7,
506
- w: 5.6,
507
- h: 0.5,
508
- fontSize: 20,
509
- fontFace: 'Inter',
510
- color: 'FFFFFF',
511
- bold: true,
512
- align: 'center',
513
- });
514
-
515
- // CTA body text — supporting description beneath the heading
516
- conclusionSlide.addText(slide.content.ctaText || 'Ready to implement these insights?', {
517
- x: 2.2,
518
- y: 6.2,
519
- w: 5.6,
520
- h: 0.8,
521
- fontSize: 14,
522
- fontFace: 'Inter',
523
- color: 'F1F5F9', // Very light slate
524
- align: 'center',
525
- });
526
-
527
- // "Thank You" sign-off in the brand serif font
528
- conclusionSlide.addText('Thank You', {
529
- x: 1,
530
- y: 7.5,
531
- w: 8,
532
- h: 0.5,
533
- fontSize: 24,
534
- fontFace: 'Playfair Display',
535
- color: 'FFFFFF',
536
- align: 'center',
537
- });
538
-
539
- // Contact info footer — small text at the very bottom
540
- conclusionSlide.addText(slide.content.contactInfo || 'Contact us for more information', {
541
- x: 1,
542
- y: 8,
543
- w: 8,
544
- h: 0.3,
545
- fontSize: 12,
546
- fontFace: 'Inter',
547
- color: 'E2E8F0', // Light slate
548
- align: 'center',
549
- });
550
- }
551
- }
552
-
553
- // ---------------------------------------------------------------------------
554
- // Convenience wrapper
555
- // ---------------------------------------------------------------------------
556
-
557
- /**
558
- * Convenience function that creates a one-shot {@link PPTGenerator} and
559
- * immediately triggers the PowerPoint file download.
560
- *
561
- * @param slides - Ordered array of slide data to render.
562
- * @returns A Promise that resolves when the download has been initiated.
563
- */
564
- export const generatePowerPoint = async (slides: SlideData[]): Promise<void> => {
565
- const generator = new PPTGenerator();
566
- await generator.generatePresentation(slides);
567
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/unsplash.ts DELETED
@@ -1,90 +0,0 @@
1
- /**
2
- * Unsplash Image Search Helper
3
- *
4
- * Provides a single lightweight function for searching the Unsplash API.
5
- * This module is used by the orchestrator when enriching generated slides
6
- * with relevant stock photography.
7
- *
8
- * API key is read from `process.env.UNSPLASH_ACCESS_KEY`.
9
- * If no key is present the function silently returns an empty array so the
10
- * rest of the generation pipeline can continue without images.
11
- *
12
- * Unsplash rate limits:
13
- * - Demo / development: 50 requests / hour
14
- * - Production (approved app): 5,000 requests / hour
15
- *
16
- * @see https://unsplash.com/developers for key registration
17
- */
18
-
19
- import axios from 'axios';
20
-
21
- /**
22
- * The subset of Unsplash photo fields used by the application.
23
- * Only the properties that are consumed downstream are typed here.
24
- */
25
- export interface UnsplashImage {
26
- /** Unique Unsplash photo ID. */
27
- id: string;
28
- /** Human-readable description written by the photographer (may be null). */
29
- description: string | null;
30
- /** Machine-generated alt text derived from image content (may be null). */
31
- alt_description: string | null;
32
- /** Pre-sized image URLs provided by the Unsplash CDN. */
33
- urls: {
34
- /** ~400 px wide — suitable for thumbnails. */
35
- small: string;
36
- /** ~1080 px wide — suitable for slide backgrounds. */
37
- regular: string;
38
- /** Full-resolution download URL. */
39
- full: string;
40
- };
41
- /** Photographer attribution (must be shown when displaying photos). */
42
- user: {
43
- /** Display name of the photographer. */
44
- name: string;
45
- /** Public username used for attribution links. */
46
- username: string;
47
- };
48
- }
49
-
50
- /**
51
- * Searches the Unsplash API for landscape-oriented photos matching `query`.
52
- *
53
- * Results are filtered to landscape orientation automatically so they fit
54
- * standard 16:9 slide dimensions without distortion.
55
- *
56
- * @param query - Search keywords (e.g. `"artificial intelligence technology"`).
57
- * @param perPage - Maximum number of results to return (default: 6).
58
- * @returns Array of {@link UnsplashImage} objects, or an empty array when the
59
- * API key is missing or the request fails.
60
- *
61
- * @example
62
- * const images = await searchUnsplash("sustainable energy", 3);
63
- * const firstImageUrl = images[0]?.urls.regular;
64
- */
65
- export async function searchUnsplash(query: string, perPage = 6): Promise<UnsplashImage[]> {
66
- // Read the Unsplash access key from the environment
67
- const key = process.env.UNSPLASH_ACCESS_KEY;
68
-
69
- // Bail out silently when no key is configured — callers handle the empty array
70
- if (!key) return [];
71
-
72
- // Base URL for the Unsplash search endpoint
73
- const url = `https://api.unsplash.com/search/photos`;
74
-
75
- // Perform the search request using axios for automatic error handling
76
- const { data } = await axios.get(url, {
77
- params: {
78
- query, // The user-supplied search term
79
- per_page: perPage, // Limit the number of results returned
80
- orientation: 'landscape', // Only return landscape photos for 16:9 slides
81
- },
82
- headers: {
83
- // Unsplash requires the Client-ID header for authentication
84
- Authorization: `Client-ID ${key}`,
85
- },
86
- });
87
-
88
- // `data.results` is the array of photo objects; default to empty if absent
89
- return (data.results || []) as UnsplashImage[];
90
- }