Spaces:
Running
Running
working on production
Browse files- app/api/auth/callback/route.ts +0 -96
- app/api/auth/hf/route.ts +0 -144
- app/api/export-pptx/route.ts +0 -179
- app/api/generate-slides/route.ts +0 -340
- app/api/presentations/route.ts +0 -79
- app/api/upload-template/route.ts +0 -141
- app/editor/page.tsx +21 -5
- app/layout.tsx +2 -2
- components/HFAuth.tsx +0 -145
- components/InteractivePresenter.tsx +0 -36
- components/auth/HuggingFaceLogin.tsx +0 -263
- components/editor/GoogleSlidesEditor.tsx +14 -127
- components/editor/GoogleSlidesMenubar.tsx +0 -21
- components/editor/GoogleSlidesToolbar.tsx +0 -373
- components/editor/SlideThumbnailPanel.tsx +0 -77
- components/home/HomePage.tsx +72 -35
- components/ui/button.tsx +0 -59
- components/ui/dialog.tsx +0 -143
- components/ui/dropdown-menu.tsx +0 -257
- components/ui/popover.tsx +0 -48
- components/ui/select.tsx +0 -185
- components/ui/textarea.tsx +0 -18
- hooks/useEditableField.ts +0 -56
- hooks/useExport.ts +2 -105
- lib/imageService.ts +0 -127
- lib/orchestrator.ts +0 -123
- lib/ppt-generator.ts +0 -567
- lib/unsplash.ts +0 -90
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 |
-
|
| 29 |
-
|
| 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: "
|
| 96 |
-
description: "Generate
|
| 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,
|
| 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 |
-
//
|
| 525 |
-
const {
|
| 526 |
-
slideRef, slides, slideSpecs, currentTheme,
|
| 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
|
| 606 |
const hfApiKey = localStorage.getItem('hf_api_key');
|
| 607 |
if (hfApiKey) {
|
| 608 |
-
// Use x-hf-token
|
| 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 |
-
|
| 1439 |
-
|
| 1440 |
-
|
| 1441 |
-
|
| 1442 |
-
|
| 1443 |
-
>
|
| 1444 |
-
|
| 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 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
setIsGenerating(true);
|
| 113 |
|
| 114 |
-
sessionStorage.setItem('generationPrompt',
|
| 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=
|
| 154 |
<div className="flex items-center gap-2 text-zinc-950 dark:text-zinc-50">
|
| 155 |
-
<span className="text-xl font-semibold tracking-tight">
|
| 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
|
| 198 |
</button>
|
| 199 |
)}
|
| 200 |
</div>
|
|
@@ -216,7 +250,12 @@ export default function HomePage() {
|
|
| 216 |
<textarea
|
| 217 |
ref={textareaRef}
|
| 218 |
value={prompt}
|
| 219 |
-
onChange={(e) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 {
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|