Spaces:
Running
Running
committing since i am heading to bed
Browse files- app/api/presentations/generate/route.ts +130 -28
- app/api/search-images/route.ts +74 -60
- app/globals.css +4 -4
- app/layout.tsx +7 -2
- app/page.tsx +1 -273
- components/UnsplashImageSearch.tsx +2 -0
- components/editor/BottomToolbar.tsx +85 -60
- components/editor/GoogleSlidesEditor.tsx +169 -31
- components/editor/SlideThumbnailPanel.tsx +13 -9
- components/home/HomePage.tsx +13 -13
- components/slides/SlideFactory.tsx +18 -7
- components/slides/galeryn/layouts.tsx +883 -724
- components/slides/neobrutalism/layouts.tsx +1122 -579
- components/slides/noisy/layouts.tsx +729 -500
- components/slides/shared/PersistedDraggableSurface.tsx +303 -0
- data/templates/galeryn.ts +43 -8
- data/templates/index.ts +3 -0
- data/templates/neo-brutalism.ts +20 -3
- data/templates/noisy.ts +21 -4
- hooks/useExport.ts +69 -71
- lib/capture-element.ts +200 -0
- lib/editable-pptx-export.ts +1542 -0
- lib/hf-client.ts +34 -8
- lib/imageService.ts +6 -20
- lib/orchestrator.ts +9 -0
- lib/slide-prompt.ts +43 -30
- lib/template-options.ts +28 -0
- lib/theme-system.ts +3 -2
- lib/unsplash.ts +2 -0
- package-lock.json +0 -1
- package.json +0 -1
- package_tmp.json +1 -2
app/api/presentations/generate/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { GeminiClient } from '@/lib/gemini-client';
|
| 3 |
-
import { HFClient } from '@/lib/hf-client';
|
| 4 |
import { buildSlidePrompt, normalizeLayout } from '@/lib/slide-prompt';
|
| 5 |
|
| 6 |
async function fetchImageForSlide(query: string): Promise<string | undefined> {
|
|
@@ -27,13 +27,71 @@ interface Slide {
|
|
| 27 |
id: string;
|
| 28 |
title: string;
|
| 29 |
subtitle?: string;
|
| 30 |
-
content: string[];
|
|
|
|
| 31 |
notes?: string;
|
| 32 |
imageUrl?: string;
|
| 33 |
imageKeyword?: string;
|
| 34 |
layout: string;
|
| 35 |
}
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
export async function POST(req: NextRequest) {
|
| 38 |
try {
|
| 39 |
const { prompt, model } = await req.json();
|
|
@@ -69,15 +127,21 @@ export async function POST(req: NextRequest) {
|
|
| 69 |
const slidesArray = parsed.slides || (Array.isArray(parsed) ? parsed : []);
|
| 70 |
const total = slidesArray.length;
|
| 71 |
|
| 72 |
-
slides = slidesArray.map((slide: any, index: number) =>
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
} catch (error) {
|
| 82 |
console.error('Gemini generation error:', error);
|
| 83 |
slides = createFallbackSlides(prompt);
|
|
@@ -92,7 +156,39 @@ export async function POST(req: NextRequest) {
|
|
| 92 |
const hf = new HFClient({ apiKey: hfToken, model });
|
| 93 |
|
| 94 |
try {
|
| 95 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
console.log('Raw HF response (first 500 chars):', response.substring(0, 500));
|
| 97 |
|
| 98 |
if (!response || response.trim().length === 0) {
|
|
@@ -140,19 +236,26 @@ export async function POST(req: NextRequest) {
|
|
| 140 |
const slidesArray = parsed.slides || (Array.isArray(parsed) ? parsed : []);
|
| 141 |
const total = slidesArray.length;
|
| 142 |
|
| 143 |
-
slides = slidesArray.map((slide: any, index: number) =>
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
console.log(`Parsed ${slides.length} slides`);
|
| 154 |
} catch (error) {
|
| 155 |
-
|
|
|
|
| 156 |
slides = createFallbackSlides(prompt);
|
| 157 |
}
|
| 158 |
}
|
|
@@ -180,11 +283,10 @@ function createFallbackSlides(prompt: string): Slide[] {
|
|
| 180 |
return [
|
| 181 |
{ id: 'slide-1', title, subtitle: `Overview of ${prompt.toLowerCase()}`, content: [], layout: 'title_subtitle', imageKeyword: '' },
|
| 182 |
{ id: 'slide-2', title: 'Agenda', content: ['Background & Context', 'Key Concepts', 'Analysis', 'Benefits & Impact'], layout: 'agenda', imageKeyword: '' },
|
| 183 |
-
{ id: 'slide-3', title: 'Background & Context', content:
|
| 184 |
-
{ id: 'slide-4', title: 'Key Concepts', content:
|
| 185 |
-
{ id: 'slide-5', title: 'Analysis', content:
|
| 186 |
-
{ id: 'slide-6', title: 'Benefits & Impact', content:
|
| 187 |
-
{ id: 'slide-7', title: '
|
| 188 |
-
{ id: 'slide-8', title: 'Thank You', subtitle: 'Questions welcome', content: [], layout: 'thank_you', imageKeyword: '' },
|
| 189 |
];
|
| 190 |
}
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { GeminiClient } from '@/lib/gemini-client';
|
| 3 |
+
import { HFClient, HFGenerationError } from '@/lib/hf-client';
|
| 4 |
import { buildSlidePrompt, normalizeLayout } from '@/lib/slide-prompt';
|
| 5 |
|
| 6 |
async function fetchImageForSlide(query: string): Promise<string | undefined> {
|
|
|
|
| 27 |
id: string;
|
| 28 |
title: string;
|
| 29 |
subtitle?: string;
|
| 30 |
+
content: string | string[];
|
| 31 |
+
columns?: Array<{ heading: string; text: string }>;
|
| 32 |
notes?: string;
|
| 33 |
imageUrl?: string;
|
| 34 |
imageKeyword?: string;
|
| 35 |
layout: string;
|
| 36 |
}
|
| 37 |
|
| 38 |
+
function toStringArray(value: unknown): string[] {
|
| 39 |
+
if (Array.isArray(value)) {
|
| 40 |
+
return value
|
| 41 |
+
.filter((entry): entry is string => typeof entry === 'string')
|
| 42 |
+
.map((entry) => entry.trim())
|
| 43 |
+
.filter(Boolean);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (typeof value === 'string' && value.trim()) {
|
| 47 |
+
return [value.trim()];
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return [];
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function normalizeSlideContent(content: unknown, layout: string): string | string[] {
|
| 54 |
+
const items = toStringArray(content);
|
| 55 |
+
|
| 56 |
+
if (layout === 'agenda' || layout === 'references') {
|
| 57 |
+
return items;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return items.join(' ');
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function normalizeColumns(value: unknown): Array<{ heading: string; text: string }> | undefined {
|
| 64 |
+
if (!Array.isArray(value)) return undefined;
|
| 65 |
+
|
| 66 |
+
const columns = value
|
| 67 |
+
.map((entry, index) => {
|
| 68 |
+
if (!entry || typeof entry !== 'object') return null;
|
| 69 |
+
const heading = typeof (entry as { heading?: unknown }).heading === 'string'
|
| 70 |
+
? (entry as { heading: string }).heading.trim()
|
| 71 |
+
: `Column ${index + 1}`;
|
| 72 |
+
const text = typeof (entry as { text?: unknown }).text === 'string'
|
| 73 |
+
? (entry as { text: string }).text.trim()
|
| 74 |
+
: '';
|
| 75 |
+
|
| 76 |
+
if (!heading && !text) return null;
|
| 77 |
+
return { heading: heading || `Column ${index + 1}`, text };
|
| 78 |
+
})
|
| 79 |
+
.filter((entry): entry is { heading: string; text: string } => Boolean(entry))
|
| 80 |
+
.slice(0, 3);
|
| 81 |
+
|
| 82 |
+
return columns.length ? columns : undefined;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function deriveColumnsFromContent(content: unknown): Array<{ heading: string; text: string }> | undefined {
|
| 86 |
+
const items = toStringArray(content).slice(0, 3);
|
| 87 |
+
if (!items.length) return undefined;
|
| 88 |
+
|
| 89 |
+
return items.map((text, index) => ({
|
| 90 |
+
heading: `Pillar ${index + 1}`,
|
| 91 |
+
text,
|
| 92 |
+
}));
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
export async function POST(req: NextRequest) {
|
| 96 |
try {
|
| 97 |
const { prompt, model } = await req.json();
|
|
|
|
| 127 |
const slidesArray = parsed.slides || (Array.isArray(parsed) ? parsed : []);
|
| 128 |
const total = slidesArray.length;
|
| 129 |
|
| 130 |
+
slides = slidesArray.map((slide: any, index: number) => {
|
| 131 |
+
const layout = normalizeLayout(slide.layout || '', index, total);
|
| 132 |
+
return {
|
| 133 |
+
id: `slide-${index + 1}`,
|
| 134 |
+
title: slide.title || `Slide ${index + 1}`,
|
| 135 |
+
subtitle: slide.subtitle || undefined,
|
| 136 |
+
layout,
|
| 137 |
+
content: normalizeSlideContent(slide.content, layout),
|
| 138 |
+
columns: layout === 'three_columns'
|
| 139 |
+
? (normalizeColumns(slide.columns) || deriveColumnsFromContent(slide.content))
|
| 140 |
+
: undefined,
|
| 141 |
+
notes: slide.notes || '',
|
| 142 |
+
imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
|
| 143 |
+
};
|
| 144 |
+
});
|
| 145 |
} catch (error) {
|
| 146 |
console.error('Gemini generation error:', error);
|
| 147 |
slides = createFallbackSlides(prompt);
|
|
|
|
| 156 |
const hf = new HFClient({ apiKey: hfToken, model });
|
| 157 |
|
| 158 |
try {
|
| 159 |
+
const fallbackModels = [
|
| 160 |
+
'deepseek-ai/DeepSeek-V3.1',
|
| 161 |
+
'meta-llama/Llama-4-Maverick-17B-128E-Instruct',
|
| 162 |
+
].filter((candidate) => candidate !== model);
|
| 163 |
+
|
| 164 |
+
let response = '';
|
| 165 |
+
|
| 166 |
+
try {
|
| 167 |
+
response = await hf.generateSlideContent(systemPrompt, model);
|
| 168 |
+
} catch (error) {
|
| 169 |
+
const primaryMessage = error instanceof Error ? error.message : 'Unknown provider error';
|
| 170 |
+
const shouldRetry = error instanceof HFGenerationError
|
| 171 |
+
? !error.status || error.status >= 500
|
| 172 |
+
: true;
|
| 173 |
+
|
| 174 |
+
if (!shouldRetry) throw error;
|
| 175 |
+
|
| 176 |
+
let recovered = false;
|
| 177 |
+
for (const fallbackModel of fallbackModels) {
|
| 178 |
+
try {
|
| 179 |
+
console.warn(`Primary HF model failed (${primaryMessage}). Retrying with ${fallbackModel}.`);
|
| 180 |
+
response = await hf.generateSlideContent(systemPrompt, fallbackModel);
|
| 181 |
+
recovered = true;
|
| 182 |
+
break;
|
| 183 |
+
} catch (fallbackError) {
|
| 184 |
+
const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : 'Unknown provider error';
|
| 185 |
+
console.warn(`HF fallback model ${fallbackModel} failed: ${fallbackMessage}`);
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
if (!recovered) throw error;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
console.log('Raw HF response (first 500 chars):', response.substring(0, 500));
|
| 193 |
|
| 194 |
if (!response || response.trim().length === 0) {
|
|
|
|
| 236 |
const slidesArray = parsed.slides || (Array.isArray(parsed) ? parsed : []);
|
| 237 |
const total = slidesArray.length;
|
| 238 |
|
| 239 |
+
slides = slidesArray.map((slide: any, index: number) => {
|
| 240 |
+
const layout = normalizeLayout(slide.layout || '', index, total);
|
| 241 |
+
return {
|
| 242 |
+
id: `slide-${index + 1}`,
|
| 243 |
+
title: slide.title || `Slide ${index + 1}`,
|
| 244 |
+
subtitle: slide.subtitle || undefined,
|
| 245 |
+
layout,
|
| 246 |
+
content: normalizeSlideContent(slide.content, layout),
|
| 247 |
+
columns: layout === 'three_columns'
|
| 248 |
+
? (normalizeColumns(slide.columns) || deriveColumnsFromContent(slide.content))
|
| 249 |
+
: undefined,
|
| 250 |
+
notes: slide.notes || '',
|
| 251 |
+
imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
|
| 252 |
+
};
|
| 253 |
+
});
|
| 254 |
|
| 255 |
console.log(`Parsed ${slides.length} slides`);
|
| 256 |
} catch (error) {
|
| 257 |
+
const message = error instanceof Error ? error.message : 'Unknown provider error';
|
| 258 |
+
console.warn(`HuggingFace generation failed, using fallback slides: ${message}`);
|
| 259 |
slides = createFallbackSlides(prompt);
|
| 260 |
}
|
| 261 |
}
|
|
|
|
| 283 |
return [
|
| 284 |
{ id: 'slide-1', title, subtitle: `Overview of ${prompt.toLowerCase()}`, content: [], layout: 'title_subtitle', imageKeyword: '' },
|
| 285 |
{ id: 'slide-2', title: 'Agenda', content: ['Background & Context', 'Key Concepts', 'Analysis', 'Benefits & Impact'], layout: 'agenda', imageKeyword: '' },
|
| 286 |
+
{ id: 'slide-3', title: 'Background & Context', content: `This section introduces the core context behind ${prompt.toLowerCase()} and frames why the topic matters right now.`, layout: 'title_and_text', imageKeyword: '' },
|
| 287 |
+
{ id: 'slide-4', title: 'Key Concepts', content: `This slide highlights the main concepts, practical principles, and important patterns someone should understand about ${prompt.toLowerCase()}.`, layout: 'image_and_text', imageKeyword: `${prompt.toLowerCase()} concept` },
|
| 288 |
+
{ id: 'slide-5', title: 'Analysis', content: `This section summarizes the most relevant trends, observations, and implications surrounding ${prompt.toLowerCase()} in a concise way.`, layout: 'title_and_text', imageKeyword: '' },
|
| 289 |
+
{ id: 'slide-6', title: 'Benefits & Impact', content: `This slide explains the likely benefits, measurable outcomes, and longer-term value that can come from ${prompt.toLowerCase()}.`, layout: 'title_and_text', imageKeyword: '' },
|
| 290 |
+
{ id: 'slide-7', title: 'Thank You', subtitle: 'Questions welcome', content: [], layout: 'thank_you', imageKeyword: '' },
|
|
|
|
| 291 |
];
|
| 292 |
}
|
app/api/search-images/route.ts
CHANGED
|
@@ -12,8 +12,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
| 12 |
// Sign up at: https://unsplash.com/developers
|
| 13 |
const UNSPLASH_ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY || process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY || 'demo';
|
| 14 |
|
| 15 |
-
interface UnsplashImage {
|
| 16 |
-
id: string;
|
| 17 |
urls: {
|
| 18 |
raw: string;
|
| 19 |
full: string;
|
|
@@ -26,15 +26,44 @@ interface UnsplashImage {
|
|
| 26 |
user: {
|
| 27 |
name: string;
|
| 28 |
username: string;
|
| 29 |
-
};
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
const
|
| 37 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
if (!query) {
|
| 40 |
return NextResponse.json(
|
|
@@ -43,28 +72,16 @@ export async function GET(request: NextRequest) {
|
|
| 43 |
);
|
| 44 |
}
|
| 45 |
|
| 46 |
-
// If no API key is set, return placeholder images
|
| 47 |
-
if (UNSPLASH_ACCESS_KEY === 'demo') {
|
| 48 |
-
console.log('Using placeholder images (no Unsplash API key configured)');
|
| 49 |
-
return NextResponse.json({
|
| 50 |
-
results:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
},
|
| 57 |
-
alt_description: query,
|
| 58 |
-
description: `Placeholder image for: ${query}`,
|
| 59 |
-
user: {
|
| 60 |
-
name: 'Unsplash',
|
| 61 |
-
username: 'unsplash'
|
| 62 |
-
}
|
| 63 |
-
}],
|
| 64 |
-
total: 1,
|
| 65 |
-
message: 'Using placeholder - Add UNSPLASH_ACCESS_KEY to .env.local for real images'
|
| 66 |
-
});
|
| 67 |
-
}
|
| 68 |
|
| 69 |
// Make request to Unsplash API
|
| 70 |
const unsplashUrl = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&per_page=${perPage}&page=${page}&orientation=landscape`;
|
|
@@ -79,22 +96,13 @@ export async function GET(request: NextRequest) {
|
|
| 79 |
const errorText = await response.text();
|
| 80 |
console.error('Unsplash API error:', response.status, errorText);
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
)
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
if (response.status === 403) {
|
| 90 |
-
return NextResponse.json(
|
| 91 |
-
{ error: 'Rate limit exceeded. Please try again later or upgrade your Unsplash plan.' },
|
| 92 |
-
{ status: 429 }
|
| 93 |
-
);
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
throw new Error(`Unsplash API returned ${response.status}`);
|
| 97 |
-
}
|
| 98 |
|
| 99 |
const data = await response.json();
|
| 100 |
|
|
@@ -104,14 +112,20 @@ export async function GET(request: NextRequest) {
|
|
| 104 |
total_pages: data.total_pages,
|
| 105 |
});
|
| 106 |
|
| 107 |
-
} catch (error) {
|
| 108 |
-
console.error('Image search error:', error);
|
| 109 |
-
return NextResponse.json(
|
| 110 |
-
{
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
// Sign up at: https://unsplash.com/developers
|
| 13 |
const UNSPLASH_ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY || process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY || 'demo';
|
| 14 |
|
| 15 |
+
interface UnsplashImage {
|
| 16 |
+
id: string;
|
| 17 |
urls: {
|
| 18 |
raw: string;
|
| 19 |
full: string;
|
|
|
|
| 26 |
user: {
|
| 27 |
name: string;
|
| 28 |
username: string;
|
| 29 |
+
};
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function createFallbackResults(query: string, perPage: number, page: number) {
|
| 33 |
+
const safePerPage = Math.min(Math.max(perPage, 1), 20);
|
| 34 |
+
|
| 35 |
+
return Array.from({ length: safePerPage }, (_, index) => {
|
| 36 |
+
const seed = `${query || 'presentation'}-${page}-${index + 1}`;
|
| 37 |
+
const baseUrl = `https://picsum.photos/seed/${encodeURIComponent(seed)}`;
|
| 38 |
+
|
| 39 |
+
return {
|
| 40 |
+
id: `fallback-${seed}`,
|
| 41 |
+
urls: {
|
| 42 |
+
raw: `${baseUrl}/1600/900`,
|
| 43 |
+
full: `${baseUrl}/1600/900`,
|
| 44 |
+
regular: `${baseUrl}/1200/675`,
|
| 45 |
+
small: `${baseUrl}/800/450`,
|
| 46 |
+
thumb: `${baseUrl}/320/180`,
|
| 47 |
+
},
|
| 48 |
+
alt_description: query || 'presentation',
|
| 49 |
+
description: `Fallback image for: ${query || 'presentation'}`,
|
| 50 |
+
user: {
|
| 51 |
+
name: 'Fallback Library',
|
| 52 |
+
username: 'fallback-library',
|
| 53 |
+
},
|
| 54 |
+
links: {
|
| 55 |
+
download_location: '',
|
| 56 |
+
},
|
| 57 |
+
};
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export async function GET(request: NextRequest) {
|
| 62 |
+
try {
|
| 63 |
+
const searchParams = request.nextUrl.searchParams;
|
| 64 |
+
const query = searchParams.get('query');
|
| 65 |
+
const perPage = Number(searchParams.get('per_page') || '1');
|
| 66 |
+
const page = Number(searchParams.get('page') || '1');
|
| 67 |
|
| 68 |
if (!query) {
|
| 69 |
return NextResponse.json(
|
|
|
|
| 72 |
);
|
| 73 |
}
|
| 74 |
|
| 75 |
+
// If no API key is set, return placeholder images
|
| 76 |
+
if (UNSPLASH_ACCESS_KEY === 'demo') {
|
| 77 |
+
console.log('Using placeholder images (no Unsplash API key configured)');
|
| 78 |
+
return NextResponse.json({
|
| 79 |
+
results: createFallbackResults(query, perPage, page),
|
| 80 |
+
total: perPage,
|
| 81 |
+
total_pages: 1,
|
| 82 |
+
message: 'Using fallback results - add UNSPLASH_ACCESS_KEY to .env.local for real Unsplash images',
|
| 83 |
+
});
|
| 84 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
// Make request to Unsplash API
|
| 87 |
const unsplashUrl = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&per_page=${perPage}&page=${page}&orientation=landscape`;
|
|
|
|
| 96 |
const errorText = await response.text();
|
| 97 |
console.error('Unsplash API error:', response.status, errorText);
|
| 98 |
|
| 99 |
+
return NextResponse.json({
|
| 100 |
+
results: createFallbackResults(query, perPage, page),
|
| 101 |
+
total: perPage,
|
| 102 |
+
total_pages: 1,
|
| 103 |
+
message: `Unsplash unavailable (${response.status}), using fallback results`,
|
| 104 |
+
});
|
| 105 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
const data = await response.json();
|
| 108 |
|
|
|
|
| 112 |
total_pages: data.total_pages,
|
| 113 |
});
|
| 114 |
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error('Image search error:', error);
|
| 117 |
+
return NextResponse.json(
|
| 118 |
+
{
|
| 119 |
+
results: createFallbackResults(
|
| 120 |
+
request.nextUrl.searchParams.get('query') || 'presentation',
|
| 121 |
+
Number(request.nextUrl.searchParams.get('per_page') || '1'),
|
| 122 |
+
Number(request.nextUrl.searchParams.get('page') || '1')
|
| 123 |
+
),
|
| 124 |
+
total: Number(request.nextUrl.searchParams.get('per_page') || '1'),
|
| 125 |
+
total_pages: 1,
|
| 126 |
+
message: error instanceof Error ? error.message : 'Unknown error',
|
| 127 |
+
},
|
| 128 |
+
{ status: 200 }
|
| 129 |
+
);
|
| 130 |
+
}
|
| 131 |
+
}
|
app/globals.css
CHANGED
|
@@ -334,7 +334,7 @@ input::placeholder {
|
|
| 334 |
}
|
| 335 |
|
| 336 |
/* Neo-brutalism template utilities */
|
| 337 |
-
.neo-shadow { box-shadow:
|
| 338 |
-
.neo-shadow-lg { box-shadow:
|
| 339 |
-
.neo-shadow-purple { box-shadow:
|
| 340 |
-
.neo-border { border: 3px solid black; }
|
|
|
|
| 334 |
}
|
| 335 |
|
| 336 |
/* Neo-brutalism template utilities */
|
| 337 |
+
.neo-shadow { box-shadow: 8px 8px 0px 0px rgba(0,0,0,1); }
|
| 338 |
+
.neo-shadow-lg { box-shadow: 10px 10px 0px 0px rgba(0,0,0,1); }
|
| 339 |
+
.neo-shadow-purple { box-shadow: 8px 8px 0px 0px #A000A0; }
|
| 340 |
+
.neo-border { border: 3px solid black; }
|
app/layout.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 12 |
*/
|
| 13 |
|
| 14 |
import type { Metadata } from "next";
|
| 15 |
-
import { Geist, Geist_Mono, Sora, Inter, Roboto, Open_Sans, Lato, Montserrat, Poppins, Playfair_Display, Merriweather } from "next/font/google";
|
| 16 |
import "./globals.css";
|
| 17 |
|
| 18 |
// Primary sans-serif font used throughout the application
|
|
@@ -46,6 +46,11 @@ const roboto = Roboto({
|
|
| 46 |
weight: ["300", "400", "500", "700"],
|
| 47 |
});
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
// Clean, humanist sans-serif for presentations
|
| 50 |
const openSans = Open_Sans({
|
| 51 |
variable: "--font-open-sans",
|
|
@@ -107,7 +112,7 @@ export default function RootLayout({
|
|
| 107 |
return (
|
| 108 |
<html lang="en">
|
| 109 |
<body
|
| 110 |
-
className={`${geistSans.variable} ${geistMono.variable} ${sora.variable} ${inter.variable} ${roboto.variable} ${openSans.variable} ${lato.variable} ${montserrat.variable} ${poppins.variable} ${playfair.variable} ${merriweather.variable} antialiased`}
|
| 111 |
>
|
| 112 |
<ThemeProvider>
|
| 113 |
{children}
|
|
|
|
| 12 |
*/
|
| 13 |
|
| 14 |
import type { Metadata } from "next";
|
| 15 |
+
import { Geist, Geist_Mono, Sora, Inter, Roboto, Roboto_Mono, Open_Sans, Lato, Montserrat, Poppins, Playfair_Display, Merriweather } from "next/font/google";
|
| 16 |
import "./globals.css";
|
| 17 |
|
| 18 |
// Primary sans-serif font used throughout the application
|
|
|
|
| 46 |
weight: ["300", "400", "500", "700"],
|
| 47 |
});
|
| 48 |
|
| 49 |
+
const robotoMono = Roboto_Mono({
|
| 50 |
+
variable: "--font-roboto-mono",
|
| 51 |
+
subsets: ["latin"],
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
// Clean, humanist sans-serif for presentations
|
| 55 |
const openSans = Open_Sans({
|
| 56 |
variable: "--font-open-sans",
|
|
|
|
| 112 |
return (
|
| 113 |
<html lang="en">
|
| 114 |
<body
|
| 115 |
+
className={`${geistSans.variable} ${geistMono.variable} ${sora.variable} ${inter.variable} ${roboto.variable} ${robotoMono.variable} ${openSans.variable} ${lato.variable} ${montserrat.variable} ${poppins.variable} ${playfair.variable} ${merriweather.variable} antialiased`}
|
| 116 |
>
|
| 117 |
<ThemeProvider>
|
| 118 |
{children}
|
app/page.tsx
CHANGED
|
@@ -1,273 +1 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
import React, { useState, useRef, useEffect } from 'react';
|
| 4 |
-
import { useRouter } from 'next/navigation';
|
| 5 |
-
import { LogIn, Moon, Sun, ArrowUp, Check, ChevronDown, User, LogOut } from 'lucide-react';
|
| 6 |
-
import { useTheme } from '@/components/ThemeProvider';
|
| 7 |
-
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
|
| 8 |
-
|
| 9 |
-
export default function HomePage() {
|
| 10 |
-
const [prompt, setPrompt] = useState('');
|
| 11 |
-
const [template, setTemplate] = useState('neobrutalism');
|
| 12 |
-
const [isGenerating, setIsGenerating] = useState(false);
|
| 13 |
-
const [isSelectOpen, setIsSelectOpen] = useState(false);
|
| 14 |
-
const [user, setUser] = useState<{ name: string; avatarUrl?: string } | null>(null);
|
| 15 |
-
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 16 |
-
const selectRef = useRef<HTMLDivElement>(null);
|
| 17 |
-
const { theme, setTheme } = useTheme();
|
| 18 |
-
const [mounted, setMounted] = useState(false);
|
| 19 |
-
|
| 20 |
-
useEffect(() => {
|
| 21 |
-
setMounted(true);
|
| 22 |
-
|
| 23 |
-
const initializeAuth = async () => {
|
| 24 |
-
// Check stored auth
|
| 25 |
-
const stored = localStorage.getItem('hf_oauth');
|
| 26 |
-
if (stored) {
|
| 27 |
-
try {
|
| 28 |
-
const parsed = JSON.parse(stored);
|
| 29 |
-
setUser({ name: parsed.userInfo.name || parsed.userInfo.preferred_username, avatarUrl: parsed.userInfo.avatarUrl || parsed.userInfo.picture || '' });
|
| 30 |
-
if (parsed.accessToken) {
|
| 31 |
-
localStorage.setItem('hf_api_key', parsed.accessToken);
|
| 32 |
-
}
|
| 33 |
-
} catch {
|
| 34 |
-
localStorage.removeItem('hf_oauth');
|
| 35 |
-
}
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
// Handle redirect
|
| 39 |
-
try {
|
| 40 |
-
const result = await oauthHandleRedirectIfPresent();
|
| 41 |
-
if (result) {
|
| 42 |
-
const ui = result.userInfo as any;
|
| 43 |
-
const userData = {
|
| 44 |
-
accessToken: result.accessToken,
|
| 45 |
-
accessTokenExpiresAt: result.accessTokenExpiresAt,
|
| 46 |
-
userInfo: {
|
| 47 |
-
name: ui.preferred_username || ui.name,
|
| 48 |
-
fullname: ui.name || '',
|
| 49 |
-
avatarUrl: ui.picture || ui.avatarUrl || '',
|
| 50 |
-
email: ui.email,
|
| 51 |
-
}
|
| 52 |
-
};
|
| 53 |
-
localStorage.setItem('hf_oauth', JSON.stringify(userData));
|
| 54 |
-
localStorage.setItem('hf_api_key', result.accessToken);
|
| 55 |
-
setUser({ name: userData.userInfo.name, avatarUrl: userData.userInfo.avatarUrl });
|
| 56 |
-
// Clean URL
|
| 57 |
-
window.history.replaceState({}, document.title, window.location.pathname);
|
| 58 |
-
}
|
| 59 |
-
} catch (err) {
|
| 60 |
-
console.error('OAuth error:', err);
|
| 61 |
-
}
|
| 62 |
-
};
|
| 63 |
-
|
| 64 |
-
initializeAuth();
|
| 65 |
-
}, []);
|
| 66 |
-
|
| 67 |
-
const handleSignIn = async () => {
|
| 68 |
-
try {
|
| 69 |
-
const res = await fetch('/api/auth/client-id');
|
| 70 |
-
const { clientId } = await res.json();
|
| 71 |
-
if (!clientId) {
|
| 72 |
-
console.error('HuggingFace Client ID not configured');
|
| 73 |
-
return;
|
| 74 |
-
}
|
| 75 |
-
const loginUrl = await oauthLoginUrl({
|
| 76 |
-
clientId,
|
| 77 |
-
scopes: 'openid profile inference-api',
|
| 78 |
-
redirectUrl: `${window.location.origin}/`,
|
| 79 |
-
});
|
| 80 |
-
window.location.href = loginUrl + '&prompt=consent';
|
| 81 |
-
} catch (err) {
|
| 82 |
-
console.error('Sign in error:', err);
|
| 83 |
-
}
|
| 84 |
-
};
|
| 85 |
-
|
| 86 |
-
const handleSignOut = () => {
|
| 87 |
-
localStorage.removeItem('hf_oauth');
|
| 88 |
-
localStorage.removeItem('hf_api_key');
|
| 89 |
-
setUser(null);
|
| 90 |
-
};
|
| 91 |
-
|
| 92 |
-
const router = useRouter();
|
| 93 |
-
|
| 94 |
-
const templates = [
|
| 95 |
-
'neobrutalism',
|
| 96 |
-
'galeryn',
|
| 97 |
-
'noisy'
|
| 98 |
-
];
|
| 99 |
-
|
| 100 |
-
const handleSubmit = () => {
|
| 101 |
-
if (!prompt.trim()) return;
|
| 102 |
-
setIsGenerating(true);
|
| 103 |
-
|
| 104 |
-
// Store generation parameters (keys must match GoogleSlidesEditor.tsx)
|
| 105 |
-
sessionStorage.setItem('generationPrompt', prompt);
|
| 106 |
-
sessionStorage.setItem('generationModel', 'meta-llama/Llama-3.3-70B-Instruct:together');
|
| 107 |
-
sessionStorage.setItem('isGenerating', 'true');
|
| 108 |
-
localStorage.setItem('ppt_theme', template);
|
| 109 |
-
|
| 110 |
-
router.push('/editor');
|
| 111 |
-
};
|
| 112 |
-
|
| 113 |
-
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
| 114 |
-
if (e.key === 'Enter' && !e.shiftKey) {
|
| 115 |
-
e.preventDefault();
|
| 116 |
-
handleSubmit();
|
| 117 |
-
}
|
| 118 |
-
};
|
| 119 |
-
|
| 120 |
-
// Auto-resize textarea
|
| 121 |
-
useEffect(() => {
|
| 122 |
-
if (textareaRef.current) {
|
| 123 |
-
textareaRef.current.style.height = 'auto';
|
| 124 |
-
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 300)}px`;
|
| 125 |
-
}
|
| 126 |
-
}, [prompt]);
|
| 127 |
-
|
| 128 |
-
// Close select on click outside
|
| 129 |
-
useEffect(() => {
|
| 130 |
-
const handleClickOutside = (event: MouseEvent) => {
|
| 131 |
-
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
|
| 132 |
-
setIsSelectOpen(false);
|
| 133 |
-
}
|
| 134 |
-
};
|
| 135 |
-
document.addEventListener('mousedown', handleClickOutside);
|
| 136 |
-
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 137 |
-
}, []);
|
| 138 |
-
|
| 139 |
-
if (!mounted) return null;
|
| 140 |
-
|
| 141 |
-
return (
|
| 142 |
-
<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">
|
| 143 |
-
{/* Navbar */}
|
| 144 |
-
<nav className="flex items-center justify-between px-6 py-6 font-sans">
|
| 145 |
-
<div className="flex items-center gap-2 text-zinc-950 dark:text-zinc-50">
|
| 146 |
-
<span className="text-xl font-semibold tracking-tight">Slide.ai</span>
|
| 147 |
-
</div>
|
| 148 |
-
|
| 149 |
-
<div className="flex items-center gap-6">
|
| 150 |
-
<button
|
| 151 |
-
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
| 152 |
-
className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-50"
|
| 153 |
-
>
|
| 154 |
-
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
| 155 |
-
<span className="sr-only">Toggle theme</span>
|
| 156 |
-
</button>
|
| 157 |
-
|
| 158 |
-
{user ? (
|
| 159 |
-
<div className="flex items-center gap-4 group">
|
| 160 |
-
<div className="flex items-center gap-2">
|
| 161 |
-
<div className="w-8 h-8 rounded-full overflow-hidden border border-zinc-200 dark:border-zinc-800">
|
| 162 |
-
{user.avatarUrl ? (
|
| 163 |
-
<img src={user.avatarUrl} alt={user.name} className="w-full h-full object-cover" />
|
| 164 |
-
) : (
|
| 165 |
-
<div className="w-full h-full bg-zinc-100 dark:bg-zinc-900 flex items-center justify-center">
|
| 166 |
-
<User className="w-4 h-4 text-zinc-500" />
|
| 167 |
-
</div>
|
| 168 |
-
)}
|
| 169 |
-
</div>
|
| 170 |
-
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 hidden md:block">
|
| 171 |
-
{user.name}
|
| 172 |
-
</span>
|
| 173 |
-
</div>
|
| 174 |
-
<button
|
| 175 |
-
onClick={handleSignOut}
|
| 176 |
-
className="text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-50 transition-colors"
|
| 177 |
-
title="Sign Out"
|
| 178 |
-
>
|
| 179 |
-
<LogOut className="w-4 h-4" />
|
| 180 |
-
</button>
|
| 181 |
-
</div>
|
| 182 |
-
) : (
|
| 183 |
-
<button
|
| 184 |
-
onClick={handleSignIn}
|
| 185 |
-
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"
|
| 186 |
-
>
|
| 187 |
-
<LogIn className="w-4 h-4" />
|
| 188 |
-
<span>Log in with Huggingface</span>
|
| 189 |
-
</button>
|
| 190 |
-
)}
|
| 191 |
-
</div>
|
| 192 |
-
</nav>
|
| 193 |
-
|
| 194 |
-
{/* Main Content */}
|
| 195 |
-
<main className="flex-1 flex flex-col items-center justify-center p-6 md:p-12 w-full max-w-4xl mx-auto space-y-12">
|
| 196 |
-
|
| 197 |
-
{/* Hero Section */}
|
| 198 |
-
<div className="text-center">
|
| 199 |
-
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
|
| 200 |
-
What kind of presentation do you need today?
|
| 201 |
-
</h1>
|
| 202 |
-
</div>
|
| 203 |
-
|
| 204 |
-
{/* Builder Input Section */}
|
| 205 |
-
<div className="w-full max-w-3xl">
|
| 206 |
-
<div className="bg-[#f4f4f5] dark:bg-[#2f2f2f] rounded-[2rem] p-3 md:p-4 flex flex-col transition-all border border-zinc-200 dark:border-zinc-700 shadow-sm focus-within:ring-1 focus-within:ring-zinc-300 dark:focus-within:ring-zinc-500">
|
| 207 |
-
<textarea
|
| 208 |
-
ref={textareaRef}
|
| 209 |
-
value={prompt}
|
| 210 |
-
onChange={(e) => setPrompt(e.target.value)}
|
| 211 |
-
onKeyDown={handleKeyDown}
|
| 212 |
-
placeholder="How can I help you today?"
|
| 213 |
-
className="w-full min-h-[100px] bg-transparent text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-500 dark:placeholder:text-zinc-400 text-base outline-none resize-none px-2 md:px-3 py-2 leading-relaxed"
|
| 214 |
-
style={{ overflow: 'hidden' }}
|
| 215 |
-
/>
|
| 216 |
-
|
| 217 |
-
<div className="flex items-center justify-end gap-2 mt-2 px-2 pb-1">
|
| 218 |
-
<div className="relative" ref={selectRef}>
|
| 219 |
-
<button
|
| 220 |
-
onClick={() => setIsSelectOpen(!isSelectOpen)}
|
| 221 |
-
className="flex h-9 items-center justify-between gap-2 rounded-2xl bg-white dark:bg-[#18181b] border border-zinc-200 dark:border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors shadow-sm"
|
| 222 |
-
>
|
| 223 |
-
<span className="truncate">{template}</span>
|
| 224 |
-
<ChevronDown className={`h-4 w-4 opacity-50 transition-transform ${isSelectOpen ? 'rotate-180' : ''}`} />
|
| 225 |
-
</button>
|
| 226 |
-
|
| 227 |
-
{isSelectOpen && (
|
| 228 |
-
<div className="absolute right-0 bottom-full mb-2 z-50 min-w-[12rem] overflow-hidden rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-[#18181b] text-zinc-950 dark:text-zinc-50 shadow-xl animate-in fade-in slide-in-from-bottom-2">
|
| 229 |
-
<div className="p-1.5">
|
| 230 |
-
{templates.map((t) => (
|
| 231 |
-
<div
|
| 232 |
-
key={t}
|
| 233 |
-
onClick={() => {
|
| 234 |
-
setTemplate(t);
|
| 235 |
-
setIsSelectOpen(false);
|
| 236 |
-
}}
|
| 237 |
-
className="relative flex w-full cursor-pointer select-none items-center rounded-xl py-2 pl-9 pr-4 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
| 238 |
-
>
|
| 239 |
-
<span className="absolute left-3 flex h-4 w-4 items-center justify-center">
|
| 240 |
-
{template === t && <Check className="h-4 w-4" />}
|
| 241 |
-
</span>
|
| 242 |
-
{t}
|
| 243 |
-
</div>
|
| 244 |
-
))}
|
| 245 |
-
</div>
|
| 246 |
-
</div>
|
| 247 |
-
)}
|
| 248 |
-
</div>
|
| 249 |
-
<button
|
| 250 |
-
onClick={handleSubmit}
|
| 251 |
-
disabled={!prompt.trim() || isGenerating}
|
| 252 |
-
className={`flex h-9 w-9 items-center justify-center rounded-2xl transition-all flex-shrink-0 ${
|
| 253 |
-
prompt.trim() && !isGenerating
|
| 254 |
-
? 'bg-black text-white dark:bg-white dark:text-black hover:opacity-80 shadow-md'
|
| 255 |
-
: 'bg-[#e5e5e5] text-zinc-400 dark:bg-zinc-700 dark:text-zinc-500 cursor-not-allowed'
|
| 256 |
-
}`}
|
| 257 |
-
>
|
| 258 |
-
<ArrowUp className="h-5 w-5" />
|
| 259 |
-
</button>
|
| 260 |
-
</div>
|
| 261 |
-
</div>
|
| 262 |
-
</div>
|
| 263 |
-
</main>
|
| 264 |
-
|
| 265 |
-
{/* Brand Icon (Fixed bottom left like in image) */}
|
| 266 |
-
<div className="fixed bottom-6 left-6">
|
| 267 |
-
<div className="w-8 h-8 rounded-full bg-zinc-950 dark:bg-white flex items-center justify-center shadow-lg">
|
| 268 |
-
<span className="text-white dark:text-black font-bold text-xs italic">N</span>
|
| 269 |
-
</div>
|
| 270 |
-
</div>
|
| 271 |
-
</div>
|
| 272 |
-
);
|
| 273 |
-
}
|
|
|
|
| 1 |
+
export { default } from '@/components/home/HomePage';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/UnsplashImageSearch.tsx
CHANGED
|
@@ -138,6 +138,8 @@ export default function UnsplashImageSearch({ onImageSelect, onClose }: Unsplash
|
|
| 138 |
<img
|
| 139 |
src={image.urls.small}
|
| 140 |
alt={image.alt_description || 'Unsplash image'}
|
|
|
|
|
|
|
| 141 |
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
| 142 |
/>
|
| 143 |
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity flex items-center justify-center">
|
|
|
|
| 138 |
<img
|
| 139 |
src={image.urls.small}
|
| 140 |
alt={image.alt_description || 'Unsplash image'}
|
| 141 |
+
loading="lazy"
|
| 142 |
+
referrerPolicy="no-referrer"
|
| 143 |
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
| 144 |
/>
|
| 145 |
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity flex items-center justify-center">
|
components/editor/BottomToolbar.tsx
CHANGED
|
@@ -1,10 +1,7 @@
|
|
| 1 |
import React from "react";
|
| 2 |
import {
|
| 3 |
-
MousePointer2,
|
| 4 |
ChevronDown,
|
| 5 |
Type,
|
| 6 |
-
Copy,
|
| 7 |
-
Link,
|
| 8 |
Image,
|
| 9 |
Search,
|
| 10 |
Minus,
|
|
@@ -27,6 +24,9 @@ interface BottomToolbarProps {
|
|
| 27 |
zoom: number;
|
| 28 |
setZoom: (fn: (z: number) => number) => void;
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
// Selection
|
| 31 |
selectedId: string | null;
|
| 32 |
|
|
@@ -65,6 +65,26 @@ const COLOR_PRESETS = [
|
|
| 65 |
"#547BEE",
|
| 66 |
];
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
function ToolButton({
|
| 69 |
children,
|
| 70 |
title,
|
|
@@ -106,6 +126,7 @@ export default function BottomToolbar(props: BottomToolbarProps) {
|
|
| 106 |
onFileChange,
|
| 107 |
zoom,
|
| 108 |
setZoom,
|
|
|
|
| 109 |
selectedId,
|
| 110 |
availableLayouts,
|
| 111 |
currentLayout,
|
|
@@ -124,65 +145,69 @@ export default function BottomToolbar(props: BottomToolbarProps) {
|
|
| 124 |
const [showColorMenu, setShowColorMenu] = React.useState(false);
|
| 125 |
const [showAddSlideMenu, setShowAddSlideMenu] = React.useState(false);
|
| 126 |
const [customColor, setCustomColor] = React.useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
return (
|
| 129 |
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50">
|
| 130 |
<div className="flex items-center gap-0.5 px-2 py-1.5 bg-white rounded-xl shadow-lg border border-gray-200">
|
| 131 |
-
{
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
<Divider />
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
>
|
| 166 |
-
<Image className="w-4 h-4" />
|
| 167 |
-
Upload image
|
| 168 |
-
</button>
|
| 169 |
-
<button
|
| 170 |
-
onClick={() => { openUnsplashSearch(); setShowImageMenu(false); }}
|
| 171 |
-
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
| 172 |
-
>
|
| 173 |
-
<Search className="w-4 h-4" />
|
| 174 |
-
Search Unsplash
|
| 175 |
-
</button>
|
| 176 |
-
</div>
|
| 177 |
-
</>
|
| 178 |
-
)}
|
| 179 |
-
</div>
|
| 180 |
-
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onFileChange} />
|
| 181 |
|
| 182 |
-
|
|
|
|
|
|
|
| 183 |
|
| 184 |
{/* Layout Selector */}
|
| 185 |
-
{
|
| 186 |
<div className="relative">
|
| 187 |
<ToolButton title="Layout" onClick={() => setShowLayoutMenu(!showLayoutMenu)} dropdown>
|
| 188 |
<Layout className="w-5 h-5" />
|
|
@@ -191,7 +216,7 @@ export default function BottomToolbar(props: BottomToolbarProps) {
|
|
| 191 |
<>
|
| 192 |
<div className="fixed inset-0 z-40" onClick={() => setShowLayoutMenu(false)} />
|
| 193 |
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-gray-200 py-1 z-50 min-w-[180px]">
|
| 194 |
-
{
|
| 195 |
<button
|
| 196 |
key={layout.id}
|
| 197 |
onClick={() => { onLayoutChange(layout.id); setShowLayoutMenu(false); }}
|
|
@@ -207,7 +232,7 @@ export default function BottomToolbar(props: BottomToolbarProps) {
|
|
| 207 |
)}
|
| 208 |
|
| 209 |
{/* Font Selector */}
|
| 210 |
-
{onFontChange && (
|
| 211 |
<div className="relative">
|
| 212 |
<ToolButton title="Font" onClick={() => setShowFontMenu(!showFontMenu)} dropdown>
|
| 213 |
<span className="text-sm font-medium w-5 h-5 flex items-center justify-center">A</span>
|
|
@@ -233,7 +258,7 @@ export default function BottomToolbar(props: BottomToolbarProps) {
|
|
| 233 |
)}
|
| 234 |
|
| 235 |
{/* Color Picker */}
|
| 236 |
-
{onColorChange && (
|
| 237 |
<div className="relative">
|
| 238 |
<ToolButton title="Text Color" onClick={() => setShowColorMenu(!showColorMenu)}>
|
| 239 |
<Palette className="w-5 h-5" />
|
|
@@ -284,10 +309,10 @@ export default function BottomToolbar(props: BottomToolbarProps) {
|
|
| 284 |
</div>
|
| 285 |
)}
|
| 286 |
|
| 287 |
-
<Divider />
|
| 288 |
|
| 289 |
{/* Add Slide with Layout */}
|
| 290 |
-
{onAddSlideWithLayout &&
|
| 291 |
<div className="relative">
|
| 292 |
<ToolButton title="Add Slide" onClick={() => setShowAddSlideMenu(!showAddSlideMenu)} dropdown>
|
| 293 |
<PlusCircle className="w-5 h-5" />
|
|
@@ -297,7 +322,7 @@ export default function BottomToolbar(props: BottomToolbarProps) {
|
|
| 297 |
<div className="fixed inset-0 z-40" onClick={() => setShowAddSlideMenu(false)} />
|
| 298 |
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-gray-200 py-1 z-50 min-w-[180px]">
|
| 299 |
<div className="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Add slide</div>
|
| 300 |
-
{
|
| 301 |
<button
|
| 302 |
key={layout.id}
|
| 303 |
onClick={() => { onAddSlideWithLayout(layout.id); setShowAddSlideMenu(false); }}
|
|
|
|
| 1 |
import React from "react";
|
| 2 |
import {
|
|
|
|
| 3 |
ChevronDown,
|
| 4 |
Type,
|
|
|
|
|
|
|
| 5 |
Image,
|
| 6 |
Search,
|
| 7 |
Minus,
|
|
|
|
| 24 |
zoom: number;
|
| 25 |
setZoom: (fn: (z: number) => number) => void;
|
| 26 |
|
| 27 |
+
// Mode
|
| 28 |
+
isTemplateTheme?: boolean;
|
| 29 |
+
|
| 30 |
// Selection
|
| 31 |
selectedId: string | null;
|
| 32 |
|
|
|
|
| 65 |
"#547BEE",
|
| 66 |
];
|
| 67 |
|
| 68 |
+
const LAYOUT_ORDER: LayoutType[] = [
|
| 69 |
+
"title_subtitle",
|
| 70 |
+
"agenda",
|
| 71 |
+
"title_and_text",
|
| 72 |
+
"three_columns",
|
| 73 |
+
"image_and_text",
|
| 74 |
+
"references",
|
| 75 |
+
"thank_you",
|
| 76 |
+
];
|
| 77 |
+
|
| 78 |
+
const LAYOUT_LABELS: Record<LayoutType, string> = {
|
| 79 |
+
title_subtitle: "Title & Subtitle",
|
| 80 |
+
agenda: "Agenda",
|
| 81 |
+
title_and_text: "Title & Text",
|
| 82 |
+
three_columns: "Three Columns",
|
| 83 |
+
image_and_text: "Image & Text",
|
| 84 |
+
references: "References",
|
| 85 |
+
thank_you: "Thank You",
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
function ToolButton({
|
| 89 |
children,
|
| 90 |
title,
|
|
|
|
| 126 |
onFileChange,
|
| 127 |
zoom,
|
| 128 |
setZoom,
|
| 129 |
+
isTemplateTheme = false,
|
| 130 |
selectedId,
|
| 131 |
availableLayouts,
|
| 132 |
currentLayout,
|
|
|
|
| 145 |
const [showColorMenu, setShowColorMenu] = React.useState(false);
|
| 146 |
const [showAddSlideMenu, setShowAddSlideMenu] = React.useState(false);
|
| 147 |
const [customColor, setCustomColor] = React.useState("");
|
| 148 |
+
const layoutOptions = React.useMemo(() => {
|
| 149 |
+
if (!availableLayouts?.length) return [];
|
| 150 |
+
|
| 151 |
+
const layoutsById = new Map<LayoutType, string>();
|
| 152 |
+
availableLayouts.forEach((layout) => {
|
| 153 |
+
layoutsById.set(layout.id, layout.name || LAYOUT_LABELS[layout.id]);
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
if (!layoutsById.has("three_columns")) {
|
| 157 |
+
layoutsById.set("three_columns", LAYOUT_LABELS.three_columns);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
return LAYOUT_ORDER
|
| 161 |
+
.filter((layoutId) => layoutsById.has(layoutId))
|
| 162 |
+
.map((layoutId) => ({
|
| 163 |
+
id: layoutId,
|
| 164 |
+
name: layoutsById.get(layoutId) || LAYOUT_LABELS[layoutId],
|
| 165 |
+
}));
|
| 166 |
+
}, [availableLayouts]);
|
| 167 |
|
| 168 |
return (
|
| 169 |
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50">
|
| 170 |
<div className="flex items-center gap-0.5 px-2 py-1.5 bg-white rounded-xl shadow-lg border border-gray-200">
|
| 171 |
+
{!isTemplateTheme && (
|
| 172 |
+
<>
|
| 173 |
+
<ToolButton title="Text" onClick={addText}>
|
| 174 |
+
<Type className="w-5 h-5" />
|
| 175 |
+
</ToolButton>
|
|
|
|
| 176 |
|
| 177 |
+
<div className="relative">
|
| 178 |
+
<ToolButton title="Image" onClick={() => setShowImageMenu(!showImageMenu)} dropdown>
|
| 179 |
+
<Image className="w-5 h-5" />
|
| 180 |
+
</ToolButton>
|
| 181 |
+
{showImageMenu && (
|
| 182 |
+
<>
|
| 183 |
+
<div className="fixed inset-0 z-40" onClick={() => setShowImageMenu(false)} />
|
| 184 |
+
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-gray-200 py-1 z-50 min-w-[160px]">
|
| 185 |
+
<button
|
| 186 |
+
onClick={() => { addImage(); setShowImageMenu(false); }}
|
| 187 |
+
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
| 188 |
+
>
|
| 189 |
+
<Image className="w-4 h-4" />
|
| 190 |
+
Upload image
|
| 191 |
+
</button>
|
| 192 |
+
<button
|
| 193 |
+
onClick={() => { openUnsplashSearch(); setShowImageMenu(false); }}
|
| 194 |
+
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
| 195 |
+
>
|
| 196 |
+
<Search className="w-4 h-4" />
|
| 197 |
+
Search Unsplash
|
| 198 |
+
</button>
|
| 199 |
+
</div>
|
| 200 |
+
</>
|
| 201 |
+
)}
|
| 202 |
+
</div>
|
| 203 |
+
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onFileChange} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
+
<Divider />
|
| 206 |
+
</>
|
| 207 |
+
)}
|
| 208 |
|
| 209 |
{/* Layout Selector */}
|
| 210 |
+
{layoutOptions.length > 0 && onLayoutChange && (
|
| 211 |
<div className="relative">
|
| 212 |
<ToolButton title="Layout" onClick={() => setShowLayoutMenu(!showLayoutMenu)} dropdown>
|
| 213 |
<Layout className="w-5 h-5" />
|
|
|
|
| 216 |
<>
|
| 217 |
<div className="fixed inset-0 z-40" onClick={() => setShowLayoutMenu(false)} />
|
| 218 |
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-gray-200 py-1 z-50 min-w-[180px]">
|
| 219 |
+
{layoutOptions.map((layout) => (
|
| 220 |
<button
|
| 221 |
key={layout.id}
|
| 222 |
onClick={() => { onLayoutChange(layout.id); setShowLayoutMenu(false); }}
|
|
|
|
| 232 |
)}
|
| 233 |
|
| 234 |
{/* Font Selector */}
|
| 235 |
+
{!isTemplateTheme && onFontChange && (
|
| 236 |
<div className="relative">
|
| 237 |
<ToolButton title="Font" onClick={() => setShowFontMenu(!showFontMenu)} dropdown>
|
| 238 |
<span className="text-sm font-medium w-5 h-5 flex items-center justify-center">A</span>
|
|
|
|
| 258 |
)}
|
| 259 |
|
| 260 |
{/* Color Picker */}
|
| 261 |
+
{!isTemplateTheme && onColorChange && (
|
| 262 |
<div className="relative">
|
| 263 |
<ToolButton title="Text Color" onClick={() => setShowColorMenu(!showColorMenu)}>
|
| 264 |
<Palette className="w-5 h-5" />
|
|
|
|
| 309 |
</div>
|
| 310 |
)}
|
| 311 |
|
| 312 |
+
{!isTemplateTheme && (onFontChange || onColorChange) && <Divider />}
|
| 313 |
|
| 314 |
{/* Add Slide with Layout */}
|
| 315 |
+
{onAddSlideWithLayout && layoutOptions.length > 0 && (
|
| 316 |
<div className="relative">
|
| 317 |
<ToolButton title="Add Slide" onClick={() => setShowAddSlideMenu(!showAddSlideMenu)} dropdown>
|
| 318 |
<PlusCircle className="w-5 h-5" />
|
|
|
|
| 322 |
<div className="fixed inset-0 z-40" onClick={() => setShowAddSlideMenu(false)} />
|
| 323 |
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-gray-200 py-1 z-50 min-w-[180px]">
|
| 324 |
<div className="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Add slide</div>
|
| 325 |
+
{layoutOptions.map((layout) => (
|
| 326 |
<button
|
| 327 |
key={layout.id}
|
| 328 |
onClick={() => { onAddSlideWithLayout(layout.id); setShowAddSlideMenu(false); }}
|
components/editor/GoogleSlidesEditor.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import { createId, withinBounds, getWorkshopLayoutBackground, createLayoutElemen
|
|
| 30 |
import { useSlideHistory } from '@/hooks/useSlideHistory';
|
| 31 |
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
| 32 |
import { useExport } from '@/hooks/useExport';
|
|
|
|
| 33 |
|
| 34 |
export default function GoogleSlidesEditor() {
|
| 35 |
// Presentation data
|
|
@@ -52,6 +53,7 @@ export default function GoogleSlidesEditor() {
|
|
| 52 |
const [isAIEditing, setIsAIEditing] = useState(false); // AI is processing text
|
| 53 |
const [showAIDialog, setShowAIDialog] = useState(false); // AI dialog visible
|
| 54 |
const [showUnsplashSearch, setShowUnsplashSearch] = useState(false);
|
|
|
|
| 55 |
|
| 56 |
// DOM REFERENCES - For direct DOM manipulation
|
| 57 |
const fileInputRef = useRef<HTMLInputElement>(null); // Hidden file input for images
|
|
@@ -70,20 +72,58 @@ export default function GoogleSlidesEditor() {
|
|
| 70 |
if (spec.id !== slideId) return spec;
|
| 71 |
if (field === 'title') return { ...spec, title: value };
|
| 72 |
if (field === 'subtitle') return { ...spec, subtitle: value };
|
| 73 |
-
if (field === 'body' && index !== undefined
|
| 74 |
-
const newBody = [...spec.body];
|
| 75 |
-
newBody[index] = { ...newBody[index], text: value };
|
| 76 |
return { ...spec, body: newBody };
|
| 77 |
}
|
| 78 |
-
if (field === 'items' && index !== undefined
|
| 79 |
-
const newItems = [...spec.items];
|
| 80 |
newItems[index] = { text: value };
|
| 81 |
return { ...spec, items: newItems };
|
| 82 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
return spec;
|
| 84 |
}));
|
| 85 |
}, []);
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
// User avatar (loaded from localStorage to avoid hydration mismatch)
|
| 88 |
const [avatarUrl, setAvatarUrl] = useState('');
|
| 89 |
const [avatarName, setAvatarName] = useState('');
|
|
@@ -123,7 +163,7 @@ export default function GoogleSlidesEditor() {
|
|
| 123 |
document.head.appendChild(link);
|
| 124 |
|
| 125 |
// Load theme from localStorage if set from homepage
|
| 126 |
-
const savedTheme = localStorage.getItem('ppt_theme');
|
| 127 |
if (savedTheme && savedTheme in themes) {
|
| 128 |
setCurrentTheme(savedTheme as keyof typeof themes);
|
| 129 |
}
|
|
@@ -138,6 +178,11 @@ export default function GoogleSlidesEditor() {
|
|
| 138 |
const generationModel = sessionStorage.getItem('generationModel');
|
| 139 |
const generationImageSource = sessionStorage.getItem('generationImageSource');
|
| 140 |
const isGeneratingFlag = sessionStorage.getItem('isGenerating');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
if (isGeneratingFlag === 'true' && generationPrompt && generationModel) {
|
| 143 |
setIsGenerating(true);
|
|
@@ -177,8 +222,9 @@ export default function GoogleSlidesEditor() {
|
|
| 177 |
const data = await response.json();
|
| 178 |
|
| 179 |
// Transform API response to editor format
|
| 180 |
-
const
|
| 181 |
-
const
|
|
|
|
| 182 |
const generatedSlides: SlideModel[] = data.slides.map((slide: any, index: number) => {
|
| 183 |
const layout = slide.layout || 'titleContent';
|
| 184 |
let elements: EditorElement[] = [];
|
|
@@ -196,7 +242,7 @@ export default function GoogleSlidesEditor() {
|
|
| 196 |
height: 60,
|
| 197 |
text: slide.title || `Slide ${index + 1}`,
|
| 198 |
fontSize: 36,
|
| 199 |
-
color:
|
| 200 |
fontWeight: 'bold',
|
| 201 |
fontStyle: 'normal',
|
| 202 |
textDecoration: 'none',
|
|
@@ -212,7 +258,7 @@ export default function GoogleSlidesEditor() {
|
|
| 212 |
height: 280,
|
| 213 |
text: Array.isArray(slide.content) ? slide.content.join('\n\n') : slide.content || '',
|
| 214 |
fontSize: 18,
|
| 215 |
-
color:
|
| 216 |
fontWeight: 'normal',
|
| 217 |
fontStyle: 'normal',
|
| 218 |
textDecoration: 'none',
|
|
@@ -247,7 +293,7 @@ export default function GoogleSlidesEditor() {
|
|
| 247 |
height: 330,
|
| 248 |
text: leftContent,
|
| 249 |
fontSize: 20,
|
| 250 |
-
color:
|
| 251 |
fontWeight: 'normal',
|
| 252 |
fontStyle: 'normal',
|
| 253 |
textDecoration: 'none',
|
|
@@ -263,7 +309,7 @@ export default function GoogleSlidesEditor() {
|
|
| 263 |
height: 330,
|
| 264 |
text: rightContent,
|
| 265 |
fontSize: 20,
|
| 266 |
-
color:
|
| 267 |
fontWeight: 'normal',
|
| 268 |
fontStyle: 'normal',
|
| 269 |
textDecoration: 'none',
|
|
@@ -271,6 +317,62 @@ export default function GoogleSlidesEditor() {
|
|
| 271 |
fontFamily: bodyFont,
|
| 272 |
} as TextElement
|
| 273 |
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
} else {
|
| 275 |
// Default: Title + Content layout
|
| 276 |
elements = [
|
|
@@ -283,7 +385,7 @@ export default function GoogleSlidesEditor() {
|
|
| 283 |
height: 60,
|
| 284 |
text: slide.title || `Slide ${index + 1}`,
|
| 285 |
fontSize: 36,
|
| 286 |
-
color:
|
| 287 |
fontWeight: 'bold',
|
| 288 |
fontStyle: 'normal',
|
| 289 |
textDecoration: 'none',
|
|
@@ -299,7 +401,7 @@ export default function GoogleSlidesEditor() {
|
|
| 299 |
height: 280,
|
| 300 |
text: Array.isArray(slide.content) ? slide.content.join('\n\n') : slide.content || '',
|
| 301 |
fontSize: 20,
|
| 302 |
-
color:
|
| 303 |
fontWeight: 'normal',
|
| 304 |
fontStyle: 'normal',
|
| 305 |
textDecoration: 'none',
|
|
@@ -325,7 +427,7 @@ export default function GoogleSlidesEditor() {
|
|
| 325 |
// Also create SlideSpec[] for template-based rendering
|
| 326 |
const specs: SlideSpec[] = data.slides.map((slide: any, index: number) => {
|
| 327 |
// Use layout from API if it's a valid LayoutType, otherwise infer
|
| 328 |
-
const validLayouts = ['title_subtitle', 'agenda', 'title_and_text', 'image_and_text', 'references', 'thank_you'];
|
| 329 |
let layout: LayoutType;
|
| 330 |
if (slide.layout && validLayouts.includes(slide.layout)) {
|
| 331 |
layout = slide.layout as LayoutType;
|
|
@@ -337,14 +439,31 @@ export default function GoogleSlidesEditor() {
|
|
| 337 |
layout = 'title_and_text';
|
| 338 |
}
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
return {
|
| 341 |
id: `slide-${index + 1}`,
|
| 342 |
-
templateId:
|
| 343 |
layout,
|
| 344 |
title: slide.title || `Slide ${index + 1}`,
|
| 345 |
subtitle: slide.subtitle || (layout === 'title_subtitle' ? (Array.isArray(slide.content) ? slide.content[0] : slide.content) : undefined),
|
| 346 |
-
body: ['title_subtitle', 'thank_you'].includes(layout)
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
imageUrl: slide.imageUrl || undefined,
|
| 349 |
};
|
| 350 |
});
|
|
@@ -367,7 +486,7 @@ export default function GoogleSlidesEditor() {
|
|
| 367 |
setSlides([
|
| 368 |
{
|
| 369 |
id: createId(),
|
| 370 |
-
elements: createLayoutElements('titleContent',
|
| 371 |
},
|
| 372 |
]);
|
| 373 |
|
|
@@ -382,10 +501,10 @@ export default function GoogleSlidesEditor() {
|
|
| 382 |
} else if (slides.length === 0) {
|
| 383 |
// Initialize with one default slide if no generation
|
| 384 |
const initialSlides = [
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
];
|
| 390 |
setSlides(initialSlides);
|
| 391 |
// Save initial state to history
|
|
@@ -434,7 +553,7 @@ export default function GoogleSlidesEditor() {
|
|
| 434 |
|
| 435 |
// PDF/PPTX export via useExport hook
|
| 436 |
const { exportToPDF, exportToPPTX } = useExport({
|
| 437 |
-
slideRef, slides, currentTheme, currentSlideIndex, zoom,
|
| 438 |
selectedId, isEditingTextId, presentationTitle,
|
| 439 |
setCurrentSlideIndex, setZoom, setSelectedId, setIsEditingTextId,
|
| 440 |
});
|
|
@@ -1239,6 +1358,7 @@ export default function GoogleSlidesEditor() {
|
|
| 1239 |
if (field.key === 'title' && !updated.title) updated.title = field.defaultValue;
|
| 1240 |
if (field.key === 'subtitle' && !updated.subtitle) updated.subtitle = field.defaultValue;
|
| 1241 |
if (field.key === 'body' && !updated.body) updated.body = field.defaultValue;
|
|
|
|
| 1242 |
if (field.key === 'items' && !updated.items) updated.items = field.defaultValue;
|
| 1243 |
if (field.key === 'imageUrl' && !updated.imageUrl) updated.imageUrl = field.defaultValue;
|
| 1244 |
}
|
|
@@ -1285,6 +1405,7 @@ export default function GoogleSlidesEditor() {
|
|
| 1285 |
if (field.key === 'title') newSpec.title = field.defaultValue;
|
| 1286 |
if (field.key === 'subtitle') newSpec.subtitle = field.defaultValue;
|
| 1287 |
if (field.key === 'body') newSpec.body = field.defaultValue;
|
|
|
|
| 1288 |
if (field.key === 'items') newSpec.items = field.defaultValue;
|
| 1289 |
if (field.key === 'imageUrl') newSpec.imageUrl = field.defaultValue;
|
| 1290 |
}
|
|
@@ -1391,12 +1512,14 @@ export default function GoogleSlidesEditor() {
|
|
| 1391 |
<div
|
| 1392 |
className="absolute inset-0 overflow-hidden"
|
| 1393 |
style={{
|
| 1394 |
-
backgroundColor:
|
|
|
|
|
|
|
| 1395 |
}}
|
| 1396 |
>
|
| 1397 |
{isTemplateTheme && slideSpecs[idx] ? (
|
| 1398 |
/* Template thumbnail via SlideFactory */
|
| 1399 |
-
<div style={{ transform: 'scale(0.
|
| 1400 |
{renderSlide(slideSpecs[idx], currentTheme as ThemeName)}
|
| 1401 |
</div>
|
| 1402 |
) : (
|
|
@@ -1521,6 +1644,8 @@ export default function GoogleSlidesEditor() {
|
|
| 1521 |
{renderSlide(slideSpecs[currentSlideIndex], currentTheme as ThemeName, {
|
| 1522 |
isEditable: true,
|
| 1523 |
onFieldUpdate: handleSlideFieldUpdate,
|
|
|
|
|
|
|
| 1524 |
})}
|
| 1525 |
</div>
|
| 1526 |
)}
|
|
@@ -1562,11 +1687,15 @@ export default function GoogleSlidesEditor() {
|
|
| 1562 |
<BottomToolbar
|
| 1563 |
addText={addText}
|
| 1564 |
addImage={addImage}
|
| 1565 |
-
openUnsplashSearch={() =>
|
|
|
|
|
|
|
|
|
|
| 1566 |
fileInputRef={fileInputRef}
|
| 1567 |
onFileChange={onFileChange}
|
| 1568 |
zoom={zoom}
|
| 1569 |
setZoom={setZoom}
|
|
|
|
| 1570 |
selectedId={selectedId}
|
| 1571 |
availableLayouts={templateLayouts}
|
| 1572 |
currentLayout={currentSlideLayout}
|
|
@@ -1584,8 +1713,19 @@ export default function GoogleSlidesEditor() {
|
|
| 1584 |
{/* Unsplash Image Search */}
|
| 1585 |
{showUnsplashSearch && (
|
| 1586 |
<UnsplashImageSearch
|
| 1587 |
-
onImageSelect={(url) => {
|
| 1588 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1589 |
/>
|
| 1590 |
)}
|
| 1591 |
|
|
@@ -1614,5 +1754,3 @@ export default function GoogleSlidesEditor() {
|
|
| 1614 |
</div >
|
| 1615 |
);
|
| 1616 |
}
|
| 1617 |
-
|
| 1618 |
-
|
|
|
|
| 30 |
import { useSlideHistory } from '@/hooks/useSlideHistory';
|
| 31 |
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
| 32 |
import { useExport } from '@/hooks/useExport';
|
| 33 |
+
import { normalizeTemplateId } from '@/lib/template-options';
|
| 34 |
|
| 35 |
export default function GoogleSlidesEditor() {
|
| 36 |
// Presentation data
|
|
|
|
| 53 |
const [isAIEditing, setIsAIEditing] = useState(false); // AI is processing text
|
| 54 |
const [showAIDialog, setShowAIDialog] = useState(false); // AI dialog visible
|
| 55 |
const [showUnsplashSearch, setShowUnsplashSearch] = useState(false);
|
| 56 |
+
const [templateImageSelection, setTemplateImageSelection] = useState<{ slideId: string; field: 'imageUrl' } | null>(null);
|
| 57 |
|
| 58 |
// DOM REFERENCES - For direct DOM manipulation
|
| 59 |
const fileInputRef = useRef<HTMLInputElement>(null); // Hidden file input for images
|
|
|
|
| 72 |
if (spec.id !== slideId) return spec;
|
| 73 |
if (field === 'title') return { ...spec, title: value };
|
| 74 |
if (field === 'subtitle') return { ...spec, subtitle: value };
|
| 75 |
+
if (field === 'body' && index !== undefined) {
|
| 76 |
+
const newBody = [...(spec.body || [])];
|
| 77 |
+
newBody[index] = { ...(newBody[index] || {}), text: value };
|
| 78 |
return { ...spec, body: newBody };
|
| 79 |
}
|
| 80 |
+
if (field === 'items' && index !== undefined) {
|
| 81 |
+
const newItems = [...(spec.items || [])];
|
| 82 |
newItems[index] = { text: value };
|
| 83 |
return { ...spec, items: newItems };
|
| 84 |
}
|
| 85 |
+
if (field === 'columns' && index !== undefined) {
|
| 86 |
+
try {
|
| 87 |
+
const parsed = JSON.parse(value) as { heading?: string; text?: string };
|
| 88 |
+
const newColumns = [...(spec.columns || [])];
|
| 89 |
+
newColumns[index] = {
|
| 90 |
+
heading: parsed.heading || `Column ${index + 1}`,
|
| 91 |
+
text: parsed.text || '',
|
| 92 |
+
};
|
| 93 |
+
return { ...spec, columns: newColumns };
|
| 94 |
+
} catch {
|
| 95 |
+
return spec;
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
if (field === 'imageUrl') return { ...spec, imageUrl: value };
|
| 99 |
return spec;
|
| 100 |
}));
|
| 101 |
}, []);
|
| 102 |
|
| 103 |
+
const handleTemplateImageRequest = useCallback((slideId: string) => {
|
| 104 |
+
setTemplateImageSelection({ slideId, field: 'imageUrl' });
|
| 105 |
+
setShowUnsplashSearch(true);
|
| 106 |
+
}, []);
|
| 107 |
+
|
| 108 |
+
const handleSlideFormattingUpdate = useCallback((slideId: string, key: string, patch: Record<string, unknown>) => {
|
| 109 |
+
setSlideSpecs((prev) =>
|
| 110 |
+
prev.map((spec) => {
|
| 111 |
+
if (spec.id !== slideId) return spec;
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
...spec,
|
| 115 |
+
formatting: {
|
| 116 |
+
...(spec.formatting || {}),
|
| 117 |
+
[key]: {
|
| 118 |
+
...((spec.formatting || {})[key] || {}),
|
| 119 |
+
...patch,
|
| 120 |
+
},
|
| 121 |
+
},
|
| 122 |
+
};
|
| 123 |
+
})
|
| 124 |
+
);
|
| 125 |
+
}, []);
|
| 126 |
+
|
| 127 |
// User avatar (loaded from localStorage to avoid hydration mismatch)
|
| 128 |
const [avatarUrl, setAvatarUrl] = useState('');
|
| 129 |
const [avatarName, setAvatarName] = useState('');
|
|
|
|
| 163 |
document.head.appendChild(link);
|
| 164 |
|
| 165 |
// Load theme from localStorage if set from homepage
|
| 166 |
+
const savedTheme = normalizeTemplateId(localStorage.getItem('ppt_theme'));
|
| 167 |
if (savedTheme && savedTheme in themes) {
|
| 168 |
setCurrentTheme(savedTheme as keyof typeof themes);
|
| 169 |
}
|
|
|
|
| 178 |
const generationModel = sessionStorage.getItem('generationModel');
|
| 179 |
const generationImageSource = sessionStorage.getItem('generationImageSource');
|
| 180 |
const isGeneratingFlag = sessionStorage.getItem('isGenerating');
|
| 181 |
+
const resolvedTheme = (normalizeTemplateId(localStorage.getItem('ppt_theme')) || currentTheme) as keyof typeof themes;
|
| 182 |
+
|
| 183 |
+
if (resolvedTheme !== currentTheme) {
|
| 184 |
+
setCurrentTheme(resolvedTheme);
|
| 185 |
+
}
|
| 186 |
|
| 187 |
if (isGeneratingFlag === 'true' && generationPrompt && generationModel) {
|
| 188 |
setIsGenerating(true);
|
|
|
|
| 222 |
const data = await response.json();
|
| 223 |
|
| 224 |
// Transform API response to editor format
|
| 225 |
+
const activeTheme = themes[resolvedTheme];
|
| 226 |
+
const headingFont = activeTheme.headingFont || 'Arial';
|
| 227 |
+
const bodyFont = activeTheme.bodyFont || 'Arial';
|
| 228 |
const generatedSlides: SlideModel[] = data.slides.map((slide: any, index: number) => {
|
| 229 |
const layout = slide.layout || 'titleContent';
|
| 230 |
let elements: EditorElement[] = [];
|
|
|
|
| 242 |
height: 60,
|
| 243 |
text: slide.title || `Slide ${index + 1}`,
|
| 244 |
fontSize: 36,
|
| 245 |
+
color: activeTheme.titleColor,
|
| 246 |
fontWeight: 'bold',
|
| 247 |
fontStyle: 'normal',
|
| 248 |
textDecoration: 'none',
|
|
|
|
| 258 |
height: 280,
|
| 259 |
text: Array.isArray(slide.content) ? slide.content.join('\n\n') : slide.content || '',
|
| 260 |
fontSize: 18,
|
| 261 |
+
color: activeTheme.textColor,
|
| 262 |
fontWeight: 'normal',
|
| 263 |
fontStyle: 'normal',
|
| 264 |
textDecoration: 'none',
|
|
|
|
| 293 |
height: 330,
|
| 294 |
text: leftContent,
|
| 295 |
fontSize: 20,
|
| 296 |
+
color: activeTheme.textColor,
|
| 297 |
fontWeight: 'normal',
|
| 298 |
fontStyle: 'normal',
|
| 299 |
textDecoration: 'none',
|
|
|
|
| 309 |
height: 330,
|
| 310 |
text: rightContent,
|
| 311 |
fontSize: 20,
|
| 312 |
+
color: activeTheme.textColor,
|
| 313 |
fontWeight: 'normal',
|
| 314 |
fontStyle: 'normal',
|
| 315 |
textDecoration: 'none',
|
|
|
|
| 317 |
fontFamily: bodyFont,
|
| 318 |
} as TextElement
|
| 319 |
];
|
| 320 |
+
} else if (layout === 'three_columns' && Array.isArray(slide.columns) && slide.columns.length > 0) {
|
| 321 |
+
elements = [
|
| 322 |
+
{
|
| 323 |
+
id: createId(),
|
| 324 |
+
type: 'text',
|
| 325 |
+
x: 50,
|
| 326 |
+
y: 30,
|
| 327 |
+
width: 700,
|
| 328 |
+
height: 50,
|
| 329 |
+
text: slide.title || `Slide ${index + 1}`,
|
| 330 |
+
fontSize: 34,
|
| 331 |
+
color: activeTheme.titleColor,
|
| 332 |
+
fontWeight: 'bold',
|
| 333 |
+
fontStyle: 'normal',
|
| 334 |
+
textDecoration: 'none',
|
| 335 |
+
align: 'left',
|
| 336 |
+
fontFamily: headingFont,
|
| 337 |
+
} as TextElement,
|
| 338 |
+
...slide.columns.slice(0, 3).flatMap((column: any, columnIndex: number) => {
|
| 339 |
+
const x = 45 + (columnIndex * 245);
|
| 340 |
+
return [
|
| 341 |
+
{
|
| 342 |
+
id: createId(),
|
| 343 |
+
type: 'text',
|
| 344 |
+
x,
|
| 345 |
+
y: 115,
|
| 346 |
+
width: 210,
|
| 347 |
+
height: 45,
|
| 348 |
+
text: column.heading || `Column ${columnIndex + 1}`,
|
| 349 |
+
fontSize: 22,
|
| 350 |
+
color: activeTheme.titleColor,
|
| 351 |
+
fontWeight: 'bold',
|
| 352 |
+
fontStyle: 'normal',
|
| 353 |
+
textDecoration: 'none',
|
| 354 |
+
align: 'left',
|
| 355 |
+
fontFamily: headingFont,
|
| 356 |
+
} as TextElement,
|
| 357 |
+
{
|
| 358 |
+
id: createId(),
|
| 359 |
+
type: 'text',
|
| 360 |
+
x,
|
| 361 |
+
y: 175,
|
| 362 |
+
width: 210,
|
| 363 |
+
height: 190,
|
| 364 |
+
text: column.text || '',
|
| 365 |
+
fontSize: 16,
|
| 366 |
+
color: activeTheme.textColor,
|
| 367 |
+
fontWeight: 'normal',
|
| 368 |
+
fontStyle: 'normal',
|
| 369 |
+
textDecoration: 'none',
|
| 370 |
+
align: 'left',
|
| 371 |
+
fontFamily: bodyFont,
|
| 372 |
+
} as TextElement,
|
| 373 |
+
];
|
| 374 |
+
}),
|
| 375 |
+
];
|
| 376 |
} else {
|
| 377 |
// Default: Title + Content layout
|
| 378 |
elements = [
|
|
|
|
| 385 |
height: 60,
|
| 386 |
text: slide.title || `Slide ${index + 1}`,
|
| 387 |
fontSize: 36,
|
| 388 |
+
color: activeTheme.titleColor,
|
| 389 |
fontWeight: 'bold',
|
| 390 |
fontStyle: 'normal',
|
| 391 |
textDecoration: 'none',
|
|
|
|
| 401 |
height: 280,
|
| 402 |
text: Array.isArray(slide.content) ? slide.content.join('\n\n') : slide.content || '',
|
| 403 |
fontSize: 20,
|
| 404 |
+
color: activeTheme.textColor,
|
| 405 |
fontWeight: 'normal',
|
| 406 |
fontStyle: 'normal',
|
| 407 |
textDecoration: 'none',
|
|
|
|
| 427 |
// Also create SlideSpec[] for template-based rendering
|
| 428 |
const specs: SlideSpec[] = data.slides.map((slide: any, index: number) => {
|
| 429 |
// Use layout from API if it's a valid LayoutType, otherwise infer
|
| 430 |
+
const validLayouts = ['title_subtitle', 'agenda', 'title_and_text', 'three_columns', 'image_and_text', 'references', 'thank_you'];
|
| 431 |
let layout: LayoutType;
|
| 432 |
if (slide.layout && validLayouts.includes(slide.layout)) {
|
| 433 |
layout = slide.layout as LayoutType;
|
|
|
|
| 439 |
layout = 'title_and_text';
|
| 440 |
}
|
| 441 |
|
| 442 |
+
const contentArray = Array.isArray(slide.content)
|
| 443 |
+
? slide.content
|
| 444 |
+
: (typeof slide.content === 'string' && slide.content.trim() ? [slide.content.trim()] : []);
|
| 445 |
+
const columns = Array.isArray(slide.columns)
|
| 446 |
+
? slide.columns
|
| 447 |
+
.filter((column: any) => column && typeof column === 'object')
|
| 448 |
+
.map((column: any, columnIndex: number) => ({
|
| 449 |
+
heading: String(column.heading || `Column ${columnIndex + 1}`),
|
| 450 |
+
text: String(column.text || ''),
|
| 451 |
+
}))
|
| 452 |
+
: undefined;
|
| 453 |
+
|
| 454 |
return {
|
| 455 |
id: `slide-${index + 1}`,
|
| 456 |
+
templateId: resolvedTheme,
|
| 457 |
layout,
|
| 458 |
title: slide.title || `Slide ${index + 1}`,
|
| 459 |
subtitle: slide.subtitle || (layout === 'title_subtitle' ? (Array.isArray(slide.content) ? slide.content[0] : slide.content) : undefined),
|
| 460 |
+
body: ['title_subtitle', 'thank_you', 'agenda', 'references', 'three_columns'].includes(layout)
|
| 461 |
+
? undefined
|
| 462 |
+
: (contentArray.length ? [{ text: contentArray.join(' ') }] : undefined),
|
| 463 |
+
items: ['agenda', 'references'].includes(layout)
|
| 464 |
+
? contentArray.map((text: string) => ({ text }))
|
| 465 |
+
: undefined,
|
| 466 |
+
columns: layout === 'three_columns' ? columns : undefined,
|
| 467 |
imageUrl: slide.imageUrl || undefined,
|
| 468 |
};
|
| 469 |
});
|
|
|
|
| 486 |
setSlides([
|
| 487 |
{
|
| 488 |
id: createId(),
|
| 489 |
+
elements: createLayoutElements('titleContent', resolvedTheme),
|
| 490 |
},
|
| 491 |
]);
|
| 492 |
|
|
|
|
| 501 |
} else if (slides.length === 0) {
|
| 502 |
// Initialize with one default slide if no generation
|
| 503 |
const initialSlides = [
|
| 504 |
+
{
|
| 505 |
+
id: createId(),
|
| 506 |
+
elements: createLayoutElements('titleContent', resolvedTheme),
|
| 507 |
+
},
|
| 508 |
];
|
| 509 |
setSlides(initialSlides);
|
| 510 |
// Save initial state to history
|
|
|
|
| 553 |
|
| 554 |
// PDF/PPTX export via useExport hook
|
| 555 |
const { exportToPDF, exportToPPTX } = useExport({
|
| 556 |
+
slideRef, slides, slideSpecs, currentTheme, currentSlideIndex, zoom,
|
| 557 |
selectedId, isEditingTextId, presentationTitle,
|
| 558 |
setCurrentSlideIndex, setZoom, setSelectedId, setIsEditingTextId,
|
| 559 |
});
|
|
|
|
| 1358 |
if (field.key === 'title' && !updated.title) updated.title = field.defaultValue;
|
| 1359 |
if (field.key === 'subtitle' && !updated.subtitle) updated.subtitle = field.defaultValue;
|
| 1360 |
if (field.key === 'body' && !updated.body) updated.body = field.defaultValue;
|
| 1361 |
+
if (field.key === 'columns' && !updated.columns) updated.columns = field.defaultValue;
|
| 1362 |
if (field.key === 'items' && !updated.items) updated.items = field.defaultValue;
|
| 1363 |
if (field.key === 'imageUrl' && !updated.imageUrl) updated.imageUrl = field.defaultValue;
|
| 1364 |
}
|
|
|
|
| 1405 |
if (field.key === 'title') newSpec.title = field.defaultValue;
|
| 1406 |
if (field.key === 'subtitle') newSpec.subtitle = field.defaultValue;
|
| 1407 |
if (field.key === 'body') newSpec.body = field.defaultValue;
|
| 1408 |
+
if (field.key === 'columns') newSpec.columns = field.defaultValue;
|
| 1409 |
if (field.key === 'items') newSpec.items = field.defaultValue;
|
| 1410 |
if (field.key === 'imageUrl') newSpec.imageUrl = field.defaultValue;
|
| 1411 |
}
|
|
|
|
| 1512 |
<div
|
| 1513 |
className="absolute inset-0 overflow-hidden"
|
| 1514 |
style={{
|
| 1515 |
+
backgroundColor: isTemplateTheme
|
| 1516 |
+
? 'transparent'
|
| 1517 |
+
: ((themes[currentTheme] as any).solidBackground || (themes[currentTheme] as any).background)
|
| 1518 |
}}
|
| 1519 |
>
|
| 1520 |
{isTemplateTheme && slideSpecs[idx] ? (
|
| 1521 |
/* Template thumbnail via SlideFactory */
|
| 1522 |
+
<div style={{ transform: 'scale(0.25)', transformOrigin: '0 0', width: 800, height: 450 }}>
|
| 1523 |
{renderSlide(slideSpecs[idx], currentTheme as ThemeName)}
|
| 1524 |
</div>
|
| 1525 |
) : (
|
|
|
|
| 1644 |
{renderSlide(slideSpecs[currentSlideIndex], currentTheme as ThemeName, {
|
| 1645 |
isEditable: true,
|
| 1646 |
onFieldUpdate: handleSlideFieldUpdate,
|
| 1647 |
+
onRequestImageSelect: handleTemplateImageRequest,
|
| 1648 |
+
onFormattingUpdate: handleSlideFormattingUpdate,
|
| 1649 |
})}
|
| 1650 |
</div>
|
| 1651 |
)}
|
|
|
|
| 1687 |
<BottomToolbar
|
| 1688 |
addText={addText}
|
| 1689 |
addImage={addImage}
|
| 1690 |
+
openUnsplashSearch={() => {
|
| 1691 |
+
setTemplateImageSelection(null);
|
| 1692 |
+
setShowUnsplashSearch(true);
|
| 1693 |
+
}}
|
| 1694 |
fileInputRef={fileInputRef}
|
| 1695 |
onFileChange={onFileChange}
|
| 1696 |
zoom={zoom}
|
| 1697 |
setZoom={setZoom}
|
| 1698 |
+
isTemplateTheme={isTemplateTheme}
|
| 1699 |
selectedId={selectedId}
|
| 1700 |
availableLayouts={templateLayouts}
|
| 1701 |
currentLayout={currentSlideLayout}
|
|
|
|
| 1713 |
{/* Unsplash Image Search */}
|
| 1714 |
{showUnsplashSearch && (
|
| 1715 |
<UnsplashImageSearch
|
| 1716 |
+
onImageSelect={(url) => {
|
| 1717 |
+
if (templateImageSelection) {
|
| 1718 |
+
handleSlideFieldUpdate(templateImageSelection.slideId, templateImageSelection.field, url);
|
| 1719 |
+
setTemplateImageSelection(null);
|
| 1720 |
+
} else {
|
| 1721 |
+
addImageFromUrl(url);
|
| 1722 |
+
}
|
| 1723 |
+
setShowUnsplashSearch(false);
|
| 1724 |
+
}}
|
| 1725 |
+
onClose={() => {
|
| 1726 |
+
setShowUnsplashSearch(false);
|
| 1727 |
+
setTemplateImageSelection(null);
|
| 1728 |
+
}}
|
| 1729 |
/>
|
| 1730 |
)}
|
| 1731 |
|
|
|
|
| 1754 |
</div >
|
| 1755 |
);
|
| 1756 |
}
|
|
|
|
|
|
components/editor/SlideThumbnailPanel.tsx
CHANGED
|
@@ -29,15 +29,19 @@ export function SlideThumbnailPanel(props: SlideThumbnailPanelProps) {
|
|
| 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
|
| 36 |
-
<div
|
| 37 |
-
{
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
{/* Slide Number */}
|
| 43 |
<div className="absolute top-1 left-1 bg-slate-900/80 text-white text-xs px-1.5 py-0.5 rounded">
|
|
|
|
| 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">
|
components/home/HomePage.tsx
CHANGED
|
@@ -5,10 +5,11 @@ import { useRouter } from 'next/navigation';
|
|
| 5 |
import { LogIn, Moon, Sun, ArrowUp, Check, ChevronDown, User, LogOut } from 'lucide-react';
|
| 6 |
import { useTheme } from '@/components/ThemeProvider';
|
| 7 |
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
|
|
|
|
| 8 |
|
| 9 |
export default function HomePage() {
|
| 10 |
const [prompt, setPrompt] = useState('');
|
| 11 |
-
const [template, setTemplate] = useState('neobrutalism');
|
| 12 |
const [isGenerating, setIsGenerating] = useState(false);
|
| 13 |
const [isSelectOpen, setIsSelectOpen] = useState(false);
|
| 14 |
const [user, setUser] = useState<{ name: string; avatarUrl?: string } | null>(null);
|
|
@@ -19,6 +20,11 @@ export default function HomePage() {
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
setMounted(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
const initializeAuth = async () => {
|
| 24 |
// Check stored auth
|
|
@@ -91,12 +97,6 @@ export default function HomePage() {
|
|
| 91 |
|
| 92 |
const router = useRouter();
|
| 93 |
|
| 94 |
-
const templates = [
|
| 95 |
-
'neobrutalism',
|
| 96 |
-
'galeryn',
|
| 97 |
-
'noisy'
|
| 98 |
-
];
|
| 99 |
-
|
| 100 |
const handleSubmit = () => {
|
| 101 |
if (!prompt.trim()) return;
|
| 102 |
setIsGenerating(true);
|
|
@@ -219,26 +219,26 @@ export default function HomePage() {
|
|
| 219 |
onClick={() => setIsSelectOpen(!isSelectOpen)}
|
| 220 |
className="flex h-9 items-center justify-between gap-2 rounded-xl bg-white dark:bg-[#09090b] border border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors"
|
| 221 |
>
|
| 222 |
-
<span className="truncate">{template}</span>
|
| 223 |
<ChevronDown className={`h-3.5 w-3.5 opacity-50 transition-transform ${isSelectOpen ? 'rotate-180' : ''}`} />
|
| 224 |
</button>
|
| 225 |
|
| 226 |
{isSelectOpen && (
|
| 227 |
<div className="absolute right-0 bottom-full mb-2 z-50 min-w-[12rem] overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-[#09090b] text-zinc-950 dark:text-zinc-50 shadow-xl animate-in fade-in slide-in-from-bottom-2">
|
| 228 |
<div className="p-1">
|
| 229 |
-
{
|
| 230 |
<div
|
| 231 |
-
key={
|
| 232 |
onClick={() => {
|
| 233 |
-
setTemplate(
|
| 234 |
setIsSelectOpen(false);
|
| 235 |
}}
|
| 236 |
className="relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pl-9 pr-4 text-xs font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
| 237 |
>
|
| 238 |
<span className="absolute left-3 flex h-3.5 w-3.5 items-center justify-center">
|
| 239 |
-
{template ===
|
| 240 |
</span>
|
| 241 |
-
{
|
| 242 |
</div>
|
| 243 |
))}
|
| 244 |
</div>
|
|
|
|
| 5 |
import { LogIn, Moon, Sun, ArrowUp, Check, ChevronDown, User, LogOut } from 'lucide-react';
|
| 6 |
import { useTheme } from '@/components/ThemeProvider';
|
| 7 |
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
|
| 8 |
+
import { TEMPLATE_OPTIONS, TemplateOptionId, getTemplateLabel, normalizeTemplateId } from '@/lib/template-options';
|
| 9 |
|
| 10 |
export default function HomePage() {
|
| 11 |
const [prompt, setPrompt] = useState('');
|
| 12 |
+
const [template, setTemplate] = useState<TemplateOptionId>('neobrutalism');
|
| 13 |
const [isGenerating, setIsGenerating] = useState(false);
|
| 14 |
const [isSelectOpen, setIsSelectOpen] = useState(false);
|
| 15 |
const [user, setUser] = useState<{ name: string; avatarUrl?: string } | null>(null);
|
|
|
|
| 20 |
|
| 21 |
useEffect(() => {
|
| 22 |
setMounted(true);
|
| 23 |
+
|
| 24 |
+
const savedTemplate = normalizeTemplateId(localStorage.getItem('ppt_theme'));
|
| 25 |
+
if (savedTemplate) {
|
| 26 |
+
setTemplate(savedTemplate);
|
| 27 |
+
}
|
| 28 |
|
| 29 |
const initializeAuth = async () => {
|
| 30 |
// Check stored auth
|
|
|
|
| 97 |
|
| 98 |
const router = useRouter();
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
const handleSubmit = () => {
|
| 101 |
if (!prompt.trim()) return;
|
| 102 |
setIsGenerating(true);
|
|
|
|
| 219 |
onClick={() => setIsSelectOpen(!isSelectOpen)}
|
| 220 |
className="flex h-9 items-center justify-between gap-2 rounded-xl bg-white dark:bg-[#09090b] border border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors"
|
| 221 |
>
|
| 222 |
+
<span className="truncate">{getTemplateLabel(template)}</span>
|
| 223 |
<ChevronDown className={`h-3.5 w-3.5 opacity-50 transition-transform ${isSelectOpen ? 'rotate-180' : ''}`} />
|
| 224 |
</button>
|
| 225 |
|
| 226 |
{isSelectOpen && (
|
| 227 |
<div className="absolute right-0 bottom-full mb-2 z-50 min-w-[12rem] overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-[#09090b] text-zinc-950 dark:text-zinc-50 shadow-xl animate-in fade-in slide-in-from-bottom-2">
|
| 228 |
<div className="p-1">
|
| 229 |
+
{TEMPLATE_OPTIONS.map((option) => (
|
| 230 |
<div
|
| 231 |
+
key={option.id}
|
| 232 |
onClick={() => {
|
| 233 |
+
setTemplate(option.id);
|
| 234 |
setIsSelectOpen(false);
|
| 235 |
}}
|
| 236 |
className="relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pl-9 pr-4 text-xs font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
| 237 |
>
|
| 238 |
<span className="absolute left-3 flex h-3.5 w-3.5 items-center justify-center">
|
| 239 |
+
{template === option.id && <Check className="h-3.5 w-3.5" />}
|
| 240 |
</span>
|
| 241 |
+
{option.label}
|
| 242 |
</div>
|
| 243 |
))}
|
| 244 |
</div>
|
components/slides/SlideFactory.tsx
CHANGED
|
@@ -3,15 +3,15 @@ import { SlideSpec, getTemplateById, TemplateStyles } from '@/data/templates';
|
|
| 3 |
|
| 4 |
// Template-specific layout components
|
| 5 |
import {
|
| 6 |
-
NeoTitleSubtitle, NeoAgenda, NeoTitleAndText,
|
| 7 |
NeoImageAndText, NeoReferences, NeoThankYou,
|
| 8 |
} from './neobrutalism/layouts';
|
| 9 |
import {
|
| 10 |
-
NoisyTitleSubtitle, NoisyAgenda, NoisyTitleAndText,
|
| 11 |
NoisyImageAndText, NoisyReferences, NoisyThankYou,
|
| 12 |
} from './noisy/layouts';
|
| 13 |
import {
|
| 14 |
-
GalerynTitleSubtitle, GalerynAgenda, GalerynTitleAndText,
|
| 15 |
GalerynImageAndText, GalerynReferences, GalerynThankYou,
|
| 16 |
} from './galeryn/layouts';
|
| 17 |
|
|
@@ -19,6 +19,7 @@ import {
|
|
| 19 |
import TitleSlideLayout from './TitleSlideLayout';
|
| 20 |
import AgendaSlideLayout from './AgendaSlideLayout';
|
| 21 |
import TitleAndBodyLayout from './TitleAndBodyLayout';
|
|
|
|
| 22 |
import ImageAndTextLayout from './ImageAndTextLayout';
|
| 23 |
import ReferenceLayout from './ReferenceLayout';
|
| 24 |
import ThankYouLayout from './ThankYouLayout';
|
|
@@ -29,6 +30,8 @@ export type { SlideSpec } from '@/data/templates';
|
|
| 29 |
export interface RenderSlideOptions {
|
| 30 |
isEditable?: boolean;
|
| 31 |
onFieldUpdate?: (slideId: string, field: string, value: string, index?: number) => void;
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
/**
|
|
@@ -41,7 +44,7 @@ export function renderSlide(
|
|
| 41 |
_theme?: string,
|
| 42 |
options?: RenderSlideOptions
|
| 43 |
): React.ReactNode {
|
| 44 |
-
const { isEditable = false, onFieldUpdate } = options || {};
|
| 45 |
|
| 46 |
const template = getTemplateById(spec.templateId);
|
| 47 |
if (!template) {
|
|
@@ -53,12 +56,15 @@ export function renderSlide(
|
|
| 53 |
title: spec.title || '',
|
| 54 |
subtitle: spec.subtitle,
|
| 55 |
body: spec.body,
|
|
|
|
| 56 |
items: spec.items,
|
| 57 |
imageUrl: spec.imageUrl,
|
|
|
|
| 58 |
styles,
|
| 59 |
slideId: spec.id,
|
| 60 |
isEditable,
|
| 61 |
onFieldUpdate,
|
|
|
|
| 62 |
};
|
| 63 |
|
| 64 |
// Template-specific rendering
|
|
@@ -67,7 +73,8 @@ export function renderSlide(
|
|
| 67 |
case 'title_subtitle': return <NeoTitleSubtitle {...commonProps} />;
|
| 68 |
case 'agenda': return <NeoAgenda {...commonProps} />;
|
| 69 |
case 'title_and_text': return <NeoTitleAndText {...commonProps} />;
|
| 70 |
-
case '
|
|
|
|
| 71 |
case 'references': return <NeoReferences {...commonProps} />;
|
| 72 |
case 'thank_you': return <NeoThankYou {...commonProps} />;
|
| 73 |
}
|
|
@@ -77,8 +84,9 @@ export function renderSlide(
|
|
| 77 |
switch (spec.layout) {
|
| 78 |
case 'title_subtitle': return <NoisyTitleSubtitle {...commonProps} />;
|
| 79 |
case 'agenda': return <NoisyAgenda {...commonProps} />;
|
|
|
|
| 80 |
case 'title_and_text': return <NoisyTitleAndText {...commonProps} />;
|
| 81 |
-
case 'image_and_text': return <NoisyImageAndText {...commonProps} />;
|
| 82 |
case 'references': return <NoisyReferences {...commonProps} />;
|
| 83 |
case 'thank_you': return <NoisyThankYou {...commonProps} />;
|
| 84 |
}
|
|
@@ -88,8 +96,9 @@ export function renderSlide(
|
|
| 88 |
switch (spec.layout) {
|
| 89 |
case 'title_subtitle': return <GalerynTitleSubtitle {...commonProps} />;
|
| 90 |
case 'agenda': return <GalerynAgenda {...commonProps} />;
|
|
|
|
| 91 |
case 'title_and_text': return <GalerynTitleAndText {...commonProps} />;
|
| 92 |
-
case 'image_and_text': return <GalerynImageAndText {...commonProps} />;
|
| 93 |
case 'references': return <GalerynReferences {...commonProps} />;
|
| 94 |
case 'thank_you': return <GalerynThankYou {...commonProps} />;
|
| 95 |
}
|
|
@@ -103,6 +112,8 @@ export function renderSlide(
|
|
| 103 |
return <AgendaSlideLayout title={spec.title || ''} items={spec.items || []} styles={styles} slideId={spec.id} isEditable={isEditable} onFieldUpdate={onFieldUpdate} />;
|
| 104 |
case 'title_and_text':
|
| 105 |
return <TitleAndBodyLayout title={spec.title || ''} body={spec.body || []} styles={styles} slideId={spec.id} isEditable={isEditable} onFieldUpdate={onFieldUpdate} />;
|
|
|
|
|
|
|
| 106 |
case 'image_and_text':
|
| 107 |
return <ImageAndTextLayout title={spec.title || ''} body={spec.body?.[0]?.text || spec.subtitle || ''} imageUrl={spec.imageUrl} styles={styles} slideId={spec.id} isEditable={isEditable} onFieldUpdate={onFieldUpdate} />;
|
| 108 |
case 'references':
|
|
|
|
| 3 |
|
| 4 |
// Template-specific layout components
|
| 5 |
import {
|
| 6 |
+
NeoTitleSubtitle, NeoAgenda, NeoTitleAndText, NeoThreeColumns,
|
| 7 |
NeoImageAndText, NeoReferences, NeoThankYou,
|
| 8 |
} from './neobrutalism/layouts';
|
| 9 |
import {
|
| 10 |
+
NoisyTitleSubtitle, NoisyAgenda, NoisyThreeColumns, NoisyTitleAndText,
|
| 11 |
NoisyImageAndText, NoisyReferences, NoisyThankYou,
|
| 12 |
} from './noisy/layouts';
|
| 13 |
import {
|
| 14 |
+
GalerynTitleSubtitle, GalerynAgenda, GalerynThreeColumns, GalerynTitleAndText,
|
| 15 |
GalerynImageAndText, GalerynReferences, GalerynThankYou,
|
| 16 |
} from './galeryn/layouts';
|
| 17 |
|
|
|
|
| 19 |
import TitleSlideLayout from './TitleSlideLayout';
|
| 20 |
import AgendaSlideLayout from './AgendaSlideLayout';
|
| 21 |
import TitleAndBodyLayout from './TitleAndBodyLayout';
|
| 22 |
+
import ThreeColumnLayout from './ThreeColumnLayout';
|
| 23 |
import ImageAndTextLayout from './ImageAndTextLayout';
|
| 24 |
import ReferenceLayout from './ReferenceLayout';
|
| 25 |
import ThankYouLayout from './ThankYouLayout';
|
|
|
|
| 30 |
export interface RenderSlideOptions {
|
| 31 |
isEditable?: boolean;
|
| 32 |
onFieldUpdate?: (slideId: string, field: string, value: string, index?: number) => void;
|
| 33 |
+
onRequestImageSelect?: (slideId: string) => void;
|
| 34 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 35 |
}
|
| 36 |
|
| 37 |
/**
|
|
|
|
| 44 |
_theme?: string,
|
| 45 |
options?: RenderSlideOptions
|
| 46 |
): React.ReactNode {
|
| 47 |
+
const { isEditable = false, onFieldUpdate, onRequestImageSelect, onFormattingUpdate } = options || {};
|
| 48 |
|
| 49 |
const template = getTemplateById(spec.templateId);
|
| 50 |
if (!template) {
|
|
|
|
| 56 |
title: spec.title || '',
|
| 57 |
subtitle: spec.subtitle,
|
| 58 |
body: spec.body,
|
| 59 |
+
columns: spec.columns,
|
| 60 |
items: spec.items,
|
| 61 |
imageUrl: spec.imageUrl,
|
| 62 |
+
formatting: spec.formatting,
|
| 63 |
styles,
|
| 64 |
slideId: spec.id,
|
| 65 |
isEditable,
|
| 66 |
onFieldUpdate,
|
| 67 |
+
onFormattingUpdate,
|
| 68 |
};
|
| 69 |
|
| 70 |
// Template-specific rendering
|
|
|
|
| 73 |
case 'title_subtitle': return <NeoTitleSubtitle {...commonProps} />;
|
| 74 |
case 'agenda': return <NeoAgenda {...commonProps} />;
|
| 75 |
case 'title_and_text': return <NeoTitleAndText {...commonProps} />;
|
| 76 |
+
case 'three_columns': return <NeoThreeColumns {...commonProps} />;
|
| 77 |
+
case 'image_and_text': return <NeoImageAndText {...commonProps} onRequestImageSelect={onRequestImageSelect} />;
|
| 78 |
case 'references': return <NeoReferences {...commonProps} />;
|
| 79 |
case 'thank_you': return <NeoThankYou {...commonProps} />;
|
| 80 |
}
|
|
|
|
| 84 |
switch (spec.layout) {
|
| 85 |
case 'title_subtitle': return <NoisyTitleSubtitle {...commonProps} />;
|
| 86 |
case 'agenda': return <NoisyAgenda {...commonProps} />;
|
| 87 |
+
case 'three_columns': return <NoisyThreeColumns {...commonProps} />;
|
| 88 |
case 'title_and_text': return <NoisyTitleAndText {...commonProps} />;
|
| 89 |
+
case 'image_and_text': return <NoisyImageAndText {...commonProps} onRequestImageSelect={onRequestImageSelect} />;
|
| 90 |
case 'references': return <NoisyReferences {...commonProps} />;
|
| 91 |
case 'thank_you': return <NoisyThankYou {...commonProps} />;
|
| 92 |
}
|
|
|
|
| 96 |
switch (spec.layout) {
|
| 97 |
case 'title_subtitle': return <GalerynTitleSubtitle {...commonProps} />;
|
| 98 |
case 'agenda': return <GalerynAgenda {...commonProps} />;
|
| 99 |
+
case 'three_columns': return <GalerynThreeColumns {...commonProps} />;
|
| 100 |
case 'title_and_text': return <GalerynTitleAndText {...commonProps} />;
|
| 101 |
+
case 'image_and_text': return <GalerynImageAndText {...commonProps} onRequestImageSelect={onRequestImageSelect} />;
|
| 102 |
case 'references': return <GalerynReferences {...commonProps} />;
|
| 103 |
case 'thank_you': return <GalerynThankYou {...commonProps} />;
|
| 104 |
}
|
|
|
|
| 112 |
return <AgendaSlideLayout title={spec.title || ''} items={spec.items || []} styles={styles} slideId={spec.id} isEditable={isEditable} onFieldUpdate={onFieldUpdate} />;
|
| 113 |
case 'title_and_text':
|
| 114 |
return <TitleAndBodyLayout title={spec.title || ''} body={spec.body || []} styles={styles} slideId={spec.id} isEditable={isEditable} onFieldUpdate={onFieldUpdate} />;
|
| 115 |
+
case 'three_columns':
|
| 116 |
+
return <ThreeColumnLayout title={spec.title || ''} columns={spec.columns || []} styles={styles} slideId={spec.id} isEditable={isEditable} onFieldUpdate={onFieldUpdate} />;
|
| 117 |
case 'image_and_text':
|
| 118 |
return <ImageAndTextLayout title={spec.title || ''} body={spec.body?.[0]?.text || spec.subtitle || ''} imageUrl={spec.imageUrl} styles={styles} slideId={spec.id} isEditable={isEditable} onFieldUpdate={onFieldUpdate} />;
|
| 119 |
case 'references':
|
components/slides/galeryn/layouts.tsx
CHANGED
|
@@ -1,312 +1,636 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
-
import React, {
|
|
|
|
| 4 |
import { TemplateStyles } from '@/data/templates';
|
| 5 |
-
|
| 6 |
-
// ============================================================================
|
| 7 |
-
// SHARED TYPES
|
| 8 |
-
// ============================================================================
|
| 9 |
|
| 10 |
interface LayoutProps {
|
| 11 |
title: string;
|
| 12 |
subtitle?: string;
|
| 13 |
body?: Array<{ heading?: string; text: string }>;
|
|
|
|
| 14 |
items?: Array<{ text: string }>;
|
| 15 |
imageUrl?: string;
|
|
|
|
| 16 |
styles: TemplateStyles;
|
| 17 |
slideId?: string;
|
| 18 |
isEditable?: boolean;
|
| 19 |
onFieldUpdate?: (slideId: string, field: string, value: string, index?: number) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
const COLORS = {
|
| 27 |
-
|
| 28 |
text: '#021d30',
|
| 29 |
-
|
| 30 |
secondary: '#d4e8d4',
|
| 31 |
-
|
| 32 |
-
|
| 33 |
};
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
export function GalerynTitleSubtitle({
|
| 45 |
title,
|
| 46 |
subtitle,
|
|
|
|
| 47 |
styles,
|
| 48 |
slideId,
|
| 49 |
isEditable = false,
|
| 50 |
onFieldUpdate,
|
|
|
|
| 51 |
}: LayoutProps) {
|
| 52 |
-
const
|
| 53 |
-
const [tempTitle, setTempTitle] = useState(title);
|
| 54 |
-
const [tempSubtitle, setTempSubtitle] = useState(subtitle || '');
|
| 55 |
-
|
| 56 |
-
const handleBlur = (field: string) => {
|
| 57 |
-
if (!slideId || !onFieldUpdate) return;
|
| 58 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 59 |
-
if (field === 'subtitle' && tempSubtitle !== subtitle) onFieldUpdate(slideId, 'subtitle', tempSubtitle);
|
| 60 |
-
setEditingField(null);
|
| 61 |
-
};
|
| 62 |
-
|
| 63 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 64 |
-
if (e.key === 'Enter') { e.preventDefault(); handleBlur(field); }
|
| 65 |
-
if (e.key === 'Escape') {
|
| 66 |
-
setEditingField(null);
|
| 67 |
-
if (field === 'title') setTempTitle(title);
|
| 68 |
-
if (field === 'subtitle') setTempSubtitle(subtitle || '');
|
| 69 |
-
}
|
| 70 |
-
};
|
| 71 |
|
| 72 |
return (
|
| 73 |
<div
|
| 74 |
-
|
| 75 |
-
|
|
|
|
| 76 |
>
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
fontSize: '72px',
|
| 88 |
-
fontFamily: FONTS.serif,
|
| 89 |
-
fontWeight: 800,
|
| 90 |
-
color: COLORS.text,
|
| 91 |
-
letterSpacing: '-0.04em',
|
| 92 |
-
lineHeight: 1,
|
| 93 |
-
textTransform: 'uppercase' as const,
|
| 94 |
-
}}
|
| 95 |
-
autoFocus
|
| 96 |
-
/>
|
| 97 |
-
) : (
|
| 98 |
-
<h1
|
| 99 |
-
className={`text-center ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
|
| 100 |
-
style={{
|
| 101 |
-
fontSize: '72px',
|
| 102 |
-
fontFamily: FONTS.serif,
|
| 103 |
-
fontWeight: 800,
|
| 104 |
-
color: COLORS.text,
|
| 105 |
-
letterSpacing: '-0.04em',
|
| 106 |
-
lineHeight: 1,
|
| 107 |
-
textTransform: 'uppercase' as const,
|
| 108 |
-
}}
|
| 109 |
-
onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
|
| 110 |
-
>
|
| 111 |
-
{title || 'Presentation Title'}
|
| 112 |
-
</h1>
|
| 113 |
-
)}
|
| 114 |
-
|
| 115 |
-
{/* Subtitle */}
|
| 116 |
-
{(subtitle || isEditable) && (
|
| 117 |
-
editingField === 'subtitle' ? (
|
| 118 |
-
<input
|
| 119 |
-
type="text"
|
| 120 |
-
value={tempSubtitle}
|
| 121 |
-
onChange={(e) => setTempSubtitle(e.target.value)}
|
| 122 |
-
onBlur={() => handleBlur('subtitle')}
|
| 123 |
-
onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
|
| 124 |
-
className="w-full text-center bg-transparent outline-none mt-6"
|
| 125 |
-
style={{
|
| 126 |
-
fontSize: '12px',
|
| 127 |
-
fontFamily: FONTS.sans,
|
| 128 |
-
color: COLORS.text,
|
| 129 |
-
opacity: 0.8,
|
| 130 |
-
maxWidth: '480px',
|
| 131 |
-
}}
|
| 132 |
-
autoFocus
|
| 133 |
-
/>
|
| 134 |
-
) : (
|
| 135 |
-
<p
|
| 136 |
-
className={`text-center mt-6 ${isEditable ? 'cursor-pointer hover:opacity-60' : ''}`}
|
| 137 |
-
style={{
|
| 138 |
-
fontSize: '12px',
|
| 139 |
-
fontFamily: FONTS.sans,
|
| 140 |
-
color: COLORS.text,
|
| 141 |
-
opacity: 0.8,
|
| 142 |
-
maxWidth: '480px',
|
| 143 |
-
lineHeight: 1.6,
|
| 144 |
-
}}
|
| 145 |
-
onClick={() => { if (isEditable) { setEditingField('subtitle'); setTempSubtitle(subtitle || ''); } }}
|
| 146 |
>
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
</div>
|
| 152 |
);
|
| 153 |
}
|
| 154 |
|
| 155 |
-
// ============================================================================
|
| 156 |
-
// 2. GalerynAgenda
|
| 157 |
-
// ============================================================================
|
| 158 |
-
|
| 159 |
export function GalerynAgenda({
|
| 160 |
title,
|
| 161 |
-
subtitle,
|
| 162 |
items = [],
|
|
|
|
| 163 |
styles,
|
| 164 |
slideId,
|
| 165 |
isEditable = false,
|
| 166 |
onFieldUpdate,
|
|
|
|
| 167 |
}: LayoutProps) {
|
| 168 |
-
const
|
| 169 |
-
const [
|
| 170 |
-
const [tempItems, setTempItems] = useState(items.map((i) => i.text));
|
| 171 |
-
|
| 172 |
-
const handleBlur = (field: string, index?: number) => {
|
| 173 |
-
if (!slideId || !onFieldUpdate) return;
|
| 174 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 175 |
-
if (field === 'items' && index !== undefined && tempItems[index] !== items[index]?.text)
|
| 176 |
-
onFieldUpdate(slideId, 'items', tempItems[index], index);
|
| 177 |
-
setEditingField(null);
|
| 178 |
-
};
|
| 179 |
-
|
| 180 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 181 |
-
if (e.key === 'Enter') { e.preventDefault(); handleBlur(field, index); }
|
| 182 |
-
if (e.key === 'Escape') {
|
| 183 |
-
setEditingField(null);
|
| 184 |
-
if (field === 'title') setTempTitle(title);
|
| 185 |
-
if (field === 'items') setTempItems(items.map((i) => i.text));
|
| 186 |
-
}
|
| 187 |
-
};
|
| 188 |
|
| 189 |
return (
|
| 190 |
<div
|
| 191 |
-
|
| 192 |
-
|
|
|
|
| 193 |
>
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
lineHeight: 1.5,
|
| 207 |
-
}}
|
| 208 |
-
>
|
| 209 |
-
{subtitle || 'A curated overview of topics and themes.'}
|
| 210 |
-
</span>
|
| 211 |
</div>
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
className="bg-transparent outline-none mb-6"
|
| 224 |
-
style={{
|
| 225 |
-
fontSize: '36px',
|
| 226 |
-
fontFamily: FONTS.serif,
|
| 227 |
-
fontWeight: 800,
|
| 228 |
-
color: COLORS.text,
|
| 229 |
-
}}
|
| 230 |
-
autoFocus
|
| 231 |
-
/>
|
| 232 |
-
) : (
|
| 233 |
-
<h2
|
| 234 |
-
className={`mb-6 ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
|
| 235 |
-
style={{
|
| 236 |
-
fontSize: '36px',
|
| 237 |
-
fontFamily: FONTS.serif,
|
| 238 |
-
fontWeight: 800,
|
| 239 |
-
color: COLORS.text,
|
| 240 |
-
lineHeight: 1.1,
|
| 241 |
-
}}
|
| 242 |
-
onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
|
| 243 |
>
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
>
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
autoFocus
|
| 275 |
-
/>
|
| 276 |
-
) : (
|
| 277 |
<span
|
| 278 |
-
className=
|
| 279 |
style={{
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
if (isEditable) {
|
| 287 |
-
setEditingField({ field: 'items', index: i });
|
| 288 |
-
setTempItems(items.map((x) => x.text));
|
| 289 |
-
}
|
| 290 |
}}
|
| 291 |
>
|
| 292 |
-
{
|
| 293 |
</span>
|
| 294 |
-
)}
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
))}
|
| 311 |
</div>
|
| 312 |
</div>
|
|
@@ -314,574 +638,409 @@ export function GalerynAgenda({
|
|
| 314 |
);
|
| 315 |
}
|
| 316 |
|
| 317 |
-
// ============================================================================
|
| 318 |
-
// 3. GalerynTitleAndText
|
| 319 |
-
// ============================================================================
|
| 320 |
-
|
| 321 |
export function GalerynTitleAndText({
|
| 322 |
title,
|
| 323 |
body = [],
|
|
|
|
| 324 |
styles,
|
| 325 |
slideId,
|
| 326 |
isEditable = false,
|
| 327 |
onFieldUpdate,
|
|
|
|
| 328 |
}: LayoutProps) {
|
| 329 |
-
const
|
| 330 |
-
const [
|
| 331 |
-
const [tempBody, setTempBody] = useState(body.map((b) => b.text));
|
| 332 |
-
|
| 333 |
-
const handleBlur = (field: string, index?: number) => {
|
| 334 |
-
if (!slideId || !onFieldUpdate) return;
|
| 335 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 336 |
-
if (field === 'body' && index !== undefined && tempBody[index] !== body[index]?.text)
|
| 337 |
-
onFieldUpdate(slideId, 'body', tempBody[index], index);
|
| 338 |
-
setEditingField(null);
|
| 339 |
-
};
|
| 340 |
-
|
| 341 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 342 |
-
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleBlur(field, index); }
|
| 343 |
-
if (e.key === 'Escape') {
|
| 344 |
-
setEditingField(null);
|
| 345 |
-
if (field === 'title') setTempTitle(title);
|
| 346 |
-
if (field === 'body') setTempBody(body.map((b) => b.text));
|
| 347 |
-
}
|
| 348 |
-
};
|
| 349 |
|
| 350 |
return (
|
| 351 |
<div
|
| 352 |
-
|
| 353 |
-
|
|
|
|
| 354 |
>
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
className="w-
|
| 365 |
-
style={{
|
| 366 |
-
fontSize: '48px',
|
| 367 |
-
fontFamily: FONTS.serif,
|
| 368 |
-
fontWeight: 800,
|
| 369 |
-
color: COLORS.text,
|
| 370 |
-
lineHeight: 1.05,
|
| 371 |
-
letterSpacing: '-0.02em',
|
| 372 |
-
}}
|
| 373 |
-
autoFocus
|
| 374 |
-
/>
|
| 375 |
-
) : (
|
| 376 |
-
<h2
|
| 377 |
-
className={`${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
|
| 378 |
-
style={{
|
| 379 |
-
fontSize: '48px',
|
| 380 |
-
fontFamily: FONTS.serif,
|
| 381 |
-
fontWeight: 800,
|
| 382 |
-
color: COLORS.text,
|
| 383 |
-
lineHeight: 1.05,
|
| 384 |
-
letterSpacing: '-0.02em',
|
| 385 |
-
}}
|
| 386 |
-
onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
|
| 387 |
>
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
lineHeight: 1.7,
|
| 444 |
-
opacity: 0.8,
|
| 445 |
-
}}
|
| 446 |
-
onClick={() => {
|
| 447 |
-
if (isEditable) {
|
| 448 |
-
setEditingField({ field: 'body', index: i });
|
| 449 |
-
setTempBody(body.map((x) => x.text));
|
| 450 |
-
}
|
| 451 |
-
}}
|
| 452 |
-
>
|
| 453 |
-
{b.text}
|
| 454 |
-
</p>
|
| 455 |
-
)}
|
| 456 |
-
</div>
|
| 457 |
-
))}
|
| 458 |
</div>
|
| 459 |
</div>
|
| 460 |
);
|
| 461 |
}
|
| 462 |
|
| 463 |
-
// ============================================================================
|
| 464 |
-
// 4. GalerynImageAndText
|
| 465 |
-
// ============================================================================
|
| 466 |
-
|
| 467 |
export function GalerynImageAndText({
|
| 468 |
title,
|
| 469 |
body = [],
|
| 470 |
imageUrl,
|
|
|
|
| 471 |
styles,
|
| 472 |
slideId,
|
| 473 |
isEditable = false,
|
| 474 |
onFieldUpdate,
|
|
|
|
|
|
|
| 475 |
}: LayoutProps) {
|
| 476 |
-
const
|
| 477 |
-
const
|
| 478 |
-
const [tempBody, setTempBody] = useState(body[0]?.text || '');
|
| 479 |
-
|
| 480 |
-
const handleBlur = (field: string) => {
|
| 481 |
-
if (!slideId || !onFieldUpdate) return;
|
| 482 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 483 |
-
if (field === 'body' && tempBody !== (body[0]?.text || '')) onFieldUpdate(slideId, 'body', tempBody, 0);
|
| 484 |
-
setEditingField(null);
|
| 485 |
-
};
|
| 486 |
-
|
| 487 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 488 |
-
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleBlur(field); }
|
| 489 |
-
if (e.key === 'Escape') {
|
| 490 |
-
setEditingField(null);
|
| 491 |
-
setTempTitle(title);
|
| 492 |
-
setTempBody(body[0]?.text || '');
|
| 493 |
-
}
|
| 494 |
-
};
|
| 495 |
|
| 496 |
return (
|
| 497 |
<div
|
| 498 |
-
|
| 499 |
-
|
|
|
|
| 500 |
>
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
style={{ opacity: 0.2 }}
|
| 518 |
-
>
|
| 519 |
-
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
| 520 |
-
</svg>
|
| 521 |
-
</div>
|
| 522 |
-
)}
|
| 523 |
-
|
| 524 |
-
{/* Small label overlay */}
|
| 525 |
-
<div
|
| 526 |
-
className="absolute bottom-3 left-3 px-2 py-1"
|
| 527 |
-
style={{
|
| 528 |
-
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
| 529 |
-
backdropFilter: 'blur(8px)',
|
| 530 |
-
WebkitBackdropFilter: 'blur(8px)',
|
| 531 |
-
}}
|
| 532 |
>
|
| 533 |
-
<
|
|
|
|
| 534 |
style={{
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
color: COLORS.text,
|
| 539 |
-
textTransform: 'uppercase' as const,
|
| 540 |
-
letterSpacing: '0.1em',
|
| 541 |
-
opacity: 0.7,
|
| 542 |
}}
|
| 543 |
>
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
</div>
|
| 548 |
|
| 549 |
-
{/* Right — Text */}
|
| 550 |
<div
|
| 551 |
-
className="
|
| 552 |
-
style={{ backgroundColor:
|
| 553 |
>
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
}}
|
| 582 |
-
onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
|
| 583 |
-
>
|
| 584 |
-
{title}
|
| 585 |
-
</h2>
|
| 586 |
-
)}
|
| 587 |
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
) : (
|
| 607 |
-
<p
|
| 608 |
-
className={`${isEditable ? 'cursor-pointer hover:opacity-60' : ''}`}
|
| 609 |
-
style={{
|
| 610 |
-
fontSize: '14px',
|
| 611 |
-
fontFamily: FONTS.sans,
|
| 612 |
-
color: COLORS.text,
|
| 613 |
-
lineHeight: 1.7,
|
| 614 |
-
opacity: 0.8,
|
| 615 |
-
}}
|
| 616 |
-
onClick={() => { if (isEditable) { setEditingField('body'); setTempBody(body[0]?.text || ''); } }}
|
| 617 |
-
>
|
| 618 |
-
{body[0]?.text || (isEditable ? 'Click to add description' : '')}
|
| 619 |
-
</p>
|
| 620 |
-
)}
|
| 621 |
</div>
|
| 622 |
</div>
|
| 623 |
);
|
| 624 |
}
|
| 625 |
|
| 626 |
-
// ============================================================================
|
| 627 |
-
// 5. GalerynReferences
|
| 628 |
-
// ============================================================================
|
| 629 |
-
|
| 630 |
export function GalerynReferences({
|
| 631 |
title,
|
| 632 |
items = [],
|
|
|
|
| 633 |
styles,
|
| 634 |
slideId,
|
| 635 |
isEditable = false,
|
| 636 |
onFieldUpdate,
|
|
|
|
| 637 |
}: LayoutProps) {
|
| 638 |
-
const
|
| 639 |
-
const [
|
| 640 |
-
const [tempItems, setTempItems] = useState(items.map((i) => i.text));
|
| 641 |
-
|
| 642 |
-
const handleBlur = (field: string, index?: number) => {
|
| 643 |
-
if (!slideId || !onFieldUpdate) return;
|
| 644 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 645 |
-
if (field === 'items' && index !== undefined && tempItems[index] !== items[index]?.text)
|
| 646 |
-
onFieldUpdate(slideId, 'items', tempItems[index], index);
|
| 647 |
-
setEditingField(null);
|
| 648 |
-
};
|
| 649 |
-
|
| 650 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 651 |
-
if (e.key === 'Enter') { e.preventDefault(); handleBlur(field, index); }
|
| 652 |
-
if (e.key === 'Escape') {
|
| 653 |
-
setEditingField(null);
|
| 654 |
-
if (field === 'title') setTempTitle(title);
|
| 655 |
-
if (field === 'items') setTempItems(items.map((i) => i.text));
|
| 656 |
-
}
|
| 657 |
-
};
|
| 658 |
|
| 659 |
return (
|
| 660 |
<div
|
| 661 |
-
|
| 662 |
-
|
|
|
|
| 663 |
>
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
className="
|
| 673 |
-
style={{
|
| 674 |
-
fontSize: '28px',
|
| 675 |
-
fontFamily: FONTS.serif,
|
| 676 |
-
fontWeight: 700,
|
| 677 |
-
color: COLORS.text,
|
| 678 |
-
}}
|
| 679 |
-
autoFocus
|
| 680 |
-
/>
|
| 681 |
-
) : (
|
| 682 |
-
<h2
|
| 683 |
-
className={`mb-6 ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
|
| 684 |
-
style={{
|
| 685 |
-
fontSize: '28px',
|
| 686 |
-
fontFamily: FONTS.serif,
|
| 687 |
-
fontWeight: 700,
|
| 688 |
-
color: COLORS.text,
|
| 689 |
-
lineHeight: 1.2,
|
| 690 |
-
}}
|
| 691 |
-
onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
|
| 692 |
>
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
className="
|
| 708 |
-
|
| 709 |
-
fontSize: '11px',
|
| 710 |
-
fontFamily: FONTS.sans,
|
| 711 |
-
fontWeight: 600,
|
| 712 |
-
color: COLORS.text,
|
| 713 |
-
opacity: 0.4,
|
| 714 |
-
marginTop: '2px',
|
| 715 |
-
fontVariantNumeric: 'tabular-nums',
|
| 716 |
-
}}
|
| 717 |
>
|
| 718 |
-
|
| 719 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
) : (
|
| 742 |
-
<span
|
| 743 |
-
className={`flex-1 ${isEditable ? 'cursor-pointer hover:opacity-60' : ''}`}
|
| 744 |
-
style={{
|
| 745 |
-
fontSize: '13px',
|
| 746 |
-
fontFamily: FONTS.sans,
|
| 747 |
-
color: COLORS.text,
|
| 748 |
-
lineHeight: 1.6,
|
| 749 |
-
opacity: 0.75,
|
| 750 |
-
}}
|
| 751 |
-
onClick={() => {
|
| 752 |
-
if (isEditable) {
|
| 753 |
-
setEditingField({ field: 'items', index: i });
|
| 754 |
-
setTempItems(items.map((x) => x.text));
|
| 755 |
-
}
|
| 756 |
-
}}
|
| 757 |
-
>
|
| 758 |
-
{item.text}
|
| 759 |
-
</span>
|
| 760 |
-
)}
|
| 761 |
-
</div>
|
| 762 |
-
))}
|
| 763 |
</div>
|
| 764 |
</div>
|
| 765 |
);
|
| 766 |
}
|
| 767 |
|
| 768 |
-
// ============================================================================
|
| 769 |
-
// 6. GalerynThankYou
|
| 770 |
-
// ============================================================================
|
| 771 |
-
|
| 772 |
export function GalerynThankYou({
|
| 773 |
title,
|
| 774 |
subtitle,
|
|
|
|
| 775 |
styles,
|
| 776 |
slideId,
|
| 777 |
isEditable = false,
|
| 778 |
onFieldUpdate,
|
|
|
|
| 779 |
}: LayoutProps) {
|
| 780 |
-
const
|
| 781 |
-
const [tempTitle, setTempTitle] = useState(title);
|
| 782 |
-
const [tempSubtitle, setTempSubtitle] = useState(subtitle || '');
|
| 783 |
-
|
| 784 |
-
const handleBlur = (field: string) => {
|
| 785 |
-
if (!slideId || !onFieldUpdate) return;
|
| 786 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 787 |
-
if (field === 'subtitle' && tempSubtitle !== subtitle) onFieldUpdate(slideId, 'subtitle', tempSubtitle);
|
| 788 |
-
setEditingField(null);
|
| 789 |
-
};
|
| 790 |
-
|
| 791 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 792 |
-
if (e.key === 'Enter') { e.preventDefault(); handleBlur(field); }
|
| 793 |
-
if (e.key === 'Escape') {
|
| 794 |
-
setEditingField(null);
|
| 795 |
-
if (field === 'title') setTempTitle(title);
|
| 796 |
-
if (field === 'subtitle') setTempSubtitle(subtitle || '');
|
| 797 |
-
}
|
| 798 |
-
};
|
| 799 |
|
| 800 |
return (
|
| 801 |
<div
|
| 802 |
-
|
| 803 |
-
|
|
|
|
| 804 |
>
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
className="
|
| 814 |
-
style={{
|
| 815 |
-
fontSize: '52px',
|
| 816 |
-
fontFamily: FONTS.serif,
|
| 817 |
-
fontWeight: 700,
|
| 818 |
-
color: COLORS.bg,
|
| 819 |
-
lineHeight: 1.1,
|
| 820 |
-
}}
|
| 821 |
-
autoFocus
|
| 822 |
-
/>
|
| 823 |
-
) : (
|
| 824 |
-
<h1
|
| 825 |
-
className={`text-center ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
|
| 826 |
-
style={{
|
| 827 |
-
fontSize: '52px',
|
| 828 |
-
fontFamily: FONTS.serif,
|
| 829 |
-
fontWeight: 700,
|
| 830 |
-
color: COLORS.bg,
|
| 831 |
-
lineHeight: 1.1,
|
| 832 |
-
}}
|
| 833 |
-
onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
|
| 834 |
>
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
editingField === 'subtitle' ? (
|
| 842 |
-
<input
|
| 843 |
-
type="text"
|
| 844 |
-
value={tempSubtitle}
|
| 845 |
-
onChange={(e) => setTempSubtitle(e.target.value)}
|
| 846 |
-
onBlur={() => handleBlur('subtitle')}
|
| 847 |
-
onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
|
| 848 |
-
className="w-full text-center bg-transparent outline-none mt-5"
|
| 849 |
-
style={{
|
| 850 |
-
fontSize: '14px',
|
| 851 |
-
fontFamily: FONTS.sans,
|
| 852 |
-
color: COLORS.bg,
|
| 853 |
-
opacity: 0.6,
|
| 854 |
}}
|
| 855 |
-
autoFocus
|
| 856 |
-
/>
|
| 857 |
-
) : (
|
| 858 |
-
<p
|
| 859 |
-
className={`text-center mt-5 ${isEditable ? 'cursor-pointer hover:opacity-40' : ''}`}
|
| 860 |
style={{
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
}}
|
| 867 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
>
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 873 |
|
| 874 |
-
{/* Bottom label */}
|
| 875 |
<div
|
| 876 |
-
className="absolute bottom-
|
| 877 |
style={{
|
| 878 |
-
|
| 879 |
-
|
| 880 |
fontWeight: 600,
|
| 881 |
-
color: COLORS.bg,
|
| 882 |
-
opacity: 0.3,
|
| 883 |
letterSpacing: '0.3em',
|
| 884 |
-
|
|
|
|
| 885 |
}}
|
| 886 |
>
|
| 887 |
GALERYN CO. // 2026
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
+
import React, { useEffect, useRef } from 'react';
|
| 4 |
+
import { GripHorizontal, Image as ImageIcon } from 'lucide-react';
|
| 5 |
import { TemplateStyles } from '@/data/templates';
|
| 6 |
+
import { PersistedDraggableSurface, SlideFormattingMap } from '@/components/slides/shared/PersistedDraggableSurface';
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
interface LayoutProps {
|
| 9 |
title: string;
|
| 10 |
subtitle?: string;
|
| 11 |
body?: Array<{ heading?: string; text: string }>;
|
| 12 |
+
columns?: Array<{ heading: string; text: string }>;
|
| 13 |
items?: Array<{ text: string }>;
|
| 14 |
imageUrl?: string;
|
| 15 |
+
formatting?: SlideFormattingMap;
|
| 16 |
styles: TemplateStyles;
|
| 17 |
slideId?: string;
|
| 18 |
isEditable?: boolean;
|
| 19 |
onFieldUpdate?: (slideId: string, field: string, value: string, index?: number) => void;
|
| 20 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 21 |
+
onRequestImageSelect?: (slideId: string) => void;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface DraggableSurfaceProps {
|
| 25 |
+
children: React.ReactNode;
|
| 26 |
+
constraintsRef: React.RefObject<HTMLDivElement | null>;
|
| 27 |
+
isEditable?: boolean;
|
| 28 |
+
slideId?: string;
|
| 29 |
+
formatKey: string;
|
| 30 |
+
formatting?: SlideFormattingMap;
|
| 31 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 32 |
+
className?: string;
|
| 33 |
+
style?: React.CSSProperties;
|
| 34 |
+
handleClassName?: string;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
interface EditableContentProps {
|
| 38 |
+
value: string;
|
| 39 |
+
as?: React.ElementType;
|
| 40 |
+
className?: string;
|
| 41 |
+
style?: React.CSSProperties;
|
| 42 |
+
isEditable?: boolean;
|
| 43 |
+
multiline?: boolean;
|
| 44 |
+
onCommit?: (value: string) => void;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const DEFAULT_AGENDA_IMAGE =
|
| 48 |
+
'https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&q=80&w=1000';
|
| 49 |
|
| 50 |
const COLORS = {
|
| 51 |
+
background: '#fbf9f4',
|
| 52 |
text: '#021d30',
|
| 53 |
+
surface: '#f5f3ee',
|
| 54 |
secondary: '#d4e8d4',
|
| 55 |
+
placeholder: '#ebe5da',
|
| 56 |
+
white: '#ffffff',
|
| 57 |
};
|
| 58 |
|
| 59 |
+
function stopPointerDown(e: React.PointerEvent<HTMLElement>) {
|
| 60 |
+
e.stopPropagation();
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function readEditableValue(value: string) {
|
| 64 |
+
return value
|
| 65 |
+
.replace(/\u00a0/g, ' ')
|
| 66 |
+
.replace(/\r/g, '')
|
| 67 |
+
.replace(/\n{3,}/g, '\n\n')
|
| 68 |
+
.trim();
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function formatIndex(index: number) {
|
| 72 |
+
return String(index).padStart(2, '0');
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function parseAgendaItem(text: string) {
|
| 76 |
+
const [title, description] = text.split('||').map((part) => part.trim());
|
| 77 |
+
return {
|
| 78 |
+
title: title || text || '',
|
| 79 |
+
description: description || '',
|
| 80 |
+
};
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function serializeAgendaItem(title: string, description: string) {
|
| 84 |
+
return description ? `${title} || ${description}` : title;
|
| 85 |
+
}
|
| 86 |
|
| 87 |
+
function DraggableSurface({
|
| 88 |
+
children,
|
| 89 |
+
constraintsRef,
|
| 90 |
+
isEditable = false,
|
| 91 |
+
slideId,
|
| 92 |
+
formatKey,
|
| 93 |
+
formatting,
|
| 94 |
+
onFormattingUpdate,
|
| 95 |
+
className,
|
| 96 |
+
style,
|
| 97 |
+
handleClassName = '-top-6 left-1/2 -translate-x-1/2',
|
| 98 |
+
}: DraggableSurfaceProps) {
|
| 99 |
+
return (
|
| 100 |
+
<PersistedDraggableSurface
|
| 101 |
+
constraintsRef={constraintsRef}
|
| 102 |
+
slideId={slideId}
|
| 103 |
+
formatKey={formatKey}
|
| 104 |
+
formatting={formatting}
|
| 105 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 106 |
+
isEditable={isEditable}
|
| 107 |
+
className={className}
|
| 108 |
+
style={style}
|
| 109 |
+
handleClassName={handleClassName}
|
| 110 |
+
groupClassName="group"
|
| 111 |
+
handle={
|
| 112 |
+
<div className="rounded border border-black/10 bg-white p-1 shadow-md opacity-0 transition-opacity group-hover:opacity-100">
|
| 113 |
+
<GripHorizontal size={14} className="text-black" />
|
| 114 |
+
</div>
|
| 115 |
+
}
|
| 116 |
+
>
|
| 117 |
+
{children}
|
| 118 |
+
</PersistedDraggableSurface>
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function EditableContent({
|
| 123 |
+
value,
|
| 124 |
+
as = 'div',
|
| 125 |
+
className,
|
| 126 |
+
style,
|
| 127 |
+
isEditable = false,
|
| 128 |
+
multiline = false,
|
| 129 |
+
onCommit,
|
| 130 |
+
}: EditableContentProps) {
|
| 131 |
+
const contentRef = useRef<HTMLElement>(null);
|
| 132 |
+
const Component = as as any;
|
| 133 |
+
|
| 134 |
+
useEffect(() => {
|
| 135 |
+
if (!contentRef.current) return;
|
| 136 |
+
if (document.activeElement === contentRef.current) return;
|
| 137 |
+
if (contentRef.current.innerText !== value) {
|
| 138 |
+
contentRef.current.innerText = value;
|
| 139 |
+
}
|
| 140 |
+
}, [value]);
|
| 141 |
+
|
| 142 |
+
return (
|
| 143 |
+
<Component
|
| 144 |
+
ref={contentRef}
|
| 145 |
+
contentEditable={isEditable}
|
| 146 |
+
suppressContentEditableWarning
|
| 147 |
+
spellCheck={false}
|
| 148 |
+
onPointerDown={stopPointerDown}
|
| 149 |
+
onBlur={() => {
|
| 150 |
+
if (!isEditable || !contentRef.current || !onCommit) return;
|
| 151 |
+
const nextValue = readEditableValue(contentRef.current.innerText);
|
| 152 |
+
if (nextValue !== value) {
|
| 153 |
+
onCommit(nextValue);
|
| 154 |
+
} else if (contentRef.current.innerText !== value) {
|
| 155 |
+
contentRef.current.innerText = value;
|
| 156 |
+
}
|
| 157 |
+
}}
|
| 158 |
+
onKeyDown={(e: React.KeyboardEvent<HTMLElement>) => {
|
| 159 |
+
if (e.key === 'Escape') {
|
| 160 |
+
e.preventDefault();
|
| 161 |
+
if (contentRef.current) {
|
| 162 |
+
contentRef.current.innerText = value;
|
| 163 |
+
}
|
| 164 |
+
(e.currentTarget as HTMLElement).blur();
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
if (!multiline && e.key === 'Enter') {
|
| 168 |
+
e.preventDefault();
|
| 169 |
+
(e.currentTarget as HTMLElement).blur();
|
| 170 |
+
}
|
| 171 |
+
}}
|
| 172 |
+
className={`outline-none ${isEditable ? 'cursor-text' : ''} ${className || ''}`}
|
| 173 |
+
style={{
|
| 174 |
+
whiteSpace: multiline ? 'pre-line' : 'normal',
|
| 175 |
+
...style,
|
| 176 |
+
}}
|
| 177 |
+
>
|
| 178 |
+
{value}
|
| 179 |
+
</Component>
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function EditorialImage({
|
| 184 |
+
src,
|
| 185 |
+
alt,
|
| 186 |
+
constraintsRef,
|
| 187 |
+
isEditable = false,
|
| 188 |
+
slideId,
|
| 189 |
+
formatKey,
|
| 190 |
+
formatting,
|
| 191 |
+
onFormattingUpdate,
|
| 192 |
+
onReplace,
|
| 193 |
+
className,
|
| 194 |
+
children,
|
| 195 |
+
}: {
|
| 196 |
+
src?: string;
|
| 197 |
+
alt: string;
|
| 198 |
+
constraintsRef: React.RefObject<HTMLDivElement | null>;
|
| 199 |
+
isEditable?: boolean;
|
| 200 |
+
slideId?: string;
|
| 201 |
+
formatKey: string;
|
| 202 |
+
formatting?: SlideFormattingMap;
|
| 203 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 204 |
+
onReplace?: () => void;
|
| 205 |
+
className?: string;
|
| 206 |
+
children?: React.ReactNode;
|
| 207 |
+
}) {
|
| 208 |
+
return (
|
| 209 |
+
<DraggableSurface
|
| 210 |
+
constraintsRef={constraintsRef}
|
| 211 |
+
isEditable={isEditable}
|
| 212 |
+
slideId={slideId}
|
| 213 |
+
formatKey={formatKey}
|
| 214 |
+
formatting={formatting}
|
| 215 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 216 |
+
className={className}
|
| 217 |
+
handleClassName="top-4 right-4"
|
| 218 |
+
>
|
| 219 |
+
<div className="relative h-full w-full overflow-hidden">
|
| 220 |
+
{src ? (
|
| 221 |
+
<img
|
| 222 |
+
src={src}
|
| 223 |
+
alt={alt}
|
| 224 |
+
referrerPolicy="no-referrer"
|
| 225 |
+
className="h-full w-full object-cover"
|
| 226 |
+
/>
|
| 227 |
+
) : (
|
| 228 |
+
<div
|
| 229 |
+
className="flex h-full w-full items-center justify-center"
|
| 230 |
+
style={{ backgroundColor: COLORS.placeholder }}
|
| 231 |
+
>
|
| 232 |
+
<ImageIcon size={28} className="text-[#021d30]/25" />
|
| 233 |
+
</div>
|
| 234 |
+
)}
|
| 235 |
+
|
| 236 |
+
{children}
|
| 237 |
+
|
| 238 |
+
{isEditable && onReplace && (
|
| 239 |
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 opacity-0 transition-opacity group-hover:opacity-100">
|
| 240 |
+
<button
|
| 241 |
+
type="button"
|
| 242 |
+
onClick={onReplace}
|
| 243 |
+
onPointerDown={stopPointerDown}
|
| 244 |
+
className="pointer-events-auto inline-flex items-center gap-2 rounded bg-black/70 px-4 py-2 text-sm font-bold text-white transition-colors hover:bg-black/85"
|
| 245 |
+
>
|
| 246 |
+
<ImageIcon size={16} />
|
| 247 |
+
Replace Image
|
| 248 |
+
</button>
|
| 249 |
+
</div>
|
| 250 |
+
)}
|
| 251 |
+
</div>
|
| 252 |
+
</DraggableSurface>
|
| 253 |
+
);
|
| 254 |
+
}
|
| 255 |
|
| 256 |
export function GalerynTitleSubtitle({
|
| 257 |
title,
|
| 258 |
subtitle,
|
| 259 |
+
formatting,
|
| 260 |
styles,
|
| 261 |
slideId,
|
| 262 |
isEditable = false,
|
| 263 |
onFieldUpdate,
|
| 264 |
+
onFormattingUpdate,
|
| 265 |
}: LayoutProps) {
|
| 266 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
return (
|
| 269 |
<div
|
| 270 |
+
ref={slideRef}
|
| 271 |
+
className="h-full w-full overflow-hidden px-12 py-16 md:px-16 md:py-20"
|
| 272 |
+
style={{ backgroundColor: styles.colors.background || COLORS.background }}
|
| 273 |
>
|
| 274 |
+
<div className="flex h-full flex-col justify-center">
|
| 275 |
+
<div className="flex flex-1 items-center justify-center">
|
| 276 |
+
<DraggableSurface
|
| 277 |
+
constraintsRef={slideRef}
|
| 278 |
+
isEditable={isEditable}
|
| 279 |
+
slideId={slideId}
|
| 280 |
+
formatKey="title"
|
| 281 |
+
formatting={formatting}
|
| 282 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 283 |
+
className="mx-auto max-w-full"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
>
|
| 285 |
+
<EditableContent
|
| 286 |
+
as="h1"
|
| 287 |
+
value={title || 'GALERYN'}
|
| 288 |
+
isEditable={isEditable}
|
| 289 |
+
multiline
|
| 290 |
+
onCommit={(value) => {
|
| 291 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 292 |
+
}}
|
| 293 |
+
className="text-center"
|
| 294 |
+
style={{
|
| 295 |
+
fontFamily: styles.fonts.body,
|
| 296 |
+
fontSize: '128px',
|
| 297 |
+
fontWeight: 800,
|
| 298 |
+
letterSpacing: '-0.08em',
|
| 299 |
+
lineHeight: 0.9,
|
| 300 |
+
color: styles.colors.text,
|
| 301 |
+
}}
|
| 302 |
+
/>
|
| 303 |
+
</DraggableSurface>
|
| 304 |
+
</div>
|
| 305 |
+
|
| 306 |
+
{(subtitle || isEditable) && (
|
| 307 |
+
<div className="mx-auto flex max-w-[560px] justify-center">
|
| 308 |
+
<DraggableSurface
|
| 309 |
+
constraintsRef={slideRef}
|
| 310 |
+
isEditable={isEditable}
|
| 311 |
+
slideId={slideId}
|
| 312 |
+
formatKey="subtitle"
|
| 313 |
+
formatting={formatting}
|
| 314 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 315 |
+
className="w-full"
|
| 316 |
+
>
|
| 317 |
+
<EditableContent
|
| 318 |
+
as="p"
|
| 319 |
+
value={subtitle || ''}
|
| 320 |
+
isEditable={isEditable}
|
| 321 |
+
multiline
|
| 322 |
+
onCommit={(value) => {
|
| 323 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'subtitle', value);
|
| 324 |
+
}}
|
| 325 |
+
className="text-center"
|
| 326 |
+
style={{
|
| 327 |
+
fontFamily: styles.fonts.body,
|
| 328 |
+
fontSize: '15px',
|
| 329 |
+
lineHeight: 1.75,
|
| 330 |
+
color: styles.colors.text,
|
| 331 |
+
opacity: 0.9,
|
| 332 |
+
}}
|
| 333 |
+
/>
|
| 334 |
+
</DraggableSurface>
|
| 335 |
+
</div>
|
| 336 |
+
)}
|
| 337 |
+
</div>
|
| 338 |
</div>
|
| 339 |
);
|
| 340 |
}
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
export function GalerynAgenda({
|
| 343 |
title,
|
|
|
|
| 344 |
items = [],
|
| 345 |
+
formatting,
|
| 346 |
styles,
|
| 347 |
slideId,
|
| 348 |
isEditable = false,
|
| 349 |
onFieldUpdate,
|
| 350 |
+
onFormattingUpdate,
|
| 351 |
}: LayoutProps) {
|
| 352 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 353 |
+
const agendaItems = items.length ? items : [{ text: '' }];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
return (
|
| 356 |
<div
|
| 357 |
+
ref={slideRef}
|
| 358 |
+
className="flex h-full w-full overflow-hidden"
|
| 359 |
+
style={{ backgroundColor: styles.colors.background || COLORS.background }}
|
| 360 |
>
|
| 361 |
+
<div className="h-full w-[19%] p-4">
|
| 362 |
+
<EditorialImage
|
| 363 |
+
src={DEFAULT_AGENDA_IMAGE}
|
| 364 |
+
alt="Editorial texture"
|
| 365 |
+
constraintsRef={slideRef}
|
| 366 |
+
isEditable={isEditable}
|
| 367 |
+
slideId={slideId}
|
| 368 |
+
formatKey="agenda-image"
|
| 369 |
+
formatting={formatting}
|
| 370 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 371 |
+
className="h-full w-full"
|
| 372 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
</div>
|
| 374 |
|
| 375 |
+
<div className="flex flex-1 flex-col px-10 py-12 md:px-14 md:py-14">
|
| 376 |
+
<div className="mb-10 flex justify-center">
|
| 377 |
+
<DraggableSurface
|
| 378 |
+
constraintsRef={slideRef}
|
| 379 |
+
isEditable={isEditable}
|
| 380 |
+
slideId={slideId}
|
| 381 |
+
formatKey="agenda-title"
|
| 382 |
+
formatting={formatting}
|
| 383 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 384 |
+
className="w-fit"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
>
|
| 386 |
+
<EditableContent
|
| 387 |
+
as="h2"
|
| 388 |
+
value={title || 'Table of Contents'}
|
| 389 |
+
isEditable={isEditable}
|
| 390 |
+
onCommit={(value) => {
|
| 391 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 392 |
+
}}
|
| 393 |
+
className="text-center"
|
| 394 |
+
style={{
|
| 395 |
+
fontFamily: styles.fonts.body,
|
| 396 |
+
fontSize: '54px',
|
| 397 |
+
fontWeight: 800,
|
| 398 |
+
lineHeight: 1,
|
| 399 |
+
color: styles.colors.text,
|
| 400 |
+
}}
|
| 401 |
+
/>
|
| 402 |
+
</DraggableSurface>
|
| 403 |
+
</div>
|
| 404 |
+
|
| 405 |
+
<div className="flex flex-1 justify-center">
|
| 406 |
+
<div className="flex w-full max-w-[560px] flex-col justify-center">
|
| 407 |
+
{agendaItems.map((item, index) => {
|
| 408 |
+
const parsed = parseAgendaItem(item.text);
|
| 409 |
+
|
| 410 |
+
return (
|
| 411 |
+
<DraggableSurface
|
| 412 |
+
key={`${index}-${item.text}`}
|
| 413 |
+
constraintsRef={slideRef}
|
| 414 |
+
isEditable={isEditable}
|
| 415 |
+
slideId={slideId}
|
| 416 |
+
formatKey={`agenda-item-${index}`}
|
| 417 |
+
formatting={formatting}
|
| 418 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 419 |
+
className="border-b border-[#021d30]/10 py-4"
|
| 420 |
+
handleClassName="-top-4 right-0"
|
| 421 |
+
>
|
| 422 |
+
<div className="flex items-end justify-between gap-6">
|
| 423 |
+
<div className="min-w-0 flex-1">
|
| 424 |
+
<EditableContent
|
| 425 |
+
as="h3"
|
| 426 |
+
value={parsed.title}
|
| 427 |
+
isEditable={isEditable}
|
| 428 |
+
onCommit={(value) => {
|
| 429 |
+
if (slideId && onFieldUpdate) {
|
| 430 |
+
onFieldUpdate(slideId, 'items', serializeAgendaItem(value, parsed.description), index);
|
| 431 |
+
}
|
| 432 |
+
}}
|
| 433 |
+
style={{
|
| 434 |
+
fontFamily: styles.fonts.body,
|
| 435 |
+
fontSize: '22px',
|
| 436 |
+
fontWeight: 700,
|
| 437 |
+
color: styles.colors.text,
|
| 438 |
+
lineHeight: 1.2,
|
| 439 |
+
}}
|
| 440 |
+
/>
|
| 441 |
+
{(parsed.description || isEditable) && (
|
| 442 |
+
<EditableContent
|
| 443 |
+
as="p"
|
| 444 |
+
value={parsed.description}
|
| 445 |
+
isEditable={isEditable}
|
| 446 |
+
multiline
|
| 447 |
+
onCommit={(value) => {
|
| 448 |
+
if (slideId && onFieldUpdate) {
|
| 449 |
+
onFieldUpdate(slideId, 'items', serializeAgendaItem(parsed.title, value), index);
|
| 450 |
+
}
|
| 451 |
+
}}
|
| 452 |
+
className="mt-1 lowercase"
|
| 453 |
+
style={{
|
| 454 |
+
fontFamily: styles.fonts.body,
|
| 455 |
+
fontSize: '12px',
|
| 456 |
+
lineHeight: 1.55,
|
| 457 |
+
color: styles.colors.text,
|
| 458 |
+
opacity: 0.6,
|
| 459 |
+
}}
|
| 460 |
+
/>
|
| 461 |
+
)}
|
| 462 |
+
</div>
|
| 463 |
+
|
| 464 |
+
<span
|
| 465 |
+
className="shrink-0"
|
| 466 |
+
style={{
|
| 467 |
+
fontFamily: styles.fonts.body,
|
| 468 |
+
fontSize: '26px',
|
| 469 |
+
fontWeight: 700,
|
| 470 |
+
color: styles.colors.text,
|
| 471 |
+
}}
|
| 472 |
+
>
|
| 473 |
+
{formatIndex(index + 1)}
|
| 474 |
+
</span>
|
| 475 |
+
</div>
|
| 476 |
+
</DraggableSurface>
|
| 477 |
+
);
|
| 478 |
+
})}
|
| 479 |
+
</div>
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
</div>
|
| 483 |
+
);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
export function GalerynThreeColumns({
|
| 487 |
+
title,
|
| 488 |
+
columns = [],
|
| 489 |
+
formatting,
|
| 490 |
+
styles,
|
| 491 |
+
slideId,
|
| 492 |
+
isEditable = false,
|
| 493 |
+
onFieldUpdate,
|
| 494 |
+
onFormattingUpdate,
|
| 495 |
+
}: LayoutProps) {
|
| 496 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 497 |
+
const columnItems = columns.length ? columns : [{ heading: '', text: '' }];
|
| 498 |
|
| 499 |
+
return (
|
| 500 |
+
<div
|
| 501 |
+
ref={slideRef}
|
| 502 |
+
className="h-full w-full overflow-hidden px-14 py-16 md:px-16 md:py-20"
|
| 503 |
+
style={{ backgroundColor: styles.colors.background || COLORS.background }}
|
| 504 |
+
>
|
| 505 |
+
<div className="flex h-full flex-col justify-center">
|
| 506 |
+
<div className="mb-16 flex items-start justify-between gap-8">
|
| 507 |
+
<DraggableSurface
|
| 508 |
+
constraintsRef={slideRef}
|
| 509 |
+
isEditable={isEditable}
|
| 510 |
+
slideId={slideId}
|
| 511 |
+
formatKey="columns-title"
|
| 512 |
+
formatting={formatting}
|
| 513 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 514 |
+
className="max-w-[520px]"
|
| 515 |
+
>
|
| 516 |
+
<EditableContent
|
| 517 |
+
as="h2"
|
| 518 |
+
value={title || 'Core Principles'}
|
| 519 |
+
isEditable={isEditable}
|
| 520 |
+
onCommit={(value) => {
|
| 521 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 522 |
+
}}
|
| 523 |
+
style={{
|
| 524 |
+
fontFamily: styles.fonts.heading,
|
| 525 |
+
fontSize: '54px',
|
| 526 |
+
fontWeight: 700,
|
| 527 |
+
lineHeight: 1,
|
| 528 |
+
color: styles.colors.text,
|
| 529 |
+
}}
|
| 530 |
+
/>
|
| 531 |
+
</DraggableSurface>
|
| 532 |
+
|
| 533 |
+
<DraggableSurface
|
| 534 |
+
constraintsRef={slideRef}
|
| 535 |
+
isEditable={isEditable}
|
| 536 |
+
slideId={slideId}
|
| 537 |
+
formatKey="columns-tag"
|
| 538 |
+
formatting={formatting}
|
| 539 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 540 |
+
className="shrink-0"
|
| 541 |
+
handleClassName="-top-5 right-0"
|
| 542 |
+
>
|
| 543 |
+
<span
|
| 544 |
+
style={{
|
| 545 |
+
fontFamily: styles.fonts.body,
|
| 546 |
+
fontSize: '13px',
|
| 547 |
+
fontWeight: 600,
|
| 548 |
+
letterSpacing: '0.08em',
|
| 549 |
+
color: styles.colors.text,
|
| 550 |
+
opacity: 0.55,
|
| 551 |
+
}}
|
| 552 |
>
|
| 553 |
+
001 // Design
|
| 554 |
+
</span>
|
| 555 |
+
</DraggableSurface>
|
| 556 |
+
</div>
|
| 557 |
+
|
| 558 |
+
<div className="grid grid-cols-3 gap-10">
|
| 559 |
+
{columnItems.map((column, index) => (
|
| 560 |
+
<DraggableSurface
|
| 561 |
+
key={`${index}-${column.heading}-${column.text}`}
|
| 562 |
+
constraintsRef={slideRef}
|
| 563 |
+
isEditable={isEditable}
|
| 564 |
+
slideId={slideId}
|
| 565 |
+
formatKey={`column-${index}`}
|
| 566 |
+
formatting={formatting}
|
| 567 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 568 |
+
className="h-full"
|
| 569 |
+
>
|
| 570 |
+
<div className="flex h-full flex-col items-start">
|
|
|
|
|
|
|
|
|
|
| 571 |
<span
|
| 572 |
+
className="mb-5"
|
| 573 |
style={{
|
| 574 |
+
fontFamily: styles.fonts.body,
|
| 575 |
+
fontSize: '13px',
|
| 576 |
+
fontWeight: 600,
|
| 577 |
+
letterSpacing: '0.08em',
|
| 578 |
+
color: styles.colors.text,
|
| 579 |
+
opacity: 0.55,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
}}
|
| 581 |
>
|
| 582 |
+
{formatIndex(index + 1)}
|
| 583 |
</span>
|
|
|
|
| 584 |
|
| 585 |
+
<EditableContent
|
| 586 |
+
as="h3"
|
| 587 |
+
value={column.heading}
|
| 588 |
+
isEditable={isEditable}
|
| 589 |
+
onCommit={(value) => {
|
| 590 |
+
if (slideId && onFieldUpdate) {
|
| 591 |
+
onFieldUpdate(
|
| 592 |
+
slideId,
|
| 593 |
+
'columns',
|
| 594 |
+
JSON.stringify({ heading: value, text: column.text }),
|
| 595 |
+
index
|
| 596 |
+
);
|
| 597 |
+
}
|
| 598 |
+
}}
|
| 599 |
+
className="mb-5"
|
| 600 |
+
style={{
|
| 601 |
+
fontFamily: styles.fonts.body,
|
| 602 |
+
fontSize: '28px',
|
| 603 |
+
fontWeight: 700,
|
| 604 |
+
lineHeight: 1.1,
|
| 605 |
+
color: styles.colors.text,
|
| 606 |
+
}}
|
| 607 |
+
/>
|
| 608 |
+
|
| 609 |
+
<EditableContent
|
| 610 |
+
as="p"
|
| 611 |
+
value={column.text}
|
| 612 |
+
isEditable={isEditable}
|
| 613 |
+
multiline
|
| 614 |
+
onCommit={(value) => {
|
| 615 |
+
if (slideId && onFieldUpdate) {
|
| 616 |
+
onFieldUpdate(
|
| 617 |
+
slideId,
|
| 618 |
+
'columns',
|
| 619 |
+
JSON.stringify({ heading: column.heading, text: value }),
|
| 620 |
+
index
|
| 621 |
+
);
|
| 622 |
+
}
|
| 623 |
+
}}
|
| 624 |
+
style={{
|
| 625 |
+
fontFamily: styles.fonts.body,
|
| 626 |
+
fontSize: '15px',
|
| 627 |
+
lineHeight: 1.75,
|
| 628 |
+
color: styles.colors.text,
|
| 629 |
+
opacity: 0.72,
|
| 630 |
+
}}
|
| 631 |
+
/>
|
| 632 |
+
</div>
|
| 633 |
+
</DraggableSurface>
|
| 634 |
))}
|
| 635 |
</div>
|
| 636 |
</div>
|
|
|
|
| 638 |
);
|
| 639 |
}
|
| 640 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
export function GalerynTitleAndText({
|
| 642 |
title,
|
| 643 |
body = [],
|
| 644 |
+
formatting,
|
| 645 |
styles,
|
| 646 |
slideId,
|
| 647 |
isEditable = false,
|
| 648 |
onFieldUpdate,
|
| 649 |
+
onFormattingUpdate,
|
| 650 |
}: LayoutProps) {
|
| 651 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 652 |
+
const bodyItems = body.length ? body : [{ text: '' }];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
|
| 654 |
return (
|
| 655 |
<div
|
| 656 |
+
ref={slideRef}
|
| 657 |
+
className="flex h-full w-full items-center overflow-hidden px-14 py-16 md:px-16"
|
| 658 |
+
style={{ backgroundColor: styles.colors.background || COLORS.background }}
|
| 659 |
>
|
| 660 |
+
<div className="grid w-full grid-cols-12 gap-10">
|
| 661 |
+
<div className="col-span-7 flex items-start">
|
| 662 |
+
<DraggableSurface
|
| 663 |
+
constraintsRef={slideRef}
|
| 664 |
+
isEditable={isEditable}
|
| 665 |
+
slideId={slideId}
|
| 666 |
+
formatKey="title-and-text-title"
|
| 667 |
+
formatting={formatting}
|
| 668 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 669 |
+
className="max-w-[440px]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
>
|
| 671 |
+
<EditableContent
|
| 672 |
+
as="h2"
|
| 673 |
+
value={title || 'The Architectural Precision of Design'}
|
| 674 |
+
isEditable={isEditable}
|
| 675 |
+
multiline
|
| 676 |
+
onCommit={(value) => {
|
| 677 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 678 |
+
}}
|
| 679 |
+
style={{
|
| 680 |
+
fontFamily: styles.fonts.heading,
|
| 681 |
+
fontSize: '68px',
|
| 682 |
+
fontWeight: 700,
|
| 683 |
+
lineHeight: 0.95,
|
| 684 |
+
letterSpacing: '-0.04em',
|
| 685 |
+
color: styles.colors.text,
|
| 686 |
+
}}
|
| 687 |
+
/>
|
| 688 |
+
</DraggableSurface>
|
| 689 |
+
</div>
|
| 690 |
|
| 691 |
+
<div className="col-span-5 flex flex-col justify-end gap-5">
|
| 692 |
+
{bodyItems.map((entry, index) => (
|
| 693 |
+
<DraggableSurface
|
| 694 |
+
key={`${index}-${entry.text}`}
|
| 695 |
+
constraintsRef={slideRef}
|
| 696 |
+
isEditable={isEditable}
|
| 697 |
+
slideId={slideId}
|
| 698 |
+
formatKey={`title-and-text-body-${index}`}
|
| 699 |
+
formatting={formatting}
|
| 700 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 701 |
+
className="w-full"
|
| 702 |
+
handleClassName="-top-4 right-0"
|
| 703 |
+
>
|
| 704 |
+
<div>
|
| 705 |
+
{entry.heading && (
|
| 706 |
+
<div
|
| 707 |
+
className="mb-2 uppercase"
|
| 708 |
+
style={{
|
| 709 |
+
fontFamily: styles.fonts.body,
|
| 710 |
+
fontSize: '11px',
|
| 711 |
+
fontWeight: 700,
|
| 712 |
+
letterSpacing: '0.12em',
|
| 713 |
+
color: styles.colors.text,
|
| 714 |
+
opacity: 0.45,
|
| 715 |
+
}}
|
| 716 |
+
>
|
| 717 |
+
{entry.heading}
|
| 718 |
+
</div>
|
| 719 |
+
)}
|
| 720 |
+
|
| 721 |
+
<EditableContent
|
| 722 |
+
as="p"
|
| 723 |
+
value={entry.text}
|
| 724 |
+
isEditable={isEditable}
|
| 725 |
+
multiline
|
| 726 |
+
onCommit={(value) => {
|
| 727 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'body', value, index);
|
| 728 |
+
}}
|
| 729 |
+
style={{
|
| 730 |
+
fontFamily: styles.fonts.body,
|
| 731 |
+
fontSize: '16px',
|
| 732 |
+
lineHeight: 1.75,
|
| 733 |
+
color: styles.colors.text,
|
| 734 |
+
opacity: 0.8,
|
| 735 |
+
}}
|
| 736 |
+
/>
|
| 737 |
+
</div>
|
| 738 |
+
</DraggableSurface>
|
| 739 |
+
))}
|
| 740 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
</div>
|
| 742 |
</div>
|
| 743 |
);
|
| 744 |
}
|
| 745 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
export function GalerynImageAndText({
|
| 747 |
title,
|
| 748 |
body = [],
|
| 749 |
imageUrl,
|
| 750 |
+
formatting,
|
| 751 |
styles,
|
| 752 |
slideId,
|
| 753 |
isEditable = false,
|
| 754 |
onFieldUpdate,
|
| 755 |
+
onFormattingUpdate,
|
| 756 |
+
onRequestImageSelect,
|
| 757 |
}: LayoutProps) {
|
| 758 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 759 |
+
const bodyText = body[0]?.text || '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
|
| 761 |
return (
|
| 762 |
<div
|
| 763 |
+
ref={slideRef}
|
| 764 |
+
className="flex h-full w-full overflow-hidden"
|
| 765 |
+
style={{ backgroundColor: styles.colors.background || COLORS.background }}
|
| 766 |
>
|
| 767 |
+
<div className="h-full w-1/2 p-4 pr-2">
|
| 768 |
+
<EditorialImage
|
| 769 |
+
src={imageUrl}
|
| 770 |
+
alt={title || 'Galeryn image'}
|
| 771 |
+
constraintsRef={slideRef}
|
| 772 |
+
isEditable={isEditable}
|
| 773 |
+
slideId={slideId}
|
| 774 |
+
formatKey="image-card"
|
| 775 |
+
formatting={formatting}
|
| 776 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 777 |
+
onReplace={
|
| 778 |
+
isEditable && slideId && onRequestImageSelect
|
| 779 |
+
? () => onRequestImageSelect(slideId)
|
| 780 |
+
: undefined
|
| 781 |
+
}
|
| 782 |
+
className="h-full w-full"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 783 |
>
|
| 784 |
+
<div
|
| 785 |
+
className="absolute bottom-8 left-8 px-4 py-2"
|
| 786 |
style={{
|
| 787 |
+
backgroundColor: 'rgba(255,255,255,0.5)',
|
| 788 |
+
backdropFilter: 'blur(12px)',
|
| 789 |
+
WebkitBackdropFilter: 'blur(12px)',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
}}
|
| 791 |
>
|
| 792 |
+
<span
|
| 793 |
+
style={{
|
| 794 |
+
fontFamily: styles.fonts.body,
|
| 795 |
+
fontSize: '12px',
|
| 796 |
+
fontWeight: 600,
|
| 797 |
+
color: styles.colors.text,
|
| 798 |
+
letterSpacing: '0.08em',
|
| 799 |
+
}}
|
| 800 |
+
>
|
| 801 |
+
001 Stories
|
| 802 |
+
</span>
|
| 803 |
+
</div>
|
| 804 |
+
</EditorialImage>
|
| 805 |
</div>
|
| 806 |
|
|
|
|
| 807 |
<div
|
| 808 |
+
className="flex h-full w-1/2 items-center p-12 md:p-16"
|
| 809 |
+
style={{ backgroundColor: styles.colors.cardBg || COLORS.surface }}
|
| 810 |
>
|
| 811 |
+
<DraggableSurface
|
| 812 |
+
constraintsRef={slideRef}
|
| 813 |
+
isEditable={isEditable}
|
| 814 |
+
slideId={slideId}
|
| 815 |
+
formatKey="image-text-content"
|
| 816 |
+
formatting={formatting}
|
| 817 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 818 |
+
className="w-full"
|
| 819 |
+
>
|
| 820 |
+
<div className="flex flex-col items-start">
|
| 821 |
+
<EditableContent
|
| 822 |
+
as="h2"
|
| 823 |
+
value={title || 'Curated Experiences'}
|
| 824 |
+
isEditable={isEditable}
|
| 825 |
+
multiline
|
| 826 |
+
onCommit={(value) => {
|
| 827 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 828 |
+
}}
|
| 829 |
+
className="mb-8"
|
| 830 |
+
style={{
|
| 831 |
+
fontFamily: styles.fonts.heading,
|
| 832 |
+
fontSize: '54px',
|
| 833 |
+
fontWeight: 700,
|
| 834 |
+
lineHeight: 0.98,
|
| 835 |
+
color: styles.colors.text,
|
| 836 |
+
}}
|
| 837 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 838 |
|
| 839 |
+
<EditableContent
|
| 840 |
+
as="p"
|
| 841 |
+
value={bodyText}
|
| 842 |
+
isEditable={isEditable}
|
| 843 |
+
multiline
|
| 844 |
+
onCommit={(value) => {
|
| 845 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'body', value, 0);
|
| 846 |
+
}}
|
| 847 |
+
style={{
|
| 848 |
+
fontFamily: styles.fonts.body,
|
| 849 |
+
fontSize: '16px',
|
| 850 |
+
lineHeight: 1.8,
|
| 851 |
+
color: styles.colors.text,
|
| 852 |
+
opacity: 0.8,
|
| 853 |
+
}}
|
| 854 |
+
/>
|
| 855 |
+
</div>
|
| 856 |
+
</DraggableSurface>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
</div>
|
| 858 |
</div>
|
| 859 |
);
|
| 860 |
}
|
| 861 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
export function GalerynReferences({
|
| 863 |
title,
|
| 864 |
items = [],
|
| 865 |
+
formatting,
|
| 866 |
styles,
|
| 867 |
slideId,
|
| 868 |
isEditable = false,
|
| 869 |
onFieldUpdate,
|
| 870 |
+
onFormattingUpdate,
|
| 871 |
}: LayoutProps) {
|
| 872 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 873 |
+
const referenceItems = items.length ? items : [{ text: '' }];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
|
| 875 |
return (
|
| 876 |
<div
|
| 877 |
+
ref={slideRef}
|
| 878 |
+
className="h-full w-full overflow-hidden px-14 py-16 md:px-16"
|
| 879 |
+
style={{ backgroundColor: styles.colors.background || COLORS.background }}
|
| 880 |
>
|
| 881 |
+
<div className="flex h-full flex-col justify-center">
|
| 882 |
+
<DraggableSurface
|
| 883 |
+
constraintsRef={slideRef}
|
| 884 |
+
isEditable={isEditable}
|
| 885 |
+
slideId={slideId}
|
| 886 |
+
formatKey="references-title"
|
| 887 |
+
formatting={formatting}
|
| 888 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 889 |
+
className="mb-10 w-fit"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
>
|
| 891 |
+
<EditableContent
|
| 892 |
+
as="h2"
|
| 893 |
+
value={title || 'References'}
|
| 894 |
+
isEditable={isEditable}
|
| 895 |
+
onCommit={(value) => {
|
| 896 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 897 |
+
}}
|
| 898 |
+
style={{
|
| 899 |
+
fontFamily: styles.fonts.heading,
|
| 900 |
+
fontSize: '52px',
|
| 901 |
+
fontWeight: 700,
|
| 902 |
+
lineHeight: 1,
|
| 903 |
+
color: styles.colors.text,
|
| 904 |
+
}}
|
| 905 |
+
/>
|
| 906 |
+
</DraggableSurface>
|
| 907 |
|
| 908 |
+
<div className="flex max-w-[720px] flex-col gap-4">
|
| 909 |
+
{referenceItems.map((item, index) => (
|
| 910 |
+
<DraggableSurface
|
| 911 |
+
key={`${index}-${item.text}`}
|
| 912 |
+
constraintsRef={slideRef}
|
| 913 |
+
isEditable={isEditable}
|
| 914 |
+
slideId={slideId}
|
| 915 |
+
formatKey={`reference-item-${index}`}
|
| 916 |
+
formatting={formatting}
|
| 917 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 918 |
+
className="w-full"
|
| 919 |
+
handleClassName="-top-4 right-0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 920 |
>
|
| 921 |
+
<div className="flex items-baseline gap-5 border-b border-[#021d30]/10 pb-3">
|
| 922 |
+
<span
|
| 923 |
+
className="shrink-0"
|
| 924 |
+
style={{
|
| 925 |
+
fontFamily: styles.fonts.body,
|
| 926 |
+
fontSize: '12px',
|
| 927 |
+
fontWeight: 600,
|
| 928 |
+
color: styles.colors.text,
|
| 929 |
+
opacity: 0.35,
|
| 930 |
+
}}
|
| 931 |
+
>
|
| 932 |
+
[{index + 1}]
|
| 933 |
+
</span>
|
| 934 |
|
| 935 |
+
<EditableContent
|
| 936 |
+
as="p"
|
| 937 |
+
value={item.text}
|
| 938 |
+
isEditable={isEditable}
|
| 939 |
+
multiline
|
| 940 |
+
onCommit={(value) => {
|
| 941 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'items', value, index);
|
| 942 |
+
}}
|
| 943 |
+
style={{
|
| 944 |
+
fontFamily: styles.fonts.body,
|
| 945 |
+
fontSize: '16px',
|
| 946 |
+
lineHeight: 1.7,
|
| 947 |
+
color: styles.colors.text,
|
| 948 |
+
opacity: 0.82,
|
| 949 |
+
}}
|
| 950 |
+
/>
|
| 951 |
+
</div>
|
| 952 |
+
</DraggableSurface>
|
| 953 |
+
))}
|
| 954 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 955 |
</div>
|
| 956 |
</div>
|
| 957 |
);
|
| 958 |
}
|
| 959 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 960 |
export function GalerynThankYou({
|
| 961 |
title,
|
| 962 |
subtitle,
|
| 963 |
+
formatting,
|
| 964 |
styles,
|
| 965 |
slideId,
|
| 966 |
isEditable = false,
|
| 967 |
onFieldUpdate,
|
| 968 |
+
onFormattingUpdate,
|
| 969 |
}: LayoutProps) {
|
| 970 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 971 |
|
| 972 |
return (
|
| 973 |
<div
|
| 974 |
+
ref={slideRef}
|
| 975 |
+
className="relative h-full w-full overflow-hidden px-12 py-16 md:px-16"
|
| 976 |
+
style={{ backgroundColor: styles.colors.text || COLORS.text }}
|
| 977 |
>
|
| 978 |
+
<div className="flex h-full flex-col items-center justify-center text-center">
|
| 979 |
+
<DraggableSurface
|
| 980 |
+
constraintsRef={slideRef}
|
| 981 |
+
isEditable={isEditable}
|
| 982 |
+
slideId={slideId}
|
| 983 |
+
formatKey="thank-you-title"
|
| 984 |
+
formatting={formatting}
|
| 985 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 986 |
+
className="mb-5"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
>
|
| 988 |
+
<EditableContent
|
| 989 |
+
as="h2"
|
| 990 |
+
value={title || 'Thank You'}
|
| 991 |
+
isEditable={isEditable}
|
| 992 |
+
onCommit={(value) => {
|
| 993 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 994 |
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
style={{
|
| 996 |
+
fontFamily: styles.fonts.heading,
|
| 997 |
+
fontSize: '84px',
|
| 998 |
+
fontWeight: 700,
|
| 999 |
+
lineHeight: 0.95,
|
| 1000 |
+
color: COLORS.white,
|
| 1001 |
}}
|
| 1002 |
+
/>
|
| 1003 |
+
</DraggableSurface>
|
| 1004 |
+
|
| 1005 |
+
{(subtitle || isEditable) && (
|
| 1006 |
+
<DraggableSurface
|
| 1007 |
+
constraintsRef={slideRef}
|
| 1008 |
+
isEditable={isEditable}
|
| 1009 |
+
slideId={slideId}
|
| 1010 |
+
formatKey="thank-you-subtitle"
|
| 1011 |
+
formatting={formatting}
|
| 1012 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 1013 |
+
className="max-w-[520px]"
|
| 1014 |
>
|
| 1015 |
+
<EditableContent
|
| 1016 |
+
as="p"
|
| 1017 |
+
value={subtitle || ''}
|
| 1018 |
+
isEditable={isEditable}
|
| 1019 |
+
multiline
|
| 1020 |
+
onCommit={(value) => {
|
| 1021 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'subtitle', value);
|
| 1022 |
+
}}
|
| 1023 |
+
style={{
|
| 1024 |
+
fontFamily: styles.fonts.body,
|
| 1025 |
+
fontSize: '16px',
|
| 1026 |
+
lineHeight: 1.7,
|
| 1027 |
+
color: COLORS.white,
|
| 1028 |
+
opacity: 0.62,
|
| 1029 |
+
}}
|
| 1030 |
+
/>
|
| 1031 |
+
</DraggableSurface>
|
| 1032 |
+
)}
|
| 1033 |
+
</div>
|
| 1034 |
|
|
|
|
| 1035 |
<div
|
| 1036 |
+
className="absolute bottom-8 left-0 right-0 text-center"
|
| 1037 |
style={{
|
| 1038 |
+
fontFamily: styles.fonts.body,
|
| 1039 |
+
fontSize: '11px',
|
| 1040 |
fontWeight: 600,
|
|
|
|
|
|
|
| 1041 |
letterSpacing: '0.3em',
|
| 1042 |
+
color: COLORS.white,
|
| 1043 |
+
opacity: 0.4,
|
| 1044 |
}}
|
| 1045 |
>
|
| 1046 |
GALERYN CO. // 2026
|
components/slides/neobrutalism/layouts.tsx
CHANGED
|
@@ -1,49 +1,115 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
-
import React, { useState } from 'react';
|
| 4 |
-
import { Star } from 'lucide-react';
|
| 5 |
import { TemplateStyles } from '@/data/templates';
|
| 6 |
-
|
| 7 |
-
// ============================================================================
|
| 8 |
-
// SHARED PROPS & HELPERS
|
| 9 |
-
// ============================================================================
|
| 10 |
|
| 11 |
interface LayoutProps {
|
| 12 |
title: string;
|
| 13 |
subtitle?: string;
|
| 14 |
body?: Array<{ heading?: string; text: string }>;
|
|
|
|
| 15 |
items?: Array<{ text: string }>;
|
| 16 |
imageUrl?: string;
|
|
|
|
| 17 |
styles: TemplateStyles;
|
| 18 |
slideId?: string;
|
| 19 |
isEditable?: boolean;
|
| 20 |
onFieldUpdate?: (slideId: string, field: string, value: string, index?: number) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
-
/** Dot-pattern overlay shared by all neo-brutalism layouts */
|
| 24 |
function DotOverlay({ pattern }: { pattern?: string }) {
|
| 25 |
if (!pattern) return null;
|
|
|
|
| 26 |
return (
|
| 27 |
<div
|
| 28 |
aria-hidden
|
| 29 |
-
className="absolute inset-0
|
| 30 |
style={{ background: pattern }}
|
| 31 |
/>
|
| 32 |
);
|
| 33 |
}
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
export function NeoTitleSubtitle({
|
| 40 |
title,
|
| 41 |
subtitle,
|
|
|
|
| 42 |
styles,
|
| 43 |
slideId,
|
| 44 |
isEditable = false,
|
| 45 |
onFieldUpdate,
|
|
|
|
| 46 |
}: LayoutProps) {
|
|
|
|
| 47 |
const [editingField, setEditingField] = useState<string | null>(null);
|
| 48 |
const [tempTitle, setTempTitle] = useState(title);
|
| 49 |
const [tempSubtitle, setTempSubtitle] = useState(subtitle || '');
|
|
@@ -56,273 +122,318 @@ export function NeoTitleSubtitle({
|
|
| 56 |
};
|
| 57 |
|
| 58 |
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 59 |
-
if (e.key === 'Enter'
|
|
|
|
|
|
|
|
|
|
| 60 |
if (e.key === 'Escape') {
|
| 61 |
setEditingField(null);
|
| 62 |
-
|
| 63 |
-
|
| 64 |
}
|
| 65 |
};
|
| 66 |
|
| 67 |
return (
|
| 68 |
<div
|
| 69 |
-
|
|
|
|
| 70 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 71 |
>
|
| 72 |
<DotOverlay pattern={styles.dotPattern} />
|
| 73 |
|
| 74 |
-
<div className="relative z-10 flex flex-col items-center
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
>
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
{editingField === 'title' ? (
|
| 87 |
-
<input
|
| 88 |
-
type="text"
|
| 89 |
-
value={tempTitle}
|
| 90 |
-
onChange={(e) => setTempTitle(e.target.value)}
|
| 91 |
-
onBlur={() => handleBlur('title')}
|
| 92 |
-
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
| 93 |
-
className="bg-neo-lime neo-border neo-shadow text-center outline-none"
|
| 94 |
-
style={{
|
| 95 |
-
fontFamily: styles.fonts.heading,
|
| 96 |
-
fontSize: '42px',
|
| 97 |
-
fontWeight: 900,
|
| 98 |
-
color: '#000',
|
| 99 |
-
textTransform: 'uppercase',
|
| 100 |
-
padding: '10px 28px',
|
| 101 |
-
transform: 'rotate(-1deg)',
|
| 102 |
-
letterSpacing: '-0.02em',
|
| 103 |
-
width: '100%',
|
| 104 |
-
}}
|
| 105 |
-
autoFocus
|
| 106 |
-
/>
|
| 107 |
-
) : (
|
| 108 |
-
<h1
|
| 109 |
-
className={`bg-neo-lime neo-border neo-shadow ${isEditable ? 'cursor-pointer' : ''}`}
|
| 110 |
-
style={{
|
| 111 |
-
fontFamily: styles.fonts.heading,
|
| 112 |
-
fontSize: '42px',
|
| 113 |
-
fontWeight: 900,
|
| 114 |
-
color: '#000',
|
| 115 |
-
textTransform: 'uppercase',
|
| 116 |
-
padding: '10px 28px',
|
| 117 |
-
transform: 'rotate(-1deg)',
|
| 118 |
-
letterSpacing: '-0.02em',
|
| 119 |
-
display: 'inline-block',
|
| 120 |
-
}}
|
| 121 |
-
onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
|
| 122 |
-
>
|
| 123 |
-
{title || 'TITLE'}
|
| 124 |
-
</h1>
|
| 125 |
-
)}
|
| 126 |
-
</div>
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
{editingField === 'subtitle' ? (
|
| 134 |
-
<
|
| 135 |
-
type="text"
|
| 136 |
value={tempSubtitle}
|
| 137 |
onChange={(e) => setTempSubtitle(e.target.value)}
|
| 138 |
onBlur={() => handleBlur('subtitle')}
|
| 139 |
onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
|
| 140 |
-
|
|
|
|
| 141 |
style={{
|
| 142 |
fontFamily: styles.fonts.body,
|
| 143 |
-
fontSize: '
|
| 144 |
-
fontWeight:
|
| 145 |
color: '#000',
|
|
|
|
|
|
|
| 146 |
textTransform: 'uppercase',
|
| 147 |
-
padding: '8px 20px',
|
| 148 |
-
backgroundColor: '#fff',
|
| 149 |
-
width: '100%',
|
| 150 |
}}
|
|
|
|
| 151 |
autoFocus
|
| 152 |
/>
|
| 153 |
) : (
|
| 154 |
<p
|
| 155 |
-
className={
|
| 156 |
style={{
|
| 157 |
fontFamily: styles.fonts.body,
|
| 158 |
-
fontSize: '
|
| 159 |
-
fontWeight:
|
| 160 |
color: '#000',
|
|
|
|
|
|
|
|
|
|
| 161 |
textTransform: 'uppercase',
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
| 166 |
}}
|
| 167 |
-
onClick={() => { if (isEditable) { setEditingField('subtitle'); setTempSubtitle(subtitle || ''); } }}
|
| 168 |
>
|
| 169 |
{subtitle || (isEditable ? 'Click to add subtitle' : '')}
|
| 170 |
</p>
|
| 171 |
)}
|
| 172 |
</div>
|
| 173 |
-
|
| 174 |
</div>
|
| 175 |
</div>
|
| 176 |
);
|
| 177 |
}
|
| 178 |
|
| 179 |
-
// ============================================================================
|
| 180 |
-
// 2. NeoAgenda
|
| 181 |
-
// ============================================================================
|
| 182 |
-
|
| 183 |
export function NeoAgenda({
|
| 184 |
title,
|
| 185 |
items = [],
|
|
|
|
| 186 |
styles,
|
| 187 |
slideId,
|
| 188 |
isEditable = false,
|
| 189 |
onFieldUpdate,
|
|
|
|
| 190 |
}: LayoutProps) {
|
|
|
|
| 191 |
const [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
|
| 192 |
const [tempTitle, setTempTitle] = useState(title);
|
| 193 |
-
const [tempItems, setTempItems] = useState(items.map((
|
| 194 |
|
| 195 |
const handleBlur = (field: string, index?: number) => {
|
| 196 |
if (!slideId || !onFieldUpdate) return;
|
| 197 |
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 198 |
-
if (field === 'items' && index !== undefined && tempItems[index] !== items[index]?.text)
|
| 199 |
onFieldUpdate(slideId, 'items', tempItems[index], index);
|
|
|
|
| 200 |
setEditingField(null);
|
| 201 |
};
|
| 202 |
|
| 203 |
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 204 |
-
if (e.key === 'Enter'
|
|
|
|
|
|
|
|
|
|
| 205 |
if (e.key === 'Escape') {
|
| 206 |
setEditingField(null);
|
| 207 |
-
|
| 208 |
-
|
| 209 |
}
|
| 210 |
};
|
| 211 |
|
| 212 |
-
// Pad items to fill a 3-column grid (min 3)
|
| 213 |
-
const displayItems = items.length > 0 ? items : [];
|
| 214 |
-
|
| 215 |
return (
|
| 216 |
<div
|
| 217 |
-
|
|
|
|
| 218 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 219 |
>
|
| 220 |
<DotOverlay pattern={styles.dotPattern} />
|
| 221 |
|
| 222 |
-
<div className="relative z-10 flex
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
>
|
| 227 |
{editingField?.field === 'title' ? (
|
| 228 |
-
<
|
| 229 |
-
type="text"
|
| 230 |
value={tempTitle}
|
| 231 |
onChange={(e) => setTempTitle(e.target.value)}
|
| 232 |
onBlur={() => handleBlur('title')}
|
| 233 |
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
| 234 |
-
|
|
|
|
| 235 |
style={{
|
| 236 |
fontFamily: styles.fonts.heading,
|
| 237 |
-
fontSize: '
|
| 238 |
fontWeight: 900,
|
| 239 |
color: '#000',
|
|
|
|
|
|
|
| 240 |
textTransform: 'uppercase',
|
| 241 |
-
letterSpacing: '-0.02em',
|
| 242 |
}}
|
|
|
|
| 243 |
autoFocus
|
| 244 |
/>
|
| 245 |
) : (
|
| 246 |
<h2
|
| 247 |
-
className={
|
| 248 |
style={{
|
| 249 |
fontFamily: styles.fonts.heading,
|
| 250 |
-
fontSize: '
|
| 251 |
fontWeight: 900,
|
| 252 |
color: '#000',
|
|
|
|
|
|
|
| 253 |
textTransform: 'uppercase',
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
}}
|
| 256 |
-
onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
|
| 257 |
>
|
| 258 |
-
{title}
|
| 259 |
</h2>
|
| 260 |
)}
|
| 261 |
-
</
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
}
|
| 273 |
>
|
| 274 |
-
<
|
| 275 |
-
className="text-neo-purple"
|
| 276 |
-
style={{
|
| 277 |
-
fontFamily: styles.fonts.heading,
|
| 278 |
-
fontSize: '13px',
|
| 279 |
-
fontWeight: 900,
|
| 280 |
-
letterSpacing: '0.04em',
|
| 281 |
-
}}
|
| 282 |
-
>
|
| 283 |
-
{String(i + 1).padStart(2, '0')}/
|
| 284 |
-
</span>
|
| 285 |
-
|
| 286 |
-
{editingField?.field === 'items' && editingField?.index === i ? (
|
| 287 |
-
<input
|
| 288 |
-
type="text"
|
| 289 |
-
value={tempItems[i] || ''}
|
| 290 |
-
onChange={(e) => {
|
| 291 |
-
const next = [...tempItems];
|
| 292 |
-
next[i] = e.target.value;
|
| 293 |
-
setTempItems(next);
|
| 294 |
-
}}
|
| 295 |
-
onBlur={() => handleBlur('items', i)}
|
| 296 |
-
onKeyDown={(e) => handleKeyDown(e, 'items', i)}
|
| 297 |
-
className="bg-transparent outline-none mt-2"
|
| 298 |
-
style={{
|
| 299 |
-
fontFamily: styles.fonts.body,
|
| 300 |
-
fontSize: '14px',
|
| 301 |
-
fontWeight: 700,
|
| 302 |
-
color: '#000',
|
| 303 |
-
}}
|
| 304 |
-
autoFocus
|
| 305 |
-
/>
|
| 306 |
-
) : (
|
| 307 |
<span
|
| 308 |
-
className=
|
| 309 |
style={{
|
| 310 |
-
fontFamily: styles.fonts.
|
| 311 |
-
fontSize: '
|
| 312 |
-
fontWeight:
|
| 313 |
-
|
| 314 |
-
}}
|
| 315 |
-
onClick={() => {
|
| 316 |
-
if (isEditable) {
|
| 317 |
-
setEditingField({ field: 'items', index: i });
|
| 318 |
-
setTempItems(items.map((x) => x.text));
|
| 319 |
-
}
|
| 320 |
}}
|
| 321 |
>
|
| 322 |
-
{
|
| 323 |
</span>
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
))}
|
| 327 |
</div>
|
| 328 |
</div>
|
|
@@ -330,473 +441,887 @@ export function NeoAgenda({
|
|
| 330 |
);
|
| 331 |
}
|
| 332 |
|
| 333 |
-
// ============================================================================
|
| 334 |
-
// 3. NeoTitleAndText
|
| 335 |
-
// ============================================================================
|
| 336 |
-
|
| 337 |
export function NeoTitleAndText({
|
| 338 |
title,
|
| 339 |
body = [],
|
|
|
|
| 340 |
styles,
|
| 341 |
slideId,
|
| 342 |
isEditable = false,
|
| 343 |
onFieldUpdate,
|
|
|
|
| 344 |
}: LayoutProps) {
|
|
|
|
| 345 |
const [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
|
| 346 |
const [tempTitle, setTempTitle] = useState(title);
|
| 347 |
-
const [tempBody, setTempBody] = useState(body.map((
|
|
|
|
| 348 |
|
| 349 |
const handleBlur = (field: string, index?: number) => {
|
| 350 |
if (!slideId || !onFieldUpdate) return;
|
| 351 |
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 352 |
-
if (field === 'body' && index !== undefined && tempBody[index] !== body[index]?.text)
|
| 353 |
onFieldUpdate(slideId, 'body', tempBody[index], index);
|
|
|
|
| 354 |
setEditingField(null);
|
| 355 |
};
|
| 356 |
|
| 357 |
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 358 |
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
|
|
|
|
|
| 359 |
if (e.key === 'Escape') {
|
| 360 |
setEditingField(null);
|
| 361 |
-
|
| 362 |
-
|
| 363 |
}
|
| 364 |
};
|
| 365 |
|
| 366 |
-
const bodyText = body.length > 0 ? body[0].text : '';
|
| 367 |
-
|
| 368 |
return (
|
| 369 |
<div
|
| 370 |
-
|
|
|
|
| 371 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 372 |
>
|
| 373 |
<DotOverlay pattern={styles.dotPattern} />
|
| 374 |
|
| 375 |
-
<div className="relative z-10 flex
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
>
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
>
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
>
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
</div>
|
| 463 |
</div>
|
| 464 |
</div>
|
| 465 |
);
|
| 466 |
}
|
| 467 |
|
| 468 |
-
// ============================================================================
|
| 469 |
-
// 4. NeoImageAndText
|
| 470 |
-
// ============================================================================
|
| 471 |
-
|
| 472 |
export function NeoImageAndText({
|
| 473 |
title,
|
| 474 |
body = [],
|
| 475 |
imageUrl,
|
|
|
|
| 476 |
styles,
|
| 477 |
slideId,
|
| 478 |
isEditable = false,
|
| 479 |
onFieldUpdate,
|
|
|
|
|
|
|
| 480 |
}: LayoutProps) {
|
|
|
|
| 481 |
const [editingField, setEditingField] = useState<string | null>(null);
|
| 482 |
const [tempTitle, setTempTitle] = useState(title);
|
| 483 |
-
const [tempBody, setTempBody] = useState(body
|
|
|
|
| 484 |
|
| 485 |
const handleBlur = (field: string) => {
|
| 486 |
if (!slideId || !onFieldUpdate) return;
|
| 487 |
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 488 |
-
if (field === 'body' && tempBody !==
|
| 489 |
setEditingField(null);
|
| 490 |
};
|
| 491 |
|
| 492 |
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 493 |
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
|
|
|
|
|
| 494 |
if (e.key === 'Escape') {
|
| 495 |
setEditingField(null);
|
| 496 |
-
|
| 497 |
-
|
| 498 |
}
|
| 499 |
};
|
| 500 |
|
| 501 |
-
const bodyText = body.length > 0 ? body[0].text : '';
|
| 502 |
-
|
| 503 |
return (
|
| 504 |
<div
|
| 505 |
-
|
|
|
|
| 506 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 507 |
>
|
| 508 |
<DotOverlay pattern={styles.dotPattern} />
|
| 509 |
|
| 510 |
-
<div className="relative z-10 flex
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
>
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
|
|
|
|
|
|
|
|
|
| 530 |
}}
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
style={{
|
| 537 |
fontFamily: styles.fonts.heading,
|
| 538 |
-
fontSize: '
|
| 539 |
fontWeight: 900,
|
| 540 |
color: '#000',
|
| 541 |
textTransform: 'uppercase',
|
| 542 |
-
|
| 543 |
-
display: 'inline-block',
|
| 544 |
}}
|
| 545 |
-
onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
|
| 546 |
>
|
| 547 |
-
|
| 548 |
-
</
|
| 549 |
-
|
| 550 |
-
</
|
| 551 |
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
{
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
/>
|
| 565 |
) : (
|
| 566 |
-
<
|
| 567 |
-
className=
|
| 568 |
style={{
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
}}
|
| 574 |
>
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
fontFamily: styles.fonts.body,
|
| 578 |
-
fontSize: '13px',
|
| 579 |
-
fontWeight: 700,
|
| 580 |
-
color: '#000',
|
| 581 |
-
opacity: 0.45,
|
| 582 |
-
textTransform: 'uppercase',
|
| 583 |
-
}}
|
| 584 |
-
>
|
| 585 |
-
Click to add image
|
| 586 |
-
</span>
|
| 587 |
-
</div>
|
| 588 |
)}
|
| 589 |
-
</div>
|
| 590 |
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
</div>
|
| 630 |
-
</
|
| 631 |
</div>
|
| 632 |
</div>
|
| 633 |
);
|
| 634 |
}
|
| 635 |
|
| 636 |
-
// ============================================================================
|
| 637 |
-
// 5. NeoReferences
|
| 638 |
-
// ============================================================================
|
| 639 |
-
|
| 640 |
export function NeoReferences({
|
| 641 |
title,
|
| 642 |
items = [],
|
|
|
|
| 643 |
styles,
|
| 644 |
slideId,
|
| 645 |
isEditable = false,
|
| 646 |
onFieldUpdate,
|
|
|
|
| 647 |
}: LayoutProps) {
|
|
|
|
| 648 |
const [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
|
| 649 |
const [tempTitle, setTempTitle] = useState(title);
|
| 650 |
-
const [tempItems, setTempItems] = useState(items.map((
|
| 651 |
|
| 652 |
const handleBlur = (field: string, index?: number) => {
|
| 653 |
if (!slideId || !onFieldUpdate) return;
|
| 654 |
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 655 |
-
if (field === 'items' && index !== undefined && tempItems[index] !== items[index]?.text)
|
| 656 |
onFieldUpdate(slideId, 'items', tempItems[index], index);
|
|
|
|
| 657 |
setEditingField(null);
|
| 658 |
};
|
| 659 |
|
| 660 |
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 661 |
-
if (e.key === 'Enter'
|
|
|
|
|
|
|
|
|
|
| 662 |
if (e.key === 'Escape') {
|
| 663 |
setEditingField(null);
|
| 664 |
-
|
| 665 |
-
|
| 666 |
}
|
| 667 |
};
|
| 668 |
|
| 669 |
return (
|
| 670 |
<div
|
| 671 |
-
|
|
|
|
| 672 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 673 |
>
|
| 674 |
<DotOverlay pattern={styles.dotPattern} />
|
| 675 |
|
| 676 |
-
<div className="relative z-10 flex
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
>
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
fontSize: '26px',
|
| 706 |
-
fontWeight: 900,
|
| 707 |
-
color: '#fff',
|
| 708 |
-
backgroundColor: '#000',
|
| 709 |
-
textTransform: 'uppercase',
|
| 710 |
-
padding: '6px 18px',
|
| 711 |
-
display: 'inline-block',
|
| 712 |
-
}}
|
| 713 |
-
onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
|
| 714 |
-
>
|
| 715 |
-
{title}
|
| 716 |
-
</h2>
|
| 717 |
-
)}
|
| 718 |
-
</div>
|
| 719 |
-
|
| 720 |
-
{/* Reference list */}
|
| 721 |
-
<div className="flex flex-col flex-1">
|
| 722 |
-
{items.map((item, i) => (
|
| 723 |
-
<div
|
| 724 |
-
key={i}
|
| 725 |
-
className="flex items-center gap-3 py-3"
|
| 726 |
-
style={{ borderBottom: '2px solid #000' }}
|
| 727 |
-
>
|
| 728 |
-
{/* Purple label */}
|
| 729 |
-
<span
|
| 730 |
-
className="shrink-0 bg-neo-purple text-white"
|
| 731 |
style={{
|
| 732 |
fontFamily: styles.fonts.heading,
|
| 733 |
-
fontSize: '
|
| 734 |
fontWeight: 900,
|
| 735 |
-
|
| 736 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
}}
|
| 738 |
>
|
| 739 |
-
|
| 740 |
-
</
|
|
|
|
|
|
|
|
|
|
| 741 |
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
}
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 762 |
<span
|
| 763 |
-
className={`flex-1 ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
|
| 764 |
style={{
|
| 765 |
-
fontFamily: styles.fonts.
|
| 766 |
-
fontSize: '
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
}}
|
| 770 |
-
onClick={() => {
|
| 771 |
-
if (isEditable) {
|
| 772 |
-
setEditingField({ field: 'items', index: i });
|
| 773 |
-
setTempItems(items.map((x) => x.text));
|
| 774 |
-
}
|
| 775 |
}}
|
| 776 |
>
|
| 777 |
-
|
| 778 |
</span>
|
| 779 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
</div>
|
| 781 |
-
|
| 782 |
</div>
|
| 783 |
</div>
|
| 784 |
</div>
|
| 785 |
);
|
| 786 |
}
|
| 787 |
|
| 788 |
-
// ============================================================================
|
| 789 |
-
// 6. NeoThankYou
|
| 790 |
-
// ============================================================================
|
| 791 |
-
|
| 792 |
export function NeoThankYou({
|
| 793 |
title,
|
| 794 |
subtitle,
|
|
|
|
| 795 |
styles,
|
| 796 |
slideId,
|
| 797 |
isEditable = false,
|
| 798 |
onFieldUpdate,
|
|
|
|
| 799 |
}: LayoutProps) {
|
|
|
|
| 800 |
const [editingField, setEditingField] = useState<string | null>(null);
|
| 801 |
const [tempTitle, setTempTitle] = useState(title);
|
| 802 |
const [tempSubtitle, setTempSubtitle] = useState(subtitle || '');
|
|
@@ -809,131 +1334,149 @@ export function NeoThankYou({
|
|
| 809 |
};
|
| 810 |
|
| 811 |
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 812 |
-
if (e.key === 'Enter'
|
|
|
|
|
|
|
|
|
|
| 813 |
if (e.key === 'Escape') {
|
| 814 |
setEditingField(null);
|
| 815 |
-
|
| 816 |
-
|
| 817 |
}
|
| 818 |
};
|
| 819 |
|
| 820 |
return (
|
| 821 |
<div
|
| 822 |
-
|
| 823 |
-
|
|
|
|
| 824 |
>
|
| 825 |
<DotOverlay pattern={styles.dotPattern} />
|
| 826 |
|
| 827 |
-
<div className="relative z-10 flex flex-col items-center
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
}
|
|
|
|
| 835 |
>
|
| 836 |
-
<
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
|
|
|
| 894 |
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
{editingField === 'subtitle' ? (
|
| 901 |
-
<
|
| 902 |
-
type="text"
|
| 903 |
value={tempSubtitle}
|
| 904 |
onChange={(e) => setTempSubtitle(e.target.value)}
|
| 905 |
onBlur={() => handleBlur('subtitle')}
|
| 906 |
onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
|
| 907 |
-
|
|
|
|
| 908 |
style={{
|
| 909 |
fontFamily: styles.fonts.body,
|
| 910 |
-
fontSize: '
|
| 911 |
-
fontWeight:
|
| 912 |
color: '#000',
|
|
|
|
|
|
|
| 913 |
textTransform: 'uppercase',
|
| 914 |
-
letterSpacing: '0.12em',
|
| 915 |
-
width: '100%',
|
| 916 |
}}
|
|
|
|
| 917 |
autoFocus
|
| 918 |
/>
|
| 919 |
) : (
|
| 920 |
<p
|
| 921 |
-
className={
|
| 922 |
style={{
|
| 923 |
fontFamily: styles.fonts.body,
|
| 924 |
-
fontSize: '
|
| 925 |
-
fontWeight:
|
| 926 |
color: '#000',
|
|
|
|
|
|
|
| 927 |
textTransform: 'uppercase',
|
| 928 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
}}
|
| 930 |
-
onClick={() => { if (isEditable) { setEditingField('subtitle'); setTempSubtitle(subtitle || ''); } }}
|
| 931 |
>
|
| 932 |
{subtitle || (isEditable ? 'Click to add subtitle' : '')}
|
| 933 |
</p>
|
| 934 |
)}
|
| 935 |
</div>
|
| 936 |
-
|
| 937 |
</div>
|
| 938 |
</div>
|
| 939 |
);
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
+
import React, { useRef, useState } from 'react';
|
| 4 |
+
import { Link as LinkIcon, Star } from 'lucide-react';
|
| 5 |
import { TemplateStyles } from '@/data/templates';
|
| 6 |
+
import { PersistedDraggableSurface, SlideFormattingMap } from '@/components/slides/shared/PersistedDraggableSurface';
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
interface LayoutProps {
|
| 9 |
title: string;
|
| 10 |
subtitle?: string;
|
| 11 |
body?: Array<{ heading?: string; text: string }>;
|
| 12 |
+
columns?: Array<{ heading: string; text: string }>;
|
| 13 |
items?: Array<{ text: string }>;
|
| 14 |
imageUrl?: string;
|
| 15 |
+
formatting?: SlideFormattingMap;
|
| 16 |
styles: TemplateStyles;
|
| 17 |
slideId?: string;
|
| 18 |
isEditable?: boolean;
|
| 19 |
onFieldUpdate?: (slideId: string, field: string, value: string, index?: number) => void;
|
| 20 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 21 |
+
onRequestImageSelect?: (slideId: string) => void;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface DraggableSurfaceProps {
|
| 25 |
+
children: React.ReactNode;
|
| 26 |
+
constraintsRef: React.RefObject<HTMLDivElement | null>;
|
| 27 |
+
slideId?: string;
|
| 28 |
+
formatKey: string;
|
| 29 |
+
formatting?: SlideFormattingMap;
|
| 30 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 31 |
+
isEditable?: boolean;
|
| 32 |
+
className?: string;
|
| 33 |
+
style?: React.CSSProperties;
|
| 34 |
}
|
| 35 |
|
|
|
|
| 36 |
function DotOverlay({ pattern }: { pattern?: string }) {
|
| 37 |
if (!pattern) return null;
|
| 38 |
+
|
| 39 |
return (
|
| 40 |
<div
|
| 41 |
aria-hidden
|
| 42 |
+
className="absolute inset-0 pointer-events-none opacity-30"
|
| 43 |
style={{ background: pattern }}
|
| 44 |
/>
|
| 45 |
);
|
| 46 |
}
|
| 47 |
|
| 48 |
+
function DraggableSurface({
|
| 49 |
+
children,
|
| 50 |
+
constraintsRef,
|
| 51 |
+
slideId,
|
| 52 |
+
formatKey,
|
| 53 |
+
formatting,
|
| 54 |
+
onFormattingUpdate,
|
| 55 |
+
isEditable = false,
|
| 56 |
+
className,
|
| 57 |
+
style,
|
| 58 |
+
}: DraggableSurfaceProps) {
|
| 59 |
+
return (
|
| 60 |
+
<PersistedDraggableSurface
|
| 61 |
+
constraintsRef={constraintsRef}
|
| 62 |
+
slideId={slideId}
|
| 63 |
+
formatKey={formatKey}
|
| 64 |
+
formatting={formatting}
|
| 65 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 66 |
+
isEditable={isEditable}
|
| 67 |
+
className={className}
|
| 68 |
+
style={style}
|
| 69 |
+
groupClassName=""
|
| 70 |
+
whileDrag={{ scale: 1.02, zIndex: 40 }}
|
| 71 |
+
>
|
| 72 |
+
{children}
|
| 73 |
+
</PersistedDraggableSurface>
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function stopPointerDown(e: React.PointerEvent<HTMLElement>) {
|
| 78 |
+
e.stopPropagation();
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function parseReference(text: string, index: number) {
|
| 82 |
+
const parts = text
|
| 83 |
+
.split('||')
|
| 84 |
+
.map((part) => part.trim())
|
| 85 |
+
.filter(Boolean);
|
| 86 |
+
|
| 87 |
+
if (parts.length >= 2) {
|
| 88 |
+
return {
|
| 89 |
+
label: parts[0],
|
| 90 |
+
value: parts[1],
|
| 91 |
+
url: parts[2],
|
| 92 |
+
};
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
return {
|
| 96 |
+
label: `REF_${String(index + 1).padStart(2, '0')}`,
|
| 97 |
+
value: text,
|
| 98 |
+
url: '',
|
| 99 |
+
};
|
| 100 |
+
}
|
| 101 |
|
| 102 |
export function NeoTitleSubtitle({
|
| 103 |
title,
|
| 104 |
subtitle,
|
| 105 |
+
formatting,
|
| 106 |
styles,
|
| 107 |
slideId,
|
| 108 |
isEditable = false,
|
| 109 |
onFieldUpdate,
|
| 110 |
+
onFormattingUpdate,
|
| 111 |
}: LayoutProps) {
|
| 112 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 113 |
const [editingField, setEditingField] = useState<string | null>(null);
|
| 114 |
const [tempTitle, setTempTitle] = useState(title);
|
| 115 |
const [tempSubtitle, setTempSubtitle] = useState(subtitle || '');
|
|
|
|
| 122 |
};
|
| 123 |
|
| 124 |
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 125 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 126 |
+
e.preventDefault();
|
| 127 |
+
handleBlur(field);
|
| 128 |
+
}
|
| 129 |
if (e.key === 'Escape') {
|
| 130 |
setEditingField(null);
|
| 131 |
+
setTempTitle(title);
|
| 132 |
+
setTempSubtitle(subtitle || '');
|
| 133 |
}
|
| 134 |
};
|
| 135 |
|
| 136 |
return (
|
| 137 |
<div
|
| 138 |
+
ref={slideRef}
|
| 139 |
+
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden px-8 py-10"
|
| 140 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 141 |
>
|
| 142 |
<DotOverlay pattern={styles.dotPattern} />
|
| 143 |
|
| 144 |
+
<div className="relative z-10 flex w-full flex-col items-center gap-8">
|
| 145 |
+
<DraggableSurface
|
| 146 |
+
constraintsRef={slideRef}
|
| 147 |
+
slideId={slideId}
|
| 148 |
+
formatKey="title"
|
| 149 |
+
formatting={formatting}
|
| 150 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 151 |
+
isEditable={isEditable}
|
| 152 |
+
className="relative w-full max-w-[680px] -rotate-1"
|
| 153 |
>
|
| 154 |
+
<div className="relative bg-neo-lime neo-border neo-shadow px-8 py-10 md:px-10 md:py-12">
|
| 155 |
+
<div className="absolute -right-4 -top-4 bg-neo-lime neo-border p-1">
|
| 156 |
+
<div className="bg-black p-1">
|
| 157 |
+
<Star className="h-5 w-5 fill-current text-neo-lime" />
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
+
{editingField === 'title' ? (
|
| 162 |
+
<textarea
|
| 163 |
+
value={tempTitle}
|
| 164 |
+
onChange={(e) => setTempTitle(e.target.value)}
|
| 165 |
+
onBlur={() => handleBlur('title')}
|
| 166 |
+
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
| 167 |
+
onPointerDown={stopPointerDown}
|
| 168 |
+
className="w-full resize-none bg-transparent text-center outline-none"
|
| 169 |
+
style={{
|
| 170 |
+
fontFamily: styles.fonts.heading,
|
| 171 |
+
fontSize: '54px',
|
| 172 |
+
fontWeight: 900,
|
| 173 |
+
color: '#000',
|
| 174 |
+
lineHeight: 0.92,
|
| 175 |
+
letterSpacing: '-0.05em',
|
| 176 |
+
textTransform: 'uppercase',
|
| 177 |
+
}}
|
| 178 |
+
rows={2}
|
| 179 |
+
autoFocus
|
| 180 |
+
/>
|
| 181 |
+
) : (
|
| 182 |
+
<h1
|
| 183 |
+
className={isEditable ? 'cursor-text' : ''}
|
| 184 |
+
style={{
|
| 185 |
+
fontFamily: styles.fonts.heading,
|
| 186 |
+
fontSize: '54px',
|
| 187 |
+
fontWeight: 900,
|
| 188 |
+
color: '#000',
|
| 189 |
+
lineHeight: 0.92,
|
| 190 |
+
letterSpacing: '-0.05em',
|
| 191 |
+
textAlign: 'center',
|
| 192 |
+
textTransform: 'uppercase',
|
| 193 |
+
whiteSpace: 'pre-line',
|
| 194 |
+
}}
|
| 195 |
+
onClick={() => {
|
| 196 |
+
if (isEditable) {
|
| 197 |
+
setEditingField('title');
|
| 198 |
+
setTempTitle(title);
|
| 199 |
+
}
|
| 200 |
+
}}
|
| 201 |
+
>
|
| 202 |
+
{title || 'NEO-BRUTALISM'}
|
| 203 |
+
</h1>
|
| 204 |
+
)}
|
| 205 |
+
</div>
|
| 206 |
+
</DraggableSurface>
|
| 207 |
+
|
| 208 |
+
<DraggableSurface
|
| 209 |
+
constraintsRef={slideRef}
|
| 210 |
+
slideId={slideId}
|
| 211 |
+
formatKey="subtitle"
|
| 212 |
+
formatting={formatting}
|
| 213 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 214 |
+
isEditable={isEditable}
|
| 215 |
+
className="w-full max-w-[560px]"
|
| 216 |
+
>
|
| 217 |
+
<div className="bg-white neo-border neo-shadow px-6 py-5 md:px-8 md:py-6">
|
| 218 |
{editingField === 'subtitle' ? (
|
| 219 |
+
<textarea
|
|
|
|
| 220 |
value={tempSubtitle}
|
| 221 |
onChange={(e) => setTempSubtitle(e.target.value)}
|
| 222 |
onBlur={() => handleBlur('subtitle')}
|
| 223 |
onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
|
| 224 |
+
onPointerDown={stopPointerDown}
|
| 225 |
+
className="w-full resize-none bg-transparent text-center outline-none"
|
| 226 |
style={{
|
| 227 |
fontFamily: styles.fonts.body,
|
| 228 |
+
fontSize: '18px',
|
| 229 |
+
fontWeight: 800,
|
| 230 |
color: '#000',
|
| 231 |
+
lineHeight: 1.2,
|
| 232 |
+
letterSpacing: '-0.02em',
|
| 233 |
textTransform: 'uppercase',
|
|
|
|
|
|
|
|
|
|
| 234 |
}}
|
| 235 |
+
rows={2}
|
| 236 |
autoFocus
|
| 237 |
/>
|
| 238 |
) : (
|
| 239 |
<p
|
| 240 |
+
className={isEditable ? 'cursor-text' : ''}
|
| 241 |
style={{
|
| 242 |
fontFamily: styles.fonts.body,
|
| 243 |
+
fontSize: '18px',
|
| 244 |
+
fontWeight: 800,
|
| 245 |
color: '#000',
|
| 246 |
+
lineHeight: 1.2,
|
| 247 |
+
letterSpacing: '-0.02em',
|
| 248 |
+
textAlign: 'center',
|
| 249 |
textTransform: 'uppercase',
|
| 250 |
+
whiteSpace: 'pre-line',
|
| 251 |
+
}}
|
| 252 |
+
onClick={() => {
|
| 253 |
+
if (isEditable) {
|
| 254 |
+
setEditingField('subtitle');
|
| 255 |
+
setTempSubtitle(subtitle || '');
|
| 256 |
+
}
|
| 257 |
}}
|
|
|
|
| 258 |
>
|
| 259 |
{subtitle || (isEditable ? 'Click to add subtitle' : '')}
|
| 260 |
</p>
|
| 261 |
)}
|
| 262 |
</div>
|
| 263 |
+
</DraggableSurface>
|
| 264 |
</div>
|
| 265 |
</div>
|
| 266 |
);
|
| 267 |
}
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
export function NeoAgenda({
|
| 270 |
title,
|
| 271 |
items = [],
|
| 272 |
+
formatting,
|
| 273 |
styles,
|
| 274 |
slideId,
|
| 275 |
isEditable = false,
|
| 276 |
onFieldUpdate,
|
| 277 |
+
onFormattingUpdate,
|
| 278 |
}: LayoutProps) {
|
| 279 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 280 |
const [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
|
| 281 |
const [tempTitle, setTempTitle] = useState(title);
|
| 282 |
+
const [tempItems, setTempItems] = useState(items.map((item) => item.text));
|
| 283 |
|
| 284 |
const handleBlur = (field: string, index?: number) => {
|
| 285 |
if (!slideId || !onFieldUpdate) return;
|
| 286 |
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 287 |
+
if (field === 'items' && index !== undefined && tempItems[index] !== items[index]?.text) {
|
| 288 |
onFieldUpdate(slideId, 'items', tempItems[index], index);
|
| 289 |
+
}
|
| 290 |
setEditingField(null);
|
| 291 |
};
|
| 292 |
|
| 293 |
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 294 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 295 |
+
e.preventDefault();
|
| 296 |
+
handleBlur(field, index);
|
| 297 |
+
}
|
| 298 |
if (e.key === 'Escape') {
|
| 299 |
setEditingField(null);
|
| 300 |
+
setTempTitle(title);
|
| 301 |
+
setTempItems(items.map((item) => item.text));
|
| 302 |
}
|
| 303 |
};
|
| 304 |
|
|
|
|
|
|
|
|
|
|
| 305 |
return (
|
| 306 |
<div
|
| 307 |
+
ref={slideRef}
|
| 308 |
+
className="relative flex h-full w-full flex-col overflow-hidden px-7 py-7"
|
| 309 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 310 |
>
|
| 311 |
<DotOverlay pattern={styles.dotPattern} />
|
| 312 |
|
| 313 |
+
<div className="relative z-10 flex h-full flex-col">
|
| 314 |
+
<DraggableSurface
|
| 315 |
+
constraintsRef={slideRef}
|
| 316 |
+
slideId={slideId}
|
| 317 |
+
formatKey="agenda-title"
|
| 318 |
+
formatting={formatting}
|
| 319 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 320 |
+
isEditable={isEditable}
|
| 321 |
+
className="mb-6 self-start"
|
| 322 |
>
|
| 323 |
{editingField?.field === 'title' ? (
|
| 324 |
+
<textarea
|
|
|
|
| 325 |
value={tempTitle}
|
| 326 |
onChange={(e) => setTempTitle(e.target.value)}
|
| 327 |
onBlur={() => handleBlur('title')}
|
| 328 |
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
| 329 |
+
onPointerDown={stopPointerDown}
|
| 330 |
+
className="w-[340px] resize-none bg-transparent outline-none"
|
| 331 |
style={{
|
| 332 |
fontFamily: styles.fonts.heading,
|
| 333 |
+
fontSize: '36px',
|
| 334 |
fontWeight: 900,
|
| 335 |
color: '#000',
|
| 336 |
+
lineHeight: 0.95,
|
| 337 |
+
letterSpacing: '-0.04em',
|
| 338 |
textTransform: 'uppercase',
|
|
|
|
| 339 |
}}
|
| 340 |
+
rows={2}
|
| 341 |
autoFocus
|
| 342 |
/>
|
| 343 |
) : (
|
| 344 |
<h2
|
| 345 |
+
className={isEditable ? 'cursor-text' : ''}
|
| 346 |
style={{
|
| 347 |
fontFamily: styles.fonts.heading,
|
| 348 |
+
fontSize: '36px',
|
| 349 |
fontWeight: 900,
|
| 350 |
color: '#000',
|
| 351 |
+
lineHeight: 0.95,
|
| 352 |
+
letterSpacing: '-0.04em',
|
| 353 |
textTransform: 'uppercase',
|
| 354 |
+
whiteSpace: 'pre-line',
|
| 355 |
+
}}
|
| 356 |
+
onClick={() => {
|
| 357 |
+
if (isEditable) {
|
| 358 |
+
setEditingField({ field: 'title' });
|
| 359 |
+
setTempTitle(title);
|
| 360 |
+
}
|
| 361 |
}}
|
|
|
|
| 362 |
>
|
| 363 |
+
{title || 'THE_MISSION_PLAN'}
|
| 364 |
</h2>
|
| 365 |
)}
|
| 366 |
+
</DraggableSurface>
|
| 367 |
|
| 368 |
+
<div className="grid flex-1 grid-cols-3 gap-3">
|
| 369 |
+
{items.map((item, index) => (
|
| 370 |
+
<DraggableSurface
|
| 371 |
+
key={`${item.text}-${index}`}
|
| 372 |
+
constraintsRef={slideRef}
|
| 373 |
+
slideId={slideId}
|
| 374 |
+
formatKey={`agenda-card-${index}`}
|
| 375 |
+
formatting={formatting}
|
| 376 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 377 |
+
isEditable={isEditable}
|
| 378 |
>
|
| 379 |
+
<div className="flex h-full min-h-[112px] flex-col bg-white neo-border neo-shadow-purple p-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
<span
|
| 381 |
+
className="mb-2 text-neo-purple"
|
| 382 |
style={{
|
| 383 |
+
fontFamily: styles.fonts.heading,
|
| 384 |
+
fontSize: '15px',
|
| 385 |
+
fontWeight: 900,
|
| 386 |
+
letterSpacing: '0.04em',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
}}
|
| 388 |
>
|
| 389 |
+
{String(index + 1).padStart(2, '0')}/
|
| 390 |
</span>
|
| 391 |
+
|
| 392 |
+
{editingField?.field === 'items' && editingField.index === index ? (
|
| 393 |
+
<textarea
|
| 394 |
+
value={tempItems[index] || ''}
|
| 395 |
+
onChange={(e) => {
|
| 396 |
+
const next = [...tempItems];
|
| 397 |
+
next[index] = e.target.value;
|
| 398 |
+
setTempItems(next);
|
| 399 |
+
}}
|
| 400 |
+
onBlur={() => handleBlur('items', index)}
|
| 401 |
+
onKeyDown={(e) => handleKeyDown(e, 'items', index)}
|
| 402 |
+
onPointerDown={stopPointerDown}
|
| 403 |
+
className="w-full flex-1 resize-none bg-transparent outline-none"
|
| 404 |
+
style={{
|
| 405 |
+
fontFamily: styles.fonts.body,
|
| 406 |
+
fontSize: '18px',
|
| 407 |
+
fontWeight: 800,
|
| 408 |
+
color: '#000',
|
| 409 |
+
lineHeight: 1.05,
|
| 410 |
+
}}
|
| 411 |
+
rows={3}
|
| 412 |
+
autoFocus
|
| 413 |
+
/>
|
| 414 |
+
) : (
|
| 415 |
+
<div
|
| 416 |
+
className={isEditable ? 'cursor-text' : ''}
|
| 417 |
+
style={{
|
| 418 |
+
fontFamily: styles.fonts.body,
|
| 419 |
+
fontSize: '18px',
|
| 420 |
+
fontWeight: 800,
|
| 421 |
+
color: '#000',
|
| 422 |
+
lineHeight: 1.05,
|
| 423 |
+
whiteSpace: 'pre-line',
|
| 424 |
+
}}
|
| 425 |
+
onClick={() => {
|
| 426 |
+
if (isEditable) {
|
| 427 |
+
setEditingField({ field: 'items', index });
|
| 428 |
+
setTempItems(items.map((entry) => entry.text));
|
| 429 |
+
}
|
| 430 |
+
}}
|
| 431 |
+
>
|
| 432 |
+
{item.text}
|
| 433 |
+
</div>
|
| 434 |
+
)}
|
| 435 |
+
</div>
|
| 436 |
+
</DraggableSurface>
|
| 437 |
))}
|
| 438 |
</div>
|
| 439 |
</div>
|
|
|
|
| 441 |
);
|
| 442 |
}
|
| 443 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
export function NeoTitleAndText({
|
| 445 |
title,
|
| 446 |
body = [],
|
| 447 |
+
formatting,
|
| 448 |
styles,
|
| 449 |
slideId,
|
| 450 |
isEditable = false,
|
| 451 |
onFieldUpdate,
|
| 452 |
+
onFormattingUpdate,
|
| 453 |
}: LayoutProps) {
|
| 454 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 455 |
const [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
|
| 456 |
const [tempTitle, setTempTitle] = useState(title);
|
| 457 |
+
const [tempBody, setTempBody] = useState(body.map((entry) => entry.text));
|
| 458 |
+
const bodyText = body[0]?.text || '';
|
| 459 |
|
| 460 |
const handleBlur = (field: string, index?: number) => {
|
| 461 |
if (!slideId || !onFieldUpdate) return;
|
| 462 |
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 463 |
+
if (field === 'body' && index !== undefined && tempBody[index] !== body[index]?.text) {
|
| 464 |
onFieldUpdate(slideId, 'body', tempBody[index], index);
|
| 465 |
+
}
|
| 466 |
setEditingField(null);
|
| 467 |
};
|
| 468 |
|
| 469 |
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 470 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 471 |
+
e.preventDefault();
|
| 472 |
+
handleBlur(field, index);
|
| 473 |
+
}
|
| 474 |
if (e.key === 'Escape') {
|
| 475 |
setEditingField(null);
|
| 476 |
+
setTempTitle(title);
|
| 477 |
+
setTempBody(body.map((entry) => entry.text));
|
| 478 |
}
|
| 479 |
};
|
| 480 |
|
|
|
|
|
|
|
| 481 |
return (
|
| 482 |
<div
|
| 483 |
+
ref={slideRef}
|
| 484 |
+
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden px-8 py-10"
|
| 485 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 486 |
>
|
| 487 |
<DotOverlay pattern={styles.dotPattern} />
|
| 488 |
|
| 489 |
+
<div className="relative z-10 flex w-full max-w-[680px] flex-col items-center gap-6">
|
| 490 |
+
<DraggableSurface
|
| 491 |
+
constraintsRef={slideRef}
|
| 492 |
+
slideId={slideId}
|
| 493 |
+
formatKey="title-and-text-title"
|
| 494 |
+
formatting={formatting}
|
| 495 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 496 |
+
isEditable={isEditable}
|
| 497 |
+
className="w-full max-w-[540px]"
|
| 498 |
>
|
| 499 |
+
<div className="bg-neo-cyan neo-border neo-shadow px-8 py-6">
|
| 500 |
+
{editingField?.field === 'title' ? (
|
| 501 |
+
<textarea
|
| 502 |
+
value={tempTitle}
|
| 503 |
+
onChange={(e) => setTempTitle(e.target.value)}
|
| 504 |
+
onBlur={() => handleBlur('title')}
|
| 505 |
+
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
| 506 |
+
onPointerDown={stopPointerDown}
|
| 507 |
+
className="w-full resize-none bg-transparent text-center outline-none"
|
| 508 |
+
style={{
|
| 509 |
+
fontFamily: styles.fonts.heading,
|
| 510 |
+
fontSize: '40px',
|
| 511 |
+
fontWeight: 900,
|
| 512 |
+
color: '#000',
|
| 513 |
+
lineHeight: 0.95,
|
| 514 |
+
letterSpacing: '-0.04em',
|
| 515 |
+
textTransform: 'uppercase',
|
| 516 |
+
}}
|
| 517 |
+
rows={2}
|
| 518 |
+
autoFocus
|
| 519 |
+
/>
|
| 520 |
+
) : (
|
| 521 |
+
<h2
|
| 522 |
+
className={isEditable ? 'cursor-text text-center' : 'text-center'}
|
| 523 |
+
style={{
|
| 524 |
+
fontFamily: styles.fonts.heading,
|
| 525 |
+
fontSize: '40px',
|
| 526 |
+
fontWeight: 900,
|
| 527 |
+
color: '#000',
|
| 528 |
+
lineHeight: 0.95,
|
| 529 |
+
letterSpacing: '-0.04em',
|
| 530 |
+
textTransform: 'uppercase',
|
| 531 |
+
whiteSpace: 'pre-line',
|
| 532 |
+
}}
|
| 533 |
+
onClick={() => {
|
| 534 |
+
if (isEditable) {
|
| 535 |
+
setEditingField({ field: 'title' });
|
| 536 |
+
setTempTitle(title);
|
| 537 |
+
}
|
| 538 |
+
}}
|
| 539 |
+
>
|
| 540 |
+
{title || 'THE PROBLEM'}
|
| 541 |
+
</h2>
|
| 542 |
+
)}
|
| 543 |
+
</div>
|
| 544 |
+
</DraggableSurface>
|
| 545 |
|
| 546 |
+
<DraggableSurface
|
| 547 |
+
constraintsRef={slideRef}
|
| 548 |
+
slideId={slideId}
|
| 549 |
+
formatKey="title-and-text-body"
|
| 550 |
+
formatting={formatting}
|
| 551 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 552 |
+
isEditable={isEditable}
|
| 553 |
+
className="w-full"
|
| 554 |
>
|
| 555 |
+
<div className="bg-white neo-border neo-shadow px-8 py-8 md:px-10 md:py-10">
|
| 556 |
+
{editingField?.field === 'body' && editingField.index === 0 ? (
|
| 557 |
+
<textarea
|
| 558 |
+
value={tempBody[0] || ''}
|
| 559 |
+
onChange={(e) => {
|
| 560 |
+
const next = [...tempBody];
|
| 561 |
+
next[0] = e.target.value;
|
| 562 |
+
setTempBody(next);
|
| 563 |
+
}}
|
| 564 |
+
onBlur={() => handleBlur('body', 0)}
|
| 565 |
+
onKeyDown={(e) => handleKeyDown(e, 'body', 0)}
|
| 566 |
+
onPointerDown={stopPointerDown}
|
| 567 |
+
className="w-full resize-none bg-transparent text-center outline-none"
|
| 568 |
+
style={{
|
| 569 |
+
fontFamily: styles.fonts.body,
|
| 570 |
+
fontSize: '24px',
|
| 571 |
+
fontWeight: 600,
|
| 572 |
+
color: '#000',
|
| 573 |
+
lineHeight: 1.25,
|
| 574 |
+
}}
|
| 575 |
+
rows={5}
|
| 576 |
+
autoFocus
|
| 577 |
+
/>
|
| 578 |
+
) : (
|
| 579 |
+
<p
|
| 580 |
+
className={isEditable ? 'cursor-text text-center' : 'text-center'}
|
| 581 |
+
style={{
|
| 582 |
+
fontFamily: styles.fonts.body,
|
| 583 |
+
fontSize: '24px',
|
| 584 |
+
fontWeight: 600,
|
| 585 |
+
color: '#000',
|
| 586 |
+
lineHeight: 1.25,
|
| 587 |
+
whiteSpace: 'pre-line',
|
| 588 |
+
}}
|
| 589 |
+
onClick={() => {
|
| 590 |
+
if (isEditable) {
|
| 591 |
+
setEditingField({ field: 'body', index: 0 });
|
| 592 |
+
setTempBody(body.map((entry) => entry.text));
|
| 593 |
+
}
|
| 594 |
+
}}
|
| 595 |
+
>
|
| 596 |
+
{bodyText || (isEditable ? 'Click to add text' : '')}
|
| 597 |
+
</p>
|
| 598 |
+
)}
|
| 599 |
+
</div>
|
| 600 |
+
</DraggableSurface>
|
| 601 |
+
</div>
|
| 602 |
+
</div>
|
| 603 |
+
);
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
export function NeoThreeColumns({
|
| 607 |
+
title,
|
| 608 |
+
columns = [],
|
| 609 |
+
formatting,
|
| 610 |
+
styles,
|
| 611 |
+
slideId,
|
| 612 |
+
isEditable = false,
|
| 613 |
+
onFieldUpdate,
|
| 614 |
+
onFormattingUpdate,
|
| 615 |
+
}: LayoutProps) {
|
| 616 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 617 |
+
const [editingField, setEditingField] = useState<{ field: string; index?: number; sub?: 'heading' | 'text' } | null>(null);
|
| 618 |
+
const [tempTitle, setTempTitle] = useState(title);
|
| 619 |
+
const [tempColumns, setTempColumns] = useState(columns);
|
| 620 |
+
|
| 621 |
+
const handleBlur = (field: string, index?: number) => {
|
| 622 |
+
if (!slideId || !onFieldUpdate) return;
|
| 623 |
+
if (field === 'title' && tempTitle !== title) {
|
| 624 |
+
onFieldUpdate(slideId, 'title', tempTitle);
|
| 625 |
+
}
|
| 626 |
+
if (field === 'columns' && index !== undefined && tempColumns[index]) {
|
| 627 |
+
onFieldUpdate(slideId, 'columns', JSON.stringify(tempColumns[index]), index);
|
| 628 |
+
}
|
| 629 |
+
setEditingField(null);
|
| 630 |
+
};
|
| 631 |
+
|
| 632 |
+
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 633 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 634 |
+
e.preventDefault();
|
| 635 |
+
handleBlur(field, index);
|
| 636 |
+
}
|
| 637 |
+
if (e.key === 'Escape') {
|
| 638 |
+
setEditingField(null);
|
| 639 |
+
setTempTitle(title);
|
| 640 |
+
setTempColumns(columns);
|
| 641 |
+
}
|
| 642 |
+
};
|
| 643 |
+
|
| 644 |
+
const displayColumns = columns.length
|
| 645 |
+
? columns
|
| 646 |
+
: [
|
| 647 |
+
{ heading: 'FOUNDATIONAL STRUCTURE', text: 'Define the first structural pillar here.' },
|
| 648 |
+
{ heading: 'RADICAL TRANSPARENCY', text: 'Define the second structural pillar here.' },
|
| 649 |
+
{ heading: 'DYNAMIC SCALABILITY', text: 'Define the third structural pillar here.' },
|
| 650 |
+
];
|
| 651 |
+
|
| 652 |
+
return (
|
| 653 |
+
<div
|
| 654 |
+
ref={slideRef}
|
| 655 |
+
className="relative flex h-full w-full flex-col overflow-hidden px-7 py-7"
|
| 656 |
+
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 657 |
+
>
|
| 658 |
+
<DotOverlay pattern={styles.dotPattern} />
|
| 659 |
+
|
| 660 |
+
<div className="relative z-10 flex h-full flex-col">
|
| 661 |
+
<div className="mb-6 flex items-start justify-between gap-4">
|
| 662 |
+
<DraggableSurface
|
| 663 |
+
constraintsRef={slideRef}
|
| 664 |
+
slideId={slideId}
|
| 665 |
+
formatKey="columns-title"
|
| 666 |
+
formatting={formatting}
|
| 667 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 668 |
+
isEditable={isEditable}
|
| 669 |
+
className="self-start"
|
| 670 |
+
>
|
| 671 |
+
<div className="bg-neo-lime neo-border neo-shadow px-6 py-4">
|
| 672 |
+
{editingField?.field === 'title' ? (
|
| 673 |
+
<textarea
|
| 674 |
+
value={tempTitle}
|
| 675 |
+
onChange={(e) => setTempTitle(e.target.value)}
|
| 676 |
+
onBlur={() => handleBlur('title')}
|
| 677 |
+
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
| 678 |
+
onPointerDown={stopPointerDown}
|
| 679 |
+
className="w-[320px] resize-none bg-transparent outline-none"
|
| 680 |
+
style={{
|
| 681 |
+
fontFamily: styles.fonts.heading,
|
| 682 |
+
fontSize: '34px',
|
| 683 |
+
fontWeight: 900,
|
| 684 |
+
color: '#000',
|
| 685 |
+
lineHeight: 0.95,
|
| 686 |
+
letterSpacing: '-0.05em',
|
| 687 |
+
textTransform: 'uppercase',
|
| 688 |
+
}}
|
| 689 |
+
rows={2}
|
| 690 |
+
autoFocus
|
| 691 |
+
/>
|
| 692 |
+
) : (
|
| 693 |
+
<h2
|
| 694 |
+
className={isEditable ? 'cursor-text' : ''}
|
| 695 |
+
style={{
|
| 696 |
+
fontFamily: styles.fonts.heading,
|
| 697 |
+
fontSize: '34px',
|
| 698 |
+
fontWeight: 900,
|
| 699 |
+
color: '#000',
|
| 700 |
+
lineHeight: 0.95,
|
| 701 |
+
letterSpacing: '-0.05em',
|
| 702 |
+
textTransform: 'uppercase',
|
| 703 |
+
whiteSpace: 'pre-line',
|
| 704 |
+
}}
|
| 705 |
+
onClick={() => {
|
| 706 |
+
if (isEditable) {
|
| 707 |
+
setEditingField({ field: 'title' });
|
| 708 |
+
setTempTitle(title);
|
| 709 |
+
}
|
| 710 |
+
}}
|
| 711 |
+
>
|
| 712 |
+
{title || 'THE 3 PILLARS'}
|
| 713 |
+
</h2>
|
| 714 |
+
)}
|
| 715 |
+
</div>
|
| 716 |
+
</DraggableSurface>
|
| 717 |
+
|
| 718 |
+
<DraggableSurface
|
| 719 |
+
constraintsRef={slideRef}
|
| 720 |
+
slideId={slideId}
|
| 721 |
+
formatKey="columns-decor"
|
| 722 |
+
formatting={formatting}
|
| 723 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 724 |
+
isEditable={isEditable}
|
| 725 |
+
>
|
| 726 |
+
<div className="opacity-25">
|
| 727 |
+
<div className="h-16 w-16 neo-border bg-white" />
|
| 728 |
+
</div>
|
| 729 |
+
</DraggableSurface>
|
| 730 |
+
</div>
|
| 731 |
+
|
| 732 |
+
<div className="grid flex-1 grid-cols-3 gap-4">
|
| 733 |
+
{displayColumns.slice(0, 3).map((column, index) => (
|
| 734 |
+
<DraggableSurface
|
| 735 |
+
key={`${column.heading}-${index}`}
|
| 736 |
+
constraintsRef={slideRef}
|
| 737 |
+
slideId={slideId}
|
| 738 |
+
formatKey={`column-card-${index}`}
|
| 739 |
+
formatting={formatting}
|
| 740 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 741 |
+
isEditable={isEditable}
|
| 742 |
>
|
| 743 |
+
<div className="flex h-full flex-col overflow-hidden bg-white neo-border neo-shadow">
|
| 744 |
+
<div className="border-b-[3px] border-black bg-neo-lime px-5 py-4">
|
| 745 |
+
<span
|
| 746 |
+
style={{
|
| 747 |
+
fontFamily: styles.fonts.heading,
|
| 748 |
+
fontSize: '30px',
|
| 749 |
+
fontWeight: 900,
|
| 750 |
+
color: '#000',
|
| 751 |
+
lineHeight: 1,
|
| 752 |
+
}}
|
| 753 |
+
>
|
| 754 |
+
{String(index + 1).padStart(2, '0')}
|
| 755 |
+
</span>
|
| 756 |
+
</div>
|
| 757 |
+
|
| 758 |
+
<div className="flex flex-1 flex-col px-5 py-5">
|
| 759 |
+
{editingField?.field === 'columns' && editingField.index === index && editingField.sub === 'heading' ? (
|
| 760 |
+
<textarea
|
| 761 |
+
value={tempColumns[index]?.heading || column.heading}
|
| 762 |
+
onChange={(e) => {
|
| 763 |
+
const next = [...tempColumns];
|
| 764 |
+
next[index] = { ...(next[index] || column), heading: e.target.value };
|
| 765 |
+
setTempColumns(next);
|
| 766 |
+
}}
|
| 767 |
+
onBlur={() => handleBlur('columns', index)}
|
| 768 |
+
onKeyDown={(e) => handleKeyDown(e, 'columns', index)}
|
| 769 |
+
onPointerDown={stopPointerDown}
|
| 770 |
+
className="mb-4 w-full resize-none bg-transparent outline-none"
|
| 771 |
+
style={{
|
| 772 |
+
fontFamily: styles.fonts.heading,
|
| 773 |
+
fontSize: '22px',
|
| 774 |
+
fontWeight: 900,
|
| 775 |
+
color: '#000',
|
| 776 |
+
lineHeight: 1,
|
| 777 |
+
textTransform: 'uppercase',
|
| 778 |
+
}}
|
| 779 |
+
rows={2}
|
| 780 |
+
autoFocus
|
| 781 |
+
/>
|
| 782 |
+
) : (
|
| 783 |
+
<h3
|
| 784 |
+
className={isEditable ? 'mb-4 cursor-text' : 'mb-4'}
|
| 785 |
+
style={{
|
| 786 |
+
fontFamily: styles.fonts.heading,
|
| 787 |
+
fontSize: '22px',
|
| 788 |
+
fontWeight: 900,
|
| 789 |
+
color: '#000',
|
| 790 |
+
lineHeight: 1,
|
| 791 |
+
textTransform: 'uppercase',
|
| 792 |
+
whiteSpace: 'pre-line',
|
| 793 |
+
}}
|
| 794 |
+
onClick={() => {
|
| 795 |
+
if (isEditable) {
|
| 796 |
+
setEditingField({ field: 'columns', index, sub: 'heading' });
|
| 797 |
+
setTempColumns(displayColumns);
|
| 798 |
+
}
|
| 799 |
+
}}
|
| 800 |
+
>
|
| 801 |
+
{column.heading}
|
| 802 |
+
</h3>
|
| 803 |
+
)}
|
| 804 |
+
|
| 805 |
+
{editingField?.field === 'columns' && editingField.index === index && editingField.sub === 'text' ? (
|
| 806 |
+
<textarea
|
| 807 |
+
value={tempColumns[index]?.text || column.text}
|
| 808 |
+
onChange={(e) => {
|
| 809 |
+
const next = [...tempColumns];
|
| 810 |
+
next[index] = { ...(next[index] || column), text: e.target.value };
|
| 811 |
+
setTempColumns(next);
|
| 812 |
+
}}
|
| 813 |
+
onBlur={() => handleBlur('columns', index)}
|
| 814 |
+
onKeyDown={(e) => handleKeyDown(e, 'columns', index)}
|
| 815 |
+
onPointerDown={stopPointerDown}
|
| 816 |
+
className="w-full flex-1 resize-none bg-transparent outline-none"
|
| 817 |
+
style={{
|
| 818 |
+
fontFamily: styles.fonts.body,
|
| 819 |
+
fontSize: '16px',
|
| 820 |
+
fontWeight: 600,
|
| 821 |
+
color: '#000',
|
| 822 |
+
lineHeight: 1.45,
|
| 823 |
+
}}
|
| 824 |
+
autoFocus
|
| 825 |
+
/>
|
| 826 |
+
) : (
|
| 827 |
+
<p
|
| 828 |
+
className={isEditable ? 'cursor-text' : ''}
|
| 829 |
+
style={{
|
| 830 |
+
fontFamily: styles.fonts.body,
|
| 831 |
+
fontSize: '16px',
|
| 832 |
+
fontWeight: 600,
|
| 833 |
+
color: '#000',
|
| 834 |
+
lineHeight: 1.45,
|
| 835 |
+
whiteSpace: 'pre-line',
|
| 836 |
+
}}
|
| 837 |
+
onClick={() => {
|
| 838 |
+
if (isEditable) {
|
| 839 |
+
setEditingField({ field: 'columns', index, sub: 'text' });
|
| 840 |
+
setTempColumns(displayColumns);
|
| 841 |
+
}
|
| 842 |
+
}}
|
| 843 |
+
>
|
| 844 |
+
{column.text}
|
| 845 |
+
</p>
|
| 846 |
+
)}
|
| 847 |
+
</div>
|
| 848 |
+
</div>
|
| 849 |
+
</DraggableSurface>
|
| 850 |
+
))}
|
| 851 |
</div>
|
| 852 |
</div>
|
| 853 |
</div>
|
| 854 |
);
|
| 855 |
}
|
| 856 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
export function NeoImageAndText({
|
| 858 |
title,
|
| 859 |
body = [],
|
| 860 |
imageUrl,
|
| 861 |
+
formatting,
|
| 862 |
styles,
|
| 863 |
slideId,
|
| 864 |
isEditable = false,
|
| 865 |
onFieldUpdate,
|
| 866 |
+
onFormattingUpdate,
|
| 867 |
+
onRequestImageSelect,
|
| 868 |
}: LayoutProps) {
|
| 869 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 870 |
const [editingField, setEditingField] = useState<string | null>(null);
|
| 871 |
const [tempTitle, setTempTitle] = useState(title);
|
| 872 |
+
const [tempBody, setTempBody] = useState(body[0]?.text || '');
|
| 873 |
+
const bodyText = body[0]?.text || '';
|
| 874 |
|
| 875 |
const handleBlur = (field: string) => {
|
| 876 |
if (!slideId || !onFieldUpdate) return;
|
| 877 |
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 878 |
+
if (field === 'body' && tempBody !== bodyText) onFieldUpdate(slideId, 'body', tempBody, 0);
|
| 879 |
setEditingField(null);
|
| 880 |
};
|
| 881 |
|
| 882 |
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 883 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 884 |
+
e.preventDefault();
|
| 885 |
+
handleBlur(field);
|
| 886 |
+
}
|
| 887 |
if (e.key === 'Escape') {
|
| 888 |
setEditingField(null);
|
| 889 |
+
setTempTitle(title);
|
| 890 |
+
setTempBody(bodyText);
|
| 891 |
}
|
| 892 |
};
|
| 893 |
|
|
|
|
|
|
|
| 894 |
return (
|
| 895 |
<div
|
| 896 |
+
ref={slideRef}
|
| 897 |
+
className="relative flex h-full w-full items-center justify-center overflow-hidden px-7 py-8"
|
| 898 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 899 |
>
|
| 900 |
<DotOverlay pattern={styles.dotPattern} />
|
| 901 |
|
| 902 |
+
<div className="relative z-10 flex h-full w-full items-center gap-6">
|
| 903 |
+
<DraggableSurface
|
| 904 |
+
constraintsRef={slideRef}
|
| 905 |
+
slideId={slideId}
|
| 906 |
+
formatKey="image-card"
|
| 907 |
+
formatting={formatting}
|
| 908 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 909 |
+
isEditable={isEditable}
|
| 910 |
+
className="w-[48%] rotate-2"
|
| 911 |
>
|
| 912 |
+
<div className="relative bg-neo-yellow neo-border neo-shadow p-4">
|
| 913 |
+
{isEditable && slideId && onRequestImageSelect && (
|
| 914 |
+
<button
|
| 915 |
+
type="button"
|
| 916 |
+
onClick={() => onRequestImageSelect(slideId)}
|
| 917 |
+
onPointerDown={stopPointerDown}
|
| 918 |
+
className="absolute right-3 top-3 z-20 bg-black px-3 py-1.5 text-xs font-black uppercase tracking-wide text-white neo-border hover:bg-neo-purple"
|
| 919 |
+
>
|
| 920 |
+
Replace
|
| 921 |
+
</button>
|
| 922 |
+
)}
|
| 923 |
+
|
| 924 |
+
<div
|
| 925 |
+
className={`overflow-hidden neo-border bg-white ${isEditable && slideId && onRequestImageSelect ? 'cursor-pointer' : ''}`}
|
| 926 |
+
onClick={() => {
|
| 927 |
+
if (isEditable && slideId && onRequestImageSelect) {
|
| 928 |
+
onRequestImageSelect(slideId);
|
| 929 |
+
}
|
| 930 |
}}
|
| 931 |
+
>
|
| 932 |
+
{imageUrl ? (
|
| 933 |
+
<img
|
| 934 |
+
src={imageUrl}
|
| 935 |
+
alt={title}
|
| 936 |
+
className="h-[250px] w-full object-cover"
|
| 937 |
+
/>
|
| 938 |
+
) : (
|
| 939 |
+
<div className="flex h-[250px] items-center justify-center bg-[#f5f5f0] p-6 text-center">
|
| 940 |
+
<span
|
| 941 |
+
style={{
|
| 942 |
+
fontFamily: styles.fonts.body,
|
| 943 |
+
fontSize: '14px',
|
| 944 |
+
fontWeight: 800,
|
| 945 |
+
color: '#000',
|
| 946 |
+
opacity: 0.6,
|
| 947 |
+
textTransform: 'uppercase',
|
| 948 |
+
}}
|
| 949 |
+
>
|
| 950 |
+
Drop in an image for this slide
|
| 951 |
+
</span>
|
| 952 |
+
</div>
|
| 953 |
+
)}
|
| 954 |
+
</div>
|
| 955 |
+
<div
|
| 956 |
+
className="mt-4 text-center"
|
| 957 |
style={{
|
| 958 |
fontFamily: styles.fonts.heading,
|
| 959 |
+
fontSize: '18px',
|
| 960 |
fontWeight: 900,
|
| 961 |
color: '#000',
|
| 962 |
textTransform: 'uppercase',
|
| 963 |
+
letterSpacing: '-0.03em',
|
|
|
|
| 964 |
}}
|
|
|
|
| 965 |
>
|
| 966 |
+
FIG 1. STRUCTURAL HONESTY
|
| 967 |
+
</div>
|
| 968 |
+
</div>
|
| 969 |
+
</DraggableSurface>
|
| 970 |
|
| 971 |
+
<DraggableSurface
|
| 972 |
+
constraintsRef={slideRef}
|
| 973 |
+
slideId={slideId}
|
| 974 |
+
formatKey="image-text"
|
| 975 |
+
formatting={formatting}
|
| 976 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 977 |
+
isEditable={isEditable}
|
| 978 |
+
className="w-[44%] -rotate-1"
|
| 979 |
+
>
|
| 980 |
+
<div className="bg-white neo-border neo-shadow px-7 py-8">
|
| 981 |
+
{editingField === 'title' ? (
|
| 982 |
+
<textarea
|
| 983 |
+
value={tempTitle}
|
| 984 |
+
onChange={(e) => setTempTitle(e.target.value)}
|
| 985 |
+
onBlur={() => handleBlur('title')}
|
| 986 |
+
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
| 987 |
+
onPointerDown={stopPointerDown}
|
| 988 |
+
className="mb-5 w-full resize-none bg-transparent outline-none"
|
| 989 |
+
style={{
|
| 990 |
+
fontFamily: styles.fonts.heading,
|
| 991 |
+
fontSize: '42px',
|
| 992 |
+
fontWeight: 900,
|
| 993 |
+
color: '#000',
|
| 994 |
+
lineHeight: 0.95,
|
| 995 |
+
letterSpacing: '-0.05em',
|
| 996 |
+
textTransform: 'uppercase',
|
| 997 |
+
}}
|
| 998 |
+
rows={2}
|
| 999 |
+
autoFocus
|
| 1000 |
/>
|
| 1001 |
) : (
|
| 1002 |
+
<h2
|
| 1003 |
+
className={isEditable ? 'mb-5 cursor-text' : 'mb-5'}
|
| 1004 |
style={{
|
| 1005 |
+
fontFamily: styles.fonts.heading,
|
| 1006 |
+
fontSize: '42px',
|
| 1007 |
+
fontWeight: 900,
|
| 1008 |
+
color: '#000',
|
| 1009 |
+
lineHeight: 0.95,
|
| 1010 |
+
letterSpacing: '-0.05em',
|
| 1011 |
+
textTransform: 'uppercase',
|
| 1012 |
+
whiteSpace: 'pre-line',
|
| 1013 |
+
}}
|
| 1014 |
+
onClick={() => {
|
| 1015 |
+
if (isEditable) {
|
| 1016 |
+
setEditingField('title');
|
| 1017 |
+
setTempTitle(title);
|
| 1018 |
+
}
|
| 1019 |
}}
|
| 1020 |
>
|
| 1021 |
+
{title || 'VISUAL IMPACT'}
|
| 1022 |
+
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
)}
|
|
|
|
| 1024 |
|
| 1025 |
+
{editingField === 'body' ? (
|
| 1026 |
+
<textarea
|
| 1027 |
+
value={tempBody}
|
| 1028 |
+
onChange={(e) => setTempBody(e.target.value)}
|
| 1029 |
+
onBlur={() => handleBlur('body')}
|
| 1030 |
+
onKeyDown={(e) => handleKeyDown(e, 'body')}
|
| 1031 |
+
onPointerDown={stopPointerDown}
|
| 1032 |
+
className="h-[170px] w-full resize-none bg-transparent outline-none"
|
| 1033 |
+
style={{
|
| 1034 |
+
fontFamily: styles.fonts.body,
|
| 1035 |
+
fontSize: '18px',
|
| 1036 |
+
fontWeight: 600,
|
| 1037 |
+
color: '#000',
|
| 1038 |
+
lineHeight: 1.45,
|
| 1039 |
+
}}
|
| 1040 |
+
autoFocus
|
| 1041 |
+
/>
|
| 1042 |
+
) : (
|
| 1043 |
+
<p
|
| 1044 |
+
className={isEditable ? 'cursor-text' : ''}
|
| 1045 |
+
style={{
|
| 1046 |
+
fontFamily: styles.fonts.body,
|
| 1047 |
+
fontSize: '18px',
|
| 1048 |
+
fontWeight: 600,
|
| 1049 |
+
color: '#000',
|
| 1050 |
+
lineHeight: 1.45,
|
| 1051 |
+
whiteSpace: 'pre-line',
|
| 1052 |
+
}}
|
| 1053 |
+
onClick={() => {
|
| 1054 |
+
if (isEditable) {
|
| 1055 |
+
setEditingField('body');
|
| 1056 |
+
setTempBody(bodyText);
|
| 1057 |
+
}
|
| 1058 |
+
}}
|
| 1059 |
+
>
|
| 1060 |
+
{bodyText || (isEditable ? 'Click to add description' : '')}
|
| 1061 |
+
</p>
|
| 1062 |
+
)}
|
| 1063 |
</div>
|
| 1064 |
+
</DraggableSurface>
|
| 1065 |
</div>
|
| 1066 |
</div>
|
| 1067 |
);
|
| 1068 |
}
|
| 1069 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1070 |
export function NeoReferences({
|
| 1071 |
title,
|
| 1072 |
items = [],
|
| 1073 |
+
formatting,
|
| 1074 |
styles,
|
| 1075 |
slideId,
|
| 1076 |
isEditable = false,
|
| 1077 |
onFieldUpdate,
|
| 1078 |
+
onFormattingUpdate,
|
| 1079 |
}: LayoutProps) {
|
| 1080 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 1081 |
const [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
|
| 1082 |
const [tempTitle, setTempTitle] = useState(title);
|
| 1083 |
+
const [tempItems, setTempItems] = useState(items.map((item) => item.text));
|
| 1084 |
|
| 1085 |
const handleBlur = (field: string, index?: number) => {
|
| 1086 |
if (!slideId || !onFieldUpdate) return;
|
| 1087 |
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 1088 |
+
if (field === 'items' && index !== undefined && tempItems[index] !== items[index]?.text) {
|
| 1089 |
onFieldUpdate(slideId, 'items', tempItems[index], index);
|
| 1090 |
+
}
|
| 1091 |
setEditingField(null);
|
| 1092 |
};
|
| 1093 |
|
| 1094 |
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 1095 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 1096 |
+
e.preventDefault();
|
| 1097 |
+
handleBlur(field, index);
|
| 1098 |
+
}
|
| 1099 |
if (e.key === 'Escape') {
|
| 1100 |
setEditingField(null);
|
| 1101 |
+
setTempTitle(title);
|
| 1102 |
+
setTempItems(items.map((item) => item.text));
|
| 1103 |
}
|
| 1104 |
};
|
| 1105 |
|
| 1106 |
return (
|
| 1107 |
<div
|
| 1108 |
+
ref={slideRef}
|
| 1109 |
+
className="relative flex h-full w-full flex-col overflow-hidden px-7 py-7"
|
| 1110 |
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 1111 |
>
|
| 1112 |
<DotOverlay pattern={styles.dotPattern} />
|
| 1113 |
|
| 1114 |
+
<div className="relative z-10 flex h-full flex-col">
|
| 1115 |
+
<DraggableSurface
|
| 1116 |
+
constraintsRef={slideRef}
|
| 1117 |
+
slideId={slideId}
|
| 1118 |
+
formatKey="references-title"
|
| 1119 |
+
formatting={formatting}
|
| 1120 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 1121 |
+
isEditable={isEditable}
|
| 1122 |
+
className="mb-6 self-start"
|
| 1123 |
>
|
| 1124 |
+
<div className="bg-black neo-border neo-shadow px-6 py-4 text-white">
|
| 1125 |
+
{editingField?.field === 'title' ? (
|
| 1126 |
+
<textarea
|
| 1127 |
+
value={tempTitle}
|
| 1128 |
+
onChange={(e) => setTempTitle(e.target.value)}
|
| 1129 |
+
onBlur={() => handleBlur('title')}
|
| 1130 |
+
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
| 1131 |
+
onPointerDown={stopPointerDown}
|
| 1132 |
+
className="w-[320px] resize-none bg-transparent outline-none"
|
| 1133 |
+
style={{
|
| 1134 |
+
fontFamily: styles.fonts.heading,
|
| 1135 |
+
fontSize: '28px',
|
| 1136 |
+
fontWeight: 900,
|
| 1137 |
+
color: '#fff',
|
| 1138 |
+
lineHeight: 0.95,
|
| 1139 |
+
letterSpacing: '-0.04em',
|
| 1140 |
+
textTransform: 'uppercase',
|
| 1141 |
+
}}
|
| 1142 |
+
rows={2}
|
| 1143 |
+
autoFocus
|
| 1144 |
+
/>
|
| 1145 |
+
) : (
|
| 1146 |
+
<h2
|
| 1147 |
+
className={isEditable ? 'cursor-text' : ''}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1148 |
style={{
|
| 1149 |
fontFamily: styles.fonts.heading,
|
| 1150 |
+
fontSize: '28px',
|
| 1151 |
fontWeight: 900,
|
| 1152 |
+
color: '#fff',
|
| 1153 |
+
lineHeight: 0.95,
|
| 1154 |
+
letterSpacing: '-0.04em',
|
| 1155 |
+
textTransform: 'uppercase',
|
| 1156 |
+
whiteSpace: 'pre-line',
|
| 1157 |
+
}}
|
| 1158 |
+
onClick={() => {
|
| 1159 |
+
if (isEditable) {
|
| 1160 |
+
setEditingField({ field: 'title' });
|
| 1161 |
+
setTempTitle(title);
|
| 1162 |
+
}
|
| 1163 |
}}
|
| 1164 |
>
|
| 1165 |
+
{title || 'REFERENCES_&_SOURCES'}
|
| 1166 |
+
</h2>
|
| 1167 |
+
)}
|
| 1168 |
+
</div>
|
| 1169 |
+
</DraggableSurface>
|
| 1170 |
|
| 1171 |
+
<div className="grid flex-1 grid-cols-[1.4fr_0.9fr] gap-6">
|
| 1172 |
+
<div className="space-y-4 overflow-hidden">
|
| 1173 |
+
{items.map((item, index) => {
|
| 1174 |
+
const parsed = parseReference(item.text, index);
|
| 1175 |
+
|
| 1176 |
+
return (
|
| 1177 |
+
<DraggableSurface
|
| 1178 |
+
key={`${item.text}-${index}`}
|
| 1179 |
+
constraintsRef={slideRef}
|
| 1180 |
+
slideId={slideId}
|
| 1181 |
+
formatKey={`reference-item-${index}`}
|
| 1182 |
+
formatting={formatting}
|
| 1183 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 1184 |
+
isEditable={isEditable}
|
| 1185 |
+
>
|
| 1186 |
+
<div className="border-b-[3px] border-black bg-white/70 p-3 backdrop-blur-sm">
|
| 1187 |
+
<span
|
| 1188 |
+
className="mb-1 block text-neo-purple"
|
| 1189 |
+
style={{
|
| 1190 |
+
fontFamily: styles.fonts.heading,
|
| 1191 |
+
fontSize: '12px',
|
| 1192 |
+
fontWeight: 900,
|
| 1193 |
+
letterSpacing: '0.12em',
|
| 1194 |
+
textTransform: 'uppercase',
|
| 1195 |
+
}}
|
| 1196 |
+
>
|
| 1197 |
+
{parsed.label}
|
| 1198 |
+
</span>
|
| 1199 |
+
|
| 1200 |
+
{editingField?.field === 'items' && editingField.index === index ? (
|
| 1201 |
+
<textarea
|
| 1202 |
+
value={tempItems[index] || ''}
|
| 1203 |
+
onChange={(e) => {
|
| 1204 |
+
const next = [...tempItems];
|
| 1205 |
+
next[index] = e.target.value;
|
| 1206 |
+
setTempItems(next);
|
| 1207 |
+
}}
|
| 1208 |
+
onBlur={() => handleBlur('items', index)}
|
| 1209 |
+
onKeyDown={(e) => handleKeyDown(e, 'items', index)}
|
| 1210 |
+
onPointerDown={stopPointerDown}
|
| 1211 |
+
className="w-full resize-none bg-transparent outline-none"
|
| 1212 |
+
style={{
|
| 1213 |
+
fontFamily: styles.fonts.body,
|
| 1214 |
+
fontSize: '18px',
|
| 1215 |
+
fontWeight: 700,
|
| 1216 |
+
color: '#000',
|
| 1217 |
+
lineHeight: 1.25,
|
| 1218 |
+
}}
|
| 1219 |
+
rows={2}
|
| 1220 |
+
autoFocus
|
| 1221 |
+
/>
|
| 1222 |
+
) : parsed.url ? (
|
| 1223 |
+
<a
|
| 1224 |
+
href={parsed.url}
|
| 1225 |
+
target="_blank"
|
| 1226 |
+
rel="noopener noreferrer"
|
| 1227 |
+
className="flex items-center gap-2"
|
| 1228 |
+
style={{
|
| 1229 |
+
fontFamily: styles.fonts.body,
|
| 1230 |
+
fontSize: '18px',
|
| 1231 |
+
fontWeight: 700,
|
| 1232 |
+
color: '#000',
|
| 1233 |
+
lineHeight: 1.25,
|
| 1234 |
+
}}
|
| 1235 |
+
onClick={(e) => {
|
| 1236 |
+
if (isEditable) {
|
| 1237 |
+
e.preventDefault();
|
| 1238 |
+
setEditingField({ field: 'items', index });
|
| 1239 |
+
setTempItems(items.map((entry) => entry.text));
|
| 1240 |
+
}
|
| 1241 |
+
}}
|
| 1242 |
+
>
|
| 1243 |
+
<span>{parsed.value}</span>
|
| 1244 |
+
<LinkIcon className="h-4 w-4 shrink-0" />
|
| 1245 |
+
</a>
|
| 1246 |
+
) : (
|
| 1247 |
+
<div
|
| 1248 |
+
className={isEditable ? 'cursor-text' : ''}
|
| 1249 |
+
style={{
|
| 1250 |
+
fontFamily: styles.fonts.body,
|
| 1251 |
+
fontSize: '18px',
|
| 1252 |
+
fontWeight: 700,
|
| 1253 |
+
color: '#000',
|
| 1254 |
+
lineHeight: 1.25,
|
| 1255 |
+
whiteSpace: 'pre-line',
|
| 1256 |
+
}}
|
| 1257 |
+
onClick={() => {
|
| 1258 |
+
if (isEditable) {
|
| 1259 |
+
setEditingField({ field: 'items', index });
|
| 1260 |
+
setTempItems(items.map((entry) => entry.text));
|
| 1261 |
+
}
|
| 1262 |
+
}}
|
| 1263 |
+
>
|
| 1264 |
+
{parsed.value}
|
| 1265 |
+
</div>
|
| 1266 |
+
)}
|
| 1267 |
+
</div>
|
| 1268 |
+
</DraggableSurface>
|
| 1269 |
+
);
|
| 1270 |
+
})}
|
| 1271 |
+
</div>
|
| 1272 |
+
|
| 1273 |
+
<DraggableSurface
|
| 1274 |
+
constraintsRef={slideRef}
|
| 1275 |
+
slideId={slideId}
|
| 1276 |
+
formatKey="references-note"
|
| 1277 |
+
formatting={formatting}
|
| 1278 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 1279 |
+
isEditable={isEditable}
|
| 1280 |
+
className="self-start"
|
| 1281 |
+
>
|
| 1282 |
+
<div className="flex flex-col gap-4 bg-neo-yellow neo-border neo-shadow p-6">
|
| 1283 |
+
<div className="inline-block w-fit -rotate-2 bg-black px-4 py-2 text-white">
|
| 1284 |
<span
|
|
|
|
| 1285 |
style={{
|
| 1286 |
+
fontFamily: styles.fonts.heading,
|
| 1287 |
+
fontSize: '20px',
|
| 1288 |
+
fontWeight: 900,
|
| 1289 |
+
textTransform: 'uppercase',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1290 |
}}
|
| 1291 |
>
|
| 1292 |
+
RAW SOURCES
|
| 1293 |
</span>
|
| 1294 |
+
</div>
|
| 1295 |
+
<p
|
| 1296 |
+
style={{
|
| 1297 |
+
fontFamily: styles.fonts.body,
|
| 1298 |
+
fontSize: '16px',
|
| 1299 |
+
fontWeight: 700,
|
| 1300 |
+
color: '#000',
|
| 1301 |
+
lineHeight: 1.4,
|
| 1302 |
+
}}
|
| 1303 |
+
>
|
| 1304 |
+
Reference entries stay editable inline. Use <span className="font-black">label || title || url</span> if you want a tagged source with a clickable link.
|
| 1305 |
+
</p>
|
| 1306 |
</div>
|
| 1307 |
+
</DraggableSurface>
|
| 1308 |
</div>
|
| 1309 |
</div>
|
| 1310 |
</div>
|
| 1311 |
);
|
| 1312 |
}
|
| 1313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1314 |
export function NeoThankYou({
|
| 1315 |
title,
|
| 1316 |
subtitle,
|
| 1317 |
+
formatting,
|
| 1318 |
styles,
|
| 1319 |
slideId,
|
| 1320 |
isEditable = false,
|
| 1321 |
onFieldUpdate,
|
| 1322 |
+
onFormattingUpdate,
|
| 1323 |
}: LayoutProps) {
|
| 1324 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 1325 |
const [editingField, setEditingField] = useState<string | null>(null);
|
| 1326 |
const [tempTitle, setTempTitle] = useState(title);
|
| 1327 |
const [tempSubtitle, setTempSubtitle] = useState(subtitle || '');
|
|
|
|
| 1334 |
};
|
| 1335 |
|
| 1336 |
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 1337 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 1338 |
+
e.preventDefault();
|
| 1339 |
+
handleBlur(field);
|
| 1340 |
+
}
|
| 1341 |
if (e.key === 'Escape') {
|
| 1342 |
setEditingField(null);
|
| 1343 |
+
setTempTitle(title);
|
| 1344 |
+
setTempSubtitle(subtitle || '');
|
| 1345 |
}
|
| 1346 |
};
|
| 1347 |
|
| 1348 |
return (
|
| 1349 |
<div
|
| 1350 |
+
ref={slideRef}
|
| 1351 |
+
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden px-8 py-10"
|
| 1352 |
+
style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
|
| 1353 |
>
|
| 1354 |
<DotOverlay pattern={styles.dotPattern} />
|
| 1355 |
|
| 1356 |
+
<div className="relative z-10 flex w-full flex-col items-center gap-8">
|
| 1357 |
+
<DraggableSurface
|
| 1358 |
+
constraintsRef={slideRef}
|
| 1359 |
+
slideId={slideId}
|
| 1360 |
+
formatKey="thank-you-title"
|
| 1361 |
+
formatting={formatting}
|
| 1362 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 1363 |
+
isEditable={isEditable}
|
| 1364 |
+
className="relative"
|
| 1365 |
>
|
| 1366 |
+
<div className="relative bg-neo-lime neo-border neo-shadow px-10 py-12 md:px-14 md:py-14">
|
| 1367 |
+
<div className="absolute -left-6 -top-6 rotate-12 bg-neo-purple px-4 py-3 text-white neo-border">
|
| 1368 |
+
<span
|
| 1369 |
+
style={{
|
| 1370 |
+
fontFamily: styles.fonts.heading,
|
| 1371 |
+
fontSize: '18px',
|
| 1372 |
+
fontWeight: 900,
|
| 1373 |
+
textTransform: 'uppercase',
|
| 1374 |
+
}}
|
| 1375 |
+
>
|
| 1376 |
+
THE_END
|
| 1377 |
+
</span>
|
| 1378 |
+
</div>
|
| 1379 |
|
| 1380 |
+
{editingField === 'title' ? (
|
| 1381 |
+
<textarea
|
| 1382 |
+
value={tempTitle}
|
| 1383 |
+
onChange={(e) => setTempTitle(e.target.value)}
|
| 1384 |
+
onBlur={() => handleBlur('title')}
|
| 1385 |
+
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
| 1386 |
+
onPointerDown={stopPointerDown}
|
| 1387 |
+
className="w-full resize-none bg-transparent text-center outline-none"
|
| 1388 |
+
style={{
|
| 1389 |
+
fontFamily: styles.fonts.heading,
|
| 1390 |
+
fontSize: '58px',
|
| 1391 |
+
fontWeight: 900,
|
| 1392 |
+
color: '#000',
|
| 1393 |
+
lineHeight: 0.9,
|
| 1394 |
+
letterSpacing: '-0.05em',
|
| 1395 |
+
textTransform: 'uppercase',
|
| 1396 |
+
}}
|
| 1397 |
+
rows={2}
|
| 1398 |
+
autoFocus
|
| 1399 |
+
/>
|
| 1400 |
+
) : (
|
| 1401 |
+
<h2
|
| 1402 |
+
className={isEditable ? 'cursor-text text-center' : 'text-center'}
|
| 1403 |
+
style={{
|
| 1404 |
+
fontFamily: styles.fonts.heading,
|
| 1405 |
+
fontSize: '58px',
|
| 1406 |
+
fontWeight: 900,
|
| 1407 |
+
color: '#000',
|
| 1408 |
+
lineHeight: 0.9,
|
| 1409 |
+
letterSpacing: '-0.05em',
|
| 1410 |
+
textTransform: 'uppercase',
|
| 1411 |
+
whiteSpace: 'pre-line',
|
| 1412 |
+
}}
|
| 1413 |
+
onClick={() => {
|
| 1414 |
+
if (isEditable) {
|
| 1415 |
+
setEditingField('title');
|
| 1416 |
+
setTempTitle(title);
|
| 1417 |
+
}
|
| 1418 |
+
}}
|
| 1419 |
+
>
|
| 1420 |
+
{title || 'THANK YOU!'}
|
| 1421 |
+
</h2>
|
| 1422 |
+
)}
|
| 1423 |
+
</div>
|
| 1424 |
+
</DraggableSurface>
|
| 1425 |
|
| 1426 |
+
<DraggableSurface
|
| 1427 |
+
constraintsRef={slideRef}
|
| 1428 |
+
slideId={slideId}
|
| 1429 |
+
formatKey="thank-you-subtitle"
|
| 1430 |
+
formatting={formatting}
|
| 1431 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 1432 |
+
isEditable={isEditable}
|
| 1433 |
+
>
|
| 1434 |
+
<div className="bg-white/70 px-3 py-2">
|
| 1435 |
{editingField === 'subtitle' ? (
|
| 1436 |
+
<textarea
|
|
|
|
| 1437 |
value={tempSubtitle}
|
| 1438 |
onChange={(e) => setTempSubtitle(e.target.value)}
|
| 1439 |
onBlur={() => handleBlur('subtitle')}
|
| 1440 |
onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
|
| 1441 |
+
onPointerDown={stopPointerDown}
|
| 1442 |
+
className="w-full resize-none bg-transparent text-center outline-none"
|
| 1443 |
style={{
|
| 1444 |
fontFamily: styles.fonts.body,
|
| 1445 |
+
fontSize: '18px',
|
| 1446 |
+
fontWeight: 800,
|
| 1447 |
color: '#000',
|
| 1448 |
+
lineHeight: 1.2,
|
| 1449 |
+
letterSpacing: '0.08em',
|
| 1450 |
textTransform: 'uppercase',
|
|
|
|
|
|
|
| 1451 |
}}
|
| 1452 |
+
rows={2}
|
| 1453 |
autoFocus
|
| 1454 |
/>
|
| 1455 |
) : (
|
| 1456 |
<p
|
| 1457 |
+
className={isEditable ? 'cursor-text text-center' : 'text-center'}
|
| 1458 |
style={{
|
| 1459 |
fontFamily: styles.fonts.body,
|
| 1460 |
+
fontSize: '18px',
|
| 1461 |
+
fontWeight: 800,
|
| 1462 |
color: '#000',
|
| 1463 |
+
lineHeight: 1.2,
|
| 1464 |
+
letterSpacing: '0.08em',
|
| 1465 |
textTransform: 'uppercase',
|
| 1466 |
+
whiteSpace: 'pre-line',
|
| 1467 |
+
}}
|
| 1468 |
+
onClick={() => {
|
| 1469 |
+
if (isEditable) {
|
| 1470 |
+
setEditingField('subtitle');
|
| 1471 |
+
setTempSubtitle(subtitle || '');
|
| 1472 |
+
}
|
| 1473 |
}}
|
|
|
|
| 1474 |
>
|
| 1475 |
{subtitle || (isEditable ? 'Click to add subtitle' : '')}
|
| 1476 |
</p>
|
| 1477 |
)}
|
| 1478 |
</div>
|
| 1479 |
+
</DraggableSurface>
|
| 1480 |
</div>
|
| 1481 |
</div>
|
| 1482 |
);
|
components/slides/noisy/layouts.tsx
CHANGED
|
@@ -1,237 +1,417 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
-
import React, {
|
|
|
|
| 4 |
import { TemplateStyles } from '@/data/templates';
|
| 5 |
-
|
| 6 |
-
// ============================================================================
|
| 7 |
-
// SHARED TYPES
|
| 8 |
-
// ============================================================================
|
| 9 |
|
| 10 |
interface LayoutProps {
|
| 11 |
title: string;
|
| 12 |
subtitle?: string;
|
| 13 |
body?: Array<{ heading?: string; text: string }>;
|
|
|
|
| 14 |
items?: Array<{ text: string }>;
|
| 15 |
imageUrl?: string;
|
|
|
|
| 16 |
styles: TemplateStyles;
|
| 17 |
slideId?: string;
|
| 18 |
isEditable?: boolean;
|
| 19 |
onFieldUpdate?: (slideId: string, field: string, value: string, index?: number) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
);
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
export function NoisyTitleSubtitle({
|
| 43 |
title,
|
| 44 |
subtitle,
|
|
|
|
| 45 |
styles,
|
| 46 |
slideId,
|
| 47 |
isEditable = false,
|
| 48 |
onFieldUpdate,
|
|
|
|
| 49 |
}: LayoutProps) {
|
| 50 |
-
const
|
| 51 |
-
const [tempTitle, setTempTitle] = useState(title);
|
| 52 |
-
const [tempSubtitle, setTempSubtitle] = useState(subtitle || '');
|
| 53 |
-
|
| 54 |
-
const handleBlur = (field: string) => {
|
| 55 |
-
if (!slideId || !onFieldUpdate) return;
|
| 56 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 57 |
-
if (field === 'subtitle' && tempSubtitle !== subtitle) onFieldUpdate(slideId, 'subtitle', tempSubtitle);
|
| 58 |
-
setEditingField(null);
|
| 59 |
-
};
|
| 60 |
-
|
| 61 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 62 |
-
if (e.key === 'Enter') { e.preventDefault(); handleBlur(field); }
|
| 63 |
-
if (e.key === 'Escape') {
|
| 64 |
-
setEditingField(null);
|
| 65 |
-
if (field === 'title') setTempTitle(title);
|
| 66 |
-
if (field === 'subtitle') setTempSubtitle(subtitle || '');
|
| 67 |
-
}
|
| 68 |
-
};
|
| 69 |
|
| 70 |
return (
|
| 71 |
<div
|
| 72 |
-
|
|
|
|
| 73 |
style={{ backgroundColor: '#547BEE' }}
|
| 74 |
>
|
| 75 |
<NoiseOverlay />
|
| 76 |
|
| 77 |
-
<div className="relative z-10">
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
/>
|
| 90 |
-
|
| 91 |
-
<h1
|
| 92 |
-
className={`font-mono font-bold underline decoration-2 underline-offset-4 mb-4 ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
|
| 93 |
-
style={{ fontSize: '36px', color: '#ffffff', lineHeight: 1.2 }}
|
| 94 |
-
onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
|
| 95 |
-
>
|
| 96 |
-
{title || 'Presentation Title'}
|
| 97 |
-
</h1>
|
| 98 |
-
)}
|
| 99 |
|
| 100 |
-
{/* Subtitle */}
|
| 101 |
{(subtitle || isEditable) && (
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
/>
|
| 113 |
-
|
| 114 |
-
<p
|
| 115 |
-
className={`font-mono ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
|
| 116 |
-
style={{ fontSize: '16px', color: '#ffffff', opacity: 0.85, lineHeight: 1.6 }}
|
| 117 |
-
onClick={() => { if (isEditable) { setEditingField('subtitle'); setTempSubtitle(subtitle || ''); } }}
|
| 118 |
-
>
|
| 119 |
-
{subtitle || (isEditable ? 'Click to add subtitle' : '')}
|
| 120 |
-
</p>
|
| 121 |
-
)
|
| 122 |
)}
|
| 123 |
</div>
|
| 124 |
</div>
|
| 125 |
);
|
| 126 |
}
|
| 127 |
|
| 128 |
-
// ============================================================================
|
| 129 |
-
// 2. NoisyAgenda
|
| 130 |
-
// ============================================================================
|
| 131 |
-
|
| 132 |
export function NoisyAgenda({
|
| 133 |
title,
|
| 134 |
items = [],
|
|
|
|
| 135 |
styles,
|
| 136 |
slideId,
|
| 137 |
isEditable = false,
|
| 138 |
onFieldUpdate,
|
|
|
|
| 139 |
}: LayoutProps) {
|
| 140 |
-
const
|
| 141 |
-
const [
|
| 142 |
-
const [tempItems, setTempItems] = useState(items.map((i) => i.text));
|
| 143 |
-
|
| 144 |
-
const handleBlur = (field: string, index?: number) => {
|
| 145 |
-
if (!slideId || !onFieldUpdate) return;
|
| 146 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 147 |
-
if (field === 'items' && index !== undefined && tempItems[index] !== items[index]?.text)
|
| 148 |
-
onFieldUpdate(slideId, 'items', tempItems[index], index);
|
| 149 |
-
setEditingField(null);
|
| 150 |
-
};
|
| 151 |
-
|
| 152 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 153 |
-
if (e.key === 'Enter') { e.preventDefault(); handleBlur(field, index); }
|
| 154 |
-
if (e.key === 'Escape') {
|
| 155 |
-
setEditingField(null);
|
| 156 |
-
if (field === 'title') setTempTitle(title);
|
| 157 |
-
if (field === 'items') setTempItems(items.map((i) => i.text));
|
| 158 |
-
}
|
| 159 |
-
};
|
| 160 |
|
| 161 |
return (
|
| 162 |
<div
|
| 163 |
-
|
|
|
|
| 164 |
style={{ backgroundColor: '#547BEE' }}
|
| 165 |
>
|
| 166 |
<NoiseOverlay />
|
| 167 |
|
| 168 |
-
<div className="relative z-10 flex
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
/>
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
{
|
|
|
|
|
|
|
| 196 |
<span
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
>
|
| 200 |
-
{
|
| 201 |
</span>
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
/>
|
| 208 |
-
|
| 209 |
-
{/* Item text */}
|
| 210 |
-
{editingField?.field === 'items' && editingField?.index === i ? (
|
| 211 |
-
<input
|
| 212 |
-
type="text"
|
| 213 |
-
value={tempItems[i] || ''}
|
| 214 |
-
onChange={(e) => {
|
| 215 |
-
const next = [...tempItems];
|
| 216 |
-
next[i] = e.target.value;
|
| 217 |
-
setTempItems(next);
|
| 218 |
-
}}
|
| 219 |
-
onBlur={() => handleBlur('items', i)}
|
| 220 |
-
onKeyDown={(e) => handleKeyDown(e, 'items', i)}
|
| 221 |
-
className="w-full bg-transparent outline-none text-center font-mono"
|
| 222 |
-
style={{ fontSize: '14px', color: '#ffffff' }}
|
| 223 |
-
autoFocus
|
| 224 |
-
/>
|
| 225 |
-
) : (
|
| 226 |
-
<span
|
| 227 |
-
className={`font-mono ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
|
| 228 |
-
style={{ fontSize: '14px', color: '#ffffff', opacity: 0.9, lineHeight: 1.5 }}
|
| 229 |
-
onClick={() => { if (isEditable) { setEditingField({ field: 'items', index: i }); setTempItems(items.map((x) => x.text)); } }}
|
| 230 |
-
>
|
| 231 |
-
{item.text}
|
| 232 |
-
</span>
|
| 233 |
-
)}
|
| 234 |
-
</div>
|
| 235 |
))}
|
| 236 |
</div>
|
| 237 |
</div>
|
|
@@ -239,422 +419,471 @@ export function NoisyAgenda({
|
|
| 239 |
);
|
| 240 |
}
|
| 241 |
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
export function NoisyTitleAndText({
|
| 247 |
title,
|
| 248 |
body = [],
|
|
|
|
| 249 |
styles,
|
| 250 |
slideId,
|
| 251 |
isEditable = false,
|
| 252 |
onFieldUpdate,
|
|
|
|
| 253 |
}: LayoutProps) {
|
| 254 |
-
const
|
| 255 |
-
const [
|
| 256 |
-
const [tempBody, setTempBody] = useState(body.map((b) => b.text));
|
| 257 |
-
|
| 258 |
-
const handleBlur = (field: string, index?: number) => {
|
| 259 |
-
if (!slideId || !onFieldUpdate) return;
|
| 260 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 261 |
-
if (field === 'body' && index !== undefined && tempBody[index] !== body[index]?.text)
|
| 262 |
-
onFieldUpdate(slideId, 'body', tempBody[index], index);
|
| 263 |
-
setEditingField(null);
|
| 264 |
-
};
|
| 265 |
-
|
| 266 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 267 |
-
if (e.key === 'Enter') { e.preventDefault(); handleBlur(field, index); }
|
| 268 |
-
if (e.key === 'Escape') {
|
| 269 |
-
setEditingField(null);
|
| 270 |
-
if (field === 'title') setTempTitle(title);
|
| 271 |
-
if (field === 'body') setTempBody(body.map((b) => b.text));
|
| 272 |
-
}
|
| 273 |
-
};
|
| 274 |
|
| 275 |
return (
|
| 276 |
<div
|
| 277 |
-
|
| 278 |
-
|
| 279 |
>
|
| 280 |
-
<
|
| 281 |
-
{
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
>
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
{b.heading && (
|
| 309 |
-
<span
|
| 310 |
-
className="font-mono font-bold shrink-0"
|
| 311 |
-
style={{ fontSize: '14px', color: '#547BEE' }}
|
| 312 |
-
>
|
| 313 |
-
{b.heading}
|
| 314 |
-
</span>
|
| 315 |
-
)}
|
| 316 |
-
|
| 317 |
-
{/* Body text */}
|
| 318 |
-
{editingField?.field === 'body' && editingField?.index === i ? (
|
| 319 |
-
<textarea
|
| 320 |
-
value={tempBody[i] || ''}
|
| 321 |
-
onChange={(e) => {
|
| 322 |
-
const next = [...tempBody];
|
| 323 |
-
next[i] = e.target.value;
|
| 324 |
-
setTempBody(next);
|
| 325 |
-
}}
|
| 326 |
-
onBlur={() => handleBlur('body', i)}
|
| 327 |
-
onKeyDown={(e) => handleKeyDown(e, 'body', i)}
|
| 328 |
-
className="w-full bg-transparent outline-none resize-none font-mono"
|
| 329 |
-
style={{ fontSize: '14px', color: '#1f2937', lineHeight: 1.7 }}
|
| 330 |
-
rows={2}
|
| 331 |
-
autoFocus
|
| 332 |
-
/>
|
| 333 |
-
) : (
|
| 334 |
-
<p
|
| 335 |
-
className={`font-mono leading-relaxed ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
|
| 336 |
-
style={{ fontSize: '14px', color: '#1f2937', lineHeight: 1.7 }}
|
| 337 |
-
onClick={() => {
|
| 338 |
-
if (isEditable) {
|
| 339 |
-
setEditingField({ field: 'body', index: i });
|
| 340 |
-
setTempBody(body.map((x) => x.text));
|
| 341 |
-
}
|
| 342 |
}}
|
| 343 |
>
|
| 344 |
-
{
|
| 345 |
-
</
|
| 346 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
</div>
|
| 348 |
-
|
| 349 |
-
|
| 350 |
</div>
|
| 351 |
</div>
|
| 352 |
);
|
| 353 |
}
|
| 354 |
|
| 355 |
-
// ============================================================================
|
| 356 |
-
// 4. NoisyImageAndText
|
| 357 |
-
// ============================================================================
|
| 358 |
-
|
| 359 |
export function NoisyImageAndText({
|
| 360 |
title,
|
| 361 |
body = [],
|
| 362 |
imageUrl,
|
|
|
|
| 363 |
styles,
|
| 364 |
slideId,
|
| 365 |
isEditable = false,
|
| 366 |
onFieldUpdate,
|
|
|
|
|
|
|
| 367 |
}: LayoutProps) {
|
| 368 |
-
const
|
| 369 |
-
const
|
| 370 |
-
const [tempBody, setTempBody] = useState(body[0]?.text || '');
|
| 371 |
-
|
| 372 |
-
const handleBlur = (field: string) => {
|
| 373 |
-
if (!slideId || !onFieldUpdate) return;
|
| 374 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 375 |
-
if (field === 'body' && tempBody !== (body[0]?.text || '')) onFieldUpdate(slideId, 'body', tempBody, 0);
|
| 376 |
-
setEditingField(null);
|
| 377 |
-
};
|
| 378 |
-
|
| 379 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 380 |
-
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleBlur(field); }
|
| 381 |
-
if (e.key === 'Escape') {
|
| 382 |
-
setEditingField(null);
|
| 383 |
-
setTempTitle(title);
|
| 384 |
-
setTempBody(body[0]?.text || '');
|
| 385 |
-
}
|
| 386 |
-
};
|
| 387 |
|
| 388 |
return (
|
| 389 |
<div
|
| 390 |
-
|
|
|
|
| 391 |
style={{ backgroundColor: '#F2725C' }}
|
| 392 |
>
|
| 393 |
<NoiseOverlay />
|
| 394 |
|
| 395 |
-
<div className="relative z-10 flex flex-col
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
/>
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
) : (
|
| 429 |
-
<div className="flex flex-col items-center justify-center gap-2 text-white/60">
|
| 430 |
-
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 431 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
| 432 |
-
</svg>
|
| 433 |
-
<span className="font-mono text-xs">Image placeholder</span>
|
| 434 |
-
</div>
|
| 435 |
-
)}
|
| 436 |
</div>
|
| 437 |
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
|
|
|
|
|
|
| 461 |
</div>
|
| 462 |
</div>
|
| 463 |
</div>
|
| 464 |
);
|
| 465 |
}
|
| 466 |
|
| 467 |
-
// ============================================================================
|
| 468 |
-
// 5. NoisyReferences
|
| 469 |
-
// ============================================================================
|
| 470 |
-
|
| 471 |
export function NoisyReferences({
|
| 472 |
title,
|
| 473 |
items = [],
|
|
|
|
| 474 |
styles,
|
| 475 |
slideId,
|
| 476 |
isEditable = false,
|
| 477 |
onFieldUpdate,
|
|
|
|
| 478 |
}: LayoutProps) {
|
| 479 |
-
const
|
| 480 |
-
const [
|
| 481 |
-
const [tempItems, setTempItems] = useState(items.map((i) => i.text));
|
| 482 |
-
|
| 483 |
-
const handleBlur = (field: string, index?: number) => {
|
| 484 |
-
if (!slideId || !onFieldUpdate) return;
|
| 485 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 486 |
-
if (field === 'items' && index !== undefined && tempItems[index] !== items[index]?.text)
|
| 487 |
-
onFieldUpdate(slideId, 'items', tempItems[index], index);
|
| 488 |
-
setEditingField(null);
|
| 489 |
-
};
|
| 490 |
-
|
| 491 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string, index?: number) => {
|
| 492 |
-
if (e.key === 'Enter') { e.preventDefault(); handleBlur(field, index); }
|
| 493 |
-
if (e.key === 'Escape') {
|
| 494 |
-
setEditingField(null);
|
| 495 |
-
if (field === 'title') setTempTitle(title);
|
| 496 |
-
if (field === 'items') setTempItems(items.map((i) => i.text));
|
| 497 |
-
}
|
| 498 |
-
};
|
| 499 |
|
| 500 |
return (
|
| 501 |
<div
|
| 502 |
-
|
| 503 |
-
|
| 504 |
>
|
| 505 |
-
<
|
| 506 |
-
{
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
>
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
/>
|
| 541 |
-
|
| 542 |
-
{editingField?.field === 'items' && editingField?.index === i ? (
|
| 543 |
-
<input
|
| 544 |
-
type="text"
|
| 545 |
-
value={tempItems[i] || ''}
|
| 546 |
-
onChange={(e) => {
|
| 547 |
-
const next = [...tempItems];
|
| 548 |
-
next[i] = e.target.value;
|
| 549 |
-
setTempItems(next);
|
| 550 |
-
}}
|
| 551 |
-
onBlur={() => handleBlur('items', i)}
|
| 552 |
-
onKeyDown={(e) => handleKeyDown(e, 'items', i)}
|
| 553 |
-
className="flex-1 bg-transparent outline-none font-mono"
|
| 554 |
-
style={{ fontSize: '14px', color: '#1f2937' }}
|
| 555 |
-
autoFocus
|
| 556 |
-
/>
|
| 557 |
-
) : (
|
| 558 |
-
<span
|
| 559 |
-
className={`flex-1 font-mono ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
|
| 560 |
-
style={{ fontSize: '14px', color: '#1f2937', lineHeight: 1.6, opacity: 0.85 }}
|
| 561 |
-
onClick={() => { if (isEditable) { setEditingField({ field: 'items', index: i }); setTempItems(items.map((x) => x.text)); } }}
|
| 562 |
-
>
|
| 563 |
-
{item.text}
|
| 564 |
-
</span>
|
| 565 |
-
)}
|
| 566 |
</div>
|
| 567 |
-
|
| 568 |
-
|
| 569 |
</div>
|
| 570 |
</div>
|
| 571 |
);
|
| 572 |
}
|
| 573 |
|
| 574 |
-
// ============================================================================
|
| 575 |
-
// 6. NoisyThankYou
|
| 576 |
-
// ============================================================================
|
| 577 |
-
|
| 578 |
export function NoisyThankYou({
|
| 579 |
title,
|
| 580 |
subtitle,
|
|
|
|
| 581 |
styles,
|
| 582 |
slideId,
|
| 583 |
isEditable = false,
|
| 584 |
onFieldUpdate,
|
|
|
|
| 585 |
}: LayoutProps) {
|
| 586 |
-
const
|
| 587 |
-
const [tempTitle, setTempTitle] = useState(title);
|
| 588 |
-
const [tempSubtitle, setTempSubtitle] = useState(subtitle || '');
|
| 589 |
-
|
| 590 |
-
const handleBlur = (field: string) => {
|
| 591 |
-
if (!slideId || !onFieldUpdate) return;
|
| 592 |
-
if (field === 'title' && tempTitle !== title) onFieldUpdate(slideId, 'title', tempTitle);
|
| 593 |
-
if (field === 'subtitle' && tempSubtitle !== subtitle) onFieldUpdate(slideId, 'subtitle', tempSubtitle);
|
| 594 |
-
setEditingField(null);
|
| 595 |
-
};
|
| 596 |
-
|
| 597 |
-
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
| 598 |
-
if (e.key === 'Enter') { e.preventDefault(); handleBlur(field); }
|
| 599 |
-
if (e.key === 'Escape') {
|
| 600 |
-
setEditingField(null);
|
| 601 |
-
if (field === 'title') setTempTitle(title);
|
| 602 |
-
if (field === 'subtitle') setTempSubtitle(subtitle || '');
|
| 603 |
-
}
|
| 604 |
-
};
|
| 605 |
|
| 606 |
return (
|
| 607 |
<div
|
| 608 |
-
|
|
|
|
| 609 |
style={{ backgroundColor: '#547BEE' }}
|
| 610 |
>
|
| 611 |
<NoiseOverlay />
|
| 612 |
|
| 613 |
-
<div className="relative z-10 text-center">
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
/>
|
| 626 |
-
|
| 627 |
-
<h1
|
| 628 |
-
className={`font-mono font-bold mb-4 ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
|
| 629 |
-
style={{ fontSize: '48px', color: '#ffffff', lineHeight: 1.2 }}
|
| 630 |
-
onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
|
| 631 |
-
>
|
| 632 |
-
{title}
|
| 633 |
-
</h1>
|
| 634 |
-
)}
|
| 635 |
|
| 636 |
-
{/* Subtitle */}
|
| 637 |
{(subtitle || isEditable) && (
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
/>
|
| 649 |
-
|
| 650 |
-
<p
|
| 651 |
-
className={`font-mono ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
|
| 652 |
-
style={{ fontSize: '20px', color: '#ffffff', opacity: 0.85 }}
|
| 653 |
-
onClick={() => { if (isEditable) { setEditingField('subtitle'); setTempSubtitle(subtitle || ''); } }}
|
| 654 |
-
>
|
| 655 |
-
{subtitle || (isEditable ? 'Click to add subtitle' : '')}
|
| 656 |
-
</p>
|
| 657 |
-
)
|
| 658 |
)}
|
| 659 |
</div>
|
| 660 |
</div>
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
+
import React, { useEffect, useId, useRef } from 'react';
|
| 4 |
+
import { GripHorizontal, Image as ImageIcon } from 'lucide-react';
|
| 5 |
import { TemplateStyles } from '@/data/templates';
|
| 6 |
+
import { PersistedDraggableSurface, SlideFormattingMap } from '@/components/slides/shared/PersistedDraggableSurface';
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
interface LayoutProps {
|
| 9 |
title: string;
|
| 10 |
subtitle?: string;
|
| 11 |
body?: Array<{ heading?: string; text: string }>;
|
| 12 |
+
columns?: Array<{ heading: string; text: string }>;
|
| 13 |
items?: Array<{ text: string }>;
|
| 14 |
imageUrl?: string;
|
| 15 |
+
formatting?: SlideFormattingMap;
|
| 16 |
styles: TemplateStyles;
|
| 17 |
slideId?: string;
|
| 18 |
isEditable?: boolean;
|
| 19 |
onFieldUpdate?: (slideId: string, field: string, value: string, index?: number) => void;
|
| 20 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 21 |
+
onRequestImageSelect?: (slideId: string) => void;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface DraggableSurfaceProps {
|
| 25 |
+
children: React.ReactNode;
|
| 26 |
+
constraintsRef: React.RefObject<HTMLDivElement | null>;
|
| 27 |
+
isEditable?: boolean;
|
| 28 |
+
slideId?: string;
|
| 29 |
+
formatKey: string;
|
| 30 |
+
formatting?: SlideFormattingMap;
|
| 31 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 32 |
+
className?: string;
|
| 33 |
+
style?: React.CSSProperties;
|
| 34 |
+
handleClassName?: string;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
interface EditableContentProps {
|
| 38 |
+
value: string;
|
| 39 |
+
as?: React.ElementType;
|
| 40 |
+
className?: string;
|
| 41 |
+
style?: React.CSSProperties;
|
| 42 |
+
isEditable?: boolean;
|
| 43 |
+
multiline?: boolean;
|
| 44 |
+
onCommit?: (value: string) => void;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const DEFAULT_IMAGE = 'https://picsum.photos/seed/presentation/800/600';
|
| 48 |
+
const DEFAULT_TRANSLITERATION_IMAGE = 'https://picsum.photos/seed/transliteration/800/600';
|
| 49 |
+
|
| 50 |
+
function stopPointerDown(e: React.PointerEvent<HTMLElement>) {
|
| 51 |
+
e.stopPropagation();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function readEditableValue(value: string) {
|
| 55 |
+
return value
|
| 56 |
+
.replace(/\u00a0/g, ' ')
|
| 57 |
+
.replace(/\r/g, '')
|
| 58 |
+
.replace(/\n{3,}/g, '\n\n')
|
| 59 |
+
.trim();
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function formatIndex(index: number) {
|
| 63 |
+
return String(index).padStart(2, '0');
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function NoiseOverlay() {
|
| 67 |
+
const filterId = useId().replace(/:/g, '');
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<div className="pointer-events-none absolute inset-0 z-0 opacity-60 mix-blend-overlay">
|
| 71 |
+
<svg className="h-full w-full">
|
| 72 |
+
<filter id={filterId}>
|
| 73 |
+
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" />
|
| 74 |
+
<feColorMatrix type="saturate" values="0" />
|
| 75 |
+
</filter>
|
| 76 |
+
<rect width="100%" height="100%" filter={`url(#${filterId})`} />
|
| 77 |
+
</svg>
|
| 78 |
+
</div>
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function DraggableSurface({
|
| 83 |
+
children,
|
| 84 |
+
constraintsRef,
|
| 85 |
+
isEditable = false,
|
| 86 |
+
slideId,
|
| 87 |
+
formatKey,
|
| 88 |
+
formatting,
|
| 89 |
+
onFormattingUpdate,
|
| 90 |
+
className,
|
| 91 |
+
style,
|
| 92 |
+
handleClassName = '-top-6 left-1/2 -translate-x-1/2',
|
| 93 |
+
}: DraggableSurfaceProps) {
|
| 94 |
+
return (
|
| 95 |
+
<PersistedDraggableSurface
|
| 96 |
+
constraintsRef={constraintsRef}
|
| 97 |
+
slideId={slideId}
|
| 98 |
+
formatKey={formatKey}
|
| 99 |
+
formatting={formatting}
|
| 100 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 101 |
+
isEditable={isEditable}
|
| 102 |
+
className={className}
|
| 103 |
+
style={style}
|
| 104 |
+
handleClassName={handleClassName}
|
| 105 |
+
groupClassName="group"
|
| 106 |
+
handle={
|
| 107 |
+
<div className="rounded bg-blue-600 p-1 text-white shadow-md opacity-0 transition-all group-hover:scale-110 group-hover:opacity-100 group-hover:bg-blue-500">
|
| 108 |
+
<GripHorizontal size={16} />
|
| 109 |
+
</div>
|
| 110 |
+
}
|
| 111 |
+
>
|
| 112 |
+
{children}
|
| 113 |
+
</PersistedDraggableSurface>
|
| 114 |
+
);
|
| 115 |
}
|
| 116 |
|
| 117 |
+
function EditableContent({
|
| 118 |
+
value,
|
| 119 |
+
as = 'div',
|
| 120 |
+
className,
|
| 121 |
+
style,
|
| 122 |
+
isEditable = false,
|
| 123 |
+
multiline = false,
|
| 124 |
+
onCommit,
|
| 125 |
+
}: EditableContentProps) {
|
| 126 |
+
const contentRef = useRef<HTMLElement>(null);
|
| 127 |
+
const Component = as as any;
|
| 128 |
+
|
| 129 |
+
useEffect(() => {
|
| 130 |
+
if (!contentRef.current) return;
|
| 131 |
+
if (document.activeElement === contentRef.current) return;
|
| 132 |
+
if (contentRef.current.innerText !== value) {
|
| 133 |
+
contentRef.current.innerText = value;
|
| 134 |
+
}
|
| 135 |
+
}, [value]);
|
| 136 |
+
|
| 137 |
+
return (
|
| 138 |
+
<Component
|
| 139 |
+
ref={contentRef}
|
| 140 |
+
contentEditable={isEditable}
|
| 141 |
+
suppressContentEditableWarning
|
| 142 |
+
spellCheck={false}
|
| 143 |
+
onPointerDown={stopPointerDown}
|
| 144 |
+
onBlur={() => {
|
| 145 |
+
if (!isEditable || !contentRef.current || !onCommit) return;
|
| 146 |
+
const nextValue = readEditableValue(contentRef.current.innerText);
|
| 147 |
+
if (nextValue !== value) {
|
| 148 |
+
onCommit(nextValue);
|
| 149 |
+
} else if (contentRef.current.innerText !== value) {
|
| 150 |
+
contentRef.current.innerText = value;
|
| 151 |
+
}
|
| 152 |
+
}}
|
| 153 |
+
onKeyDown={(e: React.KeyboardEvent<HTMLElement>) => {
|
| 154 |
+
if (e.key === 'Escape') {
|
| 155 |
+
e.preventDefault();
|
| 156 |
+
if (contentRef.current) {
|
| 157 |
+
contentRef.current.innerText = value;
|
| 158 |
+
}
|
| 159 |
+
(e.currentTarget as HTMLElement).blur();
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
if (!multiline && e.key === 'Enter') {
|
| 163 |
+
e.preventDefault();
|
| 164 |
+
(e.currentTarget as HTMLElement).blur();
|
| 165 |
+
}
|
| 166 |
+
}}
|
| 167 |
+
className={`outline-none ${isEditable ? 'cursor-text hover:ring-2 hover:ring-blue-500/30' : ''} ${className || ''}`}
|
| 168 |
+
style={{
|
| 169 |
+
whiteSpace: multiline ? 'pre-line' : 'normal',
|
| 170 |
+
...style,
|
| 171 |
+
}}
|
| 172 |
+
>
|
| 173 |
+
{value}
|
| 174 |
+
</Component>
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function DraggableImage({
|
| 179 |
+
src,
|
| 180 |
+
alt,
|
| 181 |
+
constraintsRef,
|
| 182 |
+
isEditable = false,
|
| 183 |
+
slideId,
|
| 184 |
+
formatKey,
|
| 185 |
+
formatting,
|
| 186 |
+
onFormattingUpdate,
|
| 187 |
+
onReplace,
|
| 188 |
+
className,
|
| 189 |
+
}: {
|
| 190 |
+
src?: string;
|
| 191 |
+
alt: string;
|
| 192 |
+
constraintsRef: React.RefObject<HTMLDivElement | null>;
|
| 193 |
+
isEditable?: boolean;
|
| 194 |
+
slideId?: string;
|
| 195 |
+
formatKey: string;
|
| 196 |
+
formatting?: SlideFormattingMap;
|
| 197 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 198 |
+
onReplace?: () => void;
|
| 199 |
+
className?: string;
|
| 200 |
+
}) {
|
| 201 |
+
const imageSrc = src || DEFAULT_IMAGE;
|
| 202 |
+
|
| 203 |
+
return (
|
| 204 |
+
<DraggableSurface
|
| 205 |
+
constraintsRef={constraintsRef}
|
| 206 |
+
isEditable={isEditable}
|
| 207 |
+
slideId={slideId}
|
| 208 |
+
formatKey={formatKey}
|
| 209 |
+
formatting={formatting}
|
| 210 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 211 |
+
className={className}
|
| 212 |
+
handleClassName="-top-6 left-1/2 -translate-x-1/2"
|
| 213 |
+
>
|
| 214 |
+
<div className="relative h-full w-full overflow-hidden rounded-lg shadow-lg">
|
| 215 |
+
<img
|
| 216 |
+
src={imageSrc}
|
| 217 |
+
alt={alt}
|
| 218 |
+
referrerPolicy="no-referrer"
|
| 219 |
+
className="h-full w-full max-w-full rounded-lg object-cover"
|
| 220 |
+
/>
|
| 221 |
+
|
| 222 |
+
{isEditable && onReplace && (
|
| 223 |
+
<button
|
| 224 |
+
type="button"
|
| 225 |
+
onClick={onReplace}
|
| 226 |
+
onPointerDown={stopPointerDown}
|
| 227 |
+
className="absolute right-2 top-2 z-30 inline-flex items-center gap-2 rounded bg-blue-600/85 px-3 py-2 text-xs font-bold text-white opacity-30 shadow-md transition-all hover:bg-blue-500 group-hover:opacity-100"
|
| 228 |
+
>
|
| 229 |
+
<ImageIcon size={16} />
|
| 230 |
+
Replace
|
| 231 |
+
</button>
|
| 232 |
+
)}
|
| 233 |
+
</div>
|
| 234 |
+
</DraggableSurface>
|
| 235 |
+
);
|
| 236 |
+
}
|
| 237 |
|
| 238 |
export function NoisyTitleSubtitle({
|
| 239 |
title,
|
| 240 |
subtitle,
|
| 241 |
+
formatting,
|
| 242 |
styles,
|
| 243 |
slideId,
|
| 244 |
isEditable = false,
|
| 245 |
onFieldUpdate,
|
| 246 |
+
onFormattingUpdate,
|
| 247 |
}: LayoutProps) {
|
| 248 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
return (
|
| 251 |
<div
|
| 252 |
+
ref={slideRef}
|
| 253 |
+
className="relative flex h-full w-full flex-col justify-center overflow-hidden p-12 md:p-16"
|
| 254 |
style={{ backgroundColor: '#547BEE' }}
|
| 255 |
>
|
| 256 |
<NoiseOverlay />
|
| 257 |
|
| 258 |
+
<div className="relative z-10 flex flex-col gap-8">
|
| 259 |
+
<DraggableSurface
|
| 260 |
+
constraintsRef={slideRef}
|
| 261 |
+
isEditable={isEditable}
|
| 262 |
+
slideId={slideId}
|
| 263 |
+
formatKey="title"
|
| 264 |
+
formatting={formatting}
|
| 265 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 266 |
+
className="w-fit"
|
| 267 |
+
>
|
| 268 |
+
<EditableContent
|
| 269 |
+
as="h1"
|
| 270 |
+
value={title || 'Presentation Title'}
|
| 271 |
+
isEditable={isEditable}
|
| 272 |
+
multiline
|
| 273 |
+
onCommit={(value) => {
|
| 274 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 275 |
+
}}
|
| 276 |
+
className="mb-0 underline decoration-2 underline-offset-8"
|
| 277 |
+
style={{
|
| 278 |
+
fontFamily: styles.fonts.heading,
|
| 279 |
+
fontSize: '64px',
|
| 280 |
+
fontWeight: 700,
|
| 281 |
+
color: '#ffffff',
|
| 282 |
+
lineHeight: 1.05,
|
| 283 |
+
}}
|
| 284 |
/>
|
| 285 |
+
</DraggableSurface>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
|
|
|
| 287 |
{(subtitle || isEditable) && (
|
| 288 |
+
<DraggableSurface
|
| 289 |
+
constraintsRef={slideRef}
|
| 290 |
+
isEditable={isEditable}
|
| 291 |
+
slideId={slideId}
|
| 292 |
+
formatKey="subtitle"
|
| 293 |
+
formatting={formatting}
|
| 294 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 295 |
+
className="max-w-[780px]"
|
| 296 |
+
>
|
| 297 |
+
<EditableContent
|
| 298 |
+
as="p"
|
| 299 |
+
value={subtitle || ''}
|
| 300 |
+
isEditable={isEditable}
|
| 301 |
+
multiline
|
| 302 |
+
onCommit={(value) => {
|
| 303 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'subtitle', value);
|
| 304 |
+
}}
|
| 305 |
+
style={{
|
| 306 |
+
fontFamily: styles.fonts.body,
|
| 307 |
+
fontSize: '26px',
|
| 308 |
+
lineHeight: 1.6,
|
| 309 |
+
color: '#ffffff',
|
| 310 |
+
opacity: 0.95,
|
| 311 |
+
}}
|
| 312 |
/>
|
| 313 |
+
</DraggableSurface>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
)}
|
| 315 |
</div>
|
| 316 |
</div>
|
| 317 |
);
|
| 318 |
}
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
export function NoisyAgenda({
|
| 321 |
title,
|
| 322 |
items = [],
|
| 323 |
+
formatting,
|
| 324 |
styles,
|
| 325 |
slideId,
|
| 326 |
isEditable = false,
|
| 327 |
onFieldUpdate,
|
| 328 |
+
onFormattingUpdate,
|
| 329 |
}: LayoutProps) {
|
| 330 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 331 |
+
const agendaItems = items.length ? items : [{ text: '' }, { text: '' }, { text: '' }];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
|
| 333 |
return (
|
| 334 |
<div
|
| 335 |
+
ref={slideRef}
|
| 336 |
+
className="relative flex h-full w-full flex-col overflow-hidden p-12 md:p-16"
|
| 337 |
style={{ backgroundColor: '#547BEE' }}
|
| 338 |
>
|
| 339 |
<NoiseOverlay />
|
| 340 |
|
| 341 |
+
<div className="relative z-10 flex h-full flex-col">
|
| 342 |
+
<DraggableSurface
|
| 343 |
+
constraintsRef={slideRef}
|
| 344 |
+
isEditable={isEditable}
|
| 345 |
+
slideId={slideId}
|
| 346 |
+
formatKey="agenda-title"
|
| 347 |
+
formatting={formatting}
|
| 348 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 349 |
+
className="mb-16 w-fit"
|
| 350 |
+
>
|
| 351 |
+
<EditableContent
|
| 352 |
+
as="h1"
|
| 353 |
+
value={title || 'Agenda'}
|
| 354 |
+
isEditable={isEditable}
|
| 355 |
+
onCommit={(value) => {
|
| 356 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 357 |
+
}}
|
| 358 |
+
className="underline decoration-2 underline-offset-8"
|
| 359 |
+
style={{
|
| 360 |
+
fontFamily: styles.fonts.heading,
|
| 361 |
+
fontSize: '54px',
|
| 362 |
+
fontWeight: 700,
|
| 363 |
+
color: '#ffffff',
|
| 364 |
+
lineHeight: 1.05,
|
| 365 |
+
}}
|
| 366 |
/>
|
| 367 |
+
</DraggableSurface>
|
| 368 |
+
|
| 369 |
+
<div
|
| 370 |
+
className="grid flex-1 items-center gap-8"
|
| 371 |
+
style={{ gridTemplateColumns: `repeat(${Math.min(agendaItems.length, 3)}, minmax(0, 1fr))` }}
|
| 372 |
+
>
|
| 373 |
+
{agendaItems.map((item, index) => (
|
| 374 |
+
<DraggableSurface
|
| 375 |
+
key={`${index}-${item.text}`}
|
| 376 |
+
constraintsRef={slideRef}
|
| 377 |
+
isEditable={isEditable}
|
| 378 |
+
slideId={slideId}
|
| 379 |
+
formatKey={`agenda-item-${index}`}
|
| 380 |
+
formatting={formatting}
|
| 381 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 382 |
+
className="flex h-full flex-col items-center justify-center text-center"
|
| 383 |
+
>
|
| 384 |
<span
|
| 385 |
+
style={{
|
| 386 |
+
fontFamily: styles.fonts.heading,
|
| 387 |
+
fontSize: '140px',
|
| 388 |
+
fontWeight: 700,
|
| 389 |
+
lineHeight: 0.9,
|
| 390 |
+
letterSpacing: '-0.06em',
|
| 391 |
+
color: '#ffffff',
|
| 392 |
+
}}
|
| 393 |
>
|
| 394 |
+
{formatIndex(index + 1)}
|
| 395 |
</span>
|
| 396 |
|
| 397 |
+
<div className="mb-6 mt-4 h-4 w-40 bg-[#FF7A59]" />
|
| 398 |
+
|
| 399 |
+
<EditableContent
|
| 400 |
+
as="span"
|
| 401 |
+
value={item.text}
|
| 402 |
+
isEditable={isEditable}
|
| 403 |
+
multiline
|
| 404 |
+
onCommit={(value) => {
|
| 405 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'items', value, index);
|
| 406 |
+
}}
|
| 407 |
+
style={{
|
| 408 |
+
fontFamily: styles.fonts.body,
|
| 409 |
+
fontSize: '32px',
|
| 410 |
+
lineHeight: 1.35,
|
| 411 |
+
color: '#ffffff',
|
| 412 |
+
}}
|
| 413 |
/>
|
| 414 |
+
</DraggableSurface>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
))}
|
| 416 |
</div>
|
| 417 |
</div>
|
|
|
|
| 419 |
);
|
| 420 |
}
|
| 421 |
|
| 422 |
+
export function NoisyThreeColumns({
|
| 423 |
+
title,
|
| 424 |
+
columns = [],
|
| 425 |
+
formatting,
|
| 426 |
+
styles,
|
| 427 |
+
slideId,
|
| 428 |
+
isEditable = false,
|
| 429 |
+
onFieldUpdate,
|
| 430 |
+
onFormattingUpdate,
|
| 431 |
+
}: LayoutProps) {
|
| 432 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 433 |
+
const columnItems = columns.length ? columns : [{ heading: '', text: '' }];
|
| 434 |
+
|
| 435 |
+
return (
|
| 436 |
+
<div
|
| 437 |
+
ref={slideRef}
|
| 438 |
+
className="flex h-full w-full flex-col overflow-hidden bg-white p-12 md:p-16"
|
| 439 |
+
>
|
| 440 |
+
<DraggableSurface
|
| 441 |
+
constraintsRef={slideRef}
|
| 442 |
+
isEditable={isEditable}
|
| 443 |
+
slideId={slideId}
|
| 444 |
+
formatKey="columns-title"
|
| 445 |
+
formatting={formatting}
|
| 446 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 447 |
+
className="mb-14 w-fit"
|
| 448 |
+
>
|
| 449 |
+
<EditableContent
|
| 450 |
+
as="h1"
|
| 451 |
+
value={title || 'Three Column Layout'}
|
| 452 |
+
isEditable={isEditable}
|
| 453 |
+
onCommit={(value) => {
|
| 454 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 455 |
+
}}
|
| 456 |
+
className="underline decoration-2 underline-offset-8"
|
| 457 |
+
style={{
|
| 458 |
+
fontFamily: styles.fonts.heading,
|
| 459 |
+
fontSize: '54px',
|
| 460 |
+
fontWeight: 700,
|
| 461 |
+
color: '#547BEE',
|
| 462 |
+
lineHeight: 1.05,
|
| 463 |
+
}}
|
| 464 |
+
/>
|
| 465 |
+
</DraggableSurface>
|
| 466 |
+
|
| 467 |
+
<div
|
| 468 |
+
className="grid flex-1 gap-10"
|
| 469 |
+
style={{ gridTemplateColumns: `repeat(${Math.min(columnItems.length, 3)}, minmax(0, 1fr))` }}
|
| 470 |
+
>
|
| 471 |
+
{columnItems.map((column, index) => (
|
| 472 |
+
<DraggableSurface
|
| 473 |
+
key={`${index}-${column.heading}-${column.text}`}
|
| 474 |
+
constraintsRef={slideRef}
|
| 475 |
+
isEditable={isEditable}
|
| 476 |
+
slideId={slideId}
|
| 477 |
+
formatKey={`column-${index}`}
|
| 478 |
+
formatting={formatting}
|
| 479 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 480 |
+
className="flex h-full flex-col"
|
| 481 |
+
>
|
| 482 |
+
<EditableContent
|
| 483 |
+
as="h2"
|
| 484 |
+
value={column.heading}
|
| 485 |
+
isEditable={isEditable}
|
| 486 |
+
onCommit={(value) => {
|
| 487 |
+
if (slideId && onFieldUpdate) {
|
| 488 |
+
onFieldUpdate(
|
| 489 |
+
slideId,
|
| 490 |
+
'columns',
|
| 491 |
+
JSON.stringify({ heading: value, text: column.text }),
|
| 492 |
+
index
|
| 493 |
+
);
|
| 494 |
+
}
|
| 495 |
+
}}
|
| 496 |
+
className="mb-6"
|
| 497 |
+
style={{
|
| 498 |
+
fontFamily: styles.fonts.heading,
|
| 499 |
+
fontSize: '32px',
|
| 500 |
+
fontWeight: 700,
|
| 501 |
+
color: '#547BEE',
|
| 502 |
+
lineHeight: 1.15,
|
| 503 |
+
}}
|
| 504 |
+
/>
|
| 505 |
+
|
| 506 |
+
<EditableContent
|
| 507 |
+
as="p"
|
| 508 |
+
value={column.text}
|
| 509 |
+
isEditable={isEditable}
|
| 510 |
+
multiline
|
| 511 |
+
onCommit={(value) => {
|
| 512 |
+
if (slideId && onFieldUpdate) {
|
| 513 |
+
onFieldUpdate(
|
| 514 |
+
slideId,
|
| 515 |
+
'columns',
|
| 516 |
+
JSON.stringify({ heading: column.heading, text: value }),
|
| 517 |
+
index
|
| 518 |
+
);
|
| 519 |
+
}
|
| 520 |
+
}}
|
| 521 |
+
style={{
|
| 522 |
+
fontFamily: styles.fonts.body,
|
| 523 |
+
fontSize: '26px',
|
| 524 |
+
lineHeight: 1.55,
|
| 525 |
+
color: '#1f2937',
|
| 526 |
+
}}
|
| 527 |
+
/>
|
| 528 |
+
</DraggableSurface>
|
| 529 |
+
))}
|
| 530 |
+
</div>
|
| 531 |
+
</div>
|
| 532 |
+
);
|
| 533 |
+
}
|
| 534 |
|
| 535 |
export function NoisyTitleAndText({
|
| 536 |
title,
|
| 537 |
body = [],
|
| 538 |
+
formatting,
|
| 539 |
styles,
|
| 540 |
slideId,
|
| 541 |
isEditable = false,
|
| 542 |
onFieldUpdate,
|
| 543 |
+
onFormattingUpdate,
|
| 544 |
}: LayoutProps) {
|
| 545 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 546 |
+
const bodyItems = body.length ? body : [{ text: '' }];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
|
| 548 |
return (
|
| 549 |
<div
|
| 550 |
+
ref={slideRef}
|
| 551 |
+
className="flex h-full w-full flex-col overflow-hidden bg-white p-12 md:p-16"
|
| 552 |
>
|
| 553 |
+
<DraggableSurface
|
| 554 |
+
constraintsRef={slideRef}
|
| 555 |
+
isEditable={isEditable}
|
| 556 |
+
slideId={slideId}
|
| 557 |
+
formatKey="title-and-text-title"
|
| 558 |
+
formatting={formatting}
|
| 559 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 560 |
+
className="mb-10 w-fit"
|
| 561 |
+
>
|
| 562 |
+
<EditableContent
|
| 563 |
+
as="h1"
|
| 564 |
+
value={title || 'Slide Title'}
|
| 565 |
+
isEditable={isEditable}
|
| 566 |
+
onCommit={(value) => {
|
| 567 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 568 |
+
}}
|
| 569 |
+
className="underline decoration-2 underline-offset-8"
|
| 570 |
+
style={{
|
| 571 |
+
fontFamily: styles.fonts.heading,
|
| 572 |
+
fontSize: '54px',
|
| 573 |
+
fontWeight: 700,
|
| 574 |
+
color: '#547BEE',
|
| 575 |
+
lineHeight: 1.05,
|
| 576 |
+
}}
|
| 577 |
+
/>
|
| 578 |
+
</DraggableSurface>
|
| 579 |
+
|
| 580 |
+
<div className="flex flex-1 flex-col gap-8">
|
| 581 |
+
{bodyItems.map((entry, index) => (
|
| 582 |
+
<DraggableSurface
|
| 583 |
+
key={`${index}-${entry.heading}-${entry.text}`}
|
| 584 |
+
constraintsRef={slideRef}
|
| 585 |
+
isEditable={isEditable}
|
| 586 |
+
slideId={slideId}
|
| 587 |
+
formatKey={`title-and-text-body-${index}`}
|
| 588 |
+
formatting={formatting}
|
| 589 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 590 |
+
className="w-full max-w-[1080px]"
|
| 591 |
>
|
| 592 |
+
<div>
|
| 593 |
+
{entry.heading && (
|
| 594 |
+
<div
|
| 595 |
+
className="mb-4"
|
| 596 |
+
style={{
|
| 597 |
+
fontFamily: styles.fonts.heading,
|
| 598 |
+
fontSize: '36px',
|
| 599 |
+
fontWeight: 700,
|
| 600 |
+
color: '#547BEE',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
}}
|
| 602 |
>
|
| 603 |
+
{entry.heading}
|
| 604 |
+
</div>
|
| 605 |
)}
|
| 606 |
+
|
| 607 |
+
<EditableContent
|
| 608 |
+
as="p"
|
| 609 |
+
value={entry.text}
|
| 610 |
+
isEditable={isEditable}
|
| 611 |
+
multiline
|
| 612 |
+
onCommit={(value) => {
|
| 613 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'body', value, index);
|
| 614 |
+
}}
|
| 615 |
+
style={{
|
| 616 |
+
fontFamily: styles.fonts.body,
|
| 617 |
+
fontSize: '34px',
|
| 618 |
+
lineHeight: 1.5,
|
| 619 |
+
color: '#1f2937',
|
| 620 |
+
}}
|
| 621 |
+
/>
|
| 622 |
</div>
|
| 623 |
+
</DraggableSurface>
|
| 624 |
+
))}
|
| 625 |
</div>
|
| 626 |
</div>
|
| 627 |
);
|
| 628 |
}
|
| 629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
export function NoisyImageAndText({
|
| 631 |
title,
|
| 632 |
body = [],
|
| 633 |
imageUrl,
|
| 634 |
+
formatting,
|
| 635 |
styles,
|
| 636 |
slideId,
|
| 637 |
isEditable = false,
|
| 638 |
onFieldUpdate,
|
| 639 |
+
onFormattingUpdate,
|
| 640 |
+
onRequestImageSelect,
|
| 641 |
}: LayoutProps) {
|
| 642 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 643 |
+
const bodyText = body[0]?.text || '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
|
| 645 |
return (
|
| 646 |
<div
|
| 647 |
+
ref={slideRef}
|
| 648 |
+
className="relative flex h-full w-full flex-col overflow-hidden"
|
| 649 |
style={{ backgroundColor: '#F2725C' }}
|
| 650 |
>
|
| 651 |
<NoiseOverlay />
|
| 652 |
|
| 653 |
+
<div className="relative z-10 flex h-full flex-col p-12 md:p-16">
|
| 654 |
+
<DraggableSurface
|
| 655 |
+
constraintsRef={slideRef}
|
| 656 |
+
isEditable={isEditable}
|
| 657 |
+
slideId={slideId}
|
| 658 |
+
formatKey="image-title"
|
| 659 |
+
formatting={formatting}
|
| 660 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 661 |
+
className="mb-10 w-fit"
|
| 662 |
+
>
|
| 663 |
+
<EditableContent
|
| 664 |
+
as="h1"
|
| 665 |
+
value={title || 'Visual Results'}
|
| 666 |
+
isEditable={isEditable}
|
| 667 |
+
onCommit={(value) => {
|
| 668 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 669 |
+
}}
|
| 670 |
+
className="underline decoration-2 underline-offset-8"
|
| 671 |
+
style={{
|
| 672 |
+
fontFamily: styles.fonts.heading,
|
| 673 |
+
fontSize: '54px',
|
| 674 |
+
fontWeight: 700,
|
| 675 |
+
color: '#ffffff',
|
| 676 |
+
lineHeight: 1.05,
|
| 677 |
+
}}
|
| 678 |
/>
|
| 679 |
+
</DraggableSurface>
|
| 680 |
+
|
| 681 |
+
<div className="flex flex-1 items-center gap-10">
|
| 682 |
+
<div className="flex w-1/2 justify-center">
|
| 683 |
+
<DraggableImage
|
| 684 |
+
src={imageUrl || DEFAULT_TRANSLITERATION_IMAGE}
|
| 685 |
+
alt={title || 'Noisy visual'}
|
| 686 |
+
constraintsRef={slideRef}
|
| 687 |
+
isEditable={isEditable}
|
| 688 |
+
slideId={slideId}
|
| 689 |
+
formatKey="image-card"
|
| 690 |
+
formatting={formatting}
|
| 691 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 692 |
+
onReplace={
|
| 693 |
+
isEditable && slideId && onRequestImageSelect
|
| 694 |
+
? () => onRequestImageSelect(slideId)
|
| 695 |
+
: undefined
|
| 696 |
+
}
|
| 697 |
+
className="w-full"
|
| 698 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 699 |
</div>
|
| 700 |
|
| 701 |
+
<DraggableSurface
|
| 702 |
+
constraintsRef={slideRef}
|
| 703 |
+
isEditable={isEditable}
|
| 704 |
+
slideId={slideId}
|
| 705 |
+
formatKey="image-body"
|
| 706 |
+
formatting={formatting}
|
| 707 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 708 |
+
className="flex w-1/2 flex-col gap-8"
|
| 709 |
+
>
|
| 710 |
+
<EditableContent
|
| 711 |
+
as="p"
|
| 712 |
+
value={bodyText}
|
| 713 |
+
isEditable={isEditable}
|
| 714 |
+
multiline
|
| 715 |
+
onCommit={(value) => {
|
| 716 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'body', value, 0);
|
| 717 |
+
}}
|
| 718 |
+
style={{
|
| 719 |
+
fontFamily: styles.fonts.body,
|
| 720 |
+
fontSize: '30px',
|
| 721 |
+
lineHeight: 1.55,
|
| 722 |
+
color: '#ffffff',
|
| 723 |
+
}}
|
| 724 |
+
/>
|
| 725 |
+
</DraggableSurface>
|
| 726 |
</div>
|
| 727 |
</div>
|
| 728 |
</div>
|
| 729 |
);
|
| 730 |
}
|
| 731 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
export function NoisyReferences({
|
| 733 |
title,
|
| 734 |
items = [],
|
| 735 |
+
formatting,
|
| 736 |
styles,
|
| 737 |
slideId,
|
| 738 |
isEditable = false,
|
| 739 |
onFieldUpdate,
|
| 740 |
+
onFormattingUpdate,
|
| 741 |
}: LayoutProps) {
|
| 742 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
| 743 |
+
const referenceItems = items.length ? items : [{ text: '' }];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 744 |
|
| 745 |
return (
|
| 746 |
<div
|
| 747 |
+
ref={slideRef}
|
| 748 |
+
className="flex h-full w-full flex-col overflow-hidden bg-white p-12 md:p-16"
|
| 749 |
>
|
| 750 |
+
<DraggableSurface
|
| 751 |
+
constraintsRef={slideRef}
|
| 752 |
+
isEditable={isEditable}
|
| 753 |
+
slideId={slideId}
|
| 754 |
+
formatKey="references-title"
|
| 755 |
+
formatting={formatting}
|
| 756 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 757 |
+
className="mb-10 w-fit"
|
| 758 |
+
>
|
| 759 |
+
<EditableContent
|
| 760 |
+
as="h1"
|
| 761 |
+
value={title || 'References'}
|
| 762 |
+
isEditable={isEditable}
|
| 763 |
+
onCommit={(value) => {
|
| 764 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 765 |
+
}}
|
| 766 |
+
className="underline decoration-2 underline-offset-8"
|
| 767 |
+
style={{
|
| 768 |
+
fontFamily: styles.fonts.heading,
|
| 769 |
+
fontSize: '54px',
|
| 770 |
+
fontWeight: 700,
|
| 771 |
+
color: '#547BEE',
|
| 772 |
+
lineHeight: 1.05,
|
| 773 |
+
}}
|
| 774 |
+
/>
|
| 775 |
+
</DraggableSurface>
|
| 776 |
+
|
| 777 |
+
<div className="flex flex-1 flex-col gap-6">
|
| 778 |
+
{referenceItems.map((item, index) => (
|
| 779 |
+
<DraggableSurface
|
| 780 |
+
key={`${index}-${item.text}`}
|
| 781 |
+
constraintsRef={slideRef}
|
| 782 |
+
isEditable={isEditable}
|
| 783 |
+
slideId={slideId}
|
| 784 |
+
formatKey={`reference-item-${index}`}
|
| 785 |
+
formatting={formatting}
|
| 786 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 787 |
+
className="w-full"
|
| 788 |
>
|
| 789 |
+
<div className="flex items-start gap-4 border-b border-gray-200 py-2">
|
| 790 |
+
<div className="mt-3 h-1.5 w-1.5 shrink-0 rounded-full bg-[#547BEE]" />
|
| 791 |
+
<EditableContent
|
| 792 |
+
as="p"
|
| 793 |
+
value={item.text}
|
| 794 |
+
isEditable={isEditable}
|
| 795 |
+
multiline
|
| 796 |
+
onCommit={(value) => {
|
| 797 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'items', value, index);
|
| 798 |
+
}}
|
| 799 |
+
style={{
|
| 800 |
+
fontFamily: styles.fonts.body,
|
| 801 |
+
fontSize: '30px',
|
| 802 |
+
lineHeight: 1.55,
|
| 803 |
+
color: '#1f2937',
|
| 804 |
+
}}
|
| 805 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
</div>
|
| 807 |
+
</DraggableSurface>
|
| 808 |
+
))}
|
| 809 |
</div>
|
| 810 |
</div>
|
| 811 |
);
|
| 812 |
}
|
| 813 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
export function NoisyThankYou({
|
| 815 |
title,
|
| 816 |
subtitle,
|
| 817 |
+
formatting,
|
| 818 |
styles,
|
| 819 |
slideId,
|
| 820 |
isEditable = false,
|
| 821 |
onFieldUpdate,
|
| 822 |
+
onFormattingUpdate,
|
| 823 |
}: LayoutProps) {
|
| 824 |
+
const slideRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
|
| 826 |
return (
|
| 827 |
<div
|
| 828 |
+
ref={slideRef}
|
| 829 |
+
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden p-12 md:p-16"
|
| 830 |
style={{ backgroundColor: '#547BEE' }}
|
| 831 |
>
|
| 832 |
<NoiseOverlay />
|
| 833 |
|
| 834 |
+
<div className="relative z-10 flex flex-col items-center text-center">
|
| 835 |
+
<DraggableSurface
|
| 836 |
+
constraintsRef={slideRef}
|
| 837 |
+
isEditable={isEditable}
|
| 838 |
+
slideId={slideId}
|
| 839 |
+
formatKey="thank-you-title"
|
| 840 |
+
formatting={formatting}
|
| 841 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 842 |
+
className="mb-6"
|
| 843 |
+
>
|
| 844 |
+
<EditableContent
|
| 845 |
+
as="h1"
|
| 846 |
+
value={title || 'Thank You!'}
|
| 847 |
+
isEditable={isEditable}
|
| 848 |
+
onCommit={(value) => {
|
| 849 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'title', value);
|
| 850 |
+
}}
|
| 851 |
+
style={{
|
| 852 |
+
fontFamily: styles.fonts.heading,
|
| 853 |
+
fontSize: '88px',
|
| 854 |
+
fontWeight: 700,
|
| 855 |
+
color: '#ffffff',
|
| 856 |
+
lineHeight: 1,
|
| 857 |
+
}}
|
| 858 |
/>
|
| 859 |
+
</DraggableSurface>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
|
|
|
|
| 861 |
{(subtitle || isEditable) && (
|
| 862 |
+
<DraggableSurface
|
| 863 |
+
constraintsRef={slideRef}
|
| 864 |
+
isEditable={isEditable}
|
| 865 |
+
slideId={slideId}
|
| 866 |
+
formatKey="thank-you-subtitle"
|
| 867 |
+
formatting={formatting}
|
| 868 |
+
onFormattingUpdate={onFormattingUpdate}
|
| 869 |
+
className="w-fit"
|
| 870 |
+
>
|
| 871 |
+
<EditableContent
|
| 872 |
+
as="p"
|
| 873 |
+
value={subtitle || ''}
|
| 874 |
+
isEditable={isEditable}
|
| 875 |
+
multiline
|
| 876 |
+
onCommit={(value) => {
|
| 877 |
+
if (slideId && onFieldUpdate) onFieldUpdate(slideId, 'subtitle', value);
|
| 878 |
+
}}
|
| 879 |
+
style={{
|
| 880 |
+
fontFamily: styles.fonts.body,
|
| 881 |
+
fontSize: '38px',
|
| 882 |
+
color: '#ffffff',
|
| 883 |
+
opacity: 0.9,
|
| 884 |
+
}}
|
| 885 |
/>
|
| 886 |
+
</DraggableSurface>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
)}
|
| 888 |
</div>
|
| 889 |
</div>
|
components/slides/shared/PersistedDraggableSurface.tsx
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
| 4 |
+
import { motion, useDragControls } from 'framer-motion';
|
| 5 |
+
|
| 6 |
+
export type SlideFormattingMap = Record<string, Record<string, unknown>>;
|
| 7 |
+
export type TemplateTextAlign = 'left' | 'center' | 'right';
|
| 8 |
+
|
| 9 |
+
interface TemplateSurfaceSelectionContextValue {
|
| 10 |
+
selectedFormatKey: string | null;
|
| 11 |
+
onSelectFormatKey?: (formatKey: string | null) => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const TemplateSurfaceSelectionContext = createContext<TemplateSurfaceSelectionContextValue>({
|
| 15 |
+
selectedFormatKey: null,
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
export function TemplateSurfaceSelectionProvider({
|
| 19 |
+
children,
|
| 20 |
+
selectedFormatKey,
|
| 21 |
+
onSelectFormatKey,
|
| 22 |
+
}: React.PropsWithChildren<TemplateSurfaceSelectionContextValue>) {
|
| 23 |
+
return (
|
| 24 |
+
<TemplateSurfaceSelectionContext.Provider value={{ selectedFormatKey, onSelectFormatKey }}>
|
| 25 |
+
{children}
|
| 26 |
+
</TemplateSurfaceSelectionContext.Provider>
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function useTemplateSurfaceSelection() {
|
| 31 |
+
return useContext(TemplateSurfaceSelectionContext);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function readFormattedTextAlign(
|
| 35 |
+
formatting: SlideFormattingMap | undefined,
|
| 36 |
+
formatKey: string,
|
| 37 |
+
fallback: TemplateTextAlign = 'left'
|
| 38 |
+
): TemplateTextAlign {
|
| 39 |
+
const value = formatting?.[formatKey]?.textAlign;
|
| 40 |
+
return value === 'left' || value === 'center' || value === 'right' ? value : fallback;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
interface PersistedDraggableSurfaceProps {
|
| 44 |
+
children: React.ReactNode;
|
| 45 |
+
constraintsRef: React.RefObject<HTMLDivElement | null>;
|
| 46 |
+
slideId?: string;
|
| 47 |
+
formatKey: string;
|
| 48 |
+
formatting?: SlideFormattingMap;
|
| 49 |
+
onFormattingUpdate?: (slideId: string, key: string, patch: Record<string, unknown>) => void;
|
| 50 |
+
isEditable?: boolean;
|
| 51 |
+
className?: string;
|
| 52 |
+
style?: React.CSSProperties;
|
| 53 |
+
handleClassName?: string;
|
| 54 |
+
handle?: React.ReactNode;
|
| 55 |
+
groupClassName?: string;
|
| 56 |
+
whileDrag?: any;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function readAxis(value: unknown) {
|
| 60 |
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function readDimension(value: unknown) {
|
| 64 |
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export function PersistedDraggableSurface({
|
| 68 |
+
children,
|
| 69 |
+
constraintsRef,
|
| 70 |
+
slideId,
|
| 71 |
+
formatKey,
|
| 72 |
+
formatting,
|
| 73 |
+
onFormattingUpdate,
|
| 74 |
+
isEditable = false,
|
| 75 |
+
className,
|
| 76 |
+
style,
|
| 77 |
+
handleClassName = '-top-6 left-1/2 -translate-x-1/2',
|
| 78 |
+
handle,
|
| 79 |
+
groupClassName,
|
| 80 |
+
whileDrag,
|
| 81 |
+
}: PersistedDraggableSurfaceProps) {
|
| 82 |
+
const controls = useDragControls();
|
| 83 |
+
const elementRef = useRef<HTMLDivElement>(null);
|
| 84 |
+
const resizeState = useRef<{
|
| 85 |
+
handle: 'n' | 'e' | 's' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
| 86 |
+
pointerId: number;
|
| 87 |
+
startX: number;
|
| 88 |
+
startY: number;
|
| 89 |
+
startFrame: { x: number; y: number; width?: number; height?: number };
|
| 90 |
+
measuredWidth: number;
|
| 91 |
+
measuredHeight: number;
|
| 92 |
+
} | null>(null);
|
| 93 |
+
const { selectedFormatKey, onSelectFormatKey } = useTemplateSurfaceSelection();
|
| 94 |
+
const selected = selectedFormatKey === formatKey;
|
| 95 |
+
const storedFrame = useMemo(
|
| 96 |
+
() => ({
|
| 97 |
+
x: readAxis(formatting?.[formatKey]?.x),
|
| 98 |
+
y: readAxis(formatting?.[formatKey]?.y),
|
| 99 |
+
width: readDimension(formatting?.[formatKey]?.width),
|
| 100 |
+
height: readDimension(formatting?.[formatKey]?.height),
|
| 101 |
+
}),
|
| 102 |
+
[formatKey, formatting]
|
| 103 |
+
);
|
| 104 |
+
const [frame, setFrame] = useState(storedFrame);
|
| 105 |
+
const textAlign = readFormattedTextAlign(formatting, formatKey, 'left');
|
| 106 |
+
|
| 107 |
+
useEffect(() => {
|
| 108 |
+
setFrame(storedFrame);
|
| 109 |
+
}, [storedFrame]);
|
| 110 |
+
|
| 111 |
+
useEffect(() => {
|
| 112 |
+
if (!selected) {
|
| 113 |
+
resizeState.current = null;
|
| 114 |
+
}
|
| 115 |
+
}, [selected]);
|
| 116 |
+
|
| 117 |
+
useEffect(() => {
|
| 118 |
+
if (!isEditable) return;
|
| 119 |
+
|
| 120 |
+
const handlePointerMove = (event: PointerEvent) => {
|
| 121 |
+
const state = resizeState.current;
|
| 122 |
+
if (!state) return;
|
| 123 |
+
|
| 124 |
+
const dx = event.clientX - state.startX;
|
| 125 |
+
const dy = event.clientY - state.startY;
|
| 126 |
+
const baseWidth = state.startFrame.width ?? state.measuredWidth;
|
| 127 |
+
const baseHeight = state.startFrame.height ?? state.measuredHeight;
|
| 128 |
+
const minWidth = Math.min(140, baseWidth);
|
| 129 |
+
const minHeight = Math.min(72, baseHeight);
|
| 130 |
+
|
| 131 |
+
let nextX = state.startFrame.x;
|
| 132 |
+
let nextY = state.startFrame.y;
|
| 133 |
+
let nextWidth = baseWidth;
|
| 134 |
+
let nextHeight = baseHeight;
|
| 135 |
+
|
| 136 |
+
if (state.handle.includes('e')) {
|
| 137 |
+
nextWidth = Math.max(minWidth, baseWidth + dx);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (state.handle.includes('s')) {
|
| 141 |
+
nextHeight = Math.max(minHeight, baseHeight + dy);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (state.handle.includes('w')) {
|
| 145 |
+
const width = Math.max(minWidth, baseWidth - dx);
|
| 146 |
+
nextX = state.startFrame.x + (baseWidth - width);
|
| 147 |
+
nextWidth = width;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if (state.handle.includes('n')) {
|
| 151 |
+
const height = Math.max(minHeight, baseHeight - dy);
|
| 152 |
+
nextY = state.startFrame.y + (baseHeight - height);
|
| 153 |
+
nextHeight = height;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
setFrame({
|
| 157 |
+
x: nextX,
|
| 158 |
+
y: nextY,
|
| 159 |
+
width: nextWidth,
|
| 160 |
+
height: nextHeight,
|
| 161 |
+
});
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
const handlePointerUp = () => {
|
| 165 |
+
const state = resizeState.current;
|
| 166 |
+
if (!state) return;
|
| 167 |
+
|
| 168 |
+
resizeState.current = null;
|
| 169 |
+
|
| 170 |
+
if (slideId && onFormattingUpdate) {
|
| 171 |
+
setFrame((currentFrame) => {
|
| 172 |
+
onFormattingUpdate(slideId, formatKey, {
|
| 173 |
+
x: currentFrame.x,
|
| 174 |
+
y: currentFrame.y,
|
| 175 |
+
width: currentFrame.width,
|
| 176 |
+
height: currentFrame.height,
|
| 177 |
+
});
|
| 178 |
+
return currentFrame;
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
window.addEventListener('pointermove', handlePointerMove);
|
| 184 |
+
window.addEventListener('pointerup', handlePointerUp);
|
| 185 |
+
|
| 186 |
+
return () => {
|
| 187 |
+
window.removeEventListener('pointermove', handlePointerMove);
|
| 188 |
+
window.removeEventListener('pointerup', handlePointerUp);
|
| 189 |
+
};
|
| 190 |
+
}, [formatKey, isEditable, onFormattingUpdate, slideId]);
|
| 191 |
+
|
| 192 |
+
const startResize = (
|
| 193 |
+
handle: 'n' | 'e' | 's' | 'w' | 'ne' | 'nw' | 'se' | 'sw',
|
| 194 |
+
event: React.PointerEvent<HTMLButtonElement>
|
| 195 |
+
) => {
|
| 196 |
+
if (!isEditable || !elementRef.current) return;
|
| 197 |
+
|
| 198 |
+
const measuredWidth = elementRef.current.offsetWidth;
|
| 199 |
+
const measuredHeight = elementRef.current.offsetHeight;
|
| 200 |
+
|
| 201 |
+
resizeState.current = {
|
| 202 |
+
handle,
|
| 203 |
+
pointerId: event.pointerId,
|
| 204 |
+
startX: event.clientX,
|
| 205 |
+
startY: event.clientY,
|
| 206 |
+
startFrame: frame,
|
| 207 |
+
measuredWidth,
|
| 208 |
+
measuredHeight,
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
onSelectFormatKey?.(formatKey);
|
| 212 |
+
event.preventDefault();
|
| 213 |
+
event.stopPropagation();
|
| 214 |
+
};
|
| 215 |
+
|
| 216 |
+
const handleDragEnd = (_: unknown, info: { offset: { x: number; y: number } }) => {
|
| 217 |
+
if (!isEditable) return;
|
| 218 |
+
|
| 219 |
+
const nextFrame = {
|
| 220 |
+
...frame,
|
| 221 |
+
x: frame.x + info.offset.x,
|
| 222 |
+
y: frame.y + info.offset.y,
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
setFrame(nextFrame);
|
| 226 |
+
|
| 227 |
+
if (slideId && onFormattingUpdate) {
|
| 228 |
+
onFormattingUpdate(slideId, formatKey, nextFrame);
|
| 229 |
+
}
|
| 230 |
+
};
|
| 231 |
+
|
| 232 |
+
const showResizeHandles = isEditable && selected;
|
| 233 |
+
const surfaceStyle: React.CSSProperties = {
|
| 234 |
+
...(style || {}),
|
| 235 |
+
textAlign,
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
if (typeof frame.width === 'number') {
|
| 239 |
+
surfaceStyle.width = frame.width;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
if (typeof frame.height === 'number') {
|
| 243 |
+
surfaceStyle.height = frame.height;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
return (
|
| 247 |
+
<motion.div
|
| 248 |
+
ref={elementRef}
|
| 249 |
+
drag={isEditable}
|
| 250 |
+
dragControls={controls}
|
| 251 |
+
dragListener={isEditable && !handle}
|
| 252 |
+
dragMomentum={false}
|
| 253 |
+
dragElastic={0.04}
|
| 254 |
+
dragConstraints={constraintsRef}
|
| 255 |
+
whileDrag={isEditable ? (whileDrag || { scale: 1.01, zIndex: 40 }) : undefined}
|
| 256 |
+
onPointerDown={() => {
|
| 257 |
+
if (!isEditable) return;
|
| 258 |
+
onSelectFormatKey?.(formatKey);
|
| 259 |
+
}}
|
| 260 |
+
onDragEnd={handleDragEnd}
|
| 261 |
+
data-template-surface="true"
|
| 262 |
+
data-template-align={textAlign}
|
| 263 |
+
className={`${isEditable ? `relative ${groupClassName || 'group'} cursor-grab active:cursor-grabbing` : 'relative'} ${selected ? 'z-30' : ''} ${className || ''}`}
|
| 264 |
+
style={{ ...surfaceStyle, x: frame.x, y: frame.y }}
|
| 265 |
+
>
|
| 266 |
+
{isEditable && handle ? (
|
| 267 |
+
<div
|
| 268 |
+
onPointerDown={(e) => controls.start(e)}
|
| 269 |
+
style={{ touchAction: 'none' }}
|
| 270 |
+
className={`absolute z-30 ${handleClassName}`}
|
| 271 |
+
>
|
| 272 |
+
{handle}
|
| 273 |
+
</div>
|
| 274 |
+
) : null}
|
| 275 |
+
{showResizeHandles ? (
|
| 276 |
+
<>
|
| 277 |
+
<div className="pointer-events-none absolute inset-0 rounded border border-blue-500 shadow-[0_0_0_1px_rgba(59,130,246,0.18)]" />
|
| 278 |
+
{[
|
| 279 |
+
{ handle: 'nw', className: '-left-1.5 -top-1.5 cursor-nwse-resize' },
|
| 280 |
+
{ handle: 'n', className: 'left-1/2 -top-1.5 -translate-x-1/2 cursor-ns-resize' },
|
| 281 |
+
{ handle: 'ne', className: '-right-1.5 -top-1.5 cursor-nesw-resize' },
|
| 282 |
+
{ handle: 'e', className: '-right-1.5 top-1/2 -translate-y-1/2 cursor-ew-resize' },
|
| 283 |
+
{ handle: 'se', className: '-right-1.5 -bottom-1.5 cursor-nwse-resize' },
|
| 284 |
+
{ handle: 's', className: 'left-1/2 -bottom-1.5 -translate-x-1/2 cursor-ns-resize' },
|
| 285 |
+
{ handle: 'sw', className: '-left-1.5 -bottom-1.5 cursor-nesw-resize' },
|
| 286 |
+
{ handle: 'w', className: '-left-1.5 top-1/2 -translate-y-1/2 cursor-ew-resize' },
|
| 287 |
+
].map((entry) => (
|
| 288 |
+
<button
|
| 289 |
+
key={entry.handle}
|
| 290 |
+
type="button"
|
| 291 |
+
aria-label={`Resize ${entry.handle}`}
|
| 292 |
+
onPointerDown={(event) =>
|
| 293 |
+
startResize(entry.handle as 'n' | 'e' | 's' | 'w' | 'ne' | 'nw' | 'se' | 'sw', event)
|
| 294 |
+
}
|
| 295 |
+
className={`absolute z-40 h-3 w-3 rounded-sm border border-blue-600 bg-white shadow ${entry.className}`}
|
| 296 |
+
/>
|
| 297 |
+
))}
|
| 298 |
+
</>
|
| 299 |
+
) : null}
|
| 300 |
+
{children}
|
| 301 |
+
</motion.div>
|
| 302 |
+
);
|
| 303 |
+
}
|
data/templates/galeryn.ts
CHANGED
|
@@ -30,7 +30,13 @@ export const galerynTemplate: Template = {
|
|
| 30 |
name: 'Title & Subtitle',
|
| 31 |
fields: [
|
| 32 |
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'GALERYN' },
|
| 33 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
],
|
| 35 |
},
|
| 36 |
{
|
|
@@ -43,11 +49,11 @@ export const galerynTemplate: Template = {
|
|
| 43 |
label: 'Agenda Items',
|
| 44 |
type: 'list',
|
| 45 |
defaultValue: [
|
| 46 |
-
{ text: 'Tell About Us' },
|
| 47 |
-
{ text: 'Stories' },
|
| 48 |
-
{ text: 'Our Concept' },
|
| 49 |
-
{ text: 'Design Style' },
|
| 50 |
-
{ text: 'Contact Info' },
|
| 51 |
],
|
| 52 |
},
|
| 53 |
],
|
|
@@ -67,13 +73,42 @@ export const galerynTemplate: Template = {
|
|
| 67 |
},
|
| 68 |
],
|
| 69 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
{
|
| 71 |
id: 'image_and_text',
|
| 72 |
name: 'Image & Text',
|
| 73 |
fields: [
|
| 74 |
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'Curated Experiences' },
|
| 75 |
-
{
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
],
|
| 78 |
},
|
| 79 |
{
|
|
|
|
| 30 |
name: 'Title & Subtitle',
|
| 31 |
fields: [
|
| 32 |
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'GALERYN' },
|
| 33 |
+
{
|
| 34 |
+
key: 'subtitle',
|
| 35 |
+
label: 'Subtitle',
|
| 36 |
+
type: 'text',
|
| 37 |
+
defaultValue:
|
| 38 |
+
'Sit amet consectetur adipiscing elit duis. Cursus eget nunc scelerisque viverra mauris in aliquam sem fringilla. Lorem donec massa sapien faucibus et. Vel orci porta non pulvinar neque laoreet suspendisse interdum.',
|
| 39 |
+
},
|
| 40 |
],
|
| 41 |
},
|
| 42 |
{
|
|
|
|
| 49 |
label: 'Agenda Items',
|
| 50 |
type: 'list',
|
| 51 |
defaultValue: [
|
| 52 |
+
{ text: 'Tell About Us || Lone lowe effr at set, costomed poloring elt.' },
|
| 53 |
+
{ text: 'Stories || Lone lowe effr at set, costomed poinring elt.' },
|
| 54 |
+
{ text: 'Our Concept || Lone lowe effr at set, customed poinring etc.' },
|
| 55 |
+
{ text: 'Design Style || Lone lowe effr at aat, customer poinring etc.' },
|
| 56 |
+
{ text: 'Contact Info || Lone lowe effr at aas, customer poinring ett.' },
|
| 57 |
],
|
| 58 |
},
|
| 59 |
],
|
|
|
|
| 73 |
},
|
| 74 |
],
|
| 75 |
},
|
| 76 |
+
{
|
| 77 |
+
id: 'three_columns',
|
| 78 |
+
name: 'Three Columns',
|
| 79 |
+
fields: [
|
| 80 |
+
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'Core Principles' },
|
| 81 |
+
{
|
| 82 |
+
key: 'columns',
|
| 83 |
+
label: 'Columns',
|
| 84 |
+
type: 'list',
|
| 85 |
+
defaultValue: [
|
| 86 |
+
{ heading: 'Asymmetry', text: 'Rejecting center-aligned boredom in favor of weighted layouts that guide the eye.' },
|
| 87 |
+
{ heading: 'White Space', text: 'Using negative space as a functional element to create breathing room for ideas.' },
|
| 88 |
+
{ heading: 'Tension', text: 'The juxtaposition of heritage serifs against precise modern sans-serif typography.' },
|
| 89 |
+
],
|
| 90 |
+
},
|
| 91 |
+
],
|
| 92 |
+
},
|
| 93 |
{
|
| 94 |
id: 'image_and_text',
|
| 95 |
name: 'Image & Text',
|
| 96 |
fields: [
|
| 97 |
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'Curated Experiences' },
|
| 98 |
+
{
|
| 99 |
+
key: 'body',
|
| 100 |
+
label: 'Description',
|
| 101 |
+
type: 'text',
|
| 102 |
+
defaultValue:
|
| 103 |
+
'We are creating engaging content using artificial intelligence technologies and data analysis for social media platforms. Our goal is to improve brand awareness, customer retention, and loyalty for our clients.',
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
key: 'imageUrl',
|
| 107 |
+
label: 'Image URL',
|
| 108 |
+
type: 'image',
|
| 109 |
+
defaultValue:
|
| 110 |
+
'https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85?auto=format&fit=crop&q=80&w=1000',
|
| 111 |
+
},
|
| 112 |
],
|
| 113 |
},
|
| 114 |
{
|
data/templates/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type LayoutType =
|
|
| 11 |
| 'title_subtitle'
|
| 12 |
| 'agenda'
|
| 13 |
| 'title_and_text'
|
|
|
|
| 14 |
| 'image_and_text'
|
| 15 |
| 'references'
|
| 16 |
| 'thank_you';
|
|
@@ -107,6 +108,7 @@ export interface SlideSpec {
|
|
| 107 |
title?: string;
|
| 108 |
subtitle?: string;
|
| 109 |
body?: Array<{ heading?: string; text: string }>;
|
|
|
|
| 110 |
items?: Array<{ text: string }>;
|
| 111 |
imageUrl?: string;
|
| 112 |
/** Per-field formatting overrides captured by the editor */
|
|
@@ -130,6 +132,7 @@ export function createDefaultSlides(templateId: string): SlideSpec[] {
|
|
| 130 |
if (field.key === 'title') spec.title = field.defaultValue;
|
| 131 |
else if (field.key === 'subtitle') spec.subtitle = field.defaultValue;
|
| 132 |
else if (field.key === 'body') spec.body = field.defaultValue;
|
|
|
|
| 133 |
else if (field.key === 'items') spec.items = field.defaultValue;
|
| 134 |
else if (field.key === 'imageUrl') spec.imageUrl = field.defaultValue;
|
| 135 |
}
|
|
|
|
| 11 |
| 'title_subtitle'
|
| 12 |
| 'agenda'
|
| 13 |
| 'title_and_text'
|
| 14 |
+
| 'three_columns'
|
| 15 |
| 'image_and_text'
|
| 16 |
| 'references'
|
| 17 |
| 'thank_you';
|
|
|
|
| 108 |
title?: string;
|
| 109 |
subtitle?: string;
|
| 110 |
body?: Array<{ heading?: string; text: string }>;
|
| 111 |
+
columns?: Array<{ heading: string; text: string }>;
|
| 112 |
items?: Array<{ text: string }>;
|
| 113 |
imageUrl?: string;
|
| 114 |
/** Per-field formatting overrides captured by the editor */
|
|
|
|
| 132 |
if (field.key === 'title') spec.title = field.defaultValue;
|
| 133 |
else if (field.key === 'subtitle') spec.subtitle = field.defaultValue;
|
| 134 |
else if (field.key === 'body') spec.body = field.defaultValue;
|
| 135 |
+
else if (field.key === 'columns') spec.columns = field.defaultValue;
|
| 136 |
else if (field.key === 'items') spec.items = field.defaultValue;
|
| 137 |
else if (field.key === 'imageUrl') spec.imageUrl = field.defaultValue;
|
| 138 |
}
|
data/templates/neo-brutalism.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Template } from './index';
|
|
| 2 |
|
| 3 |
export const neoBrutalismTemplate: Template = {
|
| 4 |
id: 'neobrutalism',
|
| 5 |
-
name: 'Neo
|
| 6 |
description: 'Bold, raw aesthetic with thick borders, vivid colors, and strong shadows',
|
| 7 |
thumbnail: '🟡',
|
| 8 |
styles: {
|
|
@@ -68,11 +68,28 @@ export const neoBrutalismTemplate: Template = {
|
|
| 68 |
},
|
| 69 |
],
|
| 70 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
{
|
| 72 |
id: 'image_and_text',
|
| 73 |
name: 'Image & Text',
|
| 74 |
fields: [
|
| 75 |
-
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'VISUAL
|
| 76 |
{ key: 'body', label: 'Description', type: 'text', defaultValue: 'Add a description of your image or visual content here.' },
|
| 77 |
{ key: 'imageUrl', label: 'Image URL', type: 'image', defaultValue: '' },
|
| 78 |
],
|
|
@@ -81,7 +98,7 @@ export const neoBrutalismTemplate: Template = {
|
|
| 81 |
id: 'references',
|
| 82 |
name: 'References',
|
| 83 |
fields: [
|
| 84 |
-
{ key: 'title', label: 'Title', type: 'text', defaultValue: '
|
| 85 |
{
|
| 86 |
key: 'items',
|
| 87 |
label: 'References',
|
|
|
|
| 2 |
|
| 3 |
export const neoBrutalismTemplate: Template = {
|
| 4 |
id: 'neobrutalism',
|
| 5 |
+
name: 'Neo-Brutal',
|
| 6 |
description: 'Bold, raw aesthetic with thick borders, vivid colors, and strong shadows',
|
| 7 |
thumbnail: '🟡',
|
| 8 |
styles: {
|
|
|
|
| 68 |
},
|
| 69 |
],
|
| 70 |
},
|
| 71 |
+
{
|
| 72 |
+
id: 'three_columns',
|
| 73 |
+
name: 'Three Columns',
|
| 74 |
+
fields: [
|
| 75 |
+
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'THE 3 PILLARS' },
|
| 76 |
+
{
|
| 77 |
+
key: 'columns',
|
| 78 |
+
label: 'Columns',
|
| 79 |
+
type: 'list',
|
| 80 |
+
defaultValue: [
|
| 81 |
+
{ heading: 'FOUNDATIONAL STRUCTURE', text: 'The core architectural framework that supports high-velocity iteration and strong creative direction.' },
|
| 82 |
+
{ heading: 'RADICAL TRANSPARENCY', text: 'A clear, visible system for decisions, tradeoffs, and communication across the entire presentation.' },
|
| 83 |
+
{ heading: 'DYNAMIC SCALABILITY', text: 'A modular setup that can grow without losing its structure, clarity, or visual impact.' },
|
| 84 |
+
],
|
| 85 |
+
},
|
| 86 |
+
],
|
| 87 |
+
},
|
| 88 |
{
|
| 89 |
id: 'image_and_text',
|
| 90 |
name: 'Image & Text',
|
| 91 |
fields: [
|
| 92 |
+
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'VISUAL IMPACT' },
|
| 93 |
{ key: 'body', label: 'Description', type: 'text', defaultValue: 'Add a description of your image or visual content here.' },
|
| 94 |
{ key: 'imageUrl', label: 'Image URL', type: 'image', defaultValue: '' },
|
| 95 |
],
|
|
|
|
| 98 |
id: 'references',
|
| 99 |
name: 'References',
|
| 100 |
fields: [
|
| 101 |
+
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'REFERENCES_&_SOURCES' },
|
| 102 |
{
|
| 103 |
key: 'items',
|
| 104 |
label: 'References',
|
data/templates/noisy.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { Template } from './index';
|
|
| 2 |
|
| 3 |
export const noisyTemplate: Template = {
|
| 4 |
id: 'noisy',
|
| 5 |
-
name: '
|
| 6 |
-
description: 'Bold monospace aesthetic with noise texture overlay and
|
| 7 |
thumbnail: '📡',
|
| 8 |
styles: {
|
| 9 |
colors: {
|
|
@@ -15,8 +15,8 @@ export const noisyTemplate: Template = {
|
|
| 15 |
cardBg: '#ffffff',
|
| 16 |
},
|
| 17 |
fonts: {
|
| 18 |
-
heading:
|
| 19 |
-
body:
|
| 20 |
},
|
| 21 |
border: {
|
| 22 |
width: '0px',
|
|
@@ -66,6 +66,23 @@ export const noisyTemplate: Template = {
|
|
| 66 |
},
|
| 67 |
],
|
| 68 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
{
|
| 70 |
id: 'image_and_text',
|
| 71 |
name: 'Image & Text',
|
|
|
|
| 2 |
|
| 3 |
export const noisyTemplate: Template = {
|
| 4 |
id: 'noisy',
|
| 5 |
+
name: 'Distortion',
|
| 6 |
+
description: 'Bold distorted monospace aesthetic with noise texture overlay and high-contrast color blocking',
|
| 7 |
thumbnail: '📡',
|
| 8 |
styles: {
|
| 9 |
colors: {
|
|
|
|
| 15 |
cardBg: '#ffffff',
|
| 16 |
},
|
| 17 |
fonts: {
|
| 18 |
+
heading: 'var(--font-roboto-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
| 19 |
+
body: 'var(--font-roboto-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
| 20 |
},
|
| 21 |
border: {
|
| 22 |
width: '0px',
|
|
|
|
| 66 |
},
|
| 67 |
],
|
| 68 |
},
|
| 69 |
+
{
|
| 70 |
+
id: 'three_columns',
|
| 71 |
+
name: 'Three Columns',
|
| 72 |
+
fields: [
|
| 73 |
+
{ key: 'title', label: 'Title', type: 'text', defaultValue: 'THREE SIGNALS' },
|
| 74 |
+
{
|
| 75 |
+
key: 'columns',
|
| 76 |
+
label: 'Columns',
|
| 77 |
+
type: 'list',
|
| 78 |
+
defaultValue: [
|
| 79 |
+
{ heading: 'INPUT', text: 'The first column introduces the initial signal, source, or condition.' },
|
| 80 |
+
{ heading: 'SYSTEM', text: 'The second column explains the mechanism, workflow, or decision layer.' },
|
| 81 |
+
{ heading: 'OUTPUT', text: 'The final column summarizes the outcome, result, or audience-facing impact.' },
|
| 82 |
+
],
|
| 83 |
+
},
|
| 84 |
+
],
|
| 85 |
+
},
|
| 86 |
{
|
| 87 |
id: 'image_and_text',
|
| 88 |
name: 'Image & Text',
|
hooks/useExport.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
| 1 |
-
import html2canvas from 'html2canvas';
|
| 2 |
import jsPDF from 'jspdf';
|
| 3 |
import type { SlideModel } from '@/lib/editor-types';
|
| 4 |
-
import { themes
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
interface UseExportParams {
|
| 7 |
slideRef: React.RefObject<HTMLDivElement | null>;
|
| 8 |
slides: SlideModel[];
|
|
|
|
| 9 |
currentTheme: keyof typeof themes;
|
| 10 |
currentSlideIndex: number;
|
| 11 |
zoom: number;
|
|
@@ -18,37 +21,50 @@ interface UseExportParams {
|
|
| 18 |
setIsEditingTextId: (id: string | null) => void;
|
| 19 |
}
|
| 20 |
|
| 21 |
-
function
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
body, html { background: #ffffff !important; }
|
| 27 |
-
`;
|
| 28 |
-
clonedDoc.head.appendChild(style);
|
| 29 |
-
|
| 30 |
-
const allElements = clonedDoc.querySelectorAll('*');
|
| 31 |
-
allElements.forEach((el: Element) => {
|
| 32 |
-
const style = el.getAttribute('style');
|
| 33 |
-
if (style) {
|
| 34 |
-
const sanitized = style
|
| 35 |
-
.replace(/lab\([^)]+\)/g, '#000000')
|
| 36 |
-
.replace(/lch\([^)]+\)/g, '#000000')
|
| 37 |
-
.replace(/oklch\([^)]+\)/g, '#000000')
|
| 38 |
-
.replace(/oklab\([^)]+\)/g, '#000000')
|
| 39 |
-
.replace(/color\([^)]+\)/g, '#000000');
|
| 40 |
-
if (sanitized !== style) el.setAttribute('style', sanitized);
|
| 41 |
-
}
|
| 42 |
});
|
| 43 |
}
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
export function useExport({
|
| 46 |
-
slideRef, slides,
|
| 47 |
-
|
| 48 |
-
setCurrentSlideIndex, setZoom,
|
|
|
|
| 49 |
}: UseExportParams) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
const captureAllSlides = async (): Promise<string[]> => {
|
| 52 |
const originalSlideIndex = currentSlideIndex;
|
| 53 |
const originalZoom = zoom;
|
| 54 |
const originalSelectedId = selectedId;
|
|
@@ -57,57 +73,43 @@ export function useExport({
|
|
| 57 |
setSelectedId(null);
|
| 58 |
setIsEditingTextId(null);
|
| 59 |
setZoom(1);
|
| 60 |
-
await
|
|
|
|
| 61 |
|
| 62 |
const images: string[] = [];
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
if (!slideRef.current) continue;
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
useCORS: true,
|
| 73 |
-
allowTaint: true,
|
| 74 |
-
onclone: (clonedDoc) => {
|
| 75 |
-
const el = clonedDoc.querySelector('[style*="width: 800px"]') as HTMLElement | null;
|
| 76 |
-
if (el && !TEMPLATE_THEMES.has(currentTheme)) {
|
| 77 |
-
const theme = themes[currentTheme] as any;
|
| 78 |
-
if (theme.backgroundImage) {
|
| 79 |
-
el.style.backgroundImage = theme.backgroundImage;
|
| 80 |
-
el.style.backgroundSize = theme.backgroundSize || 'cover';
|
| 81 |
-
el.style.backgroundPosition = theme.backgroundPosition || 'center';
|
| 82 |
-
if (theme.backgroundColor) el.style.backgroundColor = theme.backgroundColor;
|
| 83 |
-
} else if (theme.background) {
|
| 84 |
-
el.style.backgroundColor = theme.background;
|
| 85 |
-
} else if (theme.solidBackground) {
|
| 86 |
-
el.style.backgroundColor = theme.solidBackground;
|
| 87 |
-
}
|
| 88 |
-
}
|
| 89 |
-
sanitizeColorsForExport(clonedDoc);
|
| 90 |
-
},
|
| 91 |
-
});
|
| 92 |
-
images.push(canvas.toDataURL('image/png'));
|
| 93 |
}
|
| 94 |
|
| 95 |
setCurrentSlideIndex(originalSlideIndex);
|
| 96 |
setZoom(originalZoom);
|
| 97 |
setSelectedId(originalSelectedId);
|
| 98 |
setIsEditingTextId(originalEditingTextId);
|
|
|
|
| 99 |
return images;
|
| 100 |
};
|
| 101 |
|
| 102 |
const exportToPDF = async () => {
|
| 103 |
if (!slideRef.current || slides.length === 0) return;
|
|
|
|
| 104 |
try {
|
| 105 |
-
const images = await
|
| 106 |
const pdf = new jsPDF({ orientation: 'landscape', unit: 'px', format: [800, 450] });
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
| 110 |
});
|
|
|
|
| 111 |
const sanitizedTitle = presentationTitle.replace(/[^a-zA-Z0-9 -]/g, '').trim() || 'presentation';
|
| 112 |
pdf.save(`${sanitizedTitle}.pdf`);
|
| 113 |
} catch (error) {
|
|
@@ -118,18 +120,14 @@ export function useExport({
|
|
| 118 |
|
| 119 |
const exportToPPTX = async () => {
|
| 120 |
if (!slideRef.current || slides.length === 0) return;
|
|
|
|
| 121 |
try {
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
const slide = pres.addSlide();
|
| 129 |
-
slide.addImage({ data: imgData, x: 0, y: 0, w: 10, h: 5.625 });
|
| 130 |
-
}
|
| 131 |
-
const sanitizedTitle = presentationTitle.replace(/[^a-zA-Z0-9 -]/g, '').trim() || 'presentation';
|
| 132 |
-
await pres.writeFile({ fileName: `${sanitizedTitle}.pptx` });
|
| 133 |
} catch (error) {
|
| 134 |
console.error('Error exporting to PPTX:', error);
|
| 135 |
alert('Failed to export to PPTX. Please try again.');
|
|
|
|
|
|
|
| 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;
|
|
|
|
| 21 |
setIsEditingTextId: (id: string | null) => void;
|
| 22 |
}
|
| 23 |
|
| 24 |
+
function waitForPaint() {
|
| 25 |
+
return new Promise<void>((resolve) => {
|
| 26 |
+
requestAnimationFrame(() => {
|
| 27 |
+
requestAnimationFrame(() => resolve());
|
| 28 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
});
|
| 30 |
}
|
| 31 |
|
| 32 |
+
async function waitForFonts() {
|
| 33 |
+
if ('fonts' in document) {
|
| 34 |
+
await (document as Document & { fonts: FontFaceSet }).fonts.ready;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
async function waitForImages(container: HTMLElement) {
|
| 39 |
+
const images = Array.from(container.querySelectorAll('img'));
|
| 40 |
+
|
| 41 |
+
await Promise.all(
|
| 42 |
+
images.map(
|
| 43 |
+
(image) =>
|
| 44 |
+
new Promise<void>((resolve) => {
|
| 45 |
+
if (image.complete) {
|
| 46 |
+
resolve();
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
image.onload = () => resolve();
|
| 51 |
+
image.onerror = () => resolve();
|
| 52 |
+
})
|
| 53 |
+
)
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
export function useExport({
|
| 58 |
+
slideRef, slides, slideSpecs, currentTheme,
|
| 59 |
+
currentSlideIndex, zoom, selectedId, isEditingTextId,
|
| 60 |
+
presentationTitle, setCurrentSlideIndex, setZoom,
|
| 61 |
+
setSelectedId, setIsEditingTextId,
|
| 62 |
}: UseExportParams) {
|
| 63 |
+
const captureNode = async (node: HTMLElement) => captureElementAsPng(node, { scale: 2 });
|
| 64 |
+
|
| 65 |
+
const captureEditorSlides = async (): Promise<string[]> => {
|
| 66 |
+
if (!slideRef.current || slides.length === 0) return [];
|
| 67 |
|
|
|
|
| 68 |
const originalSlideIndex = currentSlideIndex;
|
| 69 |
const originalZoom = zoom;
|
| 70 |
const originalSelectedId = selectedId;
|
|
|
|
| 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) {
|
|
|
|
| 120 |
|
| 121 |
const exportToPPTX = async () => {
|
| 122 |
if (!slideRef.current || slides.length === 0) return;
|
| 123 |
+
|
| 124 |
try {
|
| 125 |
+
await exportEditablePptx({
|
| 126 |
+
slides,
|
| 127 |
+
slideSpecs,
|
| 128 |
+
currentTheme,
|
| 129 |
+
presentationTitle,
|
| 130 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
} catch (error) {
|
| 132 |
console.error('Error exporting to PPTX:', error);
|
| 133 |
alert('Failed to export to PPTX. Please try again.');
|
lib/capture-element.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface CaptureOptions {
|
| 2 |
+
scale?: number;
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
function blobToDataUrl(blob: Blob) {
|
| 6 |
+
return new Promise<string>((resolve, reject) => {
|
| 7 |
+
const reader = new FileReader();
|
| 8 |
+
reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
| 9 |
+
reader.onerror = () => reject(reader.error || new Error('Failed to read blob as data URL'));
|
| 10 |
+
reader.readAsDataURL(blob);
|
| 11 |
+
});
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
async function urlToDataUrl(url: string) {
|
| 15 |
+
const response = await fetch(url);
|
| 16 |
+
if (!response.ok) {
|
| 17 |
+
throw new Error(`Failed to fetch asset: ${response.status}`);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return blobToDataUrl(await response.blob());
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
async function inlineCssUrls(value: string) {
|
| 24 |
+
const matches = Array.from(value.matchAll(/url\((['"]?)(.*?)\1\)/gi));
|
| 25 |
+
if (matches.length === 0) return value;
|
| 26 |
+
|
| 27 |
+
let nextValue = value;
|
| 28 |
+
for (const match of matches) {
|
| 29 |
+
const rawUrl = match[2]?.trim();
|
| 30 |
+
if (!rawUrl || rawUrl.startsWith('data:') || rawUrl.startsWith('blob:')) continue;
|
| 31 |
+
|
| 32 |
+
try {
|
| 33 |
+
const dataUrl = await urlToDataUrl(rawUrl);
|
| 34 |
+
nextValue = nextValue.replace(match[0], `url("${dataUrl}")`);
|
| 35 |
+
} catch {
|
| 36 |
+
// Leave the original URL in place when it can't be inlined.
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return nextValue;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
async function copyComputedStyles(source: Element, clone: HTMLElement | SVGElement) {
|
| 44 |
+
const computed = window.getComputedStyle(source);
|
| 45 |
+
|
| 46 |
+
for (let index = 0; index < computed.length; index++) {
|
| 47 |
+
const property = computed[index];
|
| 48 |
+
if (!property) continue;
|
| 49 |
+
|
| 50 |
+
let value = computed.getPropertyValue(property);
|
| 51 |
+
if (!value) continue;
|
| 52 |
+
|
| 53 |
+
if (property === 'background-image' || property === 'mask-image' || property === '-webkit-mask-image') {
|
| 54 |
+
value = await inlineCssUrls(value);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
clone.style.setProperty(property, value, computed.getPropertyPriority(property));
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
clone.style.setProperty('animation', 'none');
|
| 61 |
+
clone.style.setProperty('transition', 'none');
|
| 62 |
+
clone.style.setProperty('caret-color', 'transparent');
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async function cloneNodeForCapture(node: Node): Promise<Node> {
|
| 66 |
+
if (node.nodeType === Node.TEXT_NODE) {
|
| 67 |
+
return node.cloneNode(false);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if (!(node instanceof Element)) {
|
| 71 |
+
return node.cloneNode(false);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if (node instanceof HTMLCanvasElement) {
|
| 75 |
+
const image = document.createElement('img');
|
| 76 |
+
image.src = node.toDataURL('image/png');
|
| 77 |
+
image.width = node.width;
|
| 78 |
+
image.height = node.height;
|
| 79 |
+
await copyComputedStyles(node, image);
|
| 80 |
+
return image;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const clone = node.cloneNode(false) as HTMLElement | SVGElement;
|
| 84 |
+
await copyComputedStyles(node, clone);
|
| 85 |
+
|
| 86 |
+
if (node instanceof HTMLImageElement && clone instanceof HTMLImageElement) {
|
| 87 |
+
const source = node.currentSrc || node.src;
|
| 88 |
+
clone.loading = 'eager';
|
| 89 |
+
clone.decoding = 'sync';
|
| 90 |
+
clone.removeAttribute('srcset');
|
| 91 |
+
|
| 92 |
+
if (source) {
|
| 93 |
+
if (source.startsWith('data:') || source.startsWith('blob:')) {
|
| 94 |
+
clone.src = source;
|
| 95 |
+
} else {
|
| 96 |
+
try {
|
| 97 |
+
clone.src = await urlToDataUrl(source);
|
| 98 |
+
} catch {
|
| 99 |
+
clone.src = source;
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
if (node instanceof HTMLInputElement && clone instanceof HTMLInputElement) {
|
| 106 |
+
clone.value = node.value;
|
| 107 |
+
if (node.checked) clone.setAttribute('checked', 'checked');
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
if (node instanceof HTMLTextAreaElement && clone instanceof HTMLTextAreaElement) {
|
| 111 |
+
clone.value = node.value;
|
| 112 |
+
clone.textContent = node.value;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
if (node instanceof HTMLSelectElement && clone instanceof HTMLSelectElement) {
|
| 116 |
+
clone.value = node.value;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
for (const child of Array.from(node.childNodes)) {
|
| 120 |
+
clone.appendChild(await cloneNodeForCapture(child));
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
return clone;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
async function waitForClonedImages(root: HTMLElement) {
|
| 127 |
+
const images = Array.from(root.querySelectorAll('img'));
|
| 128 |
+
|
| 129 |
+
await Promise.all(
|
| 130 |
+
images.map(
|
| 131 |
+
(image) =>
|
| 132 |
+
new Promise<void>((resolve) => {
|
| 133 |
+
if (image.complete) {
|
| 134 |
+
resolve();
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
image.onload = () => resolve();
|
| 139 |
+
image.onerror = () => resolve();
|
| 140 |
+
})
|
| 141 |
+
)
|
| 142 |
+
);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function loadImage(url: string) {
|
| 146 |
+
return new Promise<HTMLImageElement>((resolve, reject) => {
|
| 147 |
+
const image = new Image();
|
| 148 |
+
image.decoding = 'async';
|
| 149 |
+
image.onload = () => resolve(image);
|
| 150 |
+
image.onerror = () => reject(new Error('Failed to load rendered SVG image'));
|
| 151 |
+
image.src = url;
|
| 152 |
+
});
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
export async function captureElementAsPng(node: HTMLElement, options: CaptureOptions = {}) {
|
| 156 |
+
const scale = options.scale ?? 2;
|
| 157 |
+
const width = Math.round(node.offsetWidth);
|
| 158 |
+
const height = Math.round(node.offsetHeight);
|
| 159 |
+
|
| 160 |
+
const wrapper = document.createElement('div');
|
| 161 |
+
wrapper.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
| 162 |
+
wrapper.style.width = `${width}px`;
|
| 163 |
+
wrapper.style.height = `${height}px`;
|
| 164 |
+
wrapper.style.overflow = 'hidden';
|
| 165 |
+
wrapper.style.margin = '0';
|
| 166 |
+
wrapper.style.padding = '0';
|
| 167 |
+
wrapper.style.boxSizing = 'border-box';
|
| 168 |
+
|
| 169 |
+
const clonedNode = await cloneNodeForCapture(node);
|
| 170 |
+
wrapper.appendChild(clonedNode);
|
| 171 |
+
await waitForClonedImages(wrapper);
|
| 172 |
+
|
| 173 |
+
const serialized = new XMLSerializer().serializeToString(wrapper);
|
| 174 |
+
const svg = `
|
| 175 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
| 176 |
+
<foreignObject x="0" y="0" width="100%" height="100%">${serialized}</foreignObject>
|
| 177 |
+
</svg>
|
| 178 |
+
`;
|
| 179 |
+
|
| 180 |
+
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
|
| 181 |
+
const url = URL.createObjectURL(blob);
|
| 182 |
+
|
| 183 |
+
try {
|
| 184 |
+
const image = await loadImage(url);
|
| 185 |
+
const canvas = document.createElement('canvas');
|
| 186 |
+
canvas.width = width * scale;
|
| 187 |
+
canvas.height = height * scale;
|
| 188 |
+
|
| 189 |
+
const context = canvas.getContext('2d');
|
| 190 |
+
if (!context) {
|
| 191 |
+
throw new Error('Failed to create canvas context');
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
context.scale(scale, scale);
|
| 195 |
+
context.drawImage(image, 0, 0, width, height);
|
| 196 |
+
return canvas.toDataURL('image/png');
|
| 197 |
+
} finally {
|
| 198 |
+
URL.revokeObjectURL(url);
|
| 199 |
+
}
|
| 200 |
+
}
|
lib/editable-pptx-export.ts
ADDED
|
@@ -0,0 +1,1542 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import PptxGenJS from 'pptxgenjs';
|
| 2 |
+
import { getTemplateById, type SlideSpec } from '@/data/templates';
|
| 3 |
+
import type { SlideModel, TextElement, ImageElement, ShapeElement } from '@/lib/editor-types';
|
| 4 |
+
import { themes as editorThemes, TEMPLATE_THEMES } from '@/lib/editor-themes';
|
| 5 |
+
|
| 6 |
+
const PPT_WIDTH = 10;
|
| 7 |
+
const PPT_HEIGHT = 5.625;
|
| 8 |
+
const CANVAS_WIDTH = 800;
|
| 9 |
+
const PX_TO_IN = PPT_WIDTH / CANVAS_WIDTH;
|
| 10 |
+
|
| 11 |
+
type Rect = { x: number; y: number; w: number; h: number };
|
| 12 |
+
type PptxSlide = PptxGenJS.Slide;
|
| 13 |
+
type ImageProps = PptxGenJS.ImageProps;
|
| 14 |
+
|
| 15 |
+
interface EditablePptxExportParams {
|
| 16 |
+
slides: SlideModel[];
|
| 17 |
+
slideSpecs: SlideSpec[];
|
| 18 |
+
currentTheme: keyof typeof editorThemes;
|
| 19 |
+
presentationTitle: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function pxToIn(value: number) {
|
| 23 |
+
return value * PX_TO_IN;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function clamp(value: number, min: number, max: number) {
|
| 27 |
+
return Math.max(min, Math.min(value, max));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function cleanText(value: string | undefined | null) {
|
| 31 |
+
return (value || '').replace(/\r\n/g, '\n').trim();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function upperText(value: string | undefined | null) {
|
| 35 |
+
return cleanText(value).toUpperCase();
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function normalizeColor(color: string | undefined, fallback = '000000') {
|
| 39 |
+
if (!color) return fallback;
|
| 40 |
+
const hex = color.trim().replace(/^#/, '');
|
| 41 |
+
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
| 42 |
+
return hex
|
| 43 |
+
.split('')
|
| 44 |
+
.map((char) => `${char}${char}`)
|
| 45 |
+
.join('')
|
| 46 |
+
.toUpperCase();
|
| 47 |
+
}
|
| 48 |
+
if (/^[0-9a-fA-F]{6}$/.test(hex)) return hex.toUpperCase();
|
| 49 |
+
return fallback;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function normalizeOpacity(opacity: number | undefined) {
|
| 53 |
+
if (typeof opacity !== 'number' || Number.isNaN(opacity)) return 0;
|
| 54 |
+
return clamp(Math.round((1 - opacity) * 100), 0, 100);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function sanitizeFileName(title: string) {
|
| 58 |
+
return title.replace(/[^a-zA-Z0-9 -]/g, '').trim() || 'presentation';
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function normalizeFontFace(font: string | undefined, fallback = 'Arial') {
|
| 62 |
+
if (!font) return fallback;
|
| 63 |
+
const raw = font
|
| 64 |
+
.split(',')
|
| 65 |
+
.map((item) => item.trim().replace(/^['"]|['"]$/g, ''))
|
| 66 |
+
.find(Boolean);
|
| 67 |
+
|
| 68 |
+
if (!raw) return fallback;
|
| 69 |
+
|
| 70 |
+
const lower = raw.toLowerCase();
|
| 71 |
+
if (lower.includes('newsreader')) return 'Georgia';
|
| 72 |
+
if (lower.includes('inter')) return 'Arial';
|
| 73 |
+
if (lower.includes('manrope')) return 'Arial';
|
| 74 |
+
if (lower.includes('anton')) return 'Arial Black';
|
| 75 |
+
if (lower.includes('oswald')) return 'Arial Narrow';
|
| 76 |
+
if (lower.includes('roboto mono') || lower.includes('courier') || lower.includes('mono')) return 'Courier New';
|
| 77 |
+
if (lower.includes('serif')) return 'Georgia';
|
| 78 |
+
if (lower.includes('sans')) return 'Arial';
|
| 79 |
+
return raw;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function bodyToParagraph(body: SlideSpec['body']) {
|
| 83 |
+
return (body || [])
|
| 84 |
+
.map((item) => {
|
| 85 |
+
const heading = cleanText(item.heading);
|
| 86 |
+
const text = cleanText(item.text);
|
| 87 |
+
if (heading && text) return `${heading}\n${text}`;
|
| 88 |
+
return heading || text;
|
| 89 |
+
})
|
| 90 |
+
.filter(Boolean)
|
| 91 |
+
.join('\n\n');
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function splitAgendaItem(text: string) {
|
| 95 |
+
const [heading, description] = text.split('||').map((part) => cleanText(part));
|
| 96 |
+
return { heading: heading || cleanText(text), description };
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function formattingOffset(spec: SlideSpec, key: string) {
|
| 100 |
+
const source = spec.formatting?.[key];
|
| 101 |
+
const x = typeof source?.x === 'number' && Number.isFinite(source.x) ? source.x : 0;
|
| 102 |
+
const y = typeof source?.y === 'number' && Number.isFinite(source.y) ? source.y : 0;
|
| 103 |
+
return { x: pxToIn(x), y: pxToIn(y) };
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function withOffset(spec: SlideSpec, key: string, rect: Rect): Rect {
|
| 107 |
+
const offset = formattingOffset(spec, key);
|
| 108 |
+
return { x: rect.x + offset.x, y: rect.y + offset.y, w: rect.w, h: rect.h };
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function addTextBox(slide: PptxSlide, text: string, rect: Rect, options: Record<string, unknown> = {}) {
|
| 112 |
+
slide.addText(text || ' ', {
|
| 113 |
+
x: rect.x,
|
| 114 |
+
y: rect.y,
|
| 115 |
+
w: rect.w,
|
| 116 |
+
h: rect.h,
|
| 117 |
+
margin: 0,
|
| 118 |
+
fit: 'shrink',
|
| 119 |
+
valign: 'middle',
|
| 120 |
+
...options,
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
function addRect(
|
| 125 |
+
slide: PptxSlide,
|
| 126 |
+
rect: Rect,
|
| 127 |
+
fillColor: string,
|
| 128 |
+
lineColor = fillColor,
|
| 129 |
+
extra: Record<string, unknown> = {}
|
| 130 |
+
) {
|
| 131 |
+
slide.addShape('rect', {
|
| 132 |
+
x: rect.x,
|
| 133 |
+
y: rect.y,
|
| 134 |
+
w: rect.w,
|
| 135 |
+
h: rect.h,
|
| 136 |
+
fill: { color: normalizeColor(fillColor) },
|
| 137 |
+
line: { color: normalizeColor(lineColor), width: 1 },
|
| 138 |
+
...extra,
|
| 139 |
+
});
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function getImageSizing(rect: Rect): Pick<ImageProps, 'x' | 'y' | 'w' | 'h' | 'sizing'> {
|
| 143 |
+
return {
|
| 144 |
+
x: rect.x,
|
| 145 |
+
y: rect.y,
|
| 146 |
+
w: rect.w,
|
| 147 |
+
h: rect.h,
|
| 148 |
+
sizing: { type: 'cover', w: rect.w, h: rect.h },
|
| 149 |
+
};
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
async function sourceToImageProps(src: string | undefined, rect: Rect): Promise<ImageProps | null> {
|
| 153 |
+
const cleanSrc = cleanText(src);
|
| 154 |
+
if (!cleanSrc) return null;
|
| 155 |
+
|
| 156 |
+
if (cleanSrc.startsWith('data:')) {
|
| 157 |
+
return { data: cleanSrc, ...getImageSizing(rect) };
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
try {
|
| 161 |
+
const response = await fetch(cleanSrc);
|
| 162 |
+
if (!response.ok) throw new Error(`Image fetch failed: ${response.status}`);
|
| 163 |
+
const blob = await response.blob();
|
| 164 |
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
| 165 |
+
const reader = new FileReader();
|
| 166 |
+
reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
| 167 |
+
reader.onerror = () => reject(reader.error || new Error('Failed to read image'));
|
| 168 |
+
reader.readAsDataURL(blob);
|
| 169 |
+
});
|
| 170 |
+
if (dataUrl) return { data: dataUrl, ...getImageSizing(rect) };
|
| 171 |
+
} catch {
|
| 172 |
+
if (/^https?:\/\//i.test(cleanSrc)) {
|
| 173 |
+
return { path: cleanSrc, ...getImageSizing(rect) };
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
return null;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
async function addImage(slide: PptxSlide, src: string | undefined, rect: Rect) {
|
| 181 |
+
const imageProps = await sourceToImageProps(src, rect);
|
| 182 |
+
if (imageProps) {
|
| 183 |
+
slide.addImage(imageProps);
|
| 184 |
+
return;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
addRect(slide, rect, 'E5E7EB', 'CBD5E1');
|
| 188 |
+
addTextBox(slide, 'Image unavailable', rect, {
|
| 189 |
+
fontSize: 16,
|
| 190 |
+
color: '475569',
|
| 191 |
+
fontFace: 'Arial',
|
| 192 |
+
align: 'center',
|
| 193 |
+
valign: 'middle',
|
| 194 |
+
});
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function addEditorText(slide: PptxSlide, element: TextElement) {
|
| 198 |
+
addTextBox(
|
| 199 |
+
slide,
|
| 200 |
+
cleanText(element.text),
|
| 201 |
+
{
|
| 202 |
+
x: pxToIn(element.x),
|
| 203 |
+
y: pxToIn(element.y),
|
| 204 |
+
w: pxToIn(element.width),
|
| 205 |
+
h: pxToIn(element.height),
|
| 206 |
+
},
|
| 207 |
+
{
|
| 208 |
+
fontFace: normalizeFontFace(element.fontFamily),
|
| 209 |
+
fontSize: Math.max(8, Math.round(element.fontSize * 0.72)),
|
| 210 |
+
color: normalizeColor(element.color),
|
| 211 |
+
bold: element.fontWeight === 'bold',
|
| 212 |
+
italic: element.fontStyle === 'italic',
|
| 213 |
+
underline: element.textDecoration === 'underline',
|
| 214 |
+
align: element.align,
|
| 215 |
+
rotate: element.rotation || 0,
|
| 216 |
+
valign: 'top',
|
| 217 |
+
margin: 0,
|
| 218 |
+
}
|
| 219 |
+
);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
async function addEditorImage(slide: PptxSlide, element: ImageElement) {
|
| 223 |
+
await addImage(slide, element.src, {
|
| 224 |
+
x: pxToIn(element.x),
|
| 225 |
+
y: pxToIn(element.y),
|
| 226 |
+
w: pxToIn(element.width),
|
| 227 |
+
h: pxToIn(element.height),
|
| 228 |
+
});
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
function addEditorShape(slide: PptxSlide, element: ShapeElement) {
|
| 232 |
+
const rect = {
|
| 233 |
+
x: pxToIn(element.x),
|
| 234 |
+
y: pxToIn(element.y),
|
| 235 |
+
w: pxToIn(element.width),
|
| 236 |
+
h: pxToIn(element.height),
|
| 237 |
+
};
|
| 238 |
+
const line = {
|
| 239 |
+
color: normalizeColor(element.borderColor),
|
| 240 |
+
width: Math.max(0.75, element.borderWidth),
|
| 241 |
+
transparency: normalizeOpacity(element.opacity),
|
| 242 |
+
};
|
| 243 |
+
const fill = {
|
| 244 |
+
color: normalizeColor(element.backgroundColor, 'FFFFFF'),
|
| 245 |
+
transparency: normalizeOpacity(element.opacity),
|
| 246 |
+
};
|
| 247 |
+
const common = { ...rect, rotate: element.rotation || 0, line, fill };
|
| 248 |
+
|
| 249 |
+
switch (element.shapeType) {
|
| 250 |
+
case 'rectangle':
|
| 251 |
+
slide.addShape('rect', common);
|
| 252 |
+
return;
|
| 253 |
+
case 'circle':
|
| 254 |
+
slide.addShape('ellipse', common);
|
| 255 |
+
return;
|
| 256 |
+
case 'triangle':
|
| 257 |
+
slide.addShape('triangle', common);
|
| 258 |
+
return;
|
| 259 |
+
case 'arrow':
|
| 260 |
+
slide.addShape('chevron', common);
|
| 261 |
+
return;
|
| 262 |
+
case 'star':
|
| 263 |
+
slide.addShape('star5', common);
|
| 264 |
+
return;
|
| 265 |
+
case 'diamond':
|
| 266 |
+
slide.addShape('diamond', common);
|
| 267 |
+
return;
|
| 268 |
+
case 'hexagon':
|
| 269 |
+
slide.addShape('hexagon', common);
|
| 270 |
+
return;
|
| 271 |
+
case 'line':
|
| 272 |
+
slide.addShape('line', {
|
| 273 |
+
...rect,
|
| 274 |
+
rotate: element.rotation || 0,
|
| 275 |
+
line: {
|
| 276 |
+
color: normalizeColor(element.borderColor),
|
| 277 |
+
width: Math.max(1, element.borderWidth),
|
| 278 |
+
transparency: normalizeOpacity(element.opacity),
|
| 279 |
+
},
|
| 280 |
+
});
|
| 281 |
+
return;
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
async function addLegacySlide(
|
| 286 |
+
slide: PptxSlide,
|
| 287 |
+
slideModel: SlideModel,
|
| 288 |
+
currentTheme: keyof typeof editorThemes
|
| 289 |
+
) {
|
| 290 |
+
const theme = editorThemes[currentTheme] as {
|
| 291 |
+
solidBackground?: string;
|
| 292 |
+
background?: string;
|
| 293 |
+
backgroundImage?: string;
|
| 294 |
+
};
|
| 295 |
+
slide.background = { color: normalizeColor(theme.solidBackground || theme.background, 'FFFFFF') };
|
| 296 |
+
|
| 297 |
+
const backgroundImage = theme.backgroundImage?.match(/url\((['"]?)(.*?)\1\)/)?.[2];
|
| 298 |
+
if (backgroundImage) {
|
| 299 |
+
await addImage(slide, backgroundImage, { x: 0, y: 0, w: PPT_WIDTH, h: PPT_HEIGHT });
|
| 300 |
+
slide.addShape('rect', {
|
| 301 |
+
x: 0,
|
| 302 |
+
y: 0,
|
| 303 |
+
w: PPT_WIDTH,
|
| 304 |
+
h: PPT_HEIGHT,
|
| 305 |
+
fill: { color: normalizeColor(theme.solidBackground || '000000'), transparency: 30 },
|
| 306 |
+
line: { color: normalizeColor(theme.solidBackground || '000000'), transparency: 100 },
|
| 307 |
+
});
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
for (const element of slideModel.elements) {
|
| 311 |
+
if (element.type === 'text') {
|
| 312 |
+
addEditorText(slide, element);
|
| 313 |
+
continue;
|
| 314 |
+
}
|
| 315 |
+
if (element.type === 'image') {
|
| 316 |
+
await addEditorImage(slide, element);
|
| 317 |
+
continue;
|
| 318 |
+
}
|
| 319 |
+
addEditorShape(slide, element);
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
function addNeoBackground(slide: PptxSlide) {
|
| 324 |
+
slide.background = { color: 'F5F5F0' };
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function addGalerynBackground(slide: PptxSlide) {
|
| 328 |
+
slide.background = { color: 'FBF9F4' };
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
function addNoisyBackground(slide: PptxSlide, color = '547BEE') {
|
| 332 |
+
slide.background = { color };
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function addNeoDotPattern(slide: PptxSlide) {
|
| 336 |
+
const spacing = 0.42;
|
| 337 |
+
const dotSize = 0.022;
|
| 338 |
+
for (let y = 0.18; y < PPT_HEIGHT - 0.08; y += spacing) {
|
| 339 |
+
for (let x = 0.18; x < PPT_WIDTH - 0.08; x += spacing) {
|
| 340 |
+
slide.addShape('ellipse', {
|
| 341 |
+
x,
|
| 342 |
+
y,
|
| 343 |
+
w: dotSize,
|
| 344 |
+
h: dotSize,
|
| 345 |
+
fill: { color: '000000', transparency: 55 },
|
| 346 |
+
line: { color: '000000', transparency: 100 },
|
| 347 |
+
});
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function addNeoShadowCard(
|
| 353 |
+
slide: PptxSlide,
|
| 354 |
+
rect: Rect,
|
| 355 |
+
fillColor: string,
|
| 356 |
+
options: { rotate?: number; shadowX?: number; shadowY?: number; lineWidth?: number } = {}
|
| 357 |
+
) {
|
| 358 |
+
const {
|
| 359 |
+
rotate = 0,
|
| 360 |
+
shadowX = 0.12,
|
| 361 |
+
shadowY = 0.12,
|
| 362 |
+
lineWidth = 2.25,
|
| 363 |
+
} = options;
|
| 364 |
+
|
| 365 |
+
slide.addShape('rect', {
|
| 366 |
+
x: rect.x + shadowX,
|
| 367 |
+
y: rect.y + shadowY,
|
| 368 |
+
w: rect.w,
|
| 369 |
+
h: rect.h,
|
| 370 |
+
rotate,
|
| 371 |
+
fill: { color: '000000' },
|
| 372 |
+
line: { color: '000000', transparency: 100 },
|
| 373 |
+
});
|
| 374 |
+
|
| 375 |
+
slide.addShape('rect', {
|
| 376 |
+
x: rect.x,
|
| 377 |
+
y: rect.y,
|
| 378 |
+
w: rect.w,
|
| 379 |
+
h: rect.h,
|
| 380 |
+
rotate,
|
| 381 |
+
fill: { color: normalizeColor(fillColor) },
|
| 382 |
+
line: { color: '000000', width: lineWidth },
|
| 383 |
+
});
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
function addNeoTitleSubtitle(slide: PptxSlide, spec: SlideSpec) {
|
| 387 |
+
addNeoBackground(slide);
|
| 388 |
+
addNeoDotPattern(slide);
|
| 389 |
+
|
| 390 |
+
const titleCard = withOffset(spec, 'title', { x: 0.72, y: 0.78, w: 8.15, h: 2.02 });
|
| 391 |
+
addNeoShadowCard(slide, titleCard, 'D9FF00', { rotate: -1.2, shadowX: 0.12, shadowY: 0.12 });
|
| 392 |
+
addTextBox(slide, upperText(spec.title), { x: titleCard.x + 0.36, y: titleCard.y + 0.3, w: titleCard.w - 0.72, h: titleCard.h - 0.6 }, {
|
| 393 |
+
fontFace: 'Arial Black',
|
| 394 |
+
fontSize: 31,
|
| 395 |
+
bold: true,
|
| 396 |
+
color: '000000',
|
| 397 |
+
align: 'center',
|
| 398 |
+
valign: 'middle',
|
| 399 |
+
rotate: -1.2,
|
| 400 |
+
});
|
| 401 |
+
|
| 402 |
+
const badgeRect = { x: titleCard.x + titleCard.w - 0.48, y: titleCard.y - 0.16, w: 0.48, h: 0.48 };
|
| 403 |
+
addNeoShadowCard(slide, badgeRect, 'D9FF00', { shadowX: 0.05, shadowY: 0.05 });
|
| 404 |
+
slide.addShape('rect', {
|
| 405 |
+
x: badgeRect.x + 0.08,
|
| 406 |
+
y: badgeRect.y + 0.08,
|
| 407 |
+
w: badgeRect.w - 0.16,
|
| 408 |
+
h: badgeRect.h - 0.16,
|
| 409 |
+
fill: { color: '000000' },
|
| 410 |
+
line: { color: '000000', transparency: 100 },
|
| 411 |
+
});
|
| 412 |
+
addTextBox(slide, '★', { x: badgeRect.x + 0.115, y: badgeRect.y + 0.085, w: 0.25, h: 0.24 }, {
|
| 413 |
+
fontFace: 'Arial Black',
|
| 414 |
+
fontSize: 16,
|
| 415 |
+
bold: true,
|
| 416 |
+
color: 'D9FF00',
|
| 417 |
+
align: 'center',
|
| 418 |
+
valign: 'middle',
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
const subtitleCard = withOffset(spec, 'subtitle', { x: 1.52, y: 3.35, w: 7.0, h: 1.02 });
|
| 422 |
+
addNeoShadowCard(slide, subtitleCard, 'FFFFFF', { shadowX: 0.1, shadowY: 0.1 });
|
| 423 |
+
addTextBox(slide, upperText(spec.subtitle), { x: subtitleCard.x + 0.28, y: subtitleCard.y + 0.22, w: subtitleCard.w - 0.56, h: subtitleCard.h - 0.44 }, {
|
| 424 |
+
fontFace: 'Arial',
|
| 425 |
+
fontSize: 15,
|
| 426 |
+
bold: true,
|
| 427 |
+
color: '000000',
|
| 428 |
+
align: 'center',
|
| 429 |
+
valign: 'middle',
|
| 430 |
+
});
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
function addNeoAgenda(slide: PptxSlide, spec: SlideSpec) {
|
| 434 |
+
addNeoBackground(slide);
|
| 435 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'agenda-title', { x: 0.55, y: 0.45, w: 4.2, h: 0.65 }), {
|
| 436 |
+
fontFace: 'Arial Black',
|
| 437 |
+
fontSize: 25,
|
| 438 |
+
bold: true,
|
| 439 |
+
color: '000000',
|
| 440 |
+
});
|
| 441 |
+
|
| 442 |
+
const cards = [
|
| 443 |
+
{ x: 0.55, y: 1.45 },
|
| 444 |
+
{ x: 3.45, y: 1.45 },
|
| 445 |
+
{ x: 6.35, y: 1.45 },
|
| 446 |
+
{ x: 0.55, y: 3.28 },
|
| 447 |
+
{ x: 3.45, y: 3.28 },
|
| 448 |
+
];
|
| 449 |
+
|
| 450 |
+
(spec.items || []).slice(0, 5).forEach((item, index) => {
|
| 451 |
+
const rect = withOffset(spec, `agenda-card-${index}`, { x: cards[index]?.x || 0.55, y: cards[index]?.y || 3.28, w: 2.45, h: 1.45 });
|
| 452 |
+
addRect(slide, rect, 'FFFFFF', '000000', { line: { color: '000000', width: 2.25 } });
|
| 453 |
+
addTextBox(slide, `${String(index + 1).padStart(2, '0')}`, { x: rect.x + 0.16, y: rect.y + 0.12, w: 0.4, h: 0.2 }, {
|
| 454 |
+
fontFace: 'Arial',
|
| 455 |
+
fontSize: 11,
|
| 456 |
+
bold: true,
|
| 457 |
+
color: 'A000A0',
|
| 458 |
+
});
|
| 459 |
+
addTextBox(slide, cleanText(item.text), { x: rect.x + 0.16, y: rect.y + 0.42, w: rect.w - 0.32, h: rect.h - 0.55 }, {
|
| 460 |
+
fontFace: 'Arial Black',
|
| 461 |
+
fontSize: 18,
|
| 462 |
+
bold: true,
|
| 463 |
+
color: '000000',
|
| 464 |
+
valign: 'middle',
|
| 465 |
+
});
|
| 466 |
+
});
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
function addNeoTitleAndText(slide: PptxSlide, spec: SlideSpec) {
|
| 470 |
+
addNeoBackground(slide);
|
| 471 |
+
const titleRect = withOffset(spec, 'title-and-text-title', { x: 2.2, y: 1.0, w: 5.6, h: 0.85 });
|
| 472 |
+
addRect(slide, titleRect, '00FFFF', '000000', { line: { color: '000000', width: 2.25 } });
|
| 473 |
+
addTextBox(slide, cleanText(spec.title), titleRect, {
|
| 474 |
+
fontFace: 'Arial Black',
|
| 475 |
+
fontSize: 26,
|
| 476 |
+
bold: true,
|
| 477 |
+
color: '000000',
|
| 478 |
+
align: 'center',
|
| 479 |
+
});
|
| 480 |
+
|
| 481 |
+
const bodyRect = withOffset(spec, 'title-and-text-body', { x: 1.25, y: 2.2, w: 7.5, h: 1.7 });
|
| 482 |
+
addRect(slide, bodyRect, 'FFFFFF', '000000', { line: { color: '000000', width: 2.25 } });
|
| 483 |
+
addTextBox(slide, bodyToParagraph(spec.body), bodyRect, {
|
| 484 |
+
fontFace: 'Arial',
|
| 485 |
+
fontSize: 18,
|
| 486 |
+
color: '000000',
|
| 487 |
+
align: 'center',
|
| 488 |
+
valign: 'middle',
|
| 489 |
+
});
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
function addNeoThreeColumns(slide: PptxSlide, spec: SlideSpec) {
|
| 493 |
+
addNeoBackground(slide);
|
| 494 |
+
const titleRect = withOffset(spec, 'columns-title', { x: 0.55, y: 0.45, w: 3.8, h: 0.75 });
|
| 495 |
+
addRect(slide, titleRect, 'D9FF00', '000000', { line: { color: '000000', width: 2.25 } });
|
| 496 |
+
addTextBox(slide, cleanText(spec.title), titleRect, {
|
| 497 |
+
fontFace: 'Arial Black',
|
| 498 |
+
fontSize: 22,
|
| 499 |
+
bold: true,
|
| 500 |
+
color: '000000',
|
| 501 |
+
});
|
| 502 |
+
|
| 503 |
+
const columnX = [0.55, 3.35, 6.15];
|
| 504 |
+
(spec.columns || []).slice(0, 3).forEach((column, index) => {
|
| 505 |
+
const rect = withOffset(spec, `column-card-${index}`, { x: columnX[index], y: 1.65, w: 2.55, h: 3.25 });
|
| 506 |
+
addRect(slide, rect, 'FFFFFF', '000000', { line: { color: '000000', width: 2.25 } });
|
| 507 |
+
addRect(slide, { x: rect.x, y: rect.y, w: rect.w, h: 0.65 }, 'D9FF00', '000000', {
|
| 508 |
+
line: { color: '000000', width: 0.75 },
|
| 509 |
+
});
|
| 510 |
+
addTextBox(slide, String(index + 1).padStart(2, '0'), { x: rect.x + 0.15, y: rect.y + 0.12, w: 0.5, h: 0.2 }, {
|
| 511 |
+
fontFace: 'Arial Black',
|
| 512 |
+
fontSize: 14,
|
| 513 |
+
bold: true,
|
| 514 |
+
color: '000000',
|
| 515 |
+
});
|
| 516 |
+
addTextBox(slide, cleanText(column.heading), { x: rect.x + 0.18, y: rect.y + 0.85, w: rect.w - 0.36, h: 0.65 }, {
|
| 517 |
+
fontFace: 'Arial Black',
|
| 518 |
+
fontSize: 16,
|
| 519 |
+
bold: true,
|
| 520 |
+
color: '000000',
|
| 521 |
+
valign: 'top',
|
| 522 |
+
});
|
| 523 |
+
addTextBox(slide, cleanText(column.text), { x: rect.x + 0.18, y: rect.y + 1.65, w: rect.w - 0.36, h: 1.25 }, {
|
| 524 |
+
fontFace: 'Arial',
|
| 525 |
+
fontSize: 11,
|
| 526 |
+
color: '000000',
|
| 527 |
+
valign: 'top',
|
| 528 |
+
});
|
| 529 |
+
});
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
async function addNeoImageAndText(slide: PptxSlide, spec: SlideSpec) {
|
| 533 |
+
addNeoBackground(slide);
|
| 534 |
+
const imageCard = withOffset(spec, 'image-card', { x: 0.6, y: 0.7, w: 4.15, h: 3.45 });
|
| 535 |
+
addRect(slide, imageCard, 'FFD700', '000000', { line: { color: '000000', width: 2.25 } });
|
| 536 |
+
await addImage(slide, spec.imageUrl, { x: imageCard.x + 0.18, y: imageCard.y + 0.18, w: imageCard.w - 0.36, h: 2.5 });
|
| 537 |
+
addTextBox(slide, 'FIG 1. STRUCTURAL HONESTY', { x: imageCard.x + 0.18, y: imageCard.y + 2.82, w: imageCard.w - 0.36, h: 0.32 }, {
|
| 538 |
+
fontFace: 'Arial Black',
|
| 539 |
+
fontSize: 11,
|
| 540 |
+
bold: true,
|
| 541 |
+
color: '000000',
|
| 542 |
+
align: 'center',
|
| 543 |
+
});
|
| 544 |
+
|
| 545 |
+
const textCard = withOffset(spec, 'image-text', { x: 5.3, y: 1.15, w: 4.05, h: 2.65 });
|
| 546 |
+
addRect(slide, textCard, 'FFFFFF', '000000', { line: { color: '000000', width: 2.25 } });
|
| 547 |
+
addTextBox(slide, cleanText(spec.title), { x: textCard.x + 0.2, y: textCard.y + 0.2, w: textCard.w - 0.4, h: 0.8 }, {
|
| 548 |
+
fontFace: 'Arial Black',
|
| 549 |
+
fontSize: 24,
|
| 550 |
+
bold: true,
|
| 551 |
+
color: '000000',
|
| 552 |
+
});
|
| 553 |
+
addTextBox(slide, bodyToParagraph(spec.body), { x: textCard.x + 0.2, y: textCard.y + 1.1, w: textCard.w - 0.4, h: 1.2 }, {
|
| 554 |
+
fontFace: 'Arial',
|
| 555 |
+
fontSize: 12,
|
| 556 |
+
color: '000000',
|
| 557 |
+
valign: 'top',
|
| 558 |
+
});
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
function addNeoReferences(slide: PptxSlide, spec: SlideSpec) {
|
| 562 |
+
addNeoBackground(slide);
|
| 563 |
+
const titleRect = withOffset(spec, 'references-title', { x: 0.55, y: 0.45, w: 4.0, h: 0.65 });
|
| 564 |
+
addRect(slide, titleRect, '000000', '000000');
|
| 565 |
+
addTextBox(slide, cleanText(spec.title), titleRect, {
|
| 566 |
+
fontFace: 'Arial Black',
|
| 567 |
+
fontSize: 18,
|
| 568 |
+
bold: true,
|
| 569 |
+
color: 'FFFFFF',
|
| 570 |
+
});
|
| 571 |
+
|
| 572 |
+
(spec.items || []).slice(0, 6).forEach((item, index) => {
|
| 573 |
+
const row = withOffset(spec, `reference-item-${index}`, { x: 0.7, y: 1.45 + index * 0.62, w: 5.1, h: 0.45 });
|
| 574 |
+
addTextBox(slide, `[${index + 1}]`, { x: row.x, y: row.y, w: 0.4, h: row.h }, {
|
| 575 |
+
fontFace: 'Arial Black',
|
| 576 |
+
fontSize: 12,
|
| 577 |
+
color: 'A000A0',
|
| 578 |
+
bold: true,
|
| 579 |
+
});
|
| 580 |
+
addTextBox(slide, cleanText(item.text), { x: row.x + 0.45, y: row.y, w: row.w - 0.45, h: row.h }, {
|
| 581 |
+
fontFace: 'Arial',
|
| 582 |
+
fontSize: 12,
|
| 583 |
+
color: '000000',
|
| 584 |
+
});
|
| 585 |
+
});
|
| 586 |
+
|
| 587 |
+
const noteRect = withOffset(spec, 'references-note', { x: 6.4, y: 1.55, w: 2.85, h: 2.0 });
|
| 588 |
+
addRect(slide, noteRect, 'FFD700', '000000', { line: { color: '000000', width: 2.25 } });
|
| 589 |
+
addTextBox(slide, 'ADD_NEW_SOURCE', { x: noteRect.x + 0.2, y: noteRect.y + 0.22, w: noteRect.w - 0.4, h: 0.3 }, {
|
| 590 |
+
fontFace: 'Arial Black',
|
| 591 |
+
fontSize: 14,
|
| 592 |
+
color: '000000',
|
| 593 |
+
bold: true,
|
| 594 |
+
});
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
function addNeoThankYou(slide: PptxSlide, spec: SlideSpec) {
|
| 598 |
+
addNeoBackground(slide);
|
| 599 |
+
const labelRect = { x: 1.95, y: 0.7, w: 1.3, h: 0.4 };
|
| 600 |
+
addRect(slide, labelRect, 'A000A0', '000000', { line: { color: '000000', width: 2.25 } });
|
| 601 |
+
addTextBox(slide, 'THE_END', labelRect, {
|
| 602 |
+
fontFace: 'Arial Black',
|
| 603 |
+
fontSize: 12,
|
| 604 |
+
color: 'FFFFFF',
|
| 605 |
+
bold: true,
|
| 606 |
+
align: 'center',
|
| 607 |
+
});
|
| 608 |
+
|
| 609 |
+
const titleRect = withOffset(spec, 'thank-you-title', { x: 2.0, y: 1.05, w: 5.9, h: 2.2 });
|
| 610 |
+
addRect(slide, titleRect, 'D9FF00', '000000', { line: { color: '000000', width: 2.25 } });
|
| 611 |
+
addTextBox(slide, cleanText(spec.title), titleRect, {
|
| 612 |
+
fontFace: 'Arial Black',
|
| 613 |
+
fontSize: 30,
|
| 614 |
+
bold: true,
|
| 615 |
+
color: '000000',
|
| 616 |
+
align: 'center',
|
| 617 |
+
valign: 'middle',
|
| 618 |
+
});
|
| 619 |
+
|
| 620 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'thank-you-subtitle', { x: 2.2, y: 4.05, w: 5.6, h: 0.42 }), {
|
| 621 |
+
fontFace: 'Arial',
|
| 622 |
+
fontSize: 13,
|
| 623 |
+
bold: true,
|
| 624 |
+
color: '000000',
|
| 625 |
+
align: 'center',
|
| 626 |
+
});
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
function addGalerynTitleSubtitle(slide: PptxSlide, spec: SlideSpec) {
|
| 630 |
+
addGalerynBackground(slide);
|
| 631 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title', { x: 1.0, y: 1.1, w: 8.0, h: 1.55 }), {
|
| 632 |
+
fontFace: 'Georgia',
|
| 633 |
+
fontSize: 42,
|
| 634 |
+
bold: true,
|
| 635 |
+
color: '021D30',
|
| 636 |
+
align: 'center',
|
| 637 |
+
valign: 'middle',
|
| 638 |
+
});
|
| 639 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'subtitle', { x: 2.0, y: 3.65, w: 6.0, h: 0.85 }), {
|
| 640 |
+
fontFace: 'Arial',
|
| 641 |
+
fontSize: 12,
|
| 642 |
+
color: '021D30',
|
| 643 |
+
align: 'center',
|
| 644 |
+
valign: 'middle',
|
| 645 |
+
});
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
async function addGalerynAgenda(slide: PptxSlide, spec: SlideSpec) {
|
| 649 |
+
addGalerynBackground(slide);
|
| 650 |
+
const imageRect = withOffset(spec, 'agenda-image', { x: 0, y: 0, w: 1.8, h: PPT_HEIGHT });
|
| 651 |
+
await addImage(slide, spec.imageUrl || 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&q=80&w=1000', imageRect);
|
| 652 |
+
|
| 653 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'agenda-title', { x: 2.45, y: 0.72, w: 5.1, h: 0.75 }), {
|
| 654 |
+
fontFace: 'Georgia',
|
| 655 |
+
fontSize: 26,
|
| 656 |
+
bold: true,
|
| 657 |
+
color: '021D30',
|
| 658 |
+
align: 'center',
|
| 659 |
+
});
|
| 660 |
+
|
| 661 |
+
(spec.items || []).slice(0, 5).forEach((item, index) => {
|
| 662 |
+
const { heading, description } = splitAgendaItem(item.text);
|
| 663 |
+
const row = withOffset(spec, `agenda-item-${index}`, { x: 2.2, y: 1.6 + index * 0.68, w: 5.7, h: 0.55 });
|
| 664 |
+
slide.addShape('line', {
|
| 665 |
+
x: row.x,
|
| 666 |
+
y: row.y + row.h,
|
| 667 |
+
w: row.w,
|
| 668 |
+
h: 0,
|
| 669 |
+
line: { color: 'CBD5D1', width: 1 },
|
| 670 |
+
});
|
| 671 |
+
addTextBox(slide, heading, { x: row.x, y: row.y, w: 3.8, h: 0.24 }, {
|
| 672 |
+
fontFace: 'Arial',
|
| 673 |
+
fontSize: 15,
|
| 674 |
+
bold: true,
|
| 675 |
+
color: '021D30',
|
| 676 |
+
});
|
| 677 |
+
addTextBox(slide, description, { x: row.x, y: row.y + 0.24, w: 3.8, h: 0.22 }, {
|
| 678 |
+
fontFace: 'Arial',
|
| 679 |
+
fontSize: 9,
|
| 680 |
+
color: '021D30',
|
| 681 |
+
transparency: 35,
|
| 682 |
+
});
|
| 683 |
+
addTextBox(slide, String(index + 1).padStart(2, '0'), { x: row.x + 4.55, y: row.y + 0.05, w: 0.4, h: 0.2 }, {
|
| 684 |
+
fontFace: 'Arial',
|
| 685 |
+
fontSize: 14,
|
| 686 |
+
bold: true,
|
| 687 |
+
color: '021D30',
|
| 688 |
+
align: 'right',
|
| 689 |
+
});
|
| 690 |
+
});
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
function addGalerynThreeColumns(slide: PptxSlide, spec: SlideSpec) {
|
| 694 |
+
addGalerynBackground(slide);
|
| 695 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'columns-title', { x: 0.85, y: 0.7, w: 4.1, h: 0.55 }), {
|
| 696 |
+
fontFace: 'Georgia',
|
| 697 |
+
fontSize: 24,
|
| 698 |
+
bold: true,
|
| 699 |
+
color: '021D30',
|
| 700 |
+
});
|
| 701 |
+
addTextBox(slide, '001 // Design', withOffset(spec, 'columns-tag', { x: 7.2, y: 0.78, w: 1.75, h: 0.22 }), {
|
| 702 |
+
fontFace: 'Arial',
|
| 703 |
+
fontSize: 10,
|
| 704 |
+
color: '021D30',
|
| 705 |
+
transparency: 35,
|
| 706 |
+
align: 'right',
|
| 707 |
+
});
|
| 708 |
+
|
| 709 |
+
const columnX = [0.85, 3.45, 6.05];
|
| 710 |
+
(spec.columns || []).slice(0, 3).forEach((column, index) => {
|
| 711 |
+
const rect = withOffset(spec, `column-${index}`, { x: columnX[index], y: 1.85, w: 2.0, h: 2.3 });
|
| 712 |
+
addTextBox(slide, `0${index + 1}`, { x: rect.x, y: rect.y, w: 0.35, h: 0.18 }, {
|
| 713 |
+
fontFace: 'Arial',
|
| 714 |
+
fontSize: 10,
|
| 715 |
+
color: '021D30',
|
| 716 |
+
transparency: 35,
|
| 717 |
+
});
|
| 718 |
+
addTextBox(slide, cleanText(column.heading), { x: rect.x, y: rect.y + 0.35, w: rect.w, h: 0.5 }, {
|
| 719 |
+
fontFace: 'Arial',
|
| 720 |
+
fontSize: 16,
|
| 721 |
+
bold: true,
|
| 722 |
+
color: '021D30',
|
| 723 |
+
});
|
| 724 |
+
addTextBox(slide, cleanText(column.text), { x: rect.x, y: rect.y + 0.95, w: rect.w, h: 0.9 }, {
|
| 725 |
+
fontFace: 'Arial',
|
| 726 |
+
fontSize: 10,
|
| 727 |
+
color: '021D30',
|
| 728 |
+
transparency: 25,
|
| 729 |
+
valign: 'top',
|
| 730 |
+
});
|
| 731 |
+
});
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
function addGalerynTitleAndText(slide: PptxSlide, spec: SlideSpec) {
|
| 735 |
+
addGalerynBackground(slide);
|
| 736 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title-and-text-title', { x: 0.8, y: 1.4, w: 4.5, h: 1.2 }), {
|
| 737 |
+
fontFace: 'Georgia',
|
| 738 |
+
fontSize: 26,
|
| 739 |
+
bold: true,
|
| 740 |
+
color: '021D30',
|
| 741 |
+
valign: 'middle',
|
| 742 |
+
});
|
| 743 |
+
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'title-and-text-body-0', { x: 5.7, y: 2.2, w: 3.2, h: 1.2 }), {
|
| 744 |
+
fontFace: 'Arial',
|
| 745 |
+
fontSize: 12,
|
| 746 |
+
color: '021D30',
|
| 747 |
+
transparency: 20,
|
| 748 |
+
valign: 'middle',
|
| 749 |
+
});
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
async function addGalerynImageAndText(slide: PptxSlide, spec: SlideSpec) {
|
| 753 |
+
addGalerynBackground(slide);
|
| 754 |
+
await addImage(slide, spec.imageUrl, withOffset(spec, 'image-card', { x: 0, y: 0, w: 5, h: PPT_HEIGHT }));
|
| 755 |
+
addTextBox(slide, '001 Stories', { x: 0.72, y: 4.58, w: 1.45, h: 0.22 }, {
|
| 756 |
+
fontFace: 'Arial',
|
| 757 |
+
fontSize: 10,
|
| 758 |
+
color: 'FFFFFF',
|
| 759 |
+
fill: { color: '021D30', transparency: 20 },
|
| 760 |
+
margin: [3, 4, 3, 4],
|
| 761 |
+
});
|
| 762 |
+
|
| 763 |
+
addRect(slide, { x: 5, y: 0, w: 5, h: PPT_HEIGHT }, 'F5F3EE', 'F5F3EE');
|
| 764 |
+
const textRect = withOffset(spec, 'image-text-content', { x: 5.7, y: 1.55, w: 3.4, h: 2.0 });
|
| 765 |
+
addTextBox(slide, cleanText(spec.title), { x: textRect.x, y: textRect.y, w: textRect.w, h: 0.65 }, {
|
| 766 |
+
fontFace: 'Georgia',
|
| 767 |
+
fontSize: 24,
|
| 768 |
+
bold: true,
|
| 769 |
+
color: '021D30',
|
| 770 |
+
});
|
| 771 |
+
addTextBox(slide, bodyToParagraph(spec.body), { x: textRect.x, y: textRect.y + 0.9, w: textRect.w, h: 1.1 }, {
|
| 772 |
+
fontFace: 'Arial',
|
| 773 |
+
fontSize: 12,
|
| 774 |
+
color: '021D30',
|
| 775 |
+
transparency: 20,
|
| 776 |
+
valign: 'top',
|
| 777 |
+
});
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
function addGalerynReferences(slide: PptxSlide, spec: SlideSpec) {
|
| 781 |
+
addGalerynBackground(slide);
|
| 782 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'references-title', { x: 0.95, y: 0.8, w: 3, h: 0.55 }), {
|
| 783 |
+
fontFace: 'Georgia',
|
| 784 |
+
fontSize: 24,
|
| 785 |
+
bold: true,
|
| 786 |
+
color: '021D30',
|
| 787 |
+
});
|
| 788 |
+
|
| 789 |
+
(spec.items || []).slice(0, 6).forEach((item, index) => {
|
| 790 |
+
const row = withOffset(spec, `reference-item-${index}`, { x: 1.0, y: 1.7 + index * 0.62, w: 7.1, h: 0.4 });
|
| 791 |
+
addTextBox(slide, `[${index + 1}]`, { x: row.x, y: row.y, w: 0.35, h: row.h }, {
|
| 792 |
+
fontFace: 'Arial',
|
| 793 |
+
fontSize: 10,
|
| 794 |
+
color: '021D30',
|
| 795 |
+
transparency: 50,
|
| 796 |
+
});
|
| 797 |
+
addTextBox(slide, cleanText(item.text), { x: row.x + 0.45, y: row.y, w: row.w - 0.45, h: row.h }, {
|
| 798 |
+
fontFace: 'Arial',
|
| 799 |
+
fontSize: 12,
|
| 800 |
+
color: '021D30',
|
| 801 |
+
});
|
| 802 |
+
slide.addShape('line', {
|
| 803 |
+
x: row.x + 0.45,
|
| 804 |
+
y: row.y + row.h,
|
| 805 |
+
w: row.w - 0.45,
|
| 806 |
+
h: 0,
|
| 807 |
+
line: { color: 'D6D3D1', width: 0.75 },
|
| 808 |
+
});
|
| 809 |
+
});
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
function addGalerynThankYou(slide: PptxSlide, spec: SlideSpec) {
|
| 813 |
+
slide.background = { color: '021D30' };
|
| 814 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'thank-you-title', { x: 2.0, y: 1.95, w: 6, h: 0.9 }), {
|
| 815 |
+
fontFace: 'Georgia',
|
| 816 |
+
fontSize: 30,
|
| 817 |
+
bold: true,
|
| 818 |
+
color: 'FBF9F4',
|
| 819 |
+
align: 'center',
|
| 820 |
+
valign: 'middle',
|
| 821 |
+
});
|
| 822 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'thank-you-subtitle', { x: 2.5, y: 2.95, w: 5, h: 0.3 }), {
|
| 823 |
+
fontFace: 'Arial',
|
| 824 |
+
fontSize: 12,
|
| 825 |
+
color: 'FBF9F4',
|
| 826 |
+
transparency: 35,
|
| 827 |
+
align: 'center',
|
| 828 |
+
});
|
| 829 |
+
addTextBox(slide, 'GALERYN CO. // 2026', { x: 3.15, y: 4.15, w: 3.7, h: 0.22 }, {
|
| 830 |
+
fontFace: 'Arial',
|
| 831 |
+
fontSize: 9,
|
| 832 |
+
color: 'FBF9F4',
|
| 833 |
+
charSpacing: 2,
|
| 834 |
+
align: 'center',
|
| 835 |
+
});
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
function addNoisyTitleSubtitle(slide: PptxSlide, spec: SlideSpec) {
|
| 839 |
+
addNoisyBackground(slide);
|
| 840 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title', { x: 0.85, y: 1.1, w: 4.8, h: 0.8 }), {
|
| 841 |
+
fontFace: 'Courier New',
|
| 842 |
+
fontSize: 28,
|
| 843 |
+
bold: true,
|
| 844 |
+
color: 'FFFFFF',
|
| 845 |
+
underline: true,
|
| 846 |
+
});
|
| 847 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'subtitle', { x: 0.95, y: 2.15, w: 5.7, h: 1.8 }), {
|
| 848 |
+
fontFace: 'Courier New',
|
| 849 |
+
fontSize: 16,
|
| 850 |
+
color: 'FFFFFF',
|
| 851 |
+
valign: 'top',
|
| 852 |
+
});
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
function addNoisyAgenda(slide: PptxSlide, spec: SlideSpec) {
|
| 856 |
+
addNoisyBackground(slide);
|
| 857 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'agenda-title', { x: 0.85, y: 0.72, w: 2.2, h: 0.6 }), {
|
| 858 |
+
fontFace: 'Courier New',
|
| 859 |
+
fontSize: 24,
|
| 860 |
+
bold: true,
|
| 861 |
+
color: 'FFFFFF',
|
| 862 |
+
underline: true,
|
| 863 |
+
});
|
| 864 |
+
|
| 865 |
+
(spec.items || []).slice(0, 6).forEach((item, index) => {
|
| 866 |
+
const column = index % 3;
|
| 867 |
+
const row = Math.floor(index / 3);
|
| 868 |
+
const rect = withOffset(spec, `agenda-item-${index}`, {
|
| 869 |
+
x: 1.0 + column * 2.8,
|
| 870 |
+
y: 1.65 + row * 2.0,
|
| 871 |
+
w: 2.0,
|
| 872 |
+
h: 1.6,
|
| 873 |
+
});
|
| 874 |
+
addTextBox(slide, String(index + 1).padStart(2, '0'), { x: rect.x, y: rect.y, w: rect.w, h: 0.7 }, {
|
| 875 |
+
fontFace: 'Courier New',
|
| 876 |
+
fontSize: 34,
|
| 877 |
+
bold: true,
|
| 878 |
+
color: 'FFFFFF',
|
| 879 |
+
align: 'center',
|
| 880 |
+
});
|
| 881 |
+
addRect(slide, { x: rect.x + 0.2, y: rect.y + 0.84, w: rect.w - 0.4, h: 0.08 }, 'FF7A59', 'FF7A59');
|
| 882 |
+
addTextBox(slide, cleanText(item.text), { x: rect.x, y: rect.y + 1.0, w: rect.w, h: 0.42 }, {
|
| 883 |
+
fontFace: 'Courier New',
|
| 884 |
+
fontSize: 13,
|
| 885 |
+
color: 'FFFFFF',
|
| 886 |
+
align: 'center',
|
| 887 |
+
valign: 'middle',
|
| 888 |
+
});
|
| 889 |
+
});
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
function addNoisyThreeColumns(slide: PptxSlide, spec: SlideSpec) {
|
| 893 |
+
slide.background = { color: 'FFFFFF' };
|
| 894 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'columns-title', { x: 0.8, y: 0.72, w: 4.8, h: 0.55 }), {
|
| 895 |
+
fontFace: 'Courier New',
|
| 896 |
+
fontSize: 22,
|
| 897 |
+
bold: true,
|
| 898 |
+
color: '547BEE',
|
| 899 |
+
underline: true,
|
| 900 |
+
});
|
| 901 |
+
|
| 902 |
+
const columnX = [0.85, 3.55, 6.25];
|
| 903 |
+
(spec.columns || []).slice(0, 3).forEach((column, index) => {
|
| 904 |
+
const rect = withOffset(spec, `column-${index}`, { x: columnX[index], y: 1.7, w: 2.1, h: 2.4 });
|
| 905 |
+
addTextBox(slide, cleanText(column.heading), { x: rect.x, y: rect.y, w: rect.w, h: 0.48 }, {
|
| 906 |
+
fontFace: 'Courier New',
|
| 907 |
+
fontSize: 16,
|
| 908 |
+
bold: true,
|
| 909 |
+
color: '547BEE',
|
| 910 |
+
});
|
| 911 |
+
addTextBox(slide, cleanText(column.text), { x: rect.x, y: rect.y + 0.62, w: rect.w, h: 1.1 }, {
|
| 912 |
+
fontFace: 'Courier New',
|
| 913 |
+
fontSize: 12,
|
| 914 |
+
color: '1F2937',
|
| 915 |
+
valign: 'top',
|
| 916 |
+
});
|
| 917 |
+
});
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
function addNoisyTitleAndText(slide: PptxSlide, spec: SlideSpec) {
|
| 921 |
+
slide.background = { color: 'FFFFFF' };
|
| 922 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title-and-text-title', { x: 0.85, y: 0.75, w: 4.5, h: 0.55 }), {
|
| 923 |
+
fontFace: 'Courier New',
|
| 924 |
+
fontSize: 22,
|
| 925 |
+
bold: true,
|
| 926 |
+
color: '547BEE',
|
| 927 |
+
underline: true,
|
| 928 |
+
});
|
| 929 |
+
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'title-and-text-body-0', { x: 0.9, y: 1.7, w: 8.1, h: 2.5 }), {
|
| 930 |
+
fontFace: 'Courier New',
|
| 931 |
+
fontSize: 16,
|
| 932 |
+
color: '1F2937',
|
| 933 |
+
valign: 'top',
|
| 934 |
+
});
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
async function addNoisyImageAndText(slide: PptxSlide, spec: SlideSpec) {
|
| 938 |
+
addNoisyBackground(slide, 'F2725C');
|
| 939 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'image-title', { x: 0.8, y: 0.75, w: 4.0, h: 0.55 }), {
|
| 940 |
+
fontFace: 'Courier New',
|
| 941 |
+
fontSize: 22,
|
| 942 |
+
bold: true,
|
| 943 |
+
color: 'FFFFFF',
|
| 944 |
+
underline: true,
|
| 945 |
+
});
|
| 946 |
+
|
| 947 |
+
await addImage(slide, spec.imageUrl, withOffset(spec, 'image-card', { x: 0.9, y: 1.6, w: 4.1, h: 2.7 }));
|
| 948 |
+
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'image-body', { x: 5.45, y: 1.65, w: 3.45, h: 2.25 }), {
|
| 949 |
+
fontFace: 'Courier New',
|
| 950 |
+
fontSize: 14,
|
| 951 |
+
color: 'FFFFFF',
|
| 952 |
+
valign: 'top',
|
| 953 |
+
});
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
function addNoisyReferences(slide: PptxSlide, spec: SlideSpec) {
|
| 957 |
+
slide.background = { color: 'FFFFFF' };
|
| 958 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'references-title', { x: 0.8, y: 0.75, w: 3.5, h: 0.55 }), {
|
| 959 |
+
fontFace: 'Courier New',
|
| 960 |
+
fontSize: 22,
|
| 961 |
+
bold: true,
|
| 962 |
+
color: '547BEE',
|
| 963 |
+
underline: true,
|
| 964 |
+
});
|
| 965 |
+
|
| 966 |
+
(spec.items || []).slice(0, 8).forEach((item, index) => {
|
| 967 |
+
addTextBox(slide, `• ${cleanText(item.text)}`, withOffset(spec, `reference-item-${index}`, { x: 0.95, y: 1.65 + index * 0.45, w: 8.0, h: 0.3 }), {
|
| 968 |
+
fontFace: 'Courier New',
|
| 969 |
+
fontSize: 12,
|
| 970 |
+
color: '1F2937',
|
| 971 |
+
valign: 'middle',
|
| 972 |
+
});
|
| 973 |
+
});
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
function addNoisyThankYou(slide: PptxSlide, spec: SlideSpec) {
|
| 977 |
+
addNoisyBackground(slide);
|
| 978 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'thank-you-title', { x: 2.1, y: 1.95, w: 5.8, h: 0.9 }), {
|
| 979 |
+
fontFace: 'Courier New',
|
| 980 |
+
fontSize: 30,
|
| 981 |
+
bold: true,
|
| 982 |
+
color: 'FFFFFF',
|
| 983 |
+
align: 'center',
|
| 984 |
+
valign: 'middle',
|
| 985 |
+
});
|
| 986 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'thank-you-subtitle', { x: 2.8, y: 3.0, w: 4.4, h: 0.35 }), {
|
| 987 |
+
fontFace: 'Courier New',
|
| 988 |
+
fontSize: 15,
|
| 989 |
+
color: 'FFFFFF',
|
| 990 |
+
align: 'center',
|
| 991 |
+
});
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
async function addTemplateBackground(slide: PptxSlide, backgroundImage: string) {
|
| 995 |
+
const cleanSrc = cleanText(backgroundImage);
|
| 996 |
+
if (!cleanSrc) return;
|
| 997 |
+
|
| 998 |
+
if (cleanSrc.startsWith('data:')) {
|
| 999 |
+
slide.addImage({ data: cleanSrc, x: 0, y: 0, w: PPT_WIDTH, h: PPT_HEIGHT });
|
| 1000 |
+
return;
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
slide.addImage({ path: cleanSrc, x: 0, y: 0, w: PPT_WIDTH, h: PPT_HEIGHT });
|
| 1004 |
+
}
|
| 1005 |
+
|
| 1006 |
+
function addNeoTextOverlay(slide: PptxSlide, spec: SlideSpec) {
|
| 1007 |
+
switch (spec.layout) {
|
| 1008 |
+
case 'title_subtitle':
|
| 1009 |
+
addTextBox(slide, upperText(spec.title), withOffset(spec, 'title', { x: 1.08, y: 1.08, w: 7.15, h: 1.35 }), {
|
| 1010 |
+
fontFace: 'Arial Black',
|
| 1011 |
+
fontSize: 31,
|
| 1012 |
+
bold: true,
|
| 1013 |
+
color: '000000',
|
| 1014 |
+
align: 'center',
|
| 1015 |
+
valign: 'middle',
|
| 1016 |
+
rotate: -1.2,
|
| 1017 |
+
});
|
| 1018 |
+
addTextBox(slide, upperText(spec.subtitle), withOffset(spec, 'subtitle', { x: 1.8, y: 3.58, w: 6.45, h: 0.56 }), {
|
| 1019 |
+
fontFace: 'Arial',
|
| 1020 |
+
fontSize: 14,
|
| 1021 |
+
bold: true,
|
| 1022 |
+
color: '000000',
|
| 1023 |
+
align: 'center',
|
| 1024 |
+
valign: 'middle',
|
| 1025 |
+
});
|
| 1026 |
+
return;
|
| 1027 |
+
case 'agenda':
|
| 1028 |
+
addTextBox(slide, upperText(spec.title), withOffset(spec, 'agenda-title', { x: 0.55, y: 0.45, w: 4.2, h: 0.65 }), {
|
| 1029 |
+
fontFace: 'Arial Black',
|
| 1030 |
+
fontSize: 25,
|
| 1031 |
+
bold: true,
|
| 1032 |
+
color: '000000',
|
| 1033 |
+
});
|
| 1034 |
+
{
|
| 1035 |
+
const cards = [
|
| 1036 |
+
{ x: 0.72, y: 1.98, w: 2.05, h: 0.62 },
|
| 1037 |
+
{ x: 3.62, y: 1.98, w: 2.05, h: 0.62 },
|
| 1038 |
+
{ x: 6.52, y: 1.98, w: 2.05, h: 0.62 },
|
| 1039 |
+
{ x: 0.72, y: 3.82, w: 2.05, h: 0.62 },
|
| 1040 |
+
{ x: 3.62, y: 3.82, w: 2.05, h: 0.62 },
|
| 1041 |
+
];
|
| 1042 |
+
(spec.items || []).slice(0, 5).forEach((item, index) => {
|
| 1043 |
+
addTextBox(slide, upperText(item.text), withOffset(spec, `agenda-card-${index}`, cards[index]), {
|
| 1044 |
+
fontFace: 'Arial Black',
|
| 1045 |
+
fontSize: 17,
|
| 1046 |
+
bold: true,
|
| 1047 |
+
color: '000000',
|
| 1048 |
+
align: 'left',
|
| 1049 |
+
valign: 'top',
|
| 1050 |
+
});
|
| 1051 |
+
});
|
| 1052 |
+
}
|
| 1053 |
+
return;
|
| 1054 |
+
case 'title_and_text':
|
| 1055 |
+
addTextBox(slide, upperText(spec.title), withOffset(spec, 'title-and-text-title', { x: 2.45, y: 1.18, w: 5.1, h: 0.5 }), {
|
| 1056 |
+
fontFace: 'Arial Black',
|
| 1057 |
+
fontSize: 26,
|
| 1058 |
+
bold: true,
|
| 1059 |
+
color: '000000',
|
| 1060 |
+
align: 'center',
|
| 1061 |
+
});
|
| 1062 |
+
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'title-and-text-body', { x: 1.62, y: 2.78, w: 6.75, h: 0.78 }), {
|
| 1063 |
+
fontFace: 'Arial',
|
| 1064 |
+
fontSize: 18,
|
| 1065 |
+
color: '000000',
|
| 1066 |
+
align: 'center',
|
| 1067 |
+
valign: 'middle',
|
| 1068 |
+
});
|
| 1069 |
+
return;
|
| 1070 |
+
case 'three_columns':
|
| 1071 |
+
addTextBox(slide, upperText(spec.title), withOffset(spec, 'columns-title', { x: 0.75, y: 0.65, w: 3.3, h: 0.45 }), {
|
| 1072 |
+
fontFace: 'Arial Black',
|
| 1073 |
+
fontSize: 22,
|
| 1074 |
+
bold: true,
|
| 1075 |
+
color: '000000',
|
| 1076 |
+
});
|
| 1077 |
+
{
|
| 1078 |
+
const columnX = [0.76, 3.56, 6.36];
|
| 1079 |
+
(spec.columns || []).slice(0, 3).forEach((column, index) => {
|
| 1080 |
+
const key = `column-card-${index}`;
|
| 1081 |
+
addTextBox(slide, upperText(column.heading), withOffset(spec, key, { x: columnX[index] + 0.18, y: 2.5, w: 2.0, h: 0.55 }), {
|
| 1082 |
+
fontFace: 'Arial Black',
|
| 1083 |
+
fontSize: 15,
|
| 1084 |
+
bold: true,
|
| 1085 |
+
color: '000000',
|
| 1086 |
+
valign: 'top',
|
| 1087 |
+
});
|
| 1088 |
+
addTextBox(slide, cleanText(column.text), withOffset(spec, key, { x: columnX[index] + 0.18, y: 3.42, w: 2.0, h: 1.0 }), {
|
| 1089 |
+
fontFace: 'Arial',
|
| 1090 |
+
fontSize: 10.5,
|
| 1091 |
+
color: '000000',
|
| 1092 |
+
valign: 'top',
|
| 1093 |
+
});
|
| 1094 |
+
});
|
| 1095 |
+
}
|
| 1096 |
+
return;
|
| 1097 |
+
case 'image_and_text':
|
| 1098 |
+
addTextBox(slide, upperText(spec.title), withOffset(spec, 'image-text', { x: 5.5, y: 1.4, w: 3.55, h: 0.7 }), {
|
| 1099 |
+
fontFace: 'Arial Black',
|
| 1100 |
+
fontSize: 24,
|
| 1101 |
+
bold: true,
|
| 1102 |
+
color: '000000',
|
| 1103 |
+
});
|
| 1104 |
+
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'image-text', { x: 5.5, y: 2.35, w: 3.5, h: 1.15 }), {
|
| 1105 |
+
fontFace: 'Arial',
|
| 1106 |
+
fontSize: 12,
|
| 1107 |
+
color: '000000',
|
| 1108 |
+
valign: 'top',
|
| 1109 |
+
});
|
| 1110 |
+
return;
|
| 1111 |
+
case 'references':
|
| 1112 |
+
addTextBox(slide, upperText(spec.title), withOffset(spec, 'references-title', { x: 0.74, y: 0.56, w: 3.6, h: 0.3 }), {
|
| 1113 |
+
fontFace: 'Arial Black',
|
| 1114 |
+
fontSize: 18,
|
| 1115 |
+
bold: true,
|
| 1116 |
+
color: 'FFFFFF',
|
| 1117 |
+
});
|
| 1118 |
+
(spec.items || []).slice(0, 6).forEach((item, index) => {
|
| 1119 |
+
addTextBox(slide, cleanText(item.text), withOffset(spec, `reference-item-${index}`, { x: 1.15, y: 1.5 + index * 0.62, w: 4.5, h: 0.24 }), {
|
| 1120 |
+
fontFace: 'Arial',
|
| 1121 |
+
fontSize: 12,
|
| 1122 |
+
color: '000000',
|
| 1123 |
+
});
|
| 1124 |
+
});
|
| 1125 |
+
return;
|
| 1126 |
+
case 'thank_you':
|
| 1127 |
+
addTextBox(slide, upperText(spec.title), withOffset(spec, 'thank-you-title', { x: 2.35, y: 1.6, w: 5.15, h: 1.35 }), {
|
| 1128 |
+
fontFace: 'Arial Black',
|
| 1129 |
+
fontSize: 30,
|
| 1130 |
+
bold: true,
|
| 1131 |
+
color: '000000',
|
| 1132 |
+
align: 'center',
|
| 1133 |
+
valign: 'middle',
|
| 1134 |
+
});
|
| 1135 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'thank-you-subtitle', { x: 2.35, y: 4.08, w: 5.5, h: 0.22 }), {
|
| 1136 |
+
fontFace: 'Arial',
|
| 1137 |
+
fontSize: 13,
|
| 1138 |
+
bold: true,
|
| 1139 |
+
color: '000000',
|
| 1140 |
+
align: 'center',
|
| 1141 |
+
});
|
| 1142 |
+
return;
|
| 1143 |
+
}
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
function addGalerynTextOverlay(slide: PptxSlide, spec: SlideSpec) {
|
| 1147 |
+
switch (spec.layout) {
|
| 1148 |
+
case 'title_subtitle':
|
| 1149 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title', { x: 1.15, y: 1.68, w: 7.7, h: 0.86 }), {
|
| 1150 |
+
fontFace: 'Georgia',
|
| 1151 |
+
fontSize: 42,
|
| 1152 |
+
bold: true,
|
| 1153 |
+
color: '021D30',
|
| 1154 |
+
align: 'center',
|
| 1155 |
+
});
|
| 1156 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'subtitle', { x: 2.1, y: 3.72, w: 5.8, h: 0.5 }), {
|
| 1157 |
+
fontFace: 'Arial',
|
| 1158 |
+
fontSize: 11,
|
| 1159 |
+
color: '021D30',
|
| 1160 |
+
align: 'center',
|
| 1161 |
+
valign: 'middle',
|
| 1162 |
+
});
|
| 1163 |
+
return;
|
| 1164 |
+
case 'agenda':
|
| 1165 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'agenda-title', { x: 2.45, y: 0.9, w: 5.0, h: 0.48 }), {
|
| 1166 |
+
fontFace: 'Georgia',
|
| 1167 |
+
fontSize: 25,
|
| 1168 |
+
bold: true,
|
| 1169 |
+
color: '021D30',
|
| 1170 |
+
align: 'center',
|
| 1171 |
+
});
|
| 1172 |
+
(spec.items || []).slice(0, 5).forEach((item, index) => {
|
| 1173 |
+
const parsed = splitAgendaItem(item.text);
|
| 1174 |
+
addTextBox(slide, parsed.heading, withOffset(spec, `agenda-item-${index}`, { x: 2.25, y: 1.78 + index * 0.68, w: 3.6, h: 0.2 }), {
|
| 1175 |
+
fontFace: 'Arial',
|
| 1176 |
+
fontSize: 15,
|
| 1177 |
+
bold: true,
|
| 1178 |
+
color: '021D30',
|
| 1179 |
+
});
|
| 1180 |
+
addTextBox(slide, parsed.description, withOffset(spec, `agenda-item-${index}`, { x: 2.25, y: 2.02 + index * 0.68, w: 3.6, h: 0.18 }), {
|
| 1181 |
+
fontFace: 'Arial',
|
| 1182 |
+
fontSize: 8.5,
|
| 1183 |
+
color: '021D30',
|
| 1184 |
+
transparency: 35,
|
| 1185 |
+
});
|
| 1186 |
+
});
|
| 1187 |
+
return;
|
| 1188 |
+
case 'three_columns':
|
| 1189 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'columns-title', { x: 0.85, y: 0.7, w: 4.1, h: 0.34 }), {
|
| 1190 |
+
fontFace: 'Georgia',
|
| 1191 |
+
fontSize: 24,
|
| 1192 |
+
bold: true,
|
| 1193 |
+
color: '021D30',
|
| 1194 |
+
});
|
| 1195 |
+
{
|
| 1196 |
+
const columnX = [0.85, 3.45, 6.05];
|
| 1197 |
+
(spec.columns || []).slice(0, 3).forEach((column, index) => {
|
| 1198 |
+
addTextBox(slide, cleanText(column.heading), withOffset(spec, `column-${index}`, { x: columnX[index], y: 2.2, w: 2.0, h: 0.32 }), {
|
| 1199 |
+
fontFace: 'Arial',
|
| 1200 |
+
fontSize: 16,
|
| 1201 |
+
bold: true,
|
| 1202 |
+
color: '021D30',
|
| 1203 |
+
});
|
| 1204 |
+
addTextBox(slide, cleanText(column.text), withOffset(spec, `column-${index}`, { x: columnX[index], y: 2.82, w: 2.0, h: 0.62 }), {
|
| 1205 |
+
fontFace: 'Arial',
|
| 1206 |
+
fontSize: 10,
|
| 1207 |
+
color: '021D30',
|
| 1208 |
+
transparency: 25,
|
| 1209 |
+
valign: 'top',
|
| 1210 |
+
});
|
| 1211 |
+
});
|
| 1212 |
+
}
|
| 1213 |
+
return;
|
| 1214 |
+
case 'title_and_text':
|
| 1215 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title-and-text-title', { x: 0.86, y: 1.76, w: 4.1, h: 0.84 }), {
|
| 1216 |
+
fontFace: 'Georgia',
|
| 1217 |
+
fontSize: 26,
|
| 1218 |
+
bold: true,
|
| 1219 |
+
color: '021D30',
|
| 1220 |
+
valign: 'middle',
|
| 1221 |
+
});
|
| 1222 |
+
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'title-and-text-body-0', { x: 5.78, y: 2.58, w: 3.05, h: 0.66 }), {
|
| 1223 |
+
fontFace: 'Arial',
|
| 1224 |
+
fontSize: 12,
|
| 1225 |
+
color: '021D30',
|
| 1226 |
+
transparency: 20,
|
| 1227 |
+
valign: 'middle',
|
| 1228 |
+
});
|
| 1229 |
+
return;
|
| 1230 |
+
case 'image_and_text':
|
| 1231 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'image-text-content', { x: 5.72, y: 1.68, w: 3.3, h: 0.44 }), {
|
| 1232 |
+
fontFace: 'Georgia',
|
| 1233 |
+
fontSize: 24,
|
| 1234 |
+
bold: true,
|
| 1235 |
+
color: '021D30',
|
| 1236 |
+
});
|
| 1237 |
+
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'image-text-content', { x: 5.72, y: 2.55, w: 3.2, h: 0.85 }), {
|
| 1238 |
+
fontFace: 'Arial',
|
| 1239 |
+
fontSize: 12,
|
| 1240 |
+
color: '021D30',
|
| 1241 |
+
transparency: 20,
|
| 1242 |
+
valign: 'top',
|
| 1243 |
+
});
|
| 1244 |
+
return;
|
| 1245 |
+
case 'references':
|
| 1246 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'references-title', { x: 0.95, y: 0.82, w: 3.0, h: 0.34 }), {
|
| 1247 |
+
fontFace: 'Georgia',
|
| 1248 |
+
fontSize: 24,
|
| 1249 |
+
bold: true,
|
| 1250 |
+
color: '021D30',
|
| 1251 |
+
});
|
| 1252 |
+
(spec.items || []).slice(0, 6).forEach((item, index) => {
|
| 1253 |
+
addTextBox(slide, cleanText(item.text), withOffset(spec, `reference-item-${index}`, { x: 1.42, y: 1.7 + index * 0.62, w: 6.55, h: 0.2 }), {
|
| 1254 |
+
fontFace: 'Arial',
|
| 1255 |
+
fontSize: 12,
|
| 1256 |
+
color: '021D30',
|
| 1257 |
+
});
|
| 1258 |
+
});
|
| 1259 |
+
return;
|
| 1260 |
+
case 'thank_you':
|
| 1261 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'thank-you-title', { x: 2.0, y: 2.08, w: 6.0, h: 0.45 }), {
|
| 1262 |
+
fontFace: 'Georgia',
|
| 1263 |
+
fontSize: 30,
|
| 1264 |
+
bold: true,
|
| 1265 |
+
color: 'FBF9F4',
|
| 1266 |
+
align: 'center',
|
| 1267 |
+
});
|
| 1268 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'thank-you-subtitle', { x: 2.5, y: 3.05, w: 5.0, h: 0.16 }), {
|
| 1269 |
+
fontFace: 'Arial',
|
| 1270 |
+
fontSize: 12,
|
| 1271 |
+
color: 'FBF9F4',
|
| 1272 |
+
transparency: 35,
|
| 1273 |
+
align: 'center',
|
| 1274 |
+
});
|
| 1275 |
+
return;
|
| 1276 |
+
}
|
| 1277 |
+
}
|
| 1278 |
+
|
| 1279 |
+
function addNoisyTextOverlay(slide: PptxSlide, spec: SlideSpec) {
|
| 1280 |
+
switch (spec.layout) {
|
| 1281 |
+
case 'title_subtitle':
|
| 1282 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title', { x: 0.8, y: 1.2, w: 4.4, h: 0.38 }), {
|
| 1283 |
+
fontFace: 'Courier New',
|
| 1284 |
+
fontSize: 28,
|
| 1285 |
+
bold: true,
|
| 1286 |
+
color: 'FFFFFF',
|
| 1287 |
+
underline: true,
|
| 1288 |
+
});
|
| 1289 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'subtitle', { x: 0.82, y: 2.0, w: 5.8, h: 1.5 }), {
|
| 1290 |
+
fontFace: 'Courier New',
|
| 1291 |
+
fontSize: 16,
|
| 1292 |
+
color: 'FFFFFF',
|
| 1293 |
+
valign: 'top',
|
| 1294 |
+
});
|
| 1295 |
+
return;
|
| 1296 |
+
case 'agenda':
|
| 1297 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'agenda-title', { x: 0.82, y: 0.82, w: 2.0, h: 0.34 }), {
|
| 1298 |
+
fontFace: 'Courier New',
|
| 1299 |
+
fontSize: 24,
|
| 1300 |
+
bold: true,
|
| 1301 |
+
color: 'FFFFFF',
|
| 1302 |
+
underline: true,
|
| 1303 |
+
});
|
| 1304 |
+
(spec.items || []).slice(0, 6).forEach((item, index) => {
|
| 1305 |
+
const column = index % 3;
|
| 1306 |
+
const row = Math.floor(index / 3);
|
| 1307 |
+
addTextBox(slide, cleanText(item.text), withOffset(spec, `agenda-item-${index}`, {
|
| 1308 |
+
x: 0.98 + column * 2.8,
|
| 1309 |
+
y: 2.68 + row * 2.0,
|
| 1310 |
+
w: 2.02,
|
| 1311 |
+
h: 0.26,
|
| 1312 |
+
}), {
|
| 1313 |
+
fontFace: 'Courier New',
|
| 1314 |
+
fontSize: 13,
|
| 1315 |
+
color: 'FFFFFF',
|
| 1316 |
+
align: 'center',
|
| 1317 |
+
valign: 'middle',
|
| 1318 |
+
});
|
| 1319 |
+
});
|
| 1320 |
+
return;
|
| 1321 |
+
case 'three_columns':
|
| 1322 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'columns-title', { x: 0.8, y: 0.82, w: 4.6, h: 0.34 }), {
|
| 1323 |
+
fontFace: 'Courier New',
|
| 1324 |
+
fontSize: 22,
|
| 1325 |
+
bold: true,
|
| 1326 |
+
color: '547BEE',
|
| 1327 |
+
underline: true,
|
| 1328 |
+
});
|
| 1329 |
+
{
|
| 1330 |
+
const columnX = [0.85, 3.55, 6.25];
|
| 1331 |
+
(spec.columns || []).slice(0, 3).forEach((column, index) => {
|
| 1332 |
+
addTextBox(slide, cleanText(column.heading), withOffset(spec, `column-${index}`, { x: columnX[index], y: 1.72, w: 2.1, h: 0.28 }), {
|
| 1333 |
+
fontFace: 'Courier New',
|
| 1334 |
+
fontSize: 16,
|
| 1335 |
+
bold: true,
|
| 1336 |
+
color: '547BEE',
|
| 1337 |
+
});
|
| 1338 |
+
addTextBox(slide, cleanText(column.text), withOffset(spec, `column-${index}`, { x: columnX[index], y: 2.34, w: 2.1, h: 0.95 }), {
|
| 1339 |
+
fontFace: 'Courier New',
|
| 1340 |
+
fontSize: 12,
|
| 1341 |
+
color: '1F2937',
|
| 1342 |
+
valign: 'top',
|
| 1343 |
+
});
|
| 1344 |
+
});
|
| 1345 |
+
}
|
| 1346 |
+
return;
|
| 1347 |
+
case 'title_and_text':
|
| 1348 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title-and-text-title', { x: 0.82, y: 0.82, w: 4.5, h: 0.34 }), {
|
| 1349 |
+
fontFace: 'Courier New',
|
| 1350 |
+
fontSize: 22,
|
| 1351 |
+
bold: true,
|
| 1352 |
+
color: '547BEE',
|
| 1353 |
+
underline: true,
|
| 1354 |
+
});
|
| 1355 |
+
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'title-and-text-body-0', { x: 0.92, y: 1.76, w: 8.0, h: 1.9 }), {
|
| 1356 |
+
fontFace: 'Courier New',
|
| 1357 |
+
fontSize: 16,
|
| 1358 |
+
color: '1F2937',
|
| 1359 |
+
valign: 'top',
|
| 1360 |
+
});
|
| 1361 |
+
return;
|
| 1362 |
+
case 'image_and_text':
|
| 1363 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'image-title', { x: 0.82, y: 0.82, w: 4.0, h: 0.34 }), {
|
| 1364 |
+
fontFace: 'Courier New',
|
| 1365 |
+
fontSize: 22,
|
| 1366 |
+
bold: true,
|
| 1367 |
+
color: 'FFFFFF',
|
| 1368 |
+
underline: true,
|
| 1369 |
+
});
|
| 1370 |
+
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'image-body', { x: 5.48, y: 1.72, w: 3.3, h: 1.9 }), {
|
| 1371 |
+
fontFace: 'Courier New',
|
| 1372 |
+
fontSize: 14,
|
| 1373 |
+
color: 'FFFFFF',
|
| 1374 |
+
valign: 'top',
|
| 1375 |
+
});
|
| 1376 |
+
return;
|
| 1377 |
+
case 'references':
|
| 1378 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'references-title', { x: 0.82, y: 0.82, w: 3.4, h: 0.34 }), {
|
| 1379 |
+
fontFace: 'Courier New',
|
| 1380 |
+
fontSize: 22,
|
| 1381 |
+
bold: true,
|
| 1382 |
+
color: '547BEE',
|
| 1383 |
+
underline: true,
|
| 1384 |
+
});
|
| 1385 |
+
(spec.items || []).slice(0, 8).forEach((item, index) => {
|
| 1386 |
+
addTextBox(slide, cleanText(item.text), withOffset(spec, `reference-item-${index}`, { x: 1.12, y: 1.68 + index * 0.45, w: 7.65, h: 0.2 }), {
|
| 1387 |
+
fontFace: 'Courier New',
|
| 1388 |
+
fontSize: 12,
|
| 1389 |
+
color: '1F2937',
|
| 1390 |
+
valign: 'middle',
|
| 1391 |
+
});
|
| 1392 |
+
});
|
| 1393 |
+
return;
|
| 1394 |
+
case 'thank_you':
|
| 1395 |
+
addTextBox(slide, cleanText(spec.title), withOffset(spec, 'thank-you-title', { x: 2.1, y: 2.18, w: 5.8, h: 0.45 }), {
|
| 1396 |
+
fontFace: 'Courier New',
|
| 1397 |
+
fontSize: 30,
|
| 1398 |
+
bold: true,
|
| 1399 |
+
color: 'FFFFFF',
|
| 1400 |
+
align: 'center',
|
| 1401 |
+
});
|
| 1402 |
+
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'thank-you-subtitle', { x: 2.8, y: 3.06, w: 4.4, h: 0.22 }), {
|
| 1403 |
+
fontFace: 'Courier New',
|
| 1404 |
+
fontSize: 15,
|
| 1405 |
+
color: 'FFFFFF',
|
| 1406 |
+
align: 'center',
|
| 1407 |
+
});
|
| 1408 |
+
return;
|
| 1409 |
+
}
|
| 1410 |
+
}
|
| 1411 |
+
|
| 1412 |
+
async function addTemplateSlide(slide: PptxSlide, spec: SlideSpec) {
|
| 1413 |
+
const template = getTemplateById(spec.templateId);
|
| 1414 |
+
if (!template) {
|
| 1415 |
+
slide.background = { color: 'FFFFFF' };
|
| 1416 |
+
addTextBox(slide, cleanText(spec.title) || 'Slide', { x: 1, y: 1, w: 8, h: 1 }, {
|
| 1417 |
+
fontFace: 'Arial',
|
| 1418 |
+
fontSize: 28,
|
| 1419 |
+
color: '000000',
|
| 1420 |
+
});
|
| 1421 |
+
return;
|
| 1422 |
+
}
|
| 1423 |
+
|
| 1424 |
+
if (spec.templateId === 'neobrutalism') {
|
| 1425 |
+
switch (spec.layout) {
|
| 1426 |
+
case 'title_subtitle':
|
| 1427 |
+
addNeoTitleSubtitle(slide, spec);
|
| 1428 |
+
return;
|
| 1429 |
+
case 'agenda':
|
| 1430 |
+
addNeoAgenda(slide, spec);
|
| 1431 |
+
return;
|
| 1432 |
+
case 'title_and_text':
|
| 1433 |
+
addNeoTitleAndText(slide, spec);
|
| 1434 |
+
return;
|
| 1435 |
+
case 'three_columns':
|
| 1436 |
+
addNeoThreeColumns(slide, spec);
|
| 1437 |
+
return;
|
| 1438 |
+
case 'image_and_text':
|
| 1439 |
+
await addNeoImageAndText(slide, spec);
|
| 1440 |
+
return;
|
| 1441 |
+
case 'references':
|
| 1442 |
+
addNeoReferences(slide, spec);
|
| 1443 |
+
return;
|
| 1444 |
+
case 'thank_you':
|
| 1445 |
+
addNeoThankYou(slide, spec);
|
| 1446 |
+
return;
|
| 1447 |
+
}
|
| 1448 |
+
}
|
| 1449 |
+
|
| 1450 |
+
if (spec.templateId === 'galeryn') {
|
| 1451 |
+
switch (spec.layout) {
|
| 1452 |
+
case 'title_subtitle':
|
| 1453 |
+
addGalerynTitleSubtitle(slide, spec);
|
| 1454 |
+
return;
|
| 1455 |
+
case 'agenda':
|
| 1456 |
+
await addGalerynAgenda(slide, spec);
|
| 1457 |
+
return;
|
| 1458 |
+
case 'title_and_text':
|
| 1459 |
+
addGalerynTitleAndText(slide, spec);
|
| 1460 |
+
return;
|
| 1461 |
+
case 'three_columns':
|
| 1462 |
+
addGalerynThreeColumns(slide, spec);
|
| 1463 |
+
return;
|
| 1464 |
+
case 'image_and_text':
|
| 1465 |
+
await addGalerynImageAndText(slide, spec);
|
| 1466 |
+
return;
|
| 1467 |
+
case 'references':
|
| 1468 |
+
addGalerynReferences(slide, spec);
|
| 1469 |
+
return;
|
| 1470 |
+
case 'thank_you':
|
| 1471 |
+
addGalerynThankYou(slide, spec);
|
| 1472 |
+
return;
|
| 1473 |
+
}
|
| 1474 |
+
}
|
| 1475 |
+
|
| 1476 |
+
switch (spec.layout) {
|
| 1477 |
+
case 'title_subtitle':
|
| 1478 |
+
addNoisyTitleSubtitle(slide, spec);
|
| 1479 |
+
return;
|
| 1480 |
+
case 'agenda':
|
| 1481 |
+
addNoisyAgenda(slide, spec);
|
| 1482 |
+
return;
|
| 1483 |
+
case 'title_and_text':
|
| 1484 |
+
addNoisyTitleAndText(slide, spec);
|
| 1485 |
+
return;
|
| 1486 |
+
case 'three_columns':
|
| 1487 |
+
addNoisyThreeColumns(slide, spec);
|
| 1488 |
+
return;
|
| 1489 |
+
case 'image_and_text':
|
| 1490 |
+
await addNoisyImageAndText(slide, spec);
|
| 1491 |
+
return;
|
| 1492 |
+
case 'references':
|
| 1493 |
+
addNoisyReferences(slide, spec);
|
| 1494 |
+
return;
|
| 1495 |
+
case 'thank_you':
|
| 1496 |
+
addNoisyThankYou(slide, spec);
|
| 1497 |
+
return;
|
| 1498 |
+
}
|
| 1499 |
+
}
|
| 1500 |
+
|
| 1501 |
+
function addTemplateTextOverlay(slide: PptxSlide, spec: SlideSpec) {
|
| 1502 |
+
if (spec.templateId === 'neobrutalism') {
|
| 1503 |
+
addNeoTextOverlay(slide, spec);
|
| 1504 |
+
return;
|
| 1505 |
+
}
|
| 1506 |
+
|
| 1507 |
+
if (spec.templateId === 'galeryn') {
|
| 1508 |
+
addGalerynTextOverlay(slide, spec);
|
| 1509 |
+
return;
|
| 1510 |
+
}
|
| 1511 |
+
|
| 1512 |
+
addNoisyTextOverlay(slide, spec);
|
| 1513 |
+
}
|
| 1514 |
+
|
| 1515 |
+
export async function exportEditablePptx({
|
| 1516 |
+
slides,
|
| 1517 |
+
slideSpecs,
|
| 1518 |
+
currentTheme,
|
| 1519 |
+
presentationTitle,
|
| 1520 |
+
}: EditablePptxExportParams) {
|
| 1521 |
+
const pptx = new PptxGenJS();
|
| 1522 |
+
pptx.defineLayout({ name: 'LAYOUT_16x9', width: PPT_WIDTH, height: PPT_HEIGHT });
|
| 1523 |
+
pptx.layout = 'LAYOUT_16x9';
|
| 1524 |
+
pptx.author = 'AI PowerPoint Generator';
|
| 1525 |
+
pptx.company = 'AI Generated';
|
| 1526 |
+
pptx.subject = 'Editable presentation export';
|
| 1527 |
+
pptx.title = presentationTitle || 'Presentation';
|
| 1528 |
+
|
| 1529 |
+
if (TEMPLATE_THEMES.has(currentTheme) && slideSpecs.length > 0) {
|
| 1530 |
+
for (const spec of slideSpecs) {
|
| 1531 |
+
const slide = pptx.addSlide();
|
| 1532 |
+
await addTemplateSlide(slide, spec);
|
| 1533 |
+
}
|
| 1534 |
+
} else {
|
| 1535 |
+
for (const slideModel of slides) {
|
| 1536 |
+
const slide = pptx.addSlide();
|
| 1537 |
+
await addLegacySlide(slide, slideModel, currentTheme);
|
| 1538 |
+
}
|
| 1539 |
+
}
|
| 1540 |
+
|
| 1541 |
+
await pptx.writeFile({ fileName: `${sanitizeFileName(presentationTitle)}.pptx` });
|
| 1542 |
+
}
|
lib/hf-client.ts
CHANGED
|
@@ -15,6 +15,23 @@ export interface ConversationalModels {
|
|
| 15 |
[key: string]: FireworksModel;
|
| 16 |
}
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const conversationalModels: ConversationalModels = {
|
| 19 |
"meta-llama/Llama-3.3-70B-Instruct:together": {
|
| 20 |
"_id": "llama-33-70b-instruct",
|
|
@@ -54,12 +71,10 @@ export class HFClient {
|
|
| 54 |
|
| 55 |
async generateSlideContent(prompt: string, model?: string): Promise<string> {
|
| 56 |
const activeModel = model || this.defaultModel;
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
// Removed auto-correct logic; allowing Llama-3.3-70B-Instruct to pass through natively
|
| 63 |
|
| 64 |
try {
|
| 65 |
const completion = await this.client.chatCompletion({
|
|
@@ -77,8 +92,19 @@ export class HFClient {
|
|
| 77 |
|
| 78 |
return completion.choices[0]?.message?.content || "";
|
| 79 |
} catch (error) {
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
}
|
| 84 |
|
|
|
|
| 15 |
[key: string]: FireworksModel;
|
| 16 |
}
|
| 17 |
|
| 18 |
+
export class HFGenerationError extends Error {
|
| 19 |
+
status?: number;
|
| 20 |
+
provider?: string;
|
| 21 |
+
model?: string;
|
| 22 |
+
|
| 23 |
+
constructor(message: string, options?: { status?: number; provider?: string; model?: string; cause?: unknown }) {
|
| 24 |
+
super(message);
|
| 25 |
+
this.name = 'HFGenerationError';
|
| 26 |
+
this.status = options?.status;
|
| 27 |
+
this.provider = options?.provider;
|
| 28 |
+
this.model = options?.model;
|
| 29 |
+
if (options?.cause !== undefined) {
|
| 30 |
+
(this as Error & { cause?: unknown }).cause = options.cause;
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
const conversationalModels: ConversationalModels = {
|
| 36 |
"meta-llama/Llama-3.3-70B-Instruct:together": {
|
| 37 |
"_id": "llama-33-70b-instruct",
|
|
|
|
| 71 |
|
| 72 |
async generateSlideContent(prompt: string, model?: string): Promise<string> {
|
| 73 |
const activeModel = model || this.defaultModel;
|
| 74 |
+
const colonIndex = activeModel.indexOf(":", activeModel.indexOf("/") + 1);
|
| 75 |
+
const hasEncodedProvider = colonIndex !== -1;
|
| 76 |
+
const modelId = hasEncodedProvider ? activeModel.substring(0, colonIndex) : activeModel;
|
| 77 |
+
const provider = hasEncodedProvider ? activeModel.substring(colonIndex + 1) : "fireworks-ai";
|
|
|
|
|
|
|
| 78 |
|
| 79 |
try {
|
| 80 |
const completion = await this.client.chatCompletion({
|
|
|
|
| 92 |
|
| 93 |
return completion.choices[0]?.message?.content || "";
|
| 94 |
} catch (error) {
|
| 95 |
+
const status = typeof error === 'object' && error !== null
|
| 96 |
+
? (error as { httpResponse?: { status?: number } }).httpResponse?.status
|
| 97 |
+
: undefined;
|
| 98 |
+
const message = status
|
| 99 |
+
? `Hugging Face provider request failed with status ${status}`
|
| 100 |
+
: 'Hugging Face provider request failed';
|
| 101 |
+
|
| 102 |
+
throw new HFGenerationError(message, {
|
| 103 |
+
status,
|
| 104 |
+
provider,
|
| 105 |
+
model: modelId,
|
| 106 |
+
cause: error,
|
| 107 |
+
});
|
| 108 |
}
|
| 109 |
}
|
| 110 |
|
lib/imageService.ts
CHANGED
|
@@ -3,12 +3,14 @@
|
|
| 3 |
* Handles fetching images from Unsplash API for slide generation
|
| 4 |
*/
|
| 5 |
|
|
|
|
|
|
|
| 6 |
interface UnsplashImage {
|
| 7 |
id: string;
|
| 8 |
urls: {
|
| 9 |
regular: string;
|
| 10 |
small: string;
|
| 11 |
-
|
| 12 |
};
|
| 13 |
alt_description: string | null;
|
| 14 |
user: {
|
|
@@ -40,26 +42,10 @@ export async function fetchImageForSlide(keywords: string): Promise<ImageSearchR
|
|
| 40 |
}
|
| 41 |
|
| 42 |
try {
|
| 43 |
-
|
| 44 |
-
const response = await fetch(
|
| 45 |
-
`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/search-images?query=${encodeURIComponent(keywords)}&per_page=1`,
|
| 46 |
-
{
|
| 47 |
-
method: 'GET',
|
| 48 |
-
headers: {
|
| 49 |
-
'Content-Type': 'application/json',
|
| 50 |
-
},
|
| 51 |
-
}
|
| 52 |
-
);
|
| 53 |
-
|
| 54 |
-
if (!response.ok) {
|
| 55 |
-
console.warn('Image search failed, using fallback');
|
| 56 |
-
return getFallbackImage();
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
const data = await response.json();
|
| 60 |
|
| 61 |
-
if (
|
| 62 |
-
const image: UnsplashImage =
|
| 63 |
return {
|
| 64 |
imageUrl: image.urls.regular,
|
| 65 |
attribution: {
|
|
|
|
| 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: {
|
|
|
|
| 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: {
|
lib/orchestrator.ts
CHANGED
|
@@ -74,6 +74,7 @@ export interface SlideJSON {
|
|
| 74 |
title?: string;
|
| 75 |
subtitle?: string;
|
| 76 |
body?: Array<{ heading?: string; text: string }>;
|
|
|
|
| 77 |
chart?: {
|
| 78 |
type: 'bar' | 'line' | 'pie';
|
| 79 |
data: Record<string, unknown>;
|
|
@@ -177,6 +178,14 @@ export async function generatePresentation(req: GenerationRequest): Promise<Pres
|
|
| 177 |
title: slide.title,
|
| 178 |
subtitle: slide.subtitle,
|
| 179 |
body: slide.body,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
chart: slide.chart,
|
| 181 |
images: slide.images,
|
| 182 |
imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
|
|
|
|
| 74 |
title?: string;
|
| 75 |
subtitle?: string;
|
| 76 |
body?: Array<{ heading?: string; text: string }>;
|
| 77 |
+
columns?: Array<{ heading: string; text: string }>;
|
| 78 |
chart?: {
|
| 79 |
type: 'bar' | 'line' | 'pie';
|
| 80 |
data: Record<string, unknown>;
|
|
|
|
| 178 |
title: slide.title,
|
| 179 |
subtitle: slide.subtitle,
|
| 180 |
body: slide.body,
|
| 181 |
+
columns: Array.isArray(slide.columns)
|
| 182 |
+
? slide.columns
|
| 183 |
+
.filter((column: any) => column && typeof column === 'object')
|
| 184 |
+
.map((column: any, columnIndex: number) => ({
|
| 185 |
+
heading: String(column.heading || `Column ${columnIndex + 1}`),
|
| 186 |
+
text: String(column.text || ''),
|
| 187 |
+
}))
|
| 188 |
+
: undefined,
|
| 189 |
chart: slide.chart,
|
| 190 |
images: slide.images,
|
| 191 |
imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
|
lib/slide-prompt.ts
CHANGED
|
@@ -16,7 +16,8 @@ export interface GeneratedSlideOutput {
|
|
| 16 |
export interface GeneratedSlide {
|
| 17 |
title: string;
|
| 18 |
subtitle?: string;
|
| 19 |
-
content?: string[];
|
|
|
|
| 20 |
body?: Array<{ text: string }>;
|
| 21 |
layout: LayoutType;
|
| 22 |
imageKeyword?: string;
|
|
@@ -33,11 +34,11 @@ export function buildSlidePrompt(topic: string): string {
|
|
| 33 |
INSTRUCTIONS:
|
| 34 |
1. Generate 8-10 slides with detailed, topic-specific content
|
| 35 |
2. The first slide title should relate to the topic (NOT "Reuben AI")
|
| 36 |
-
3. All content must be specific
|
| 37 |
-
4.
|
| 38 |
5. Generate relevant imageKeyword for Unsplash (2-4 descriptive terms)
|
| 39 |
|
| 40 |
-
OUTPUT FORMAT
|
| 41 |
{
|
| 42 |
"presentationName": "A short name for the presentation file",
|
| 43 |
"slides": [
|
|
@@ -55,22 +56,26 @@ OUTPUT FORMAT — Return ONLY valid JSON matching this exact structure:
|
|
| 55 |
},
|
| 56 |
{
|
| 57 |
"title": "Key Point Title",
|
| 58 |
-
"content":
|
| 59 |
"layout": "title_and_text",
|
| 60 |
"imageKeyword": ""
|
| 61 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
{
|
| 63 |
"title": "Visual Topic Title",
|
| 64 |
-
"content":
|
| 65 |
"layout": "image_and_text",
|
| 66 |
"imageKeyword": "specific relevant search terms for Unsplash"
|
| 67 |
},
|
| 68 |
-
{
|
| 69 |
-
"title": "References",
|
| 70 |
-
"content": ["Source Name - Description 2024", "Another Source - Relevant Publication"],
|
| 71 |
-
"layout": "references",
|
| 72 |
-
"imageKeyword": ""
|
| 73 |
-
},
|
| 74 |
{
|
| 75 |
"title": "Thank You",
|
| 76 |
"subtitle": "Contact information or closing message",
|
|
@@ -83,17 +88,16 @@ OUTPUT FORMAT — Return ONLY valid JSON matching this exact structure:
|
|
| 83 |
SLIDE ORDER RULES:
|
| 84 |
- Slide 1: MUST be "title_subtitle" layout
|
| 85 |
- Slide 2: MUST be "agenda" layout
|
| 86 |
-
- Slides 3-7/8: Mix of "title_and_text" and "image_and_text" (model decides distribution)
|
| 87 |
-
- Second-to-last slide: "references" (only if relevant to topic)
|
| 88 |
- Last slide: MUST be "thank_you" layout
|
| 89 |
|
| 90 |
LAYOUT VALUES (use EXACTLY these strings):
|
| 91 |
-
- "title_subtitle"
|
| 92 |
-
- "agenda"
|
| 93 |
-
- "title_and_text"
|
| 94 |
-
- "
|
| 95 |
-
- "
|
| 96 |
-
- "thank_you"
|
| 97 |
|
| 98 |
IMAGE KEYWORD RULES:
|
| 99 |
- For "image_and_text" slides: ALWAYS provide 2-4 specific search terms
|
|
@@ -101,6 +105,13 @@ IMAGE KEYWORD RULES:
|
|
| 101 |
- Examples: "modern office workspace", "artificial intelligence circuit", "sustainable green energy"
|
| 102 |
- Leave empty ("") for all other layout types
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
VALIDATION:
|
| 105 |
- Output MUST be valid JSON parseable by JSON.parse
|
| 106 |
- NO markdown formatting, NO code blocks, NO explanatory text
|
|
@@ -116,6 +127,7 @@ export const VALID_LAYOUTS: LayoutType[] = [
|
|
| 116 |
'title_subtitle',
|
| 117 |
'agenda',
|
| 118 |
'title_and_text',
|
|
|
|
| 119 |
'image_and_text',
|
| 120 |
'references',
|
| 121 |
'thank_you',
|
|
@@ -123,29 +135,30 @@ export const VALID_LAYOUTS: LayoutType[] = [
|
|
| 123 |
|
| 124 |
/** Normalize a layout string from AI output to a valid LayoutType */
|
| 125 |
export function normalizeLayout(layout: string, index: number, totalSlides: number): LayoutType {
|
| 126 |
-
// Direct match
|
| 127 |
if (VALID_LAYOUTS.includes(layout as LayoutType)) {
|
| 128 |
return layout as LayoutType;
|
| 129 |
}
|
| 130 |
|
| 131 |
-
// Map old layout names to new ones
|
| 132 |
const layoutMap: Record<string, LayoutType> = {
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
'content-image': 'image_and_text',
|
| 137 |
-
|
| 138 |
'two-column': 'title_and_text',
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
};
|
| 143 |
|
| 144 |
if (layoutMap[layout]) {
|
| 145 |
return layoutMap[layout];
|
| 146 |
}
|
| 147 |
|
| 148 |
-
// Fallback based on position
|
| 149 |
if (index === 0) return 'title_subtitle';
|
| 150 |
if (index === 1) return 'agenda';
|
| 151 |
if (index === totalSlides - 1) return 'thank_you';
|
|
|
|
| 16 |
export interface GeneratedSlide {
|
| 17 |
title: string;
|
| 18 |
subtitle?: string;
|
| 19 |
+
content?: string | string[];
|
| 20 |
+
columns?: Array<{ heading: string; text: string }>;
|
| 21 |
body?: Array<{ text: string }>;
|
| 22 |
layout: LayoutType;
|
| 23 |
imageKeyword?: string;
|
|
|
|
| 34 |
INSTRUCTIONS:
|
| 35 |
1. Generate 8-10 slides with detailed, topic-specific content
|
| 36 |
2. The first slide title should relate to the topic (NOT "Reuben AI")
|
| 37 |
+
3. All content must be specific and avoid generic placeholders
|
| 38 |
+
4. For "title_and_text" and "image_and_text" slides, write one short paragraph of 30-55 words
|
| 39 |
5. Generate relevant imageKeyword for Unsplash (2-4 descriptive terms)
|
| 40 |
|
| 41 |
+
OUTPUT FORMAT - Return ONLY valid JSON matching this exact structure:
|
| 42 |
{
|
| 43 |
"presentationName": "A short name for the presentation file",
|
| 44 |
"slides": [
|
|
|
|
| 56 |
},
|
| 57 |
{
|
| 58 |
"title": "Key Point Title",
|
| 59 |
+
"content": "A single short paragraph with concrete, topic-specific information and a clear takeaway for the audience.",
|
| 60 |
"layout": "title_and_text",
|
| 61 |
"imageKeyword": ""
|
| 62 |
},
|
| 63 |
+
{
|
| 64 |
+
"title": "Three Key Pillars",
|
| 65 |
+
"columns": [
|
| 66 |
+
{ "heading": "Pillar One", "text": "Short paragraph for the first column." },
|
| 67 |
+
{ "heading": "Pillar Two", "text": "Short paragraph for the second column." },
|
| 68 |
+
{ "heading": "Pillar Three", "text": "Short paragraph for the third column." }
|
| 69 |
+
],
|
| 70 |
+
"layout": "three_columns",
|
| 71 |
+
"imageKeyword": ""
|
| 72 |
+
},
|
| 73 |
{
|
| 74 |
"title": "Visual Topic Title",
|
| 75 |
+
"content": "A single short paragraph describing the visual, its relevance, and the main insight the audience should remember.",
|
| 76 |
"layout": "image_and_text",
|
| 77 |
"imageKeyword": "specific relevant search terms for Unsplash"
|
| 78 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
{
|
| 80 |
"title": "Thank You",
|
| 81 |
"subtitle": "Contact information or closing message",
|
|
|
|
| 88 |
SLIDE ORDER RULES:
|
| 89 |
- Slide 1: MUST be "title_subtitle" layout
|
| 90 |
- Slide 2: MUST be "agenda" layout
|
| 91 |
+
- Slides 3-7/8: Mix of "title_and_text", "three_columns", and "image_and_text" (model decides distribution)
|
|
|
|
| 92 |
- Last slide: MUST be "thank_you" layout
|
| 93 |
|
| 94 |
LAYOUT VALUES (use EXACTLY these strings):
|
| 95 |
+
- "title_subtitle" - Opening slide with title and subtitle
|
| 96 |
+
- "agenda" - Table of contents / agenda items
|
| 97 |
+
- "title_and_text" - Text-heavy content slide with one short paragraph
|
| 98 |
+
- "three_columns" - A slide with exactly three columns, each with a heading and short paragraph
|
| 99 |
+
- "image_and_text" - Content slide with one short paragraph and image (MUST have imageKeyword)
|
| 100 |
+
- "thank_you" - Closing slide
|
| 101 |
|
| 102 |
IMAGE KEYWORD RULES:
|
| 103 |
- For "image_and_text" slides: ALWAYS provide 2-4 specific search terms
|
|
|
|
| 105 |
- Examples: "modern office workspace", "artificial intelligence circuit", "sustainable green energy"
|
| 106 |
- Leave empty ("") for all other layout types
|
| 107 |
|
| 108 |
+
CONTENT RULES:
|
| 109 |
+
- "agenda" and "references" should use arrays of short strings
|
| 110 |
+
- "title_and_text" and "image_and_text" should use a single string paragraph, not an array
|
| 111 |
+
- "three_columns" should use a "columns" array with exactly 3 objects, each containing "heading" and "text"
|
| 112 |
+
- Keep paragraphs compact and presentation-friendly, not essay-length
|
| 113 |
+
- Do NOT add a trailing references slide before "thank_you" unless the user explicitly asks for one
|
| 114 |
+
|
| 115 |
VALIDATION:
|
| 116 |
- Output MUST be valid JSON parseable by JSON.parse
|
| 117 |
- NO markdown formatting, NO code blocks, NO explanatory text
|
|
|
|
| 127 |
'title_subtitle',
|
| 128 |
'agenda',
|
| 129 |
'title_and_text',
|
| 130 |
+
'three_columns',
|
| 131 |
'image_and_text',
|
| 132 |
'references',
|
| 133 |
'thank_you',
|
|
|
|
| 135 |
|
| 136 |
/** Normalize a layout string from AI output to a valid LayoutType */
|
| 137 |
export function normalizeLayout(layout: string, index: number, totalSlides: number): LayoutType {
|
|
|
|
| 138 |
if (VALID_LAYOUTS.includes(layout as LayoutType)) {
|
| 139 |
return layout as LayoutType;
|
| 140 |
}
|
| 141 |
|
|
|
|
| 142 |
const layoutMap: Record<string, LayoutType> = {
|
| 143 |
+
titleContent: 'title_subtitle',
|
| 144 |
+
title: 'title_subtitle',
|
| 145 |
+
titleContentImage: 'image_and_text',
|
| 146 |
'content-image': 'image_and_text',
|
| 147 |
+
twoContent: 'title_and_text',
|
| 148 |
'two-column': 'title_and_text',
|
| 149 |
+
columns: 'three_columns',
|
| 150 |
+
threeColumns: 'three_columns',
|
| 151 |
+
'three-column': 'three_columns',
|
| 152 |
+
three_column: 'three_columns',
|
| 153 |
+
bullets: 'title_and_text',
|
| 154 |
+
section: 'title_and_text',
|
| 155 |
+
chart: 'title_and_text',
|
| 156 |
};
|
| 157 |
|
| 158 |
if (layoutMap[layout]) {
|
| 159 |
return layoutMap[layout];
|
| 160 |
}
|
| 161 |
|
|
|
|
| 162 |
if (index === 0) return 'title_subtitle';
|
| 163 |
if (index === 1) return 'agenda';
|
| 164 |
if (index === totalSlides - 1) return 'thank_you';
|
lib/template-options.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const TEMPLATE_OPTIONS = [
|
| 2 |
+
{ id: 'neobrutalism', label: 'Neo-Brutal' },
|
| 3 |
+
{ id: 'galeryn', label: 'Galeryn' },
|
| 4 |
+
{ id: 'noisy', label: 'Distortion' },
|
| 5 |
+
] as const;
|
| 6 |
+
|
| 7 |
+
export type TemplateOptionId = (typeof TEMPLATE_OPTIONS)[number]['id'];
|
| 8 |
+
|
| 9 |
+
const TEMPLATE_ALIASES: Record<string, TemplateOptionId> = {
|
| 10 |
+
neobrutal: 'neobrutalism',
|
| 11 |
+
neobrutalism: 'neobrutalism',
|
| 12 |
+
galeryn: 'galeryn',
|
| 13 |
+
noisy: 'noisy',
|
| 14 |
+
distortion: 'noisy',
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
function sanitizeTemplateKey(value: string) {
|
| 18 |
+
return value.toLowerCase().replace(/[\s_-]+/g, '');
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function normalizeTemplateId(value?: string | null): TemplateOptionId | null {
|
| 22 |
+
if (!value) return null;
|
| 23 |
+
return TEMPLATE_ALIASES[sanitizeTemplateKey(value)] || null;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function getTemplateLabel(templateId: TemplateOptionId) {
|
| 27 |
+
return TEMPLATE_OPTIONS.find((option) => option.id === templateId)?.label || templateId;
|
| 28 |
+
}
|
lib/theme-system.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
* Theme System
|
| 3 |
*
|
| 4 |
* Supports three templates: neobrutalism, galeryn, and noisy.
|
|
|
|
| 5 |
* Layout-specific styling is handled by the template config in data/templates/.
|
| 6 |
* This file exists for backward compatibility with components that reference ThemeName.
|
| 7 |
*/
|
|
@@ -25,7 +26,7 @@ export interface Theme {
|
|
| 25 |
export const themes: Theme[] = [
|
| 26 |
{
|
| 27 |
name: 'neobrutalism',
|
| 28 |
-
displayName: 'Neo
|
| 29 |
default: {
|
| 30 |
background: 'bg-[#F5F5F0]',
|
| 31 |
textColor: 'text-black',
|
|
@@ -46,7 +47,7 @@ export const themes: Theme[] = [
|
|
| 46 |
},
|
| 47 |
{
|
| 48 |
name: 'noisy',
|
| 49 |
-
displayName: '
|
| 50 |
default: {
|
| 51 |
background: 'bg-[#547BEE]',
|
| 52 |
textColor: 'text-white',
|
|
|
|
| 2 |
* Theme System
|
| 3 |
*
|
| 4 |
* Supports three templates: neobrutalism, galeryn, and noisy.
|
| 5 |
+
* User-facing labels are Neo-Brutal, Galeryn, and Distortion.
|
| 6 |
* Layout-specific styling is handled by the template config in data/templates/.
|
| 7 |
* This file exists for backward compatibility with components that reference ThemeName.
|
| 8 |
*/
|
|
|
|
| 26 |
export const themes: Theme[] = [
|
| 27 |
{
|
| 28 |
name: 'neobrutalism',
|
| 29 |
+
displayName: 'Neo-Brutal',
|
| 30 |
default: {
|
| 31 |
background: 'bg-[#F5F5F0]',
|
| 32 |
textColor: 'text-black',
|
|
|
|
| 47 |
},
|
| 48 |
{
|
| 49 |
name: 'noisy',
|
| 50 |
+
displayName: 'Distortion',
|
| 51 |
default: {
|
| 52 |
background: 'bg-[#547BEE]',
|
| 53 |
textColor: 'text-white',
|
lib/unsplash.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface UnsplashImage {
|
|
| 42 |
user: {
|
| 43 |
/** Display name of the photographer. */
|
| 44 |
name: string;
|
|
|
|
|
|
|
| 45 |
};
|
| 46 |
}
|
| 47 |
|
|
|
|
| 42 |
user: {
|
| 43 |
/** Display name of the photographer. */
|
| 44 |
name: string;
|
| 45 |
+
/** Public username used for attribution links. */
|
| 46 |
+
username: string;
|
| 47 |
};
|
| 48 |
}
|
| 49 |
|
package-lock.json
CHANGED
|
@@ -24,7 +24,6 @@
|
|
| 24 |
"clsx": "^2.1.1",
|
| 25 |
"cohere-ai": "^7.20.0",
|
| 26 |
"framer-motion": "^12.38.0",
|
| 27 |
-
"html2canvas": "^1.4.1",
|
| 28 |
"jspdf": "^4.2.1",
|
| 29 |
"lucide-react": "^0.577.0",
|
| 30 |
"next": "16.2.1",
|
|
|
|
| 24 |
"clsx": "^2.1.1",
|
| 25 |
"cohere-ai": "^7.20.0",
|
| 26 |
"framer-motion": "^12.38.0",
|
|
|
|
| 27 |
"jspdf": "^4.2.1",
|
| 28 |
"lucide-react": "^0.577.0",
|
| 29 |
"next": "16.2.1",
|
package.json
CHANGED
|
@@ -25,7 +25,6 @@
|
|
| 25 |
"clsx": "^2.1.1",
|
| 26 |
"cohere-ai": "^7.20.0",
|
| 27 |
"framer-motion": "^12.38.0",
|
| 28 |
-
"html2canvas": "^1.4.1",
|
| 29 |
"jspdf": "^4.2.1",
|
| 30 |
"lucide-react": "^0.577.0",
|
| 31 |
"next": "16.2.1",
|
|
|
|
| 25 |
"clsx": "^2.1.1",
|
| 26 |
"cohere-ai": "^7.20.0",
|
| 27 |
"framer-motion": "^12.38.0",
|
|
|
|
| 28 |
"jspdf": "^4.2.1",
|
| 29 |
"lucide-react": "^0.577.0",
|
| 30 |
"next": "16.2.1",
|
package_tmp.json
CHANGED
|
@@ -25,8 +25,7 @@
|
|
| 25 |
"clsx": "^2.1.1",
|
| 26 |
"cohere-ai": "^7.20.0",
|
| 27 |
"framer-motion": "^12.38.0",
|
| 28 |
-
"
|
| 29 |
-
"jspdf": "^4.2.1",
|
| 30 |
"lucide-react": "^0.577.0",
|
| 31 |
"next": "16.2.1",
|
| 32 |
"next-auth": "^4.24.13",
|
|
|
|
| 25 |
"clsx": "^2.1.1",
|
| 26 |
"cohere-ai": "^7.20.0",
|
| 27 |
"framer-motion": "^12.38.0",
|
| 28 |
+
"jspdf": "^4.2.1",
|
|
|
|
| 29 |
"lucide-react": "^0.577.0",
|
| 30 |
"next": "16.2.1",
|
| 31 |
"next-auth": "^4.24.13",
|